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,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跑一次评审子进程:`pi -p --no-session -ns <prompt>`,带超时与「invalid 重评一次」。
|
|
3
|
+
*
|
|
4
|
+
* 这里是评审流水线唯一会起外部进程的地方,评审督导(review-supervisor)与必红自检
|
|
5
|
+
* (selftest)共用,避免两处各写一套超时/重试导致行为不一致。
|
|
6
|
+
* spawnFn / now / sleep 全部可注入:单测不真起 pi、不真等 300 秒。
|
|
7
|
+
*/
|
|
8
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
9
|
+
import { resolvePiBin } from "./spawn-plan.ts";
|
|
10
|
+
import { postProcessReview, type ReviewResult } from "./review-post.ts";
|
|
11
|
+
|
|
12
|
+
export interface ReviewChild {
|
|
13
|
+
pid?: number;
|
|
14
|
+
stdout?: { on(event: "data", cb: (chunk: unknown) => void): void } | null;
|
|
15
|
+
once(event: "exit" | "error", cb: (arg?: unknown) => void): void;
|
|
16
|
+
unref?: () => void;
|
|
17
|
+
kill?: (signal?: NodeJS.Signals) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ReviewSpawn = (command: string, args: string[], options: Record<string, unknown>) => ReviewChild;
|
|
21
|
+
|
|
22
|
+
export interface ReviewChildOptions {
|
|
23
|
+
prompt: string;
|
|
24
|
+
cwd: string;
|
|
25
|
+
/** "inherit" 或空 → 不传 --model,让子进程用默认模型 */
|
|
26
|
+
model?: string;
|
|
27
|
+
env?: NodeJS.ProcessEnv;
|
|
28
|
+
spawnFn?: ReviewSpawn;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
pollMs?: number;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
sleep?: (ms: number) => Promise<void>;
|
|
33
|
+
log?: (msg: string) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ReviewChildOutcome {
|
|
37
|
+
status: "text" | "timeout" | "spawn-error";
|
|
38
|
+
text: string;
|
|
39
|
+
ms: number;
|
|
40
|
+
detail?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_REVIEW_TIMEOUT_MS = 300_000;
|
|
44
|
+
const DEFAULT_POLL_MS = 250;
|
|
45
|
+
|
|
46
|
+
export function reviewArgs(prompt: string, model?: string): string[] {
|
|
47
|
+
const args = ["-p", "--no-session", "-ns", prompt];
|
|
48
|
+
if (model && model !== "inherit") args.push("--model", model);
|
|
49
|
+
return args;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runReviewChild(options: ReviewChildOptions): Promise<ReviewChildOutcome> {
|
|
53
|
+
const env = options.env ?? process.env;
|
|
54
|
+
const now = options.now ?? Date.now;
|
|
55
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
56
|
+
const spawnFn = options.spawnFn ?? (nodeSpawn as unknown as ReviewSpawn);
|
|
57
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS;
|
|
58
|
+
const pollMs = Math.max(1, options.pollMs ?? DEFAULT_POLL_MS);
|
|
59
|
+
const started = now();
|
|
60
|
+
let text = "";
|
|
61
|
+
let settled = false;
|
|
62
|
+
let failure: string | undefined;
|
|
63
|
+
const child = spawnFn(resolvePiBin(env), reviewArgs(options.prompt, options.model), {
|
|
64
|
+
cwd: options.cwd,
|
|
65
|
+
detached: true,
|
|
66
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
67
|
+
// 评审进程也是子代理进程:带上标记,避免它自己被扩展的采集钩子挂上去
|
|
68
|
+
env: { ...env, SKILL_EVOLVE_CHILD: "1" },
|
|
69
|
+
});
|
|
70
|
+
child.stdout?.on("data", (chunk) => {
|
|
71
|
+
text += String(chunk);
|
|
72
|
+
});
|
|
73
|
+
child.once("exit", () => {
|
|
74
|
+
settled = true;
|
|
75
|
+
});
|
|
76
|
+
child.once("error", (error) => {
|
|
77
|
+
failure = (error as Error | undefined)?.message ?? "spawn 失败";
|
|
78
|
+
settled = true;
|
|
79
|
+
});
|
|
80
|
+
const deadline = started + timeoutMs;
|
|
81
|
+
while (!settled && now() < deadline) await sleep(pollMs);
|
|
82
|
+
const ms = now() - started;
|
|
83
|
+
if (!settled) {
|
|
84
|
+
try {
|
|
85
|
+
child.kill?.("SIGKILL");
|
|
86
|
+
} catch {
|
|
87
|
+
// 已经退出或权限不足:超时结论不变,不必再喊
|
|
88
|
+
}
|
|
89
|
+
options.log?.(`[skill-evolve] 评审子进程超时(${timeoutMs}ms),已放弃`);
|
|
90
|
+
return { status: "timeout", text, ms, detail: `review-timeout: 超过 ${timeoutMs}ms` };
|
|
91
|
+
}
|
|
92
|
+
if (failure) return { status: "spawn-error", text, ms, detail: `spawn-error: ${failure}` };
|
|
93
|
+
return { status: "text", text, ms };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ReviewWithRetryOptions extends ReviewChildOptions {
|
|
97
|
+
/** 机械复验:返回非空字符串表示候选文件不合法(frontmatter 坏) */
|
|
98
|
+
recheck?: () => string | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ReviewWithRetryOutcome {
|
|
102
|
+
result: ReviewResult;
|
|
103
|
+
attempts: number;
|
|
104
|
+
ms: number;
|
|
105
|
+
/** 每次尝试的结论摘要,写进 03-review.md 便于人工复盘 */
|
|
106
|
+
attemptsLog: string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 12.3 后处理 + 重试规则:结构不合格(invalid)重评一次,仍不合格就是 review_failed;
|
|
111
|
+
* 超时与 spawn 失败不重试(再来一次只是再赔 300 秒)。
|
|
112
|
+
*/
|
|
113
|
+
export async function reviewWithRetry(options: ReviewWithRetryOptions): Promise<ReviewWithRetryOutcome> {
|
|
114
|
+
const started = (options.now ?? Date.now)();
|
|
115
|
+
const attemptsLog: string[] = [];
|
|
116
|
+
let last: ReviewResult = { status: "invalid", blocking: [], detail: "not-run" };
|
|
117
|
+
let attempts = 0;
|
|
118
|
+
for (let round = 1; round <= 2; round += 1) {
|
|
119
|
+
attempts = round;
|
|
120
|
+
const outcome = await runReviewChild(options);
|
|
121
|
+
if (outcome.status !== "text") {
|
|
122
|
+
last = { status: "invalid", blocking: [], detail: outcome.detail ?? outcome.status };
|
|
123
|
+
attemptsLog.push(`第 ${round} 次:${last.detail}`);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
last = postProcessReview(outcome.text, { ...(options.recheck ? { recheck: options.recheck } : {}) });
|
|
127
|
+
attemptsLog.push(`第 ${round} 次:${last.status}${last.verdict ? `/${last.verdict}` : ""}${last.detail ? `(${last.detail})` : ""}`);
|
|
128
|
+
if (last.status !== "invalid") break;
|
|
129
|
+
}
|
|
130
|
+
return { result: last, attempts, ms: (options.now ?? Date.now)() - started, attemptsLog };
|
|
131
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 起评审督导进程:与复盘子进程同一次派发里、同一条「detached + unref」纪律。
|
|
3
|
+
*
|
|
4
|
+
* 为什么用 `process.execPath` 而不是 `pi`:督导本身是普通 node 脚本(只读产物、写 03-review.*),
|
|
5
|
+
* 评审才需要 pi。这样即使 `pi` 不可见也不影响督导先把 900 秒超时落成一条记录。
|
|
6
|
+
*
|
|
7
|
+
* 全局单飞闸在这里也拦一次:督导算第二个子进程,已有别的复盘在跑就不叠(用户 2026-11-08:
|
|
8
|
+
* 「每次最多一个复盘agent」)。与**自己**那条 run 的复盘子进程不冲突,靠 ignoreRunId 排除。
|
|
9
|
+
*/
|
|
10
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { patchRunRecordSync } from "./runs.ts";
|
|
14
|
+
import { guardBusy } from "./single-flight.ts";
|
|
15
|
+
import { spawnDetached } from "./spawn-plan.ts";
|
|
16
|
+
import type { TelemetryWriter } from "./telemetry.ts";
|
|
17
|
+
|
|
18
|
+
/** 督导脚本绝对路径:dispatch 与 shutdown-dispatch 两条路径共用 */
|
|
19
|
+
export const SUPERVISOR_SCRIPT = fileURLToPath(new URL("./review-supervisor.ts", import.meta.url));
|
|
20
|
+
|
|
21
|
+
/** 督导日志文件名:督导自己也可能静默死掉,日志跟子进程一样落到 run 目录 */
|
|
22
|
+
export const SUPERVISOR_LOG = "supervisor.log";
|
|
23
|
+
|
|
24
|
+
export interface SupervisorChild {
|
|
25
|
+
pid?: number;
|
|
26
|
+
unref?: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SupervisorSpawn = (command: string, args: string[], options: Record<string, unknown>) => SupervisorChild;
|
|
30
|
+
|
|
31
|
+
export interface SpawnSupervisorOptions {
|
|
32
|
+
runDir: string;
|
|
33
|
+
cwd: string;
|
|
34
|
+
env?: NodeJS.ProcessEnv;
|
|
35
|
+
/** "inherit" / 空 → 不传参,督导按默认模型评审 */
|
|
36
|
+
reviewModel?: string;
|
|
37
|
+
spawnFn?: SupervisorSpawn;
|
|
38
|
+
log?: (msg: string) => void;
|
|
39
|
+
/** 同一条 run 的会话 ID:busy 遥测要带它(缺省退化到 runId,事件不能丢) */
|
|
40
|
+
sessionId?: string;
|
|
41
|
+
telemetry?: TelemetryWriter;
|
|
42
|
+
/** pid 存活判定(单测注入;生产用默认 kill(pid,0)) */
|
|
43
|
+
alive?: (pid: number) => boolean;
|
|
44
|
+
/** 退出补发路径:主进程马上退,遥测只能同步写 */
|
|
45
|
+
sync?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 返回 pid;被单飞闸挡住或起不来返回 null(只记日志,绝不抛到主会话) */
|
|
49
|
+
export function spawnReviewSupervisor(options: SpawnSupervisorOptions): number | null {
|
|
50
|
+
const runId = path.basename(options.runDir);
|
|
51
|
+
const occupant = guardBusy({
|
|
52
|
+
evolutionDir: path.dirname(path.dirname(options.runDir)),
|
|
53
|
+
sessionId: options.sessionId ?? runId,
|
|
54
|
+
// 自己这条 run 的复盘子进程本来就该在跑,不能把自己当占用者
|
|
55
|
+
ignoreRunId: runId,
|
|
56
|
+
...(options.telemetry ? { telemetry: options.telemetry } : {}),
|
|
57
|
+
...(options.alive ? { alive: options.alive } : {}),
|
|
58
|
+
...(options.sync === true ? { sync: true } : {}),
|
|
59
|
+
...(options.log ? { log: options.log } : {}),
|
|
60
|
+
});
|
|
61
|
+
if (occupant) return null;
|
|
62
|
+
|
|
63
|
+
const env = { ...(options.env ?? process.env) };
|
|
64
|
+
// 督导自己不是 pi 子代理:把「别再采集」的标记去掉,避免它起评审子进程时语义混乱
|
|
65
|
+
delete env.SKILL_EVOLVE_CHILD;
|
|
66
|
+
const args = ["--experimental-strip-types", SUPERVISOR_SCRIPT, options.runDir];
|
|
67
|
+
if (options.reviewModel && options.reviewModel !== "inherit") args.push(options.reviewModel);
|
|
68
|
+
const spawnFn = options.spawnFn ?? (nodeSpawn as unknown as SupervisorSpawn);
|
|
69
|
+
try {
|
|
70
|
+
const child = spawnDetached(spawnFn, process.execPath, args, {
|
|
71
|
+
cwd: options.cwd,
|
|
72
|
+
env,
|
|
73
|
+
logFile: path.join(options.runDir, SUPERVISOR_LOG),
|
|
74
|
+
});
|
|
75
|
+
// 占用登记:督导 pid 写进同一条 run.json;它退出 / 失败时清成 null
|
|
76
|
+
if (child.pid) patchRunRecordSync(options.runDir, { supervisorPid: child.pid });
|
|
77
|
+
options.log?.(`[skill-evolve] 已起评审督导 pid ${child.pid ?? "未知"}(${runId})`);
|
|
78
|
+
return child.pid ?? null;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
options.log?.(`[skill-evolve] 评审督导启动失败:${String(error)}`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 评审督导(设计稿 12)——独立 node 进程,由派发方在起复盘子进程的同一次派发里拉起。
|
|
3
|
+
*
|
|
4
|
+
* 为什么必须是独立进程而不是复盘 agent 自己评:复盘 agent 刚写完提案,让它评审等于自评,
|
|
5
|
+
* 而且它的上下文里全是自己的推理,评不出自己的错。这里只等产物落盘,再按固定模板另起评审子进程。
|
|
6
|
+
*
|
|
7
|
+
* 职责:
|
|
8
|
+
* 1. 轮询 `02-proposal.json`(默认 900s 超时 → 写 skipped/proposal-timeout)
|
|
9
|
+
* 2. 组装评审 prompt(只给硬约束 + diff + 改后全文 + 验收标准)
|
|
10
|
+
* 3. 起评审子进程(默认 300s 超时),invalid 重评一次
|
|
11
|
+
* 4. 代码后处理 + 机械复验(新增 P0 命中、candidate frontmatter 坏 → 一票 reject)
|
|
12
|
+
* 5. 写 `03-review.json` / `03-review.md`,更新 `run.json`
|
|
13
|
+
*
|
|
14
|
+
* 本进程不做 git 操作、不写 `.pi/skills`、不写别的 run 目录(闸 0 / 闸 1 的边界)。
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { loadHardRules, scanText, type CompiledRules, type HardRuleHit } from "./hard-rules.ts";
|
|
20
|
+
import { buildReviewPrompt } from "./review-prompt.ts";
|
|
21
|
+
import { classifyChange } from "./classify-action.ts";
|
|
22
|
+
import { renderReviewMarkdown, stageModel, type ReviewRecord, type SupervisorStatus } from "./review-record.ts";
|
|
23
|
+
import { reviewWithRetry, type ReviewSpawn } from "./review-run.ts";
|
|
24
|
+
import { patchRunRecordSync } from "./runs.ts";
|
|
25
|
+
import { applyUnifiedDiff } from "./diff-apply.ts";
|
|
26
|
+
|
|
27
|
+
export const PROPOSAL_TIMEOUT_MS = 900_000;
|
|
28
|
+
const PROPOSAL_POLL_MS = 2000;
|
|
29
|
+
|
|
30
|
+
export interface ReviewSupervisorDeps {
|
|
31
|
+
runDir: string;
|
|
32
|
+
evolutionDir?: string;
|
|
33
|
+
env?: NodeJS.ProcessEnv;
|
|
34
|
+
log?: (msg: string) => void;
|
|
35
|
+
now?: () => number;
|
|
36
|
+
sleep?: (ms: number) => Promise<void>;
|
|
37
|
+
spawnFn?: ReviewSpawn;
|
|
38
|
+
/** 读文本;undefined = 文件不存在(单测注入) */
|
|
39
|
+
readText?: (file: string) => string | undefined;
|
|
40
|
+
writeText?: (file: string, text: string) => void;
|
|
41
|
+
/** 评审模型:"inherit" 或 provider/model */
|
|
42
|
+
reviewModel?: string;
|
|
43
|
+
rules?: CompiledRules;
|
|
44
|
+
proposalTimeoutMs?: number;
|
|
45
|
+
reviewTimeoutMs?: number;
|
|
46
|
+
pollMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SupervisorOutcome {
|
|
50
|
+
status: SupervisorStatus;
|
|
51
|
+
record: ReviewRecord;
|
|
52
|
+
ms: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function defaultReadText(file: string): string | undefined {
|
|
56
|
+
try {
|
|
57
|
+
return fs.readFileSync(file, "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 先写临时文件再 rename:督导被 SIGKILL 时不会留下半截 JSON */
|
|
64
|
+
function defaultWriteText(file: string, text: string): void {
|
|
65
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
66
|
+
fs.writeFileSync(tmp, text, "utf8");
|
|
67
|
+
fs.renameSync(tmp, file);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ProposalMeta {
|
|
71
|
+
type: string;
|
|
72
|
+
skill: string;
|
|
73
|
+
diffFile?: string;
|
|
74
|
+
candidateFile?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseProposal(text: string | undefined): ProposalMeta | undefined {
|
|
78
|
+
if (!text) return undefined;
|
|
79
|
+
try {
|
|
80
|
+
const raw = JSON.parse(text) as Record<string, unknown>;
|
|
81
|
+
if (typeof raw.skill !== "string" || raw.skill.trim().length === 0) return undefined;
|
|
82
|
+
return {
|
|
83
|
+
type: typeof raw.type === "string" ? raw.type : "improve",
|
|
84
|
+
skill: raw.skill.trim(),
|
|
85
|
+
...(typeof raw.diffFile === "string" ? { diffFile: raw.diffFile } : {}),
|
|
86
|
+
...(typeof raw.candidateFile === "string" ? { candidateFile: raw.candidateFile } : {}),
|
|
87
|
+
};
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 只查「新增」的 P0 命中:存量债不该拦住一次正当的文案改动 */
|
|
94
|
+
export function newHardRuleHits(before: string, after: string, rules: CompiledRules, skill: string): HardRuleHit[] {
|
|
95
|
+
const key = (hit: HardRuleHit) => `${hit.ruleId}\u0000${hit.quote.trim()}`;
|
|
96
|
+
const known = new Set(scanText(before, rules.compiled, skill).map(key));
|
|
97
|
+
return scanText(after, rules.compiled, skill).filter((hit) => !known.has(key(hit)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** candidate 的最小 frontmatter 校验:独立进程里拿不到 loadSkillsFromDir,这是兜底 */
|
|
101
|
+
export function checkCandidateFrontmatter(text: string): string | null {
|
|
102
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
103
|
+
if (!match) return "缺 frontmatter";
|
|
104
|
+
const front = match[1];
|
|
105
|
+
if (!/^name:\s*\S/m.test(front)) return "frontmatter 缺 name";
|
|
106
|
+
if (!/^description:\s*\S/m.test(front)) return "frontmatter 缺 description";
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cwdOf(runRaw: string | undefined): string {
|
|
111
|
+
if (runRaw) {
|
|
112
|
+
try {
|
|
113
|
+
const raw = JSON.parse(runRaw) as Record<string, unknown>;
|
|
114
|
+
if (typeof raw.cwd === "string" && raw.cwd.length > 0) return raw.cwd;
|
|
115
|
+
} catch {
|
|
116
|
+
// run.json 坏掉就退回当前工作目录,后面读不到原文会走「只有 diff 可信」的降级
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return process.cwd();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 等提案文件出现;超时也再读一次(可能刚好落盘) */
|
|
123
|
+
async function waitForProposal(
|
|
124
|
+
file: string,
|
|
125
|
+
deps: ReviewSupervisorDeps,
|
|
126
|
+
read: (file: string) => string | undefined,
|
|
127
|
+
): Promise<string | undefined> {
|
|
128
|
+
const now = deps.now ?? Date.now;
|
|
129
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
130
|
+
const deadline = now() + (deps.proposalTimeoutMs ?? PROPOSAL_TIMEOUT_MS);
|
|
131
|
+
while (now() < deadline) {
|
|
132
|
+
const text = read(file);
|
|
133
|
+
if (text !== undefined) return text;
|
|
134
|
+
await sleep(deps.pollMs ?? PROPOSAL_POLL_MS);
|
|
135
|
+
}
|
|
136
|
+
return read(file);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stamp(ts: number): { reviewedAt: number; reviewedAtIso: string } {
|
|
140
|
+
return { reviewedAt: ts, reviewedAtIso: new Date(ts).toISOString() };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function runReviewSupervisor(deps: ReviewSupervisorDeps): Promise<SupervisorOutcome> {
|
|
144
|
+
const now = deps.now ?? Date.now;
|
|
145
|
+
const started = now();
|
|
146
|
+
const read = deps.readText ?? defaultReadText;
|
|
147
|
+
const write = deps.writeText ?? defaultWriteText;
|
|
148
|
+
const log = deps.log ?? (() => {});
|
|
149
|
+
const runDir = path.resolve(deps.runDir);
|
|
150
|
+
const evolutionDir = deps.evolutionDir ?? path.resolve(runDir, "..", "..");
|
|
151
|
+
const runFile = path.join(runDir, "run.json");
|
|
152
|
+
const runRaw = read(runFile);
|
|
153
|
+
const proposerModel = stageModel(runRaw);
|
|
154
|
+
const reviewerModel = deps.reviewModel ?? "inherit";
|
|
155
|
+
const sameModel = proposerModel.length > 0 && proposerModel === reviewerModel;
|
|
156
|
+
const base = { reviewerModel, proposerModel, sameModel };
|
|
157
|
+
|
|
158
|
+
const proposalText = await waitForProposal(path.join(runDir, "02-proposal.json"), deps, read);
|
|
159
|
+
const proposal = parseProposal(proposalText);
|
|
160
|
+
if (!proposal) {
|
|
161
|
+
const record: ReviewRecord = {
|
|
162
|
+
...base,
|
|
163
|
+
status: "skipped",
|
|
164
|
+
reason: proposalText === undefined ? "proposal-timeout" : "proposal-unreadable",
|
|
165
|
+
blocking: [],
|
|
166
|
+
...stamp(now()),
|
|
167
|
+
};
|
|
168
|
+
write(path.join(runDir, "03-review.json"), `${JSON.stringify(record, null, 2)}\n`);
|
|
169
|
+
log(`[skill-evolve] 评审跳过:${record.reason}(${path.basename(runDir)})`);
|
|
170
|
+
return { status: "skipped", record, ms: now() - started };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const cwd = cwdOf(runRaw);
|
|
174
|
+
const rules = deps.rules ?? loadHardRules(path.join(evolutionDir, "rules", "hard-rules.json"));
|
|
175
|
+
const target = path.join(cwd, ".pi", "skills", proposal.skill, "SKILL.md");
|
|
176
|
+
const candidateFile = path.join(runDir, proposal.candidateFile ?? path.posix.join("candidate", proposal.skill, "SKILL.md"));
|
|
177
|
+
const diffFile = proposal.diffFile ?? "02-proposal.diff";
|
|
178
|
+
const diffText = read(path.join(runDir, diffFile)) ?? "";
|
|
179
|
+
// 改动前全文:优先 candidate(新建技能没有基线文件),其次工作区里当前的技能
|
|
180
|
+
const beforeText = read(candidateFile) ?? read(target) ?? "";
|
|
181
|
+
const applied = diffText.length > 0 ? applyUnifiedDiff(beforeText, diffText) : null;
|
|
182
|
+
const afterText = applied !== null && applied.ok ? applied.text : beforeText;
|
|
183
|
+
const recheck = (): string | null => {
|
|
184
|
+
if (proposal.type === "create") return checkCandidateFrontmatter(read(candidateFile) ?? "");
|
|
185
|
+
const hits = newHardRuleHits(beforeText, afterText, rules, proposal.skill);
|
|
186
|
+
return hits.length > 0 ? `新增 P0 命中 ${hits[0].ruleId}(${hits[0].skill}:${hits[0].line})` : null;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const prompt = buildReviewPrompt({
|
|
190
|
+
skillName: proposal.skill,
|
|
191
|
+
skillText: afterText,
|
|
192
|
+
diffApplied: applied !== null && applied.ok,
|
|
193
|
+
diff: diffText,
|
|
194
|
+
hardRules: rules.compiled,
|
|
195
|
+
});
|
|
196
|
+
const outcome = await reviewWithRetry({
|
|
197
|
+
prompt,
|
|
198
|
+
cwd,
|
|
199
|
+
model: reviewerModel,
|
|
200
|
+
...(deps.env ? { env: deps.env } : {}),
|
|
201
|
+
...(deps.spawnFn ? { spawnFn: deps.spawnFn } : {}),
|
|
202
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
203
|
+
...(deps.sleep ? { sleep: deps.sleep } : {}),
|
|
204
|
+
...(deps.reviewTimeoutMs ? { timeoutMs: deps.reviewTimeoutMs } : {}),
|
|
205
|
+
...(deps.log ? { log: deps.log } : {}),
|
|
206
|
+
recheck,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const verdict = outcome.result.verdict;
|
|
210
|
+
// invalid = 评审输出不可用(重评也没救);reject 是机械复验的定论,按「被否决」记,不算失败
|
|
211
|
+
const status: SupervisorStatus =
|
|
212
|
+
outcome.result.status === "invalid" ? "review_failed" : verdict === "pass" ? "reviewed" : "rejected";
|
|
213
|
+
const record: ReviewRecord = {
|
|
214
|
+
...base,
|
|
215
|
+
status,
|
|
216
|
+
...(verdict ? { verdict } : {}),
|
|
217
|
+
blocking: outcome.result.blocking,
|
|
218
|
+
...(outcome.result.notes ? { notes: outcome.result.notes } : {}),
|
|
219
|
+
action: classifyChange({ diffText, proposalType: proposal.type, skill: proposal.skill }).level,
|
|
220
|
+
attempts: outcome.attempts,
|
|
221
|
+
...(outcome.result.detail ? { detail: outcome.result.detail } : {}),
|
|
222
|
+
...stamp(now()),
|
|
223
|
+
};
|
|
224
|
+
write(path.join(runDir, "03-review.json"), `${JSON.stringify(record, null, 2)}\n`);
|
|
225
|
+
write(path.join(runDir, "03-review.md"), renderReviewMarkdown(record, proposal.skill));
|
|
226
|
+
|
|
227
|
+
if (runRaw) {
|
|
228
|
+
try {
|
|
229
|
+
const runJson = JSON.parse(runRaw) as Record<string, unknown>;
|
|
230
|
+
runJson.status = status === "reviewed" ? "reviewed" : status === "rejected" ? "rejected" : "review_failed";
|
|
231
|
+
runJson.review = { status, verdict: verdict ?? null, action: record.action, reviewerModel, proposerModel, sameModel };
|
|
232
|
+
write(runFile, `${JSON.stringify(runJson, null, 2)}\n`);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
log(`[skill-evolve] 评审结论写不回 run.json:${(error as Error).message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
log(`[skill-evolve] 评审完成:${status}${verdict ? `/${verdict}` : ""}(${path.basename(runDir)},${outcome.attempts} 次)`);
|
|
238
|
+
return { status, record, ms: now() - started };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function main(): Promise<void> {
|
|
242
|
+
const runDir = process.argv[2];
|
|
243
|
+
if (!runDir) {
|
|
244
|
+
console.error("[skill-evolve] 用法:review-supervisor.ts <runDir> [reviewModel]");
|
|
245
|
+
process.exitCode = 2;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const outcome = await runReviewSupervisor({
|
|
250
|
+
runDir,
|
|
251
|
+
...(process.argv[3] ? { reviewModel: process.argv[3] } : {}),
|
|
252
|
+
log: (msg) => console.error(msg),
|
|
253
|
+
});
|
|
254
|
+
process.exitCode = outcome.status === "review_failed" ? 1 : 0;
|
|
255
|
+
} finally {
|
|
256
|
+
// 退出即释放单飞占用位:留着旧 pid 会被系统复用的 pid 假挡住后续派发
|
|
257
|
+
patchRunRecordSync(runDir, { supervisorPid: null });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const invokedDirectly = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
262
|
+
if (invokedDirectly) {
|
|
263
|
+
void main().catch((error: Error) => {
|
|
264
|
+
console.error(`[skill-evolve] 评审督导异常:${error.message}`);
|
|
265
|
+
process.exitCode = 1;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一轮(一条用户消息)内的曝光 / 加载状态机:曝光 → 待确认 → 回填 → 结算。
|
|
3
|
+
*
|
|
4
|
+
* 单独成文件是因为它只有内存状态、不碰派发与文件:演进层把「什么时候动手」留在自己那儿,
|
|
5
|
+
* 这里只负责把 pi 的事件流变成「本轮曝光了几个、真加载了几个、谁没被加载」。
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { detectFromBash, detectFromRead, type SkillHit } from "./detect.ts";
|
|
9
|
+
import type { TelemetryInput, TelemetrySkill } from "./telemetry.ts";
|
|
10
|
+
|
|
11
|
+
export interface RoundAdvertisedInput {
|
|
12
|
+
sessionId: string;
|
|
13
|
+
cwd: string;
|
|
14
|
+
prompt: string;
|
|
15
|
+
skills: TelemetrySkill[];
|
|
16
|
+
activeTools: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RoundResult {
|
|
20
|
+
/** 真被曝光(disabled 的不算,它们没进 prompt)的技能 */
|
|
21
|
+
exposed: TelemetrySkill[];
|
|
22
|
+
missed: string[];
|
|
23
|
+
activeTools: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RoundTracker {
|
|
27
|
+
recordAdvertised(input: RoundAdvertisedInput): void;
|
|
28
|
+
noteToolCall(input: { sessionId: string; toolCallId: string; toolName: string; input: Record<string, unknown> }): void;
|
|
29
|
+
resolveToolResult(input: { sessionId: string; toolCallId: string; isError: boolean }): void;
|
|
30
|
+
/** 结算并清空本轮;没有本轮 / 会话不匹配 / 零曝光时返回 null(零曝光既不记未命中也不派生) */
|
|
31
|
+
settle(sessionId: string): RoundResult | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RoundState {
|
|
35
|
+
sessionId: string;
|
|
36
|
+
skills: TelemetrySkill[];
|
|
37
|
+
loaded: Set<string>;
|
|
38
|
+
/** toolCallId → 该次调用碰到的技能,等 tool_result 回填 ok/failed */
|
|
39
|
+
pending: Map<string, SkillHit[]>;
|
|
40
|
+
activeTools: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hashOf(text: string): string {
|
|
44
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createRoundTracker(options: {
|
|
48
|
+
cwd: string;
|
|
49
|
+
append: (event: TelemetryInput) => void;
|
|
50
|
+
}): RoundTracker {
|
|
51
|
+
let round: RoundState | null = null;
|
|
52
|
+
|
|
53
|
+
const recordAdvertised: RoundTracker["recordAdvertised"] = (input) => {
|
|
54
|
+
round = { sessionId: input.sessionId, skills: input.skills, loaded: new Set(), pending: new Map(), activeTools: input.activeTools };
|
|
55
|
+
options.append({
|
|
56
|
+
kind: "advertised",
|
|
57
|
+
sessionId: input.sessionId,
|
|
58
|
+
cwd: input.cwd,
|
|
59
|
+
promptHash: hashOf(input.prompt),
|
|
60
|
+
promptExcerpt: input.prompt.slice(0, 200),
|
|
61
|
+
skills: input.skills,
|
|
62
|
+
activeTools: input.activeTools,
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const noteToolCall: RoundTracker["noteToolCall"] = (input) => {
|
|
67
|
+
if (!round || round.sessionId !== input.sessionId) return;
|
|
68
|
+
const hits: SkillHit[] = [];
|
|
69
|
+
if (input.toolName === "read") {
|
|
70
|
+
const hit = detectFromRead(String(input.input.path ?? ""), options.cwd);
|
|
71
|
+
if (hit) hits.push(hit);
|
|
72
|
+
} else if (input.toolName === "bash") {
|
|
73
|
+
hits.push(...detectFromBash(String(input.input.command ?? "")));
|
|
74
|
+
}
|
|
75
|
+
// 路径规则只能判定「这看起来像技能文件」;只有本轮真被曝光的技能才算加载证据,
|
|
76
|
+
// 否则仓库里任何一个 <目录>/SKILL.md 都会被错记成一次加载。
|
|
77
|
+
const advertised = new Set(round.skills.map((skill) => skill.name));
|
|
78
|
+
const owned = hits.filter((hit) => advertised.has(hit.skill));
|
|
79
|
+
if (owned.length > 0) round.pending.set(input.toolCallId, owned);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const resolveToolResult: RoundTracker["resolveToolResult"] = (input) => {
|
|
83
|
+
if (!round || round.sessionId !== input.sessionId) return;
|
|
84
|
+
const hits = round.pending.get(input.toolCallId);
|
|
85
|
+
if (!hits) return;
|
|
86
|
+
round.pending.delete(input.toolCallId);
|
|
87
|
+
for (const hit of hits) {
|
|
88
|
+
options.append({
|
|
89
|
+
kind: input.isError ? "failed" : "loaded",
|
|
90
|
+
sessionId: input.sessionId,
|
|
91
|
+
skill: hit.skill,
|
|
92
|
+
how: hit.how,
|
|
93
|
+
ok: !input.isError,
|
|
94
|
+
raw: hit.raw,
|
|
95
|
+
});
|
|
96
|
+
if (!input.isError) round.loaded.add(hit.skill);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const settle: RoundTracker["settle"] = (sessionId) => {
|
|
101
|
+
const current = round;
|
|
102
|
+
round = null; // 一轮只结算一次,杜绝同一个 round 被重复记未命中
|
|
103
|
+
if (!current || current.sessionId !== sessionId) return null;
|
|
104
|
+
// 门禁①:本轮没有任何技能曝光(没走到技能系统),既不算未命中也不派生
|
|
105
|
+
if (current.skills.length === 0) return null;
|
|
106
|
+
|
|
107
|
+
// disabled 的技能根本没进 prompt,不算「曝光未命中」,留待归因阶段按 A 类处理
|
|
108
|
+
const exposed = current.skills.filter((skill) => !skill.disabled);
|
|
109
|
+
const missed = exposed.filter((skill) => !current.loaded.has(skill.name)).map((skill) => skill.name);
|
|
110
|
+
options.append({ kind: "pending", sessionId, exposed: exposed.length, missed: missed.length });
|
|
111
|
+
for (const name of missed) options.append({ kind: "missed", sessionId, skill: name });
|
|
112
|
+
return { exposed, missed, activeTools: current.activeTools };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return { recordAdvertised, noteToolCall, resolveToolResult, settle };
|
|
116
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 盯着复盘子进程收尾:非正常退出就把 run 标 abandoned,无论成败都释放全局锁。
|
|
3
|
+
*
|
|
4
|
+
* 单独一个文件是因为 dispatch.ts 已经贴着 300 行闸门,而这段逻辑与派发主流程无耦合:
|
|
5
|
+
* 它只关心「子进程死了没有」,由调用方把释放锁的回调传进来。
|
|
6
|
+
*/
|
|
7
|
+
import { markRunAbandoned } from "./runs.ts";
|
|
8
|
+
import type { SpawnedChild } from "./spawn-plan.ts";
|
|
9
|
+
import type { MutationQueue } from "./telemetry.ts";
|
|
10
|
+
|
|
11
|
+
export interface WatchRunDeps {
|
|
12
|
+
runDir: string;
|
|
13
|
+
queue: MutationQueue;
|
|
14
|
+
log: (msg: string) => void;
|
|
15
|
+
/** 子进程结束(无论成败)后调用:派发方用它释放全局锁 */
|
|
16
|
+
onExit: () => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function watchRunChild(child: SpawnedChild, deps: WatchRunDeps): void {
|
|
20
|
+
let done = false;
|
|
21
|
+
const finish = (reason: string | null): void => {
|
|
22
|
+
if (done) return;
|
|
23
|
+
done = true;
|
|
24
|
+
void (async () => {
|
|
25
|
+
if (reason) {
|
|
26
|
+
deps.log(`[skill-evolve] 复盘子进程异常退出:${reason}`);
|
|
27
|
+
await markRunAbandoned(deps.runDir, reason, deps.queue, deps.log);
|
|
28
|
+
}
|
|
29
|
+
await deps.onExit();
|
|
30
|
+
})();
|
|
31
|
+
};
|
|
32
|
+
child.once("exit", (code) => finish(code === 0 ? null : `exit ${String(code)}`));
|
|
33
|
+
child.once("error", (error) => finish(`spawn-error: ${String(error)}`));
|
|
34
|
+
}
|