Files
InfoRSI/eval/forward/run.ts
T
iomgaa de8e13b031 fix: 用容器生命周期核实恢复并单列三份总体验收
审阅发现spawn后快照早于恢复实际执行,不能证明没有重启;改用杀前至目标结果落账的Docker历史事件,保留缓存完整性限制。逐份stats不再冒称任务书总体通过;独立验收要求三份不同研究、成功杀进程及不重叠区间。两项缺陷附回归,7项定向测试与check通过,独立复审无新问题;真实B尚待启动。
2026-10-04 04:52:13 -04:00

341 lines
18 KiB
TypeScript

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 <new|kill|resume> <研究编号> <绝对输出目录>
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 <new|kill|resume> <研究编号> <绝对输出目录>",
);
}
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;
}
}