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; 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): 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 { 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 { while (!existsSync(join(studyDir, "repo.git"))) { if (child.exitCode !== null) throw new Error(`研究进程没建出 repo.git 就退出了,退出码 ${child.exitCode}`); await sleep(50); } }