pi-skill-evolve 0.0.0-stage → 0.1.0

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 +116 -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,122 @@
1
+ /**
2
+ * 全局单飞闸(用户 2026-11-08:「每次最多一个复盘agent」):
3
+ * 扫 `runs/<runId>/run.json` 的 pid(复盘)与 supervisorPid(督导),alive 为真即占用。
4
+ *
5
+ * 用注入的 alive 模拟存活,不起真进程、不看真 pid。
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import test from "node:test";
12
+ import { findOccupant, guardBusy } from "../lib/single-flight.ts";
13
+ import type { TelemetryInput, TelemetryWriter } from "../lib/telemetry.ts";
14
+
15
+ const NOW = 1_760_000_000_000;
16
+
17
+ function workspace(): string {
18
+ return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-single-"));
19
+ }
20
+
21
+ function writeRun(evolutionDir: string, runId: string, record: Record<string, unknown>): void {
22
+ const runDir = path.join(evolutionDir, "runs", runId);
23
+ fs.mkdirSync(runDir, { recursive: true });
24
+ fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify({ runId, ...record }), "utf8");
25
+ }
26
+
27
+ /** 只有列出来的 pid 算活着 */
28
+ function aliveOnly(...pids: number[]): (pid: number) => boolean {
29
+ return (pid) => pids.includes(pid);
30
+ }
31
+
32
+ function writer(): { events: TelemetryInput[]; sync: TelemetryInput[]; telemetry: TelemetryWriter } {
33
+ const events: TelemetryInput[] = [];
34
+ const sync: TelemetryInput[] = [];
35
+ return {
36
+ events,
37
+ sync,
38
+ telemetry: {
39
+ append: (event) => events.push(event),
40
+ appendSync: (event) => sync.push(event),
41
+ flush: async () => undefined,
42
+ },
43
+ };
44
+ }
45
+
46
+ test("findOccupant:没有 runs 目录 → null", () => {
47
+ assert.equal(findOccupant(workspace()), null);
48
+ });
49
+
50
+ test("findOccupant:run.json 里没有 pid / supervisorPid → null", () => {
51
+ const dir = workspace();
52
+ writeRun(dir, "r1", { status: "dispatched", pid: null });
53
+ assert.equal(findOccupant(dir, { alive: () => true }), null);
54
+ });
55
+
56
+ test("findOccupant:pid 已死 → null(死进程自然释放,不需要谁清场)", () => {
57
+ const dir = workspace();
58
+ writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
59
+ assert.equal(findOccupant(dir, { alive: aliveOnly(9999) }), null);
60
+ });
61
+
62
+ test("findOccupant:复盘子进程活着 → kind=复盘", () => {
63
+ const dir = workspace();
64
+ writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
65
+ assert.deepEqual(findOccupant(dir, { alive: aliveOnly(4242) }), { runId: "r1", pid: 4242, kind: "复盘" });
66
+ });
67
+
68
+ test("findOccupant:复盘 pid 死了但督导活着 → kind=督导", () => {
69
+ const dir = workspace();
70
+ writeRun(dir, "r1", { status: "analyzed", pid: 4242, supervisorPid: 5151, dispatchedAt: NOW });
71
+ assert.deepEqual(findOccupant(dir, { alive: aliveOnly(5151) }), { runId: "r1", pid: 5151, kind: "督导" });
72
+ });
73
+
74
+ test("findOccupant:两条 run 都活着 → 返回最早派发的那条", () => {
75
+ const dir = workspace();
76
+ writeRun(dir, "new", { status: "dispatched", pid: 2, dispatchedAt: NOW + 1000 });
77
+ writeRun(dir, "old", { status: "dispatched", pid: 1, dispatchedAt: NOW });
78
+ assert.deepEqual(findOccupant(dir, { alive: aliveOnly(1, 2) }), { runId: "old", pid: 1, kind: "复盘" });
79
+ });
80
+
81
+ test("findOccupant:ignoreRunId 排除自己那条(督导与自己同属一条 run)", () => {
82
+ const dir = workspace();
83
+ writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
84
+ assert.equal(findOccupant(dir, { alive: aliveOnly(4242), ignoreRunId: "r1" }), null);
85
+ });
86
+
87
+ test("guardBusy:占用时写 skipped(busy) 遥测并返回占用者", () => {
88
+ const dir = workspace();
89
+ writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
90
+ const { events, sync, telemetry } = writer();
91
+ const occupant = guardBusy({
92
+ evolutionDir: dir,
93
+ sessionId: "s1",
94
+ telemetry,
95
+ trigger: "auto",
96
+ rounds: 2,
97
+ alive: aliveOnly(4242),
98
+ });
99
+ assert.deepEqual(occupant, { runId: "r1", pid: 4242, kind: "复盘" });
100
+ assert.deepEqual(events, [
101
+ { kind: "skipped", sessionId: "s1", why: "busy", occupant: { runId: "r1", pid: 4242, kind: "复盘" }, trigger: "auto", rounds: 2 },
102
+ ]);
103
+ assert.deepEqual(sync, [], "非退出路径走异步 append");
104
+ });
105
+
106
+ test("guardBusy:退出补发路径用 appendSync 同步落盘", () => {
107
+ const dir = workspace();
108
+ writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
109
+ const { events, sync, telemetry } = writer();
110
+ guardBusy({ evolutionDir: dir, sessionId: "s1", telemetry, sync: true, trigger: "shutdown", alive: aliveOnly(4242) });
111
+ assert.equal(events.length, 0);
112
+ assert.equal(sync.length, 1);
113
+ assert.equal(sync[0]!.why, "busy");
114
+ });
115
+
116
+ test("guardBusy:空闲时返回 null 且不写遥测", () => {
117
+ const dir = workspace();
118
+ writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
119
+ const { events, telemetry } = writer();
120
+ assert.equal(guardBusy({ evolutionDir: dir, sessionId: "s1", telemetry, alive: aliveOnly(9999) }), null);
121
+ assert.deepEqual(events, []);
122
+ });
@@ -0,0 +1,272 @@
1
+ /**
2
+ * 卡死判定与「失败可见」单测(设计稿 14 章的可观测补丁)。
3
+ *
4
+ * 铁律:全程**不真起 pi / node 子进程**。spawn 一律传假 spawnFn,
5
+ * 只断言它收到的 stdio / 目录,进程由测试自己用 fs 模拟写日志。
6
+ * 依据:2026-11-08 漏注入 spawnFn,一次跑出 51 个孤儿复盘子进程。
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import test from "node:test";
13
+ import { DEFAULT_CONFIG } from "../lib/config.ts";
14
+ import { collectPanelState, panelWidget, statusEntries, statusLine } from "../lib/panel.ts";
15
+ import { renderStatus } from "../lib/report.ts";
16
+ import { SUPERVISOR_LOG, spawnReviewSupervisor } from "../lib/review-spawn.ts";
17
+ import { CHILD_LOG, spawnDetached } from "../lib/spawn-plan.ts";
18
+ import { classifyStale, listStaleRuns, readRunError, scanStaleRuns, STALE_MS } from "../lib/stale.ts";
19
+
20
+ const NOW = Date.UTC(2026, 10, 8, 12);
21
+ const MINUTE = 60_000;
22
+
23
+ function tmpDir(): string {
24
+ return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-stale-"));
25
+ }
26
+
27
+ /** 写一条 run.json;raw 里给了什么就写什么(pid 缺省不写 = dispatch 半路被打断的形态) */
28
+ function writeRun(dir: string, runId: string, raw: Record<string, unknown>): string {
29
+ const runDir = path.join(dir, "runs", runId);
30
+ fs.mkdirSync(runDir, { recursive: true });
31
+ fs.writeFileSync(path.join(runDir, "run.json"), `${JSON.stringify({ runId, sessionId: "s", ...raw }, null, 2)}\n`, "utf8");
32
+ return runDir;
33
+ }
34
+
35
+ function readRun(runDir: string): Record<string, unknown> {
36
+ return JSON.parse(fs.readFileSync(path.join(runDir, "run.json"), "utf8")) as Record<string, unknown>;
37
+ }
38
+
39
+ const deadPid = (): boolean => false;
40
+ const alivePid = (): boolean => true;
41
+
42
+ test("classifyStale:刚派发 10 秒内即使 pid 不在也不算卡死", () => {
43
+ const verdict = classifyStale({ status: "dispatched", pid: 291916, dispatchedAt: NOW - 10_000 }, NOW, { alive: deadPid });
44
+ assert.deepEqual(verdict, { stale: false });
45
+ });
46
+
47
+ test("classifyStale:子进程已死且超过 5 分钟 → child-exited", () => {
48
+ assert.deepEqual(
49
+ classifyStale({ status: "dispatched", pid: 291916, dispatchedAt: NOW - STALE_MS - 1000 }, NOW, { alive: deadPid }),
50
+ { stale: true, reason: "child-exited" },
51
+ );
52
+ assert.deepEqual(classifyStale({ status: "proposed", pid: 1, dispatchedAt: NOW - STALE_MS }, NOW, { alive: deadPid }), {
53
+ stale: false,
54
+ }, "正好 5 分钟不算「超过」");
55
+ assert.deepEqual(
56
+ classifyStale({ status: "analyzed", dispatchedAt: NOW - STALE_MS - 1 }, NOW, { alive: deadPid }),
57
+ { stale: false },
58
+ "analyzed 说明结论已经写盘:子进程退出是正常的,不能报成卡死",
59
+ );
60
+ });
61
+
62
+ test("classifyStale:pid 还活着但超单 run 上限 → timeout", () => {
63
+ assert.deepEqual(
64
+ classifyStale({ status: "dispatched", pid: 11367, dispatchedAt: NOW - DEFAULT_CONFIG.maxRuntimeMs - 1 }, NOW, { alive: alivePid }),
65
+ { stale: true, reason: "timeout" },
66
+ );
67
+ assert.deepEqual(
68
+ classifyStale({ status: "dispatched", pid: 11367, dispatchedAt: NOW - 1000 }, NOW, { alive: alivePid }),
69
+ { stale: false },
70
+ "活着且没超时不能误报",
71
+ );
72
+ });
73
+
74
+ test("classifyStale:已出结论的状态与缺 dispatchedAt 的历史记录都不判", () => {
75
+ for (const status of ["reviewed", "applied", "rolled-back", "stale", "abandoned"]) {
76
+ assert.deepEqual(classifyStale({ status, pid: 1, dispatchedAt: NOW - 10 * 60 * MINUTE }, NOW, { alive: deadPid }), { stale: false });
77
+ }
78
+ assert.deepEqual(classifyStale({ status: "dispatched", pid: 1 }, NOW, { alive: deadPid }), { stale: false });
79
+ });
80
+
81
+ test("spawnDetached:stdout+stderr 指向 logFile,子进程写入能落到文件", () => {
82
+ const dir = tmpDir();
83
+ const logFile = path.join(dir, "runs", "run-1", CHILD_LOG);
84
+ let seen: Record<string, unknown> = {};
85
+ const child = spawnDetached(
86
+ (command, args, options) => {
87
+ seen = { command, args, options };
88
+ const stdio = options.stdio as [string, number, number];
89
+ // 父进程的 fd 此刻还开着:用 fs 模拟子进程写日志(不真起进程)
90
+ fs.writeSync(stdio[1], "子进程崩了\n");
91
+ return { pid: 4242, unref: () => {} };
92
+ },
93
+ "/bin/pi",
94
+ ["-p", "--no-session", "-ns", "prompt"],
95
+ { cwd: dir, env: {}, logFile },
96
+ );
97
+ assert.equal(child.pid, 4242);
98
+ const stdio = seen.options.stdio as [string, number, number];
99
+ assert.deepEqual([stdio[0], stdio[1] === stdio[2], typeof stdio[1]], ["ignore", true, "number"]);
100
+ assert.equal(seen.options.detached, true);
101
+ assert.equal(fs.readFileSync(logFile, "utf8"), "子进程崩了\n");
102
+ });
103
+
104
+ test("spawnDetached:日志文件开不出来时退回 stdio:\"ignore\",不阻断派发", () => {
105
+ let stdio: unknown = null;
106
+ const child = spawnDetached(
107
+ (_command, _args, options) => {
108
+ stdio = options.stdio;
109
+ return { pid: 1, unref: () => {} };
110
+ },
111
+ "/bin/pi",
112
+ [],
113
+ { cwd: "/", env: {}, logFile: "/r/不许写的目录/child.log" },
114
+ );
115
+ assert.equal(child.pid, 1);
116
+ assert.equal(stdio, "ignore");
117
+ });
118
+
119
+ test("督导进程同样落盘:supervisor.log 就在 run 目录里", () => {
120
+ const dir = tmpDir();
121
+ const runDir = path.join(dir, "runs", "run-1");
122
+ fs.mkdirSync(runDir, { recursive: true });
123
+ let stdio: unknown = null;
124
+ spawnReviewSupervisor({
125
+ runDir,
126
+ cwd: dir,
127
+ env: {},
128
+ spawnFn: (_command, _args, options) => {
129
+ stdio = options.stdio;
130
+ return { pid: 7777, unref: () => {} };
131
+ },
132
+ });
133
+ assert.equal(Array.isArray(stdio), true, "写法必须是 fd 数组");
134
+ assert.equal(fs.existsSync(path.join(runDir, SUPERVISOR_LOG)), true);
135
+ });
136
+
137
+ test("scanStaleRuns:子进程已死 → run.json 转 stale + run-error.json 带日志尾部 20 行", () => {
138
+ const dir = tmpDir();
139
+ const runDir = writeRun(dir, "354df7db-1794130855", { status: "dispatched", pid: 291916, dispatchedAt: NOW - 10 * MINUTE });
140
+ const lines = Array.from({ length: 25 }, (_value, index) => `日志第 ${index + 1} 行`);
141
+ fs.writeFileSync(path.join(runDir, CHILD_LOG), `${lines.join("\n")}\n`, "utf8");
142
+
143
+ const found = scanStaleRuns(dir, NOW, { alive: deadPid });
144
+ assert.equal(found.length, 1);
145
+ assert.deepEqual([found[0]!.runId, found[0]!.reason], ["354df7db-1794130855", "child-exited"]);
146
+ assert.equal(found[0]!.lastLine, "日志第 25 行");
147
+ assert.equal(readRun(runDir).status, "stale");
148
+
149
+ const error = readRunError(runDir);
150
+ assert.equal(error?.reason, "child-exited");
151
+ assert.equal(error?.at, NOW);
152
+ assert.equal(error?.childLogTail.length, 20, "只留最后 20 行");
153
+ assert.equal(error?.childLogTail[0], "日志第 6 行");
154
+ assert.equal(error?.childLogTail[19], "日志第 25 行");
155
+ });
156
+
157
+ test("scanStaleRuns:超时的 run 先 kill 再标 stale,重复扫描不重复写(幂等)", () => {
158
+ const dir = tmpDir();
159
+ const runDir = writeRun(dir, "run-timeout", { status: "dispatched", pid: 4321, dispatchedAt: NOW - 30 * MINUTE });
160
+ const killed: number[] = [];
161
+ const found = scanStaleRuns(dir, NOW, { alive: alivePid, kill: (pid) => killed.push(pid), maxRuntimeMs: 20 * MINUTE });
162
+ assert.deepEqual(killed, [4321]);
163
+ assert.equal(found[0]!.reason, "timeout");
164
+ assert.equal(readRun(runDir).status, "stale");
165
+ const before = fs.readFileSync(path.join(runDir, "run-error.json"), "utf8");
166
+ assert.deepEqual(scanStaleRuns(dir, NOW + MINUTE, { alive: alivePid, kill: (pid) => killed.push(pid) }), []);
167
+ assert.deepEqual(killed, [4321], "已标 stale 的不再 kill 第二次");
168
+ assert.equal(fs.readFileSync(path.join(runDir, "run-error.json"), "utf8"), before);
169
+ });
170
+
171
+ test("scanStaleRuns:活着且没超时的 run 一动不动;run.json 坏掉的目录直接跳过", () => {
172
+ const dir = tmpDir();
173
+ const runDir = writeRun(dir, "run-alive", { status: "dispatched", pid: 1, dispatchedAt: NOW - MINUTE });
174
+ const broken = path.join(dir, "runs", "broken");
175
+ fs.mkdirSync(broken, { recursive: true });
176
+ fs.writeFileSync(path.join(broken, "run.json"), "{ 不是 json", "utf8");
177
+ assert.deepEqual(scanStaleRuns(dir, NOW, { alive: alivePid }), []);
178
+ assert.equal(readRun(runDir).status, "dispatched");
179
+ assert.equal(fs.existsSync(path.join(runDir, "run-error.json")), false);
180
+ });
181
+
182
+ test("collectPanelState:reviewing 只数活着的 run,staleCount 覆盖「已落盘 + 判定命中」", () => {
183
+ const dir = tmpDir();
184
+ writeRun(dir, "r-dead", { status: "dispatched", pid: 291916, dispatchedAt: NOW - 10 * MINUTE });
185
+ writeRun(dir, "r-alive", { status: "dispatched", pid: 1, dispatchedAt: NOW - MINUTE });
186
+ writeRun(dir, "r-young", { status: "dispatched", pid: 291917, dispatchedAt: NOW - MINUTE });
187
+ writeRun(dir, "r-stale", { status: "stale", pid: 2, dispatchedAt: NOW - 20 * MINUTE });
188
+ const state = collectPanelState({ evolutionDir: dir, now: NOW, alive: (pid) => pid === 1, maxRuntimeMs: 20 * MINUTE });
189
+ assert.equal(state.reviewing, 2, "r-dead 超期且 pid 不在不再算「复盘」;r-alive 与宽限期内的 r-young 仍算");
190
+ assert.equal(state.staleCount, 2, "r-stale 已落盘 + r-dead 判定命中:两处口径必须一致,否则网页会出现「卡死 1、列表 2 条」");
191
+
192
+ // 5 分钟宽限内即使 pid 不在也还当在跑:与扫描共用同一口径,免得面板在子进程刚退出时闪一下
193
+ const youngDead = collectPanelState({ evolutionDir: dir, now: NOW, alive: deadPid, maxRuntimeMs: 20 * MINUTE });
194
+ assert.equal(youngDead.reviewing, 2, "宽限期内的 r-young 仍算在跑");
195
+ assert.equal(youngDead.staleCount, 2, "宽限期内的死 pid 不算卡死,只有已落盘的 r-stale 与超期的 r-dead");
196
+ });
197
+
198
+ test("collectPanelState:analyzed 的 run 进程死掉不算「复盘」,督导活着才算", () => {
199
+ const dir = tmpDir();
200
+ writeRun(dir, "a-dead", { status: "analyzed", pid: 3001, dispatchedAt: NOW - 2 * MINUTE });
201
+ writeRun(dir, "a-live", { status: "analyzed", pid: 1, dispatchedAt: NOW - 2 * MINUTE });
202
+ writeRun(dir, "a-sup", { status: "analyzed", pid: 3002, supervisorPid: 1, dispatchedAt: NOW - 3 * MINUTE });
203
+ const state = collectPanelState({ evolutionDir: dir, now: NOW, alive: (pid) => pid === 1 });
204
+ assert.equal(state.reviewing, 2, "a-dead 结论已出且进程不在:不再算「复盘」;a-live 与有活督导的 a-sup 算");
205
+ });
206
+
207
+ test("panelWidget / statusLine:卡死压过待批,且不显示「复盘 0」", () => {
208
+ const stale = { reviewing: 0, staleCount: 1, pendingApprovals: 2, lastAppliedAt: null, rollbackRate: 0, appliedSamples: 0 };
209
+ assert.deepEqual(panelWidget(stale), ["复盘失败 1 / 待批 2"]);
210
+ assert.equal(statusLine(stale), "复盘失败 1");
211
+ assert.deepEqual(panelWidget({ ...stale, staleCount: 0, pendingApprovals: 0 }), null, "全零仍然返回 null");
212
+ });
213
+
214
+ test("statusEntries:stale 也会发一条 /tree 条目", () => {
215
+ const entry = statusEntries([{ runId: "354df7db-1794130855", status: "stale" }], new Map(), NOW)[0];
216
+ assert.deepEqual([entry?.kind, entry?.runId], ["stale", "354df7db-1794130855"]);
217
+ });
218
+
219
+ test("renderStatus:卡死单列一行,带原因与日志最后一行", () => {
220
+ const text = renderStatus({
221
+ config: DEFAULT_CONFIG,
222
+ configPath: "/repo/.pi/evolution/config.json",
223
+ telemetryDir: "/repo/.pi/evolution/telemetry",
224
+ lockPid: undefined,
225
+ runs: [],
226
+ staleRuns: [{ runId: "354df7db-1794130855", runDir: "/repo/runs/x", reason: "child-exited", lastLine: "Error: 子进程崩了", at: NOW }],
227
+ });
228
+ assert.equal(text.includes(" 卡死 1 条:354df7db(child-exited,日志最后一行:Error: 子进程崩了)"), true);
229
+ const noLog = renderStatus({
230
+ config: DEFAULT_CONFIG,
231
+ configPath: "/c.json",
232
+ telemetryDir: "/t",
233
+ lockPid: undefined,
234
+ runs: [],
235
+ staleRuns: [{ runId: "abc-1", runDir: "/repo/runs/y", reason: "timeout", lastLine: null, at: NOW }],
236
+ });
237
+ assert.equal(noLog.includes("卡死 1 条:abc(timeout,日志最后一行:无日志(child.log 不存在或为空))"), true);
238
+ });
239
+
240
+ test("renderStatus:同一会话的多条卡死 run 用完整 runId,不写成两行一样的", () => {
241
+ const text = renderStatus({
242
+ config: DEFAULT_CONFIG,
243
+ configPath: "/c.json",
244
+ telemetryDir: "/t",
245
+ lockPid: undefined,
246
+ runs: [],
247
+ staleRuns: [
248
+ { runId: "354df7db-1794130855", runDir: "/r/a", reason: "child-exited", lastLine: null, at: NOW },
249
+ { runId: "354df7db-1794131353", runDir: "/r/b", reason: "child-exited", lastLine: null, at: NOW },
250
+ ],
251
+ });
252
+ assert.equal(text.includes("卡死 2 条:354df7db-1794130855("), true);
253
+ assert.equal(text.includes("354df7db-1794131353("), true);
254
+ });
255
+
256
+ test("listStaleRuns:只列已判 stale 的,按派发时间倒序并带上日志最后一行", () => {
257
+ const dir = tmpDir();
258
+ const older = writeRun(dir, "old-1", { status: "stale", pid: 1, dispatchedAt: NOW - 60 * MINUTE });
259
+ writeRun(dir, "new-2", { status: "stale", pid: 2, dispatchedAt: NOW - 10 * MINUTE });
260
+ writeRun(dir, "live-3", { status: "dispatched", pid: 3, dispatchedAt: NOW - MINUTE });
261
+ // run-error.json 是 stale 条目的唯一权威来源:lastLine 取它里面的 childLogTail 尾部
262
+ fs.writeFileSync(
263
+ path.join(older, "run-error.json"),
264
+ JSON.stringify({ reason: "timeout", at: NOW, childLogTail: ["前一行", "最后一行"] }),
265
+ "utf8",
266
+ );
267
+ scanStaleRuns(dir, NOW, { alive: deadPid });
268
+ const runs = listStaleRuns(dir, 5);
269
+ assert.deepEqual(runs.map((run) => run.runId), ["new-2", "old-1"]);
270
+ assert.equal(runs[0]!.lastLine, null, "没有 run-error / 日志就是 null,不编内容");
271
+ assert.deepEqual([runs[1]!.lastLine, runs[1]!.reason], ["最后一行", "timeout"]);
272
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 遥测单测(零依赖,node --test)。
3
+ *
4
+ * 运行:
5
+ * cd .pi/extensions/skill-evolve
6
+ * node --experimental-strip-types --test test/telemetry.test.ts
7
+ *
8
+ * 覆盖:除零返回 0(绝不返回 NaN)、disabled 技能不算曝光、未命中 top10 排序与截断、
9
+ * 按天分片写入与跨天读取、写入失败只记日志不抛。
10
+ */
11
+ import { test } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+
17
+ import { createTelemetry, dayOf, directQueue, ratio, readEvents, shardFile, summarize, type MutationQueue, type TelemetryEvent, type TelemetrySkill } from "../lib/telemetry.ts";
18
+
19
+ const NOW = new Date(2026, 10, 8, 15, 30, 0).getTime(); // 本地时间 2026-11-08 15:30
20
+
21
+ function tmpDir(): string {
22
+ return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-tele-"));
23
+ }
24
+
25
+ function skill(name: string, disabled = false): TelemetrySkill {
26
+ return { name, filePath: `/repo/.pi/skills/${name}/SKILL.md`, disabled, source: "project" };
27
+ }
28
+
29
+ function advertised(skills: TelemetrySkill[]): TelemetryEvent {
30
+ return { ts: NOW, iso: new Date(NOW).toISOString(), kind: "advertised", sessionId: "s1", skills };
31
+ }
32
+
33
+ test("ratio:除零与非法值一律返回 0", () => {
34
+ assert.equal(ratio(0, 0), 0);
35
+ assert.equal(ratio(3, 0), 0);
36
+ assert.equal(ratio(Number.NaN, 5), 0);
37
+ assert.equal(ratio(5, Number.POSITIVE_INFINITY), 0);
38
+ assert.equal(ratio(2, 4), 0.5);
39
+ });
40
+
41
+ test("summarize:空数据不产生 NaN", () => {
42
+ const summary = summarize([]);
43
+ assert.equal(summary.loadRate, 0);
44
+ assert.equal(Number.isNaN(summary.loadRate), false);
45
+ assert.equal(summary.advertised, 0);
46
+ assert.deepEqual(summary.missedTop, []);
47
+ });
48
+
49
+ test("summarize:disabled 技能不算曝光,加载率按有效曝光算", () => {
50
+ const events: TelemetryEvent[] = [
51
+ advertised([skill("a"), skill("b"), skill("c", true)]),
52
+ { ts: NOW, iso: "", kind: "loaded", sessionId: "s1", skill: "a" },
53
+ { ts: NOW, iso: "", kind: "failed", sessionId: "s1", skill: "b" },
54
+ ];
55
+ const summary = summarize(events);
56
+ assert.equal(summary.rounds, 1);
57
+ assert.equal(summary.advertised, 2);
58
+ assert.equal(summary.loaded, 1);
59
+ assert.equal(summary.failed, 1);
60
+ assert.equal(summary.loadRate, 0.5);
61
+ });
62
+
63
+ test("summarize:未命中 top10 按次数倒序、同名同次按名字排序、最多 10 条", () => {
64
+ const events: TelemetryEvent[] = [{ ts: NOW, iso: "", kind: "missed", sessionId: "s1", skill: "b" }];
65
+ events.push({ ts: NOW, iso: "", kind: "missed", skill: "b" });
66
+ events.push({ ts: NOW, iso: "", kind: "missed", skill: "a" });
67
+ for (let i = 0; i < 12; i += 1) events.push({ ts: NOW, iso: "", kind: "missed", skill: `z${String(i).padStart(2, "0")}` });
68
+ const summary = summarize(events);
69
+ assert.equal(summary.missed, 15);
70
+ assert.equal(summary.missedTop.length, 10);
71
+ assert.deepEqual(summary.missedTop[0], { skill: "b", count: 2 });
72
+ assert.deepEqual(summary.missedTop[1], { skill: "a", count: 1 });
73
+ });
74
+
75
+ test("dayOf / shardFile:按本地日期分片", () => {
76
+ assert.equal(dayOf(NOW), "2026-11-08");
77
+ assert.equal(shardFile("/x/telemetry", NOW), path.join("/x/telemetry", "2026-11-08.jsonl"));
78
+ });
79
+
80
+ test("createTelemetry:append 后 flush,跨天读取能读到今天的行", async () => {
81
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-tel-"));
82
+ const writer = createTelemetry({ dir, queue: directQueue, enabled: true, now: () => NOW });
83
+ // 两次写之间必须 flush:测试里注入的是 directQueue(不串行),真实链路靠 withFileMutationQueue 保证同文件串行
84
+ writer.append({ kind: "advertised", sessionId: "s1", skills: [skill("a")] });
85
+ await writer.flush();
86
+ writer.append({ kind: "loaded", sessionId: "s1", skill: "a", how: "read", ok: true });
87
+ await writer.flush();
88
+
89
+ const file = shardFile(dir, NOW);
90
+ const lines = fs.readFileSync(file, "utf8").trim().split("\n");
91
+ assert.equal(lines.length, 2);
92
+ const first = JSON.parse(lines[0]);
93
+ assert.equal(first.kind, "advertised");
94
+ assert.equal(first.ts, NOW);
95
+ assert.equal(first.iso, new Date(NOW).toISOString());
96
+ assert.equal(summarize(readEvents(dir, 1, NOW)).loaded, 1);
97
+ });
98
+
99
+ test("createTelemetry:enabled=false 时完全不写盘", async () => {
100
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-tel-"));
101
+ const writer = createTelemetry({ dir, queue: directQueue, enabled: false, now: () => NOW });
102
+ writer.append({ kind: "pending", sessionId: "s1" });
103
+ await writer.flush();
104
+ assert.equal(fs.existsSync(shardFile(dir, NOW)), false);
105
+ });
106
+
107
+ test("createTelemetry:写失败只记日志,不抛到调用方", async () => {
108
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-tel-"));
109
+ const logs: string[] = [];
110
+ const failing: MutationQueue = async () => {
111
+ throw new Error("磁盘满了");
112
+ };
113
+ const writer = createTelemetry({ dir, queue: failing, enabled: true, now: () => NOW, log: (msg) => logs.push(msg) });
114
+ writer.append({ kind: "loaded", sessionId: "s1", skill: "a" });
115
+ await writer.flush();
116
+ assert.equal(logs.some((line) => line.includes("遥测写入失败")), true);
117
+ });
118
+
119
+ test("readEvents:目录不存在、天数非法、坏行都安全", () => {
120
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-tel-"));
121
+ fs.writeFileSync(shardFile(dir, NOW), '{"kind":"loaded","ts":1}\n不是 JSON\n\n', "utf8");
122
+ assert.equal(readEvents(dir, 1, NOW).length, 1);
123
+ assert.deepEqual(readEvents(path.join(dir, "不存在"), 1, NOW), []);
124
+ assert.equal(readEvents(dir, 0, NOW).length, 1, "days<=0 回退 7 天应能读到今天的分片");
125
+ });
126
+
127
+ test("appendSync:同步落盘(退出前补发路径,不能等异步队列)", () => {
128
+ const dir = tmpDir();
129
+ const telemetry = createTelemetry({ dir, queue: directQueue, enabled: true, log: () => {}, now: () => NOW });
130
+ telemetry.appendSync({ kind: "dispatched", sessionId: "s1", runId: "r1", pid: 42, trigger: "shutdown", ts: NOW });
131
+ const file = shardFile(dir, NOW);
132
+ assert.equal(fs.existsSync(file), true);
133
+ assert.equal(fs.readFileSync(file, "utf8").includes("\"trigger\":\"shutdown\""), true);
134
+ });
135
+
136
+ test("appendSync:目录不可写时只记日志,不抛", () => {
137
+ const dir = tmpDir();
138
+ const logs: string[] = [];
139
+ // 用一个「父路径是文件」的目录制造必然失败的 mkdir
140
+ const blocked = path.join(dir, "not-a-dir");
141
+ fs.writeFileSync(blocked, "x");
142
+ const telemetry = createTelemetry({ dir: path.join(blocked, "telemetry"), queue: directQueue, enabled: true, log: (msg) => logs.push(msg), now: () => NOW });
143
+ assert.doesNotThrow(() => telemetry.appendSync({ kind: "skipped", sessionId: "s1", why: "lock", trigger: "shutdown" }));
144
+ assert.equal(logs.some((line) => line.includes("遥测写入失败")), true);
145
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * TUI 闸(用户 2026-11-08:「tui 的这些推送不看了,影响我」):
3
+ * `ui.widget` / `ui.entries` 为 false 时一次都不碰宿主 UI;显式打开才各写一次。
4
+ *
5
+ * 宿主用记录型假对象,不依赖 pi 运行时。
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import test from "node:test";
9
+ import type { UiConfig } from "../lib/config.ts";
10
+ import type { PanelEntry, PanelState } from "../lib/panel.ts";
11
+ import { PANEL_ID, renderPanel, writeEntry, type UiPanelHost } from "../lib/ui-gate.ts";
12
+
13
+ const OFF: UiConfig = { widget: false, entries: false };
14
+ const ON: UiConfig = { widget: true, entries: true };
15
+
16
+ function busyState(): PanelState {
17
+ return { reviewing: 1, staleCount: 1, pendingApprovals: 2, lastAppliedAt: null, rollbackRate: 0, appliedSamples: 0 };
18
+ }
19
+
20
+ interface Calls {
21
+ host: UiPanelHost;
22
+ widgets: (string[] | undefined)[];
23
+ statuses: (string | undefined)[];
24
+ }
25
+
26
+ function fakeHost(): Calls {
27
+ const widgets: (string[] | undefined)[] = [];
28
+ const statuses: (string | undefined)[] = [];
29
+ return {
30
+ widgets,
31
+ statuses,
32
+ host: {
33
+ setWidget: (id, content) => {
34
+ assert.equal(id, PANEL_ID);
35
+ widgets.push(content);
36
+ },
37
+ setStatus: (id, content) => {
38
+ assert.equal(id, PANEL_ID);
39
+ statuses.push(content);
40
+ },
41
+ },
42
+ };
43
+ }
44
+
45
+ test("renderPanel:ui.widget=false 时一次都不调 setWidget / setStatus", () => {
46
+ const calls = fakeHost();
47
+ renderPanel(calls.host, OFF, busyState());
48
+ assert.deepEqual(calls.widgets, []);
49
+ assert.deepEqual(calls.statuses, []);
50
+ });
51
+
52
+ test("renderPanel:ui.widget=true 时面板与状态栏各写一次", () => {
53
+ const calls = fakeHost();
54
+ renderPanel(calls.host, ON, busyState());
55
+ assert.equal(calls.widgets.length, 1);
56
+ assert.equal(calls.statuses.length, 1);
57
+ assert.ok((calls.widgets[0] ?? []).length > 0, "有数据时面板不该是空的");
58
+ assert.equal(typeof calls.statuses[0], "string");
59
+ });
60
+
61
+ test("writeEntry:ui.entries=false 时不写 /tree 条目,返回 false", () => {
62
+ const written: PanelEntry[] = [];
63
+ const ok = writeEntry<PanelEntry>(OFF, (entry) => written.push(entry), {
64
+ kind: "reviewed",
65
+ runId: "r1",
66
+ at: 1,
67
+ });
68
+ assert.equal(ok, false);
69
+ assert.deepEqual(written, []);
70
+ });
71
+
72
+ test("writeEntry:ui.entries=true 时写一次并返回 true", () => {
73
+ const written: PanelEntry[] = [];
74
+ const entry: PanelEntry = { kind: "dispatched", runId: "r1", at: 1 };
75
+ assert.equal(writeEntry<PanelEntry>(ON, (item) => written.push(item), entry), true);
76
+ assert.deepEqual(written, [entry]);
77
+ });