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
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import * as path from "node:path";
|
|
18
|
-
import { pathToFileURL } from "node:url";
|
|
18
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
19
19
|
import { loadHardRules, scanText, type CompiledRules, type HardRuleHit } from "./hard-rules.ts";
|
|
20
20
|
import { buildReviewPrompt } from "./review-prompt.ts";
|
|
21
21
|
import { classifyChange } from "./classify-action.ts";
|
|
@@ -270,7 +270,23 @@ async function main(): Promise<void> {
|
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
|
|
273
|
+
/**
|
|
274
|
+
* 「是不是被当脚本直接跑」判定:必须比**真实路径**。
|
|
275
|
+
* macOS 的 `/tmp` 是 `/private/tmp` 的软链,直接比 `import.meta.url` 与 `pathToFileURL(argv[1])`
|
|
276
|
+
* 会得出 false —— 督导被搬进工作区 runtime 目录后若那里恰好带软链,就会「起来但什么都不做」(静默不跑)。
|
|
277
|
+
*/
|
|
278
|
+
export function isDirectInvocation(argv1: string | undefined, moduleUrl: string): boolean {
|
|
279
|
+
if (!argv1) return false;
|
|
280
|
+
try {
|
|
281
|
+
const same = fs.realpathSync(argv1) === fs.realpathSync(fileURLToPath(moduleUrl));
|
|
282
|
+
if (same) return true;
|
|
283
|
+
} catch {
|
|
284
|
+
// 路径解析不了就退回字符串比较(行为与旧版一致)
|
|
285
|
+
}
|
|
286
|
+
return pathToFileURL(argv1).href === moduleUrl;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const invokedDirectly = isDirectInvocation(process.argv[1], import.meta.url);
|
|
274
290
|
if (invokedDirectly) {
|
|
275
291
|
void main().catch((error: Error) => {
|
|
276
292
|
console.error(`[skill-evolve] 评审督导异常:${error.message}`);
|
|
@@ -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 同级) */
|
|
@@ -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 }
|
|
@@ -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);
|
|
@@ -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
|
+
});
|