pi-skill-evolve 0.0.0-stage → 0.1.0
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 +116 -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,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 派发单测(零依赖,node --test)。
|
|
3
|
+
*
|
|
4
|
+
* 运行:
|
|
5
|
+
* cd .pi/extensions/skill-evolve
|
|
6
|
+
* node --experimental-strip-types --test test/dispatch.test.ts
|
|
7
|
+
*
|
|
8
|
+
* 覆盖:pi 路径解析优先级、runId 形态、去抖判据、run.json 落盘与状态机、
|
|
9
|
+
* 抢锁失败/开关关闭/单会话上限三条跳过路径、「无会话原文降级 telemetry-only」、
|
|
10
|
+
* 以及本轮重点:**去抖把尾巴整段带上的三条路径**(窗口过后合并派发 / 退出补发 / 手动绕过去抖)。
|
|
11
|
+
*/
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import { DEFAULT_CONFIG, type EvolutionConfig } from "../lib/config.ts";
|
|
20
|
+
import { createDispatcher, shouldDebounce } from "../lib/dispatch.ts";
|
|
21
|
+
import { acquireLock, releaseLock } from "../lib/lock.ts";
|
|
22
|
+
import { buildPrompt } from "../lib/prompt.ts";
|
|
23
|
+
import { DEFAULT_PI_BIN, resolvePiBin, runIdFor, sessionHashOf, type SpawnedChild, type SpawnLike } from "../lib/spawn-plan.ts";
|
|
24
|
+
import { directQueue, type TelemetryEvent, type TelemetryInput, type TelemetryWriter } from "../lib/telemetry.ts";
|
|
25
|
+
import { fakeSupervisorSpawn } from "./fake-review-spawn.ts";
|
|
26
|
+
|
|
27
|
+
const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "prompts", "复盘.md");
|
|
28
|
+
const NOW = 1_760_000_000_000;
|
|
29
|
+
const MAX = 20 * 60_000;
|
|
30
|
+
|
|
31
|
+
class FakeChild {
|
|
32
|
+
readonly pid: number;
|
|
33
|
+
private listeners = new Map<string, (arg: unknown) => void>();
|
|
34
|
+
// 锁回收用 process.kill(pid, 0) 校验存活:拿一个乱编的 pid 会被当成死进程、锁被当成孤儿回收,
|
|
35
|
+
// 所以假子进程统一用真实存活的 pid(就是本进程)模拟
|
|
36
|
+
constructor() {
|
|
37
|
+
this.pid = process.pid;
|
|
38
|
+
}
|
|
39
|
+
unref(): void {
|
|
40
|
+
// detached 子进程交还给 init,测试里无事可做
|
|
41
|
+
}
|
|
42
|
+
once(event: string, listener: (arg: unknown) => void): this {
|
|
43
|
+
this.listeners.set(event, listener);
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
emit(event: string, arg: unknown): void {
|
|
47
|
+
this.listeners.get(event)?.(arg);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Harness {
|
|
52
|
+
dir: string;
|
|
53
|
+
calls: { command: string; args: string[]; options: Record<string, unknown> }[];
|
|
54
|
+
children: FakeChild[];
|
|
55
|
+
supervisorCalls: { args: string[]; options: Record<string, unknown> }[];
|
|
56
|
+
events: TelemetryEvent[];
|
|
57
|
+
tick: (ms: number) => void;
|
|
58
|
+
dispatch: ReturnType<typeof createDispatcher>["dispatch"];
|
|
59
|
+
flushPending: ReturnType<typeof createDispatcher>["flushPending"];
|
|
60
|
+
config: EvolutionConfig;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function harness(tag: string, overrides: Partial<EvolutionConfig> = {}): Harness {
|
|
64
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `skill-evolve-${tag}-`));
|
|
65
|
+
const calls: Harness["calls"] = [];
|
|
66
|
+
const children: FakeChild[] = [];
|
|
67
|
+
const events: TelemetryEvent[] = [];
|
|
68
|
+
const spawnFn: SpawnLike = (command, args, options) => {
|
|
69
|
+
calls.push({ command, args, options });
|
|
70
|
+
const child = new FakeChild();
|
|
71
|
+
children.push(child);
|
|
72
|
+
return child as unknown as SpawnedChild;
|
|
73
|
+
};
|
|
74
|
+
const pushEvent = (event: TelemetryInput): void => {
|
|
75
|
+
events.push({ ...event, ts: NOW, iso: "" } as TelemetryEvent);
|
|
76
|
+
};
|
|
77
|
+
const writer: TelemetryWriter = { append: pushEvent, appendSync: pushEvent, flush: async () => undefined };
|
|
78
|
+
// 生产默认全关(clone 即静默):夹具必须显式打开,否则每个用例都会被「未启用」静默跳过
|
|
79
|
+
const config: EvolutionConfig = { ...DEFAULT_CONFIG, enabled: true, telemetry: true, ...overrides };
|
|
80
|
+
const supervisorCalls: { args: string[]; options: Record<string, unknown> }[] = []; // 假督导只记参数:真起 node 会留 900 秒后台进程
|
|
81
|
+
let clock = NOW;
|
|
82
|
+
const dispatcher = createDispatcher({
|
|
83
|
+
evolutionDir: path.join(dir, ".pi", "evolution"),
|
|
84
|
+
cwd: dir,
|
|
85
|
+
config,
|
|
86
|
+
telemetry: writer,
|
|
87
|
+
queue: directQueue,
|
|
88
|
+
promptTemplatePath: TEMPLATE,
|
|
89
|
+
log: () => {},
|
|
90
|
+
now: () => clock,
|
|
91
|
+
env: {},
|
|
92
|
+
spawnFn,
|
|
93
|
+
// 假子进程 pid = 本进程 pid(锁回收靠真 pid 判活);单飞闸用注入的 alive 模拟「已退出」
|
|
94
|
+
alive: () => false,
|
|
95
|
+
supervisorSpawnFn: fakeSupervisorSpawn(supervisorCalls),
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
dir,
|
|
99
|
+
calls,
|
|
100
|
+
supervisorCalls,
|
|
101
|
+
children,
|
|
102
|
+
events,
|
|
103
|
+
tick: (ms) => { clock += ms; },
|
|
104
|
+
dispatch: dispatcher.dispatch,
|
|
105
|
+
flushPending: dispatcher.flushPending,
|
|
106
|
+
config,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readRun(dir: string, runId: string): Record<string, unknown> {
|
|
111
|
+
return JSON.parse(fs.readFileSync(path.join(dir, ".pi", "evolution", "runs", runId, "run.json"), "utf8"));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const settleAsync = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 120));
|
|
115
|
+
|
|
116
|
+
/** 模拟子进程正常跑完:收尸会顺手放锁,后面的派发才抢得到 */
|
|
117
|
+
async function childExits(h: Harness, index: number): Promise<void> {
|
|
118
|
+
h.children[index].emit("exit", 0);
|
|
119
|
+
await settleAsync();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const argsOf = (h: Harness, index = 0): string => (h.calls.length > index ? h.calls[index].args.join("\n") : "");
|
|
123
|
+
|
|
124
|
+
test("纯函数:pi 路径优先级、runId 形态、去抖判据", () => {
|
|
125
|
+
assert.equal(resolvePiBin({ SKILL_EVOLVE_PI_BIN: "/a/pi", PI_SUBAGENT_PI_BINARY: "/b/pi" }), "/a/pi");
|
|
126
|
+
assert.equal(resolvePiBin({ PI_SUBAGENT_PI_BINARY: "/b/pi" }), "/b/pi");
|
|
127
|
+
assert.equal(resolvePiBin({}), DEFAULT_PI_BIN);
|
|
128
|
+
assert.equal(runIdFor("sess-1", NOW), `${sessionHashOf("sess-1").slice(0, 8)}-${Math.floor(NOW / 1000)}`);
|
|
129
|
+
assert.notEqual(runIdFor("sess-1", NOW), runIdFor("sess-2", NOW));
|
|
130
|
+
assert.equal(shouldDebounce(undefined, NOW, 90_000), false);
|
|
131
|
+
assert.equal(shouldDebounce(NOW - 1000, NOW, 90_000), true);
|
|
132
|
+
assert.equal(shouldDebounce(NOW - 90_000, NOW, 90_000), false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("buildPrompt:带会话原文/索引/run 目录,并写清覆盖轮数", () => {
|
|
136
|
+
const full = buildPrompt({ template: "模板", sessionFile: "/s.jsonl", indexFile: "/i.json", runDir: "/r", sessionId: "s1", skills: ["a", "b"], dispatchedAt: NOW });
|
|
137
|
+
assert.equal(full.includes("会话原文:/s.jsonl"), true);
|
|
138
|
+
assert.equal(full.includes("技能清单(逐个判定,不许遗漏也不许新增):a、b"), true);
|
|
139
|
+
assert.equal(full.includes("覆盖轮数:1 轮"), true);
|
|
140
|
+
const merged = buildPrompt({ template: "模板", indexFile: "/i.json", runDir: "/r", sessionId: "s1", skills: [], dispatchedAt: NOW, coverage: { rounds: 3, since: NOW } });
|
|
141
|
+
assert.equal(merged.includes("合并了 3 轮"), true);
|
|
142
|
+
assert.equal(merged.includes("telemetry-only"), true);
|
|
143
|
+
assert.equal(merged.includes("(本轮无曝光技能)"), true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("dispatch 成功:spawn 参数、env、run.json、会话索引都到位", async () => {
|
|
147
|
+
const h = harness("ok");
|
|
148
|
+
const sessionFile = path.join(h.dir, "session.jsonl");
|
|
149
|
+
fs.writeFileSync(sessionFile, `${JSON.stringify({ type: "session", id: "sess-1", cwd: h.dir })}\n`, "utf8");
|
|
150
|
+
|
|
151
|
+
const outcome = await h.dispatch({ sessionId: "sess-1", skills: ["a"], sessionFile });
|
|
152
|
+
assert.equal(outcome.status, "dispatched");
|
|
153
|
+
if (outcome.status !== "dispatched") return;
|
|
154
|
+
const { command, args, options } = h.calls[0];
|
|
155
|
+
assert.equal(command, DEFAULT_PI_BIN);
|
|
156
|
+
assert.deepEqual(args.slice(0, 3), ["-p", "--no-session", "-ns"]);
|
|
157
|
+
assert.equal(args[3].includes(sessionFile), true);
|
|
158
|
+
assert.equal(options.detached, true);
|
|
159
|
+
assert.equal((options.env as NodeJS.ProcessEnv).SKILL_EVOLVE_CHILD, "1");
|
|
160
|
+
// stdout+stderr 必须落到 run 目录下的 child.log:子进程静默死掉时这是唯一的现场
|
|
161
|
+
const stdio = options.stdio as [string, number, number];
|
|
162
|
+
assert.deepEqual([stdio[0], stdio[1] === stdio[2], typeof stdio[1]], ["ignore", true, "number"]);
|
|
163
|
+
const childLog = path.join(h.dir, ".pi", "evolution", "runs", outcome.run.runId, "child.log");
|
|
164
|
+
assert.equal(fs.existsSync(childLog), true, "child.log 必须先建出来(append 模式)");
|
|
165
|
+
|
|
166
|
+
const record = readRun(h.dir, outcome.run.runId);
|
|
167
|
+
assert.deepEqual([record.status, record.trigger, record.evidenceMode, record.pid], ["dispatched", "auto", "session+telemetry", process.pid]);
|
|
168
|
+
assert.deepEqual(record.skills, ["a"]);
|
|
169
|
+
assert.equal(fs.existsSync(path.join(h.dir, ".pi", "evolution", "runs", outcome.run.runId, "00-session", "index.json")), true);
|
|
170
|
+
assert.equal(h.events.some((event) => event.kind === "dispatched"), true);
|
|
171
|
+
await releaseLock(path.join(h.dir, ".pi", "evolution"));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("dispatch 去抖:窗口内第二次写 superseded 并记 pending,不再起进程", async () => {
|
|
175
|
+
const h = harness("debounce");
|
|
176
|
+
assert.equal((await h.dispatch({ sessionId: "sess-1", skills: ["a"] })).status, "dispatched");
|
|
177
|
+
h.tick(1000);
|
|
178
|
+
const second = await h.dispatch({ sessionId: "sess-1", skills: ["b"] });
|
|
179
|
+
assert.deepEqual(second, { status: "superseded", rounds: 1 });
|
|
180
|
+
assert.equal(h.calls.length, 1);
|
|
181
|
+
assert.equal(h.events.filter((event) => event.kind === "superseded").length, 1);
|
|
182
|
+
await releaseLock(path.join(h.dir, ".pi", "evolution"));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("去抖尾巴:3 轮 → 1 次派发 + 2 次 superseded,退出时补发一次补上后 2 轮", async () => {
|
|
186
|
+
const h = harness("tail");
|
|
187
|
+
assert.equal((await h.dispatch({ sessionId: "s1", skills: ["a"] })).status, "dispatched");
|
|
188
|
+
await childExits(h, 0); // 第一轮的子进程跑完并放锁
|
|
189
|
+
|
|
190
|
+
h.tick(1000);
|
|
191
|
+
assert.deepEqual(await h.dispatch({ sessionId: "s1", skills: ["b"] }), { status: "superseded", rounds: 1 });
|
|
192
|
+
h.tick(1000);
|
|
193
|
+
assert.deepEqual(await h.dispatch({ sessionId: "s1", skills: ["c"] }), { status: "superseded", rounds: 2 });
|
|
194
|
+
assert.equal(h.calls.length, 1);
|
|
195
|
+
|
|
196
|
+
assert.equal(h.flushPending(), 1);
|
|
197
|
+
assert.equal(h.calls.length, 2);
|
|
198
|
+
// 第一轮已经被第一次派发覆盖,补发只盖后面 2 轮
|
|
199
|
+
assert.equal(argsOf(h, 1).includes("合并了 2 轮"), true);
|
|
200
|
+
const records = fs.readdirSync(path.join(h.dir, ".pi", "evolution", "runs"));
|
|
201
|
+
const last = records.map((runId) => readRun(h.dir, runId)).find((record) => record.trigger === "shutdown");
|
|
202
|
+
assert.ok(last);
|
|
203
|
+
assert.deepEqual(last.skills, ["b", "c"]);
|
|
204
|
+
assert.deepEqual((last.coverage as { rounds: number }).rounds, 2);
|
|
205
|
+
assert.equal(h.events.some((event) => event.kind === "dispatched" && event.trigger === "shutdown"), true);
|
|
206
|
+
// 已经补发过,再调一次不会重复起进程
|
|
207
|
+
assert.equal(h.flushPending(), 0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("去抖尾巴:静默窗口过后的一轮正常派发,并合并在途的 2 轮", async () => {
|
|
211
|
+
const h = harness("window");
|
|
212
|
+
await h.dispatch({ sessionId: "s1", skills: ["a"] });
|
|
213
|
+
await childExits(h, 0);
|
|
214
|
+
h.tick(1000);
|
|
215
|
+
await h.dispatch({ sessionId: "s1", skills: ["b"] });
|
|
216
|
+
h.tick(1000);
|
|
217
|
+
await h.dispatch({ sessionId: "s1", skills: ["c"] });
|
|
218
|
+
assert.equal(h.calls.length, 1);
|
|
219
|
+
|
|
220
|
+
h.tick(DEFAULT_CONFIG.idleMs + 1);
|
|
221
|
+
const outcome = await h.dispatch({ sessionId: "s1", skills: ["d"] });
|
|
222
|
+
assert.equal(outcome.status, "dispatched");
|
|
223
|
+
assert.equal(h.calls.length, 2);
|
|
224
|
+
assert.equal(argsOf(h, 1).includes("合并了 3 轮"), true);
|
|
225
|
+
await releaseLock(path.join(h.dir, ".pi", "evolution"));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("退出补发:锁还被别人占着时写 skipped 且不起进程", async () => {
|
|
229
|
+
const h = harness("flush-locked");
|
|
230
|
+
await h.dispatch({ sessionId: "s1", skills: ["a"] });
|
|
231
|
+
h.tick(1000);
|
|
232
|
+
await h.dispatch({ sessionId: "s1", skills: ["b"] });
|
|
233
|
+
// 第一轮的子进程没退出 → 锁仍被自己持有,补发必须让位
|
|
234
|
+
assert.equal(h.flushPending(), 0);
|
|
235
|
+
assert.equal(h.calls.length, 1);
|
|
236
|
+
assert.equal(h.events.some((event) => event.kind === "skipped" && event.trigger === "shutdown"), true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("dispatch 跳过:开关关闭、单会话上限、锁被占用", async () => {
|
|
240
|
+
const off = harness("disabled", { enabled: false });
|
|
241
|
+
assert.deepEqual(await off.dispatch({ sessionId: "s1", skills: ["a"] }), { status: "skipped", why: "disabled", ownerPid: null });
|
|
242
|
+
assert.equal(off.calls.length, 0);
|
|
243
|
+
|
|
244
|
+
const capped = harness("cap", { perSessionCap: 1, idleMs: 0 });
|
|
245
|
+
assert.equal((await capped.dispatch({ sessionId: "s1", skills: ["a"] })).status, "dispatched");
|
|
246
|
+
await childExits(capped, 0);
|
|
247
|
+
capped.tick(10);
|
|
248
|
+
assert.deepEqual(await capped.dispatch({ sessionId: "s1", skills: ["a"] }), { status: "skipped", why: "cap", ownerPid: null });
|
|
249
|
+
assert.equal(capped.calls.length, 1);
|
|
250
|
+
|
|
251
|
+
const locked = harness("locked");
|
|
252
|
+
const evolutionDir = path.join(locked.dir, ".pi", "evolution");
|
|
253
|
+
const held = await acquireLock({ evolutionDir, owner: { pid: process.pid, ts: NOW, sessionId: "other" }, maxRuntimeMs: MAX, now: () => NOW });
|
|
254
|
+
assert.equal(held.ok, true);
|
|
255
|
+
const outcome = await locked.dispatch({ sessionId: "s1", skills: ["a"] });
|
|
256
|
+
assert.equal(outcome.status, "skipped");
|
|
257
|
+
assert.equal(outcome.status === "skipped" ? outcome.why : "", "lock");
|
|
258
|
+
assert.equal(locked.calls.length, 0);
|
|
259
|
+
await releaseLock(evolutionDir);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("手动 run:跳过去抖与单会话上限,但仍守全局锁", async () => {
|
|
263
|
+
const h = harness("manual", { perSessionCap: 1, idleMs: 0 });
|
|
264
|
+
assert.equal((await h.dispatch({ sessionId: "s1", skills: ["a"] })).status, "dispatched");
|
|
265
|
+
await childExits(h, 0);
|
|
266
|
+
|
|
267
|
+
// 同会话第二次自动派发会被 cap 挡下
|
|
268
|
+
h.tick(10);
|
|
269
|
+
assert.equal((await h.dispatch({ sessionId: "s1", skills: ["a"] })).status, "skipped");
|
|
270
|
+
|
|
271
|
+
const manual = await h.dispatch({ sessionId: "s1", skills: ["a"] }, { bypassThrottle: true });
|
|
272
|
+
assert.equal(manual.status, "dispatched");
|
|
273
|
+
assert.equal(h.calls.length, 2);
|
|
274
|
+
const records = fs.readdirSync(path.join(h.dir, ".pi", "evolution", "runs")).map((runId) => readRun(h.dir, runId));
|
|
275
|
+
assert.equal(records.some((record) => record.trigger === "manual"), true);
|
|
276
|
+
|
|
277
|
+
// 子进程还在跑(锁未释放)时,手动派发也必须让位
|
|
278
|
+
h.tick(10);
|
|
279
|
+
const blocked = await h.dispatch({ sessionId: "s1", skills: ["a"] }, { bypassThrottle: true });
|
|
280
|
+
assert.equal(blocked.status, "skipped");
|
|
281
|
+
assert.equal(blocked.status === "skipped" ? blocked.ownerPid : 0, process.pid);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("dispatch 无会话原文:降级 telemetry-only 且不产索引", async () => {
|
|
285
|
+
const h = harness("degraded");
|
|
286
|
+
const outcome = await h.dispatch({ sessionId: "s1", skills: ["a"] });
|
|
287
|
+
if (outcome.status !== "dispatched") return assert.fail("应当派发成功");
|
|
288
|
+
const record = readRun(h.dir, outcome.run.runId);
|
|
289
|
+
assert.deepEqual([record.evidenceMode, record.artifacts], ["telemetry-only", {}]);
|
|
290
|
+
assert.equal(argsOf(h).includes("telemetry-only"), true);
|
|
291
|
+
// 没有索引就明说不可用,别让子进程去读一个不存在的 index.json
|
|
292
|
+
assert.equal(argsOf(h).includes("索引:不可用(evidenceMode=telemetry-only"), true);
|
|
293
|
+
await releaseLock(path.join(h.dir, ".pi", "evolution"));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("dispatch 收尸:子进程非零退出 → run.json 标 abandoned 并放锁", async () => {
|
|
297
|
+
const h = harness("abandon");
|
|
298
|
+
const outcome = await h.dispatch({ sessionId: "s1", skills: ["a"] });
|
|
299
|
+
if (outcome.status !== "dispatched") return assert.fail("应当派发成功");
|
|
300
|
+
h.children[0].emit("exit", 3);
|
|
301
|
+
await settleAsync();
|
|
302
|
+
const record = readRun(h.dir, outcome.run.runId);
|
|
303
|
+
assert.equal(record.status, "abandoned");
|
|
304
|
+
assert.equal(String(record.abandonedReason).includes("exit 3"), true);
|
|
305
|
+
assert.equal(fs.existsSync(path.join(h.dir, ".pi", "evolution", "locks", "dispatch")), false);
|
|
306
|
+
});
|