import { execFileSync } from "node:child_process"; import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { forwardAccounts, frozenConfig, jsonFile } from "../lib/forward.ts"; import { ofKind, readLedger } from "../lib/ledger.ts"; import { launch, sleep, waitForLedger, writeConfig } from "../lib/study.ts"; import { watchTimeline } from "../lib/timeline.ts"; // 任务书16:一次只启动一份;三次由父会话逐个执行。kill 只打首个正向运行,随后恢复同一研究一次。 // node eval/forward/run.ts <研究编号> <绝对输出目录> const [mode, name, output, ...extra] = process.argv.slice(2); if ( !mode || !name || !output || extra.length || !["new", "kill", "resume"].includes(mode) || output !== resolve(output) ) { throw new Error( "用法:node eval/forward/run.ts <研究编号> <绝对输出目录>", ); } const out = output; const studyDir = join(out, "study"); if (mode === "resume") { if (frozenConfig(readLedger(studyDir)).studyId !== name) throw new Error("研究编号与账本不符"); } else { if (existsSync(out)) throw new Error(`输出目录已存在,不覆盖现场:${out}`); mkdirSync(out, { recursive: true }); } const config = mode === "resume" ? null : writeConfig( "eval/round-zero/research.json", join(out, "research.json"), name, null, ); const daemonTime = () => execFileSync("docker", ["info", "--format", "{{.SystemTime}}"], { encoding: "utf8", }).trim(); const inspect = (container: string) => JSON.parse( execFileSync("docker", ["inspect", container], { encoding: "utf8", }), )[0] as { Id: string; Name: string; Created: string; State: { Running: boolean; StartedAt: string; FinishedAt: string; Status: string; }; }; function observe(label: string) { appendFileSync( join(out, "machine.jsonl"), `${JSON.stringify({ at: new Date().toISOString(), label, processes: execFileSync( "ps", ["-eo", "pid,ppid,lstart,args"], { encoding: "utf8" }, ), containers: execFileSync( "docker", ["ps", "--no-trunc"], { encoding: "utf8" }, ), })}\n`, ); } function start(path: string | null) { observe(path === null ? "resume" : "start"); const study = launch(path, studyDir, {}); appendFileSync( join(out, "launches.jsonl"), `${JSON.stringify({ event: "start", pid: study.child.pid, at: new Date().toISOString(), config: path })}\n`, ); const interrupt = () => study.child.kill("SIGINT"); process.on("SIGINT", interrupt); study.done.then((end) => { process.off("SIGINT", interrupt); appendFileSync( join(out, "launches.jsonl"), `${JSON.stringify({ event: "end", pid: study.child.pid, at: new Date().toISOString(), ...end })}\n`, ); }); return study; } let active = start(config); try { await waitForLedger(studyDir, active.child); let watching = watchTimeline( studyDir, active.child, join(out, "timeline.jsonl"), ); if (mode === "kill") { let hit = false; while ( active.child.exitCode === null && active.child.signalCode === null ) { const records = readLedger(studyDir); const plan = ofKind(records, "runPlanned").find( (r) => r.content.purpose === "forward" && r.content.round === 1, ); if (plan !== undefined) { const run = plan.content.run; if ( ofKind(records, "runResult").some( (r) => r.content.run === run, ) ) throw new Error( "首个正向运行已经结束,未打中;保留现场,不改杀别的候选", ); const started = ofKind( records, "runStarted", ).find((r) => r.content.run === run); if (started !== undefined) { // 容器名是正式入口记录在结构文档里的约定,不 import flow 内部文件。 const container = `inforsi-${name}-${run}`; // 从杀前划定历史事件区间,覆盖恢复启动到目标结果落账;不把 spawn 当成恢复已执行。 const lifecycleSince = daemonTime(); const before = inspect(container); jsonFile( join( out, "container-before.json", ), before, ); if (!before.State.Running) throw new Error( "首个正向容器已不在运行,未打中", ); if ( ofKind( readLedger(studyDir), "runResult", ).some( (r) => r.content .run === run, ) ) throw new Error( "杀前已有结果,未打中", ); jsonFile(join(out, "kill.json"), { run, candidate: plan.content.object, planSeq: plan.seq, startSeq: started.seq, beforeKillSeq: records.at(-1)!.seq, pid: active.child.pid, at: new Date().toISOString(), container, }); if (!active.child.kill("SIGKILL")) throw new Error( "未发出 SIGKILL", ); const killed = await active.done; await watching; if (killed.signal !== "SIGKILL") throw new Error( "控制进程不是以 SIGKILL 结束", ); const atKill = readLedger(studyDir); jsonFile( join( out, "ledger-at-kill.json", ), atKill, ); if ( ofKind( atKill, "runResult", ).some( (r) => r.content .run === run, ) ) throw new Error( "杀后核对发现目标运行已经写结果,未打中;保留现场", ); const afterKill = inspect(container); jsonFile( join( out, "container-after-kill.json", ), afterKill, ); observe("after-kill"); jsonFile( join( out, "container-before-resume.json", ), inspect(container), ); jsonFile( join( out, "container-events-window.json", ), { since: lifecycleSince, until: null, run, resultSeq: null, }, ); active = start(null); watching = watchTimeline( studyDir, active.child, join(out, "timeline.jsonl"), ); let targetResult; while (true) { targetResult = ofKind( readLedger(studyDir), "runResult", ).find( (r) => r.content .run === run, ); if ( targetResult !== undefined || active.child .exitCode !== null || active.child .signalCode !== null ) break; await sleep(100); } const lifecycleUntil = daemonTime(); // Docker CLI 按时间回读,不依赖事件订阅的启动时序;原文全存,stats 核对缓存上限和目标 die。 const events = execFileSync( "docker", [ "events", "--since", lifecycleSince, "--until", lifecycleUntil, "--format", "{{json .}}", ], { encoding: "utf8" }, ); writeFileSync( join( out, "container-events.jsonl", ), events, ); jsonFile( join( out, "container-events-window.json", ), { since: lifecycleSince, until: lifecycleUntil, run, resultSeq: targetResult?.seq ?? null, }, ); if (targetResult === undefined) throw new Error( "恢复进程没有交出目标结果,生命周期证据不完整;保留现场", ); hit = true; break; } } await sleep(100); } if (!hit) throw new Error( `没有打中首个正向运行:${JSON.stringify(await active.done)}`, ); } await watching; const end = await active.done; jsonFile(join(out, "outcome.json"), end); observe("end"); const accounts = forwardAccounts(readLedger(studyDir)); jsonFile(join(out, "accounts.json"), accounts); // 杀进程报告由 stats 读原始容器观察和账本重算;即使外部服务失败后人工恢复,也不覆盖原杀进程证据。 if (end.code !== 0 || !Object.values(accounts.checks).every(Boolean)) { throw new Error( `没有完成采纳验收,保留现场;查明原因后才能手动 resume:${JSON.stringify(end)}`, ); } console.log( `已到采纳后停点:${studyDir}。仍需 export、stats 和独立质量审阅。`, ); } finally { if ( active.child.exitCode === null && active.child.signalCode === null ) { active.child.kill("SIGINT"); await active.done; } }