fix(web): make continuous handoff self-recovering
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-30 14:05:10 +08:00
parent b215aa7227
commit 51d313357c
4 changed files with 305 additions and 45 deletions

View File

@@ -11,8 +11,18 @@ const maxIterations = maxArg >= 0 ? Number(process.argv[maxArg + 1]) : 0;
if (!Number.isSafeInteger(maxIterations) || maxIterations < 0) throw new Error("--max-iterations must be a non-negative integer; 0 means unlimited");
const commitEnabled = process.argv.includes("--commit");
const pushEnabled = process.argv.includes("--push");
if (pushEnabled && !commitEnabled) throw new Error("--push requires --commit");
const pushRetryArg = process.argv.indexOf("--push-retries");
const pushRetries = pushRetryArg >= 0 ? Number(process.argv[pushRetryArg + 1]) : 6;
if (!Number.isSafeInteger(pushRetries) || pushRetries < 0) throw new Error("--push-retries must be a non-negative integer");
const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
function waitMs(milliseconds) {
if (milliseconds <= 0) return;
const wait = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(wait, 0, 0, milliseconds);
}
function command(command, logPath) {
const result = spawnSync("bash", ["-lc", command], { cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
@@ -78,8 +88,23 @@ while (maxIterations === 0 || iterations < maxIterations) {
const commitResult = command(`git diff --cached --quiet || git commit -m "chore(web): continuous handoff ${before.task}"`, `tests/golden/${before.task}/continuous-commit-result.log`);
if (commitResult.code !== 0) throw new Error(`${before.task} commit failed; log=tests/golden/${before.task}/continuous-commit-result.log`);
if (pushEnabled) {
const push = command("git push origin main", `tests/golden/${before.task}/continuous-push.log`);
if (push.code !== 0) throw new Error(`${before.task} push failed; log=tests/golden/${before.task}/continuous-push.log`);
let pushed = false;
for (let attempt = 0; attempt <= pushRetries; attempt += 1) {
const pushLog = attempt === 0
? `tests/golden/${before.task}/continuous-push.log`
: `tests/golden/${before.task}/continuous-push-attempt-${attempt + 1}.log`;
const push = command("git push origin main", pushLog);
const pushVerification = command(
"local_head=$(git rev-parse --verify HEAD) && remote_head=$(git ls-remote --refs origin refs/heads/main | awk '$2 == \"refs/heads/main\" { print $1; exit }') && printf 'local_head=%s remote_head=%s\\n' \"$local_head\" \"$remote_head\" && test -n \"$remote_head\" && test \"$local_head\" = \"$remote_head\"",
`tests/golden/${before.task}/continuous-push-verify.log`,
);
if (pushVerification.code === 0) {
pushed = true;
break;
}
if (attempt < pushRetries) waitMs(Math.min(30000, 1000 * 2 ** attempt));
}
if (!pushed) throw new Error(`${before.task} remote push verification failed after ${pushRetries + 1} attempts; log=tests/golden/${before.task}/continuous-push-verify.log`);
}
}
}

View File

@@ -0,0 +1,189 @@
import fs from "node:fs";
import path from "node:path";
import { spawn } 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 logPath = `${controlRoot}/continuous-guardian.log`;
const checkpointPath = `${controlRoot}/continuous-guardian.json`;
const lockPath = `${controlRoot}/continuous-guardian.lock`;
const argv = process.argv.slice(2);
const numericArg = (name, fallback) => {
const index = argv.indexOf(name);
return Number(index >= 0 ? argv[index + 1] : fallback);
};
const restartDelayMs = numericArg("--restart-delay-ms", 5000);
const maxRestarts = numericArg("--max-restarts", 0);
const maxIterations = numericArg("--max-iterations", 0);
if (!Number.isSafeInteger(restartDelayMs) || restartDelayMs < 0) throw new Error("--restart-delay-ms must be a non-negative integer");
if (!Number.isSafeInteger(maxRestarts) || maxRestarts < 0) throw new Error("--max-restarts must be a non-negative integer; 0 means unlimited safe restarts");
if (!Number.isSafeInteger(maxIterations) || maxIterations < 0) throw new Error("--max-iterations must be a non-negative integer; 0 means unlimited");
if (argv.includes("--push") && !argv.includes("--commit")) throw new Error("--push requires --commit");
const supervisorArgs = [];
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === "--restart-delay-ms" || argv[index] === "--max-restarts") {
index += 1;
continue;
}
supervisorArgs.push(argv[index]);
}
const supervisorCommand = ["tools/web/run-wbv2-supervisor.mjs", ...supervisorArgs].join(" ");
const supervisorNodeArgs = ["tools/web/run-wbv2-supervisor.mjs", ...supervisorArgs];
fs.mkdirSync(abs(controlRoot), { recursive: true });
let lockHandle;
const acquireLock = () => {
try {
return fs.openSync(abs(lockPath), "wx");
}
catch (error) {
if (error?.code !== "EEXIST") throw error;
try {
const lock = JSON.parse(fs.readFileSync(abs(lockPath), "utf8"));
if (Number.isSafeInteger(lock.pid)) {
try { process.kill(lock.pid, 0); }
catch (probeError) {
if (probeError?.code === "ESRCH") {
fs.unlinkSync(abs(lockPath));
return fs.openSync(abs(lockPath), "wx");
}
}
}
}
catch {
// An unreadable lock is treated as owned to avoid concurrent guardians.
}
throw new Error(`another WBV2 guardian appears to be running; lock=${lockPath}`);
}
};
lockHandle = acquireLock();
fs.writeFileSync(lockHandle, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`);
let released = false;
const releaseLock = () => {
if (released || lockHandle === undefined) return;
released = true;
fs.closeSync(lockHandle);
try { fs.unlinkSync(abs(lockPath)); }
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 readJson = (file) => {
try { return JSON.parse(fs.readFileSync(abs(file), "utf8")); }
catch { return null; }
};
const currentTask = () => {
const queue = fs.readFileSync(abs("docs/EXECUTION_QUEUE.md"), "utf8");
const task = queue.match(/\| 当前任务 \| `(WBV2-P\d+-\d+)` \|/u)?.[1];
const parentManifest = queue.match(/\| parent manifest \| `([^`]+)` \|/u)?.[1];
if (!task || !parentManifest) throw new Error("queue pointer is incomplete");
return { task, parentManifest };
};
const samePointer = (left, right) => left?.task === right?.task && left?.parentManifest === right?.parentManifest;
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
let child;
let stopRequested = false;
let stopSignal;
const signalChild = (signal) => {
if (!child?.pid) return;
try { process.kill(-child.pid, signal); }
catch (error) {
if (error?.code !== "ESRCH") {
try { child.kill(signal); }
catch { /* The exit handler will record the final state. */ }
}
}
};
const requestStop = (signal) => {
stopRequested = true;
stopSignal = signal;
appendLog(`stop-requested signal=${signal}`);
signalChild(signal);
};
process.once("SIGINT", () => requestStop("SIGINT"));
process.once("SIGTERM", () => requestStop("SIGTERM"));
const runSupervisor = () => new Promise((resolve) => {
child = spawn(process.execPath, supervisorNodeArgs, {
cwd: root,
detached: true,
env: process.env,
stdio: "inherit",
});
child.once("error", (error) => resolve({ code: 1, signal: null, error: error.message }));
child.once("exit", (code, signal) => {
child = undefined;
resolve({ code: code ?? 1, signal });
});
});
function safeRestart(checkpoint, pointer) {
if (!checkpoint) return { ok: false, reason: "supervisor_checkpoint_missing" };
if (checkpoint.state === "blocked") return { ok: false, reason: checkpoint.reason ?? "supervisor_blocked" };
if (checkpoint.state === "stopped") return { ok: false, reason: checkpoint.reason ?? "supervisor_stopped" };
if (checkpoint.state !== "handed_off") return { ok: false, reason: `supervisor_state_${checkpoint.state ?? "unknown"}` };
if (!samePointer(checkpoint.after, pointer)) return { ok: false, reason: "handoff_pointer_mismatch" };
return { ok: true };
}
let guardianExit = 0;
let restartCount = 0;
try {
appendLog(`started pid=${process.pid} supervisor=${supervisorCommand} maxRestarts=${maxRestarts} restartDelayMs=${restartDelayMs}`);
writeCheckpoint({ state: "started", restartCount, supervisor: supervisorCommand, current: currentTask() });
while (true) {
if (stopRequested) {
writeCheckpoint({ state: "stopped", restartCount, reason: stopSignal, current: currentTask() });
guardianExit = stopSignal === "SIGINT" ? 130 : 143;
break;
}
const before = currentTask();
writeCheckpoint({ state: "running", restartCount, before, supervisor: supervisorCommand });
const result = await runSupervisor();
const checkpoint = readJson(checkpointPath);
const after = currentTask();
appendLog(`supervisor-exit code=${result.code} signal=${result.signal ?? "none"} task=${before.task} queue=${after.task} state=${checkpoint?.state ?? "missing"}`);
if (stopRequested) {
writeCheckpoint({ state: "stopped", restartCount, reason: stopSignal, before, after, supervisorExit: result.code });
guardianExit = stopSignal === "SIGINT" ? 130 : 143;
break;
}
if (checkpoint?.state === "complete") {
writeCheckpoint({ state: "complete", restartCount, current: after, supervisorExit: result.code });
break;
}
const decision = safeRestart(checkpoint, after);
if (!decision.ok) {
writeCheckpoint({ state: "blocked", restartCount, before, after, reason: decision.reason, supervisorExit: result.code });
appendLog(`blocked reason=${decision.reason}; no automatic replay`);
guardianExit = 2;
break;
}
restartCount += 1;
if (maxRestarts > 0 && restartCount > maxRestarts) {
writeCheckpoint({ state: "blocked", restartCount, before, after, reason: "max_safe_restarts_exceeded", supervisorExit: result.code });
appendLog("blocked reason=max_safe_restarts_exceeded");
guardianExit = 3;
break;
}
writeCheckpoint({ state: "restarting", restartCount, after, reason: "safe_handoff_checkpoint" });
appendLog(`restarting count=${restartCount} after=${after.task}`);
await sleep(restartDelayMs);
}
}
catch (error) {
guardianExit = 4;
writeCheckpoint({ state: "blocked", restartCount, reason: "guardian_error", error: error?.message ?? String(error) });
appendLog(`blocked reason=guardian_error error=${error?.message ?? String(error)}`);
}
finally {
signalChild("SIGTERM");
releaseLock();
}
process.exitCode = guardianExit;

View File

@@ -16,10 +16,12 @@ const valueArg = (name, fallback) => {
};
const maxIterations = Number(valueArg("--max-iterations", "0"));
const pollMs = Number(valueArg("--poll-ms", "1000"));
const pushRetries = Number(valueArg("--push-retries", "6"));
const commitEnabled = process.argv.includes("--commit");
const pushEnabled = process.argv.includes("--push");
if (!Number.isSafeInteger(maxIterations) || maxIterations < 0) throw new Error("--max-iterations must be a non-negative integer; 0 means unlimited");
if (!Number.isSafeInteger(pollMs) || pollMs < 0) throw new Error("--poll-ms must be a non-negative integer");
if (!Number.isSafeInteger(pushRetries) || pushRetries < 0) throw new Error("--push-retries must be a non-negative integer");
if (pushEnabled && !commitEnabled) throw new Error("--push requires --commit");
fs.mkdirSync(abs(supervisorRoot), { recursive: true });
@@ -59,19 +61,34 @@ const releaseLock = () => {
try { fs.unlinkSync(abs(lockPath)); }
catch (error) { if (error?.code !== "ENOENT") throw error; }
};
let stopRequested = false;
let stopSignal;
let iteration = 0;
let fatalHandled = false;
const stopFromSignal = (signal) => {
try {
writeCheckpoint({ state: "stopped", iteration: 0, reason: signal });
appendLog(`stopped signal=${signal}`);
releaseLock();
}
finally {
process.exitCode = signal === "SIGINT" ? 130 : 143;
}
stopRequested = true;
stopSignal = signal;
appendLog(`stop-requested signal=${signal}`);
process.exitCode = signal === "SIGINT" ? 130 : 143;
};
process.once("SIGINT", () => stopFromSignal("SIGINT"));
process.once("SIGTERM", () => stopFromSignal("SIGTERM"));
const stopOnFatal = (error, source) => {
if (fatalHandled) return;
fatalHandled = true;
try {
writeCheckpoint({ state: "blocked", iteration, reason: source, error: error?.message ?? String(error) });
appendLog(`blocked reason=${source} error=${error?.message ?? String(error)}`);
}
finally {
releaseLock();
process.exit(1);
}
};
process.once("uncaughtException", (error) => stopOnFatal(error, "uncaught_exception"));
process.once("unhandledRejection", (error) => stopOnFatal(error, "unhandled_rejection"));
if (lockHandle === undefined) {
throw new Error(`unable to acquire supervisor lock=${lockPath}`);
}
@@ -96,14 +113,18 @@ const currentTask = () => {
const childArgs = ["tools/web/run-wbv2-continuous.mjs", "--max-iterations", "1"];
if (commitEnabled) childArgs.push("--commit");
if (pushEnabled) childArgs.push("--push");
if (pushEnabled) childArgs.push("--push-retries", String(pushRetries));
appendLog(`started pid=${process.pid} maxIterations=${maxIterations} child=${childArgs.join(" ")}`);
writeCheckpoint({ state: "started", iteration: 0, current: currentTask(), child: childArgs.join(" ") });
let iteration = 0;
let exitCode = 0;
try {
while (maxIterations === 0 || iteration < maxIterations) {
if (stopRequested) {
writeCheckpoint({ state: "stopped", iteration, current: currentTask(), reason: stopSignal });
break;
}
const before = currentTask();
const print = run(process.execPath, ["tools/web/print-task-context.mjs"]);
const check = run(process.execPath, ["tools/web/check-task-context.mjs"]);
@@ -117,6 +138,10 @@ try {
const child = run(process.execPath, childArgs);
appendLog(`child task=${before.task} exit=${child.code}\n${child.output}`);
const after = currentTask();
if (stopRequested) {
writeCheckpoint({ state: "stopped", iteration, before, after, reason: stopSignal, childExit: child.code });
break;
}
if (child.code !== 0) {
const reason = after.task === before.task ? "child_failed_same_task" : "child_failed_after_queue_advance";
writeCheckpoint({ state: "blocked", iteration: iteration + 1, before, after, reason, childExit: child.code });
@@ -136,6 +161,9 @@ try {
Atomics.wait(wait, 0, 0, pollMs);
}
}
if (stopRequested) {
exitCode = stopSignal === "SIGINT" ? 130 : 143;
}
if (exitCode === 0) {
const current = currentTask();
writeCheckpoint({ state: maxIterations === 0 ? "running" : "complete", iteration, current });