pi-skill-evolve 0.1.2 → 0.1.3
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.
- package/extensions/skill-evolve/index.ts +2 -2
- package/extensions/skill-evolve/lib/command.ts +7 -5
- package/extensions/skill-evolve/lib/config.ts +45 -14
- package/extensions/skill-evolve/lib/dispatch.ts +26 -33
- package/extensions/skill-evolve/lib/drain-pending.ts +65 -0
- package/extensions/skill-evolve/lib/evolution-apply.ts +30 -1
- package/extensions/skill-evolve/lib/evolution.ts +7 -5
- package/extensions/skill-evolve/lib/flags.ts +32 -18
- package/extensions/skill-evolve/lib/queue.ts +2 -0
- package/extensions/skill-evolve/lib/replay.ts +2 -0
- package/extensions/skill-evolve/lib/report.ts +4 -5
- package/extensions/skill-evolve/lib/review-spawn.ts +11 -0
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +4 -0
- package/extensions/skill-evolve/lib/web/actions.ts +3 -3
- package/extensions/skill-evolve/lib/web/activity.ts +19 -2
- package/extensions/skill-evolve/lib/web/render-controls.ts +21 -9
- package/extensions/skill-evolve/test/approve.test.ts +63 -0
- package/extensions/skill-evolve/test/command.test.ts +33 -10
- package/extensions/skill-evolve/test/config.test.ts +75 -23
- package/extensions/skill-evolve/test/dispatch-busy.test.ts +3 -0
- package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +26 -2
- package/extensions/skill-evolve/test/evolution.test.ts +57 -14
- package/extensions/skill-evolve/test/reject.test.ts +2 -0
- package/extensions/skill-evolve/test/review-spawn.test.ts +25 -2
- package/extensions/skill-evolve/test/stale.test.ts +1 -0
- package/extensions/skill-evolve/test/web-actions.test.ts +42 -3
- package/extensions/skill-evolve/test/web-data.test.ts +8 -3
- package/extensions/skill-evolve/test/web-render.test.ts +31 -8
- package/package.json +1 -1
|
@@ -8,14 +8,21 @@
|
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { loadConfig } from "../config.ts";
|
|
11
|
+
import { readQueue } from "../queue.ts";
|
|
11
12
|
import { listRuns } from "../runs.ts";
|
|
12
13
|
import { readLogTail } from "../stale.ts";
|
|
13
14
|
|
|
14
15
|
export interface WebControls {
|
|
15
16
|
/** 采集遥测开关(config.telemetry,与 `/skill-evolve collect` 同一个字段):只写本地 jsonl,不花 token */
|
|
16
17
|
telemetry: boolean;
|
|
17
|
-
/** 自动复盘开关(config.enabled,与 `/skill-evolve
|
|
18
|
+
/** 自动复盘开关(config.enabled,与 `/skill-evolve dispatch` 同一个字段):派生子进程,花 token */
|
|
18
19
|
enabled: boolean;
|
|
20
|
+
/** 自动评审开关(config.autoReview,与 `/skill-evolve review` 同一个字段):提案后再起一个督导子进程 */
|
|
21
|
+
autoReview: boolean;
|
|
22
|
+
/** 自动采纳开关(config.autoApply,与 `/skill-evolve apply` 同一个字段):auto 提案自动落盘 */
|
|
23
|
+
autoApply: boolean;
|
|
24
|
+
/** 队列里还在 pending 的 auto 条数:采纳关着时页面要写「N 条 auto 提案待人工」 */
|
|
25
|
+
pendingAuto: number;
|
|
19
26
|
lastActivityAt: number | null;
|
|
20
27
|
}
|
|
21
28
|
|
|
@@ -53,5 +60,15 @@ export function collectControls(evolutionDir: string): WebControls {
|
|
|
53
60
|
if (latest === null || run.dispatchedAt > latest) latest = run.dispatchedAt;
|
|
54
61
|
}
|
|
55
62
|
const config = loadConfig(evolutionDir);
|
|
56
|
-
|
|
63
|
+
const pendingAuto = readQueue(evolutionDir).filter(
|
|
64
|
+
(item) => item.status === "pending" && item.level === "auto",
|
|
65
|
+
).length;
|
|
66
|
+
return {
|
|
67
|
+
telemetry: config.telemetry,
|
|
68
|
+
enabled: config.enabled,
|
|
69
|
+
autoReview: config.autoReview,
|
|
70
|
+
autoApply: config.autoApply,
|
|
71
|
+
pendingAuto,
|
|
72
|
+
lastActivityAt: latest,
|
|
73
|
+
};
|
|
57
74
|
}
|
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { AuditEntry } from "./audit-log.ts";
|
|
13
13
|
import type { ConfigFlag } from "../config.ts";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
FLAG_EFFECT,
|
|
16
|
+
FLAG_FIELDS,
|
|
17
|
+
FLAG_LABEL,
|
|
18
|
+
TELEMETRY_OFF_WARNING,
|
|
19
|
+
telemetryOffWhileEnabled,
|
|
20
|
+
} from "../flags.ts";
|
|
15
21
|
import type { WebView } from "./data.ts";
|
|
16
22
|
import { RANGE_KEYS, RANGE_LABEL, STATUS_OPTIONS, pageHref, type ListFilter, type ViewPage } from "./filter.ts";
|
|
17
23
|
import { escapeHtml, formatStamp } from "./text.ts";
|
|
@@ -55,7 +61,8 @@ function runForm(ctx: ControlBarOptions): string {
|
|
|
55
61
|
/**
|
|
56
62
|
* 一个独立开关:状态 + 后果说明 + 「点下去会发生什么」的按钮。
|
|
57
63
|
*
|
|
58
|
-
*
|
|
64
|
+
* 四个开关各自 post 到 `/toggle?field=`(带上隐藏的 view,动作完回同一页),**不互相联动**:
|
|
65
|
+
* 想采集但不花钱、想评审但不自动采纳,都是常见中间态;要批量切只有命令 `on|off`。
|
|
59
66
|
* 按钮文案写动作不写状态 —— 状态已经在左边那一段里,按钮再写一遍会让人把「当前值」看成「点了会变的值」。
|
|
60
67
|
*/
|
|
61
68
|
function flagSwitch(field: ConfigFlag, on: boolean, ctx: ControlBarOptions): string {
|
|
@@ -73,11 +80,12 @@ function flagSwitch(field: ConfigFlag, on: boolean, ctx: ControlBarOptions): str
|
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
/**
|
|
76
|
-
*
|
|
83
|
+
* 顶部控制条:四个独立开关(采集 / 复盘 / 评审 / 采纳)+ 立即复盘 + 最后活动。
|
|
77
84
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
85
|
+
* 四个开关不许合成一个「开启全部」:采集只写本地文件,复盘与评审才花 token,
|
|
86
|
+
* 采纳直接改仓库文件 —— 想采集样本但不想花钱、想评审但不想自动落盘,都是常见的中间状态。
|
|
87
|
+
* 两类提醒不许静默:只开复盘不采集(见 flags.ts 的 TELEMETRY_OFF_WARNING)、
|
|
88
|
+
* 采纳关着但队列里积了 auto 提案(告诉人有多少条等人工批)。
|
|
81
89
|
*/
|
|
82
90
|
export function renderControlBar(controls: WebControls, ctx: ControlBarOptions): string {
|
|
83
91
|
const writable = ctx.canWrite === true && (ctx.csrf ?? "").length > 0;
|
|
@@ -85,12 +93,16 @@ export function renderControlBar(controls: WebControls, ctx: ControlBarOptions):
|
|
|
85
93
|
const warn = telemetryOffWhileEnabled(controls.enabled, controls.telemetry)
|
|
86
94
|
? `<p class="warn">⚠ ${TELEMETRY_OFF_WARNING}</p>\n`
|
|
87
95
|
: "";
|
|
96
|
+
const backlog =
|
|
97
|
+
!controls.autoApply && controls.pendingAuto > 0
|
|
98
|
+
? `<p class="warn">⏸ ${controls.pendingAuto} 条 auto 提案待人工(自动采纳关着)</p>\n`
|
|
99
|
+
: "";
|
|
100
|
+
const switches = FLAG_FIELDS.map((field) => flagSwitch(field, controls[field], ctx)).join("\n");
|
|
88
101
|
return `<div class="ctl">
|
|
89
|
-
${
|
|
90
|
-
${flagSwitch("enabled", controls.enabled, ctx)}
|
|
102
|
+
${switches}
|
|
91
103
|
${run}<span class="act">${escapeHtml(activityText(controls.lastActivityAt, ctx.now))}</span>
|
|
92
104
|
</div>
|
|
93
|
-
${warn}`;
|
|
105
|
+
${warn}${backlog}`;
|
|
94
106
|
}
|
|
95
107
|
|
|
96
108
|
/** 状态下拉:先「全部」,再固定七个状态(顺序与 STATUS_OPTIONS 一致,别在渲染层重排) */
|
|
@@ -59,10 +59,73 @@ function tools(fixture: Fixture, over: Partial<Parameters<typeof createApplyTool
|
|
|
59
59
|
git: (args: string[]) => ({ code: 0, out: args[0] === "status" ? "" : "" }),
|
|
60
60
|
loadDiagnostics: () => [],
|
|
61
61
|
runMdCheck: () => ({ code: 0, output: "" }),
|
|
62
|
+
// 开关默认全开(老用例断言的是「开关都开着」时的行为);个别用例自己 over 掉
|
|
63
|
+
flags: () => ({ autoReview: true, autoApply: true }),
|
|
62
64
|
...over,
|
|
63
65
|
});
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
test("autoApply 关着:auto 条照旧入队但不落盘,提示只打一次", async () => {
|
|
69
|
+
const fixture = setup();
|
|
70
|
+
seedRun(fixture, "run-auto", "reviewed");
|
|
71
|
+
await seedGate0(fixture);
|
|
72
|
+
const logs: string[] = [];
|
|
73
|
+
const t = tools(fixture, { flags: () => ({ autoReview: true, autoApply: false }), log: (line: string) => logs.push(line) });
|
|
74
|
+
assert.equal(await t.enqueueReady(), 1, "入队不受采纳开关影响:待审批页要看得见");
|
|
75
|
+
assert.equal(await t.autoApplyPending(), 0, "采纳关着一条都不许动");
|
|
76
|
+
assert.equal(await t.autoApplyPending(), 0);
|
|
77
|
+
const item = readQueue(fixture.evolutionDir)[0]!;
|
|
78
|
+
assert.equal(item.status, "pending", "队列条目必须还在,等人工批准");
|
|
79
|
+
assert.equal(item.level, "auto");
|
|
80
|
+
assert.equal(fs.readFileSync(fixture.skillAbs, "utf8"), SKILL_TEXT, "技能文件一个字都不许改");
|
|
81
|
+
const notices = logs.filter((line) => line.includes("自动采纳已关"));
|
|
82
|
+
assert.equal(notices.length, 1, "同一批待办只提示一次,不能每轮扫描刷一遍");
|
|
83
|
+
assert.match(notices[0]!, /1 条 auto 提案在队列里等人工批准/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("autoReview 关着:proposed 的 run 也入队并标 reviewSkipped(待审批页可见)", async () => {
|
|
87
|
+
const fixture = setup();
|
|
88
|
+
seedRun(fixture, "run-proposed", "proposed");
|
|
89
|
+
const t = tools(fixture, { flags: () => ({ autoReview: false, autoApply: false }) });
|
|
90
|
+
assert.equal(await t.enqueueReady(), 1, "评审关着时 run 停在 proposed,也要让待审批页看得见");
|
|
91
|
+
const item = readQueue(fixture.evolutionDir)[0]!;
|
|
92
|
+
assert.equal(item.reviewSkipped, true, "没有 03-review.json 的条目标记出来,页面才能区分");
|
|
93
|
+
assert.equal(item.level, "auto", "级别仍按 classify-action 的分类走,不因为跳过评审就降级");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("中途打开评审:同一个 tools 实例先关后开,下一轮扫描不再收 proposed", async () => {
|
|
97
|
+
const fixture = setup();
|
|
98
|
+
const flags = { autoReview: false, autoApply: false };
|
|
99
|
+
const t = tools(fixture, { flags: () => flags });
|
|
100
|
+
seedRun(fixture, "run-p1", "proposed");
|
|
101
|
+
assert.equal(await t.enqueueReady(), 1, "关着的时候 proposed 也入队");
|
|
102
|
+
flags.autoReview = true;
|
|
103
|
+
seedRun(fixture, "run-p2", "proposed");
|
|
104
|
+
assert.equal(await t.enqueueReady(), 0, "打开评审后要等督导写 reviewed,不再提前入队");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("autoReview 开着:proposed 的 run 不入队(等督导写完 reviewed 再进)", async () => {
|
|
108
|
+
const fixture = setup();
|
|
109
|
+
seedRun(fixture, "run-proposed", "proposed");
|
|
110
|
+
const t = tools(fixture, { flags: () => ({ autoReview: true, autoApply: true }) });
|
|
111
|
+
assert.equal(await t.enqueueReady(), 0);
|
|
112
|
+
assert.deepEqual(readQueue(fixture.evolutionDir), []);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("中途改开关:同一个 tools 实例先关后开,下一轮扫描就落盘", async () => {
|
|
116
|
+
const fixture = setup();
|
|
117
|
+
seedRun(fixture, "run-auto", "reviewed");
|
|
118
|
+
await seedGate0(fixture);
|
|
119
|
+
const flags = { autoReview: true, autoApply: false };
|
|
120
|
+
const t = tools(fixture, { flags: () => flags });
|
|
121
|
+
await t.enqueueReady();
|
|
122
|
+
assert.equal(await t.autoApplyPending(), 0);
|
|
123
|
+
// 用户中途 `/skill-evolve apply on`:下一轮扫描就该生效,不许等重开会话
|
|
124
|
+
flags.autoApply = true;
|
|
125
|
+
assert.equal(await t.autoApplyPending(), 1);
|
|
126
|
+
assert.equal(readQueue(fixture.evolutionDir)[0]!.status, "applied");
|
|
127
|
+
});
|
|
128
|
+
|
|
66
129
|
test("只给 reviewed 的 run 入队,级别由代码判(description 改动 → auto)", async () => {
|
|
67
130
|
const fixture = setup();
|
|
68
131
|
seedRun(fixture, "run-ok", "reviewed");
|
|
@@ -78,7 +78,7 @@ function fakeEvolution(calls: string[]): Evolution {
|
|
|
78
78
|
test("补全:空前缀给全量,命中前缀只给命中项,乱前缀也给全量", () => {
|
|
79
79
|
assert.deepEqual(
|
|
80
80
|
matchSubcommand("").map((item) => item.value),
|
|
81
|
-
["status", "report", "run", "baseline", "selftest", "approve", "queue", "web", "config", "collect", "review", "on", "off"],
|
|
81
|
+
["status", "report", "run", "baseline", "selftest", "approve", "queue", "web", "config", "collect", "dispatch", "review", "apply", "on", "off"],
|
|
82
82
|
);
|
|
83
83
|
assert.deepEqual(
|
|
84
84
|
matchSubcommand("re").map((item) => item.value),
|
|
@@ -94,7 +94,11 @@ test("补全:空前缀给全量,命中前缀只给命中项,乱前缀也
|
|
|
94
94
|
);
|
|
95
95
|
assert.deepEqual(
|
|
96
96
|
matchSubcommand("ap").map((item) => item.value),
|
|
97
|
-
["approve"],
|
|
97
|
+
["approve", "apply"],
|
|
98
|
+
);
|
|
99
|
+
assert.deepEqual(
|
|
100
|
+
matchSubcommand("di").map((item) => item.value),
|
|
101
|
+
["dispatch"],
|
|
98
102
|
);
|
|
99
103
|
assert.deepEqual(
|
|
100
104
|
matchSubcommand("o").map((item) => item.value),
|
|
@@ -110,7 +114,7 @@ test("补全:空前缀给全量,命中前缀只给命中项,乱前缀也
|
|
|
110
114
|
);
|
|
111
115
|
assert.deepEqual(
|
|
112
116
|
matchSubcommand("zzz").map((item) => item.value),
|
|
113
|
-
["status", "report", "run", "baseline", "selftest", "approve", "queue", "web", "config", "collect", "review", "on", "off"],
|
|
117
|
+
["status", "report", "run", "baseline", "selftest", "approve", "queue", "web", "config", "collect", "dispatch", "review", "apply", "on", "off"],
|
|
114
118
|
);
|
|
115
119
|
assert.deepEqual(
|
|
116
120
|
matchSubcommand("w").map((item) => item.value),
|
|
@@ -193,7 +197,10 @@ test("未知子命令返回用法串且不碰 Evolution", async () => {
|
|
|
193
197
|
const calls: string[] = [];
|
|
194
198
|
const text = await runCommand("propose", fakeEvolution(calls), TARGET);
|
|
195
199
|
assert.match(text, /未知子命令:propose/);
|
|
196
|
-
assert.match(
|
|
200
|
+
assert.match(
|
|
201
|
+
text,
|
|
202
|
+
/status\|report\|run\|baseline\|selftest\|approve\|queue\|web\|config\|collect\|dispatch\|review\|apply\|on\|off/,
|
|
203
|
+
);
|
|
197
204
|
assert.deepEqual(calls, []);
|
|
198
205
|
});
|
|
199
206
|
|
|
@@ -205,20 +212,36 @@ test("collect on|off:只动采集(telemetry),不碰复盘", async () =>
|
|
|
205
212
|
assert.deepEqual(calls, ["flag:telemetry=true", "flag:telemetry=false"]);
|
|
206
213
|
});
|
|
207
214
|
|
|
208
|
-
test("
|
|
215
|
+
test("dispatch on|off:只动自动复盘(enabled),不碰采集", async () => {
|
|
209
216
|
const calls: string[] = [];
|
|
210
217
|
const evo = fakeEvolution(calls);
|
|
211
|
-
assert.equal(await runCommand("
|
|
212
|
-
assert.equal(await runCommand("
|
|
218
|
+
assert.equal(await runCommand("dispatch on", evo, TARGET), "开关:enabled=true");
|
|
219
|
+
assert.equal(await runCommand("dispatch off", evo, TARGET), "开关:enabled=false");
|
|
213
220
|
assert.deepEqual(calls, ["flag:enabled=true", "flag:enabled=false"]);
|
|
214
221
|
});
|
|
215
222
|
|
|
216
|
-
test("
|
|
223
|
+
test("review on|off:只动自动评审(autoReview)—— 语义从「自动复盘」改到这里", async () => {
|
|
224
|
+
const calls: string[] = [];
|
|
225
|
+
const evo = fakeEvolution(calls);
|
|
226
|
+
assert.equal(await runCommand("review on", evo, TARGET), "开关:autoReview=true");
|
|
227
|
+
assert.equal(await runCommand("review off", evo, TARGET), "开关:autoReview=false");
|
|
228
|
+
assert.deepEqual(calls, ["flag:autoReview=true", "flag:autoReview=false"]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("apply on|off:只动自动采纳(autoApply)", async () => {
|
|
232
|
+
const calls: string[] = [];
|
|
233
|
+
const evo = fakeEvolution(calls);
|
|
234
|
+
assert.equal(await runCommand("apply on", evo, TARGET), "开关:autoApply=true");
|
|
235
|
+
assert.equal(await runCommand("apply off", evo, TARGET), "开关:autoApply=false");
|
|
236
|
+
assert.deepEqual(calls, ["flag:autoApply=true", "flag:autoApply=false"]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("collect / dispatch / review / apply 参数非法(缺失或写错):给用法串且不改配置", async () => {
|
|
217
240
|
const calls: string[] = [];
|
|
218
241
|
const evo = fakeEvolution(calls);
|
|
219
|
-
for (const args of ["collect", "collect yes", "review", "review 1"]) {
|
|
242
|
+
for (const args of ["collect", "collect yes", "dispatch", "review", "review 1", "apply", "apply yes"]) {
|
|
220
243
|
const text = await runCommand(args, evo, TARGET);
|
|
221
|
-
assert.match(text, /用法:\/skill-evolve (collect|review) on\|off/, args);
|
|
244
|
+
assert.match(text, /用法:\/skill-evolve (collect|dispatch|review|apply) on\|off/, args);
|
|
222
245
|
assert.match(text, /配置未改动/, args);
|
|
223
246
|
}
|
|
224
247
|
assert.deepEqual(calls, [], "非法参数不该调 setFlag");
|
|
@@ -17,44 +17,75 @@ function workspace(): string {
|
|
|
17
17
|
return path.join(cwd, ".pi", "evolution");
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
test("DEFAULT_CONFIG
|
|
21
|
-
|
|
20
|
+
test("DEFAULT_CONFIG:只开自动评审,采集 / 复盘 / 自动采纳都默认关", () => {
|
|
21
|
+
// 自动评审单独为 true 是安全的:enabled 关着时它永远走不到,不会替 clone 下来的人花 token
|
|
22
|
+
assert.deepEqual(
|
|
23
|
+
[DEFAULT_CONFIG.enabled, DEFAULT_CONFIG.telemetry, DEFAULT_CONFIG.autoReview, DEFAULT_CONFIG.autoApply],
|
|
24
|
+
[false, false, true, false],
|
|
25
|
+
);
|
|
22
26
|
});
|
|
23
27
|
|
|
24
|
-
test("loadConfig:没有 config.json
|
|
28
|
+
test("loadConfig:没有 config.json 时只开自动评审,且不会顺手造文件", () => {
|
|
25
29
|
const evolutionDir = workspace();
|
|
26
30
|
const config = loadConfig(evolutionDir);
|
|
27
|
-
assert.deepEqual(
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
[config.enabled, config.telemetry, config.autoReview, config.autoApply],
|
|
33
|
+
[false, false, true, false],
|
|
34
|
+
);
|
|
28
35
|
assert.equal(fs.existsSync(configFile(evolutionDir)), false, "读配置不能有副作用");
|
|
29
36
|
});
|
|
30
37
|
|
|
31
|
-
test("normalizeConfig
|
|
32
|
-
|
|
38
|
+
test("normalizeConfig:空对象与缺字段都回落到默认(评审开、其余关)", () => {
|
|
39
|
+
const empty = normalizeConfig({});
|
|
40
|
+
assert.deepEqual([empty.enabled, empty.telemetry, empty.autoReview, empty.autoApply], [false, false, true, false]);
|
|
33
41
|
const onlyEnabled = normalizeConfig({ enabled: true });
|
|
34
|
-
assert.deepEqual(
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
[onlyEnabled.enabled, onlyEnabled.telemetry, onlyEnabled.autoApply],
|
|
44
|
+
[true, false, false],
|
|
45
|
+
"只写 enabled 不等于打开采集或自动采纳",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("normalizeConfig:autoReview / autoApply 写错类型回落到默认值", () => {
|
|
50
|
+
// 手改配置写成字符串是常事:坏值只回退那一个字段,不能让整份配置失效
|
|
51
|
+
const wrong = normalizeConfig({ autoReview: "off", autoApply: 1 });
|
|
52
|
+
assert.deepEqual([wrong.autoReview, wrong.autoApply], [true, false]);
|
|
53
|
+
const explicit = normalizeConfig({ autoReview: false, autoApply: true, enabled: true });
|
|
54
|
+
assert.deepEqual([explicit.enabled, explicit.autoReview, explicit.autoApply], [true, false, true]);
|
|
35
55
|
});
|
|
36
56
|
|
|
37
|
-
test("saveConfigSwitch:on
|
|
57
|
+
test("saveConfigSwitch:on 只开复盘 + 评审,采集与自动采纳保持原值", async () => {
|
|
38
58
|
const evolutionDir = workspace();
|
|
39
59
|
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
40
60
|
fs.writeFileSync(
|
|
41
61
|
configFile(evolutionDir),
|
|
42
|
-
JSON.stringify({ enabled: false, telemetry: false, idleMs: 1_234, perSessionCap: 3 }),
|
|
62
|
+
JSON.stringify({ enabled: false, telemetry: true, autoReview: false, autoApply: false, idleMs: 1_234, perSessionCap: 3 }),
|
|
43
63
|
"utf8",
|
|
44
64
|
);
|
|
45
65
|
const next = await saveConfigSwitch(evolutionDir, true, directQueue);
|
|
46
|
-
assert.deepEqual(
|
|
66
|
+
assert.deepEqual(
|
|
67
|
+
[next.enabled, next.autoReview, next.telemetry, next.autoApply, next.idleMs, next.perSessionCap],
|
|
68
|
+
[true, true, true, false, 1_234, 3],
|
|
69
|
+
);
|
|
47
70
|
const raw = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
|
|
48
|
-
assert.deepEqual(
|
|
71
|
+
assert.deepEqual(
|
|
72
|
+
[raw.enabled, raw.autoReview, raw.telemetry, raw.autoApply, raw.idleMs, raw.perSessionCap],
|
|
73
|
+
[true, true, true, false, 1_234, 3],
|
|
74
|
+
"写盘内容必须与返回值一致:采集关着不能被 on 顺手打开,自动采纳更不能被打开",
|
|
75
|
+
);
|
|
49
76
|
});
|
|
50
77
|
|
|
51
|
-
test("saveConfigSwitch:off
|
|
78
|
+
test("saveConfigSwitch:off 四个键全关,去抖等其它字段原样保留", async () => {
|
|
52
79
|
const evolutionDir = workspace();
|
|
53
80
|
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
54
|
-
fs.writeFileSync(
|
|
81
|
+
fs.writeFileSync(
|
|
82
|
+
configFile(evolutionDir),
|
|
83
|
+
JSON.stringify({ enabled: true, telemetry: true, autoReview: true, autoApply: true, idleMs: 5_000 }),
|
|
84
|
+
"utf8",
|
|
85
|
+
);
|
|
55
86
|
await saveConfigSwitch(evolutionDir, false, directQueue);
|
|
56
87
|
const raw = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
|
|
57
|
-
assert.deepEqual([raw.enabled, raw.telemetry, raw.idleMs], [false, false, 5_000]);
|
|
88
|
+
assert.deepEqual([raw.enabled, raw.telemetry, raw.autoReview, raw.autoApply, raw.idleMs], [false, false, false, false, 5_000]);
|
|
58
89
|
});
|
|
59
90
|
|
|
60
91
|
test("normalizeConfig:web 默认开 + 7788(采集/派生仍关),端口边界 1/65535 合法、整块类型不对回退", () => {
|
|
@@ -121,6 +152,23 @@ test("saveFlag:只改目标字段,其余键(含 web 块)原样保留", a
|
|
|
121
152
|
assert.deepEqual([raw.enabled, raw.telemetry, raw.idleMs, raw.web], [false, true, 1_234, { enabled: true, port: 7799 }]);
|
|
122
153
|
});
|
|
123
154
|
|
|
155
|
+
test("saveFlag:autoReview / autoApply 各自独立,翻一个不动其它三个", async () => {
|
|
156
|
+
const evolutionDir = workspace();
|
|
157
|
+
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
158
|
+
await saveFlag(evolutionDir, "enabled", true, directQueue);
|
|
159
|
+
await saveFlag(evolutionDir, "telemetry", true, directQueue);
|
|
160
|
+
await saveFlag(evolutionDir, "autoReview", false, directQueue);
|
|
161
|
+
const raw = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
|
|
162
|
+
assert.deepEqual(
|
|
163
|
+
[raw.enabled, raw.telemetry, raw.autoReview, raw.autoApply],
|
|
164
|
+
[true, true, false, false],
|
|
165
|
+
"关评审不能动复盘/采集,也不能顺手打开自动采纳",
|
|
166
|
+
);
|
|
167
|
+
await saveFlag(evolutionDir, "autoApply", true, directQueue);
|
|
168
|
+
const after = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
|
|
169
|
+
assert.deepEqual([after.enabled, after.telemetry, after.autoReview, after.autoApply], [true, true, false, true]);
|
|
170
|
+
});
|
|
171
|
+
|
|
124
172
|
test("saveFlag:两个字段互不牵连(先开复盘再关采集)", async () => {
|
|
125
173
|
const evolutionDir = workspace();
|
|
126
174
|
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
@@ -132,15 +180,19 @@ test("saveFlag:两个字段互不牵连(先开复盘再关采集)", async
|
|
|
132
180
|
assert.deepEqual([after.enabled, after.telemetry], [true, false], "关采集不动复盘");
|
|
133
181
|
});
|
|
134
182
|
|
|
135
|
-
test("switchLine
|
|
136
|
-
const base = { idleMs: 1, maxRuntimeMs: 1, perSessionCap: 1 };
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
assert.equal(
|
|
142
|
-
|
|
143
|
-
|
|
183
|
+
test("switchLine:四个开关各写一段,只开复盘不采集时带警告", () => {
|
|
184
|
+
const base = { idleMs: 1, maxRuntimeMs: 1, perSessionCap: 1, autoReview: true, autoApply: false };
|
|
185
|
+
const hint = "(collect / dispatch / review / apply 单独控制;on / off 批量切)";
|
|
186
|
+
const allOff = "采集:关(不写遥测)|复盘:关(不会派生 agent)|评审:关(提案停在 proposed 等人工)|采纳:关(auto 提案等人工批准)";
|
|
187
|
+
const collectOnly = "采集:开(遥测写入 .pi/evolution/telemetry/)|复盘:关(不会派生 agent)|评审:开(提案后自动起督导)|采纳:关(auto 提案等人工批准)";
|
|
188
|
+
const both = "采集:开(遥测写入 .pi/evolution/telemetry/)|复盘:开(会派生 agent)|评审:开(提案后自动起督导)|采纳:开(auto 提案自动落盘)";
|
|
189
|
+
assert.equal(
|
|
190
|
+
switchLine({ ...base, enabled: false, telemetry: false, autoReview: false }),
|
|
191
|
+
`${allOff}${hint}`,
|
|
192
|
+
);
|
|
193
|
+
assert.equal(switchLine({ ...base, enabled: false, telemetry: true }), `${collectOnly}${hint}`);
|
|
194
|
+
assert.equal(switchLine({ ...base, enabled: true, telemetry: true, autoApply: true }), `${both}${hint}`);
|
|
195
|
+
const warn = switchLine({ ...base, enabled: true, telemetry: false, autoReview: false });
|
|
144
196
|
assert.equal(warn.startsWith("采集:关(不写遥测)|复盘:开(会派生 agent)"), true);
|
|
145
197
|
assert.equal(warn.includes(TELEMETRY_OFF_WARNING), true, "只开复盘不采集:必须带警告,不许静默");
|
|
146
198
|
});
|
|
@@ -162,6 +162,7 @@ test("shutdown-dispatch:已有活的督导 → 不 spawn,同步写 busy,
|
|
|
162
162
|
telemetry: ctx.telemetry,
|
|
163
163
|
env: {},
|
|
164
164
|
spawnFn: noSpawn,
|
|
165
|
+
autoReview: true,
|
|
165
166
|
alive: ctx.alive,
|
|
166
167
|
log: () => {},
|
|
167
168
|
},
|
|
@@ -186,6 +187,7 @@ test("review-spawn:别的 run 的复盘活着 → 不起督导,写 busy 遥
|
|
|
186
187
|
runDir,
|
|
187
188
|
cwd: ctx.cwd,
|
|
188
189
|
env: {},
|
|
190
|
+
autoReview: true,
|
|
189
191
|
telemetry: ctx.telemetry,
|
|
190
192
|
alive: ctx.alive,
|
|
191
193
|
log: () => {},
|
|
@@ -213,6 +215,7 @@ test("review-spawn:自己这条 run 的复盘活着不算占用,起督导并
|
|
|
213
215
|
runDir,
|
|
214
216
|
cwd: ctx.cwd,
|
|
215
217
|
env: {},
|
|
218
|
+
autoReview: true,
|
|
216
219
|
telemetry: ctx.telemetry,
|
|
217
220
|
alive: ctx.alive,
|
|
218
221
|
log: () => {},
|
|
@@ -25,7 +25,14 @@ class FakeChild {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
function harness(
|
|
28
|
+
function harness(
|
|
29
|
+
options: {
|
|
30
|
+
config?: Partial<EvolutionConfig>;
|
|
31
|
+
selftestFailed?: () => boolean;
|
|
32
|
+
reviewModel?: () => string;
|
|
33
|
+
logs?: string[];
|
|
34
|
+
} = {},
|
|
35
|
+
) {
|
|
29
36
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-sup-"));
|
|
30
37
|
const events: TelemetryEvent[] = [];
|
|
31
38
|
const supervisorCalls: { args: string[]; options: Record<string, unknown> }[] = [];
|
|
@@ -47,7 +54,7 @@ function harness(options: { config?: Partial<EvolutionConfig>; selftestFailed?:
|
|
|
47
54
|
telemetry: writer,
|
|
48
55
|
queue: directQueue,
|
|
49
56
|
promptTemplatePath: TEMPLATE,
|
|
50
|
-
log: () =>
|
|
57
|
+
log: (line: string) => options.logs?.push(line),
|
|
51
58
|
now: () => NOW,
|
|
52
59
|
env: {},
|
|
53
60
|
spawnFn,
|
|
@@ -70,6 +77,23 @@ test("派发成功时同一轮就起评审督导,并把评审模型透传过
|
|
|
70
77
|
assert.equal(args[args.length - 1], "deepseek/deepseek-v3.2");
|
|
71
78
|
});
|
|
72
79
|
|
|
80
|
+
test("自动评审关着:复盘照跑,但不起督导,run 停在 proposed 等人工", async () => {
|
|
81
|
+
const logs: string[] = [];
|
|
82
|
+
const h = harness({ config: { autoReview: false }, logs });
|
|
83
|
+
const outcome = await h.dispatch({ sessionId: "sess-1", skills: ["alpha"] });
|
|
84
|
+
assert.equal(outcome.status, "dispatched", "复盘本身不受评审开关影响");
|
|
85
|
+
assert.equal(h.supervisorCalls.length, 0, "评审关着就一个督导都不许起(子进程也花 token)");
|
|
86
|
+
assert.equal(outcome.status, "dispatched");
|
|
87
|
+
const runDir = outcome.status === "dispatched" ? outcome.run.runDir : "";
|
|
88
|
+
const run = JSON.parse(fs.readFileSync(path.join(runDir, "run.json"), "utf8"));
|
|
89
|
+
assert.equal(run.status, "dispatched", "复盘 agent 还没写提案,状态由它自己推到 proposed");
|
|
90
|
+
assert.equal(
|
|
91
|
+
logs.some((line) => line.includes("自动评审已关") && line.includes(String(run.runId))),
|
|
92
|
+
true,
|
|
93
|
+
"必须留一句日志,否则人只会以为评审坏了",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
73
97
|
test("总开关关闭:既不复盘也不起督导", async () => {
|
|
74
98
|
const h = harness({ config: { enabled: false } });
|
|
75
99
|
const outcome = await h.dispatch({ sessionId: "sess-1", skills: ["alpha"] });
|
|
@@ -35,6 +35,8 @@ interface Harness {
|
|
|
35
35
|
/** 模拟某个子进程退出(收尸会顺手放锁) */
|
|
36
36
|
exitChild: (index: number) => Promise<void>;
|
|
37
37
|
children: FakeChild[];
|
|
38
|
+
/** 评审督导的起进程记录(断言「关着就一个都不起」用) */
|
|
39
|
+
supervisorCalls: { args: string[]; options: Record<string, unknown> }[];
|
|
38
40
|
events: () => TelemetryEvent[];
|
|
39
41
|
tick: (ms: number) => void;
|
|
40
42
|
}
|
|
@@ -54,7 +56,7 @@ class FakeChild {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
|
-
* 夹具的 config.json
|
|
59
|
+
* 夹具的 config.json:生产默认只开自动评审(采集 / 复盘 / 采纳关),所以每个用例都得显式打开;
|
|
58
60
|
* 要验证「默认啥都不做」的用例直接自己建目录、不走这个 helper。
|
|
59
61
|
*/
|
|
60
62
|
function writeConfig(dir: string, config: Record<string, unknown> = {}): void {
|
|
@@ -72,6 +74,7 @@ function harness(env: NodeJS.ProcessEnv = {}, config: Record<string, unknown> =
|
|
|
72
74
|
writeConfig(dir, config);
|
|
73
75
|
let clock = NOW;
|
|
74
76
|
const children: FakeChild[] = [];
|
|
77
|
+
const supervisorCalls: { args: string[]; options: Record<string, unknown> }[] = [];
|
|
75
78
|
const spawnFn: SpawnLike = () => (children.push(new FakeChild()), children[children.length - 1] as unknown as SpawnedChild);
|
|
76
79
|
const evo = createEvolution({
|
|
77
80
|
cwd: dir,
|
|
@@ -83,12 +86,13 @@ function harness(env: NodeJS.ProcessEnv = {}, config: Record<string, unknown> =
|
|
|
83
86
|
spawnFn,
|
|
84
87
|
// 假子进程的 pid 就是本进程 pid(锁回收要真 pid 才判得活);单飞闸用注入的 alive 模拟「子进程已退出」
|
|
85
88
|
alive: () => false,
|
|
86
|
-
supervisorSpawnFn: fakeSupervisorSpawn(
|
|
89
|
+
supervisorSpawnFn: fakeSupervisorSpawn(supervisorCalls),
|
|
87
90
|
});
|
|
88
91
|
return {
|
|
89
92
|
dir,
|
|
90
93
|
evo,
|
|
91
94
|
children,
|
|
95
|
+
supervisorCalls,
|
|
92
96
|
get spawns() {
|
|
93
97
|
return children.length;
|
|
94
98
|
},
|
|
@@ -216,7 +220,12 @@ test("tool_result 对齐不到 tool_call 时静默忽略(跨会话/子代理
|
|
|
216
220
|
test("statusText / reportText:无数据也不崩,且带上盲区提示", async () => {
|
|
217
221
|
const h = harness();
|
|
218
222
|
const status = await h.evo.statusText();
|
|
219
|
-
assert.equal(
|
|
223
|
+
assert.equal(
|
|
224
|
+
status.includes(
|
|
225
|
+
"采集:开(遥测写入 .pi/evolution/telemetry/)|复盘:开(会派生 agent)|评审:开(提案后自动起督导)|采纳:关(auto 提案等人工批准)",
|
|
226
|
+
),
|
|
227
|
+
true,
|
|
228
|
+
);
|
|
220
229
|
assert.equal(status.includes("锁:空闲"), true);
|
|
221
230
|
const report = h.evo.reportText();
|
|
222
231
|
assert.equal(report.includes("无曝光数据"), true);
|
|
@@ -248,29 +257,40 @@ test("reportText:按遥测给出曝光/加载/未命中", async () => {
|
|
|
248
257
|
);
|
|
249
258
|
});
|
|
250
259
|
|
|
251
|
-
test("setEnabled
|
|
260
|
+
test("setEnabled:off 四个全关、on 只开复盘 + 评审(不动采集与自动采纳)", async () => {
|
|
252
261
|
const h = harness();
|
|
253
262
|
const message = await h.evo.setEnabled(false);
|
|
254
263
|
assert.equal(message.includes("关闭"), true);
|
|
255
264
|
const raw = JSON.parse(fs.readFileSync(path.join(h.dir, ".pi", "evolution", "config.json"), "utf8"));
|
|
256
|
-
assert.deepEqual([raw.enabled, raw.telemetry], [false, false]);
|
|
257
|
-
assert.equal((await h.evo.statusText()).includes("采集:关(不写遥测)|复盘:关(不会派生 agent
|
|
265
|
+
assert.deepEqual([raw.enabled, raw.telemetry, raw.autoReview, raw.autoApply], [false, false, false, false]);
|
|
266
|
+
assert.equal((await h.evo.statusText()).includes("采集:关(不写遥测)|复盘:关(不会派生 agent)|评审:关(提案停在 proposed 等人工)|采纳:关(auto 提案等人工批准)"), true);
|
|
258
267
|
|
|
259
268
|
await h.evo.setEnabled(true);
|
|
260
269
|
const back = JSON.parse(fs.readFileSync(path.join(h.dir, ".pi", "evolution", "config.json"), "utf8"));
|
|
261
|
-
|
|
270
|
+
// 用户定的语义:on = 复盘 + 评审两开;采集要另外 collect on,自动采纳要另外 apply on
|
|
271
|
+
assert.deepEqual([back.enabled, back.autoReview, back.telemetry, back.autoApply], [true, true, false, false]);
|
|
262
272
|
});
|
|
263
273
|
|
|
264
|
-
test("setFlag
|
|
265
|
-
const h = harness({}, { enabled: false, telemetry: false });
|
|
274
|
+
test("setFlag:只切一个字段,并把当前四个开关的状态写进回执", async () => {
|
|
275
|
+
const h = harness({}, { enabled: false, telemetry: false, autoReview: false });
|
|
266
276
|
const text = await h.evo.setFlag("telemetry", true);
|
|
267
|
-
//
|
|
268
|
-
assert.equal(
|
|
277
|
+
// 回执要能看出「只开了采集、复盘/评审还关着」,否则用户以为已经会花 token 了
|
|
278
|
+
assert.equal(
|
|
279
|
+
text.includes("采集:开(遥测写入 .pi/evolution/telemetry/)|复盘:关(不会派生 agent)|评审:关(提案停在 proposed 等人工)"),
|
|
280
|
+
true,
|
|
281
|
+
);
|
|
269
282
|
const raw = JSON.parse(fs.readFileSync(path.join(h.dir, ".pi", "evolution", "config.json"), "utf8"));
|
|
270
283
|
assert.deepEqual([raw.enabled, raw.telemetry], [false, true], "setFlag(telemetry) 不碰复盘");
|
|
271
284
|
await h.evo.setFlag("enabled", true);
|
|
272
285
|
const after = JSON.parse(fs.readFileSync(path.join(h.dir, ".pi", "evolution", "config.json"), "utf8"));
|
|
273
286
|
assert.deepEqual([after.enabled, after.telemetry], [true, true], "setFlag(enabled) 不碰采集");
|
|
287
|
+
await h.evo.setFlag("autoReview", true);
|
|
288
|
+
const judged = JSON.parse(fs.readFileSync(path.join(h.dir, ".pi", "evolution", "config.json"), "utf8"));
|
|
289
|
+
assert.deepEqual(
|
|
290
|
+
[judged.enabled, judged.telemetry, judged.autoReview, judged.autoApply],
|
|
291
|
+
[true, true, true, false],
|
|
292
|
+
"review 只翻 autoReview:复盘/采集/采纳都不许跟着动",
|
|
293
|
+
);
|
|
274
294
|
});
|
|
275
295
|
|
|
276
296
|
test("没有 config.json 时默认全关:不派生、不落遥测", async () => {
|
|
@@ -288,19 +308,42 @@ test("没有 config.json 时默认全关:不派生、不落遥测", async () =
|
|
|
288
308
|
supervisorSpawnFn: fakeSupervisorSpawn([]),
|
|
289
309
|
});
|
|
290
310
|
const status = await evo.statusText();
|
|
291
|
-
assert.equal(
|
|
311
|
+
assert.equal(
|
|
312
|
+
status.includes("采集:关(不写遥测)|复盘:关(不会派生 agent)|评审:开(提案后自动起督导)|采纳:关(auto 提案等人工批准)"),
|
|
313
|
+
true,
|
|
314
|
+
"默认只开自动评审:enabled 关着时它永远走不到",
|
|
315
|
+
);
|
|
292
316
|
evo.recordAdvertised({ sessionId: "s1", cwd: dir, prompt: "x", skills: [skill("a")] });
|
|
293
317
|
evo.settle({ sessionId: "s1" });
|
|
294
318
|
await settleAsync();
|
|
295
319
|
assert.deepEqual(readTelemetry(dir), [], "默认关:遥测也不写");
|
|
296
320
|
});
|
|
297
321
|
|
|
298
|
-
test("
|
|
322
|
+
test("autoReview 关着:退出前补发照跑,但不起督导(run 停在 proposed 等人工)", async () => {
|
|
323
|
+
const h = harness({}, { autoReview: false });
|
|
324
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "第1轮", skills: [skill("a")] });
|
|
325
|
+
h.evo.settle({ sessionId: "s1" });
|
|
326
|
+
await settleAsync();
|
|
327
|
+
assert.equal(h.spawns, 1, "复盘本身照旧派发");
|
|
328
|
+
assert.equal(h.supervisorCalls.length, 0, "评审关着:派发路径也不许起督导");
|
|
329
|
+
await h.exitChild(0);
|
|
330
|
+
|
|
331
|
+
h.tick(1000);
|
|
332
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "第2轮", skills: [skill("b")] });
|
|
333
|
+
h.evo.settle({ sessionId: "s1" });
|
|
334
|
+
await settleAsync();
|
|
335
|
+
h.evo.shutdown();
|
|
336
|
+
assert.equal(h.spawns, 2, "退出补发这条路径同样要派发复盘");
|
|
337
|
+
assert.equal(h.supervisorCalls.length, 0, "退出补发路径也要看评审开关");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("telemetry 关着时只记开关状态不落盘(collect on 之后才采集)", async () => {
|
|
299
341
|
const h = harness({}, { telemetry: false });
|
|
300
342
|
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "x", skills: [skill("a")] });
|
|
301
343
|
await settleAsync();
|
|
302
344
|
assert.deepEqual(h.events(), []);
|
|
303
|
-
|
|
345
|
+
// 2026-11-08 起 on 不再顺带开采集:采集必须显式 collect on
|
|
346
|
+
await h.evo.setFlag("telemetry", true);
|
|
304
347
|
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "y", skills: [skill("a")] });
|
|
305
348
|
await settleAsync();
|
|
306
349
|
assert.equal(h.events().some((event) => event.kind === "advertised"), true, "on 之后同一会话就采集");
|
|
@@ -51,6 +51,8 @@ function tools(fixture: Fixture) {
|
|
|
51
51
|
git: () => ({ code: 0, out: "" }),
|
|
52
52
|
loadDiagnostics: () => [],
|
|
53
53
|
runMdCheck: () => ({ code: 0, output: "" }),
|
|
54
|
+
// 开关默认全开:拒绝侧的用例只关心队列/run 状态,不该被自动采纳开关挡住
|
|
55
|
+
flags: () => ({ autoReview: true, autoApply: true }),
|
|
54
56
|
});
|
|
55
57
|
}
|
|
56
58
|
|