pi-skill-evolve 0.1.4 → 0.1.5

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 (31) hide show
  1. package/extensions/skill-evolve/lib/aggregate.ts +153 -0
  2. package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
  3. package/extensions/skill-evolve/lib/dispatch.ts +32 -52
  4. package/extensions/skill-evolve/lib/evolution.ts +1 -0
  5. package/extensions/skill-evolve/lib/report.ts +4 -1
  6. package/extensions/skill-evolve/lib/runs.ts +35 -2
  7. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
  8. package/extensions/skill-evolve/lib/telemetry.ts +2 -0
  9. package/extensions/skill-evolve/lib/web/applied.ts +4 -0
  10. package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
  11. package/extensions/skill-evolve/lib/web/data.ts +13 -226
  12. package/extensions/skill-evolve/lib/web/done.ts +65 -0
  13. package/extensions/skill-evolve/lib/web/filter.ts +4 -4
  14. package/extensions/skill-evolve/lib/web/pending.ts +191 -0
  15. package/extensions/skill-evolve/lib/web/render-rows.ts +37 -6
  16. package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
  17. package/extensions/skill-evolve/lib/web/render-session.ts +67 -0
  18. package/extensions/skill-evolve/lib/web/reports.ts +11 -5
  19. package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
  20. package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
  21. package/extensions/skill-evolve/lib/web/server.ts +13 -1
  22. package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
  23. package/extensions/skill-evolve/lib/web/session-view.ts +260 -0
  24. package/extensions/skill-evolve/lib/web/style.ts +17 -0
  25. package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
  26. package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
  27. package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
  28. package/extensions/skill-evolve/test/session-page.test.ts +194 -0
  29. package/extensions/skill-evolve/test/session-view.test.ts +192 -0
  30. package/extensions/skill-evolve/test/web-session-column.test.ts +141 -0
  31. package/package.json +1 -1
@@ -0,0 +1,260 @@
1
+ /**
2
+ * 会话详情页的**纯函数**:把 pi 的会话 JSONL 解析成「可渲染的块」,再拼成 HTML 片段。
3
+ *
4
+ * 为什么要这一页(用户 2026-11-08:「支持打开会话的详情, 工具调用可收起展开, 默认收起」):
5
+ * 面板上每条 run 都关联一段 pi 会话,出问题时要看的是「那段对话里到底发生了什么」——
6
+ * 而 sessions/*.jsonl 有几 MB、几千行,在编辑器里翻根本对不上哪句是哪轮。
7
+ *
8
+ * 结构口径(与 pi 的真实写入一致):
9
+ * - 只取顶层 `type === "message"` 的条目,其余(session / model_change / thinking_level_change / custom)全跳过;
10
+ * - `message.role` = user / assistant / toolResult / system;system 不渲染(它是系统提示词,不是对话);
11
+ * - assistant 的内容里 `text` 是给人看的、`thinking` 不渲染、`toolCall` 聚成折叠块;
12
+ * - toolResult 消息按 `toolCallId` 回连到那次调用(结果贴在该调用的参数之后)。
13
+ *
14
+ * 三条上限(会话可能很大,页面不能被一段对话撑爆浏览器):
15
+ * 只渲染最后 200 条消息 · 单条 4000 字 · 用户原文 2000 字 · 总输出超 2MB 就停手并指路原文件。
16
+ *
17
+ * 这个文件里**没有一行 IO**:入参是字符串,出参是 HTML 字符串,所以能直接拿临时 JSONL 片段做单测。
18
+ */
19
+ import { escapeHtml } from "./text.ts";
20
+
21
+ /** 只渲染最后多少条 message(更早的对话对排查当前问题几乎无用) */
22
+ export const MAX_MESSAGES = 200;
23
+ /** 单条消息正文上限 */
24
+ export const MAX_TEXT = 4000;
25
+ /** 用户消息正文上限:用户要的是「原文」,所以给得比 assistant 宽,但仍要防一次粘贴几万字 */
26
+ export const MAX_USER_TEXT = 2000;
27
+ /** 一次工具调用的参数摘要上限 */
28
+ export const MAX_ARGS = 400;
29
+ /** 一次工具调用的结果上限 */
30
+ export const MAX_RESULT = 200;
31
+ /** 整页会话正文的输出上限(字符数,UTF-8 下中文 3 字节,这里按保守值算) */
32
+ export const MAX_OUTPUT = 2 * 1024 * 1024;
33
+
34
+ export interface SessionToolCall {
35
+ name: string;
36
+ /** 参数摘要(JSON 压成一行后截断) */
37
+ args: string;
38
+ /** 结果前 200 字(没有结果就是「(没有结果)」) */
39
+ result: string;
40
+ }
41
+
42
+ export type SessionEntry =
43
+ | { kind: "user"; text: string }
44
+ | { kind: "assistant"; text: string }
45
+ | { kind: "tools"; calls: SessionToolCall[] };
46
+
47
+ /**
48
+ * 解析中途的调用记录:结果(toolResult)总是排在携带 toolCall 的 assistant 消息**之后**,
49
+ * 所以先带 id 收进来,全文扫完再回填结果 —— 边扫边取会永远取不到(那时结果还没出现)。
50
+ */
51
+ interface RawCall {
52
+ id: string;
53
+ name: string;
54
+ args: string;
55
+ }
56
+
57
+ export interface SessionTranscript {
58
+ entries: SessionEntry[];
59
+ /** 解析到的 message 总条数(不限 200) */
60
+ messages: number;
61
+ /** 工具调用总次数 */
62
+ toolCalls: number;
63
+ /** 因为只留最后 200 条而丢掉的消息条数 */
64
+ dropped: number;
65
+ }
66
+
67
+ /** 截断并加省略号:中文一行话也可能超长,所以在字符数上截,不在行上截 */
68
+ function clip(text: string, max: number): string {
69
+ const trimmed = text.trim();
70
+ return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
71
+ }
72
+
73
+ /** 参数压成一行 JSON:多行缩进在面板里只会占满屏 */
74
+ function argumentsText(value: unknown): string {
75
+ let text: string;
76
+ if (typeof value === "string") {
77
+ text = value;
78
+ } else {
79
+ try {
80
+ text = JSON.stringify(value) ?? "";
81
+ } catch {
82
+ text = String(value);
83
+ }
84
+ }
85
+ return clip(text.replace(/\s+/g, " "), MAX_ARGS);
86
+ }
87
+
88
+ /** toolResult / user 的 content:可能是字符串,也可能是 [{type:"text",text}],图片只留一句占位 */
89
+ function contentText(content: unknown): string {
90
+ if (typeof content === "string") return content;
91
+ if (!Array.isArray(content)) return "";
92
+ const parts: string[] = [];
93
+ for (const part of content) {
94
+ if (typeof part !== "object" || part === null) continue;
95
+ const record = part as Record<string, unknown>;
96
+ if (record.type === "text" && typeof record.text === "string") parts.push(record.text);
97
+ else if (record.type === "image") parts.push("(图片输出)");
98
+ }
99
+ return parts.join("\n");
100
+ }
101
+
102
+ /** 工具结果:只留前 200 字(完整输出在原理行数里,面板里看首尾就够判断成败) */
103
+ function resultText(content: unknown): string {
104
+ return clip(contentText(content), MAX_RESULT);
105
+ }
106
+
107
+ /** assistant 内容块:文本拼接、toolCall 收集;thinking 不渲染(它是推理过程,不是回答) */
108
+ function readAssistantParts(content: unknown): { text: string; calls: RawCall[] } {
109
+ if (typeof content === "string") return { text: content, calls: [] };
110
+ if (!Array.isArray(content)) return { text: "", calls: [] };
111
+ const texts: string[] = [];
112
+ const calls: RawCall[] = [];
113
+ for (const part of content) {
114
+ if (typeof part !== "object" || part === null) continue;
115
+ const record = part as Record<string, unknown>;
116
+ if (record.type === "text" && typeof record.text === "string") texts.push(record.text);
117
+ else if (record.type === "toolCall") {
118
+ calls.push({
119
+ id: typeof record.id === "string" ? record.id : "",
120
+ name: typeof record.name === "string" ? record.name : "(未命名工具)",
121
+ args: argumentsText(record.arguments),
122
+ });
123
+ }
124
+ }
125
+ return { text: texts.join("\n"), calls };
126
+ }
127
+
128
+ /** 解析整份 JSONL:坏行跳过(可能是进程被 kill 时写了一半的最后一行) */
129
+ export function parseSessionText(raw: string): SessionTranscript {
130
+ type Pending = { entry: SessionEntry | { kind: "tools"; calls: RawCall[] }; roles: string[] };
131
+ const kept: Pending[] = [];
132
+ const results = new Map<string, string>();
133
+ let messages = 0;
134
+ for (const line of raw.split(/\r?\n/)) {
135
+ if (line.trim().length === 0) continue;
136
+ let record: Record<string, unknown>;
137
+ try {
138
+ record = JSON.parse(line) as Record<string, unknown>;
139
+ } catch {
140
+ continue;
141
+ }
142
+ if (record.type !== "message") continue;
143
+ const message = record.message;
144
+ if (typeof message !== "object" || message === null) continue;
145
+ const fields = message as Record<string, unknown>;
146
+ const role = typeof fields.role === "string" ? fields.role : "";
147
+ messages += 1;
148
+ if (role === "toolResult") {
149
+ // 结果先存起来:它总是排在携带 toolCall 的那条 assistant 消息之后
150
+ const id = typeof fields.toolCallId === "string" ? fields.toolCallId : "";
151
+ if (id.length > 0) results.set(id, resultText(fields.content));
152
+ continue;
153
+ }
154
+ if (role === "user") {
155
+ kept.push({ entry: { kind: "user", text: contentText(fields.content) }, roles: [role] });
156
+ continue;
157
+ }
158
+ if (role !== "assistant") continue; // system 是系统提示词,不是对话
159
+ const parts = readAssistantParts(fields.content);
160
+ if (parts.text.trim().length > 0) {
161
+ const last = kept[kept.length - 1];
162
+ // 一条 assistant 消息里「文本 + toolCall」并存:文本先出,工具另起一段,顺序与真实发生一致
163
+ if (last && last.roles[last.roles.length - 1] === "assistant-text") last.entry = { kind: "assistant", text: `${(last.entry as { text: string }).text}\n${parts.text}` };
164
+ else kept.push({ entry: { kind: "assistant", text: parts.text }, roles: ["assistant-text"] });
165
+ }
166
+ if (parts.calls.length > 0) {
167
+ const last = kept[kept.length - 1];
168
+ // 连续工具调用(中间没有文本)聚成一个折叠块:一次 read 一个块会堆出上百个折叠框
169
+ if (last && last.entry.kind === "tools" && last.roles[last.roles.length - 1] !== "assistant-text") {
170
+ (last.entry.calls as RawCall[]).push(...parts.calls);
171
+ } else {
172
+ kept.push({ entry: { kind: "tools", calls: [...parts.calls] }, roles: ["tools"] });
173
+ }
174
+ }
175
+ }
176
+ const toolCalls = kept.reduce((sum, item) => (item.entry.kind === "tools" ? sum + item.entry.calls.length : sum), 0);
177
+ const dropped = Math.max(0, kept.length - MAX_MESSAGES);
178
+ // 全文扫完再回填结果:toolResult 一定在 toolCall 之后出现
179
+ const entries: SessionEntry[] = kept.slice(dropped).map((item) =>
180
+ item.entry.kind === "tools"
181
+ ? {
182
+ kind: "tools",
183
+ calls: (item.entry.calls as RawCall[]).map((call) => ({
184
+ name: call.name,
185
+ args: call.args,
186
+ result: results.get(call.id) ?? "(没有结果)",
187
+ })),
188
+ }
189
+ : item.entry,
190
+ );
191
+ return { entries, messages, toolCalls, dropped };
192
+ }
193
+
194
+ /** 工具调用的抬头:`read ×3、bash ×2`(同名的合成一个计数,别写三遍 read) */
195
+ function toolSummary(calls: readonly SessionToolCall[]): string {
196
+ const counts = new Map<string, number>();
197
+ for (const call of calls) counts.set(call.name, (counts.get(call.name) ?? 0) + 1);
198
+ return [...counts.entries()].map(([name, count]) => `${name} ×${count}`).join("、");
199
+ }
200
+
201
+ /** 一个工具调用块:名字 + 参数摘要 + 结果前 200 字(全转义,original 里可能带尖括号) */
202
+ function toolBlock(call: SessionToolCall): string {
203
+ return `<div class="tool">
204
+ <p class="tool-name">${escapeHtml(call.name)}</p>
205
+ <pre class="tool-args">${escapeHtml(call.args.length > 0 ? call.args : "(无参数)")}</pre>
206
+ <pre class="tool-result">${escapeHtml(call.result)}</pre>
207
+ </div>`;
208
+ }
209
+
210
+ /**
211
+ * 一组工具调用:`<details>` **默认收起**(不加 `open`)——
212
+ * 一段会话动辄几百次调用,默认展开等于把对话正文顶到十屏之外。
213
+ */
214
+ function toolGroup(calls: readonly SessionToolCall[]): string {
215
+ return `<details class="tools"><summary>工具调用:${escapeHtml(toolSummary(calls))}(点击展开)</summary>
216
+ ${calls.map(toolBlock).join("\n")}
217
+ </details>`;
218
+ }
219
+
220
+ /** 一条对话消息:user 给原文(2000 字上限),assistant 给纯文本(4000 字上限) */
221
+ function messageBlock(entry: Extract<SessionEntry, { kind: "user" | "assistant" }>): string {
222
+ const label = entry.kind === "user" ? "用户" : "助手";
223
+ const max = entry.kind === "user" ? MAX_USER_TEXT : MAX_TEXT;
224
+ const body = clip(entry.text, max);
225
+ if (body.length === 0) return "";
226
+ return `<div class="msg ${entry.kind}">
227
+ <p class="who">${label}</p>
228
+ <pre>${escapeHtml(body)}</pre>
229
+ </div>`;
230
+ }
231
+
232
+ export interface RenderSessionOptions {
233
+ /** 因「只渲染最后 200 条」丢掉的条数(>0 时在顶部说明一句) */
234
+ dropped?: number;
235
+ }
236
+
237
+ /** 会话正文:按真实顺序渲染,超出总输出上限就停下并指路原文件 */
238
+ export function renderSessionBody(transcript: SessionTranscript, options: RenderSessionOptions = {}): string {
239
+ const parts: string[] = [];
240
+ let size = 0;
241
+ let stopped = false;
242
+ if (options.dropped !== undefined && options.dropped > 0) {
243
+ parts.push(`<p class="hint">只渲染最后 ${MAX_MESSAGES} 条消息,前面还有 ${options.dropped} 条未显示(看全文请用 rg/read 打开原文件)。</p>`);
244
+ }
245
+ for (const entry of transcript.entries) {
246
+ const html = entry.kind === "tools" ? toolGroup(entry.calls) : messageBlock(entry);
247
+ if (html.length === 0) continue;
248
+ if (size + html.length > MAX_OUTPUT) {
249
+ stopped = true;
250
+ break;
251
+ }
252
+ parts.push(html);
253
+ size += html.length;
254
+ }
255
+ if (stopped) {
256
+ parts.push(`<p class="hint">已达页面输出上限(${Math.round(MAX_OUTPUT / 1024)}KB),后面还有内容未渲染:继续看请用 rg/read 打开原文件。</p>`);
257
+ }
258
+ if (parts.length === 0) return '<p class="empty">这段会话里没有可渲染的对话(可能全是工具调用与系统提示)。</p>';
259
+ return parts.join("\n");
260
+ }
@@ -102,6 +102,23 @@ h2 { margin: 20px 0 6px; font-size: 15px; font-weight: 600; color: #cfe3ff }
102
102
  .diff .meta { color: #6f7a8c }
103
103
  /* 改后全文:长文件限高内部滚动,别把后面的评审/五闸顶到屏幕外 */
104
104
  .candidate { max-height: 480px; overflow: auto }
105
+ /* 会话详情页:工具调用块默认收起(details 不带 open),抬头是一行可点的摘要 */
106
+ details > summary { cursor: pointer }
107
+ details.tools { margin: 10px 0; padding: 8px 10px; background: #171b24; border-left: 2px solid #2b3342; border-radius: 4px }
108
+ details.tools > summary { color: #cfe3ff }
109
+ .tool { margin: 8px 0 0; padding: 8px 0 0; border-top: 1px solid #232733 }
110
+ .tool-name { margin: 0; color: #7cc4ff }
111
+ .tool-args, .tool-result { margin: 4px 0 0; padding: 8px; background: #141821;
112
+ border-left: 1px solid #2b3342; white-space: pre-wrap; word-break: break-word }
113
+ .tool-result { color: #8d99ae }
114
+ /* 对话消息:谁说的一句话决定左边框颜色,长文本靠 pre 自己的换行 */
115
+ .msg { margin: 14px 0; padding-left: 10px; border-left: 2px solid #2b3342 }
116
+ .msg.user { border-left-color: #7cc4ff }
117
+ .msg .who { margin: 0; color: #8d99ae; font-size: 12px }
118
+ .msg pre { margin: 4px 0 0; background: none; padding: 0; border: 0 }
119
+ /* 列表页「会话」列:只显示 sessionId 前 8 位,完整值在 title 里 */
120
+ a.sess { color: #8d99ae; text-decoration: none; border-bottom: 1px dashed #3f4654 }
121
+ a.sess:hover { color: #7cc4ff; border-bottom-color: #7cc4ff }
105
122
  footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid #232733; color: #6f7a8c }
106
123
  footer a { color: #7cc4ff }
107
124
  `.trim();
@@ -0,0 +1,165 @@
1
+ /**
2
+ * 同会话聚合检测:**接线**的单测(派发器与退出前补发真的走到并入分支)。
3
+ *
4
+ * 这里断言的是「没建新 run、没起进程」这类后果 —— 只看返回值会漏掉「返回说并入了,其实又派了一个」。
5
+ * 场景与用户现场一致:同一会话连着几轮结算,第二轮起只并轮数。
6
+ */
7
+ import { test } from "node:test";
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
+
13
+ import { DEFAULT_CONFIG } from "../lib/config.ts";
14
+ import { patchRunRecordSync } from "../lib/runs.ts";
15
+ import { flushPendingSync } from "../lib/shutdown-dispatch.ts";
16
+ import { type SpawnedChild } from "../lib/spawn-plan.ts";
17
+ import { type TelemetryEvent } from "../lib/telemetry.ts";
18
+ import { FakeChild, NOW, TEMPLATE, harness, readRun, settleAsync } from "./aggregate-harness.ts";
19
+
20
+ test("派发:同会话第二条不再派新 run,而是并入(不起进程、轮数与 merged 累加)", async () => {
21
+ const h = harness("merge");
22
+ const first = await h.dispatch({ sessionId: "s1", skills: ["a"] });
23
+ assert.equal(first.status, "dispatched");
24
+ if (first.status !== "dispatched") return;
25
+ const runId = first.run.runId;
26
+ const spawns = h.calls.length;
27
+
28
+ // 去抖窗口已过:这一轮会真的走到派发逻辑,聚合检测在单飞闸之前
29
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
30
+ const second = await h.dispatch({ sessionId: "s1", skills: ["b"] });
31
+ assert.deepEqual(second, { status: "merged", runId, rounds: 2 });
32
+ assert.equal(h.calls.length, spawns, "并入的轮次不许再起子进程");
33
+ assert.equal(fs.readdirSync(path.join(h.evolutionDir, "runs")).length, 1, "也不许再建 run 目录");
34
+
35
+ const record = readRun(h, runId);
36
+ const coverage = record.coverage as Record<string, unknown>;
37
+ assert.deepEqual([coverage.rounds, coverage.merged, coverage.lastAt], [2, 1, NOW + DEFAULT_CONFIG.idleMs + 1_000]);
38
+ // run 原有的状态与 skills 不被并入操作改写:本轮并进去的是「覆盖轮数」,不是新一轮技能清单
39
+ assert.deepEqual([record.status, record.trigger], ["dispatched", "auto"]);
40
+
41
+ const merged = h.events.filter((event) => event.kind === "merged");
42
+ assert.equal(merged.length, 1);
43
+ assert.deepEqual([merged[0]?.sessionId, merged[0]?.runId, merged[0]?.rounds], ["s1", runId, 2]);
44
+ assert.equal(h.logs.some((line) => line.includes(`同会话已有在途复盘 ${runId},本轮并入(共 2 轮),不再派生`)), true);
45
+ });
46
+
47
+ test("派发:连续并入时 rounds 递增、merged 累计;被去抖挡下的轮次也算进总数", async () => {
48
+ const h = harness("merge-twice");
49
+ const first = await h.dispatch({ sessionId: "s1", skills: ["a"] });
50
+ assert.equal(first.status, "dispatched");
51
+ if (first.status !== "dispatched") return;
52
+
53
+ // 窗口内又结算一轮:走老路记 pending(superseded),不是并入
54
+ const superseded = await h.dispatch({ sessionId: "s1", skills: ["a"] });
55
+ assert.deepEqual(superseded, { status: "superseded", rounds: 1 });
56
+
57
+ // 窗口过后这一轮:pending 里的那轮 + 这一轮一起并进去
58
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
59
+ const merged = await h.dispatch({ sessionId: "s1", skills: ["a"] });
60
+ // 派发时覆盖 1 轮 + pending 里 1 轮 + 这一轮 = 3 轮
61
+ assert.deepEqual(merged, { status: "merged", runId: first.run.runId, rounds: 3 });
62
+
63
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
64
+ const third = await h.dispatch({ sessionId: "s1", skills: ["a"] });
65
+ assert.deepEqual(third, { status: "merged", runId: first.run.runId, rounds: 4 });
66
+
67
+ const coverage = readRun(h, first.run.runId).coverage as Record<string, unknown>;
68
+ assert.deepEqual([coverage.rounds, coverage.merged], [4, 2]);
69
+ assert.equal(h.events.filter((event) => event.kind === "merged").length, 2);
70
+ });
71
+
72
+ test("派发:在途 run 的子进程已死 → 不并入,另开新 run(尸体交给 stale 逻辑)", async () => {
73
+ const h = harness("dead");
74
+ const first = await h.dispatch({ sessionId: "s1", skills: ["a"] });
75
+ assert.equal(first.status, "dispatched");
76
+ if (first.status !== "dispatched") return;
77
+
78
+ // 子进程退出(收尸会放锁并把这条 run 记成 abandoned),pid 判死:它不再是在途复盘
79
+ h.children[0].emit("exit", 1);
80
+ await settleAsync();
81
+ h.setAlive(false);
82
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
83
+
84
+ const second = await h.dispatch({ sessionId: "s1", skills: ["a"] });
85
+ assert.equal(second.status, "dispatched");
86
+ if (second.status !== "dispatched") return;
87
+ assert.notEqual(second.run.runId, first.run.runId, "死 run 不许被并入,必须另开一条");
88
+ assert.equal(h.calls.length, 2);
89
+ // 聚合检测不擅自改别人的 run.json:这次并入失败,那条老 run 的 coverage 一个字都没动
90
+ assert.equal(readRun(h, first.run.runId).coverage, undefined);
91
+ assert.equal(h.events.filter((event) => event.kind === "merged").length, 0);
92
+ });
93
+
94
+ test("派发:同会话那条 run 已 analyzed(复盘确实跑完)→ 允许另开新 run", async () => {
95
+ const h = harness("analyzed");
96
+ const first = await h.dispatch({ sessionId: "s1", skills: ["a"] });
97
+ assert.equal(first.status, "dispatched");
98
+ if (first.status !== "dispatched") return;
99
+ // 复盘跑完 = 子进程已退出:先收尸放锁,再把状态写成 analyzed(复盘确实读到了产出)
100
+ h.children[0].emit("exit", 0);
101
+ await settleAsync();
102
+ h.setAlive(false);
103
+ assert.equal(patchRunRecordSync(first.run.runDir, { status: "analyzed" }), true);
104
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
105
+
106
+ const second = await h.dispatch({ sessionId: "s1", skills: ["a"] });
107
+ assert.equal(second.status, "dispatched");
108
+ if (second.status !== "dispatched") return;
109
+ assert.notEqual(second.run.runId, first.run.runId);
110
+ });
111
+
112
+ test("派发:不同 sessionId 互不影响(各自照常派发)", async () => {
113
+ const h = harness("other-session");
114
+ const a = await h.dispatch({ sessionId: "s1", skills: ["a"] });
115
+ // s1 的子进程跑完退出(别让全局单飞闸挡住 s2 —— 它本来就该挡,这里测的是聚合不跨会话误判)
116
+ h.children[0].emit("exit", 0);
117
+ await settleAsync();
118
+ h.setAlive(false);
119
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
120
+ const b = await h.dispatch({ sessionId: "s2", skills: ["a"] });
121
+ assert.equal(a.status, "dispatched");
122
+ assert.equal(b.status, "dispatched");
123
+ if (a.status !== "dispatched" || b.status !== "dispatched") return;
124
+ assert.notEqual(a.run.runId, b.run.runId);
125
+ assert.equal(h.calls.length, 2);
126
+ assert.equal(h.events.filter((event) => event.kind === "merged").length, 0);
127
+ });
128
+
129
+ test("退出补发:同样先过聚合检查,命中就只并轮次 + 写 merged 遥测", async () => {
130
+ const h = harness("shutdown");
131
+ const first = await h.dispatch({ sessionId: "s1", skills: ["a"] });
132
+ assert.equal(first.status, "dispatched");
133
+ if (first.status !== "dispatched") return;
134
+ const spawns = h.calls.length;
135
+
136
+ const result = flushPendingSync(
137
+ { sessionId: "s1", since: NOW - 1000, rounds: 2, skills: ["a"] },
138
+ {
139
+ evolutionDir: h.evolutionDir,
140
+ cwd: os.tmpdir(),
141
+ promptTemplatePath: TEMPLATE,
142
+ maxRuntimeMs: 20 * 60_000,
143
+ telemetry: { append: () => {}, appendSync: (event) => { h.events.push({ ...event, ts: NOW, iso: "" } as TelemetryEvent); }, flush: async () => undefined },
144
+ env: {},
145
+ spawnFn: (command, args, options) => {
146
+ h.calls.push({ command, args, options });
147
+ const child = new FakeChild(95_001 + h.children.length);
148
+ h.children.push(child);
149
+ return child as unknown as SpawnedChild;
150
+ },
151
+ autoReview: false,
152
+ alive: () => true,
153
+ log: (msg) => h.logs.push(msg),
154
+ },
155
+ NOW + 5_000,
156
+ );
157
+
158
+ assert.equal(result, "merged");
159
+ assert.equal(h.calls.length, spawns, "补发并入时不许再起子进程");
160
+ const coverage = readRun(h, first.run.runId).coverage as Record<string, unknown>;
161
+ // 在途 run 派发时覆盖 1 轮,补发这 2 轮并进去 → 3 轮
162
+ assert.deepEqual([coverage.rounds, coverage.merged], [3, 1]);
163
+ const merged = h.events.filter((event) => event.kind === "merged");
164
+ assert.deepEqual([merged[0]?.sessionId, merged[0]?.rounds], ["s1", 3]);
165
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 聚合检测的测试夹具:假子进程 + 假派发器 + 临时工作区。
3
+ *
4
+ * 为什么不放在 .test.ts 里:聚合有两条入口(自动派发与退出前补发),
5
+ * 各自一个测试文件,夹具共用一份才不会两边算出不同的场景。
6
+ *
7
+ * 纪律:pid 用合成号段(90001 起)而不是本进程 pid —— 「子进程是死是活」由用例通过 setAlive 控制,
8
+ * 否则测试进程自己活着就会被判成「有在途复盘」。全程不起真子进程。
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ import { DEFAULT_CONFIG } from "../lib/config.ts";
16
+ import { createDispatcher } from "../lib/dispatch.ts";
17
+ import { type SpawnedChild, type SpawnLike } from "../lib/spawn-plan.ts";
18
+ import { directQueue, type TelemetryEvent, type TelemetryInput, type TelemetryWriter } from "../lib/telemetry.ts";
19
+ import { fakeSupervisorSpawn } from "./fake-review-spawn.ts";
20
+
21
+ export const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "prompts", "复盘.md");
22
+ /** 固定时钟:判据里的「派发时刻 / 未来时刻」都按它算,用例才可复现 */
23
+ export const NOW = 1_760_000_000_000;
24
+
25
+ /**
26
+ * 假子进程:pid 用**合成的号段**(不是本进程 pid)。
27
+ *
28
+ * 这样「子进程是死是活」完全由用例通过 setAlive 控制 —— 聚合与单飞闸都读注入的 alive,
29
+ * 不会因为测试进程自己活着就被判成「有在途复盘」。
30
+ * 合成 pid 对真 kill(pid,0) 来说是死的,于是残锁会被当作孤儿回收:正好符合「子进程已退出」的场景。
31
+ */
32
+ export class FakeChild {
33
+ readonly pid: number;
34
+ constructor(pid: number) {
35
+ this.pid = pid;
36
+ }
37
+ private listeners = new Map<string, (arg: unknown) => void>();
38
+ unref(): void {}
39
+ once(event: string, listener: (arg: unknown) => void): this {
40
+ this.listeners.set(event, listener);
41
+ return this;
42
+ }
43
+ emit(event: string, arg: unknown): void {
44
+ this.listeners.get(event)?.(arg);
45
+ }
46
+ }
47
+
48
+ export interface Harness {
49
+ evolutionDir: string;
50
+ calls: { command: string; args: string[]; options: Record<string, unknown> }[];
51
+ children: FakeChild[];
52
+ events: TelemetryEvent[];
53
+ logs: string[];
54
+ tick: (ms: number) => void;
55
+ /** 「子进程还活着吗」:聚合与单飞闸共用这个注入,用例按需翻转 */
56
+ setAlive: (value: boolean) => void;
57
+ dispatch: ReturnType<typeof createDispatcher>["dispatch"];
58
+ }
59
+
60
+ export function harness(tag: string): Harness {
61
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `skill-evolve-agg-${tag}-`));
62
+ const evolutionDir = path.join(dir, ".pi", "evolution");
63
+ const calls: Harness["calls"] = [];
64
+ const children: FakeChild[] = [];
65
+ const events: TelemetryEvent[] = [];
66
+ const logs: string[] = [];
67
+ const pushEvent = (event: TelemetryInput): void => {
68
+ events.push({ ...event, ts: NOW, iso: "" } as TelemetryEvent);
69
+ };
70
+ const writer: TelemetryWriter = { append: pushEvent, appendSync: pushEvent, flush: async () => undefined };
71
+ const spawnFn: SpawnLike = (command, args, options) => {
72
+ calls.push({ command, args, options });
73
+ // pid 从 90001 起编号:每个用例一个独立工作区,不会和本机真实进程号撞上也无关(judged by alive)
74
+ const child = new FakeChild(90_001 + children.length);
75
+ children.push(child);
76
+ return child as unknown as SpawnedChild;
77
+ };
78
+ let alive = true;
79
+ let clock = NOW;
80
+ const dispatcher = createDispatcher({
81
+ evolutionDir,
82
+ cwd: dir,
83
+ config: { ...DEFAULT_CONFIG, enabled: true, telemetry: true },
84
+ telemetry: writer,
85
+ queue: directQueue,
86
+ promptTemplatePath: TEMPLATE,
87
+ log: (msg) => logs.push(msg),
88
+ now: () => clock,
89
+ env: {},
90
+ spawnFn,
91
+ alive: () => alive,
92
+ supervisorSpawnFn: fakeSupervisorSpawn([]),
93
+ });
94
+ return {
95
+ evolutionDir,
96
+ calls,
97
+ children,
98
+ events,
99
+ logs,
100
+ tick: (ms) => {
101
+ clock += ms;
102
+ },
103
+ setAlive: (value) => {
104
+ alive = value;
105
+ },
106
+ dispatch: dispatcher.dispatch,
107
+ };
108
+ }
109
+ export function readRun(h: Harness, runId: string): Record<string, unknown> {
110
+ return JSON.parse(fs.readFileSync(path.join(h.evolutionDir, "runs", runId, "run.json"), "utf8")) as Record<string, unknown>;
111
+ }
112
+
113
+ export const settleAsync = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 50));
114
+
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 同会话聚合检测:**判定规则**的单测(纯函数,不碰派发)。
3
+ *
4
+ * 现场(用户 2026-11-08 报的现象):同一个 sessionId 下堆了 1 条 analyzed + 7 条 stale,
5
+ * 同一段会话被反反复复复盘。根因是 runId 每轮结算都会算出新的,而全局单飞闸只管「同时一个子进程」。
6
+ *
7
+ * 判定口径:只有「同会话 + 仍在途(dispatched)+ 子进程活着 + 还没落 01-findings.json」才并入;
8
+ * 尸体(进程已死)交 stale 逻辑、已跑完的交下一轮 —— 这三种不许混淆。
9
+ */
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import * as fs from "node:fs";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+
16
+ import { findSameSessionRun, readAggregateRuns, type AggregateRun } from "../lib/aggregate.ts";
17
+ import { NOW } from "./aggregate-harness.ts";
18
+
19
+ /** 假 run 的构造器:纯函数用例不需要真目录,只有 readAggregateRuns 才碰盘 */
20
+ function runOf(overrides: Partial<AggregateRun> & { sessionId: string }): AggregateRun {
21
+ return {
22
+ runId: "run-1",
23
+ runDir: "/tmp/run-1",
24
+ status: "dispatched",
25
+ pid: 4242,
26
+ dispatchedAt: NOW - 1000,
27
+ hasFindings: false,
28
+ coverage: null,
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ const alwaysAlive = (): boolean => true;
34
+ const alwaysDead = (): boolean => false;
35
+
36
+ test("纯函数:同会话在途且子进程活着 → 命中那条 run", () => {
37
+ const runs = [runOf({ sessionId: "s1", runId: "r1" })];
38
+ assert.equal(findSameSessionRun(runs, "s1", alwaysAlive, NOW)?.runId, "r1");
39
+ // 不同会话一律不并
40
+ assert.equal(findSameSessionRun(runs, "s2", alwaysAlive, NOW), null);
41
+ });
42
+
43
+ test("纯函数:状态不是 dispatched(复盘已跑完)→ 不命中", () => {
44
+ for (const status of ["analyzed", "proposed", "reviewed", "applied", "stale", "failed"]) {
45
+ const runs = [runOf({ sessionId: "s1", status })];
46
+ assert.equal(findSameSessionRun(runs, "s1", alwaysAlive, NOW), null, status);
47
+ }
48
+ });
49
+
50
+ test("纯函数:子进程已死 / 没有 pid → 不命中(尸体交给 stale,不往里并)", () => {
51
+ assert.equal(findSameSessionRun([runOf({ sessionId: "s1" })], "s1", alwaysDead, NOW), null);
52
+ assert.equal(findSameSessionRun([runOf({ sessionId: "s1", pid: null })], "s1", alwaysAlive, NOW), null);
53
+ });
54
+
55
+ test("纯函数:01-findings.json 已落盘 → 不命中(状态可能还没回写)", () => {
56
+ assert.equal(findSameSessionRun([runOf({ sessionId: "s1", hasFindings: true })], "s1", alwaysAlive, NOW), null);
57
+ });
58
+
59
+ test("纯函数:多条命中取最近派发的;派发时刻在未来的(时钟回拨)不并", () => {
60
+ const runs = [
61
+ runOf({ sessionId: "s1", runId: "old", dispatchedAt: NOW - 5000 }),
62
+ runOf({ sessionId: "s1", runId: "new", dispatchedAt: NOW - 100 }),
63
+ runOf({ sessionId: "s1", runId: "future", dispatchedAt: NOW + 60_000 }),
64
+ ];
65
+ assert.equal(findSameSessionRun(runs, "s1", alwaysAlive, NOW)?.runId, "new");
66
+ });
67
+
68
+ test("readAggregateRuns:坏 run.json 一律跳过,不伪造在途记录", () => {
69
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-agg-scan-"));
70
+ const evolutionDir = path.join(dir, ".pi", "evolution");
71
+ fs.mkdirSync(path.join(evolutionDir, "runs", "good"), { recursive: true });
72
+ fs.writeFileSync(path.join(evolutionDir, "runs", "good", "run.json"), JSON.stringify({ runId: "good", sessionId: "s1", status: "dispatched", pid: 7, dispatchedAt: NOW }), "utf8");
73
+ fs.mkdirSync(path.join(evolutionDir, "runs", "half"), { recursive: true });
74
+ fs.writeFileSync(path.join(evolutionDir, "runs", "half", "run.json"), '{"runId":"half"', "utf8");
75
+ const runs = readAggregateRuns(evolutionDir);
76
+ assert.deepEqual(runs.map((run) => run.runId), ["good"]);
77
+ // 01-findings.json 存在即视为复盘已跑完
78
+ fs.writeFileSync(path.join(evolutionDir, "runs", "good", "01-findings.json"), "{}", "utf8");
79
+ assert.equal(readAggregateRuns(evolutionDir)[0]?.hasFindings, true);
80
+ });