pi-skill-evolve 0.1.11 → 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/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/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/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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 督导运行目录:把督导要用的 `.ts` 拷到 `node_modules` 之外再跑。
|
|
3
|
+
*
|
|
4
|
+
* 2026-11-09 事故:`node --experimental-strip-types` 对 **node_modules 下的 .ts 一律拒绝**
|
|
5
|
+
* (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`)。扩展按本地 `.pi/extensions/*.ts` 装时一切正常,
|
|
6
|
+
* 一旦按 npm 包装进 `.pi/npm/node_modules/`,评审督导一启动就崩在 import 那一步 ——
|
|
7
|
+
* 结果 `autoReview` / `autoApply` 全成了装饰品,用户只看到 run 停在 analyzed。
|
|
8
|
+
*
|
|
9
|
+
* 解法:首次使用时把督导的源目录整份复制到 `<evolutionDir>/runtime/supervisor/<key>/`(普通目录,
|
|
10
|
+
* 类型剥离不再受限),再从那里起进程。`key` 由版本 + 入口文件 mtime/size 组成,升级自动换目录,
|
|
11
|
+
* 旧目录顺手清掉;拷贝失败(只读盘等)就退回原路径,行为不比现在更差。
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
|
|
16
|
+
/** 运行目录名:`<evolutionDir>/runtime/supervisor/` */
|
|
17
|
+
export const RUNTIME_DIRNAME = "supervisor";
|
|
18
|
+
|
|
19
|
+
/** 缓存键里的稳定前缀:node_modules 路径太长,只留可读的一段 */
|
|
20
|
+
const KEY_PREFIX = "lib";
|
|
21
|
+
|
|
22
|
+
/** 只拷源码:测试、fixture、日志都不该跟着搬 */
|
|
23
|
+
const COPY_FILTER = (src: string): boolean => !/(\.test\.|\.spec\.|\.log$|\.json\.tmp)/.test(src);
|
|
24
|
+
|
|
25
|
+
export interface RuntimeOptions {
|
|
26
|
+
/** `.pi/evolution`:运行目录挂在它下面,随工作区走,不污染 node_modules */
|
|
27
|
+
evolutionDir: string;
|
|
28
|
+
/** 包内督导入口绝对路径(review-spawn 的 SUPERVISOR_SCRIPT) */
|
|
29
|
+
scriptPath: string;
|
|
30
|
+
/** 包版本:参与缓存键,升级后自动重建 */
|
|
31
|
+
version?: string;
|
|
32
|
+
log?: (msg: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 只有 node_modules 里的 .ts 需要搬家;本地 `.pi/extensions` 安装原路径直接用 */
|
|
36
|
+
export function needsCopy(scriptPath: string): boolean {
|
|
37
|
+
return /[\\/]node_modules[\\/]/.test(scriptPath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 缓存键:版本 + 入口文件大小/mtime —— 内容一变就换目录,不需要手工清缓存 */
|
|
41
|
+
export function runtimeKey(scriptPath: string, version?: string): string {
|
|
42
|
+
const parts: string[] = [KEY_PREFIX];
|
|
43
|
+
const label = version ?? packageVersionOf(scriptPath);
|
|
44
|
+
if (label && /^[0-9A-Za-z._-]+$/.test(label)) parts.push(label);
|
|
45
|
+
try {
|
|
46
|
+
const stat = fs.statSync(scriptPath);
|
|
47
|
+
parts.push(String(stat.size), String(Math.trunc(stat.mtimeMs)));
|
|
48
|
+
} catch {
|
|
49
|
+
parts.push("unknown"); // 读不到 stat 也不抛:目录照样能建,只是键退化成常量
|
|
50
|
+
}
|
|
51
|
+
return parts.join("-");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 包版本:找不到 package.json 就返回空串(只影响缓存键的写法,不影响能否跑) */
|
|
55
|
+
export function packageVersionOf(scriptPath: string): string {
|
|
56
|
+
let dir = path.dirname(scriptPath);
|
|
57
|
+
for (let i = 0; i < 6; i += 1) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")) as { version?: unknown };
|
|
60
|
+
if (typeof raw.version === "string") return raw.version;
|
|
61
|
+
return "";
|
|
62
|
+
} catch {
|
|
63
|
+
const parent = path.dirname(dir);
|
|
64
|
+
if (parent === dir) break;
|
|
65
|
+
dir = parent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 目标目录(导出给面板 / 测试用,不产生副作用) */
|
|
72
|
+
export function runtimeDirOf(evolutionDir: string, key: string): string {
|
|
73
|
+
return path.join(evolutionDir, "runtime", RUNTIME_DIRNAME, key);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 返回**可以直接 spawn 的督导入口路径**:
|
|
78
|
+
* node_modules 里 → 复制后的副本;其它位置 → 原路径。
|
|
79
|
+
*/
|
|
80
|
+
export function ensureSupervisorRuntime(options: RuntimeOptions): string {
|
|
81
|
+
const { scriptPath } = options;
|
|
82
|
+
if (!needsCopy(scriptPath)) return scriptPath;
|
|
83
|
+
const key = runtimeKey(scriptPath, options.version);
|
|
84
|
+
const target = runtimeDirOf(options.evolutionDir, key);
|
|
85
|
+
const entry = path.join(target, path.basename(scriptPath));
|
|
86
|
+
// 已经有完整副本(.ready 与入口都在)就直接复用:督导每次派发都要解析一次路径,不能每次都拷 700KB
|
|
87
|
+
if (fs.existsSync(path.join(target, ".ready")) && fs.existsSync(entry)) return entry;
|
|
88
|
+
const source = path.dirname(scriptPath);
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
91
|
+
// 先拷到临时目录再 rename:两个 run 同时派发时不会读到半份源码
|
|
92
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
93
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
94
|
+
fs.cpSync(source, tmp, { recursive: true, filter: COPY_FILTER });
|
|
95
|
+
fs.writeFileSync(path.join(tmp, ".ready"), `${key}\n`, "utf8");
|
|
96
|
+
try {
|
|
97
|
+
fs.renameSync(tmp, target);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// 另一个进程刚好建好了同一个 key:以它的为准,清掉自己这份临时目录
|
|
100
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
101
|
+
if (!fs.existsSync(entry)) throw error;
|
|
102
|
+
}
|
|
103
|
+
pruneOldRuntimes(path.dirname(target), key);
|
|
104
|
+
return entry;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
options.log?.(`[skill-evolve] 督导运行目录准备失败(回退到原路径,评审仍会失败):${String(error)}`);
|
|
107
|
+
return scriptPath;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 清掉同一个父目录下的旧 key:升级后旧副本没人用,留着只会占空间 */
|
|
112
|
+
function pruneOldRuntimes(parent: string, keep: string): void {
|
|
113
|
+
let names: string[];
|
|
114
|
+
try {
|
|
115
|
+
names = fs.readdirSync(parent);
|
|
116
|
+
} catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (const name of names) {
|
|
120
|
+
if (name === keep || name.includes(".tmp-")) continue;
|
|
121
|
+
try {
|
|
122
|
+
fs.rmSync(path.join(parent, name), { recursive: true, force: true });
|
|
123
|
+
} catch {
|
|
124
|
+
// 别的进程正在用(Windows 上 rename 会撞):下次再清
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import type { QueueItem } from "../queue.ts";
|
|
11
|
+
import { buildPipeline, renderPipelineLine } from "../pipeline.ts";
|
|
11
12
|
import type { RunStatus, RunSummary } from "../runs.ts";
|
|
12
13
|
import { CHILD_LOG } from "../spawn-plan.ts";
|
|
13
14
|
import { classifyStale, readLogTail, readRunError } from "../stale.ts";
|
|
@@ -80,6 +81,17 @@ function runningItem(
|
|
|
80
81
|
mtime === null
|
|
81
82
|
? "最近输出:(还没有输出)"
|
|
82
83
|
: `最近输出:${Math.max(0, Math.round((now - mtime) / 1000))}s 前 · 末行:${lastLine.length > 0 ? lastLine : "(还没有输出)"}`;
|
|
84
|
+
// 五段进度:光写「复盘产出中」看不出是卡在分析还是卡在提案(用户 2026-11-09 的真实困惑)
|
|
85
|
+
const pipeline = renderPipelineLine(
|
|
86
|
+
buildPipeline({
|
|
87
|
+
runDir: path.join(evolutionDir, "runs", run.runId),
|
|
88
|
+
status: run.status,
|
|
89
|
+
pid: run.pid,
|
|
90
|
+
supervisorPid: run.supervisorPid,
|
|
91
|
+
dispatchedAt: run.dispatchedAt,
|
|
92
|
+
alive,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
83
95
|
return {
|
|
84
96
|
runId: run.runId,
|
|
85
97
|
kind: "running",
|
|
@@ -88,6 +100,7 @@ function runningItem(
|
|
|
88
100
|
at: run.dispatchedAt,
|
|
89
101
|
detail: [
|
|
90
102
|
`阶段:${reviewing ? "评审中" : "复盘产出中"}`,
|
|
103
|
+
`流水线:${pipeline}`,
|
|
91
104
|
`进程:pid ${run.pid} · 已跑 ${duration(now - (run.dispatchedAt ?? now))}(上限 ${Math.round(maxRuntimeMs / 60_000)} 分钟)`,
|
|
92
105
|
progressLine,
|
|
93
106
|
`日志:tail -f .pi/evolution/runs/${run.runId}/${CHILD_LOG}`,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* 六块各自的空态都要写明「缺的是哪个文件」—— 一句「暂无数据」会让人以为页面坏了。
|
|
8
8
|
*/
|
|
9
|
+
import { renderPipelineLine, type Pipeline, type PipelineStage } from "../pipeline.ts";
|
|
9
10
|
import type { WebState } from "./data.ts";
|
|
10
11
|
import { DIFF_PREVIEW_LINES, renderDiffLines, parseDiffLines } from "./diff-lines.ts";
|
|
11
12
|
import type { RunBlocking, RunDetail, RunPassing } from "./run-detail.ts";
|
|
@@ -210,6 +211,41 @@ ${more}
|
|
|
210
211
|
</section>`;
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/** 五段格子的短状态词:格子窄,只用四个字以内 */
|
|
215
|
+
const STAGE_WORD: Record<PipelineStage["state"], string> = {
|
|
216
|
+
done: "完成",
|
|
217
|
+
running: "进行中",
|
|
218
|
+
waiting: "等待",
|
|
219
|
+
blocked: "卡住",
|
|
220
|
+
failed: "失败",
|
|
221
|
+
skipped: "跳过",
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 流水线:页面顶部那一排。放在概要**之前**是因为用户点进详情页的第一个问题永远是
|
|
226
|
+
* 「它现在到哪一步了」,而不是「pid 是多少」。每格给状态词 + 明细,卡住的那格直接写出下一步谁接手。
|
|
227
|
+
*/
|
|
228
|
+
function pipelineSection(pipeline: Pipeline): string {
|
|
229
|
+
const cells = pipeline.stages
|
|
230
|
+
.map((stage) => {
|
|
231
|
+
const at = stage.at === null ? "" : `<span class="pt">${escapeHtml(formatStamp(stage.at))}</span>`;
|
|
232
|
+
return `<li class="stage s-${stage.state}">
|
|
233
|
+
<span class="pl">${escapeHtml(stage.label)}</span>
|
|
234
|
+
<span class="ps">${escapeHtml(STAGE_WORD[stage.state])}</span>
|
|
235
|
+
<span class="pd">${escapeHtml(stage.detail)}</span>
|
|
236
|
+
${at}
|
|
237
|
+
</li>`;
|
|
238
|
+
})
|
|
239
|
+
.join("\n");
|
|
240
|
+
return `<section>
|
|
241
|
+
<h2>流水线</h2>
|
|
242
|
+
<p class="hint">${escapeHtml(renderPipelineLine(pipeline))} · ${escapeHtml(pipeline.hint)}</p>
|
|
243
|
+
<ol class="pipe">
|
|
244
|
+
${cells}
|
|
245
|
+
</ol>
|
|
246
|
+
</section>`;
|
|
247
|
+
}
|
|
248
|
+
|
|
213
249
|
export interface RenderRunOptions {
|
|
214
250
|
/** 状态栏要的数字(shell.ts 的 statusBar 唯一依赖) */
|
|
215
251
|
state: WebState;
|
|
@@ -221,6 +257,7 @@ export function renderRunPage(detail: RunDetail, options: RenderRunOptions): str
|
|
|
221
257
|
const back = options.back ?? { view: "pending", label: "待处理" };
|
|
222
258
|
const body = `<main class="run">
|
|
223
259
|
<p class="hint"><a href="/?view=${escapeHtml(back.view)}">← 回${escapeHtml(back.label)}</a></p>
|
|
260
|
+
${pipelineSection(detail.pipeline)}
|
|
224
261
|
${overview(detail)}
|
|
225
262
|
${diffSection(detail)}
|
|
226
263
|
${candidateSection(detail)}
|
|
@@ -14,7 +14,10 @@ import * as fs from "node:fs";
|
|
|
14
14
|
import * as path from "node:path";
|
|
15
15
|
import type { ServerResponse } from "node:http";
|
|
16
16
|
import { readVerify } from "../apply.ts";
|
|
17
|
+
import { loadConfig } from "../config.ts";
|
|
18
|
+
import { buildPipeline, type Pipeline } from "../pipeline.ts";
|
|
17
19
|
import { readProposalSummary } from "../proposal.ts";
|
|
20
|
+
import { readQueue } from "../queue.ts";
|
|
18
21
|
import type { RunRecord } from "../runs.ts";
|
|
19
22
|
import { asNumber, asRecord, asText, readJson, reasonLabel } from "./json-read.ts";
|
|
20
23
|
import { sendText, TEXT_TYPE } from "./http.ts";
|
|
@@ -95,6 +98,8 @@ export interface RunDetail {
|
|
|
95
98
|
findings: RunFinding[];
|
|
96
99
|
findingsTotal: number;
|
|
97
100
|
notes: string;
|
|
101
|
+
/** 五段流水线(派发→分析→提案→评审→采纳):页面顶部那排格子 */
|
|
102
|
+
pipeline: Pipeline;
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
function readTextFile(file: string): string | null {
|
|
@@ -229,6 +234,20 @@ export function readRunDetail(evolutionDir: string, runId: string): RunDetail |
|
|
|
229
234
|
const reviewAt = asNumber(asRecord(readJson<unknown>(path.join(runDir, "03-review.json")))?.reviewedAt);
|
|
230
235
|
const stamps = [decision, verifyAt, reviewAt].filter((value): value is number => value !== null);
|
|
231
236
|
const hasChildLog = exists(path.join(runDir, "child.log"));
|
|
237
|
+
// 流水线:pid 存活按 run.json 记的两个 pid,队列状态按 queue.json(不在队列 = null)
|
|
238
|
+
// 开关来自 config.json —— 面板只读,读配置也只是想知道「为什么这步被跳过了」
|
|
239
|
+
const config = loadConfig(evolutionDir);
|
|
240
|
+
const queued = readQueue(evolutionDir).find((item) => item.runId === runId)?.status ?? null;
|
|
241
|
+
const pipeline = buildPipeline({
|
|
242
|
+
runDir,
|
|
243
|
+
status: asText(record?.status, ""),
|
|
244
|
+
pid: asNumber(record?.pid),
|
|
245
|
+
supervisorPid: asNumber(record?.supervisorPid),
|
|
246
|
+
dispatchedAt: asNumber(record?.dispatchedAt),
|
|
247
|
+
queued,
|
|
248
|
+
autoReview: config.autoReview,
|
|
249
|
+
autoApply: config.autoApply,
|
|
250
|
+
});
|
|
232
251
|
return {
|
|
233
252
|
runId,
|
|
234
253
|
status: asText(record?.status, "(无 run.json)"),
|
|
@@ -245,6 +264,7 @@ export function readRunDetail(evolutionDir: string, runId: string): RunDetail |
|
|
|
245
264
|
...review,
|
|
246
265
|
...gates,
|
|
247
266
|
...findings,
|
|
267
|
+
pipeline,
|
|
248
268
|
};
|
|
249
269
|
}
|
|
250
270
|
|
|
@@ -113,6 +113,24 @@ a.btn { display: inline-block; color: var(--blue); padding: 1px 8px; border: 1px
|
|
|
113
113
|
.kv .k { color: var(--muted); font-size: 12px }
|
|
114
114
|
.kv .v { color: var(--fg) }
|
|
115
115
|
.kv-line { margin: 4px 0 }
|
|
116
|
+
/* 流水线:五格等宽,颜色只表达状态(绿=完成、黄=在跑、蓝=卡住待人、红=失败、灰=等待/跳过)。
|
|
117
|
+
窄屏自动折成两行,不横向滚动 —— 状态必须一屏看全。 */
|
|
118
|
+
.pipe { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 0; padding: 0; list-style: none }
|
|
119
|
+
.pipe .stage { flex: 1 1 150px; min-width: 140px; padding: 8px 10px; border: 1px solid var(--line);
|
|
120
|
+
border-top: 2px solid var(--line); border-radius: 6px; background: var(--panel) }
|
|
121
|
+
.pipe .pl { display: block; color: var(--muted); font-size: 12px }
|
|
122
|
+
.pipe .ps { display: block; font-size: 13px; font-weight: 600 }
|
|
123
|
+
.pipe .pd { display: block; margin-top: 2px; color: #aeb7c4; font-size: 12px; line-height: 1.45 }
|
|
124
|
+
.pipe .pt { display: block; margin-top: 4px; color: #5d6675; font-size: 12px }
|
|
125
|
+
.pipe .s-done { border-top-color: var(--green) }
|
|
126
|
+
.pipe .s-done .ps { color: var(--green) }
|
|
127
|
+
.pipe .s-running { border-top-color: var(--yellow) }
|
|
128
|
+
.pipe .s-running .ps { color: var(--yellow) }
|
|
129
|
+
.pipe .s-blocked { border-top-color: var(--blue) }
|
|
130
|
+
.pipe .s-blocked .ps { color: var(--blue) }
|
|
131
|
+
.pipe .s-failed { border-top-color: var(--red) }
|
|
132
|
+
.pipe .s-failed .ps { color: var(--red) }
|
|
133
|
+
.pipe .s-waiting .ps, .pipe .s-skipped .ps { color: var(--muted) }
|
|
116
134
|
/* 评审结论摘要:详情页里唯一「大一号」的正文,用户上来先读这一句 */
|
|
117
135
|
.verdict-summary { margin: 6px 0; font-size: 15px; font-weight: 600; color: var(--fg) }
|
|
118
136
|
a.run { color: #c8d3e6; border-bottom: 1px dashed #45505f }
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 流水线渲染单测:五段各挑一次真实故障当用例 —— 缺 01 / 有提案没督导 / 驳回 / 采纳完 / 自动关着。
|
|
3
|
+
* 断言只压「状态 + 下一步」,不锁具体措辞(文案会改,状态语义不该改)。
|
|
4
|
+
*/
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { buildPipeline, pipelineProgress, renderPipelineLine, type PipelineInput } from "../lib/pipeline.ts";
|
|
11
|
+
|
|
12
|
+
function runDir(): string {
|
|
13
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-pipeline-"));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function write(dir: string, name: string, content: string): void {
|
|
17
|
+
fs.writeFileSync(path.join(dir, name), content);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function input(dir: string, patch: Partial<PipelineInput> = {}): PipelineInput {
|
|
21
|
+
return {
|
|
22
|
+
runDir: dir,
|
|
23
|
+
status: "analyzed",
|
|
24
|
+
pid: null,
|
|
25
|
+
supervisorPid: null,
|
|
26
|
+
dispatchedAt: 1_800_000_000_000,
|
|
27
|
+
...patch,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const stateOf = (pipeline: ReturnType<typeof buildPipeline>, key: string): string =>
|
|
32
|
+
pipeline.stages.find((stage) => stage.key === key)?.state ?? "missing";
|
|
33
|
+
|
|
34
|
+
test("只有 run.json(分析没出结论、子进程也死了):分析等收尾,不提评审", () => {
|
|
35
|
+
const dir = runDir();
|
|
36
|
+
const pipeline = buildPipeline(input(dir, { status: "dispatched" }));
|
|
37
|
+
assert.equal(stateOf(pipeline, "dispatch"), "done");
|
|
38
|
+
assert.equal(stateOf(pipeline, "analyze"), "waiting");
|
|
39
|
+
assert.equal(stateOf(pipeline, "propose"), "waiting");
|
|
40
|
+
assert.equal(stateOf(pipeline, "review"), "waiting");
|
|
41
|
+
assert.equal(stateOf(pipeline, "apply"), "waiting");
|
|
42
|
+
assert.match(pipeline.hint, /下一步「分析」/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("分析在跑:分析=进行中,提案=进行中(同一子进程)", () => {
|
|
46
|
+
const dir = runDir();
|
|
47
|
+
const pipeline = buildPipeline(input(dir, { status: "dispatched", pid: 4242, alive: (pid) => pid === 4242 }));
|
|
48
|
+
assert.equal(stateOf(pipeline, "analyze"), "running");
|
|
49
|
+
assert.equal(stateOf(pipeline, "propose"), "running");
|
|
50
|
+
assert.match(pipeline.stages[0]!.detail, /4242 在跑/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("分析完成没提案:写明「多半判定不该改」,不是错误", () => {
|
|
54
|
+
const dir = runDir();
|
|
55
|
+
write(dir, "01-findings.json", "{}");
|
|
56
|
+
const pipeline = buildPipeline(input(dir));
|
|
57
|
+
assert.equal(stateOf(pipeline, "analyze"), "done");
|
|
58
|
+
assert.equal(stateOf(pipeline, "propose"), "waiting");
|
|
59
|
+
assert.match(pipeline.stages[2]!.detail, /没写提案/);
|
|
60
|
+
assert.match(pipeline.hint, /下一步「提案」/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("提案在、督导已死:评审判 blocked 且指向自动重挂(2026-11-09 事故现场)", () => {
|
|
64
|
+
const dir = runDir();
|
|
65
|
+
write(dir, "01-findings.json", "{}");
|
|
66
|
+
write(dir, "01-findings.json", "{}");
|
|
67
|
+
write(dir, "02-proposal.json", JSON.stringify({ type: "improve", skill: "demo" }));
|
|
68
|
+
const pipeline = buildPipeline(input(dir, { autoReview: true }));
|
|
69
|
+
assert.equal(stateOf(pipeline, "review"), "blocked");
|
|
70
|
+
assert.match(pipeline.stages[3]!.detail, /下次会话起点自动重挂/);
|
|
71
|
+
assert.match(pipeline.hint, /下一步「评审」/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("提案在、督导在跑:评审=进行中", () => {
|
|
75
|
+
const dir = runDir();
|
|
76
|
+
write(dir, "02-proposal.json", JSON.stringify({ type: "improve", skill: "demo" }));
|
|
77
|
+
const pipeline = buildPipeline(input(dir, { status: "proposed", supervisorPid: 777, alive: (pid) => pid === 777 }));
|
|
78
|
+
assert.equal(stateOf(pipeline, "review"), "running");
|
|
79
|
+
assert.match(pipeline.stages[1]!.detail, /01-findings.json/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("自动评审关着:评审=跳过而不是卡住", () => {
|
|
83
|
+
const dir = runDir();
|
|
84
|
+
write(dir, "01-findings.json", "{}");
|
|
85
|
+
write(dir, "02-proposal.json", "{}");
|
|
86
|
+
const pipeline = buildPipeline(input(dir, { autoReview: false }));
|
|
87
|
+
assert.equal(stateOf(pipeline, "review"), "skipped");
|
|
88
|
+
assert.match(pipeline.hint, /下一步「采纳」/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("评审驳回:采纳=跳过,hint 明说不会采纳", () => {
|
|
92
|
+
const dir = runDir();
|
|
93
|
+
write(dir, "01-findings.json", "{}");
|
|
94
|
+
write(dir, "02-proposal.json", "{}");
|
|
95
|
+
write(dir, "03-review.json", JSON.stringify({ status: "rejected", verdict: "revise" }));
|
|
96
|
+
const pipeline = buildPipeline(input(dir, { status: "rejected" }));
|
|
97
|
+
assert.equal(stateOf(pipeline, "review"), "done");
|
|
98
|
+
assert.match(pipeline.stages[3]!.detail, /rejected \/ revise/);
|
|
99
|
+
assert.equal(stateOf(pipeline, "apply"), "skipped");
|
|
100
|
+
assert.match(pipeline.hint, /驳回|不会采纳|结束/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("评审通过 + 队列 pending + 自动采纳开着:采纳=进行中", () => {
|
|
104
|
+
const dir = runDir();
|
|
105
|
+
write(dir, "03-review.json", JSON.stringify({ status: "approved", verdict: "accept" }));
|
|
106
|
+
const pipeline = buildPipeline(input(dir, { status: "approved", queued: "pending", autoApply: true }));
|
|
107
|
+
assert.equal(stateOf(pipeline, "apply"), "running");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("评审通过但自动采纳关着:采纳=卡住等人工批准", () => {
|
|
111
|
+
const dir = runDir();
|
|
112
|
+
write(dir, "03-review.json", JSON.stringify({ status: "approved" }));
|
|
113
|
+
const pipeline = buildPipeline(input(dir, { status: "approved", queued: "pending", autoApply: false }));
|
|
114
|
+
assert.equal(stateOf(pipeline, "apply"), "blocked");
|
|
115
|
+
assert.match(pipeline.stages[4]!.detail, /等面板批准/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("采纳完成:五段全 done,hint 说走完", () => {
|
|
119
|
+
const dir = runDir();
|
|
120
|
+
write(dir, "01-findings.json", "{}");
|
|
121
|
+
write(dir, "02-proposal.json", "{}");
|
|
122
|
+
write(dir, "03-review.json", JSON.stringify({ status: "approved" }));
|
|
123
|
+
write(dir, "04-decision.json", "{}");
|
|
124
|
+
const pipeline = buildPipeline(input(dir, { status: "applied" }));
|
|
125
|
+
assert.equal(pipeline.stages.every((stage) => stage.state === "done"), true);
|
|
126
|
+
assert.match(pipeline.hint, /走完/);
|
|
127
|
+
assert.deepEqual(pipelineProgress(pipeline), { done: 5, total: 5 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("卡死状态:分析=失败,不再假装在跑", () => {
|
|
131
|
+
const dir = runDir();
|
|
132
|
+
const pipeline = buildPipeline(input(dir, { status: "stale" }));
|
|
133
|
+
assert.equal(stateOf(pipeline, "analyze"), "failed");
|
|
134
|
+
assert.deepEqual(pipelineProgress(pipeline), { done: 1, total: 5 });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("一行进度与符号:派发✓ 分析✗ …", () => {
|
|
138
|
+
const dir = runDir();
|
|
139
|
+
const line = renderPipelineLine(buildPipeline(input(dir, { status: "stale" })));
|
|
140
|
+
assert.equal(line, "派发✓ 分析✗ 提案· 评审· 采纳·");
|
|
141
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 重启恢复单测:这里的每一条断言都对应一次「用户看到已分析却永远没提案」的真实死法。
|
|
3
|
+
* 纪律是保守:能推状态就推,能重挂督导就重挂,**绝不重跑复盘**。
|
|
4
|
+
*/
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { recoverRuns, renderRecovery, RECOVER_WINDOW_MS } from "../lib/recover.ts";
|
|
11
|
+
import type { SpawnSupervisorOptions } from "../lib/review-spawn.ts";
|
|
12
|
+
|
|
13
|
+
const NOW = 1_800_000_000_000;
|
|
14
|
+
const queue = (file: string, run: () => Promise<void>): Promise<void> => run();
|
|
15
|
+
|
|
16
|
+
function setup(): { evolutionDir: string; write: (runId: string, record: Record<string, unknown>, files?: string[]) => string } {
|
|
17
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-recover-"));
|
|
18
|
+
const evolutionDir = path.join(root, ".pi", "evolution");
|
|
19
|
+
fs.mkdirSync(path.join(evolutionDir, "runs"), { recursive: true });
|
|
20
|
+
const write = (runId: string, record: Record<string, unknown>, files: string[] = []): string => {
|
|
21
|
+
const runDir = path.join(evolutionDir, "runs", runId);
|
|
22
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
23
|
+
fs.writeFileSync(
|
|
24
|
+
path.join(runDir, "run.json"),
|
|
25
|
+
JSON.stringify({ runId, status: "dispatched", dispatchedAt: NOW - 60_000, pid: null, supervisorPid: null, ...record }, null, 2),
|
|
26
|
+
);
|
|
27
|
+
for (const name of files) fs.writeFileSync(path.join(runDir, name), "{}");
|
|
28
|
+
return runDir;
|
|
29
|
+
};
|
|
30
|
+
return { evolutionDir, write };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function base(deps: Partial<Parameters<typeof recoverRuns>[0]> & { evolutionDir: string }) {
|
|
34
|
+
return {
|
|
35
|
+
cwd: "/repo",
|
|
36
|
+
queue,
|
|
37
|
+
now: () => NOW,
|
|
38
|
+
alive: () => false,
|
|
39
|
+
autoReview: () => true,
|
|
40
|
+
reviewModel: () => "inherit",
|
|
41
|
+
log: () => {},
|
|
42
|
+
...deps,
|
|
43
|
+
} as Parameters<typeof recoverRuns>[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("分析产物在但状态还写 dispatched:补成 analyzed,不重跑", async () => {
|
|
47
|
+
const { evolutionDir, write } = setup();
|
|
48
|
+
const runDir = write("r-1", { pid: 4242 }, ["01-findings.json"]);
|
|
49
|
+
const actions = await recoverRuns(base({ evolutionDir, alive: () => false }));
|
|
50
|
+
assert.deepEqual(actions.map((a) => a.kind), ["advanced-analyzed", "interrupted"]);
|
|
51
|
+
const record = JSON.parse(fs.readFileSync(path.join(runDir, "run.json"), "utf8")) as { status: string };
|
|
52
|
+
assert.equal(record.status, "analyzed");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("提案在、督导已死:重挂一个督导接着评审", async () => {
|
|
56
|
+
const { evolutionDir, write } = setup();
|
|
57
|
+
const runDir = write("r-2", { status: "analyzed", pid: null, supervisorPid: 777 }, ["01-findings.json", "02-proposal.json"]);
|
|
58
|
+
const seen: SpawnSupervisorOptions[] = [];
|
|
59
|
+
const actions = await recoverRuns(
|
|
60
|
+
base({
|
|
61
|
+
evolutionDir,
|
|
62
|
+
alive: (pid) => pid !== 777, // 只有督导死了
|
|
63
|
+
spawnSupervisor: (options) => {
|
|
64
|
+
seen.push(options);
|
|
65
|
+
return 9999;
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
assert.deepEqual(actions.map((a) => a.kind), ["supervisor-respawned"]);
|
|
70
|
+
assert.equal(actions[0]!.detail.includes("9999"), true);
|
|
71
|
+
assert.equal(seen.length, 1);
|
|
72
|
+
assert.equal(seen[0]!.runDir, runDir);
|
|
73
|
+
assert.equal(seen[0]!.autoReview, true, "重挂时明确开着评审");
|
|
74
|
+
assert.equal(seen[0]!.reviewModel, "inherit");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("督导还活着:不重挂、不打扰", async () => {
|
|
78
|
+
const { evolutionDir, write } = setup();
|
|
79
|
+
write("r-3", { status: "analyzed", supervisorPid: 888 }, ["01-findings.json", "02-proposal.json"]);
|
|
80
|
+
let spawned = 0;
|
|
81
|
+
const actions = await recoverRuns(
|
|
82
|
+
base({
|
|
83
|
+
evolutionDir,
|
|
84
|
+
alive: (pid) => pid === 888,
|
|
85
|
+
spawnSupervisor: () => {
|
|
86
|
+
spawned += 1;
|
|
87
|
+
return 1;
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
assert.deepEqual(actions.map((a) => a.kind), ["waiting-review"]);
|
|
92
|
+
assert.equal(spawned, 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("自动评审关着:不偷偷打开,只报告待人工", async () => {
|
|
96
|
+
const { evolutionDir, write } = setup();
|
|
97
|
+
write("r-4", { status: "analyzed", supervisorPid: 1 }, ["02-proposal.json"]);
|
|
98
|
+
let spawned = 0;
|
|
99
|
+
const actions = await recoverRuns(
|
|
100
|
+
base({
|
|
101
|
+
evolutionDir,
|
|
102
|
+
autoReview: () => false,
|
|
103
|
+
spawnSupervisor: () => {
|
|
104
|
+
spawned += 1;
|
|
105
|
+
return 1;
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
assert.deepEqual(actions.map((a) => a.kind), ["respawn-blocked"]);
|
|
110
|
+
assert.equal(spawned, 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("复盘子进程还活着且没提案:等它,不插一脚", async () => {
|
|
114
|
+
const { evolutionDir, write } = setup();
|
|
115
|
+
write("r-5", { pid: 555 }, []);
|
|
116
|
+
const actions = await recoverRuns(base({ evolutionDir, alive: (pid) => pid === 555 }));
|
|
117
|
+
assert.deepEqual(actions.map((a) => a.kind), ["waiting-proposal"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("已有 03-review.json:什么都不做,交给入队/自动采纳", async () => {
|
|
121
|
+
const { evolutionDir, write } = setup();
|
|
122
|
+
write("r-6", { status: "proposed" }, ["01-findings.json", "02-proposal.json", "03-review.json"]);
|
|
123
|
+
const actions = await recoverRuns(base({ evolutionDir }));
|
|
124
|
+
assert.deepEqual(actions.map((a) => a.kind), ["review-done"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("终态与超窗 run 一律不碰", async () => {
|
|
128
|
+
const { evolutionDir, write } = setup();
|
|
129
|
+
write("r-applied", { status: "applied", pid: null }, ["01-findings.json", "02-proposal.json", "03-review.json"]);
|
|
130
|
+
write("r-old", { status: "analyzed", dispatchedAt: NOW - RECOVER_WINDOW_MS - 1 }, ["02-proposal.json"]);
|
|
131
|
+
write("r-stale", { status: "stale", pid: null }, ["01-findings.json"]);
|
|
132
|
+
const actions = await recoverRuns(base({ evolutionDir }));
|
|
133
|
+
assert.deepEqual(actions, []);
|
|
134
|
+
assert.equal(renderRecovery([]), "重启恢复:没有需要接手的 run");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("坏 run.json 当没有,不崩", async () => {
|
|
138
|
+
const { evolutionDir } = setup();
|
|
139
|
+
const broken = path.join(evolutionDir, "runs", "r-broken");
|
|
140
|
+
fs.mkdirSync(broken, { recursive: true });
|
|
141
|
+
fs.writeFileSync(path.join(broken, "run.json"), "{not json");
|
|
142
|
+
assert.deepEqual(await recoverRuns(base({ evolutionDir })), []);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("面板文案带 kinds 与明细", () => {
|
|
146
|
+
const text = renderRecovery([
|
|
147
|
+
{ runId: "r-1", kind: "supervisor-respawned", detail: "重挂 pid 9" },
|
|
148
|
+
{ runId: "r-2", kind: "interrupted", detail: "没提案" },
|
|
149
|
+
]);
|
|
150
|
+
assert.match(text, /重启恢复:2 条 run 被检查/);
|
|
151
|
+
assert.match(text, /r-1 {2}重挂督导 — 重挂 pid 9/);
|
|
152
|
+
assert.match(text, /r-2 {2}已中断 — 没提案/);
|
|
153
|
+
});
|
|
@@ -7,6 +7,9 @@ import assert from "node:assert/strict";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { SUPERVISOR_SCRIPT, spawnReviewSupervisor } from "../lib/review-spawn.ts";
|
|
9
9
|
|
|
10
|
+
/** 测试里不搬家:直接返回包内入口(搬家用 supervisor-runtime.test.ts 单独覆盖) */
|
|
11
|
+
const direct = ({ scriptPath }: { scriptPath: string }): string => scriptPath;
|
|
12
|
+
|
|
10
13
|
function capture(): { calls: { command: string; args: string[]; options: Record<string, unknown> }[]; unrefs: number } {
|
|
11
14
|
return { calls: [], unrefs: 0 };
|
|
12
15
|
}
|
|
@@ -18,6 +21,7 @@ test("督导用 node + --experimental-strip-types 起,参数里带 runDir", ()
|
|
|
18
21
|
cwd: "/repo",
|
|
19
22
|
env: {},
|
|
20
23
|
autoReview: true,
|
|
24
|
+
resolveScript: direct,
|
|
21
25
|
spawnFn: (command, args, options) => {
|
|
22
26
|
seen.calls.push({ command, args, options });
|
|
23
27
|
return {
|
|
@@ -45,8 +49,8 @@ test("inherit 不追加 --model,显式模型追加在 runDir 之后", () => {
|
|
|
45
49
|
explicitArgs.push(args);
|
|
46
50
|
return { pid: 1 };
|
|
47
51
|
};
|
|
48
|
-
spawnReviewSupervisor({ runDir: "/r", cwd: "/repo", env: {}, autoReview: true, reviewModel: "inherit", spawnFn });
|
|
49
|
-
spawnReviewSupervisor({ runDir: "/r", cwd: "/repo", env: {}, autoReview: true, reviewModel: "deepseek/deepseek-v3.2", spawnFn });
|
|
52
|
+
spawnReviewSupervisor({ runDir: "/r", cwd: "/repo", env: {}, autoReview: true, reviewModel: "inherit", spawnFn, resolveScript: direct });
|
|
53
|
+
spawnReviewSupervisor({ runDir: "/r", cwd: "/repo", env: {}, autoReview: true, reviewModel: "deepseek/deepseek-v3.2", spawnFn, resolveScript: direct });
|
|
50
54
|
assert.deepEqual(inheritArgs[0], ["--experimental-strip-types", SUPERVISOR_SCRIPT, "/r"]);
|
|
51
55
|
assert.deepEqual(explicitArgs[1], ["--experimental-strip-types", SUPERVISOR_SCRIPT, "/r", "deepseek/deepseek-v3.2"]);
|
|
52
56
|
});
|
|
@@ -58,6 +62,7 @@ test("env 里去掉 SKILL_EVOLVE_CHILD,其他变量照旧带走", () => {
|
|
|
58
62
|
cwd: "/repo",
|
|
59
63
|
env: { SKILL_EVOLVE_CHILD: "1", PATH: "/usr/bin" },
|
|
60
64
|
autoReview: true,
|
|
65
|
+
resolveScript: direct,
|
|
61
66
|
spawnFn: (_command, _args, options) => {
|
|
62
67
|
env = options.env as NodeJS.ProcessEnv;
|
|
63
68
|
return { pid: 1 };
|
|
@@ -67,6 +72,27 @@ test("env 里去掉 SKILL_EVOLVE_CHILD,其他变量照旧带走", () => {
|
|
|
67
72
|
assert.equal(env.PATH, "/usr/bin");
|
|
68
73
|
});
|
|
69
74
|
|
|
75
|
+
test("督导入口解析拿到 evolutionDir(node_modules 里要搬到工作区 runtime 下再跑)", () => {
|
|
76
|
+
const seen: { evolutionDir?: string; scriptPath?: string }[] = [];
|
|
77
|
+
spawnReviewSupervisor({
|
|
78
|
+
runDir: "/repo/.pi/evolution/runs/run-9",
|
|
79
|
+
cwd: "/repo",
|
|
80
|
+
env: {},
|
|
81
|
+
autoReview: true,
|
|
82
|
+
resolveScript: (options) => {
|
|
83
|
+
seen.push(options);
|
|
84
|
+
return "/tmp/copied/review-supervisor.ts";
|
|
85
|
+
},
|
|
86
|
+
spawnFn: (_c, args) => {
|
|
87
|
+
assert.equal(args[1], "/tmp/copied/review-supervisor.ts", "spawn 用的必须是解析后的路径");
|
|
88
|
+
return { pid: 7 };
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
assert.equal(seen.length, 1);
|
|
92
|
+
assert.equal(seen[0]!.evolutionDir, "/repo/.pi/evolution");
|
|
93
|
+
assert.equal(seen[0]!.scriptPath, SUPERVISOR_SCRIPT);
|
|
94
|
+
});
|
|
95
|
+
|
|
70
96
|
test("自动评审关着:一个进程都不起,只留一句「停在 proposed」日志", () => {
|
|
71
97
|
const logs: string[] = [];
|
|
72
98
|
let spawned = 0;
|
|
@@ -75,6 +101,7 @@ test("自动评审关着:一个进程都不起,只留一句「停在 propose
|
|
|
75
101
|
cwd: "/repo",
|
|
76
102
|
env: {},
|
|
77
103
|
autoReview: false,
|
|
104
|
+
resolveScript: direct,
|
|
78
105
|
log: (msg) => logs.push(msg),
|
|
79
106
|
spawnFn: () => {
|
|
80
107
|
spawned += 1;
|
|
@@ -94,6 +121,7 @@ test("起不来只记日志返回 null,不抛", () => {
|
|
|
94
121
|
cwd: "/repo",
|
|
95
122
|
env: {},
|
|
96
123
|
autoReview: true,
|
|
124
|
+
resolveScript: direct,
|
|
97
125
|
log: (msg) => logs.push(msg),
|
|
98
126
|
spawnFn: () => {
|
|
99
127
|
throw new Error("EMFILE");
|