75d7cffd27
按任务书 12 实现 src/flow:从账本现算下一步的纯函数、主循环、恢复入口、派模型任务和跑正式运行两个共用动作、冻结配置、基线、第零轮三个研究者阶段和入口分配,走到第 1 轮时停下。materials 导出 finalEntries、加 researcherId 和 StudyRecord。 审阅采纳两条:研究编号本机已用过时冻结前报错;docker run 没启动时写完结果就停下,不在本进程里无限起新编号。 eval/ 新增第零轮检验:起研究命令行、回放假模型、检验用 Jev 客户端、六问质量评估、16-real-1 回放、杀进程与两份同时跑。tsc 与 depcruise 也检查 eval/,新增 eval 只经 index.ts 用正式代码的规则。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
91 lines
4.9 KiB
TypeScript
91 lines
4.9 KiB
TypeScript
import { rmSync } from "node:fs";
|
|
import type { Content } from "../ledger/index.ts";
|
|
import { formalRun as runDefinition, judge, prepareJudging, prepareRun } from "../tasks/index.ts";
|
|
import type { HypothesisMetric, JudgedResult, ProcessResult } from "../vocab/index.ts";
|
|
import { checkoutVersion } from "../workspace/index.ts";
|
|
import { containerName, freshDir, judgeContainerName, runPaths, workspaceRepo } from "./paths.ts";
|
|
import type { Study } from "./study.ts";
|
|
|
|
// 共用动作二:跑一次正式运行(任务书 12 第 2.7 节)。先登记、再启动、再写结果,前后顺序由这个入口保证
|
|
// (方法.md 第七、九部分):登记占名额,确认启动才扣次,确认没启动的写结果"没启动"、退还名额。
|
|
// 判分不扣次数,不登记。恢复时接着等一次已经启动的运行,走同一个 finishRun。
|
|
|
|
export type RunPlan = Omit<Content<"runPlanned">, "definition">;
|
|
|
|
/**
|
|
* 一次正式运行怎么结束:写完结果;被用户停下;或 docker run 没启动,附 Docker 的报错原文。
|
|
* 没启动是外部错误,本进程不重试(CLAUDE.md"错误怎么处理",重试 0 次):调用方写停下事件,
|
|
* 下次启动时"下一步做什么"照旧起新编号重跑。
|
|
*/
|
|
export type RunOutcome = "done" | "stopped" | { notStarted: string };
|
|
|
|
/** 跑一次正式运行:取出 version 的代码,在任务镜像里跑,判分,写结果。用户停下时不等容器,交回 "stopped"。 */
|
|
export async function formalRun(s: Study, plan: RunPlan, version: string, metrics: HypothesisMetric[]): Promise<RunOutcome> {
|
|
const definition = runDefinition(s.task, s.frozen.image, String(s.frozen.seed));
|
|
s.ledger.append("runPlanned", { ...plan, definition });
|
|
const paths = runPaths(s.dir, plan.run);
|
|
freshDir(paths.base);
|
|
checkoutVersion(workspaceRepo(s.dir), version, freshDir(paths.code));
|
|
prepareRun(s.task, paths.code, freshDir(paths.run), metrics);
|
|
const started = await s.runner.start(containerName(s.frozen.config.studyId, plan.run), definition, paths.run);
|
|
if (!started.started) {
|
|
s.ledger.append("runResult", { run: plan.run, process: { ending: "notStarted", reason: started.reason }, judged: null });
|
|
return { notStarted: started.reason };
|
|
}
|
|
s.ledger.append("runStarted", { run: plan.run });
|
|
return finishRun(s, plan.run, metrics);
|
|
}
|
|
|
|
/**
|
|
* 等一次已经确认启动的运行结束,判分,写结果,删掉容器。日志写进这次运行自己的日志目录。
|
|
* 收到"停下"信号就不等了,直接交回 "stopped";容器不管它,下次启动时恢复入口再来接着等。
|
|
*/
|
|
export async function finishRun(s: Study, run: string, metrics: HypothesisMetric[]): Promise<"done" | "stopped"> {
|
|
const name = containerName(s.frozen.config.studyId, run);
|
|
const paths = runPaths(s.dir, run);
|
|
const waited = await untilStopped(s.stop, s.runner.wait(name, emptyDir(paths.logs)));
|
|
if (waited === "stopped") return "stopped";
|
|
const judged = await judgeRun(s, run, waited, metrics);
|
|
s.ledger.append("runResult", { run, process: waited, judged });
|
|
await s.runner.remove(name);
|
|
return "done";
|
|
}
|
|
|
|
/**
|
|
* 判分:tasks 的 prepareJudging,要跑校验器就让 runner 在镜像里跑,再交给 judge。
|
|
* 判分目录每次重新建空的(恢复时会再判一次);判分容器还在就先删掉再用。
|
|
*/
|
|
async function judgeRun(s: Study, run: string, formal: ProcessResult, metrics: HypothesisMetric[]): Promise<JudgedResult> {
|
|
const paths = runPaths(s.dir, run);
|
|
const step = prepareJudging(s.task, paths.run, emptyDir(paths.judge), s.frozen.image, formal, metrics);
|
|
if (step.status === "judged") return step.result;
|
|
const name = judgeContainerName(s.frozen.config.studyId, run);
|
|
if ((await s.runner.status(name)) !== "absent") await s.runner.remove(name);
|
|
const started = await s.runner.start(name, step.definition, paths.judge);
|
|
if (!started.started) throw new Error(`运行 ${run} 的校验器容器没有启动:${started.reason}`);
|
|
const validator = await s.runner.wait(name, emptyDir(paths.judgeLogs));
|
|
await s.runner.remove(name);
|
|
return judge(s.task, paths.run, paths.judge, formal, validator, metrics);
|
|
}
|
|
|
|
/** 清空重建一个目录:恢复时会再等一次、再判一次,上一次没写完的日志和判分目录不留。 */
|
|
function emptyDir(dir: string): string {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
return freshDir(dir);
|
|
}
|
|
|
|
/** 等一件事做完,或者收到"停下"信号先交回 "stopped"。被放下的那个 Promise 之后的结果没人用。 */
|
|
async function untilStopped<T>(stop: AbortSignal, work: Promise<T>): Promise<T | "stopped"> {
|
|
let onStop = (): void => undefined;
|
|
const stopped = new Promise<"stopped">((resolve) => {
|
|
onStop = () => resolve("stopped");
|
|
if (stop.aborted) onStop();
|
|
else stop.addEventListener("abort", onStop, { once: true });
|
|
});
|
|
try {
|
|
return await Promise.race([work, stopped]);
|
|
} finally {
|
|
stop.removeEventListener("abort", onStop);
|
|
}
|
|
}
|