chore(web): expose live continuation status
This commit is contained in:
138
tools/web/print-wbv2-live-status.mjs
Normal file
138
tools/web/print-wbv2-live-status.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const abs = (file) => path.join(root, file);
|
||||
const controlRoot = "tests/golden/WBV2";
|
||||
const guardianLockPath = `${controlRoot}/continuous-guardian.lock`;
|
||||
const supervisorLockPath = `${controlRoot}/continuous-supervisor.lock`;
|
||||
const guardianCheckpointPath = `${controlRoot}/continuous-guardian.json`;
|
||||
const supervisorCheckpointPath = `${controlRoot}/continuous-supervisor.json`;
|
||||
const supervisorLogPath = `${controlRoot}/continuous-supervisor.log`;
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const watch = argv.includes("--watch");
|
||||
const jsonOnly = argv.includes("--json");
|
||||
const intervalIndex = argv.indexOf("--interval-ms");
|
||||
const intervalMs = Number(intervalIndex >= 0 ? argv[intervalIndex + 1] : 2000);
|
||||
const knownArgs = new Set(["--watch", "--json", "--interval-ms"]);
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (!knownArgs.has(argv[index])) throw new Error(`unknown argument: ${argv[index]}`);
|
||||
if (argv[index] === "--interval-ms") index += 1;
|
||||
}
|
||||
if (!Number.isSafeInteger(intervalMs) || intervalMs < 100) throw new Error("--interval-ms must be an integer >= 100");
|
||||
|
||||
const readJson = (file) => {
|
||||
try { return JSON.parse(fs.readFileSync(abs(file), "utf8")); }
|
||||
catch { return null; }
|
||||
};
|
||||
const pidAlive = (pid) => {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
};
|
||||
const lockInfo = (file) => {
|
||||
const lock = readJson(file);
|
||||
return lock ? { pid: lock.pid ?? null, startedAt: lock.startedAt ?? null, alive: pidAlive(lock.pid) } : { pid: null, startedAt: null, alive: false };
|
||||
};
|
||||
const currentPointer = () => {
|
||||
const queue = fs.readFileSync(abs("docs/EXECUTION_QUEUE.md"), "utf8");
|
||||
return {
|
||||
task: queue.match(/\| 当前任务 \| `(WBV2-P\d+-\d+)` \|/u)?.[1] ?? null,
|
||||
parentManifest: queue.match(/\| parent manifest \| `([^`]+)` \|/u)?.[1] ?? null,
|
||||
};
|
||||
};
|
||||
const gitHead = () => {
|
||||
try { return execFileSync("git", ["rev-parse", "--verify", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); }
|
||||
catch { return null; }
|
||||
};
|
||||
const tail = (file, maxBytes = 256 * 1024) => {
|
||||
try {
|
||||
const stat = fs.statSync(abs(file));
|
||||
const start = Math.max(0, stat.size - maxBytes);
|
||||
const handle = fs.openSync(abs(file), "r");
|
||||
const buffer = Buffer.alloc(stat.size - start);
|
||||
fs.readSync(handle, buffer, 0, buffer.length, start);
|
||||
fs.closeSync(handle);
|
||||
return buffer.toString("utf8");
|
||||
}
|
||||
catch { return ""; }
|
||||
};
|
||||
const latestHandoff = () => {
|
||||
const matches = [...tail(supervisorLogPath).matchAll(/handoff iteration=(\d+) before=(WBV2-P\d+-\d+) after=(WBV2-P\d+-\d+)/gu)];
|
||||
const match = matches.at(-1);
|
||||
return match ? { iteration: Number(match[1]), before: match[2], after: match[3] } : null;
|
||||
};
|
||||
const pushVerification = (task) => {
|
||||
if (!task) return null;
|
||||
const file = `tests/golden/${task}/continuous-push-verify.log`;
|
||||
if (!fs.existsSync(abs(file))) return null;
|
||||
const output = tail(file, 4096).trim();
|
||||
const match = output.match(/local_head=([0-9a-f]+) remote_head=([0-9a-f]+)/u);
|
||||
return { path: file, localHead: match?.[1] ?? null, remoteHead: match?.[2] ?? null, equal: Boolean(match && match[1] === match[2]) };
|
||||
};
|
||||
const collect = () => {
|
||||
const queue = currentPointer();
|
||||
const guardian = readJson(guardianCheckpointPath);
|
||||
const supervisor = readJson(supervisorCheckpointPath);
|
||||
const guardianLock = lockInfo(guardianLockPath);
|
||||
const supervisorLock = lockInfo(supervisorLockPath);
|
||||
const handoff = latestHandoff();
|
||||
const active = guardianLock.alive && supervisorLock.alive;
|
||||
const blocked = guardian?.state === "blocked" || supervisor?.state === "blocked";
|
||||
const state = blocked ? "BLOCKED" : active ? "RUNNING" : "IDLE";
|
||||
const phase = blocked ? "blocked" : supervisor?.state === "running" ? "task-or-gate" : supervisor?.state ?? "unknown";
|
||||
const observed = new Date().toISOString();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
observedAt: observed,
|
||||
state,
|
||||
phase,
|
||||
message: state === "RUNNING"
|
||||
? `RUNNING: ${queue.task ?? "unknown task"} is owned by the guardian/supervisor process; wait for its handoff.`
|
||||
: state === "BLOCKED" ? "BLOCKED: inspect the checkpoint reason before any write or retry." : "IDLE: no live guardian/supervisor lock was found.",
|
||||
queue,
|
||||
guardian: { pid: guardian?.pid ?? guardianLock.pid, alive: guardianLock.alive, state: guardian?.state ?? null, updatedAt: guardian?.updatedAt ?? null, heartbeatAt: guardian?.heartbeatAt ?? null, restartCount: guardian?.restartCount ?? null },
|
||||
supervisor: { pid: supervisor?.pid ?? supervisorLock.pid, alive: supervisorLock.alive, state: supervisor?.state ?? null, updatedAt: supervisor?.updatedAt ?? null, iteration: supervisor?.iteration ?? null, before: supervisor?.before ?? null, after: supervisor?.after ?? null },
|
||||
latestHandoff: handoff,
|
||||
latestPushVerification: pushVerification(handoff?.before ?? supervisor?.before?.task),
|
||||
localHead: gitHead(),
|
||||
checkpoints: { guardian: guardianCheckpointPath, supervisor: supervisorCheckpointPath },
|
||||
};
|
||||
};
|
||||
const format = (status) => [
|
||||
`状态: ${status.state}${status.state === "RUNNING" ? "(正在执行)" : status.state === "BLOCKED" ? "(已阻塞)" : "(未运行)"}`,
|
||||
`阶段: ${status.phase}`,
|
||||
`当前任务: ${status.queue.task ?? "unknown"}`,
|
||||
`parent manifest: ${status.queue.parentManifest ?? "unknown"}`,
|
||||
`guardian: pid=${status.guardian.pid ?? "none"} alive=${status.guardian.alive} state=${status.guardian.state ?? "unknown"} heartbeat=${status.guardian.heartbeatAt ?? "none"}`,
|
||||
`supervisor: pid=${status.supervisor.pid ?? "none"} alive=${status.supervisor.alive} state=${status.supervisor.state ?? "unknown"} iteration=${status.supervisor.iteration ?? "none"}`,
|
||||
`最近交接: ${status.latestHandoff ? `${status.latestHandoff.before} -> ${status.latestHandoff.after} (#${status.latestHandoff.iteration})` : "none"}`,
|
||||
`本地 HEAD: ${status.localHead ?? "unknown"}`,
|
||||
`最近 push 校验: ${status.latestPushVerification?.equal ? "PASS" : status.latestPushVerification ? "NOT_EQUAL_OR_INCOMPLETE" : "none"}`,
|
||||
`观察时间: ${status.observedAt}`,
|
||||
status.message,
|
||||
].join("\n");
|
||||
const print = () => {
|
||||
const status = collect();
|
||||
if (jsonOnly) {
|
||||
process.stdout.write(`${JSON.stringify(status)}\n`);
|
||||
return;
|
||||
}
|
||||
if (watch) process.stdout.write("\x1b[2J\x1b[H");
|
||||
process.stdout.write(`${format(status)}\n`);
|
||||
};
|
||||
print();
|
||||
if (watch) {
|
||||
const timer = setInterval(print, intervalMs);
|
||||
const stop = () => { clearInterval(timer); process.exitCode = 0; };
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
}
|
||||
@@ -72,7 +72,11 @@ const releaseLock = () => {
|
||||
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
||||
};
|
||||
const appendLog = (text) => fs.appendFileSync(abs(logPath), `[${new Date().toISOString()}] ${text}\n`);
|
||||
const writeCheckpoint = (payload) => fs.writeFileSync(abs(checkpointPath), `${JSON.stringify({ schemaVersion: 1, pid: process.pid, updatedAt: new Date().toISOString(), ...payload }, null, 2)}\n`);
|
||||
const writeCheckpoint = (payload) => {
|
||||
const temporaryPath = `${checkpointPath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(abs(temporaryPath), `${JSON.stringify({ schemaVersion: 1, pid: process.pid, updatedAt: new Date().toISOString(), ...payload }, null, 2)}\n`);
|
||||
fs.renameSync(abs(temporaryPath), abs(checkpointPath));
|
||||
};
|
||||
const readJson = (file) => {
|
||||
try { return JSON.parse(fs.readFileSync(abs(file), "utf8")); }
|
||||
catch { return null; }
|
||||
@@ -90,6 +94,23 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
|
||||
let child;
|
||||
let stopRequested = false;
|
||||
let stopSignal;
|
||||
let heartbeatTimer;
|
||||
const writeHeartbeat = () => {
|
||||
if (stopRequested || released) return;
|
||||
try {
|
||||
writeCheckpoint({
|
||||
state: "running",
|
||||
restartCount,
|
||||
current: currentTask(),
|
||||
supervisorPid: child?.pid ?? null,
|
||||
heartbeatAt: new Date().toISOString(),
|
||||
supervisor: supervisorCommand,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
appendLog(`heartbeat-failed error=${error?.message ?? String(error)}`);
|
||||
}
|
||||
};
|
||||
const signalChild = (signal) => {
|
||||
if (!child?.pid) return;
|
||||
try { process.kill(-child.pid, signal); }
|
||||
@@ -137,6 +158,7 @@ let restartCount = 0;
|
||||
try {
|
||||
appendLog(`started pid=${process.pid} supervisor=${supervisorCommand} maxRestarts=${maxRestarts} restartDelayMs=${restartDelayMs}`);
|
||||
writeCheckpoint({ state: "started", restartCount, supervisor: supervisorCommand, current: currentTask() });
|
||||
heartbeatTimer = setInterval(writeHeartbeat, 2000);
|
||||
while (true) {
|
||||
if (stopRequested) {
|
||||
writeCheckpoint({ state: "stopped", restartCount, reason: stopSignal, current: currentTask() });
|
||||
@@ -183,6 +205,7 @@ catch (error) {
|
||||
appendLog(`blocked reason=guardian_error error=${error?.message ?? String(error)}`);
|
||||
}
|
||||
finally {
|
||||
if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer);
|
||||
signalChild("SIGTERM");
|
||||
releaseLock();
|
||||
}
|
||||
|
||||
@@ -97,7 +97,9 @@ function appendLog(text) {
|
||||
fs.appendFileSync(abs(supervisorLog), `[${new Date().toISOString()}] ${text}\n`);
|
||||
}
|
||||
function writeCheckpoint(payload) {
|
||||
fs.writeFileSync(abs(checkpointPath), `${JSON.stringify({ schemaVersion: 1, pid: process.pid, updatedAt: new Date().toISOString(), ...payload }, null, 2)}\n`);
|
||||
const temporaryPath = `${checkpointPath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(abs(temporaryPath), `${JSON.stringify({ schemaVersion: 1, pid: process.pid, updatedAt: new Date().toISOString(), ...payload }, null, 2)}\n`);
|
||||
fs.renameSync(abs(temporaryPath), abs(checkpointPath));
|
||||
}
|
||||
const run = (command, args = []) => {
|
||||
const result = spawnSync(command, args, { cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
||||
|
||||
Reference in New Issue
Block a user