feat(web): add source-first Python autopilot and optimize provider waits
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-09-08 10:59:49 +08:00
parent a69e41457a
commit edcf0fef9a
29 changed files with 10149 additions and 412 deletions

View File

@@ -81,6 +81,8 @@ const pushVerification = (task) => {
return { path: file, localHead: match?.[1] ?? null, remoteHead: match?.[2] ?? null, equal: Boolean(match && match[1] === match[2]) };
};
const collect = () => {
const autopilot = readJson(`${controlRoot}/autopilot/state.json`);
const autopilotLock = lockInfo(`${controlRoot}/autopilot/owner.lock`);
const queue = currentPointer();
const scheduler = readJson(schedulerCheckpointPath);
const guardian = readJson(guardianCheckpointPath);
@@ -88,21 +90,23 @@ const collect = () => {
const schedulerLock = lockInfo(schedulerLockPath);
const guardianLock = lockInfo(guardianLockPath);
const supervisorLock = lockInfo(supervisorLockPath);
const pythonOwnsScheduler = autopilotLock.alive && schedulerLock.pid === autopilotLock.pid;
const handoff = latestHandoff();
const active = schedulerLock.alive || (guardianLock.alive && supervisorLock.alive);
const active = autopilotLock.alive || schedulerLock.alive || (guardianLock.alive && supervisorLock.alive);
let terminalQueue = false;
try {
const manifest = queue.task ? readJson(`tests/golden/${queue.task}/manifest.json`) : null;
const index = readJson("tests/golden/WBV2/task-index.json");
terminalQueue = !active && index?.activeTask === null && manifest?.status === "done" && manifest?.nextTask === null;
terminalQueue = !active && index?.activeTask === null && manifest?.status === "done" && manifest?.nextTask === null && manifest?.acceptanceComplete === true && manifest?.verificationDeferred !== true;
} catch { /* A malformed pointer remains visible as a normal checkpoint failure. */ }
const checkpointBlocked = scheduler?.state === "blocked" || guardian?.state === "blocked" || supervisor?.state === "blocked";
// A terminal task can leave a historical blocked checkpoint behind (for
// example, after a push retry). The watchdog owns the next planner attempt;
// do not report that stale checkpoint as a live execution block.
const blocked = checkpointBlocked && !terminalQueue;
const autopilotBlocked = ["blocked", "provider_blocked", "needs_reconcile", "planning_required", "budget_exhausted", "ready_to_publish"].includes(autopilot?.state);
const blocked = autopilotBlocked || (!autopilotLock.alive && checkpointBlocked && !terminalQueue);
const state = blocked ? "BLOCKED" : active ? "RUNNING" : "IDLE";
const phase = blocked ? "blocked" : scheduler?.state === "running" ? "scheduler-task" : supervisor?.state === "running" ? "task-or-gate" : scheduler?.state ?? supervisor?.state ?? "unknown";
const phase = autopilotLock.alive || autopilotBlocked ? `python-${autopilot?.state ?? "starting"}` : blocked ? "blocked" : scheduler?.state === "running" ? "scheduler-task" : supervisor?.state === "running" ? "task-or-gate" : scheduler?.state ?? supervisor?.state ?? "unknown";
const observed = new Date().toISOString();
return {
schemaVersion: 1,
@@ -110,10 +114,11 @@ const collect = () => {
state,
phase,
message: state === "RUNNING"
? `RUNNING: ${queue.task ?? "unknown task"} is owned by the guardian/supervisor process; wait for its handoff.`
? `RUNNING: ${queue.task ?? "unknown task"} is owned by ${autopilotLock.alive ? "Python autopilot" : "guardian/supervisor"}; wait for its handoff.`
: state === "BLOCKED" ? "BLOCKED: inspect the scheduler/guardian/supervisor checkpoint reason before any write or retry." : "IDLE: no live scheduler, guardian or supervisor lock was found.",
queue,
scheduler: { pid: scheduler?.pid ?? schedulerLock.pid, alive: schedulerLock.alive, state: scheduler?.state ?? null, updatedAt: scheduler?.updatedAt ?? null, iteration: scheduler?.iteration ?? null, before: scheduler?.before ?? null, after: scheduler?.after ?? null, latestBatch: scheduler?.latestBatch ?? null },
autopilot: { pid: autopilot?.pid ?? autopilotLock.pid, alive: autopilotLock.alive, state: autopilot?.state ?? null, phase: autopilot?.currentProcess?.phase ?? null, activeJob: autopilot?.activeJob ?? null, reason: autopilot?.reason ?? null, tokensUsed: autopilot?.tokensUsed ?? 0, providerFailures: autopilot?.providerFailures ?? 0, nextRetryAt: autopilot?.nextRetryAt ?? null },
scheduler: { pid: schedulerLock.alive ? schedulerLock.pid : scheduler?.pid ?? schedulerLock.pid, alive: schedulerLock.alive, state: pythonOwnsScheduler ? "python-owned" : scheduler?.state ?? null, updatedAt: pythonOwnsScheduler ? autopilot?.updatedAt : scheduler?.updatedAt ?? null, iteration: pythonOwnsScheduler ? null : scheduler?.iteration ?? null, before: pythonOwnsScheduler ? null : scheduler?.before ?? null, after: pythonOwnsScheduler ? null : scheduler?.after ?? null, latestBatch: pythonOwnsScheduler ? null : scheduler?.latestBatch ?? null },
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,
@@ -127,6 +132,7 @@ const format = (status) => [
`阶段: ${status.phase}`,
`当前任务: ${status.queue.task ?? "unknown"}`,
`parent manifest: ${status.queue.parentManifest ?? "unknown"}`,
`Python autopilot: pid=${status.autopilot.pid ?? "none"} alive=${status.autopilot.alive} state=${status.autopilot.state ?? "unknown"} job=${status.autopilot.activeJob ?? "none"} reason=${status.autopilot.reason ?? "none"}`,
`scheduler: pid=${status.scheduler.pid ?? "none"} alive=${status.scheduler.alive} state=${status.scheduler.state ?? "unknown"} iteration=${status.scheduler.iteration ?? "none"} batch=${status.scheduler.latestBatch?.batchId ?? "none"} completed=${status.scheduler.latestBatch?.completed ?? false}`,
`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"}`,

View File

@@ -168,7 +168,7 @@ try {
guardianExit = stopSignal === "SIGINT" ? 130 : 143;
break;
}
if (checkpoint?.state === "complete") {
if (checkpoint?.state === "complete" && result.code === 0) {
writeCheckpoint({ state: "complete", restartCount, current: after, supervisorExit: result.code });
break;
}

View File

@@ -31,9 +31,39 @@ if (!Number.isSafeInteger(pollMs) || pollMs < 0) throw new Error("--poll-ms must
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");
// A preflight must neither claim ownership nor rotate queue/checkpoint files.
if (preflightOnly) {
try {
const pointer = currentTask();
if (!pointer) throw new Error("queue pointer is missing; completion is unproven");
parentManifest(pointer);
const check = spawnSync(process.execPath, [abs("tools/web/check-task-context.mjs")], { cwd: root, encoding: "utf8" });
if (check.status !== 0) throw new Error("task context validation failed");
const readiness = terminalTask(pointer.task)
? { ok: true, terminal: true, reason: "accepted_terminal_manifest" }
: sourceMappingReadiness(pointer);
process.stdout.write(`${JSON.stringify({ task: pointer.task, ...readiness })}\n`);
process.exit(readiness.ok ? 0 : 2);
}
catch (error) {
process.stderr.write(`scheduler-preflight-blocked: ${error.message}\n`);
process.exit(2);
}
}
fs.mkdirSync(abs(controlRoot), { recursive: true });
let lockHandle;
const acquireLock = () => {
const pythonOwnerPath = abs(`${controlRoot}/autopilot/owner.lock`);
if (fs.existsSync(pythonOwnerPath)) {
const owner = JSON.parse(fs.readFileSync(pythonOwnerPath, "utf8"));
let alive = false;
if (Number.isSafeInteger(owner.pid) && owner.pid > 0) {
try { process.kill(owner.pid, 0); alive = true; }
catch (error) { if (error.code !== "ESRCH") alive = true; }
}
if (alive) throw new Error("the Python autopilot already owns the project");
}
try {
return fs.openSync(abs(lockPath), "wx");
}
@@ -186,7 +216,13 @@ function terminalTask(task) {
const index = JSON.parse(read("tests/golden/WBV2/task-index.json"));
const manifestPath = `tests/golden/${task}/manifest.json`;
const manifest = fs.existsSync(abs(manifestPath)) ? JSON.parse(read(manifestPath)) : null;
return index.activeTask === null && manifest?.task === task && manifest.status === "done" && manifest.nextTask === null;
return index.activeTask === null && manifest?.task === task && manifest.status === "done" && manifest.nextTask === null
&& manifest.acceptanceComplete === true && manifest.verificationDeferred !== true
&& Object.keys(manifest.artifacts ?? {}).length > 0
&& Object.values(manifest.artifacts).every((artifact) => typeof artifact.path === "string"
&& typeof artifact.sha256 === "string" && !path.isAbsolute(artifact.path)
&& !artifact.path.split("/").includes("..")
&& crypto.createHash("sha256").update(fs.readFileSync(abs(artifact.path))).digest("hex") === artifact.sha256);
}
catch {
return false;
@@ -319,7 +355,7 @@ try {
// guardian/supervisor chain.
try {
const index = JSON.parse(read("tests/golden/WBV2/task-index.json"));
if (index.activeTask === null && index.entries?.[before.task]) {
if (index.activeTask === null && index.entries?.[before.task] && terminalTask(before.task)) {
const completedBatchInfo = { ...batchInfo(before), completed: true };
latestBatch = completedBatchInfo;
const planned = preflightOnly ? { terminal: true } : planNextBatch(before);

View File

@@ -12,7 +12,7 @@ const arg = (name, fallback = null) => {
return index >= 0 ? process.argv[index + 1] : fallback;
};
if (process.argv.includes("--help")) {
process.stdout.write("usage: node tools/web/search-wbv2-batch-source.mjs [--task <task-id>] --pattern <text> [--runtime <path> ...] or --list\n");
process.stdout.write("usage: node tools/web/search-wbv2-batch-source.mjs [--task <task-id>] --pattern <text> [--unit <source-unit-id> ...] [--runtime <path> ...] [--max-hits <count>] or --list\n");
process.exit(0);
}
const task = arg("--task") ?? read("docs/EXECUTION_QUEUE.md").match(/\| 当前任务 \| `([^`]+)` \|/u)?.[1];
@@ -32,6 +32,21 @@ for (const artifact of Object.values(manifest?.artifacts ?? {})) {
for (const source of taskRecord?.module?.sourcePaths ?? []) {
if (source.startsWith("blender-5.2.0/source/blender/")) sourcePaths.add(source);
}
const selectedUnits = [];
for (let i = 0; i < process.argv.length; i += 1) {
if (process.argv[i] === "--unit") selectedUnits.push(process.argv[++i]);
}
if (selectedUnits.length > 16) throw new Error("source context is limited to 16 explicit unit IDs");
if (selectedUnits.length) {
const inventory = json("tests/golden/WBV2/dynamic-inventory.json");
for (const id of selectedUnits) {
const unit = inventory.units.find((item) => item.id === id);
if (!unit) throw new Error(`unknown source unit: ${id}`);
for (const location of unit.locations ?? []) {
if (location.path.startsWith("blender-5.2.0/source/blender/")) sourcePaths.add(location.path);
}
}
}
const runtimeAllowlist = new Set([
"tools/web/run-wbv2-module-task.mjs",
"tools/web/run-wbv2-continuous.mjs",
@@ -78,6 +93,17 @@ const runtimeAllowlist = new Set([
"blender-5.2.0/source/blender/web_engine/web_engine_main_state.h",
]);
const runtimePaths = [];
// Product workflows are bounded subtasks of the current authoritative task.
const backlogPath = abs("tests/golden/WBV2/web-product-backlog.json");
if (fs.existsSync(backlogPath)) {
const backlog = JSON.parse(fs.readFileSync(backlogPath, "utf8"));
if (backlog.queueTask === task) {
for (const job of backlog.tasks ?? []) {
for (const file of job.ownedPaths ?? []) runtimeAllowlist.add(file);
}
}
}
for (const file of ["tools/web/deployment-http-server.mjs", "my-tools/wbv2_autopilot.py", "my-tools/run_web_product_migration.py", "tools/web/run-wbv2-scheduler.mjs", "tools/web/run-wbv2-guardian.mjs", "tools/web/check-wbv2-module-execution.mjs"]) runtimeAllowlist.add(file);
for (let index = 0; index < process.argv.length; index += 1) {
if (process.argv[index] === "--runtime") {
const file = process.argv[index + 1];
@@ -101,4 +127,6 @@ if (result.error) throw result.error;
if (result.status !== 0 && result.status !== 1) throw new Error(`batch search failed with exit ${result.status}`);
const output = result.stdout ?? "";
const hits = output ? output.trimEnd().split("\n").filter(Boolean) : [];
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, task, scope: "batch", pattern, sourcePathCount: sourcePaths.size, runtimePathCount: runtimePaths.length, hitCount: hits.length, hits }, null, 2)}\n`);
const maxHits = Number(arg("--max-hits", "60"));
if (!Number.isSafeInteger(maxHits) || maxHits < 1 || maxHits > 200) throw new Error("--max-hits must be between 1 and 200");
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, task, scope: "batch", pattern, selectedUnits, sourcePathCount: sourcePaths.size, runtimePathCount: runtimePaths.length, hitCount: hits.length, truncated: hits.length > maxHits, hits: hits.slice(0, maxHits) }, null, 2)}\n`);