pi-skill-evolve 0.0.0-stage → 0.1.2

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.
Files changed (157) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +81 -2
  3. package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
  4. package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
  5. package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
  6. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
  7. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
  8. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
  9. package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
  10. package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
  11. package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
  12. package/extensions/skill-evolve/index.ts +300 -0
  13. package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
  14. package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
  15. package/extensions/skill-evolve/lib/apply.ts +141 -0
  16. package/extensions/skill-evolve/lib/attribution.ts +138 -0
  17. package/extensions/skill-evolve/lib/audit.ts +221 -0
  18. package/extensions/skill-evolve/lib/baseline.ts +176 -0
  19. package/extensions/skill-evolve/lib/child-env.ts +10 -0
  20. package/extensions/skill-evolve/lib/classify-action.ts +161 -0
  21. package/extensions/skill-evolve/lib/command.ts +100 -0
  22. package/extensions/skill-evolve/lib/config-command.ts +38 -0
  23. package/extensions/skill-evolve/lib/config.ts +194 -0
  24. package/extensions/skill-evolve/lib/detect.ts +62 -0
  25. package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
  26. package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
  27. package/extensions/skill-evolve/lib/dispatch.ts +300 -0
  28. package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
  29. package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
  30. package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
  31. package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
  32. package/extensions/skill-evolve/lib/evolution.ts +289 -0
  33. package/extensions/skill-evolve/lib/flags.ts +44 -0
  34. package/extensions/skill-evolve/lib/gate0.ts +78 -0
  35. package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
  36. package/extensions/skill-evolve/lib/lock.ts +215 -0
  37. package/extensions/skill-evolve/lib/logfile.ts +52 -0
  38. package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
  39. package/extensions/skill-evolve/lib/mechanical.ts +136 -0
  40. package/extensions/skill-evolve/lib/metrics.ts +172 -0
  41. package/extensions/skill-evolve/lib/model-config.ts +95 -0
  42. package/extensions/skill-evolve/lib/notify.ts +132 -0
  43. package/extensions/skill-evolve/lib/panel.ts +174 -0
  44. package/extensions/skill-evolve/lib/pending.ts +34 -0
  45. package/extensions/skill-evolve/lib/proc.ts +65 -0
  46. package/extensions/skill-evolve/lib/prompt.ts +80 -0
  47. package/extensions/skill-evolve/lib/proposal.ts +227 -0
  48. package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
  49. package/extensions/skill-evolve/lib/queue.ts +136 -0
  50. package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
  51. package/extensions/skill-evolve/lib/replay.ts +246 -0
  52. package/extensions/skill-evolve/lib/report.ts +181 -0
  53. package/extensions/skill-evolve/lib/review-post.ts +101 -0
  54. package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
  55. package/extensions/skill-evolve/lib/review-record.ts +63 -0
  56. package/extensions/skill-evolve/lib/review-run.ts +131 -0
  57. package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
  58. package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
  59. package/extensions/skill-evolve/lib/round.ts +116 -0
  60. package/extensions/skill-evolve/lib/run-watch.ts +34 -0
  61. package/extensions/skill-evolve/lib/runs.ts +208 -0
  62. package/extensions/skill-evolve/lib/selftest.ts +201 -0
  63. package/extensions/skill-evolve/lib/session-index.ts +145 -0
  64. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
  65. package/extensions/skill-evolve/lib/single-flight.ts +111 -0
  66. package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
  67. package/extensions/skill-evolve/lib/stale.ts +217 -0
  68. package/extensions/skill-evolve/lib/telemetry.ts +205 -0
  69. package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
  70. package/extensions/skill-evolve/lib/web/actions.ts +299 -0
  71. package/extensions/skill-evolve/lib/web/activity.ts +57 -0
  72. package/extensions/skill-evolve/lib/web/applied.ts +64 -0
  73. package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
  74. package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
  75. package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
  76. package/extensions/skill-evolve/lib/web/data.ts +298 -0
  77. package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
  78. package/extensions/skill-evolve/lib/web/filter.ts +203 -0
  79. package/extensions/skill-evolve/lib/web/http.ts +68 -0
  80. package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
  81. package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
  82. package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
  83. package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
  84. package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
  85. package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
  86. package/extensions/skill-evolve/lib/web/render.ts +136 -0
  87. package/extensions/skill-evolve/lib/web/reports.ts +160 -0
  88. package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
  89. package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
  90. package/extensions/skill-evolve/lib/web/server.ts +268 -0
  91. package/extensions/skill-evolve/lib/web/shell.ts +87 -0
  92. package/extensions/skill-evolve/lib/web/skills.ts +202 -0
  93. package/extensions/skill-evolve/lib/web/style.ts +107 -0
  94. package/extensions/skill-evolve/lib/web/text.ts +52 -0
  95. package/extensions/skill-evolve/lib/web/trash.ts +36 -0
  96. package/extensions/skill-evolve/lib/weekly.ts +285 -0
  97. package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
  98. package/extensions/skill-evolve/scripts/replay.ts +93 -0
  99. package/extensions/skill-evolve/test/apply.test.ts +278 -0
  100. package/extensions/skill-evolve/test/approve.test.ts +248 -0
  101. package/extensions/skill-evolve/test/attribution.test.ts +133 -0
  102. package/extensions/skill-evolve/test/audit.test.ts +151 -0
  103. package/extensions/skill-evolve/test/baseline.test.ts +132 -0
  104. package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
  105. package/extensions/skill-evolve/test/command.test.ts +267 -0
  106. package/extensions/skill-evolve/test/config-command.test.ts +73 -0
  107. package/extensions/skill-evolve/test/config.test.ts +154 -0
  108. package/extensions/skill-evolve/test/detect.test.ts +71 -0
  109. package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
  110. package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
  111. package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
  112. package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
  113. package/extensions/skill-evolve/test/evolution.test.ts +378 -0
  114. package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
  115. package/extensions/skill-evolve/test/hard-rules.test.ts +123 -0
  116. package/extensions/skill-evolve/test/lock.test.ts +138 -0
  117. package/extensions/skill-evolve/test/logfile.test.ts +85 -0
  118. package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
  119. package/extensions/skill-evolve/test/metrics.test.ts +129 -0
  120. package/extensions/skill-evolve/test/model-config.test.ts +84 -0
  121. package/extensions/skill-evolve/test/notify.test.ts +133 -0
  122. package/extensions/skill-evolve/test/observe.test.ts +100 -0
  123. package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
  124. package/extensions/skill-evolve/test/panel.test.ts +115 -0
  125. package/extensions/skill-evolve/test/proposal.test.ts +159 -0
  126. package/extensions/skill-evolve/test/queue.test.ts +110 -0
  127. package/extensions/skill-evolve/test/reject.test.ts +134 -0
  128. package/extensions/skill-evolve/test/replay.test.ts +111 -0
  129. package/extensions/skill-evolve/test/report.test.ts +66 -0
  130. package/extensions/skill-evolve/test/review-post.test.ts +92 -0
  131. package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
  132. package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
  133. package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
  134. package/extensions/skill-evolve/test/selftest.test.ts +113 -0
  135. package/extensions/skill-evolve/test/session-index.test.ts +128 -0
  136. package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
  137. package/extensions/skill-evolve/test/stale.test.ts +272 -0
  138. package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
  139. package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
  140. package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
  141. package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
  142. package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
  143. package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
  144. package/extensions/skill-evolve/test/web-data.test.ts +420 -0
  145. package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
  146. package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
  147. package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
  148. package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
  149. package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
  150. package/extensions/skill-evolve/test/web-render.test.ts +300 -0
  151. package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
  152. package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
  153. package/extensions/skill-evolve/test/web-server.test.ts +465 -0
  154. package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
  155. package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
  156. package/extensions/skill-evolve/test/weekly.test.ts +123 -0
  157. package/package.json +34 -4
@@ -0,0 +1,177 @@
1
+ /**
2
+ * 退出前补发:`session_shutdown` 里把还没派发的轮次再试一次。
3
+ *
4
+ * 为什么单独一个文件:这条路径全部是**同步** fs + 同步 spawn —— 主进程马上就要退出,
5
+ * 用异步队列很可能写完进程就没了(设计稿「静默失败」原则:命令返回成功 ≠ 真的落盘)。
6
+ * 代价是这几步会阻塞退出几十毫秒,所以只在真的还有待复盘轮次时才走。
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import type { Attribution } from "./attribution.ts";
11
+ import { acquireLockSync, releaseLockSync, writeOwnerSync } from "./lock.ts";
12
+ import { buildPrompt, readPromptTemplateSync } from "./prompt.ts";
13
+ import { spawnReviewSupervisor, type SupervisorSpawn } from "./review-spawn.ts";
14
+ import { guardBusy } from "./single-flight.ts";
15
+ import { CHILD_LOG, uniqueRunId, sessionHashOf, resolvePiBin, spawnDetached, type SpawnLike } from "./spawn-plan.ts";
16
+ import { writeRunRecordSync, type RunRecord } from "./runs.ts";
17
+ import { writeSessionIndexSync } from "./session-index.ts";
18
+ import type { TelemetryWriter } from "./telemetry.ts";
19
+
20
+ /** 待复盘的轮次(去抖期间累计,退出时补发一次) */
21
+ export interface PendingRounds {
22
+ sessionId: string;
23
+ /** 待复盘区间的起点:第一次被去抖掉的时刻 */
24
+ since: number;
25
+ /** 合并了几轮(superseded 的次数) */
26
+ rounds: number;
27
+ skills: string[];
28
+ sessionFile?: string;
29
+ /** 已经机械归因过的技能(A~E),多轮合并时去重 */
30
+ attributions?: Attribution[];
31
+ }
32
+
33
+ export interface ShutdownDeps {
34
+ evolutionDir: string;
35
+ cwd: string;
36
+ promptTemplatePath: string;
37
+ maxRuntimeMs: number;
38
+ telemetry: TelemetryWriter;
39
+ env: NodeJS.ProcessEnv;
40
+ spawnFn: SpawnLike;
41
+ /** 评审模型("inherit" 或 provider/model),退出补发这条路径同样要起督导 */
42
+ reviewModel?: string;
43
+ supervisorSpawnFn?: SupervisorSpawn;
44
+ /** pid 存活判定(全局单飞闸用;单测注入,测试里不起真子进程) */
45
+ alive?: (pid: number) => boolean;
46
+ log: (msg: string) => void;
47
+ }
48
+
49
+ export type ShutdownResult = "dispatched" | "skipped" | "failed";
50
+
51
+ /** 同步补发一次:任何异常都在内部吞掉,绝不阻塞退出、绝不抛给调用方 */
52
+ export function flushPendingSync(item: PendingRounds, deps: ShutdownDeps, now: number): ShutdownResult {
53
+ // 全局单飞:退出补发也不能叠进程(已有活的复盘 / 督导就放弃本次,不建目录不抢锁)
54
+ const occupant = guardBusy({
55
+ evolutionDir: deps.evolutionDir,
56
+ sessionId: item.sessionId,
57
+ telemetry: deps.telemetry,
58
+ sync: true,
59
+ trigger: "shutdown",
60
+ rounds: item.rounds,
61
+ ...(deps.alive ? { alive: deps.alive } : {}),
62
+ log: deps.log,
63
+ });
64
+ if (occupant) return "skipped";
65
+
66
+ const runId = uniqueRunId(deps.evolutionDir, item.sessionId, now);
67
+ const runDir = path.join(deps.evolutionDir, "runs", runId);
68
+ const lock = acquireLockSync({
69
+ evolutionDir: deps.evolutionDir,
70
+ owner: { pid: process.pid, ts: now, sessionId: item.sessionId },
71
+ maxRuntimeMs: deps.maxRuntimeMs,
72
+ log: deps.log,
73
+ now: () => now,
74
+ });
75
+ if (!lock.ok) {
76
+ deps.telemetry.appendSync({
77
+ kind: "skipped",
78
+ ts: now,
79
+ sessionId: item.sessionId,
80
+ why: lock.why === "error" ? "lock-error" : "lock",
81
+ trigger: "shutdown",
82
+ ownerPid: lock.owner?.pid ?? null,
83
+ rounds: item.rounds,
84
+ });
85
+ deps.log(`[skill-evolve] 退出补发放弃:锁被占用(pid ${lock.owner?.pid ?? "未知"})`);
86
+ return "skipped";
87
+ }
88
+
89
+ let childPid: number | null = null;
90
+ try {
91
+ const indexDir = path.join(runDir, "00-session");
92
+ const indexFile = path.join(indexDir, "index.json");
93
+ let evidenceMode: RunRecord["evidenceMode"] = "telemetry-only";
94
+ if (item.sessionFile && writeSessionIndexSync({ sessionFile: item.sessionFile, outDir: indexDir, log: deps.log })) {
95
+ evidenceMode = "session+telemetry";
96
+ }
97
+
98
+ const record: RunRecord = {
99
+ runId,
100
+ status: "dispatched",
101
+ trigger: "shutdown",
102
+ sessionId: item.sessionId,
103
+ sessionHash: sessionHashOf(item.sessionId),
104
+ sessionFile: item.sessionFile ?? null,
105
+ evidenceMode,
106
+ dispatchedAt: now,
107
+ dispatchedAtIso: new Date(now).toISOString(),
108
+ pid: null,
109
+ cwd: deps.cwd,
110
+ skills: [...item.skills],
111
+ coverage: { rounds: item.rounds, since: item.since },
112
+ stages: [{ name: "dispatch", model: null, usage: null, ms: 0 }],
113
+ artifacts: evidenceMode === "session+telemetry" ? { "00-session/index.json": indexFile } : {},
114
+ };
115
+ writeRunRecordSync(runDir, record);
116
+
117
+ const prompt = buildPrompt({
118
+ template: readPromptTemplateSync(deps.promptTemplatePath, deps.log),
119
+ sessionFile: item.sessionFile,
120
+ indexFile: evidenceMode === "session+telemetry" ? indexFile : undefined,
121
+ runDir,
122
+ sessionId: item.sessionId,
123
+ skills: item.skills,
124
+ attributions: item.attributions,
125
+ dispatchedAt: now,
126
+ coverage: record.coverage,
127
+ });
128
+ const child = spawnDetached(deps.spawnFn, resolvePiBin(deps.env), ["-p", "--no-session", "-ns", prompt], {
129
+ cwd: deps.cwd,
130
+ env: { ...deps.env, SKILL_EVOLVE_CHILD: "1" },
131
+ // 退出前补发这条路径最容易“静默失败”:日志落盘才知道子进程到底起没起来
132
+ logFile: path.join(runDir, CHILD_LOG),
133
+ });
134
+ childPid = child.pid ?? null;
135
+
136
+ record.pid = childPid;
137
+ writeRunRecordSync(runDir, record);
138
+ // 锁的 owner 换成子进程:主进程退出后 owner.pid 若指向死进程,下一次派发会把正在跑的 run 当孤儿回收
139
+ writeOwnerSync(deps.evolutionDir, { pid: childPid ?? process.pid, ts: now, sessionId: item.sessionId }, deps.log);
140
+ deps.telemetry.appendSync({
141
+ kind: "dispatched",
142
+ ts: now,
143
+ sessionId: item.sessionId,
144
+ runId,
145
+ pid: childPid,
146
+ trigger: "shutdown",
147
+ rounds: item.rounds,
148
+ });
149
+ deps.log(`[skill-evolve] 退出前补发复盘:runId ${runId} pid ${childPid ?? "未知"}(合并 ${item.rounds} 轮)`);
150
+ spawnReviewSupervisor({
151
+ runDir,
152
+ cwd: deps.cwd,
153
+ env: deps.env,
154
+ reviewModel: deps.reviewModel ?? "inherit",
155
+ spawnFn: deps.supervisorSpawnFn,
156
+ sessionId: item.sessionId,
157
+ telemetry: deps.telemetry,
158
+ sync: true,
159
+ ...(deps.alive ? { alive: deps.alive } : {}),
160
+ log: deps.log,
161
+ });
162
+ return "dispatched";
163
+ } catch (error) {
164
+ deps.log(`[skill-evolve] 退出补发失败:${String(error)}`);
165
+ // 子进程已经起来了就留着锁(它自己跑完会死,下一次按死 pid 回收);没起来必须放锁
166
+ if (childPid === null) releaseLockSync(deps.evolutionDir, deps.log);
167
+ try {
168
+ const record = JSON.parse(fs.readFileSync(path.join(runDir, "run.json"), "utf8")) as RunRecord;
169
+ record.status = "abandoned";
170
+ record.abandonedReason = `shutdown-dispatch: ${String(error)}`;
171
+ writeRunRecordSync(runDir, record);
172
+ } catch {
173
+ // run.json 本身都写不进去:日志已经记过,到此为止
174
+ }
175
+ return "failed";
176
+ }
177
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 全局单飞闸:任意时刻最多一个子进程(复盘或督导都算)。
3
+ *
4
+ * 用户 2026-11-08:「每次最多一个复盘agent」。这条约束**跨会话也成立** —— 两个 pi 会话同时结算时,
5
+ * 只让先到的那条跑;后来者不 spawn,只写一条 `skipped(busy)` 遥测,等占用者跑完的下一轮再派。
6
+ *
7
+ * 占用证据是**活着的 pid**:`runs/<runId>/run.json` 的 `pid`(复盘子进程)与 `supervisorPid`(评审督导)。
8
+ * pid 判死即自然释放,不需要谁去清场;督导退出时仍会把 `supervisorPid` 写成 `null`,
9
+ * 免得一个被系统复用的 pid 造成假占用。
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { isPidAlive } from "./lock.ts";
14
+ import type { TelemetryWriter } from "./telemetry.ts";
15
+
16
+ /** 占用者:谁在跑、pid 多少、是复盘还是督导 */
17
+ export interface Occupant {
18
+ runId: string;
19
+ pid: number;
20
+ kind: "复盘" | "督导";
21
+ }
22
+
23
+ export interface OccupantDeps {
24
+ /** pid 存活判定(默认 kill(pid,0));单测注入,测试里不许起真进程 */
25
+ alive?: (pid: number) => boolean;
26
+ /**
27
+ * 排除自己:督导与它那条复盘是同一 run 的配对进程(复盘子进程先起、督导后起)。
28
+ * 不排除的话督导永远看不到「空闲」,功能直接死掉。
29
+ */
30
+ ignoreRunId?: string;
31
+ }
32
+
33
+ interface Candidate {
34
+ occupant: Occupant;
35
+ dispatchedAt: number;
36
+ }
37
+
38
+ function readCandidates(evolutionDir: string, deps: OccupantDeps): Candidate[] {
39
+ const alive = deps.alive ?? isPidAlive;
40
+ const runsDir = path.join(evolutionDir, "runs");
41
+ let names: string[];
42
+ try {
43
+ names = fs.readdirSync(runsDir);
44
+ } catch {
45
+ return []; // 还没派过:没有 runs/ 目录就是没人占用
46
+ }
47
+ const found: Candidate[] = [];
48
+ for (const name of names) {
49
+ let raw: Record<string, unknown>;
50
+ try {
51
+ raw = JSON.parse(fs.readFileSync(path.join(runsDir, name, "run.json"), "utf8")) as Record<string, unknown>;
52
+ } catch {
53
+ continue; // run.json 不在 / 坏掉:没有可判的记录,坏记录不能永久占住闸门
54
+ }
55
+ const runId = typeof raw.runId === "string" ? raw.runId : name;
56
+ if (deps.ignoreRunId !== undefined && (runId === deps.ignoreRunId || name === deps.ignoreRunId)) continue;
57
+ const dispatchedAt = typeof raw.dispatchedAt === "number" ? raw.dispatchedAt : 0;
58
+ // 复盘在前、督导在后:同一 run 两个 pid 都活着时,先报花 token 的那个
59
+ const slots: [Occupant["kind"], unknown][] = [
60
+ ["复盘", raw.pid],
61
+ ["督导", raw.supervisorPid],
62
+ ];
63
+ for (const [kind, value] of slots) {
64
+ const pid = typeof value === "number" && value > 0 ? value : null;
65
+ if (pid !== null && alive(pid)) found.push({ occupant: { runId, pid, kind }, dispatchedAt });
66
+ }
67
+ }
68
+ return found;
69
+ }
70
+
71
+ /** 当前占用者:多条都在跑时返回最早派发的那条;没人占用返回 null */
72
+ export function findOccupant(evolutionDir: string, deps: OccupantDeps = {}): Occupant | null {
73
+ // sort 是稳定排序:dispatchedAt 相同时保持「复盘先于督导」的先后
74
+ const found = readCandidates(evolutionDir, deps).sort((a, b) => a.dispatchedAt - b.dispatchedAt);
75
+ return found[0]?.occupant ?? null;
76
+ }
77
+
78
+ export interface BusyGuardInput extends OccupantDeps {
79
+ evolutionDir: string;
80
+ sessionId: string;
81
+ /** 遥测写入器;缺省时只记日志(督导启动那条路径理论上总有) */
82
+ telemetry?: TelemetryWriter;
83
+ /** 退出补发路径:主进程马上退出,只能同步写 */
84
+ sync?: boolean;
85
+ trigger?: "auto" | "manual" | "shutdown";
86
+ rounds?: number;
87
+ log?: (msg: string) => void;
88
+ }
89
+
90
+ /**
91
+ * 三处派发点共用的闸:占用时写一条 `skipped(busy)` 遥测并返回占用者,空闲返回 null。
92
+ * 已有 run 的状态一律不动 —— 被挡住的是「再来一个」,不是「把正在跑的收掉」。
93
+ */
94
+ export function guardBusy(input: BusyGuardInput): Occupant | null {
95
+ const occupant = findOccupant(input.evolutionDir, input);
96
+ if (occupant === null) return null;
97
+ if (input.telemetry) {
98
+ const event = {
99
+ kind: "skipped" as const,
100
+ sessionId: input.sessionId,
101
+ why: "busy",
102
+ occupant,
103
+ ...(input.trigger === undefined ? {} : { trigger: input.trigger }),
104
+ ...(input.rounds === undefined ? {} : { rounds: input.rounds }),
105
+ };
106
+ if (input.sync === true) input.telemetry.appendSync(event);
107
+ else input.telemetry.append(event);
108
+ }
109
+ input.log?.(`[skill-evolve] 单飞闸拦下一次派生:已有子进程在跑(${occupant.kind} ${occupant.runId} pid ${occupant.pid})`);
110
+ return occupant;
111
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 派生的「开工参数」:pi 可执行文件、runId、会话 hash,以及子进程接口的最小形状。
3
+ *
4
+ * 单独成文件是为了让 dispatch.ts(常规派发)与 shutdown-dispatch.ts(退出前补发)
5
+ * 共享同一套命名与路径口径,而不互相 import 形成环。
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+
11
+ /** pi 不能靠 PATH 找:GUI / 非交互环境里 PATH 常常没有它 */
12
+ export const DEFAULT_PI_BIN = "/home/suveng/.pi/agent/bin/pi";
13
+
14
+ export interface SpawnedChild {
15
+ pid?: number;
16
+ unref(): void;
17
+ once(event: "exit", listener: (code: number | null) => void): unknown;
18
+ once(event: "error", listener: (error: unknown) => void): unknown;
19
+ }
20
+
21
+ export type SpawnLike = (command: string, args: string[], options: Record<string, unknown>) => SpawnedChild;
22
+
23
+ export function resolvePiBin(env: NodeJS.ProcessEnv = process.env): string {
24
+ return env.SKILL_EVOLVE_PI_BIN || env.PI_SUBAGENT_PI_BINARY || DEFAULT_PI_BIN;
25
+ }
26
+
27
+ /** 复盘子进程的日志文件名(落在 run 目录下,与 run.json 同级) */
28
+ export const CHILD_LOG = "child.log";
29
+
30
+ export interface DetachedSpawnOptions {
31
+ cwd: string;
32
+ env: NodeJS.ProcessEnv;
33
+ /** stdout+stderr 追加到这个文件;null = 丢弃(只丢证据,不丢功能) */
34
+ logFile: string | null;
35
+ }
36
+
37
+ /**
38
+ * detached 派生子进程,并把它的 stdout+stderr 追加进 logFile。
39
+ *
40
+ * 为什么必须落盘:子进程静默死掉时,run.json 只会停在 dispatched、面板永远显示「复盘 1」,
41
+ * 连一行报错都拿不到 —— 那种现场根本查不出「为什么死」(2026-11-08 真实事故)。
42
+ *
43
+ * 日志文件开不出来(磁盘满 / 权限 / 目录不在)时退回 stdio:"ignore":光有证据不如先有功能。
44
+ * 父进程 spawn 完立刻关掉自己那份 fd —— 子进程已拿到独立副本,父进程留着纯属泄漏。
45
+ * 单测注入假 spawnFn,本函数只负责拼 stdio 与关 fd,绝不真起进程。
46
+ */
47
+ export function spawnDetached<T extends { unref?: () => void }>(
48
+ spawnFn: (command: string, args: string[], options: Record<string, unknown>) => T,
49
+ command: string,
50
+ args: string[],
51
+ options: DetachedSpawnOptions,
52
+ ): T {
53
+ let fd: number | null = null;
54
+ if (options.logFile !== null) {
55
+ try {
56
+ fs.mkdirSync(path.dirname(options.logFile), { recursive: true });
57
+ fd = fs.openSync(options.logFile, "a");
58
+ } catch {
59
+ fd = null;
60
+ }
61
+ }
62
+ try {
63
+ const child = spawnFn(command, args, {
64
+ cwd: options.cwd,
65
+ detached: true,
66
+ // stdin 不给(子进程不读输入),stdout 与 stderr 共用同一个 fd
67
+ stdio: fd === null ? "ignore" : ["ignore", fd, fd],
68
+ env: options.env,
69
+ });
70
+ child.unref?.();
71
+ return child;
72
+ } finally {
73
+ if (fd !== null) {
74
+ try {
75
+ fs.closeSync(fd);
76
+ } catch {
77
+ // 关 fd 失败无影响:父进程退出时内核也会收
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ export function sessionHashOf(sessionId: string): string {
84
+ return createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
85
+ }
86
+
87
+ /** runId = 会话 hash 前 8 位 + 秒级时间戳,肉眼可排序、可回溯到会话 */
88
+ export function runIdFor(sessionId: string, ts: number): string {
89
+ return `${sessionHashOf(sessionId).slice(0, 8)}-${Math.floor(ts / 1000)}`;
90
+ }
91
+
92
+ /**
93
+ * 同一秒内可能派发两次(自动结算 + 手动 `/skill-evolve run`),或两个会话刚好撞秒。
94
+ * run.json 是按 runId 建的目录,撞了就会互相覆盖,所以撞号时加序号后缀。
95
+ */
96
+ export function uniqueRunId(evolutionDir: string, sessionId: string, ts: number): string {
97
+ const base = runIdFor(sessionId, ts);
98
+ let candidate = base;
99
+ for (let suffix = 2; fs.existsSync(path.join(evolutionDir, "runs", candidate)); suffix += 1) {
100
+ candidate = `${base}-${suffix}`;
101
+ }
102
+ return candidate;
103
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * 卡死判定(设计稿 14 章的可观测补丁):复盘子进程死掉之后,run.json 会永远停在 dispatched,
3
+ * 面板永远显示「复盘 1」,run 目录里也没有一行日志 —— 这种「静默失败」靠人肉 ps 才查得出来。
4
+ *
5
+ * 三条纪律:
6
+ * 1. 只判「派发出去还没出结论」的三种状态(dispatched / analyzed / proposed):
7
+ * reviewed / applied 已经不缺结论,超不超时都无所谓;
8
+ * 2. **绝不自动重跑**:一次复盘要烧一整段会话的 token,重跑必须人来点(`/skill-evolve run`);
9
+ * 3. 判定必须留下能查的证据:run.json 改成 stale,同目录落 run-error.json(含子进程日志尾部)。
10
+ *
11
+ * 读/写在同一个文件里:这条链路只有「读 run.json → 判 → 写 run.json + run-error.json」三步,
12
+ * 拆开反而要让三个模块共享路径口径。
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { DEFAULT_CONFIG } from "./config.ts";
17
+ import { isPidAlive } from "./lock.ts";
18
+ import { CHILD_LOG } from "./spawn-plan.ts";
19
+ import { writeRunRecordSync, type RunRecord } from "./runs.ts";
20
+
21
+ /** 在途状态:只有这些状态的 run「没结论」才叫卡死 */
22
+ /**
23
+ * 只有 `dispatched` 算「可能卡住」:run 一旦进到 analyzed/proposed,说明子进程已经把结论写回磁盘,
24
+ * 它的进程晚一点退出是正常的(本来就是一次性 CLI)。把 analyzed 也算成卡死,等于把「已经成功的复盘」
25
+ * 报成「复盘失败」—— 面板宁可少报一类,也不能误报。
26
+ */
27
+ export const STALE_STATUSES = new Set(["dispatched"]);
28
+
29
+ /** 判「子进程已死」前要等多久:5 分钟足够躲开 pi 启动慢与机器抖动,又不至于让人等半天 */
30
+ export const STALE_MS = 5 * 60_000;
31
+
32
+ /** run-error.json 保留的日志行数:够看出最后一句报错,又不会把文件撑大 */
33
+ export const LOG_TAIL_LINES = 20;
34
+
35
+ /** child-exited = 子进程没了;timeout = 还活着但超过单 run 上限 */
36
+ export type StaleReason = "child-exited" | "timeout";
37
+
38
+ export interface StaleVerdict {
39
+ stale: boolean;
40
+ reason?: StaleReason;
41
+ }
42
+
43
+ export interface StaleDeps {
44
+ /** 判「子进程已死」的等待时长(默认 STALE_MS) */
45
+ staleMs?: number;
46
+ /** 判「还在跑但太久」的上限(默认配置的 maxRuntimeMs) */
47
+ maxRuntimeMs?: number;
48
+ /** pid 存活判定:生产用 lock.ts 的 isPidAlive(kill(pid,0) 抛 ESRCH 即死),单测注入 */
49
+ alive?: (pid: number) => boolean;
50
+ }
51
+
52
+ /** 判定只需要三个字段:状态、子进程 pid、派发时刻 */
53
+ export interface StaleTarget {
54
+ status: string;
55
+ pid?: number | null;
56
+ dispatchedAt?: number | null;
57
+ }
58
+
59
+ export interface RunError {
60
+ reason: StaleReason;
61
+ at: number;
62
+ /** child.log 的最后 LOG_TAIL_LINES 行;没有日志就是空数组 —— 「一条日志都没有」本身是证据 */
63
+ childLogTail: string[];
64
+ }
65
+
66
+ export interface StaleRun {
67
+ runId: string;
68
+ runDir: string;
69
+ reason: StaleReason;
70
+ /** 日志最后一行(展示用);没有日志为 null */
71
+ lastLine: string | null;
72
+ at: number;
73
+ }
74
+
75
+ /**
76
+ * 纯判定,不做任何副作用 —— 面板要拿它算数,不能因为「看一眼面板」就去 kill 进程。
77
+ * 返回 timeout 时由 scanStaleRuns 负责真的收尾(kill + 落盘)。
78
+ */
79
+ export function classifyStale(run: StaleTarget, now: number, deps: StaleDeps = {}): StaleVerdict {
80
+ if (!STALE_STATUSES.has(run.status)) return { stale: false };
81
+ // 没有派发时刻就证明不了「卡了多久」:宁可漏报一条,也不误伤刚起来的 run
82
+ const started = typeof run.dispatchedAt === "number" && Number.isFinite(run.dispatchedAt) ? run.dispatchedAt : null;
83
+ if (started === null) return { stale: false };
84
+ const elapsed = now - started;
85
+ const pid = typeof run.pid === "number" && run.pid > 0 ? run.pid : null;
86
+ const alive = deps.alive ?? isPidAlive;
87
+ // pid 缺失也算已死:dispatch 是先写 pid=null 再补写 pid,中间被打断就是这个形态
88
+ if (pid === null || !alive(pid)) {
89
+ return elapsed > (deps.staleMs ?? STALE_MS) ? { stale: true, reason: "child-exited" } : { stale: false };
90
+ }
91
+ return elapsed > (deps.maxRuntimeMs ?? DEFAULT_CONFIG.maxRuntimeMs) ? { stale: true, reason: "timeout" } : { stale: false };
92
+ }
93
+
94
+ /** 日志尾部:文件不在 / 读不出来都返回空数组(缺日志是常态,不能因此抛) */
95
+ export function readLogTail(file: string, maxLines = LOG_TAIL_LINES): string[] {
96
+ try {
97
+ const lines = fs.readFileSync(file, "utf8").split("\n");
98
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
99
+ return lines.slice(-maxLines);
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ export interface ScanDeps extends StaleDeps {
106
+ /** 超时收尾用:默认 process.kill,单测注入,绝不真杀 */
107
+ kill?: (pid: number) => void;
108
+ log?: (msg: string) => void;
109
+ }
110
+
111
+ /** run-error.json 的内容口径:一条 reason + 判定时刻 + 日志尾部 */
112
+ export function readRunError(runDir: string): RunError | null {
113
+ try {
114
+ const raw = JSON.parse(fs.readFileSync(path.join(runDir, "run-error.json"), "utf8")) as Partial<RunError>;
115
+ const reason = raw.reason === "timeout" || raw.reason === "child-exited" ? raw.reason : null;
116
+ if (!reason) return null;
117
+ return {
118
+ reason,
119
+ at: typeof raw.at === "number" ? raw.at : 0,
120
+ childLogTail: Array.isArray(raw.childLogTail) ? raw.childLogTail.map((line) => String(line)) : [],
121
+ };
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ const lastLineOf = (tail: readonly string[]): string | null => {
128
+ const line = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
129
+ return line.length > 0 ? line : null;
130
+ };
131
+
132
+ /**
133
+ * 扫一遍 runs/:把卡死的 run 标成 stale 并落 run-error.json,返回本次新判定的条目。
134
+ * 已标过 stale 的 run 状态不再属于在途,所以重复扫描不会重复写(幂等)。
135
+ */
136
+ export function scanStaleRuns(evolutionDir: string, now: number, deps: ScanDeps = {}): StaleRun[] {
137
+ let names: string[];
138
+ try {
139
+ names = fs.readdirSync(path.join(evolutionDir, "runs"));
140
+ } catch {
141
+ return [];
142
+ }
143
+ const found: StaleRun[] = [];
144
+ for (const name of names) {
145
+ const runDir = path.join(evolutionDir, "runs", name);
146
+ const file = path.join(runDir, "run.json");
147
+ let raw: Record<string, unknown> & StaleTarget;
148
+ try {
149
+ raw = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown> & StaleTarget;
150
+ } catch {
151
+ continue; // run.json 不在或坏掉:没有可判的记录
152
+ }
153
+ const verdict = classifyStale(raw, now, deps);
154
+ if (!verdict.stale || !verdict.reason) continue;
155
+ const reason = verdict.reason;
156
+ // 超时的那类进程还活着:先杀掉,否则下一次扫描会再判一次、再 kill 一次
157
+ if (reason === "timeout" && typeof raw.pid === "number" && raw.pid > 0) {
158
+ try {
159
+ (deps.kill ?? ((pid: number) => process.kill(pid)))(raw.pid);
160
+ } catch (error) {
161
+ deps.log?.(`[skill-evolve] 超时收尾 kill(${raw.pid}) 失败:${String(error)}`);
162
+ }
163
+ }
164
+ const childLogTail = readLogTail(path.join(runDir, CHILD_LOG));
165
+ const error: RunError = { reason, at: now, childLogTail };
166
+ try {
167
+ raw.status = "stale";
168
+ writeRunRecordSync(runDir, raw as unknown as RunRecord);
169
+ fs.writeFileSync(path.join(runDir, "run-error.json"), `${JSON.stringify(error, null, 2)}\n`, "utf8");
170
+ } catch (caused) {
171
+ // 写不进去只记日志:扫描本身不能把面板刷新的调用方搞挂
172
+ deps.log?.(`[skill-evolve] 标记 stale 失败(${name}):${String(caused)}`);
173
+ continue;
174
+ }
175
+ found.push({
176
+ runId: typeof raw.runId === "string" ? raw.runId : name,
177
+ runDir,
178
+ reason,
179
+ lastLine: lastLineOf(childLogTail),
180
+ at: now,
181
+ });
182
+ deps.log?.(`[skill-evolve] run ${name} 判定卡死(${reason})`);
183
+ }
184
+ return found;
185
+ }
186
+
187
+ /** 已经标过 stale 的 run(只读,不重新判定、不写文件):`/skill-evolve status` 用它列失败条目 */
188
+ export function listStaleRuns(evolutionDir: string, limit = 5): StaleRun[] {
189
+ let names: string[];
190
+ try {
191
+ names = fs.readdirSync(path.join(evolutionDir, "runs"));
192
+ } catch {
193
+ return [];
194
+ }
195
+ const found: { run: StaleRun; dispatchedAt: number }[] = [];
196
+ for (const name of names) {
197
+ const runDir = path.join(evolutionDir, "runs", name);
198
+ try {
199
+ const raw = JSON.parse(fs.readFileSync(path.join(runDir, "run.json"), "utf8")) as Record<string, unknown>;
200
+ if (raw.status !== "stale") continue;
201
+ const error = readRunError(runDir);
202
+ found.push({
203
+ run: {
204
+ runId: typeof raw.runId === "string" ? raw.runId : name,
205
+ runDir,
206
+ reason: error?.reason ?? "child-exited",
207
+ lastLine: lastLineOf(error?.childLogTail ?? []),
208
+ at: error?.at ?? 0,
209
+ },
210
+ dispatchedAt: typeof raw.dispatchedAt === "number" ? raw.dispatchedAt : 0,
211
+ });
212
+ } catch {
213
+ continue;
214
+ }
215
+ }
216
+ return found.sort((a, b) => b.dispatchedAt - a.dispatchedAt).slice(0, limit).map((item) => item.run);
217
+ }