pi-skill-evolve 0.1.11 → 0.1.13

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.
@@ -0,0 +1,229 @@
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 { withLock } from "./lock.ts";
17
+ import { spawnReviewSupervisor, type SpawnSupervisorOptions } from "./review-spawn.ts";
18
+ import { listRuns, updateRunStatus, type RunRecord } from "./runs.ts";
19
+ import type { MutationQueue } from "./telemetry.ts";
20
+
21
+ /** 只恢复最近这么多小时内派发的 run:更老的多半是用户已经不管的历史 */
22
+ export const RECOVER_WINDOW_MS = 6 * 60 * 60 * 1000;
23
+
24
+ /** 还在「半路」的状态:出终态的不该被碰 */
25
+ const RESUMABLE = new Set(["dispatched", "analyzed", "proposed"]);
26
+
27
+ export type RecoveryKind =
28
+ /** 分析产物在、状态落后:只补状态 */
29
+ | "advanced-analyzed"
30
+ /** 提案在、督导不在:重挂督导 */
31
+ | "supervisor-respawned"
32
+ /** 提案在、督导还在跑:不打扰 */
33
+ | "waiting-review"
34
+ /** 还没出提案,复盘子进程还活着 */
35
+ | "waiting-proposal"
36
+ /** 还没出提案,复盘子进程也没了:不重跑,等 stale 收尾 */
37
+ | "interrupted"
38
+ /** 已有评审结论:交给会话起点的入队 / 自动采纳 */
39
+ | "review-done"
40
+ /** 重挂被挡下(单飞闸占满 / 关着自动评审 / 起不来) */
41
+ | "respawn-blocked";
42
+
43
+ export interface RecoveryAction {
44
+ runId: string;
45
+ kind: RecoveryKind;
46
+ detail: string;
47
+ }
48
+
49
+ export interface RecoverDeps {
50
+ evolutionDir: string;
51
+ cwd: string;
52
+ queue: MutationQueue;
53
+ now?: () => number;
54
+ env?: NodeJS.ProcessEnv;
55
+ alive?: (pid: number) => boolean;
56
+ /** 现读配置:自动评审关着就不重挂督导(用户手动关的不该被重启偷偷打开) */
57
+ autoReview?: () => boolean;
58
+ reviewModel?: () => string;
59
+ spawnSupervisor?: (options: SpawnSupervisorOptions) => number | null;
60
+ /** 本会话 ID:写进锁 owner.json,排查「谁在重挂」时有用 */
61
+ sessionId?: string;
62
+ log?: (msg: string) => void;
63
+ windowMs?: number;
64
+ }
65
+
66
+ /** 督导锁只护「起来 + 写 supervisorPid」这几毫秒,留 60 秒足够,锁主崩了也不会长期占住 */
67
+ const SUPERVISOR_LOCK_MS = 60_000;
68
+
69
+ /** 抢到锁后重新确认「这条 run 的督导确实不在」,防止两边都判死、抢完各起一个 */
70
+ function supervisorStillGone(
71
+ runDir: string,
72
+ record: RunRecord,
73
+ alive: (pid: number) => boolean,
74
+ ): boolean {
75
+ const fresh = readRecord(runDir) ?? record;
76
+ const pid = fresh.supervisorPid;
77
+ return !(typeof pid === "number" && alive(pid));
78
+ }
79
+
80
+ function exists(runDir: string, name: string): boolean {
81
+ try {
82
+ return fs.statSync(path.join(runDir, name)).isFile();
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /** 生产里就是读 run.json:坏数据当「没有」处理,宁可不动手 */
89
+ function readRecord(runDir: string): RunRecord | null {
90
+ try {
91
+ return JSON.parse(fs.readFileSync(path.join(runDir, "run.json"), "utf8")) as RunRecord;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ export async function recoverRuns(deps: RecoverDeps): Promise<RecoveryAction[]> {
98
+ const log = deps.log ?? (() => {});
99
+ const now = deps.now ?? (() => Date.now());
100
+ const alive = deps.alive ?? ((pid: number) => {
101
+ try {
102
+ process.kill(pid, 0);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ });
108
+ const windowMs = deps.windowMs ?? RECOVER_WINDOW_MS;
109
+ const spawnSupervisor = deps.spawnSupervisor ?? spawnReviewSupervisor;
110
+ const actions: RecoveryAction[] = [];
111
+ const runsRoot = path.join(deps.evolutionDir, "runs");
112
+ let entries: string[];
113
+ try {
114
+ entries = fs.readdirSync(runsRoot);
115
+ } catch {
116
+ return actions;
117
+ }
118
+ const cutoff = now() - windowMs;
119
+ for (const runId of entries.sort()) {
120
+ const runDir = path.join(runsRoot, runId);
121
+ const record = readRecord(runDir);
122
+ if (!record) continue;
123
+ if (!RESUMABLE.has(record.status ?? "")) continue;
124
+ if (typeof record.dispatchedAt === "number" && record.dispatchedAt < cutoff) continue;
125
+
126
+ const hasFindings = exists(runDir, "01-findings.json");
127
+ const hasProposal = exists(runDir, "02-proposal.json");
128
+ const hasReview = exists(runDir, "03-review.json");
129
+ const childAlive = typeof record.pid === "number" && alive(record.pid);
130
+ const supervisorAlive = typeof record.supervisorPid === "number" && alive(record.supervisorPid);
131
+
132
+ if (hasReview) {
133
+ actions.push({ runId, kind: "review-done", detail: "评审已落盘,等会话起点入队 / 自动采纳" });
134
+ continue;
135
+ }
136
+
137
+ // 1. 分析跑完了但状态没跟上:补状态(不重跑、不花 token)
138
+ if (record.status === "dispatched" && hasFindings) {
139
+ const ok = await updateRunStatus(deps.evolutionDir, runId, "analyzed", deps.queue);
140
+ actions.push({
141
+ runId,
142
+ kind: ok ? "advanced-analyzed" : "interrupted",
143
+ detail: ok ? "01-findings.json 已在,状态补成 analyzed(未重跑)" : "补状态失败:run.json 写不进去",
144
+ });
145
+ if (!ok) continue;
146
+ }
147
+
148
+ if (!hasProposal) {
149
+ if (childAlive) {
150
+ actions.push({ runId, kind: "waiting-proposal", detail: `复盘子进程 pid ${record.pid} 还活着,不打扰` });
151
+ } else {
152
+ actions.push({ runId, kind: "interrupted", detail: "还没出提案,复盘子进程已不在:不自动重跑,等 stale 收尾" });
153
+ }
154
+ continue;
155
+ }
156
+
157
+ // 2. 提案在、评审没做:重挂督导(这才是「后台任务丢了」的正解)
158
+ if (supervisorAlive) {
159
+ actions.push({ runId, kind: "waiting-review", detail: `督导 pid ${record.supervisorPid} 还在跑,等它出结论` });
160
+ continue;
161
+ }
162
+ if (deps.autoReview && !deps.autoReview()) {
163
+ actions.push({ runId, kind: "respawn-blocked", detail: "自动评审关着:停在待人工 /skill-evolve approve" });
164
+ continue;
165
+ }
166
+ // 3. 跨实例互斥:同一个工作区开两个 pi 时,两边 session_start 都会走到这里。
167
+ // 单飞闸只认「已经写进 run.json 的活 pid」,两边的 spawn 之间有空档,会各起一个督导(双花 token、双写 03-review)。
168
+ // 所以先用 locks/supervisor 抢一下,抢不到说明对面那份正在重挂,让它挂。
169
+ const spawned = await withLock(
170
+ {
171
+ evolutionDir: deps.evolutionDir,
172
+ name: "supervisor",
173
+ owner: { pid: process.pid, ts: now(), ...(deps.sessionId ? { sessionId: deps.sessionId } : {}) },
174
+ maxRuntimeMs: SUPERVISOR_LOCK_MS,
175
+ log,
176
+ ...(deps.now ? { now: deps.now } : {}),
177
+ },
178
+ () =>
179
+ // 抢到锁后再判一次:对面可能刚好在抢锁之前写完 supervisorPid
180
+ supervisorStillGone(runDir, record, alive)
181
+ ? spawnSupervisor({
182
+ runDir,
183
+ cwd: deps.cwd,
184
+ autoReview: true,
185
+ reviewModel: deps.reviewModel?.() ?? "inherit",
186
+ ...(deps.env ? { env: deps.env } : {}),
187
+ ...(deps.alive ? { alive: deps.alive } : {}),
188
+ log,
189
+ })
190
+ : "taken",
191
+ );
192
+ if (!spawned.ok) {
193
+ actions.push({
194
+ runId,
195
+ kind: "respawn-blocked",
196
+ detail: `另一个 pi 实例正在重挂督导(locks/supervisor 被 pid ${spawned.owner?.pid ?? "?"} 持有)`,
197
+ });
198
+ continue;
199
+ }
200
+ const pid = spawned.value;
201
+ if (pid === "taken") {
202
+ actions.push({ runId, kind: "waiting-review", detail: "等到锁时督导已经在了(另一个实例刚挂上),不重复起" });
203
+ continue;
204
+ }
205
+ actions.push(
206
+ pid === null
207
+ ? { runId, kind: "respawn-blocked", detail: "督导没起来(单飞闸占满或 spawn 失败),下次会话起点还会再试" }
208
+ : { runId, kind: "supervisor-respawned", detail: `提案已在、督导原本不在:重挂 pid ${pid}` },
209
+ );
210
+ }
211
+ return actions;
212
+ }
213
+
214
+ /** 面板文案:一句话说清「重启后补了什么」 */
215
+ export function renderRecovery(actions: RecoveryAction[]): string {
216
+ if (actions.length === 0) return "重启恢复:没有需要接手的 run";
217
+ const label: Record<RecoveryKind, string> = {
218
+ "advanced-analyzed": "补状态",
219
+ "supervisor-respawned": "重挂督导",
220
+ "waiting-review": "等评审",
221
+ "waiting-proposal": "等提案",
222
+ interrupted: "已中断",
223
+ "review-done": "评审完成",
224
+ "respawn-blocked": "未能重挂",
225
+ };
226
+ const lines = [`重启恢复:${actions.length} 条 run 被检查`];
227
+ for (const action of actions) lines.push(` · ${action.runId} ${label[action.kind]} — ${action.detail}`);
228
+ return lines.join("\n");
229
+ }
@@ -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
- const args = ["--experimental-strip-types", SUPERVISOR_SCRIPT, options.runDir];
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 {
@@ -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
- const invokedDirectly = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
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}`);
@@ -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 }
@@ -14,7 +14,7 @@ import * as fs from "node:fs";
14
14
  import * as os from "node:os";
15
15
  import * as path from "node:path";
16
16
 
17
- import { acquireLock, acquireLockSync, isPidAlive, lockDir, readOwnerSync, releaseLock, releaseLockSync, shouldReclaim, writeOwnerSync } from "../lib/lock.ts";
17
+ import { acquireLock, acquireLockSync, isPidAlive, lockDir, readOwnerSync, releaseLock, releaseLockSync, shouldReclaim, withLock, writeOwnerSync } from "../lib/lock.ts";
18
18
 
19
19
  const MAX = 20 * 60_000;
20
20
  const NOW = 1_760_000_000_000;
@@ -136,3 +136,40 @@ test("writeOwnerSync:把持有者换成子进程 pid,供 status 与孤儿判
136
136
  assert.deepEqual(readOwnerSync(lockDir(dir)), { pid: 4321, ts: NOW + 1, sessionId: "s1" });
137
137
  releaseLockSync(dir);
138
138
  });
139
+
140
+ test("lockDir:不同名字的锁互不干扰(派发锁被占不影响督导锁)", async () => {
141
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-lock-name-"));
142
+ const base = { evolutionDir: dir, owner: { pid: process.pid, ts: NOW }, maxRuntimeMs: MAX, now: () => NOW };
143
+ const dispatch = await acquireLock(base);
144
+ assert.equal(dispatch.ok, true);
145
+ const supervisor = await acquireLock({ ...base, name: "supervisor" });
146
+ assert.equal(supervisor.ok, true, "另一个名字的锁应当能独立抢到");
147
+ assert.equal(fs.existsSync(path.join(dir, "locks", "supervisor")), true);
148
+ await releaseLock(dir, () => {}, "supervisor");
149
+ await releaseLock(dir);
150
+ assert.equal(fs.existsSync(path.join(dir, "locks")), true, "locks 父目录留着,空的子目录删干净");
151
+ assert.deepEqual(fs.readdirSync(path.join(dir, "locks")), []);
152
+ });
153
+
154
+ test("withLock:忙时直接返回 why=busy 不执行;跑完一定释放(抛错也释放)", async () => {
155
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-withlock-"));
156
+ const options = { evolutionDir: dir, name: "apply" as const, owner: { pid: process.pid, ts: NOW }, maxRuntimeMs: MAX, now: () => NOW };
157
+ const first = await withLock(options, async () => {
158
+ const inner = await withLock(options, () => "不该执行");
159
+ assert.equal(inner.ok, false);
160
+ assert.equal(inner.why, "busy");
161
+ return "done";
162
+ });
163
+ assert.equal(first.ok, true);
164
+ assert.equal(first.value, "done");
165
+ // 异常要往上抛(调用方自己决定记什么日志),但锁必须已经放掉
166
+ await assert.rejects(
167
+ withLock(options, () => {
168
+ throw new Error("模拟采纳中途崩");
169
+ }),
170
+ /模拟采纳中途崩/,
171
+ );
172
+ assert.equal(fs.existsSync(path.join(dir, "locks", "apply")), false, "抛错也要释放,否则下次永远 busy");
173
+ const again = await withLock(options, () => "又抢到了");
174
+ assert.equal(again.ok, true);
175
+ });