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,306 @@
1
+ /**
2
+ * 派发单测(零依赖,node --test)。
3
+ *
4
+ * 运行:
5
+ * cd .pi/extensions/skill-evolve
6
+ * node --experimental-strip-types --test test/dispatch.test.ts
7
+ *
8
+ * 覆盖:pi 路径解析优先级、runId 形态、去抖判据、run.json 落盘与状态机、
9
+ * 抢锁失败/开关关闭/单会话上限三条跳过路径、「无会话原文降级 telemetry-only」、
10
+ * 以及本轮重点:**去抖把尾巴整段带上的三条路径**(窗口过后合并派发 / 退出补发 / 手动绕过去抖)。
11
+ */
12
+ import { test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { DEFAULT_CONFIG, type EvolutionConfig } from "../lib/config.ts";
20
+ import { createDispatcher, shouldDebounce } from "../lib/dispatch.ts";
21
+ import { acquireLock, releaseLock } from "../lib/lock.ts";
22
+ import { buildPrompt } from "../lib/prompt.ts";
23
+ import { DEFAULT_PI_BIN, resolvePiBin, runIdFor, sessionHashOf, type SpawnedChild, type SpawnLike } from "../lib/spawn-plan.ts";
24
+ import { directQueue, type TelemetryEvent, type TelemetryInput, type TelemetryWriter } from "../lib/telemetry.ts";
25
+ import { fakeSupervisorSpawn } from "./fake-review-spawn.ts";
26
+
27
+ const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "prompts", "复盘.md");
28
+ const NOW = 1_760_000_000_000;
29
+ const MAX = 20 * 60_000;
30
+
31
+ class FakeChild {
32
+ readonly pid: number;
33
+ private listeners = new Map<string, (arg: unknown) => void>();
34
+ // 锁回收用 process.kill(pid, 0) 校验存活:拿一个乱编的 pid 会被当成死进程、锁被当成孤儿回收,
35
+ // 所以假子进程统一用真实存活的 pid(就是本进程)模拟
36
+ constructor() {
37
+ this.pid = process.pid;
38
+ }
39
+ unref(): void {
40
+ // detached 子进程交还给 init,测试里无事可做
41
+ }
42
+ once(event: string, listener: (arg: unknown) => void): this {
43
+ this.listeners.set(event, listener);
44
+ return this;
45
+ }
46
+ emit(event: string, arg: unknown): void {
47
+ this.listeners.get(event)?.(arg);
48
+ }
49
+ }
50
+
51
+ interface Harness {
52
+ dir: string;
53
+ calls: { command: string; args: string[]; options: Record<string, unknown> }[];
54
+ children: FakeChild[];
55
+ supervisorCalls: { args: string[]; options: Record<string, unknown> }[];
56
+ events: TelemetryEvent[];
57
+ tick: (ms: number) => void;
58
+ dispatch: ReturnType<typeof createDispatcher>["dispatch"];
59
+ flushPending: ReturnType<typeof createDispatcher>["flushPending"];
60
+ config: EvolutionConfig;
61
+ }
62
+
63
+ function harness(tag: string, overrides: Partial<EvolutionConfig> = {}): Harness {
64
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `skill-evolve-${tag}-`));
65
+ const calls: Harness["calls"] = [];
66
+ const children: FakeChild[] = [];
67
+ const events: TelemetryEvent[] = [];
68
+ const spawnFn: SpawnLike = (command, args, options) => {
69
+ calls.push({ command, args, options });
70
+ const child = new FakeChild();
71
+ children.push(child);
72
+ return child as unknown as SpawnedChild;
73
+ };
74
+ const pushEvent = (event: TelemetryInput): void => {
75
+ events.push({ ...event, ts: NOW, iso: "" } as TelemetryEvent);
76
+ };
77
+ const writer: TelemetryWriter = { append: pushEvent, appendSync: pushEvent, flush: async () => undefined };
78
+ // 生产默认全关(clone 即静默):夹具必须显式打开,否则每个用例都会被「未启用」静默跳过
79
+ const config: EvolutionConfig = { ...DEFAULT_CONFIG, enabled: true, telemetry: true, ...overrides };
80
+ const supervisorCalls: { args: string[]; options: Record<string, unknown> }[] = []; // 假督导只记参数:真起 node 会留 900 秒后台进程
81
+ let clock = NOW;
82
+ const dispatcher = createDispatcher({
83
+ evolutionDir: path.join(dir, ".pi", "evolution"),
84
+ cwd: dir,
85
+ config,
86
+ telemetry: writer,
87
+ queue: directQueue,
88
+ promptTemplatePath: TEMPLATE,
89
+ log: () => {},
90
+ now: () => clock,
91
+ env: {},
92
+ spawnFn,
93
+ // 假子进程 pid = 本进程 pid(锁回收靠真 pid 判活);单飞闸用注入的 alive 模拟「已退出」
94
+ alive: () => false,
95
+ supervisorSpawnFn: fakeSupervisorSpawn(supervisorCalls),
96
+ });
97
+ return {
98
+ dir,
99
+ calls,
100
+ supervisorCalls,
101
+ children,
102
+ events,
103
+ tick: (ms) => { clock += ms; },
104
+ dispatch: dispatcher.dispatch,
105
+ flushPending: dispatcher.flushPending,
106
+ config,
107
+ };
108
+ }
109
+
110
+ function readRun(dir: string, runId: string): Record<string, unknown> {
111
+ return JSON.parse(fs.readFileSync(path.join(dir, ".pi", "evolution", "runs", runId, "run.json"), "utf8"));
112
+ }
113
+
114
+ const settleAsync = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 120));
115
+
116
+ /** 模拟子进程正常跑完:收尸会顺手放锁,后面的派发才抢得到 */
117
+ async function childExits(h: Harness, index: number): Promise<void> {
118
+ h.children[index].emit("exit", 0);
119
+ await settleAsync();
120
+ }
121
+
122
+ const argsOf = (h: Harness, index = 0): string => (h.calls.length > index ? h.calls[index].args.join("\n") : "");
123
+
124
+ test("纯函数:pi 路径优先级、runId 形态、去抖判据", () => {
125
+ assert.equal(resolvePiBin({ SKILL_EVOLVE_PI_BIN: "/a/pi", PI_SUBAGENT_PI_BINARY: "/b/pi" }), "/a/pi");
126
+ assert.equal(resolvePiBin({ PI_SUBAGENT_PI_BINARY: "/b/pi" }), "/b/pi");
127
+ assert.equal(resolvePiBin({}), DEFAULT_PI_BIN);
128
+ assert.equal(runIdFor("sess-1", NOW), `${sessionHashOf("sess-1").slice(0, 8)}-${Math.floor(NOW / 1000)}`);
129
+ assert.notEqual(runIdFor("sess-1", NOW), runIdFor("sess-2", NOW));
130
+ assert.equal(shouldDebounce(undefined, NOW, 90_000), false);
131
+ assert.equal(shouldDebounce(NOW - 1000, NOW, 90_000), true);
132
+ assert.equal(shouldDebounce(NOW - 90_000, NOW, 90_000), false);
133
+ });
134
+
135
+ test("buildPrompt:带会话原文/索引/run 目录,并写清覆盖轮数", () => {
136
+ const full = buildPrompt({ template: "模板", sessionFile: "/s.jsonl", indexFile: "/i.json", runDir: "/r", sessionId: "s1", skills: ["a", "b"], dispatchedAt: NOW });
137
+ assert.equal(full.includes("会话原文:/s.jsonl"), true);
138
+ assert.equal(full.includes("技能清单(逐个判定,不许遗漏也不许新增):a、b"), true);
139
+ assert.equal(full.includes("覆盖轮数:1 轮"), true);
140
+ const merged = buildPrompt({ template: "模板", indexFile: "/i.json", runDir: "/r", sessionId: "s1", skills: [], dispatchedAt: NOW, coverage: { rounds: 3, since: NOW } });
141
+ assert.equal(merged.includes("合并了 3 轮"), true);
142
+ assert.equal(merged.includes("telemetry-only"), true);
143
+ assert.equal(merged.includes("(本轮无曝光技能)"), true);
144
+ });
145
+
146
+ test("dispatch 成功:spawn 参数、env、run.json、会话索引都到位", async () => {
147
+ const h = harness("ok");
148
+ const sessionFile = path.join(h.dir, "session.jsonl");
149
+ fs.writeFileSync(sessionFile, `${JSON.stringify({ type: "session", id: "sess-1", cwd: h.dir })}\n`, "utf8");
150
+
151
+ const outcome = await h.dispatch({ sessionId: "sess-1", skills: ["a"], sessionFile });
152
+ assert.equal(outcome.status, "dispatched");
153
+ if (outcome.status !== "dispatched") return;
154
+ const { command, args, options } = h.calls[0];
155
+ assert.equal(command, DEFAULT_PI_BIN);
156
+ assert.deepEqual(args.slice(0, 3), ["-p", "--no-session", "-ns"]);
157
+ assert.equal(args[3].includes(sessionFile), true);
158
+ assert.equal(options.detached, true);
159
+ assert.equal((options.env as NodeJS.ProcessEnv).SKILL_EVOLVE_CHILD, "1");
160
+ // stdout+stderr 必须落到 run 目录下的 child.log:子进程静默死掉时这是唯一的现场
161
+ const stdio = options.stdio as [string, number, number];
162
+ assert.deepEqual([stdio[0], stdio[1] === stdio[2], typeof stdio[1]], ["ignore", true, "number"]);
163
+ const childLog = path.join(h.dir, ".pi", "evolution", "runs", outcome.run.runId, "child.log");
164
+ assert.equal(fs.existsSync(childLog), true, "child.log 必须先建出来(append 模式)");
165
+
166
+ const record = readRun(h.dir, outcome.run.runId);
167
+ assert.deepEqual([record.status, record.trigger, record.evidenceMode, record.pid], ["dispatched", "auto", "session+telemetry", process.pid]);
168
+ assert.deepEqual(record.skills, ["a"]);
169
+ assert.equal(fs.existsSync(path.join(h.dir, ".pi", "evolution", "runs", outcome.run.runId, "00-session", "index.json")), true);
170
+ assert.equal(h.events.some((event) => event.kind === "dispatched"), true);
171
+ await releaseLock(path.join(h.dir, ".pi", "evolution"));
172
+ });
173
+
174
+ test("dispatch 去抖:窗口内第二次写 superseded 并记 pending,不再起进程", async () => {
175
+ const h = harness("debounce");
176
+ assert.equal((await h.dispatch({ sessionId: "sess-1", skills: ["a"] })).status, "dispatched");
177
+ h.tick(1000);
178
+ const second = await h.dispatch({ sessionId: "sess-1", skills: ["b"] });
179
+ assert.deepEqual(second, { status: "superseded", rounds: 1 });
180
+ assert.equal(h.calls.length, 1);
181
+ assert.equal(h.events.filter((event) => event.kind === "superseded").length, 1);
182
+ await releaseLock(path.join(h.dir, ".pi", "evolution"));
183
+ });
184
+
185
+ test("去抖尾巴:3 轮 → 1 次派发 + 2 次 superseded,退出时补发一次补上后 2 轮", async () => {
186
+ const h = harness("tail");
187
+ assert.equal((await h.dispatch({ sessionId: "s1", skills: ["a"] })).status, "dispatched");
188
+ await childExits(h, 0); // 第一轮的子进程跑完并放锁
189
+
190
+ h.tick(1000);
191
+ assert.deepEqual(await h.dispatch({ sessionId: "s1", skills: ["b"] }), { status: "superseded", rounds: 1 });
192
+ h.tick(1000);
193
+ assert.deepEqual(await h.dispatch({ sessionId: "s1", skills: ["c"] }), { status: "superseded", rounds: 2 });
194
+ assert.equal(h.calls.length, 1);
195
+
196
+ assert.equal(h.flushPending(), 1);
197
+ assert.equal(h.calls.length, 2);
198
+ // 第一轮已经被第一次派发覆盖,补发只盖后面 2 轮
199
+ assert.equal(argsOf(h, 1).includes("合并了 2 轮"), true);
200
+ const records = fs.readdirSync(path.join(h.dir, ".pi", "evolution", "runs"));
201
+ const last = records.map((runId) => readRun(h.dir, runId)).find((record) => record.trigger === "shutdown");
202
+ assert.ok(last);
203
+ assert.deepEqual(last.skills, ["b", "c"]);
204
+ assert.deepEqual((last.coverage as { rounds: number }).rounds, 2);
205
+ assert.equal(h.events.some((event) => event.kind === "dispatched" && event.trigger === "shutdown"), true);
206
+ // 已经补发过,再调一次不会重复起进程
207
+ assert.equal(h.flushPending(), 0);
208
+ });
209
+
210
+ test("去抖尾巴:静默窗口过后的一轮正常派发,并合并在途的 2 轮", async () => {
211
+ const h = harness("window");
212
+ await h.dispatch({ sessionId: "s1", skills: ["a"] });
213
+ await childExits(h, 0);
214
+ h.tick(1000);
215
+ await h.dispatch({ sessionId: "s1", skills: ["b"] });
216
+ h.tick(1000);
217
+ await h.dispatch({ sessionId: "s1", skills: ["c"] });
218
+ assert.equal(h.calls.length, 1);
219
+
220
+ h.tick(DEFAULT_CONFIG.idleMs + 1);
221
+ const outcome = await h.dispatch({ sessionId: "s1", skills: ["d"] });
222
+ assert.equal(outcome.status, "dispatched");
223
+ assert.equal(h.calls.length, 2);
224
+ assert.equal(argsOf(h, 1).includes("合并了 3 轮"), true);
225
+ await releaseLock(path.join(h.dir, ".pi", "evolution"));
226
+ });
227
+
228
+ test("退出补发:锁还被别人占着时写 skipped 且不起进程", async () => {
229
+ const h = harness("flush-locked");
230
+ await h.dispatch({ sessionId: "s1", skills: ["a"] });
231
+ h.tick(1000);
232
+ await h.dispatch({ sessionId: "s1", skills: ["b"] });
233
+ // 第一轮的子进程没退出 → 锁仍被自己持有,补发必须让位
234
+ assert.equal(h.flushPending(), 0);
235
+ assert.equal(h.calls.length, 1);
236
+ assert.equal(h.events.some((event) => event.kind === "skipped" && event.trigger === "shutdown"), true);
237
+ });
238
+
239
+ test("dispatch 跳过:开关关闭、单会话上限、锁被占用", async () => {
240
+ const off = harness("disabled", { enabled: false });
241
+ assert.deepEqual(await off.dispatch({ sessionId: "s1", skills: ["a"] }), { status: "skipped", why: "disabled", ownerPid: null });
242
+ assert.equal(off.calls.length, 0);
243
+
244
+ const capped = harness("cap", { perSessionCap: 1, idleMs: 0 });
245
+ assert.equal((await capped.dispatch({ sessionId: "s1", skills: ["a"] })).status, "dispatched");
246
+ await childExits(capped, 0);
247
+ capped.tick(10);
248
+ assert.deepEqual(await capped.dispatch({ sessionId: "s1", skills: ["a"] }), { status: "skipped", why: "cap", ownerPid: null });
249
+ assert.equal(capped.calls.length, 1);
250
+
251
+ const locked = harness("locked");
252
+ const evolutionDir = path.join(locked.dir, ".pi", "evolution");
253
+ const held = await acquireLock({ evolutionDir, owner: { pid: process.pid, ts: NOW, sessionId: "other" }, maxRuntimeMs: MAX, now: () => NOW });
254
+ assert.equal(held.ok, true);
255
+ const outcome = await locked.dispatch({ sessionId: "s1", skills: ["a"] });
256
+ assert.equal(outcome.status, "skipped");
257
+ assert.equal(outcome.status === "skipped" ? outcome.why : "", "lock");
258
+ assert.equal(locked.calls.length, 0);
259
+ await releaseLock(evolutionDir);
260
+ });
261
+
262
+ test("手动 run:跳过去抖与单会话上限,但仍守全局锁", async () => {
263
+ const h = harness("manual", { perSessionCap: 1, idleMs: 0 });
264
+ assert.equal((await h.dispatch({ sessionId: "s1", skills: ["a"] })).status, "dispatched");
265
+ await childExits(h, 0);
266
+
267
+ // 同会话第二次自动派发会被 cap 挡下
268
+ h.tick(10);
269
+ assert.equal((await h.dispatch({ sessionId: "s1", skills: ["a"] })).status, "skipped");
270
+
271
+ const manual = await h.dispatch({ sessionId: "s1", skills: ["a"] }, { bypassThrottle: true });
272
+ assert.equal(manual.status, "dispatched");
273
+ assert.equal(h.calls.length, 2);
274
+ const records = fs.readdirSync(path.join(h.dir, ".pi", "evolution", "runs")).map((runId) => readRun(h.dir, runId));
275
+ assert.equal(records.some((record) => record.trigger === "manual"), true);
276
+
277
+ // 子进程还在跑(锁未释放)时,手动派发也必须让位
278
+ h.tick(10);
279
+ const blocked = await h.dispatch({ sessionId: "s1", skills: ["a"] }, { bypassThrottle: true });
280
+ assert.equal(blocked.status, "skipped");
281
+ assert.equal(blocked.status === "skipped" ? blocked.ownerPid : 0, process.pid);
282
+ });
283
+
284
+ test("dispatch 无会话原文:降级 telemetry-only 且不产索引", async () => {
285
+ const h = harness("degraded");
286
+ const outcome = await h.dispatch({ sessionId: "s1", skills: ["a"] });
287
+ if (outcome.status !== "dispatched") return assert.fail("应当派发成功");
288
+ const record = readRun(h.dir, outcome.run.runId);
289
+ assert.deepEqual([record.evidenceMode, record.artifacts], ["telemetry-only", {}]);
290
+ assert.equal(argsOf(h).includes("telemetry-only"), true);
291
+ // 没有索引就明说不可用,别让子进程去读一个不存在的 index.json
292
+ assert.equal(argsOf(h).includes("索引:不可用(evidenceMode=telemetry-only"), true);
293
+ await releaseLock(path.join(h.dir, ".pi", "evolution"));
294
+ });
295
+
296
+ test("dispatch 收尸:子进程非零退出 → run.json 标 abandoned 并放锁", async () => {
297
+ const h = harness("abandon");
298
+ const outcome = await h.dispatch({ sessionId: "s1", skills: ["a"] });
299
+ if (outcome.status !== "dispatched") return assert.fail("应当派发成功");
300
+ h.children[0].emit("exit", 3);
301
+ await settleAsync();
302
+ const record = readRun(h.dir, outcome.run.runId);
303
+ assert.equal(record.status, "abandoned");
304
+ assert.equal(String(record.abandonedReason).includes("exit 3"), true);
305
+ assert.equal(fs.existsSync(path.join(h.dir, ".pi", "evolution", "locks", "dispatch")), false);
306
+ });