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,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跨进程目录锁单测(零依赖,node --test)。
|
|
3
|
+
*
|
|
4
|
+
* 运行:
|
|
5
|
+
* cd .pi/extensions/skill-evolve
|
|
6
|
+
* node --experimental-strip-types --test test/lock.test.ts
|
|
7
|
+
*
|
|
8
|
+
* 覆盖三种回收判据(活 pid / 死 pid / 超时)+ 抢锁、忙等待、残锁回收。
|
|
9
|
+
*/
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { acquireLock, acquireLockSync, isPidAlive, lockDir, readOwnerSync, releaseLock, releaseLockSync, shouldReclaim, writeOwnerSync } from "../lib/lock.ts";
|
|
18
|
+
|
|
19
|
+
const MAX = 20 * 60_000;
|
|
20
|
+
const NOW = 1_760_000_000_000;
|
|
21
|
+
|
|
22
|
+
function tmpDir(): string {
|
|
23
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-lock-"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("shouldReclaim:没有 owner.json 视为残锁", () => {
|
|
27
|
+
assert.equal(shouldReclaim(null, NOW, MAX), true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("shouldReclaim:pid 已死 → 立即回收,不看年龄(3 秒前才写的也回收)", () => {
|
|
31
|
+
assert.equal(shouldReclaim({ pid: 4242, ts: NOW - 3000 }, NOW, MAX, () => false), true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("shouldReclaim:pid 活着 → 永不回收,ts 再老也不回收(宁可阻塞不可抢锁)", () => {
|
|
35
|
+
assert.equal(shouldReclaim({ pid: 4242, ts: NOW - 1000 }, NOW, MAX, () => true), false);
|
|
36
|
+
assert.equal(shouldReclaim({ pid: 4242, ts: NOW - 2 * MAX }, NOW, MAX, () => true), false);
|
|
37
|
+
assert.equal(shouldReclaim({ pid: 4242, ts: NOW - 100 * MAX }, NOW, MAX, () => true), false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("shouldReclaim:owner 字段坏(pid 非法但 ts 可读)→ 才按年龄兜底", () => {
|
|
41
|
+
assert.equal(shouldReclaim({ pid: Number.NaN, ts: NOW - 2 * MAX }, NOW, MAX, () => true), true);
|
|
42
|
+
assert.equal(shouldReclaim({ pid: 0, ts: NOW - 2 * MAX }, NOW, MAX), true);
|
|
43
|
+
assert.equal(shouldReclaim({ pid: Number.NaN, ts: NOW - 1000 }, NOW, MAX, () => true), false);
|
|
44
|
+
assert.equal(shouldReclaim({ pid: Number.NaN, ts: Number.NaN }, NOW, MAX, () => true), true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("isPidAlive:当前进程活着,0 与负值直接判死", () => {
|
|
48
|
+
assert.equal(isPidAlive(process.pid), true);
|
|
49
|
+
assert.equal(isPidAlive(0), false);
|
|
50
|
+
assert.equal(isPidAlive(-1), false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("acquireLock:抢到后写 owner.json,第二次抢返回 busy,释放后可再抢", async () => {
|
|
54
|
+
const dir = tmpDir();
|
|
55
|
+
const first = await acquireLock({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW, sessionId: "s1" }, maxRuntimeMs: MAX, now: () => NOW });
|
|
56
|
+
assert.equal(first.ok, true);
|
|
57
|
+
const ownerFile = path.join(lockDir(dir), "owner.json");
|
|
58
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(ownerFile, "utf8")), { pid: process.pid, ts: NOW, sessionId: "s1" });
|
|
59
|
+
|
|
60
|
+
const second = await acquireLock({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW }, maxRuntimeMs: MAX, now: () => NOW });
|
|
61
|
+
assert.equal(second.ok, false);
|
|
62
|
+
assert.equal(second.why, "busy");
|
|
63
|
+
assert.equal(second.owner?.sessionId, "s1");
|
|
64
|
+
|
|
65
|
+
await releaseLock(dir);
|
|
66
|
+
assert.equal(fs.existsSync(lockDir(dir)), false);
|
|
67
|
+
const third = await acquireLock({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW }, maxRuntimeMs: MAX, now: () => NOW });
|
|
68
|
+
assert.equal(third.ok, true);
|
|
69
|
+
await releaseLock(dir);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("acquireLock:回收死 pid 残锁并成功抢到(不看年龄)", async () => {
|
|
73
|
+
const dir = tmpDir();
|
|
74
|
+
fs.mkdirSync(lockDir(dir), { recursive: true });
|
|
75
|
+
// 真跑一个 node 子进程拿它的 pid,退出后这个 pid 必然已死:模拟 kill -9 后留下的残锁
|
|
76
|
+
const deadPid = spawnSync(process.execPath, ["-e", ""]).pid ?? 0;
|
|
77
|
+
assert.equal(isPidAlive(deadPid), false);
|
|
78
|
+
// ts 就是 NOW(年龄为 0)也必须回收——这正是「派发后主进程立刻退出」的死锁场景
|
|
79
|
+
fs.writeFileSync(path.join(lockDir(dir), "owner.json"), JSON.stringify({ pid: deadPid, ts: NOW, sessionId: "dead" }), "utf8");
|
|
80
|
+
const logs: string[] = [];
|
|
81
|
+
const result = await acquireLock({
|
|
82
|
+
evolutionDir: dir,
|
|
83
|
+
owner: { pid: process.pid, ts: NOW },
|
|
84
|
+
maxRuntimeMs: MAX,
|
|
85
|
+
now: () => NOW,
|
|
86
|
+
log: (msg) => logs.push(msg),
|
|
87
|
+
});
|
|
88
|
+
assert.equal(result.ok, true);
|
|
89
|
+
assert.equal(logs.some((line) => line.includes("回收孤儿锁")), true);
|
|
90
|
+
await releaseLock(dir);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("acquireLock:活 pid 的锁 ts 再老也不抢(宁可阻塞,不可抢正在跑的 run)", async () => {
|
|
94
|
+
const dir = tmpDir();
|
|
95
|
+
fs.mkdirSync(lockDir(dir), { recursive: true });
|
|
96
|
+
fs.writeFileSync(path.join(lockDir(dir), "owner.json"), JSON.stringify({ pid: process.pid, ts: NOW - 3 * MAX }), "utf8");
|
|
97
|
+
const result = await acquireLock({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW }, maxRuntimeMs: MAX, now: () => NOW });
|
|
98
|
+
assert.deepEqual([result.ok, result.why, result.owner?.pid], [false, "busy", process.pid]);
|
|
99
|
+
await releaseLock(dir);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("releaseLock:目录不存在也当成功(幂等)", async () => {
|
|
103
|
+
const dir = tmpDir();
|
|
104
|
+
await releaseLock(dir);
|
|
105
|
+
assert.equal(fs.existsSync(lockDir(dir)), false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("acquireLockSync:同步抢锁、忙时让位、写 owner.json", () => {
|
|
109
|
+
const dir = tmpDir();
|
|
110
|
+
const first = acquireLockSync({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW, sessionId: "s1" }, maxRuntimeMs: MAX, now: () => NOW });
|
|
111
|
+
assert.equal(first.ok, true);
|
|
112
|
+
assert.deepEqual(readOwnerSync(lockDir(dir)), { pid: process.pid, ts: NOW, sessionId: "s1" });
|
|
113
|
+
|
|
114
|
+
const second = acquireLockSync({ evolutionDir: dir, owner: { pid: 999_999, ts: NOW, sessionId: "s2" }, maxRuntimeMs: MAX, now: () => NOW });
|
|
115
|
+
assert.equal(second.ok, false);
|
|
116
|
+
assert.equal(second.why, "busy");
|
|
117
|
+
assert.equal(second.owner?.pid, process.pid);
|
|
118
|
+
releaseLockSync(dir);
|
|
119
|
+
assert.equal(fs.existsSync(lockDir(dir)), false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("acquireLockSync:死 pid 的残锁会被回收后抢到", () => {
|
|
123
|
+
const dir = tmpDir();
|
|
124
|
+
fs.mkdirSync(lockDir(dir), { recursive: true });
|
|
125
|
+
fs.writeFileSync(path.join(lockDir(dir), "owner.json"), `${JSON.stringify({ pid: 999_999, ts: NOW, sessionId: "old" })}\n`);
|
|
126
|
+
const result = acquireLockSync({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW, sessionId: "s1" }, maxRuntimeMs: MAX, now: () => NOW });
|
|
127
|
+
assert.equal(result.ok, true);
|
|
128
|
+
assert.equal(readOwnerSync(lockDir(dir))?.sessionId, "s1");
|
|
129
|
+
releaseLockSync(dir);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("writeOwnerSync:把持有者换成子进程 pid,供 status 与孤儿判定使用", () => {
|
|
133
|
+
const dir = tmpDir();
|
|
134
|
+
acquireLockSync({ evolutionDir: dir, owner: { pid: process.pid, ts: NOW, sessionId: "s1" }, maxRuntimeMs: MAX, now: () => NOW });
|
|
135
|
+
assert.equal(writeOwnerSync(dir, { pid: 4321, ts: NOW + 1, sessionId: "s1" }), true);
|
|
136
|
+
assert.deepEqual(readOwnerSync(lockDir(dir)), { pid: 4321, ts: NOW + 1, sessionId: "s1" });
|
|
137
|
+
releaseLockSync(dir);
|
|
138
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展日志落盘(用户 2026-11-08:「tui 的这些推送不看了,影响我」):
|
|
3
|
+
* 默认只写 `.pi/evolution/logs/extension.log`,`PI_SKILL_EVOLVE_DEBUG=1` 才回显终端,超 1MB 轮转成 `.1`。
|
|
4
|
+
*
|
|
5
|
+
* 全部喂临时目录;console.log 用临时替换的假函数断言,不起任何子进程。
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import { LOG_MAX_BYTES, logFile, logLine } from "../lib/logfile.ts";
|
|
13
|
+
|
|
14
|
+
function workspace(): string {
|
|
15
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-logfile-"));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 临时接管 console.log:返回调用记录与还原函数(测试之间不许互相污染) */
|
|
19
|
+
function spyConsole(): { calls: string[]; restore: () => void } {
|
|
20
|
+
const original = console.log;
|
|
21
|
+
const calls: string[] = [];
|
|
22
|
+
console.log = (...args: unknown[]) => {
|
|
23
|
+
calls.push(args.map((arg) => String(arg)).join(" "));
|
|
24
|
+
};
|
|
25
|
+
return { calls, restore: () => (console.log = original) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 环境变量按需设 / 删,并在 finally 里还原成测试前的样子 */
|
|
29
|
+
function withDebugEnv(value: string | undefined, run: () => void): void {
|
|
30
|
+
const before = process.env.PI_SKILL_EVOLVE_DEBUG;
|
|
31
|
+
if (value === undefined) delete process.env.PI_SKILL_EVOLVE_DEBUG;
|
|
32
|
+
else process.env.PI_SKILL_EVOLVE_DEBUG = value;
|
|
33
|
+
try {
|
|
34
|
+
run();
|
|
35
|
+
} finally {
|
|
36
|
+
if (before === undefined) delete process.env.PI_SKILL_EVOLVE_DEBUG;
|
|
37
|
+
else process.env.PI_SKILL_EVOLVE_DEBUG = before;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("logLine:自动建 logs/ 目录,一行一条带 ISO 时间戳", () => {
|
|
42
|
+
const dir = workspace();
|
|
43
|
+
withDebugEnv(undefined, () => logLine(dir, "[skill-evolve] 已派生复盘 pid 1", false));
|
|
44
|
+
const file = logFile(dir);
|
|
45
|
+
assert.equal(file, path.join(dir, "logs", "extension.log"));
|
|
46
|
+
const text = fs.readFileSync(file, "utf8");
|
|
47
|
+
assert.match(text, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[skill-evolve\] 已派生复盘 pid 1\n$/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("logLine:DEBUG 未设时一次都不调 console.log,但文件照写", () => {
|
|
51
|
+
const dir = workspace();
|
|
52
|
+
const spy = spyConsole();
|
|
53
|
+
try {
|
|
54
|
+
withDebugEnv(undefined, () => logLine(dir, "静默一行"));
|
|
55
|
+
} finally {
|
|
56
|
+
spy.restore();
|
|
57
|
+
}
|
|
58
|
+
assert.deepEqual(spy.calls, [], "默认零推送:终端不该出现任何一行");
|
|
59
|
+
assert.match(fs.readFileSync(logFile(dir), "utf8"), /静默一行/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("logLine:PI_SKILL_EVOLVE_DEBUG=1 时同时回显终端", () => {
|
|
63
|
+
const dir = workspace();
|
|
64
|
+
const spy = spyConsole();
|
|
65
|
+
try {
|
|
66
|
+
withDebugEnv("1", () => logLine(dir, "可见一行"));
|
|
67
|
+
} finally {
|
|
68
|
+
spy.restore();
|
|
69
|
+
}
|
|
70
|
+
assert.deepEqual(spy.calls, ["可见一行"]);
|
|
71
|
+
assert.match(fs.readFileSync(logFile(dir), "utf8"), /可见一行/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("logLine:单文件超 1MB 轮转成 .1,新文件只留新的一行", () => {
|
|
75
|
+
const dir = workspace();
|
|
76
|
+
const file = logFile(dir);
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
// 刚好等于阈值不轮转,超一个字节才轮转:这里直接写 MAX+1
|
|
79
|
+
fs.writeFileSync(file, "x".repeat(LOG_MAX_BYTES + 1), "utf8");
|
|
80
|
+
withDebugEnv(undefined, () => logLine(dir, "轮转后的一行", false));
|
|
81
|
+
assert.equal(fs.statSync(`${file}.1`).size, LOG_MAX_BYTES + 1, "旧内容整体挪到 .1");
|
|
82
|
+
const current = fs.readFileSync(file, "utf8");
|
|
83
|
+
assert.equal(current.includes("xxxx"), false, "新文件里没有旧内容");
|
|
84
|
+
assert.match(current, /轮转后的一行/);
|
|
85
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 机械层的接线单测:settle 之后遥测里必须真的出现 attributed / conflict,
|
|
3
|
+
* 且派生 prompt 里带着「已机械归因」清单(复盘 agent 据此只做 F/G)。
|
|
4
|
+
*
|
|
5
|
+
* 运行:
|
|
6
|
+
* cd .pi/extensions/skill-evolve
|
|
7
|
+
* node --experimental-strip-types --test test/mechanical-wiring.test.ts
|
|
8
|
+
*/
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
import { fakeSupervisorSpawn } from "./fake-review-spawn.ts";
|
|
17
|
+
import { createEvolution } from "../lib/evolution.ts";
|
|
18
|
+
import { directQueue, type TelemetryEvent, type TelemetrySkill } from "../lib/telemetry.ts";
|
|
19
|
+
import type { SpawnedChild, SpawnLike } from "../lib/spawn-plan.ts";
|
|
20
|
+
|
|
21
|
+
const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "prompts", "复盘.md");
|
|
22
|
+
const NOW = 1_760_000_000_000;
|
|
23
|
+
|
|
24
|
+
const RULES = {
|
|
25
|
+
version: 1,
|
|
26
|
+
rules: [
|
|
27
|
+
{ id: "rg-over-find", level: "P0", pattern: "\\bgrep\\s+-[a-zA-Z]*r", expect: "用 rg", why: "实测 find 全盘 180s" },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function skill(name: string, disabled = false): TelemetrySkill {
|
|
32
|
+
return { name, filePath: `/repo/.pi/skills/${name}/SKILL.md`, disabled, source: "project" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 生产默认全关(clone 即静默):这三个用例要验证采集,必须在夹具里显式打开 */
|
|
36
|
+
function enableEvolution(dir: string): void {
|
|
37
|
+
fs.mkdirSync(path.join(dir, ".pi", "evolution"), { recursive: true });
|
|
38
|
+
fs.writeFileSync(
|
|
39
|
+
path.join(dir, ".pi", "evolution", "config.json"),
|
|
40
|
+
`${JSON.stringify({ enabled: true, telemetry: true }, null, 2)}\n`,
|
|
41
|
+
"utf8",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readTelemetry(dir: string): TelemetryEvent[] {
|
|
46
|
+
const shard = path.join(dir, ".pi", "evolution", "telemetry");
|
|
47
|
+
let names: string[];
|
|
48
|
+
try {
|
|
49
|
+
names = fs.readdirSync(shard);
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const events: TelemetryEvent[] = [];
|
|
54
|
+
for (const name of names.sort()) {
|
|
55
|
+
for (const line of fs.readFileSync(path.join(shard, name), "utf8").split("\n")) {
|
|
56
|
+
if (line.trim().length > 0) events.push(JSON.parse(line));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return events;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class FakeChild {
|
|
63
|
+
readonly pid = process.pid;
|
|
64
|
+
unref(): void {}
|
|
65
|
+
once(): this {
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("settle 写 attributed + conflict,prompt 带上机械归因清单", async () => {
|
|
71
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-wire-"));
|
|
72
|
+
enableEvolution(dir);
|
|
73
|
+
// 磁盘上真的放一个会命中硬禁令的技能,让 conflict 走真实扫描而不是桩
|
|
74
|
+
fs.mkdirSync(path.join(dir, ".pi/skills/alpha"), { recursive: true });
|
|
75
|
+
fs.writeFileSync(path.join(dir, ".pi/skills/alpha/SKILL.md"), "# alpha\n用 grep -r 搜全仓\n", "utf8");
|
|
76
|
+
fs.mkdirSync(path.join(dir, ".pi/evolution/rules"), { recursive: true });
|
|
77
|
+
fs.writeFileSync(path.join(dir, ".pi/evolution/rules/hard-rules.json"), JSON.stringify(RULES), "utf8");
|
|
78
|
+
|
|
79
|
+
const prompts: string[] = [];
|
|
80
|
+
// 技能 filePath 必须指向这个临时 cwd,否则 B 类的路径比较对不上(这正是它该做的事)
|
|
81
|
+
const at = (name: string): TelemetrySkill => ({
|
|
82
|
+
name,
|
|
83
|
+
filePath: path.join(dir, ".pi/skills", name, "SKILL.md"),
|
|
84
|
+
disabled: false,
|
|
85
|
+
source: "project",
|
|
86
|
+
});
|
|
87
|
+
const spawnFn: SpawnLike = (_bin, args) => {
|
|
88
|
+
prompts.push(args[args.length - 1]);
|
|
89
|
+
return new FakeChild() as unknown as SpawnedChild;
|
|
90
|
+
};
|
|
91
|
+
const evo = createEvolution({
|
|
92
|
+
cwd: dir,
|
|
93
|
+
promptTemplatePath: TEMPLATE,
|
|
94
|
+
queue: directQueue,
|
|
95
|
+
log: () => {},
|
|
96
|
+
now: () => NOW,
|
|
97
|
+
env: {},
|
|
98
|
+
spawnFn,
|
|
99
|
+
supervisorSpawnFn: fakeSupervisorSpawn([]),
|
|
100
|
+
// 注入发现结果:alpha 正常发现,beta 的 frontmatter 坏(诊断点名它)
|
|
101
|
+
loadSkills: () => ({
|
|
102
|
+
skills: [{ name: "alpha", filePath: path.join(dir, ".pi/skills/alpha/SKILL.md") }],
|
|
103
|
+
diagnostics: [{ path: path.join(dir, ".pi/skills/beta/SKILL.md"), message: "缺少 description", type: "warning" }],
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
evo.recordAdvertised({
|
|
108
|
+
sessionId: "s1",
|
|
109
|
+
cwd: dir,
|
|
110
|
+
prompt: "查一下技能",
|
|
111
|
+
skills: [at("alpha"), at("beta")],
|
|
112
|
+
activeTools: ["read", "bash"],
|
|
113
|
+
});
|
|
114
|
+
evo.settle({ sessionId: "s1" });
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
116
|
+
|
|
117
|
+
const events = readTelemetry(dir);
|
|
118
|
+
const advertised = events.find((item) => item.kind === "advertised");
|
|
119
|
+
assert.deepEqual(advertised?.activeTools, ["read", "bash"], "activeTools 必须落进遥测,否则类 E 无据可依");
|
|
120
|
+
|
|
121
|
+
// directQueue 是直写(生产用 withFileMutationQueue 串行),事件落盘顺序不保证,所以按技能名排序比对
|
|
122
|
+
const attributed = events.filter((item) => item.kind === "attributed");
|
|
123
|
+
assert.deepEqual(
|
|
124
|
+
attributed.map((item) => [item.skill, item.category]).sort((a, b) => a[0].localeCompare(b[0])),
|
|
125
|
+
[
|
|
126
|
+
["alpha", "semantic"],
|
|
127
|
+
["beta", "B"],
|
|
128
|
+
],
|
|
129
|
+
);
|
|
130
|
+
assert.ok(attributed.every((item) => typeof item.reason === "string" && item.reason.length > 0));
|
|
131
|
+
|
|
132
|
+
const conflicts = events.filter((item) => item.kind === "conflict");
|
|
133
|
+
assert.equal(conflicts.length, 1);
|
|
134
|
+
assert.equal(conflicts[0].ruleId, "rg-over-find");
|
|
135
|
+
assert.equal(conflicts[0].line, 2);
|
|
136
|
+
assert.equal(conflicts[0].level, "P0");
|
|
137
|
+
// 首次 settle 会自动 bootstrap 存量基线(把当时的命中当存量接受),所以这条标 stock
|
|
138
|
+
assert.equal(conflicts[0].stock, true);
|
|
139
|
+
|
|
140
|
+
assert.equal(prompts.length, 1);
|
|
141
|
+
assert.match(prompts[0], /已机械归因(2 条/);
|
|
142
|
+
assert.match(prompts[0], /B beta/);
|
|
143
|
+
assert.match(prompts[0], /只对上面没出现的技能做 F\/G 判定/);
|
|
144
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("同一冲突只记一次:两轮 settle 不重复刷同一条", async () => {
|
|
148
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-wire2-"));
|
|
149
|
+
enableEvolution(dir);
|
|
150
|
+
fs.mkdirSync(path.join(dir, ".pi/skills/alpha"), { recursive: true });
|
|
151
|
+
fs.writeFileSync(path.join(dir, ".pi/skills/alpha/SKILL.md"), "# alpha\n用 grep -r 搜全仓\n", "utf8");
|
|
152
|
+
fs.mkdirSync(path.join(dir, ".pi/evolution/rules"), { recursive: true });
|
|
153
|
+
fs.writeFileSync(path.join(dir, ".pi/evolution/rules/hard-rules.json"), JSON.stringify(RULES), "utf8");
|
|
154
|
+
|
|
155
|
+
const evo = createEvolution({
|
|
156
|
+
cwd: dir,
|
|
157
|
+
promptTemplatePath: TEMPLATE,
|
|
158
|
+
queue: directQueue,
|
|
159
|
+
log: () => {},
|
|
160
|
+
now: () => NOW,
|
|
161
|
+
env: {},
|
|
162
|
+
spawnFn: () => new FakeChild() as unknown as SpawnedChild,
|
|
163
|
+
supervisorSpawnFn: fakeSupervisorSpawn([]),
|
|
164
|
+
loadSkills: () => ({ skills: [{ name: "alpha", filePath: path.join(dir, ".pi/skills/alpha/SKILL.md") }], diagnostics: [] }),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
for (let round = 0; round < 2; round += 1) {
|
|
168
|
+
evo.recordAdvertised({ sessionId: "s1", cwd: dir, prompt: "再来一次", skills: [skill("alpha")], activeTools: ["read"] });
|
|
169
|
+
evo.settle({ sessionId: "s1" });
|
|
170
|
+
}
|
|
171
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
172
|
+
|
|
173
|
+
const conflicts = readTelemetry(dir).filter((item) => item.kind === "conflict");
|
|
174
|
+
assert.equal(conflicts.length, 1);
|
|
175
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("conflict 事件带 stock:基线里没有的命中标成新增(stock=false)", async () => {
|
|
179
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-wire3-"));
|
|
180
|
+
enableEvolution(dir);
|
|
181
|
+
fs.mkdirSync(path.join(dir, ".pi/skills/alpha"), { recursive: true });
|
|
182
|
+
fs.writeFileSync(path.join(dir, ".pi/skills/alpha/SKILL.md"), "# alpha\n用 grep -r 搜全仓\n", "utf8");
|
|
183
|
+
fs.mkdirSync(path.join(dir, ".pi/evolution/rules"), { recursive: true });
|
|
184
|
+
fs.writeFileSync(path.join(dir, ".pi/evolution/rules/hard-rules.json"), JSON.stringify(RULES), "utf8");
|
|
185
|
+
// 预置一份「只含别的存量」的基线:alpha 这条不在其中,应判新增
|
|
186
|
+
fs.writeFileSync(
|
|
187
|
+
path.join(dir, ".pi/evolution/baseline.json"),
|
|
188
|
+
JSON.stringify({ version: 1, generatedAt: NOW, bootstrap: false, violations: [{ ruleId: "rg-over-find", level: "P0", skill: "other", line: 9, quote: "别的", fingerprint: "deadbeefdeadbeef" }] }),
|
|
189
|
+
"utf8",
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const evo = createEvolution({
|
|
193
|
+
cwd: dir,
|
|
194
|
+
promptTemplatePath: TEMPLATE,
|
|
195
|
+
queue: directQueue,
|
|
196
|
+
log: () => {},
|
|
197
|
+
now: () => NOW,
|
|
198
|
+
env: {},
|
|
199
|
+
spawnFn: (() => new FakeChild() as unknown as SpawnedChild) as SpawnLike,
|
|
200
|
+
supervisorSpawnFn: fakeSupervisorSpawn([]),
|
|
201
|
+
loadSkills: () => ({ skills: [], diagnostics: [] }),
|
|
202
|
+
});
|
|
203
|
+
evo.recordAdvertised({
|
|
204
|
+
sessionId: "s1",
|
|
205
|
+
cwd: dir,
|
|
206
|
+
prompt: "查一下技能",
|
|
207
|
+
skills: [skill("alpha")],
|
|
208
|
+
activeTools: ["read", "bash"],
|
|
209
|
+
});
|
|
210
|
+
evo.settle({ sessionId: "s1" });
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
212
|
+
|
|
213
|
+
const conflicts = readTelemetry(dir).filter((item) => item.kind === "conflict");
|
|
214
|
+
assert.equal(conflicts.length, 1);
|
|
215
|
+
assert.equal(conflicts[0].stock, false);
|
|
216
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
217
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M6 指标:按 ISO 周聚合、去重落盘、除零不算 NaN。
|
|
3
|
+
* 用例编号对应设计稿 14.2(趋势指标)与 5.1(metrics.jsonl)。
|
|
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 { appendMetrics, buildMetrics, isoWeek, metricsFile, readMetrics, rollbackRate } from "../lib/metrics.ts";
|
|
11
|
+
import { directQueue, type TelemetryEvent } from "../lib/telemetry.ts";
|
|
12
|
+
|
|
13
|
+
const DAY = 86_400_000;
|
|
14
|
+
|
|
15
|
+
function ev(kind: TelemetryEvent["kind"], ts: number, extra: Record<string, unknown> = {}): TelemetryEvent {
|
|
16
|
+
return { kind, ts, iso: new Date(ts).toISOString(), ...extra };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function tmpDir(): string {
|
|
20
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-metrics-"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("isoWeek:UTC 周四分周,跨年边界不拆成两周", () => {
|
|
24
|
+
// 2026-01-01 是周四 → 属于 2026-W01
|
|
25
|
+
assert.equal(isoWeek(Date.UTC(2026, 0, 1)), "2026-W01");
|
|
26
|
+
// 2026-01-04(周日)仍属 2026-W01
|
|
27
|
+
assert.equal(isoWeek(Date.UTC(2026, 0, 4)), "2026-W01");
|
|
28
|
+
// 2026-01-05(周一)进入 2026-W02
|
|
29
|
+
assert.equal(isoWeek(Date.UTC(2026, 0, 5)), "2026-W02");
|
|
30
|
+
// 2025-12-29(周一)属 2026-W01:ISO 周跨年跟周四走
|
|
31
|
+
assert.equal(isoWeek(Date.UTC(2025, 11, 29)), "2026-W01");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("buildMetrics:按周聚合曝光 / 加载 / 尝试失败 / 冲突 / 归因", () => {
|
|
35
|
+
const now = Date.UTC(2026, 10, 5, 9);
|
|
36
|
+
const events = [
|
|
37
|
+
ev("advertised", now, {
|
|
38
|
+
skills: [
|
|
39
|
+
{ name: "a", filePath: "/a/SKILL.md", disabled: false, source: "project" },
|
|
40
|
+
{ name: "b", filePath: "/b/SKILL.md", disabled: true, source: "project" },
|
|
41
|
+
],
|
|
42
|
+
}),
|
|
43
|
+
ev("advertised", now - DAY, { skills: [{ name: "a", filePath: "/a/SKILL.md", disabled: false, source: "project" }] }),
|
|
44
|
+
ev("loaded", now, { skill: "a" }),
|
|
45
|
+
ev("failed", now, { skill: "a" }),
|
|
46
|
+
ev("conflict", now, { level: "P0", ruleId: "rg-over-find" }),
|
|
47
|
+
ev("conflict", now, { level: "P1", ruleId: "x" }),
|
|
48
|
+
ev("attributed", now, { skill: "a", category: "G" }),
|
|
49
|
+
ev("attributed", now, { skill: "a", category: "semantic" }),
|
|
50
|
+
];
|
|
51
|
+
const rows = buildMetrics(events, now);
|
|
52
|
+
assert.equal(rows.length, 1);
|
|
53
|
+
const row = rows[0]!;
|
|
54
|
+
assert.equal(row.advertised, 2, "disabled 的技能不算曝光");
|
|
55
|
+
assert.equal(row.loaded, 1);
|
|
56
|
+
assert.equal(row.attemptedFailed, 1);
|
|
57
|
+
assert.equal(row.loadRate, 0.5);
|
|
58
|
+
assert.deepEqual(row.conflicts, { p0: 1, p1: 1 });
|
|
59
|
+
assert.equal(row.attribution.G, 1);
|
|
60
|
+
assert.equal(Object.values(row.attribution).reduce((sum, n) => sum + n, 0), 1, "semantic 不算 A~G 之一");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("buildMetrics:空数据返回空数组,零曝光周不产生 NaN", () => {
|
|
64
|
+
assert.deepEqual(buildMetrics([], Date.now()), []);
|
|
65
|
+
const rows = buildMetrics([ev("missed", Date.UTC(2026, 10, 5), { skill: "a" })], Date.UTC(2026, 10, 5));
|
|
66
|
+
assert.equal(rows.length, 1);
|
|
67
|
+
assert.equal(rows[0]!.loadRate, 0);
|
|
68
|
+
assert.ok(Number.isFinite(rows[0]!.loadRate));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("buildMetrics:run 状态按派发日归周,提案 / 采纳 / 回滚分开数", () => {
|
|
72
|
+
const now = Date.UTC(2026, 10, 5);
|
|
73
|
+
const runs = [
|
|
74
|
+
{ runId: "r1", status: "proposed", dispatchedAt: now },
|
|
75
|
+
{ runId: "r2", status: "applied", dispatchedAt: now },
|
|
76
|
+
{ runId: "r3", status: "rolled-back", dispatchedAt: now },
|
|
77
|
+
{ runId: "r4", status: "skipped-before-dispatch", dispatchedAt: null },
|
|
78
|
+
];
|
|
79
|
+
const row = buildMetrics([], now, runs)[0]!;
|
|
80
|
+
assert.equal(row.proposed, 3, "proposed / applied / rolled-back 都算走到过提案阶段");
|
|
81
|
+
assert.equal(row.applied, 1);
|
|
82
|
+
assert.equal(row.rolledBack, 1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("buildMetrics:多周按周升序返回", () => {
|
|
86
|
+
const now = Date.UTC(2026, 10, 20);
|
|
87
|
+
const rows = buildMetrics([ev("loaded", now), ev("loaded", now - 14 * DAY)], now);
|
|
88
|
+
assert.equal(rows.length, 2);
|
|
89
|
+
assert.ok(rows[0]!.week < rows[1]!.week);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("appendMetrics:同 week + runId 只落一行(去重)", async () => {
|
|
93
|
+
const dir = tmpDir();
|
|
94
|
+
const now = Date.UTC(2026, 10, 5);
|
|
95
|
+
const week = isoWeek(now);
|
|
96
|
+
const row = { ...emptyRow(week, now) };
|
|
97
|
+
assert.equal(await appendMetrics(dir, row, directQueue), true);
|
|
98
|
+
assert.equal(await appendMetrics(dir, row, directQueue), false, "重复写同周汇总必须被跳过");
|
|
99
|
+
const single = { ...row, runId: "r1" };
|
|
100
|
+
assert.equal(await appendMetrics(dir, single, directQueue), true, "同周不同 runId 是另一行");
|
|
101
|
+
assert.equal(await appendMetrics(dir, single, directQueue), false);
|
|
102
|
+
assert.equal(readMetrics(dir).length, 2);
|
|
103
|
+
assert.equal(fs.readFileSync(metricsFile(dir), "utf8").trim().split("\n").length, 2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("rollbackRate:没有采纳样本返回 0,不返回 NaN", () => {
|
|
107
|
+
assert.equal(rollbackRate([]), 0);
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
assert.equal(rollbackRate([emptyRow("2026-W45", now)]), 0);
|
|
110
|
+
const row = { ...emptyRow("2026-W45", now), applied: 4, rolledBack: 1 };
|
|
111
|
+
assert.equal(rollbackRate([row]), 0.25);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** 造一行空指标:数值字段全零,避免各用例重复写字段 */
|
|
115
|
+
function emptyRow(week: string, ts: number) {
|
|
116
|
+
return {
|
|
117
|
+
ts,
|
|
118
|
+
week,
|
|
119
|
+
advertised: 0,
|
|
120
|
+
loaded: 0,
|
|
121
|
+
attemptedFailed: 0,
|
|
122
|
+
loadRate: 0,
|
|
123
|
+
conflicts: { p0: 0, p1: 0 },
|
|
124
|
+
proposed: 0,
|
|
125
|
+
applied: 0,
|
|
126
|
+
rolledBack: 0,
|
|
127
|
+
attribution: { A: 0, B: 0, C: 0, D: 0, E: 0, F: 0, G: 0 },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型配置单测(设计稿 12.5):值只有两种形态——`provider/model` 或 `inherit`。
|
|
3
|
+
* 非法值一律退 inherit 而不是让派发失败,这条兜底要有用例钉住。
|
|
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 {
|
|
11
|
+
DEFAULT_MODEL_CONFIG,
|
|
12
|
+
INHERIT,
|
|
13
|
+
describeModelConfig,
|
|
14
|
+
loadModelConfig,
|
|
15
|
+
modelChoices,
|
|
16
|
+
normalizeModelSpec,
|
|
17
|
+
readModelConfig,
|
|
18
|
+
resolveModel,
|
|
19
|
+
saveModelSpec,
|
|
20
|
+
specFromChoice,
|
|
21
|
+
} from "../lib/model-config.ts";
|
|
22
|
+
import { directQueue } from "../lib/telemetry.ts";
|
|
23
|
+
|
|
24
|
+
function tmpDir(): string {
|
|
25
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "sk-model-"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("合法 provider/model 原样保留,非法值退 inherit", () => {
|
|
29
|
+
assert.equal(normalizeModelSpec("deepseek/deepseek-v3.2"), "deepseek/deepseek-v3.2");
|
|
30
|
+
assert.equal(normalizeModelSpec(" openai/gpt-5:high "), "openai/gpt-5:high");
|
|
31
|
+
for (const bad of ["", "inherit", "no-slash", "/missing-provider", "provider/", 42, null, undefined]) {
|
|
32
|
+
assert.equal(normalizeModelSpec(bad), INHERIT, `非法值 ${String(bad)} 应退 inherit`);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("readModelConfig 缺字段 / 坏结构都当 inherit", () => {
|
|
37
|
+
assert.deepEqual(readModelConfig(undefined), DEFAULT_MODEL_CONFIG);
|
|
38
|
+
assert.deepEqual(readModelConfig({ propose: "not-an-object", review: { model: 3 } }), { propose: INHERIT, review: INHERIT });
|
|
39
|
+
assert.deepEqual(readModelConfig({ propose: { model: "a/b" }, review: { model: "c/d" } }), { propose: "a/b", review: "c/d" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("loadModelConfig:配置文件不存在时不报错也不建文件", () => {
|
|
43
|
+
const dir = tmpDir();
|
|
44
|
+
assert.deepEqual(loadModelConfig(dir), DEFAULT_MODEL_CONFIG);
|
|
45
|
+
assert.equal(fs.existsSync(path.join(dir, "config.json")), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("resolveModel:inherit 取主会话模型,显式值优先于主会话", () => {
|
|
49
|
+
const host = { model: { provider: "deepseek", id: "deepseek-v3.2" } };
|
|
50
|
+
assert.equal(resolveModel("review", DEFAULT_MODEL_CONFIG, host), "deepseek/deepseek-v3.2");
|
|
51
|
+
assert.equal(resolveModel("review", { propose: INHERIT, review: "openai/gpt-5" }, host), "openai/gpt-5");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("resolveModel:主会话没模型时仍返回 inherit,不伪造成空串", () => {
|
|
55
|
+
assert.equal(resolveModel("propose", DEFAULT_MODEL_CONFIG, {}), INHERIT);
|
|
56
|
+
assert.equal(resolveModel("propose", DEFAULT_MODEL_CONFIG, { model: { provider: "x" } }), INHERIT);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("选择项第一项是跟随主会话,回读能还原成 inherit", () => {
|
|
60
|
+
const choices = modelChoices([{ provider: "deepseek", id: "deepseek-v3.2" }, { provider: "only-provider" }]);
|
|
61
|
+
assert.equal(choices.length, 2, "缺 id 的模型不进候选");
|
|
62
|
+
assert.match(choices[0], /跟随主会话/);
|
|
63
|
+
assert.equal(specFromChoice(choices[0]), INHERIT);
|
|
64
|
+
assert.equal(specFromChoice(choices[1]), "deepseek/deepseek-v3.2");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("saveModelSpec 只改目标键,其余配置原样保留", async () => {
|
|
68
|
+
const dir = tmpDir();
|
|
69
|
+
fs.writeFileSync(path.join(dir, "config.json"), JSON.stringify({ enabled: false, idleMs: 1000, review: { model: "old/x" } }), "utf8");
|
|
70
|
+
const next = await saveModelSpec(dir, "propose", "deepseek/deepseek-v3.2", directQueue);
|
|
71
|
+
assert.equal(next.propose, "deepseek/deepseek-v3.2");
|
|
72
|
+
assert.equal(next.review, "old/x");
|
|
73
|
+
const saved = JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8")) as Record<string, unknown>;
|
|
74
|
+
assert.equal(saved.enabled, false, "其他键不得被覆盖");
|
|
75
|
+
assert.equal(saved.idleMs, 1000);
|
|
76
|
+
assert.deepEqual(saved.propose, { model: "deepseek/deepseek-v3.2" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("saveModelSpec 写入非法值时落 inherit 而不是坏字符串", async () => {
|
|
80
|
+
const dir = tmpDir();
|
|
81
|
+
const next = await saveModelSpec(dir, "review", "随便写点什么", directQueue);
|
|
82
|
+
assert.equal(next.review, INHERIT);
|
|
83
|
+
assert.match(describeModelConfig(next), /跟随主会话/);
|
|
84
|
+
});
|