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.
- package/CHANGELOG.md +12 -0
- package/README.md +81 -2
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
- package/extensions/skill-evolve/index.ts +300 -0
- package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
- package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
- package/extensions/skill-evolve/lib/apply.ts +141 -0
- package/extensions/skill-evolve/lib/attribution.ts +138 -0
- package/extensions/skill-evolve/lib/audit.ts +221 -0
- package/extensions/skill-evolve/lib/baseline.ts +176 -0
- package/extensions/skill-evolve/lib/child-env.ts +10 -0
- package/extensions/skill-evolve/lib/classify-action.ts +161 -0
- package/extensions/skill-evolve/lib/command.ts +100 -0
- package/extensions/skill-evolve/lib/config-command.ts +38 -0
- package/extensions/skill-evolve/lib/config.ts +194 -0
- package/extensions/skill-evolve/lib/detect.ts +62 -0
- package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
- package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
- package/extensions/skill-evolve/lib/dispatch.ts +300 -0
- package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
- package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
- package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
- package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
- package/extensions/skill-evolve/lib/evolution.ts +289 -0
- package/extensions/skill-evolve/lib/flags.ts +44 -0
- package/extensions/skill-evolve/lib/gate0.ts +78 -0
- package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
- package/extensions/skill-evolve/lib/lock.ts +215 -0
- package/extensions/skill-evolve/lib/logfile.ts +52 -0
- package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
- package/extensions/skill-evolve/lib/mechanical.ts +136 -0
- package/extensions/skill-evolve/lib/metrics.ts +172 -0
- package/extensions/skill-evolve/lib/model-config.ts +95 -0
- package/extensions/skill-evolve/lib/notify.ts +132 -0
- package/extensions/skill-evolve/lib/panel.ts +174 -0
- package/extensions/skill-evolve/lib/pending.ts +34 -0
- package/extensions/skill-evolve/lib/proc.ts +65 -0
- package/extensions/skill-evolve/lib/prompt.ts +80 -0
- package/extensions/skill-evolve/lib/proposal.ts +227 -0
- package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
- package/extensions/skill-evolve/lib/queue.ts +136 -0
- package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
- package/extensions/skill-evolve/lib/replay.ts +246 -0
- package/extensions/skill-evolve/lib/report.ts +181 -0
- package/extensions/skill-evolve/lib/review-post.ts +101 -0
- package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
- package/extensions/skill-evolve/lib/review-record.ts +63 -0
- package/extensions/skill-evolve/lib/review-run.ts +131 -0
- package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
- package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
- package/extensions/skill-evolve/lib/round.ts +116 -0
- package/extensions/skill-evolve/lib/run-watch.ts +34 -0
- package/extensions/skill-evolve/lib/runs.ts +208 -0
- package/extensions/skill-evolve/lib/selftest.ts +201 -0
- package/extensions/skill-evolve/lib/session-index.ts +145 -0
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
- package/extensions/skill-evolve/lib/single-flight.ts +111 -0
- package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
- package/extensions/skill-evolve/lib/stale.ts +217 -0
- package/extensions/skill-evolve/lib/telemetry.ts +205 -0
- package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
- package/extensions/skill-evolve/lib/web/actions.ts +299 -0
- package/extensions/skill-evolve/lib/web/activity.ts +57 -0
- package/extensions/skill-evolve/lib/web/applied.ts +64 -0
- package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
- package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
- package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
- package/extensions/skill-evolve/lib/web/data.ts +298 -0
- package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
- package/extensions/skill-evolve/lib/web/filter.ts +203 -0
- package/extensions/skill-evolve/lib/web/http.ts +68 -0
- package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
- package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
- package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
- package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
- package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
- package/extensions/skill-evolve/lib/web/render.ts +136 -0
- package/extensions/skill-evolve/lib/web/reports.ts +160 -0
- package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
- package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
- package/extensions/skill-evolve/lib/web/server.ts +268 -0
- package/extensions/skill-evolve/lib/web/shell.ts +87 -0
- package/extensions/skill-evolve/lib/web/skills.ts +202 -0
- package/extensions/skill-evolve/lib/web/style.ts +107 -0
- package/extensions/skill-evolve/lib/web/text.ts +52 -0
- package/extensions/skill-evolve/lib/web/trash.ts +36 -0
- package/extensions/skill-evolve/lib/weekly.ts +285 -0
- package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
- package/extensions/skill-evolve/scripts/replay.ts +93 -0
- package/extensions/skill-evolve/test/apply.test.ts +278 -0
- package/extensions/skill-evolve/test/approve.test.ts +248 -0
- package/extensions/skill-evolve/test/attribution.test.ts +133 -0
- package/extensions/skill-evolve/test/audit.test.ts +151 -0
- package/extensions/skill-evolve/test/baseline.test.ts +132 -0
- package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
- package/extensions/skill-evolve/test/command.test.ts +267 -0
- package/extensions/skill-evolve/test/config-command.test.ts +73 -0
- package/extensions/skill-evolve/test/config.test.ts +154 -0
- package/extensions/skill-evolve/test/detect.test.ts +71 -0
- package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
- package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
- package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
- package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
- package/extensions/skill-evolve/test/evolution.test.ts +378 -0
- package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
- package/extensions/skill-evolve/test/hard-rules.test.ts +123 -0
- package/extensions/skill-evolve/test/lock.test.ts +138 -0
- package/extensions/skill-evolve/test/logfile.test.ts +85 -0
- package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
- package/extensions/skill-evolve/test/metrics.test.ts +129 -0
- package/extensions/skill-evolve/test/model-config.test.ts +84 -0
- package/extensions/skill-evolve/test/notify.test.ts +133 -0
- package/extensions/skill-evolve/test/observe.test.ts +100 -0
- package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
- package/extensions/skill-evolve/test/panel.test.ts +115 -0
- package/extensions/skill-evolve/test/proposal.test.ts +159 -0
- package/extensions/skill-evolve/test/queue.test.ts +110 -0
- package/extensions/skill-evolve/test/reject.test.ts +134 -0
- package/extensions/skill-evolve/test/replay.test.ts +111 -0
- package/extensions/skill-evolve/test/report.test.ts +66 -0
- package/extensions/skill-evolve/test/review-post.test.ts +92 -0
- package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
- package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
- package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
- package/extensions/skill-evolve/test/selftest.test.ts +113 -0
- package/extensions/skill-evolve/test/session-index.test.ts +128 -0
- package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
- package/extensions/skill-evolve/test/stale.test.ts +272 -0
- package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
- package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
- package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
- package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
- package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
- package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
- package/extensions/skill-evolve/test/web-data.test.ts +420 -0
- package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
- package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
- package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
- package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
- package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
- package/extensions/skill-evolve/test/web-render.test.ts +300 -0
- package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
- package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
- package/extensions/skill-evolve/test/web-server.test.ts +465 -0
- package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
- package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
- package/extensions/skill-evolve/test/weekly.test.ts +123 -0
- package/package.json +34 -4
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/skill-evolve config` 单测:TUI 用 select,非 TUI 只打印当前值。
|
|
3
|
+
* tools 用假实现,验证的是「选了哪一项 → 写成什么值」以及取消行为。
|
|
4
|
+
*/
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { runConfigCommand } from "../lib/config-command.ts";
|
|
8
|
+
import type { ModelTools } from "../lib/evolution-model.ts";
|
|
9
|
+
import { INHERIT, modelChoices } from "../lib/model-config.ts";
|
|
10
|
+
|
|
11
|
+
function fakeTools(calls: string[]): ModelTools {
|
|
12
|
+
let config = { propose: INHERIT, review: INHERIT };
|
|
13
|
+
return {
|
|
14
|
+
config: () => config,
|
|
15
|
+
reviewModel: () => config.review,
|
|
16
|
+
setModel: async (kind, spec) => {
|
|
17
|
+
calls.push(`${kind}=${spec}`);
|
|
18
|
+
config = { ...config, [kind]: spec };
|
|
19
|
+
return `${kind} 已设为 ${spec}`;
|
|
20
|
+
},
|
|
21
|
+
selftestText: async () => "自检:通过",
|
|
22
|
+
selftestFailed: () => false,
|
|
23
|
+
lastSelftest: () => undefined,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MODELS = [{ provider: "deepseek", id: "deepseek-v3.2" }, { provider: "openai", id: "gpt-5" }];
|
|
28
|
+
|
|
29
|
+
test("非 TUI:不提问,只打印当前值与手改入口", async () => {
|
|
30
|
+
const calls: string[] = [];
|
|
31
|
+
const text = await runConfigCommand(null, fakeTools(calls));
|
|
32
|
+
assert.match(text, /无法交互选择/);
|
|
33
|
+
assert.match(text, /propose\.model/);
|
|
34
|
+
assert.deepEqual(calls, []);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("TUI:选『跟随主会话』写 inherit,两个键都会问", async () => {
|
|
38
|
+
const calls: string[] = [];
|
|
39
|
+
const asked: string[] = [];
|
|
40
|
+
const text = await runConfigCommand(
|
|
41
|
+
{
|
|
42
|
+
models: MODELS,
|
|
43
|
+
select: async (title: string, options: string[]) => {
|
|
44
|
+
asked.push(`${title}|${options.join(",")}`);
|
|
45
|
+
return options[0];
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
fakeTools(calls),
|
|
49
|
+
);
|
|
50
|
+
assert.deepEqual(calls, [`propose=${INHERIT}`, `review=${INHERIT}`]);
|
|
51
|
+
assert.equal(asked.length, 2);
|
|
52
|
+
assert.ok(asked[0].includes(MODELS[0].provider));
|
|
53
|
+
assert.match(text, /当前配置/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("TUI:选具体模型写 provider/id;只问 review 时只改 review", async () => {
|
|
57
|
+
const calls: string[] = [];
|
|
58
|
+
const choices = modelChoices(MODELS);
|
|
59
|
+
const text = await runConfigCommand(
|
|
60
|
+
{ models: MODELS, select: async (_title: string, options: string[]) => options[2] ?? choices[1] },
|
|
61
|
+
fakeTools(calls),
|
|
62
|
+
"review",
|
|
63
|
+
);
|
|
64
|
+
assert.deepEqual(calls, ["review=openai/gpt-5"]);
|
|
65
|
+
assert.match(text, /review/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("取消选择:写回中止,配置不动", async () => {
|
|
69
|
+
const calls: string[] = [];
|
|
70
|
+
const text = await runConfigCommand({ models: MODELS, select: async () => undefined }, fakeTools(calls));
|
|
71
|
+
assert.deepEqual(calls, []);
|
|
72
|
+
assert.match(text, /已取消/);
|
|
73
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置默认值:别人 clone 下来必须「什么都不做」—— 默认全关、无 config.json 也不生成。
|
|
3
|
+
* on/off 只切 enabled + telemetry 两个键,其余(去抖 / 超时 / 上限)原样保留。
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { configFile, DEFAULT_CONFIG, loadConfig, normalizeConfig, saveConfigSwitch, saveFlag, saveWebConfig } from "../lib/config.ts";
|
|
11
|
+
import { TELEMETRY_OFF_WARNING } from "../lib/flags.ts";
|
|
12
|
+
import { switchLine } from "../lib/report.ts";
|
|
13
|
+
import { directQueue } from "../lib/telemetry.ts";
|
|
14
|
+
|
|
15
|
+
function workspace(): string {
|
|
16
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-config-"));
|
|
17
|
+
return path.join(cwd, ".pi", "evolution");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("DEFAULT_CONFIG:采集与派生都默认关(clone 即静默)", () => {
|
|
21
|
+
assert.deepEqual([DEFAULT_CONFIG.enabled, DEFAULT_CONFIG.telemetry], [false, false]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("loadConfig:没有 config.json 时全关,且不会顺手造文件", () => {
|
|
25
|
+
const evolutionDir = workspace();
|
|
26
|
+
const config = loadConfig(evolutionDir);
|
|
27
|
+
assert.deepEqual([config.enabled, config.telemetry], [false, false]);
|
|
28
|
+
assert.equal(fs.existsSync(configFile(evolutionDir)), false, "读配置不能有副作用");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("normalizeConfig:空对象与缺字段都回落到全关", () => {
|
|
32
|
+
assert.deepEqual([normalizeConfig({}).enabled, normalizeConfig({}).telemetry], [false, false]);
|
|
33
|
+
const onlyEnabled = normalizeConfig({ enabled: true });
|
|
34
|
+
assert.deepEqual([onlyEnabled.enabled, onlyEnabled.telemetry], [true, false], "只写 enabled 不等于打开采集");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("saveConfigSwitch:on 两个键都开,其余字段原样保留", async () => {
|
|
38
|
+
const evolutionDir = workspace();
|
|
39
|
+
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
40
|
+
fs.writeFileSync(
|
|
41
|
+
configFile(evolutionDir),
|
|
42
|
+
JSON.stringify({ enabled: false, telemetry: false, idleMs: 1_234, perSessionCap: 3 }),
|
|
43
|
+
"utf8",
|
|
44
|
+
);
|
|
45
|
+
const next = await saveConfigSwitch(evolutionDir, true, directQueue);
|
|
46
|
+
assert.deepEqual([next.enabled, next.telemetry, next.idleMs, next.perSessionCap], [true, true, 1_234, 3]);
|
|
47
|
+
const raw = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
|
|
48
|
+
assert.deepEqual([raw.enabled, raw.telemetry, raw.idleMs, raw.perSessionCap], [true, true, 1_234, 3]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("saveConfigSwitch:off 两个键都关", async () => {
|
|
52
|
+
const evolutionDir = workspace();
|
|
53
|
+
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
54
|
+
fs.writeFileSync(configFile(evolutionDir), JSON.stringify({ enabled: true, telemetry: true, idleMs: 5_000 }), "utf8");
|
|
55
|
+
await saveConfigSwitch(evolutionDir, false, directQueue);
|
|
56
|
+
const raw = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
|
|
57
|
+
assert.deepEqual([raw.enabled, raw.telemetry, raw.idleMs], [false, false, 5_000]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("normalizeConfig:web 默认开 + 7788(采集/派生仍关),端口边界 1/65535 合法、整块类型不对回退", () => {
|
|
61
|
+
assert.deepEqual(normalizeConfig({}).web, { enabled: true, port: 7788 });
|
|
62
|
+
assert.deepEqual([normalizeConfig({}).enabled, normalizeConfig({}).telemetry], [false, false], "面板默认开 ≠ 开始烧 token");
|
|
63
|
+
assert.equal(normalizeConfig({ web: { port: 1 } }).web.port, 1, "1 是合法端口,不能当假值丢掉");
|
|
64
|
+
assert.equal(normalizeConfig({ web: { port: 65_535 } }).web.port, 65_535);
|
|
65
|
+
assert.equal(normalizeConfig({ web: { port: 65_536 } }).web.port, 7788);
|
|
66
|
+
assert.equal(normalizeConfig({ web: { port: 7788.5 } }).web.port, 7788, "非整数端口回落");
|
|
67
|
+
assert.deepEqual(normalizeConfig({ web: "on" }).web, { enabled: true, port: 7788 }, "整块类型不对就整块回退");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("saveConfigSwitch:不碰 web 块(开关采集/派生不该顺手关掉面板)", async () => {
|
|
71
|
+
const evolutionDir = workspace();
|
|
72
|
+
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
73
|
+
fs.writeFileSync(
|
|
74
|
+
configFile(evolutionDir),
|
|
75
|
+
JSON.stringify({ enabled: false, telemetry: false, web: { enabled: true, port: 7799 } }),
|
|
76
|
+
"utf8",
|
|
77
|
+
);
|
|
78
|
+
await saveConfigSwitch(evolutionDir, true, directQueue);
|
|
79
|
+
const raw = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
|
|
80
|
+
assert.deepEqual(raw.web, { enabled: true, port: 7799 });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("saveWebConfig:只改 web,其余键原样保留", async () => {
|
|
84
|
+
const evolutionDir = workspace();
|
|
85
|
+
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
86
|
+
fs.writeFileSync(
|
|
87
|
+
configFile(evolutionDir),
|
|
88
|
+
JSON.stringify({ enabled: true, telemetry: true, idleMs: 1_234, web: { enabled: false, port: 7788 } }),
|
|
89
|
+
"utf8",
|
|
90
|
+
);
|
|
91
|
+
const next = await saveWebConfig(evolutionDir, { enabled: true, port: 7799 }, directQueue);
|
|
92
|
+
assert.deepEqual([next.enabled, next.telemetry, next.idleMs, next.web.port], [true, true, 1_234, 7799]);
|
|
93
|
+
const raw = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
|
|
94
|
+
assert.deepEqual([raw.enabled, raw.telemetry, raw.idleMs], [true, true, 1_234], "写 web 不能把开关或去抖改掉");
|
|
95
|
+
assert.deepEqual(raw.web, { enabled: true, port: 7799 });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("loadConfig:坏 JSON 回落默认值,并留一行日志", () => {
|
|
99
|
+
const evolutionDir = workspace();
|
|
100
|
+
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
101
|
+
fs.writeFileSync(configFile(evolutionDir), "{ 坏掉", "utf8");
|
|
102
|
+
const logs: string[] = [];
|
|
103
|
+
const config = loadConfig(evolutionDir, (msg) => logs.push(msg));
|
|
104
|
+
assert.deepEqual([config.enabled, config.telemetry], [false, false]);
|
|
105
|
+
assert.equal(logs.length, 1);
|
|
106
|
+
assert.match(logs[0]!, /配置读取失败/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("saveFlag:只改目标字段,其余键(含 web 块)原样保留", async () => {
|
|
110
|
+
const evolutionDir = workspace();
|
|
111
|
+
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
112
|
+
fs.writeFileSync(
|
|
113
|
+
configFile(evolutionDir),
|
|
114
|
+
JSON.stringify({ enabled: false, telemetry: false, idleMs: 1_234, web: { enabled: true, port: 7799 } }),
|
|
115
|
+
"utf8",
|
|
116
|
+
);
|
|
117
|
+
const next = await saveFlag(evolutionDir, "telemetry", true, directQueue);
|
|
118
|
+
const raw = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
|
|
119
|
+
// 返回值与盘上都要是「只翻采集」:复盘、去抖、web 端口一个都不许跟着动
|
|
120
|
+
assert.deepEqual([next.enabled, next.telemetry, next.idleMs, next.web.port], [false, true, 1_234, 7799]);
|
|
121
|
+
assert.deepEqual([raw.enabled, raw.telemetry, raw.idleMs, raw.web], [false, true, 1_234, { enabled: true, port: 7799 }]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("saveFlag:两个字段互不牵连(先开复盘再关采集)", async () => {
|
|
125
|
+
const evolutionDir = workspace();
|
|
126
|
+
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
127
|
+
await saveFlag(evolutionDir, "enabled", true, directQueue);
|
|
128
|
+
const raw = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
|
|
129
|
+
assert.deepEqual([raw.enabled, raw.telemetry], [true, false], "只开复盘时采集仍关");
|
|
130
|
+
await saveFlag(evolutionDir, "telemetry", false, directQueue);
|
|
131
|
+
const after = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
|
|
132
|
+
assert.deepEqual([after.enabled, after.telemetry], [true, false], "关采集不动复盘");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("switchLine:采集与复盘分两段写,第四种组合带警告", () => {
|
|
136
|
+
const base = { idleMs: 1, maxRuntimeMs: 1, perSessionCap: 1 };
|
|
137
|
+
const off = "采集:关(不写遥测)|复盘:关(不会派生 agent)";
|
|
138
|
+
const collectOnly = "采集:开(遥测写入 .pi/evolution/telemetry/)|复盘:关(不会派生 agent)";
|
|
139
|
+
const both = "采集:开(遥测写入 .pi/evolution/telemetry/)|复盘:开(会派生 agent)";
|
|
140
|
+
assert.equal(switchLine({ ...base, enabled: false, telemetry: false }), `${off}(/skill-evolve on 全开;collect / review 单独控制)`);
|
|
141
|
+
assert.equal(switchLine({ ...base, enabled: false, telemetry: true }), `${collectOnly}(/skill-evolve on 全开;collect / review 单独控制)`);
|
|
142
|
+
assert.equal(switchLine({ ...base, enabled: true, telemetry: true }), `${both}(/skill-evolve off 全关;collect / review 单独控制)`);
|
|
143
|
+
const warn = switchLine({ ...base, enabled: true, telemetry: false });
|
|
144
|
+
assert.equal(warn.startsWith("采集:关(不写遥测)|复盘:开(会派生 agent)"), true);
|
|
145
|
+
assert.equal(warn.includes(TELEMETRY_OFF_WARNING), true, "只开复盘不采集:必须带警告,不许静默");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("normalizeConfig:ui 两个开关缺字段回退 false,显式打开才生效", () => {
|
|
149
|
+
// 2026-11-08 用户「tui 的这些推送不看了」:默认零推送是硬默认,不许因为老配置没写 ui 就回到开
|
|
150
|
+
assert.deepEqual(normalizeConfig({}).ui, { widget: false, entries: false });
|
|
151
|
+
assert.deepEqual(normalizeConfig({ ui: { widget: true } }).ui, { widget: true, entries: false });
|
|
152
|
+
assert.deepEqual(normalizeConfig({ ui: { widget: "yes", entries: 1 } }).ui, { widget: false, entries: false }, "非布尔值一律回退");
|
|
153
|
+
assert.deepEqual(normalizeConfig({ ui: "on" }).ui, { widget: false, entries: false }, "整块类型不对就整块回退");
|
|
154
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能文件识别单测(零依赖,node --test)。
|
|
3
|
+
*
|
|
4
|
+
* 运行:
|
|
5
|
+
* cd .pi/extensions/skill-evolve
|
|
6
|
+
* node --experimental-strip-types --test test/detect.test.ts
|
|
7
|
+
*
|
|
8
|
+
* 覆盖:read 的绝对/相对/带 @ 路径、非技能文件不误判、bash 命令带引号与不带引号、
|
|
9
|
+
* 同一条命令里两个技能、以及路径以 `skills` 之外的目录名结尾时不误判。
|
|
10
|
+
*/
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
|
|
14
|
+
import { detectFromBash, detectFromRead, skillNameFromPath } from "../lib/detect.ts";
|
|
15
|
+
|
|
16
|
+
const CWD = "/home/suveng/doger/ev/vkk_client_flutter";
|
|
17
|
+
|
|
18
|
+
test("read:绝对路径命中技能", () => {
|
|
19
|
+
const hit = detectFromRead(`${CWD}/.pi/skills/kb-test-map/SKILL.md`, CWD);
|
|
20
|
+
assert.deepEqual(hit, { skill: "kb-test-map", how: "read", raw: `${CWD}/.pi/skills/kb-test-map/SKILL.md` });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("read:相对路径按 cwd 解析成绝对路径", () => {
|
|
24
|
+
const hit = detectFromRead(".pi/skills/fast-test/SKILL.md", CWD);
|
|
25
|
+
assert.equal(hit?.skill, "fast-test");
|
|
26
|
+
assert.equal(hit?.raw, `${CWD}/.pi/skills/fast-test/SKILL.md`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("read:带 @ 前缀与反斜杠路径同样命中", () => {
|
|
30
|
+
assert.equal(detectFromRead("@.pi/skills/file-size/SKILL.md", CWD)?.skill, "file-size");
|
|
31
|
+
assert.equal(detectFromRead("C:\\repo\\.pi\\skills\\file-size\\SKILL.md", CWD)?.skill, "file-size");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("read:非技能文件不误判", () => {
|
|
35
|
+
assert.equal(detectFromRead(".pi/skills/fast-test/reference.md", CWD), null);
|
|
36
|
+
assert.equal(detectFromRead("AGENTS.md", CWD), null);
|
|
37
|
+
assert.equal(detectFromRead("", CWD), null);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("路径规则只认「父目录名 + SKILL.md」,是否真属曝光技能由上层过滤", () => {
|
|
41
|
+
// 裸 `SKILL.md` 会被解析成 <cwd>/SKILL.md,父目录名是仓库名;它永远不会命中本轮的曝光清单,
|
|
42
|
+
// 所以上层按 advertised 名字过滤后不会错记(见 evolution.ts 的 noteToolCall)
|
|
43
|
+
assert.equal(detectFromRead("SKILL.md", CWD)?.skill, "vkk_client_flutter");
|
|
44
|
+
assert.equal(skillNameFromPath("/repo/SKILL.md"), "repo");
|
|
45
|
+
assert.equal(skillNameFromPath("/repo/.pi/skills/"), null);
|
|
46
|
+
assert.equal(skillNameFromPath("/repo/.pi/skills/foo/assets/SKILL.md"), "assets");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("bash:不带引号的 cat 命中", () => {
|
|
50
|
+
const hits = detectFromBash("cat .pi/skills/kb-test-map/SKILL.md | head -40");
|
|
51
|
+
assert.deepEqual(hits.map((hit) => hit.skill), ["kb-test-map"]);
|
|
52
|
+
assert.equal(hits[0].how, "bash");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("bash:带双引号/单引号同样命中", () => {
|
|
56
|
+
assert.deepEqual(detectFromBash('sed -n "1,20p" ".pi/skills/fast-test/SKILL.md"').map((h) => h.skill), ["fast-test"]);
|
|
57
|
+
assert.deepEqual(detectFromBash("rg -n 标题 '.pi/skills/git-workflow/SKILL.md'").map((h) => h.skill), ["git-workflow"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("bash:绝对路径 + 一条命令里两个技能,按名字去重", () => {
|
|
61
|
+
const hits = detectFromBash(
|
|
62
|
+
`diff -u ${CWD}/.pi/skills/rust/SKILL.md /tmp/rust/SKILL.md && cat .pi/skills/rust/SKILL.md`,
|
|
63
|
+
);
|
|
64
|
+
assert.deepEqual(hits.map((hit) => hit.skill), ["rust"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("bash:不含 SKILL.md 的命令直接返回空数组", () => {
|
|
68
|
+
assert.deepEqual(detectFromBash("ls .pi/skills"), []);
|
|
69
|
+
assert.deepEqual(detectFromBash("cat README.md"), []);
|
|
70
|
+
assert.deepEqual(detectFromBash(""), []);
|
|
71
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diff 应用单测:评审要看「改后全文」,diff 能不能精确落到基线上必须先证明。
|
|
3
|
+
* 不支持模糊定位是刻意的——定位失败时宁可如实说失败,也不能拼出一份假的改后全文。
|
|
4
|
+
*/
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { applyUnifiedDiff } from "../lib/diff-apply.ts";
|
|
8
|
+
|
|
9
|
+
const BASE = ["---", "name: demo", "description: 旧描述", "---", "", "正文第一行", "正文第二行"].join("\n");
|
|
10
|
+
|
|
11
|
+
test("标准 hunk 精确替换并返回文件名", () => {
|
|
12
|
+
const diff = ["--- a/.pi/skills/demo/SKILL.md", "+++ b/.pi/skills/demo/SKILL.md", "@@ -2,2 +2,2 @@", " name: demo", "-description: 旧描述", "+description: 新描述"].join("\n");
|
|
13
|
+
const result = applyUnifiedDiff(BASE, diff);
|
|
14
|
+
assert.equal(result.ok, true);
|
|
15
|
+
assert.equal(result.file, ".pi/skills/demo/SKILL.md");
|
|
16
|
+
assert.ok(result.text.includes("description: 新描述"));
|
|
17
|
+
assert.ok(!result.text.includes("旧描述"));
|
|
18
|
+
assert.ok(result.text.includes("正文第二行"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("行尾空白差异不算不匹配", () => {
|
|
22
|
+
const diff = ["+++ b/.pi/skills/demo/SKILL.md", "@@ -3,1 +3,1 @@", "-description: 旧描述 ", "+description: 新描述"].join("\n");
|
|
23
|
+
const result = applyUnifiedDiff(BASE, diff);
|
|
24
|
+
assert.equal(result.ok, true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("基线对不上就返回 ok:false 并给原因", () => {
|
|
28
|
+
const diff = ["+++ b/.pi/skills/demo/SKILL.md", "@@ -3,1 +3,1 @@", "-description: 完全不存在的行", "+description: 新描述"].join("\n");
|
|
29
|
+
const result = applyUnifiedDiff(BASE, diff);
|
|
30
|
+
assert.equal(result.ok, false);
|
|
31
|
+
assert.match(result.reason ?? "", /不一致/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("空 diff 视为失败而不是返回原文", () => {
|
|
35
|
+
const result = applyUnifiedDiff(BASE, "");
|
|
36
|
+
assert.equal(result.ok, false);
|
|
37
|
+
assert.equal(result.file, null);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("多行增删都落到结果里", () => {
|
|
41
|
+
const diff = ["+++ b/.pi/skills/demo/SKILL.md", "@@ -6,2 +6,3 @@", " 正文第一行", "-正文第二行", "+正文第二行(改)", "+新增第三行"].join("\n");
|
|
42
|
+
const result = applyUnifiedDiff(BASE, diff);
|
|
43
|
+
assert.equal(result.ok, true);
|
|
44
|
+
assert.ok(result.text.includes("正文第二行(改)"));
|
|
45
|
+
assert.ok(result.text.includes("新增第三行"));
|
|
46
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 全局单飞闸的三处接入点(用户 2026-11-08:「每次最多一个复盘agent」):
|
|
3
|
+
* 派发 / 退出补发 / 评审督导都必须在已有活着子进程时**不 spawn**,并写 `skipped(busy)` 遥测。
|
|
4
|
+
*
|
|
5
|
+
* spawn 一律换成抛错或计数的假函数:这个文件不产生任何真进程。
|
|
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 { fileURLToPath } from "node:url";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
|
|
14
|
+
import { DEFAULT_CONFIG } from "../lib/config.ts";
|
|
15
|
+
import { createDispatcher } from "../lib/dispatch.ts";
|
|
16
|
+
import { spawnReviewSupervisor } from "../lib/review-spawn.ts";
|
|
17
|
+
import { flushPendingSync } from "../lib/shutdown-dispatch.ts";
|
|
18
|
+
import type { SpawnedChild, SpawnLike } from "../lib/spawn-plan.ts";
|
|
19
|
+
import { directQueue, type TelemetryInput, type TelemetryWriter } from "../lib/telemetry.ts";
|
|
20
|
+
|
|
21
|
+
const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "prompts", "复盘.md");
|
|
22
|
+
const NOW = 1_760_000_000_000;
|
|
23
|
+
const OCCUPIED_PID = 7777;
|
|
24
|
+
|
|
25
|
+
class FakeChild {
|
|
26
|
+
readonly pid: number;
|
|
27
|
+
constructor(pid: number) {
|
|
28
|
+
this.pid = pid;
|
|
29
|
+
}
|
|
30
|
+
unref(): void {
|
|
31
|
+
// 测试里不存在真子进程,无需交还
|
|
32
|
+
}
|
|
33
|
+
once(): this {
|
|
34
|
+
// 不收尸:本文件只验证「有没有 spawn」,退出一条另有用例
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Ctx {
|
|
40
|
+
cwd: string;
|
|
41
|
+
evolutionDir: string;
|
|
42
|
+
spawns: number;
|
|
43
|
+
supervisorSpawns: number;
|
|
44
|
+
events: TelemetryInput[];
|
|
45
|
+
syncEvents: TelemetryInput[];
|
|
46
|
+
telemetry: TelemetryWriter;
|
|
47
|
+
/** 只有 OCCUPIED_PID 算活着:其余 pid(含本进程)一律当已退出 */
|
|
48
|
+
alive: (pid: number) => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function workspace(): Ctx {
|
|
52
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-busy-"));
|
|
53
|
+
const events: TelemetryInput[] = [];
|
|
54
|
+
const syncEvents: TelemetryInput[] = [];
|
|
55
|
+
const ctx: Ctx = {
|
|
56
|
+
cwd,
|
|
57
|
+
evolutionDir: path.join(cwd, ".pi", "evolution"),
|
|
58
|
+
spawns: 0,
|
|
59
|
+
supervisorSpawns: 0,
|
|
60
|
+
events,
|
|
61
|
+
syncEvents,
|
|
62
|
+
telemetry: {
|
|
63
|
+
append: (event) => events.push(event),
|
|
64
|
+
appendSync: (event) => syncEvents.push(event),
|
|
65
|
+
flush: async () => undefined,
|
|
66
|
+
},
|
|
67
|
+
alive: (pid) => pid === OCCUPIED_PID,
|
|
68
|
+
};
|
|
69
|
+
return ctx;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 造一条「已被别人占着」的 run:pid 活着 */
|
|
73
|
+
function occupy(ctx: Ctx, runId = "other"): string {
|
|
74
|
+
const runDir = path.join(ctx.evolutionDir, "runs", runId);
|
|
75
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
76
|
+
fs.writeFileSync(
|
|
77
|
+
path.join(runDir, "run.json"),
|
|
78
|
+
JSON.stringify({ runId, status: "dispatched", pid: OCCUPIED_PID, dispatchedAt: NOW - 60_000 }),
|
|
79
|
+
"utf8",
|
|
80
|
+
);
|
|
81
|
+
return runDir;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readRun(runDir: string): Record<string, unknown> {
|
|
85
|
+
return JSON.parse(fs.readFileSync(path.join(runDir, "run.json"), "utf8"));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const noSpawn: SpawnLike = () => {
|
|
89
|
+
throw new Error("全局单飞闸挡住的派发不该 spawn");
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
test("dispatch:已有活的复盘子进程 → 不 spawn,返回 busy 并写遥测", async () => {
|
|
93
|
+
const ctx = workspace();
|
|
94
|
+
occupy(ctx);
|
|
95
|
+
const dispatcher = createDispatcher({
|
|
96
|
+
evolutionDir: ctx.evolutionDir,
|
|
97
|
+
cwd: ctx.cwd,
|
|
98
|
+
config: { ...DEFAULT_CONFIG, enabled: true, telemetry: true },
|
|
99
|
+
telemetry: ctx.telemetry,
|
|
100
|
+
queue: directQueue,
|
|
101
|
+
promptTemplatePath: TEMPLATE,
|
|
102
|
+
log: () => {},
|
|
103
|
+
now: () => NOW,
|
|
104
|
+
env: {},
|
|
105
|
+
spawnFn: noSpawn,
|
|
106
|
+
alive: ctx.alive,
|
|
107
|
+
supervisorSpawnFn: () => {
|
|
108
|
+
throw new Error("被挡住时也不该起督导");
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const outcome = await dispatcher.dispatch({ sessionId: "s1", skills: ["fast-test"] });
|
|
112
|
+
assert.deepEqual(outcome, { status: "skipped", why: "busy", ownerPid: OCCUPIED_PID });
|
|
113
|
+
const busy = ctx.events.filter((event) => event.kind === "skipped" && event.why === "busy");
|
|
114
|
+
assert.equal(busy.length, 1);
|
|
115
|
+
assert.deepEqual(busy[0]!.occupant, { runId: "other", pid: OCCUPIED_PID, kind: "复盘" });
|
|
116
|
+
assert.equal(fs.existsSync(path.join(ctx.evolutionDir, "runs", "other", "child.log")), false, "占用者的东西不能被改");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("dispatch:没有占用时照常派发(闸门不误伤正常路径)", async () => {
|
|
120
|
+
const ctx = workspace();
|
|
121
|
+
const calls: string[][] = [];
|
|
122
|
+
const spawnFn: SpawnLike = (_command, args) => {
|
|
123
|
+
calls.push(args);
|
|
124
|
+
return new FakeChild(4242) as unknown as SpawnedChild;
|
|
125
|
+
};
|
|
126
|
+
const dispatcher = createDispatcher({
|
|
127
|
+
evolutionDir: ctx.evolutionDir,
|
|
128
|
+
cwd: ctx.cwd,
|
|
129
|
+
config: { ...DEFAULT_CONFIG, enabled: true, telemetry: true },
|
|
130
|
+
telemetry: ctx.telemetry,
|
|
131
|
+
queue: directQueue,
|
|
132
|
+
promptTemplatePath: TEMPLATE,
|
|
133
|
+
log: () => {},
|
|
134
|
+
now: () => NOW,
|
|
135
|
+
env: {},
|
|
136
|
+
spawnFn,
|
|
137
|
+
alive: ctx.alive,
|
|
138
|
+
supervisorSpawnFn: () => null,
|
|
139
|
+
});
|
|
140
|
+
const outcome = await dispatcher.dispatch({ sessionId: "s1", skills: ["fast-test"] });
|
|
141
|
+
assert.equal(outcome.status, "dispatched");
|
|
142
|
+
assert.equal(calls.length, 1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("shutdown-dispatch:已有活的督导 → 不 spawn,同步写 busy,且不建新 run 目录", () => {
|
|
146
|
+
const ctx = workspace();
|
|
147
|
+
const runDir = occupy(ctx);
|
|
148
|
+
// 占用者只有督导活着(复盘 pid 已退出):闸门看 supervisorPid 这一位
|
|
149
|
+
fs.writeFileSync(
|
|
150
|
+
path.join(runDir, "run.json"),
|
|
151
|
+
JSON.stringify({ runId: "other", status: "analyzed", pid: null, supervisorPid: OCCUPIED_PID, dispatchedAt: NOW }),
|
|
152
|
+
"utf8",
|
|
153
|
+
);
|
|
154
|
+
const before = fs.readdirSync(path.join(ctx.evolutionDir, "runs")).length;
|
|
155
|
+
const result = flushPendingSync(
|
|
156
|
+
{ sessionId: "s1", since: NOW - 1000, rounds: 2, skills: ["fast-test"] },
|
|
157
|
+
{
|
|
158
|
+
evolutionDir: ctx.evolutionDir,
|
|
159
|
+
cwd: ctx.cwd,
|
|
160
|
+
promptTemplatePath: TEMPLATE,
|
|
161
|
+
maxRuntimeMs: 20 * 60_000,
|
|
162
|
+
telemetry: ctx.telemetry,
|
|
163
|
+
env: {},
|
|
164
|
+
spawnFn: noSpawn,
|
|
165
|
+
alive: ctx.alive,
|
|
166
|
+
log: () => {},
|
|
167
|
+
},
|
|
168
|
+
NOW,
|
|
169
|
+
);
|
|
170
|
+
assert.equal(result, "skipped");
|
|
171
|
+
assert.equal(fs.readdirSync(path.join(ctx.evolutionDir, "runs")).length, before, "被挡住就不该留下半成品 run");
|
|
172
|
+
const busy = ctx.syncEvents.filter((event) => event.kind === "skipped" && event.why === "busy");
|
|
173
|
+
assert.equal(busy.length, 1, "退出路径必须同步落盘(异步 append 可能来不及写)");
|
|
174
|
+
assert.equal(busy[0]!.trigger, "shutdown");
|
|
175
|
+
assert.equal(busy[0]!.rounds, 2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("review-spawn:别的 run 的复盘活着 → 不起督导,写 busy 遥测", () => {
|
|
179
|
+
const ctx = workspace();
|
|
180
|
+
occupy(ctx);
|
|
181
|
+
const runDir = path.join(ctx.evolutionDir, "runs", "mine");
|
|
182
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
183
|
+
fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify({ runId: "mine", status: "dispatched" }), "utf8");
|
|
184
|
+
|
|
185
|
+
const pid = spawnReviewSupervisor({
|
|
186
|
+
runDir,
|
|
187
|
+
cwd: ctx.cwd,
|
|
188
|
+
env: {},
|
|
189
|
+
telemetry: ctx.telemetry,
|
|
190
|
+
alive: ctx.alive,
|
|
191
|
+
log: () => {},
|
|
192
|
+
spawnFn: () => {
|
|
193
|
+
throw new Error("被挡住时不该起督导");
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
assert.equal(pid, null);
|
|
197
|
+
const busy = ctx.events.filter((event) => event.kind === "skipped" && event.why === "busy");
|
|
198
|
+
assert.equal(busy.length, 1);
|
|
199
|
+
assert.equal(busy[0]!.occupant.runId, "other");
|
|
200
|
+
assert.equal(readRun(runDir).supervisorPid, undefined, "没起督导就不能写 supervisorPid");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("review-spawn:自己这条 run 的复盘活着不算占用,起督导并把 pid 写进 supervisorPid", () => {
|
|
204
|
+
const ctx = workspace();
|
|
205
|
+
const runDir = path.join(ctx.evolutionDir, "runs", "mine");
|
|
206
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
207
|
+
fs.writeFileSync(
|
|
208
|
+
path.join(runDir, "run.json"),
|
|
209
|
+
JSON.stringify({ runId: "mine", status: "dispatched", pid: OCCUPIED_PID }),
|
|
210
|
+
"utf8",
|
|
211
|
+
);
|
|
212
|
+
const pid = spawnReviewSupervisor({
|
|
213
|
+
runDir,
|
|
214
|
+
cwd: ctx.cwd,
|
|
215
|
+
env: {},
|
|
216
|
+
telemetry: ctx.telemetry,
|
|
217
|
+
alive: ctx.alive,
|
|
218
|
+
log: () => {},
|
|
219
|
+
spawnFn: () => ({ pid: 9090, unref: () => undefined }),
|
|
220
|
+
});
|
|
221
|
+
assert.equal(pid, 9090);
|
|
222
|
+
assert.equal(readRun(runDir).supervisorPid, 9090, "督导 pid 是第二个占用位,必须落盘");
|
|
223
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 派发 ↔ 评审督导的接线单测:同一次派发里必须同时起复盘与督导,
|
|
3
|
+
* 而「自检没过」这道门禁必须早于任何 spawn(否则白起一个 900 秒的轮询进程)。
|
|
4
|
+
*/
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { DEFAULT_CONFIG, type EvolutionConfig } from "../lib/config.ts";
|
|
12
|
+
import { createDispatcher } from "../lib/dispatch.ts";
|
|
13
|
+
import type { SpawnedChild, SpawnLike } from "../lib/spawn-plan.ts";
|
|
14
|
+
import { directQueue, type TelemetryEvent, type TelemetryInput, type TelemetryWriter } from "../lib/telemetry.ts";
|
|
15
|
+
import { fakeSupervisorSpawn } from "./fake-review-spawn.ts";
|
|
16
|
+
|
|
17
|
+
const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "prompts", "复盘.md");
|
|
18
|
+
const NOW = 1_760_000_000_000;
|
|
19
|
+
|
|
20
|
+
class FakeChild {
|
|
21
|
+
readonly pid = process.pid;
|
|
22
|
+
unref(): void {}
|
|
23
|
+
once(): this {
|
|
24
|
+
return this;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function harness(options: { config?: Partial<EvolutionConfig>; selftestFailed?: () => boolean; reviewModel?: () => string } = {}) {
|
|
29
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-sup-"));
|
|
30
|
+
const events: TelemetryEvent[] = [];
|
|
31
|
+
const supervisorCalls: { args: string[]; options: Record<string, unknown> }[] = [];
|
|
32
|
+
const writer: TelemetryWriter = {
|
|
33
|
+
append: (event: TelemetryInput) => {
|
|
34
|
+
events.push({ ...event, ts: NOW, iso: "" } as TelemetryEvent);
|
|
35
|
+
},
|
|
36
|
+
appendSync: (event: TelemetryInput) => {
|
|
37
|
+
events.push({ ...event, ts: NOW, iso: "" } as TelemetryEvent);
|
|
38
|
+
},
|
|
39
|
+
flush: async () => undefined,
|
|
40
|
+
};
|
|
41
|
+
const spawnFn: SpawnLike = () => new FakeChild() as unknown as SpawnedChild;
|
|
42
|
+
const dispatcher = createDispatcher({
|
|
43
|
+
evolutionDir: path.join(dir, ".pi", "evolution"),
|
|
44
|
+
cwd: dir,
|
|
45
|
+
// 生产默认全关(clone 即静默):夹具必须显式打开,否则派发用例会被「未启用」跳过
|
|
46
|
+
config: { ...DEFAULT_CONFIG, enabled: true, telemetry: true, ...options.config },
|
|
47
|
+
telemetry: writer,
|
|
48
|
+
queue: directQueue,
|
|
49
|
+
promptTemplatePath: TEMPLATE,
|
|
50
|
+
log: () => {},
|
|
51
|
+
now: () => NOW,
|
|
52
|
+
env: {},
|
|
53
|
+
spawnFn,
|
|
54
|
+
// 假子进程 pid = 本进程 pid:单飞闸靠注入的 alive 模拟「子进程已退出」,否则督导那一跳被自己的复盘挡住
|
|
55
|
+
alive: () => false,
|
|
56
|
+
...(options.reviewModel ? { reviewModel: options.reviewModel } : {}),
|
|
57
|
+
...(options.selftestFailed ? { selftestFailed: options.selftestFailed } : {}),
|
|
58
|
+
supervisorSpawnFn: fakeSupervisorSpawn(supervisorCalls),
|
|
59
|
+
});
|
|
60
|
+
return { dir, events, supervisorCalls, dispatch: dispatcher.dispatch };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test("派发成功时同一轮就起评审督导,并把评审模型透传过去", async () => {
|
|
64
|
+
const h = harness({ reviewModel: () => "deepseek/deepseek-v3.2" });
|
|
65
|
+
const outcome = await h.dispatch({ sessionId: "sess-1", skills: ["alpha"] });
|
|
66
|
+
assert.equal(outcome.status, "dispatched");
|
|
67
|
+
assert.equal(h.supervisorCalls.length, 1, "必须起且只起一个督导");
|
|
68
|
+
const args = h.supervisorCalls[0].args;
|
|
69
|
+
assert.ok(args[args.length - 2].endsWith("run-1") || args[2].includes("runs"), `args=${args.join(" ")}`);
|
|
70
|
+
assert.equal(args[args.length - 1], "deepseek/deepseek-v3.2");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("总开关关闭:既不复盘也不起督导", async () => {
|
|
74
|
+
const h = harness({ config: { enabled: false } });
|
|
75
|
+
const outcome = await h.dispatch({ sessionId: "sess-1", skills: ["alpha"] });
|
|
76
|
+
assert.equal(outcome.status, "skipped");
|
|
77
|
+
assert.equal(h.supervisorCalls.length, 0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("必红自检没过:跳过派发并记 skipped/selftest-failed", async () => {
|
|
81
|
+
const h = harness({ selftestFailed: () => true });
|
|
82
|
+
const outcome = await h.dispatch({ sessionId: "sess-1", skills: ["alpha"] });
|
|
83
|
+
assert.equal(outcome.status, "skipped");
|
|
84
|
+
assert.equal(outcome.status === "skipped" ? outcome.why : "", "selftest-failed");
|
|
85
|
+
assert.equal(h.supervisorCalls.length, 0, "自检没过时不得起督导");
|
|
86
|
+
const skipped = h.events.find((event) => event.kind === "skipped");
|
|
87
|
+
assert.equal((skipped as unknown as { why?: string }).why, "selftest-failed");
|
|
88
|
+
});
|