From 2a304b6576e3414fbac638017e1d4d816eaf221c Mon Sep 17 00:00:00 2001 From: mes123456 Date: Sun, 30 Aug 2026 16:35:42 +0800 Subject: [PATCH] chore(web): expose live continuation status --- tools/web/print-wbv2-live-status.mjs | 138 +++++++++++++++++++++++++++ tools/web/run-wbv2-guardian.mjs | 25 ++++- tools/web/run-wbv2-supervisor.mjs | 4 +- 3 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 tools/web/print-wbv2-live-status.mjs diff --git a/tools/web/print-wbv2-live-status.mjs b/tools/web/print-wbv2-live-status.mjs new file mode 100644 index 000000000..3bec293fa --- /dev/null +++ b/tools/web/print-wbv2-live-status.mjs @@ -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); +} diff --git a/tools/web/run-wbv2-guardian.mjs b/tools/web/run-wbv2-guardian.mjs index f9f1a4d6b..9e55ca7e5 100644 --- a/tools/web/run-wbv2-guardian.mjs +++ b/tools/web/run-wbv2-guardian.mjs @@ -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(); } diff --git a/tools/web/run-wbv2-supervisor.mjs b/tools/web/run-wbv2-supervisor.mjs index da9664259..d8c1fcc13 100644 --- a/tools/web/run-wbv2-supervisor.mjs +++ b/tools/web/run-wbv2-supervisor.mjs @@ -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 });