feat: flow 第 1 部分(骨架、研究开始、第零轮)与第零轮检验

按任务书 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>
This commit is contained in:
2026-10-03 10:58:52 -04:00
parent 8491191a2b
commit 75d7cffd27
36 changed files with 2456 additions and 14 deletions
+82
View File
@@ -0,0 +1,82 @@
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
// 检验脚本共用的起研究工具:检验目录放在仓库外,配置照用户的写法写一份,研究在子进程里跑(要能被 SIGKILL)。
/** 检验输出一律放在仓库外(任务书 12 第四部分第 4 条),不提交。 */
export const EVAL_ROOT = "/home/iomgaa/inforsi-eval";
/** 新建一个检验目录 <日期-名字>;已经有了就报错,不把两次检验的产物混在一起。 */
export function newEvalDir(name: string): string {
const dir = join(EVAL_ROOT, `${new Date().toISOString().slice(0, 10)}-${name}`);
if (existsSync(dir)) throw new Error(`检验目录 ${dir} 已经有了,换个名字或先挪走`);
mkdirSync(dir, { recursive: true });
return dir;
}
/**
* 照一份底稿写这次检验用的 research.json:换研究编号(容器名、镜像标签都带它),需要时换各角色模型;
* 任务目录、提示词目录写成绝对路径(底稿里相对仓库根目录),这样配置文件放在仓库外也指得对。
*/
export function writeConfig(base: string, path: string, studyId: string, models: unknown): string {
const config = JSON.parse(readFileSync(base, "utf8")) as Record<string, unknown>;
config["studyId"] = studyId;
config["task"] = resolve(config["task"] as string);
config["promptDir"] = resolve(config["promptDir"] as string);
if (models !== null) config["models"] = models;
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
return path;
}
export interface StudyProcess {
child: ChildProcess;
/** 子进程退出:退出码、信号,以及它打印的结局(被杀掉时没有,是 null)。 */
done: Promise<{ code: number | null; signal: NodeJS.Signals | null; outcome: unknown }>;
}
/** 在子进程里起 eval/lib/cli.ts。config 为 null 是接着跑。子进程的输出原样转到本进程,并记进 <研究目录>.log。 */
export function launch(config: string | null, studyDir: string, env: Record<string, string>): StudyProcess {
const args = ["eval/lib/cli.ts", ...(config === null ? ["--resume"] : ["--config", config]), "--dir", studyDir];
const child = spawn(process.execPath, args, { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });
const log: string[] = [];
let stdout = "";
child.stdout!.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
log.push(chunk.toString());
process.stdout.write(chunk);
});
child.stderr!.on("data", (chunk: Buffer) => {
log.push(chunk.toString());
process.stderr.write(chunk);
});
const done = new Promise<{ code: number | null; signal: NodeJS.Signals | null; outcome: unknown }>((ok) =>
child.on("close", (code, signal) => {
writeFileSync(`${studyDir}.log`, log.join(""), { flag: "a" });
const line = stdout.split("\n").find((l) => l.startsWith("RESULT "));
ok({ code, signal, outcome: line === undefined ? null : JSON.parse(line.slice("RESULT ".length)) });
}),
);
return { child, done };
}
export function sleep(ms: number): Promise<void> {
return new Promise((ok) => setTimeout(ok, ms));
}
/** 本机名字以 prefix 开头的容器(含已停止的)。检验直接问 Docker,不经正式代码。 */
export function containers(prefix: string): string[] {
const names = execFileSync("docker", ["ps", "-a", "--format", "{{.Names}}"], { encoding: "utf8" });
return names.split("\n").filter((n) => n.startsWith(prefix));
}
/**
* 等 flow 把账本的表建好,检验才第一次打开账本:Ledger.open 遇到空文件会自己建表,和 flow 同时建会冲突。
* 信号是研究目录里出现 repo.git:flow 先 Ledger.open(建表),冻结配置时才建裸仓库(src/flow/loop.ts、study.ts 的 freezeConfig)。
*/
export async function waitForLedger(studyDir: string, child: ChildProcess): Promise<void> {
while (!existsSync(join(studyDir, "repo.git"))) {
if (child.exitCode !== null) throw new Error(`研究进程没建出 repo.git 就退出了,退出码 ${child.exitCode}`);
await sleep(50);
}
}