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,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 全局单飞闸(用户 2026-11-08:「每次最多一个复盘agent」):
|
|
3
|
+
* 扫 `runs/<runId>/run.json` 的 pid(复盘)与 supervisorPid(督导),alive 为真即占用。
|
|
4
|
+
*
|
|
5
|
+
* 用注入的 alive 模拟存活,不起真进程、不看真 pid。
|
|
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 { findOccupant, guardBusy } from "../lib/single-flight.ts";
|
|
13
|
+
import type { TelemetryInput, TelemetryWriter } from "../lib/telemetry.ts";
|
|
14
|
+
|
|
15
|
+
const NOW = 1_760_000_000_000;
|
|
16
|
+
|
|
17
|
+
function workspace(): string {
|
|
18
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-single-"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeRun(evolutionDir: string, runId: string, record: Record<string, unknown>): void {
|
|
22
|
+
const runDir = path.join(evolutionDir, "runs", runId);
|
|
23
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
24
|
+
fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify({ runId, ...record }), "utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 只有列出来的 pid 算活着 */
|
|
28
|
+
function aliveOnly(...pids: number[]): (pid: number) => boolean {
|
|
29
|
+
return (pid) => pids.includes(pid);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writer(): { events: TelemetryInput[]; sync: TelemetryInput[]; telemetry: TelemetryWriter } {
|
|
33
|
+
const events: TelemetryInput[] = [];
|
|
34
|
+
const sync: TelemetryInput[] = [];
|
|
35
|
+
return {
|
|
36
|
+
events,
|
|
37
|
+
sync,
|
|
38
|
+
telemetry: {
|
|
39
|
+
append: (event) => events.push(event),
|
|
40
|
+
appendSync: (event) => sync.push(event),
|
|
41
|
+
flush: async () => undefined,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("findOccupant:没有 runs 目录 → null", () => {
|
|
47
|
+
assert.equal(findOccupant(workspace()), null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("findOccupant:run.json 里没有 pid / supervisorPid → null", () => {
|
|
51
|
+
const dir = workspace();
|
|
52
|
+
writeRun(dir, "r1", { status: "dispatched", pid: null });
|
|
53
|
+
assert.equal(findOccupant(dir, { alive: () => true }), null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("findOccupant:pid 已死 → null(死进程自然释放,不需要谁清场)", () => {
|
|
57
|
+
const dir = workspace();
|
|
58
|
+
writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
|
|
59
|
+
assert.equal(findOccupant(dir, { alive: aliveOnly(9999) }), null);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("findOccupant:复盘子进程活着 → kind=复盘", () => {
|
|
63
|
+
const dir = workspace();
|
|
64
|
+
writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
|
|
65
|
+
assert.deepEqual(findOccupant(dir, { alive: aliveOnly(4242) }), { runId: "r1", pid: 4242, kind: "复盘" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("findOccupant:复盘 pid 死了但督导活着 → kind=督导", () => {
|
|
69
|
+
const dir = workspace();
|
|
70
|
+
writeRun(dir, "r1", { status: "analyzed", pid: 4242, supervisorPid: 5151, dispatchedAt: NOW });
|
|
71
|
+
assert.deepEqual(findOccupant(dir, { alive: aliveOnly(5151) }), { runId: "r1", pid: 5151, kind: "督导" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("findOccupant:两条 run 都活着 → 返回最早派发的那条", () => {
|
|
75
|
+
const dir = workspace();
|
|
76
|
+
writeRun(dir, "new", { status: "dispatched", pid: 2, dispatchedAt: NOW + 1000 });
|
|
77
|
+
writeRun(dir, "old", { status: "dispatched", pid: 1, dispatchedAt: NOW });
|
|
78
|
+
assert.deepEqual(findOccupant(dir, { alive: aliveOnly(1, 2) }), { runId: "old", pid: 1, kind: "复盘" });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("findOccupant:ignoreRunId 排除自己那条(督导与自己同属一条 run)", () => {
|
|
82
|
+
const dir = workspace();
|
|
83
|
+
writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
|
|
84
|
+
assert.equal(findOccupant(dir, { alive: aliveOnly(4242), ignoreRunId: "r1" }), null);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("guardBusy:占用时写 skipped(busy) 遥测并返回占用者", () => {
|
|
88
|
+
const dir = workspace();
|
|
89
|
+
writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
|
|
90
|
+
const { events, sync, telemetry } = writer();
|
|
91
|
+
const occupant = guardBusy({
|
|
92
|
+
evolutionDir: dir,
|
|
93
|
+
sessionId: "s1",
|
|
94
|
+
telemetry,
|
|
95
|
+
trigger: "auto",
|
|
96
|
+
rounds: 2,
|
|
97
|
+
alive: aliveOnly(4242),
|
|
98
|
+
});
|
|
99
|
+
assert.deepEqual(occupant, { runId: "r1", pid: 4242, kind: "复盘" });
|
|
100
|
+
assert.deepEqual(events, [
|
|
101
|
+
{ kind: "skipped", sessionId: "s1", why: "busy", occupant: { runId: "r1", pid: 4242, kind: "复盘" }, trigger: "auto", rounds: 2 },
|
|
102
|
+
]);
|
|
103
|
+
assert.deepEqual(sync, [], "非退出路径走异步 append");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("guardBusy:退出补发路径用 appendSync 同步落盘", () => {
|
|
107
|
+
const dir = workspace();
|
|
108
|
+
writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
|
|
109
|
+
const { events, sync, telemetry } = writer();
|
|
110
|
+
guardBusy({ evolutionDir: dir, sessionId: "s1", telemetry, sync: true, trigger: "shutdown", alive: aliveOnly(4242) });
|
|
111
|
+
assert.equal(events.length, 0);
|
|
112
|
+
assert.equal(sync.length, 1);
|
|
113
|
+
assert.equal(sync[0]!.why, "busy");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("guardBusy:空闲时返回 null 且不写遥测", () => {
|
|
117
|
+
const dir = workspace();
|
|
118
|
+
writeRun(dir, "r1", { status: "dispatched", pid: 4242, dispatchedAt: NOW });
|
|
119
|
+
const { events, telemetry } = writer();
|
|
120
|
+
assert.equal(guardBusy({ evolutionDir: dir, sessionId: "s1", telemetry, alive: aliveOnly(9999) }), null);
|
|
121
|
+
assert.deepEqual(events, []);
|
|
122
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 卡死判定与「失败可见」单测(设计稿 14 章的可观测补丁)。
|
|
3
|
+
*
|
|
4
|
+
* 铁律:全程**不真起 pi / node 子进程**。spawn 一律传假 spawnFn,
|
|
5
|
+
* 只断言它收到的 stdio / 目录,进程由测试自己用 fs 模拟写日志。
|
|
6
|
+
* 依据:2026-11-08 漏注入 spawnFn,一次跑出 51 个孤儿复盘子进程。
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import { DEFAULT_CONFIG } from "../lib/config.ts";
|
|
14
|
+
import { collectPanelState, panelWidget, statusEntries, statusLine } from "../lib/panel.ts";
|
|
15
|
+
import { renderStatus } from "../lib/report.ts";
|
|
16
|
+
import { SUPERVISOR_LOG, spawnReviewSupervisor } from "../lib/review-spawn.ts";
|
|
17
|
+
import { CHILD_LOG, spawnDetached } from "../lib/spawn-plan.ts";
|
|
18
|
+
import { classifyStale, listStaleRuns, readRunError, scanStaleRuns, STALE_MS } from "../lib/stale.ts";
|
|
19
|
+
|
|
20
|
+
const NOW = Date.UTC(2026, 10, 8, 12);
|
|
21
|
+
const MINUTE = 60_000;
|
|
22
|
+
|
|
23
|
+
function tmpDir(): string {
|
|
24
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-stale-"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 写一条 run.json;raw 里给了什么就写什么(pid 缺省不写 = dispatch 半路被打断的形态) */
|
|
28
|
+
function writeRun(dir: string, runId: string, raw: Record<string, unknown>): string {
|
|
29
|
+
const runDir = path.join(dir, "runs", runId);
|
|
30
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
31
|
+
fs.writeFileSync(path.join(runDir, "run.json"), `${JSON.stringify({ runId, sessionId: "s", ...raw }, null, 2)}\n`, "utf8");
|
|
32
|
+
return runDir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readRun(runDir: string): Record<string, unknown> {
|
|
36
|
+
return JSON.parse(fs.readFileSync(path.join(runDir, "run.json"), "utf8")) as Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const deadPid = (): boolean => false;
|
|
40
|
+
const alivePid = (): boolean => true;
|
|
41
|
+
|
|
42
|
+
test("classifyStale:刚派发 10 秒内即使 pid 不在也不算卡死", () => {
|
|
43
|
+
const verdict = classifyStale({ status: "dispatched", pid: 291916, dispatchedAt: NOW - 10_000 }, NOW, { alive: deadPid });
|
|
44
|
+
assert.deepEqual(verdict, { stale: false });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("classifyStale:子进程已死且超过 5 分钟 → child-exited", () => {
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
classifyStale({ status: "dispatched", pid: 291916, dispatchedAt: NOW - STALE_MS - 1000 }, NOW, { alive: deadPid }),
|
|
50
|
+
{ stale: true, reason: "child-exited" },
|
|
51
|
+
);
|
|
52
|
+
assert.deepEqual(classifyStale({ status: "proposed", pid: 1, dispatchedAt: NOW - STALE_MS }, NOW, { alive: deadPid }), {
|
|
53
|
+
stale: false,
|
|
54
|
+
}, "正好 5 分钟不算「超过」");
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
classifyStale({ status: "analyzed", dispatchedAt: NOW - STALE_MS - 1 }, NOW, { alive: deadPid }),
|
|
57
|
+
{ stale: false },
|
|
58
|
+
"analyzed 说明结论已经写盘:子进程退出是正常的,不能报成卡死",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("classifyStale:pid 还活着但超单 run 上限 → timeout", () => {
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
classifyStale({ status: "dispatched", pid: 11367, dispatchedAt: NOW - DEFAULT_CONFIG.maxRuntimeMs - 1 }, NOW, { alive: alivePid }),
|
|
65
|
+
{ stale: true, reason: "timeout" },
|
|
66
|
+
);
|
|
67
|
+
assert.deepEqual(
|
|
68
|
+
classifyStale({ status: "dispatched", pid: 11367, dispatchedAt: NOW - 1000 }, NOW, { alive: alivePid }),
|
|
69
|
+
{ stale: false },
|
|
70
|
+
"活着且没超时不能误报",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("classifyStale:已出结论的状态与缺 dispatchedAt 的历史记录都不判", () => {
|
|
75
|
+
for (const status of ["reviewed", "applied", "rolled-back", "stale", "abandoned"]) {
|
|
76
|
+
assert.deepEqual(classifyStale({ status, pid: 1, dispatchedAt: NOW - 10 * 60 * MINUTE }, NOW, { alive: deadPid }), { stale: false });
|
|
77
|
+
}
|
|
78
|
+
assert.deepEqual(classifyStale({ status: "dispatched", pid: 1 }, NOW, { alive: deadPid }), { stale: false });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("spawnDetached:stdout+stderr 指向 logFile,子进程写入能落到文件", () => {
|
|
82
|
+
const dir = tmpDir();
|
|
83
|
+
const logFile = path.join(dir, "runs", "run-1", CHILD_LOG);
|
|
84
|
+
let seen: Record<string, unknown> = {};
|
|
85
|
+
const child = spawnDetached(
|
|
86
|
+
(command, args, options) => {
|
|
87
|
+
seen = { command, args, options };
|
|
88
|
+
const stdio = options.stdio as [string, number, number];
|
|
89
|
+
// 父进程的 fd 此刻还开着:用 fs 模拟子进程写日志(不真起进程)
|
|
90
|
+
fs.writeSync(stdio[1], "子进程崩了\n");
|
|
91
|
+
return { pid: 4242, unref: () => {} };
|
|
92
|
+
},
|
|
93
|
+
"/bin/pi",
|
|
94
|
+
["-p", "--no-session", "-ns", "prompt"],
|
|
95
|
+
{ cwd: dir, env: {}, logFile },
|
|
96
|
+
);
|
|
97
|
+
assert.equal(child.pid, 4242);
|
|
98
|
+
const stdio = seen.options.stdio as [string, number, number];
|
|
99
|
+
assert.deepEqual([stdio[0], stdio[1] === stdio[2], typeof stdio[1]], ["ignore", true, "number"]);
|
|
100
|
+
assert.equal(seen.options.detached, true);
|
|
101
|
+
assert.equal(fs.readFileSync(logFile, "utf8"), "子进程崩了\n");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("spawnDetached:日志文件开不出来时退回 stdio:\"ignore\",不阻断派发", () => {
|
|
105
|
+
let stdio: unknown = null;
|
|
106
|
+
const child = spawnDetached(
|
|
107
|
+
(_command, _args, options) => {
|
|
108
|
+
stdio = options.stdio;
|
|
109
|
+
return { pid: 1, unref: () => {} };
|
|
110
|
+
},
|
|
111
|
+
"/bin/pi",
|
|
112
|
+
[],
|
|
113
|
+
{ cwd: "/", env: {}, logFile: "/r/不许写的目录/child.log" },
|
|
114
|
+
);
|
|
115
|
+
assert.equal(child.pid, 1);
|
|
116
|
+
assert.equal(stdio, "ignore");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("督导进程同样落盘:supervisor.log 就在 run 目录里", () => {
|
|
120
|
+
const dir = tmpDir();
|
|
121
|
+
const runDir = path.join(dir, "runs", "run-1");
|
|
122
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
123
|
+
let stdio: unknown = null;
|
|
124
|
+
spawnReviewSupervisor({
|
|
125
|
+
runDir,
|
|
126
|
+
cwd: dir,
|
|
127
|
+
env: {},
|
|
128
|
+
spawnFn: (_command, _args, options) => {
|
|
129
|
+
stdio = options.stdio;
|
|
130
|
+
return { pid: 7777, unref: () => {} };
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
assert.equal(Array.isArray(stdio), true, "写法必须是 fd 数组");
|
|
134
|
+
assert.equal(fs.existsSync(path.join(runDir, SUPERVISOR_LOG)), true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("scanStaleRuns:子进程已死 → run.json 转 stale + run-error.json 带日志尾部 20 行", () => {
|
|
138
|
+
const dir = tmpDir();
|
|
139
|
+
const runDir = writeRun(dir, "354df7db-1794130855", { status: "dispatched", pid: 291916, dispatchedAt: NOW - 10 * MINUTE });
|
|
140
|
+
const lines = Array.from({ length: 25 }, (_value, index) => `日志第 ${index + 1} 行`);
|
|
141
|
+
fs.writeFileSync(path.join(runDir, CHILD_LOG), `${lines.join("\n")}\n`, "utf8");
|
|
142
|
+
|
|
143
|
+
const found = scanStaleRuns(dir, NOW, { alive: deadPid });
|
|
144
|
+
assert.equal(found.length, 1);
|
|
145
|
+
assert.deepEqual([found[0]!.runId, found[0]!.reason], ["354df7db-1794130855", "child-exited"]);
|
|
146
|
+
assert.equal(found[0]!.lastLine, "日志第 25 行");
|
|
147
|
+
assert.equal(readRun(runDir).status, "stale");
|
|
148
|
+
|
|
149
|
+
const error = readRunError(runDir);
|
|
150
|
+
assert.equal(error?.reason, "child-exited");
|
|
151
|
+
assert.equal(error?.at, NOW);
|
|
152
|
+
assert.equal(error?.childLogTail.length, 20, "只留最后 20 行");
|
|
153
|
+
assert.equal(error?.childLogTail[0], "日志第 6 行");
|
|
154
|
+
assert.equal(error?.childLogTail[19], "日志第 25 行");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("scanStaleRuns:超时的 run 先 kill 再标 stale,重复扫描不重复写(幂等)", () => {
|
|
158
|
+
const dir = tmpDir();
|
|
159
|
+
const runDir = writeRun(dir, "run-timeout", { status: "dispatched", pid: 4321, dispatchedAt: NOW - 30 * MINUTE });
|
|
160
|
+
const killed: number[] = [];
|
|
161
|
+
const found = scanStaleRuns(dir, NOW, { alive: alivePid, kill: (pid) => killed.push(pid), maxRuntimeMs: 20 * MINUTE });
|
|
162
|
+
assert.deepEqual(killed, [4321]);
|
|
163
|
+
assert.equal(found[0]!.reason, "timeout");
|
|
164
|
+
assert.equal(readRun(runDir).status, "stale");
|
|
165
|
+
const before = fs.readFileSync(path.join(runDir, "run-error.json"), "utf8");
|
|
166
|
+
assert.deepEqual(scanStaleRuns(dir, NOW + MINUTE, { alive: alivePid, kill: (pid) => killed.push(pid) }), []);
|
|
167
|
+
assert.deepEqual(killed, [4321], "已标 stale 的不再 kill 第二次");
|
|
168
|
+
assert.equal(fs.readFileSync(path.join(runDir, "run-error.json"), "utf8"), before);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("scanStaleRuns:活着且没超时的 run 一动不动;run.json 坏掉的目录直接跳过", () => {
|
|
172
|
+
const dir = tmpDir();
|
|
173
|
+
const runDir = writeRun(dir, "run-alive", { status: "dispatched", pid: 1, dispatchedAt: NOW - MINUTE });
|
|
174
|
+
const broken = path.join(dir, "runs", "broken");
|
|
175
|
+
fs.mkdirSync(broken, { recursive: true });
|
|
176
|
+
fs.writeFileSync(path.join(broken, "run.json"), "{ 不是 json", "utf8");
|
|
177
|
+
assert.deepEqual(scanStaleRuns(dir, NOW, { alive: alivePid }), []);
|
|
178
|
+
assert.equal(readRun(runDir).status, "dispatched");
|
|
179
|
+
assert.equal(fs.existsSync(path.join(runDir, "run-error.json")), false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("collectPanelState:reviewing 只数活着的 run,staleCount 覆盖「已落盘 + 判定命中」", () => {
|
|
183
|
+
const dir = tmpDir();
|
|
184
|
+
writeRun(dir, "r-dead", { status: "dispatched", pid: 291916, dispatchedAt: NOW - 10 * MINUTE });
|
|
185
|
+
writeRun(dir, "r-alive", { status: "dispatched", pid: 1, dispatchedAt: NOW - MINUTE });
|
|
186
|
+
writeRun(dir, "r-young", { status: "dispatched", pid: 291917, dispatchedAt: NOW - MINUTE });
|
|
187
|
+
writeRun(dir, "r-stale", { status: "stale", pid: 2, dispatchedAt: NOW - 20 * MINUTE });
|
|
188
|
+
const state = collectPanelState({ evolutionDir: dir, now: NOW, alive: (pid) => pid === 1, maxRuntimeMs: 20 * MINUTE });
|
|
189
|
+
assert.equal(state.reviewing, 2, "r-dead 超期且 pid 不在不再算「复盘」;r-alive 与宽限期内的 r-young 仍算");
|
|
190
|
+
assert.equal(state.staleCount, 2, "r-stale 已落盘 + r-dead 判定命中:两处口径必须一致,否则网页会出现「卡死 1、列表 2 条」");
|
|
191
|
+
|
|
192
|
+
// 5 分钟宽限内即使 pid 不在也还当在跑:与扫描共用同一口径,免得面板在子进程刚退出时闪一下
|
|
193
|
+
const youngDead = collectPanelState({ evolutionDir: dir, now: NOW, alive: deadPid, maxRuntimeMs: 20 * MINUTE });
|
|
194
|
+
assert.equal(youngDead.reviewing, 2, "宽限期内的 r-young 仍算在跑");
|
|
195
|
+
assert.equal(youngDead.staleCount, 2, "宽限期内的死 pid 不算卡死,只有已落盘的 r-stale 与超期的 r-dead");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("collectPanelState:analyzed 的 run 进程死掉不算「复盘」,督导活着才算", () => {
|
|
199
|
+
const dir = tmpDir();
|
|
200
|
+
writeRun(dir, "a-dead", { status: "analyzed", pid: 3001, dispatchedAt: NOW - 2 * MINUTE });
|
|
201
|
+
writeRun(dir, "a-live", { status: "analyzed", pid: 1, dispatchedAt: NOW - 2 * MINUTE });
|
|
202
|
+
writeRun(dir, "a-sup", { status: "analyzed", pid: 3002, supervisorPid: 1, dispatchedAt: NOW - 3 * MINUTE });
|
|
203
|
+
const state = collectPanelState({ evolutionDir: dir, now: NOW, alive: (pid) => pid === 1 });
|
|
204
|
+
assert.equal(state.reviewing, 2, "a-dead 结论已出且进程不在:不再算「复盘」;a-live 与有活督导的 a-sup 算");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("panelWidget / statusLine:卡死压过待批,且不显示「复盘 0」", () => {
|
|
208
|
+
const stale = { reviewing: 0, staleCount: 1, pendingApprovals: 2, lastAppliedAt: null, rollbackRate: 0, appliedSamples: 0 };
|
|
209
|
+
assert.deepEqual(panelWidget(stale), ["复盘失败 1 / 待批 2"]);
|
|
210
|
+
assert.equal(statusLine(stale), "复盘失败 1");
|
|
211
|
+
assert.deepEqual(panelWidget({ ...stale, staleCount: 0, pendingApprovals: 0 }), null, "全零仍然返回 null");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("statusEntries:stale 也会发一条 /tree 条目", () => {
|
|
215
|
+
const entry = statusEntries([{ runId: "354df7db-1794130855", status: "stale" }], new Map(), NOW)[0];
|
|
216
|
+
assert.deepEqual([entry?.kind, entry?.runId], ["stale", "354df7db-1794130855"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("renderStatus:卡死单列一行,带原因与日志最后一行", () => {
|
|
220
|
+
const text = renderStatus({
|
|
221
|
+
config: DEFAULT_CONFIG,
|
|
222
|
+
configPath: "/repo/.pi/evolution/config.json",
|
|
223
|
+
telemetryDir: "/repo/.pi/evolution/telemetry",
|
|
224
|
+
lockPid: undefined,
|
|
225
|
+
runs: [],
|
|
226
|
+
staleRuns: [{ runId: "354df7db-1794130855", runDir: "/repo/runs/x", reason: "child-exited", lastLine: "Error: 子进程崩了", at: NOW }],
|
|
227
|
+
});
|
|
228
|
+
assert.equal(text.includes(" 卡死 1 条:354df7db(child-exited,日志最后一行:Error: 子进程崩了)"), true);
|
|
229
|
+
const noLog = renderStatus({
|
|
230
|
+
config: DEFAULT_CONFIG,
|
|
231
|
+
configPath: "/c.json",
|
|
232
|
+
telemetryDir: "/t",
|
|
233
|
+
lockPid: undefined,
|
|
234
|
+
runs: [],
|
|
235
|
+
staleRuns: [{ runId: "abc-1", runDir: "/repo/runs/y", reason: "timeout", lastLine: null, at: NOW }],
|
|
236
|
+
});
|
|
237
|
+
assert.equal(noLog.includes("卡死 1 条:abc(timeout,日志最后一行:无日志(child.log 不存在或为空))"), true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("renderStatus:同一会话的多条卡死 run 用完整 runId,不写成两行一样的", () => {
|
|
241
|
+
const text = renderStatus({
|
|
242
|
+
config: DEFAULT_CONFIG,
|
|
243
|
+
configPath: "/c.json",
|
|
244
|
+
telemetryDir: "/t",
|
|
245
|
+
lockPid: undefined,
|
|
246
|
+
runs: [],
|
|
247
|
+
staleRuns: [
|
|
248
|
+
{ runId: "354df7db-1794130855", runDir: "/r/a", reason: "child-exited", lastLine: null, at: NOW },
|
|
249
|
+
{ runId: "354df7db-1794131353", runDir: "/r/b", reason: "child-exited", lastLine: null, at: NOW },
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
assert.equal(text.includes("卡死 2 条:354df7db-1794130855("), true);
|
|
253
|
+
assert.equal(text.includes("354df7db-1794131353("), true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("listStaleRuns:只列已判 stale 的,按派发时间倒序并带上日志最后一行", () => {
|
|
257
|
+
const dir = tmpDir();
|
|
258
|
+
const older = writeRun(dir, "old-1", { status: "stale", pid: 1, dispatchedAt: NOW - 60 * MINUTE });
|
|
259
|
+
writeRun(dir, "new-2", { status: "stale", pid: 2, dispatchedAt: NOW - 10 * MINUTE });
|
|
260
|
+
writeRun(dir, "live-3", { status: "dispatched", pid: 3, dispatchedAt: NOW - MINUTE });
|
|
261
|
+
// run-error.json 是 stale 条目的唯一权威来源:lastLine 取它里面的 childLogTail 尾部
|
|
262
|
+
fs.writeFileSync(
|
|
263
|
+
path.join(older, "run-error.json"),
|
|
264
|
+
JSON.stringify({ reason: "timeout", at: NOW, childLogTail: ["前一行", "最后一行"] }),
|
|
265
|
+
"utf8",
|
|
266
|
+
);
|
|
267
|
+
scanStaleRuns(dir, NOW, { alive: deadPid });
|
|
268
|
+
const runs = listStaleRuns(dir, 5);
|
|
269
|
+
assert.deepEqual(runs.map((run) => run.runId), ["new-2", "old-1"]);
|
|
270
|
+
assert.equal(runs[0]!.lastLine, null, "没有 run-error / 日志就是 null,不编内容");
|
|
271
|
+
assert.deepEqual([runs[1]!.lastLine, runs[1]!.reason], ["最后一行", "timeout"]);
|
|
272
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 遥测单测(零依赖,node --test)。
|
|
3
|
+
*
|
|
4
|
+
* 运行:
|
|
5
|
+
* cd .pi/extensions/skill-evolve
|
|
6
|
+
* node --experimental-strip-types --test test/telemetry.test.ts
|
|
7
|
+
*
|
|
8
|
+
* 覆盖:除零返回 0(绝不返回 NaN)、disabled 技能不算曝光、未命中 top10 排序与截断、
|
|
9
|
+
* 按天分片写入与跨天读取、写入失败只记日志不抛。
|
|
10
|
+
*/
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { createTelemetry, dayOf, directQueue, ratio, readEvents, shardFile, summarize, type MutationQueue, type TelemetryEvent, type TelemetrySkill } from "../lib/telemetry.ts";
|
|
18
|
+
|
|
19
|
+
const NOW = new Date(2026, 10, 8, 15, 30, 0).getTime(); // 本地时间 2026-11-08 15:30
|
|
20
|
+
|
|
21
|
+
function tmpDir(): string {
|
|
22
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-tele-"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function skill(name: string, disabled = false): TelemetrySkill {
|
|
26
|
+
return { name, filePath: `/repo/.pi/skills/${name}/SKILL.md`, disabled, source: "project" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function advertised(skills: TelemetrySkill[]): TelemetryEvent {
|
|
30
|
+
return { ts: NOW, iso: new Date(NOW).toISOString(), kind: "advertised", sessionId: "s1", skills };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("ratio:除零与非法值一律返回 0", () => {
|
|
34
|
+
assert.equal(ratio(0, 0), 0);
|
|
35
|
+
assert.equal(ratio(3, 0), 0);
|
|
36
|
+
assert.equal(ratio(Number.NaN, 5), 0);
|
|
37
|
+
assert.equal(ratio(5, Number.POSITIVE_INFINITY), 0);
|
|
38
|
+
assert.equal(ratio(2, 4), 0.5);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("summarize:空数据不产生 NaN", () => {
|
|
42
|
+
const summary = summarize([]);
|
|
43
|
+
assert.equal(summary.loadRate, 0);
|
|
44
|
+
assert.equal(Number.isNaN(summary.loadRate), false);
|
|
45
|
+
assert.equal(summary.advertised, 0);
|
|
46
|
+
assert.deepEqual(summary.missedTop, []);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("summarize:disabled 技能不算曝光,加载率按有效曝光算", () => {
|
|
50
|
+
const events: TelemetryEvent[] = [
|
|
51
|
+
advertised([skill("a"), skill("b"), skill("c", true)]),
|
|
52
|
+
{ ts: NOW, iso: "", kind: "loaded", sessionId: "s1", skill: "a" },
|
|
53
|
+
{ ts: NOW, iso: "", kind: "failed", sessionId: "s1", skill: "b" },
|
|
54
|
+
];
|
|
55
|
+
const summary = summarize(events);
|
|
56
|
+
assert.equal(summary.rounds, 1);
|
|
57
|
+
assert.equal(summary.advertised, 2);
|
|
58
|
+
assert.equal(summary.loaded, 1);
|
|
59
|
+
assert.equal(summary.failed, 1);
|
|
60
|
+
assert.equal(summary.loadRate, 0.5);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("summarize:未命中 top10 按次数倒序、同名同次按名字排序、最多 10 条", () => {
|
|
64
|
+
const events: TelemetryEvent[] = [{ ts: NOW, iso: "", kind: "missed", sessionId: "s1", skill: "b" }];
|
|
65
|
+
events.push({ ts: NOW, iso: "", kind: "missed", skill: "b" });
|
|
66
|
+
events.push({ ts: NOW, iso: "", kind: "missed", skill: "a" });
|
|
67
|
+
for (let i = 0; i < 12; i += 1) events.push({ ts: NOW, iso: "", kind: "missed", skill: `z${String(i).padStart(2, "0")}` });
|
|
68
|
+
const summary = summarize(events);
|
|
69
|
+
assert.equal(summary.missed, 15);
|
|
70
|
+
assert.equal(summary.missedTop.length, 10);
|
|
71
|
+
assert.deepEqual(summary.missedTop[0], { skill: "b", count: 2 });
|
|
72
|
+
assert.deepEqual(summary.missedTop[1], { skill: "a", count: 1 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("dayOf / shardFile:按本地日期分片", () => {
|
|
76
|
+
assert.equal(dayOf(NOW), "2026-11-08");
|
|
77
|
+
assert.equal(shardFile("/x/telemetry", NOW), path.join("/x/telemetry", "2026-11-08.jsonl"));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("createTelemetry:append 后 flush,跨天读取能读到今天的行", async () => {
|
|
81
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-tel-"));
|
|
82
|
+
const writer = createTelemetry({ dir, queue: directQueue, enabled: true, now: () => NOW });
|
|
83
|
+
// 两次写之间必须 flush:测试里注入的是 directQueue(不串行),真实链路靠 withFileMutationQueue 保证同文件串行
|
|
84
|
+
writer.append({ kind: "advertised", sessionId: "s1", skills: [skill("a")] });
|
|
85
|
+
await writer.flush();
|
|
86
|
+
writer.append({ kind: "loaded", sessionId: "s1", skill: "a", how: "read", ok: true });
|
|
87
|
+
await writer.flush();
|
|
88
|
+
|
|
89
|
+
const file = shardFile(dir, NOW);
|
|
90
|
+
const lines = fs.readFileSync(file, "utf8").trim().split("\n");
|
|
91
|
+
assert.equal(lines.length, 2);
|
|
92
|
+
const first = JSON.parse(lines[0]);
|
|
93
|
+
assert.equal(first.kind, "advertised");
|
|
94
|
+
assert.equal(first.ts, NOW);
|
|
95
|
+
assert.equal(first.iso, new Date(NOW).toISOString());
|
|
96
|
+
assert.equal(summarize(readEvents(dir, 1, NOW)).loaded, 1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("createTelemetry:enabled=false 时完全不写盘", async () => {
|
|
100
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-tel-"));
|
|
101
|
+
const writer = createTelemetry({ dir, queue: directQueue, enabled: false, now: () => NOW });
|
|
102
|
+
writer.append({ kind: "pending", sessionId: "s1" });
|
|
103
|
+
await writer.flush();
|
|
104
|
+
assert.equal(fs.existsSync(shardFile(dir, NOW)), false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("createTelemetry:写失败只记日志,不抛到调用方", async () => {
|
|
108
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-tel-"));
|
|
109
|
+
const logs: string[] = [];
|
|
110
|
+
const failing: MutationQueue = async () => {
|
|
111
|
+
throw new Error("磁盘满了");
|
|
112
|
+
};
|
|
113
|
+
const writer = createTelemetry({ dir, queue: failing, enabled: true, now: () => NOW, log: (msg) => logs.push(msg) });
|
|
114
|
+
writer.append({ kind: "loaded", sessionId: "s1", skill: "a" });
|
|
115
|
+
await writer.flush();
|
|
116
|
+
assert.equal(logs.some((line) => line.includes("遥测写入失败")), true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("readEvents:目录不存在、天数非法、坏行都安全", () => {
|
|
120
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-tel-"));
|
|
121
|
+
fs.writeFileSync(shardFile(dir, NOW), '{"kind":"loaded","ts":1}\n不是 JSON\n\n', "utf8");
|
|
122
|
+
assert.equal(readEvents(dir, 1, NOW).length, 1);
|
|
123
|
+
assert.deepEqual(readEvents(path.join(dir, "不存在"), 1, NOW), []);
|
|
124
|
+
assert.equal(readEvents(dir, 0, NOW).length, 1, "days<=0 回退 7 天应能读到今天的分片");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("appendSync:同步落盘(退出前补发路径,不能等异步队列)", () => {
|
|
128
|
+
const dir = tmpDir();
|
|
129
|
+
const telemetry = createTelemetry({ dir, queue: directQueue, enabled: true, log: () => {}, now: () => NOW });
|
|
130
|
+
telemetry.appendSync({ kind: "dispatched", sessionId: "s1", runId: "r1", pid: 42, trigger: "shutdown", ts: NOW });
|
|
131
|
+
const file = shardFile(dir, NOW);
|
|
132
|
+
assert.equal(fs.existsSync(file), true);
|
|
133
|
+
assert.equal(fs.readFileSync(file, "utf8").includes("\"trigger\":\"shutdown\""), true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("appendSync:目录不可写时只记日志,不抛", () => {
|
|
137
|
+
const dir = tmpDir();
|
|
138
|
+
const logs: string[] = [];
|
|
139
|
+
// 用一个「父路径是文件」的目录制造必然失败的 mkdir
|
|
140
|
+
const blocked = path.join(dir, "not-a-dir");
|
|
141
|
+
fs.writeFileSync(blocked, "x");
|
|
142
|
+
const telemetry = createTelemetry({ dir: path.join(blocked, "telemetry"), queue: directQueue, enabled: true, log: (msg) => logs.push(msg), now: () => NOW });
|
|
143
|
+
assert.doesNotThrow(() => telemetry.appendSync({ kind: "skipped", sessionId: "s1", why: "lock", trigger: "shutdown" }));
|
|
144
|
+
assert.equal(logs.some((line) => line.includes("遥测写入失败")), true);
|
|
145
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI 闸(用户 2026-11-08:「tui 的这些推送不看了,影响我」):
|
|
3
|
+
* `ui.widget` / `ui.entries` 为 false 时一次都不碰宿主 UI;显式打开才各写一次。
|
|
4
|
+
*
|
|
5
|
+
* 宿主用记录型假对象,不依赖 pi 运行时。
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import type { UiConfig } from "../lib/config.ts";
|
|
10
|
+
import type { PanelEntry, PanelState } from "../lib/panel.ts";
|
|
11
|
+
import { PANEL_ID, renderPanel, writeEntry, type UiPanelHost } from "../lib/ui-gate.ts";
|
|
12
|
+
|
|
13
|
+
const OFF: UiConfig = { widget: false, entries: false };
|
|
14
|
+
const ON: UiConfig = { widget: true, entries: true };
|
|
15
|
+
|
|
16
|
+
function busyState(): PanelState {
|
|
17
|
+
return { reviewing: 1, staleCount: 1, pendingApprovals: 2, lastAppliedAt: null, rollbackRate: 0, appliedSamples: 0 };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Calls {
|
|
21
|
+
host: UiPanelHost;
|
|
22
|
+
widgets: (string[] | undefined)[];
|
|
23
|
+
statuses: (string | undefined)[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fakeHost(): Calls {
|
|
27
|
+
const widgets: (string[] | undefined)[] = [];
|
|
28
|
+
const statuses: (string | undefined)[] = [];
|
|
29
|
+
return {
|
|
30
|
+
widgets,
|
|
31
|
+
statuses,
|
|
32
|
+
host: {
|
|
33
|
+
setWidget: (id, content) => {
|
|
34
|
+
assert.equal(id, PANEL_ID);
|
|
35
|
+
widgets.push(content);
|
|
36
|
+
},
|
|
37
|
+
setStatus: (id, content) => {
|
|
38
|
+
assert.equal(id, PANEL_ID);
|
|
39
|
+
statuses.push(content);
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("renderPanel:ui.widget=false 时一次都不调 setWidget / setStatus", () => {
|
|
46
|
+
const calls = fakeHost();
|
|
47
|
+
renderPanel(calls.host, OFF, busyState());
|
|
48
|
+
assert.deepEqual(calls.widgets, []);
|
|
49
|
+
assert.deepEqual(calls.statuses, []);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("renderPanel:ui.widget=true 时面板与状态栏各写一次", () => {
|
|
53
|
+
const calls = fakeHost();
|
|
54
|
+
renderPanel(calls.host, ON, busyState());
|
|
55
|
+
assert.equal(calls.widgets.length, 1);
|
|
56
|
+
assert.equal(calls.statuses.length, 1);
|
|
57
|
+
assert.ok((calls.widgets[0] ?? []).length > 0, "有数据时面板不该是空的");
|
|
58
|
+
assert.equal(typeof calls.statuses[0], "string");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("writeEntry:ui.entries=false 时不写 /tree 条目,返回 false", () => {
|
|
62
|
+
const written: PanelEntry[] = [];
|
|
63
|
+
const ok = writeEntry<PanelEntry>(OFF, (entry) => written.push(entry), {
|
|
64
|
+
kind: "reviewed",
|
|
65
|
+
runId: "r1",
|
|
66
|
+
at: 1,
|
|
67
|
+
});
|
|
68
|
+
assert.equal(ok, false);
|
|
69
|
+
assert.deepEqual(written, []);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("writeEntry:ui.entries=true 时写一次并返回 true", () => {
|
|
73
|
+
const written: PanelEntry[] = [];
|
|
74
|
+
const entry: PanelEntry = { kind: "dispatched", runId: "r1", at: 1 };
|
|
75
|
+
assert.equal(writeEntry<PanelEntry>(ON, (item) => written.push(item), entry), true);
|
|
76
|
+
assert.deepEqual(written, [entry]);
|
|
77
|
+
});
|