perf(web): add idempotent batch verification fast paths
This commit is contained in:
@@ -16,19 +16,44 @@ const task = process.argv.includes("--task")
|
||||
if (!task) throw new Error("unable to resolve current WBV2 task");
|
||||
const parentManifest = queue.match(/\| parent manifest \| `([^`]+)` \|/u)?.[1];
|
||||
if (!parentManifest) throw new Error("queue parent manifest is missing");
|
||||
const inputPaths = [
|
||||
const baseInputPaths = [
|
||||
"docs/EXECUTION_QUEUE.md",
|
||||
`docs/tasks/${task}.md`,
|
||||
parentManifest,
|
||||
`tests/golden/${task}/task-context.json`,
|
||||
];
|
||||
const inputHashFor = () => digest(inputPaths.map((file) => `${file}\0${fs.existsSync(abs(file)) ? digest(read(file)) : "MISSING"}\n`).join(""));
|
||||
const inputHash = inputHashFor();
|
||||
const statInput = (file) => {
|
||||
try {
|
||||
const stat = fs.statSync(abs(file), { bigint: true });
|
||||
return { bytes: Number(stat.size), mtime_ns: stat.mtimeNs.toString(), exists: true };
|
||||
}
|
||||
catch (error) {
|
||||
return { bytes: null, mtime_ns: null, exists: false, error: error?.code ?? "STAT_FAILED" };
|
||||
}
|
||||
};
|
||||
const metadataComparable = (value) => value && value.exists === true
|
||||
&& Number.isSafeInteger(value.bytes) && typeof value.mtime_ns === "string";
|
||||
let cached = null;
|
||||
try { cached = JSON.parse(fs.readFileSync(cachePath, "utf8")); }
|
||||
catch { /* First use or an interrupted write. */ }
|
||||
if (!process.argv.includes("--force") && cached?.status === "PASS" && cached.task === task && cached.inputHash === inputHash) {
|
||||
process.stdout.write(`wbv2-context-cache-hit task=${task} inputHash=${inputHash}\n`);
|
||||
const inputPaths = [...new Set([
|
||||
...baseInputPaths,
|
||||
...Object.keys(cached?.inputs ?? {}),
|
||||
])];
|
||||
const inputMetadata = Object.fromEntries(inputPaths.map((file) => [file, statInput(file)]));
|
||||
const metadataUnchanged = (cachedInputs) => inputPaths.every((file) => {
|
||||
const current = inputMetadata[file];
|
||||
const cached = cachedInputs?.[file];
|
||||
return metadataComparable(current) && metadataComparable(cached)
|
||||
&& current.bytes === cached.bytes && current.mtime_ns === cached.mtime_ns;
|
||||
});
|
||||
const digestPath = (file) => {
|
||||
try { return digest(read(file)); }
|
||||
catch { return "MISSING"; }
|
||||
};
|
||||
const inputHashFor = (paths = inputPaths) => digest(paths.map((file) => `${file}\0${digestPath(file)}\n`).join(""));
|
||||
if (!process.argv.includes("--force") && cached?.status === "PASS" && cached.task === task && metadataUnchanged(cached.inputs)) {
|
||||
process.stdout.write(`CONTEXT_PACKET_REUSED task=${task} inputHash=${cached.inputHash ?? "UNKNOWN"}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
const result = spawnSync(process.execPath, [abs("tools/web/print-task-context.mjs"), "--task", task], { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
||||
@@ -38,8 +63,21 @@ if ((result.status ?? 1) !== 0 || (check.status ?? 1) !== 0) {
|
||||
process.exit(Math.max(result.status ?? 1, check.status ?? 1));
|
||||
}
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
const refreshedInputHash = inputHashFor();
|
||||
const payload = { schemaVersion: 1, status: "PASS", task, inputHash: refreshedInputHash, refreshedAt: new Date().toISOString() };
|
||||
let contextMetadata = {};
|
||||
try {
|
||||
const context = JSON.parse(read(`tests/golden/${task}/task-context.json`));
|
||||
contextMetadata = context.inputMetadata && typeof context.inputMetadata === "object" ? context.inputMetadata : {};
|
||||
} catch { /* The task-context checker already reported the precise failure. */ }
|
||||
const refreshedPaths = [...new Set([...inputPaths, ...Object.keys(contextMetadata)])];
|
||||
const refreshedInputHash = inputHashFor(refreshedPaths);
|
||||
const payload = {
|
||||
schemaVersion: 2,
|
||||
status: "PASS",
|
||||
task,
|
||||
inputHash: refreshedInputHash,
|
||||
inputs: Object.fromEntries(refreshedPaths.map((file) => [file, { ...statInput(file), sha256: contextMetadata[file]?.sha256 ?? digestPath(file) }])),
|
||||
refreshedAt: new Date().toISOString(),
|
||||
};
|
||||
const temporary = `${cachePath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
fs.renameSync(temporary, cachePath);
|
||||
|
||||
@@ -64,6 +64,25 @@ export function validateContextBundle(bundle, { current = true } = {}) {
|
||||
if (context.commands.length > GOVERNANCE_LIMITS.maxTaskCommands) {
|
||||
add(violations, "TASK_COMMANDS_TOO_WIDE", `count=${context.commands.length}`);
|
||||
}
|
||||
if (!context.inputMetadata || typeof context.inputMetadata !== "object") {
|
||||
add(violations, "INPUT_METADATA_MISSING", context.task);
|
||||
} else {
|
||||
const requiredMetadata = [
|
||||
relative(files.queuePath),
|
||||
relative(files.taskPath),
|
||||
relative(files.parentManifestPath),
|
||||
...(fs.existsSync(files.parentStatusPath) ? [relative(files.parentStatusPath)] : []),
|
||||
...context.inputPaths,
|
||||
];
|
||||
for (const file of [...new Set(requiredMetadata)]) {
|
||||
const metadata = context.inputMetadata[file];
|
||||
if (!metadata || typeof metadata !== "object" || metadata.exists !== true
|
||||
|| !Number.isSafeInteger(metadata.bytes) || typeof metadata.mtime_ns !== "string"
|
||||
|| !/^[a-f0-9]{64}$/u.test(metadata.sha256 ?? "")) {
|
||||
add(violations, "INPUT_METADATA_INVALID", `${context.task}:${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selection = context.inputSelection;
|
||||
if (!selection || selection.schemaVersion !== 1 || !Array.isArray(selection.selected) || !Array.isArray(selection.excluded)) {
|
||||
|
||||
@@ -6,6 +6,8 @@ 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 schedulerLockPath = `${controlRoot}/continuous-scheduler.lock`;
|
||||
const schedulerCheckpointPath = `${controlRoot}/continuous-scheduler.json`;
|
||||
const guardianLockPath = `${controlRoot}/continuous-guardian.lock`;
|
||||
const supervisorLockPath = `${controlRoot}/continuous-supervisor.lock`;
|
||||
const guardianCheckpointPath = `${controlRoot}/continuous-guardian.json`;
|
||||
@@ -80,15 +82,17 @@ const pushVerification = (task) => {
|
||||
};
|
||||
const collect = () => {
|
||||
const queue = currentPointer();
|
||||
const scheduler = readJson(schedulerCheckpointPath);
|
||||
const guardian = readJson(guardianCheckpointPath);
|
||||
const supervisor = readJson(supervisorCheckpointPath);
|
||||
const schedulerLock = lockInfo(schedulerLockPath);
|
||||
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 active = schedulerLock.alive || (guardianLock.alive && supervisorLock.alive);
|
||||
const blocked = scheduler?.state === "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 phase = 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,
|
||||
@@ -97,14 +101,15 @@ const collect = () => {
|
||||
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.",
|
||||
: 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 },
|
||||
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 },
|
||||
checkpoints: { scheduler: schedulerCheckpointPath, guardian: guardianCheckpointPath, supervisor: supervisorCheckpointPath },
|
||||
};
|
||||
};
|
||||
const format = (status) => [
|
||||
@@ -112,6 +117,7 @@ const format = (status) => [
|
||||
`阶段: ${status.phase}`,
|
||||
`当前任务: ${status.queue.task ?? "unknown"}`,
|
||||
`parent manifest: ${status.queue.parentManifest ?? "unknown"}`,
|
||||
`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"}`,
|
||||
`最近交接: ${status.latestHandoff ? `${status.latestHandoff.before} -> ${status.latestHandoff.after} (#${status.latestHandoff.iteration})` : "none"}`,
|
||||
|
||||
@@ -58,20 +58,6 @@ function taskCommand(task) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function typecheckProven(task) {
|
||||
try {
|
||||
const manifest = JSON.parse(read(`tests/golden/${task}/manifest.json`));
|
||||
const receiptPath = manifest.artifacts?.moduleReceipt?.path;
|
||||
if (!receiptPath) return false;
|
||||
const receipt = JSON.parse(read(receiptPath));
|
||||
return receipt.status === "PASS" && receipt.source?.unchanged === true
|
||||
&& receipt.commands?.some((entry) => entry.exitCode === 0 && entry.command.trim() === "npm --prefix web run typecheck");
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let iterations = 0;
|
||||
const summary = [];
|
||||
while (maxIterations === 0 || iterations < maxIterations) {
|
||||
@@ -91,10 +77,10 @@ while (maxIterations === 0 || iterations < maxIterations) {
|
||||
const after = currentTask();
|
||||
const terminal = after.task === before.task && terminalTask(before.task);
|
||||
if (after.task === before.task && !terminal) throw new Error(`${before.task} focused command exited 0 without advancing queue; log=${logPath}`);
|
||||
const typecheckCommand = typecheckProven(before.task)
|
||||
? "printf 'typecheck reused from hash-bound module receipt\\n'"
|
||||
: "npm --prefix web run typecheck";
|
||||
const gates = command(`node tools/web/check-context-governance.mjs && node tools/web/check-execution-control-plane.mjs --allow-unrunnable && node tools/web/check-v2-task-index.mjs && node tools/web/check-v2-handoff.mjs && ${typecheckCommand} && npm --prefix web run lint && git diff --check`, `tests/golden/${before.task}/continuous-gates.log`);
|
||||
// Implementation cycles keep the post-handoff gate incremental. Full API,
|
||||
// format, Chromium, performance and release gates belong to the explicit
|
||||
// final audit and must not be repeated for every batch.
|
||||
const gates = command(`python3 my-tools/validate_incremental_context.py --task ${before.task}`, `tests/golden/${before.task}/continuous-gates.log`);
|
||||
if (gates.code !== 0) throw new Error(`${before.task} post-handoff gate failed (exit ${gates.code}); log=tests/golden/${before.task}/continuous-gates.log`);
|
||||
iterations += 1;
|
||||
summary.push({ task: before.task, command: focused, nextTask: terminal ? null : after.task, terminal, log: logPath });
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { recordFailure } from "./wbv2-failure-ledger.mjs";
|
||||
import { createFailureLedgerSession } from "./wbv2-failure-ledger.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const abs = (file) => path.join(root, file);
|
||||
@@ -11,6 +11,7 @@ const logPath = `${controlRoot}/continuous-guardian.log`;
|
||||
const checkpointPath = `${controlRoot}/continuous-guardian.json`;
|
||||
const supervisorCheckpointPath = `${controlRoot}/continuous-supervisor.json`;
|
||||
const lockPath = `${controlRoot}/continuous-guardian.lock`;
|
||||
const failureLedger = createFailureLedgerSession(root, { logPaths: [`${controlRoot}/continuous-guardian.log`] });
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const numericArg = (name, fallback) => {
|
||||
@@ -173,7 +174,7 @@ try {
|
||||
}
|
||||
const decision = safeRestart(checkpoint, after);
|
||||
if (!decision.ok) {
|
||||
recordFailure(root, { task: before.task, phase: "guardian", reason: decision.reason, childExit: result.code });
|
||||
failureLedger.record({ task: before.task, phase: "guardian", reason: decision.reason, childExit: result.code });
|
||||
writeCheckpoint({ state: "blocked", restartCount, before, after, reason: decision.reason, supervisorExit: result.code });
|
||||
appendLog(`blocked reason=${decision.reason}; no automatic replay`);
|
||||
guardianExit = 2;
|
||||
@@ -181,7 +182,7 @@ try {
|
||||
}
|
||||
restartCount += 1;
|
||||
if (maxRestarts > 0 && restartCount > maxRestarts) {
|
||||
recordFailure(root, { task: before.task, phase: "guardian", reason: "max_safe_restarts_exceeded", childExit: result.code });
|
||||
failureLedger.record({ task: before.task, phase: "guardian", reason: "max_safe_restarts_exceeded", childExit: result.code });
|
||||
writeCheckpoint({ state: "blocked", restartCount, before, after, reason: "max_safe_restarts_exceeded", supervisorExit: result.code });
|
||||
appendLog("blocked reason=max_safe_restarts_exceeded");
|
||||
guardianExit = 3;
|
||||
@@ -194,12 +195,13 @@ try {
|
||||
}
|
||||
catch (error) {
|
||||
guardianExit = 4;
|
||||
try { recordFailure(root, { task: currentTask()?.task ?? "UNKNOWN", phase: "guardian", reason: "guardian_error", detail: error?.message ?? String(error) }); }
|
||||
try { failureLedger.record({ task: currentTask()?.task ?? "UNKNOWN", phase: "guardian", reason: "guardian_error", detail: error?.message ?? String(error) }); }
|
||||
catch { /* Preserve the original guardian failure. */ }
|
||||
writeCheckpoint({ state: "blocked", restartCount, reason: "guardian_error", error: error?.message ?? String(error) });
|
||||
appendLog(`blocked reason=guardian_error error=${error?.message ?? String(error)}`);
|
||||
}
|
||||
finally {
|
||||
failureLedger.flush();
|
||||
signalChild("SIGTERM");
|
||||
releaseLock();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { batchContract, commandBudget, completedBatch } from "./wbv2-batch-contract.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const abs = (file) => path.join(root, file);
|
||||
@@ -11,6 +12,7 @@ const read = (file) => fs.readFileSync(abs(file), "utf8");
|
||||
const json = (file) => JSON.parse(read(file));
|
||||
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(abs(file))).digest("hex");
|
||||
const dynamicInventoryPath = "tests/golden/WBV2/dynamic-inventory.json";
|
||||
const out = `tests/golden/${process.argv[process.argv.indexOf("--task") + 1]}`;
|
||||
const writeJson = (file, value) => {
|
||||
fs.mkdirSync(path.dirname(abs(file)), { recursive: true });
|
||||
fs.writeFileSync(abs(file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
@@ -35,8 +37,28 @@ const parentId = record.dependsOn?.at(-1);
|
||||
const parentPath = `tests/golden/${parentId}/manifest.json`;
|
||||
const parent = json(parentPath);
|
||||
if (parent.status !== "done" || parent.nextTask !== task) throw new Error(`module parent is not a completed handoff: ${parentId}`);
|
||||
if (record.status !== "in_progress" || record.ready !== true) throw new Error(`module task ${task} is not active`);
|
||||
if (!record.module.sourceBacked) throw new Error(`module ${record.module.moduleId} has no source-backed implementation`);
|
||||
const contract = batchContract(task, record.module);
|
||||
if (!record.batchId) record.batchId = contract.batchId;
|
||||
if (!record.selectedRecordIds) record.selectedRecordIds = contract.selectedRecordIds;
|
||||
if (!record.module.batchId) record.module.batchId = contract.batchId;
|
||||
if (!record.module.selectedRecordIds) record.module.selectedRecordIds = contract.selectedRecordIds;
|
||||
const commandContract = commandBudget(record.module.commands ?? []);
|
||||
if (!commandContract.withinBudget) throw new Error(`task ${task} exceeds command budget: ${JSON.stringify(commandContract)}`);
|
||||
const existingManifestPath = `${out}/manifest.json`;
|
||||
if (fs.existsSync(abs(existingManifestPath))) {
|
||||
try {
|
||||
const existingManifest = json(existingManifestPath);
|
||||
const legacyCompleted = existingManifest.status === "done"
|
||||
&& existingManifest.nextTask === null
|
||||
&& JSON.stringify(existingManifest.selectedRecordIds ?? record.module.selectedRecordIds ?? []) === JSON.stringify(contract.selectedRecordIds);
|
||||
if (completedBatch(existingManifest, task, record.module) || legacyCompleted) {
|
||||
process.stdout.write(`module-batch-idempotent task=${task} batchId=${contract.batchId} plannerRequired=true\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
} catch { /* A partial manifest is not evidence and follows the normal path. */ }
|
||||
}
|
||||
if (record.status !== "in_progress" || record.ready !== true) throw new Error(`module task ${task} is not active`);
|
||||
for (const source of record.module.sourcePaths ?? []) if (!fs.existsSync(abs(source))) throw new Error(`module source is missing: ${source}`);
|
||||
const sourceAuditPaths = (record.module.sourcePaths ?? []).filter((source) =>
|
||||
/^(blender-5\.2\.0\/source\/blender\/|web\/protocol\/|web\/app\/src\/(?!vendor\/))/u.test(source)
|
||||
@@ -48,7 +70,6 @@ const reusedReceipts = (record.module.reuseReceipts ?? []).map((receipt) => ({
|
||||
reportSha256: sha(receipt.report),
|
||||
}));
|
||||
|
||||
const out = `tests/golden/${task}`;
|
||||
const reportPath = `${out}/module-receipt.json`;
|
||||
const statusPath = `docs/status/${task}.md`;
|
||||
const logPath = `${out}/module-run.log`;
|
||||
@@ -130,7 +151,11 @@ const report = {
|
||||
schemaVersion: 1,
|
||||
task,
|
||||
operation: "MODULE_FUNCTION_EXECUTION",
|
||||
phase: failed || !sourceUnchanged ? "BLOCKED" : "CONTINUE",
|
||||
moduleId: record.module.moduleId,
|
||||
batchId: contract.batchId,
|
||||
selectedRecordIds: contract.selectedRecordIds,
|
||||
commandBudget: commandContract,
|
||||
status: failed || !sourceUnchanged ? "BLOCKED" : "PASS",
|
||||
source: { paths: record.module.sourcePaths, auditedPaths: sourceAuditPaths, before: sourceBefore, after: sourceAfter, unchanged: sourceUnchanged },
|
||||
reusedReceipts,
|
||||
@@ -159,7 +184,7 @@ if (failed || !sourceUnchanged) {
|
||||
const manifestPath = `${out}/manifest.json`;
|
||||
const evidenceArtifacts = Object.fromEntries((record.module.evidencePaths ?? []).filter((file) => fs.existsSync(abs(file))).map((file, item) => [`evidence${item + 1}`, { path: file, sha256: sha(file) }]));
|
||||
const artifacts = { parentManifest: { path: parentPath, sha256: sha(parentPath) }, ...Object.fromEntries(record.module.sourcePaths.map((source, item) => [`source${item + 1}`, { path: source, sha256: sha(source) }])), ...evidenceArtifacts, moduleReceipt: { path: reportPath, sha256: sha(reportPath) }, status: { path: statusPath, sha256: sha(statusPath) }, log: { path: logPath, sha256: sha(logPath) }, plan: { path: index.source.path, sha256: sha(index.source.path) }, taskContext: { path: `${out}/task-context.json`, sha256: sha(`${out}/task-context.json`) } };
|
||||
writeJson(manifestPath, { schemaVersion: 1, task, parentTask: parentId, operation: "MODULE_FUNCTION_EXECUTION", taskClass: "MODULE", status: "done", enablingTask: true, parityStateChange: false, runtime: "BLENDER_WEB_FUNCTION_MODULE", artifacts, assertions: { moduleId: record.module.moduleId, sourceBacked: true, positiveCases: record.module.positive.length, negativeCases: record.module.negative.length, recoveryCases: record.module.recovery.length, commandCount: results.length, reusedReceiptCount: reusedReceipts.length, sourceUnchanged, fullFamilyParity: false, dynamicInventoryPath, dynamicSourceTreeSha256: dynamicInventory.authority.sourceTreeSha256, dynamicUnitCount: dynamicInventory.summary.unitCount, dynamicVerifiedUnits: dynamicInventory.coverage.verifiedUnits, dynamicUnverifiedUnits: dynamicInventory.coverage.unverifiedUnits, planningEstimate: dynamicInventory.policy.planningEstimate, historicalInventoryTotal: 6900, historicalEffectiveCompleted: 290, historicalEffectivePending: 6609, historicalReproduction: false }, nextTask });
|
||||
writeJson(manifestPath, { schemaVersion: 1, task, parentTask: parentId, operation: "MODULE_FUNCTION_EXECUTION", phase: "CONTINUE", taskClass: "MODULE", status: "done", enablingTask: true, parityStateChange: false, runtime: "BLENDER_WEB_FUNCTION_MODULE", batchId: contract.batchId, selectedRecordIds: contract.selectedRecordIds, artifacts, assertions: { moduleId: record.module.moduleId, batchId: contract.batchId, selectedRecordIds: contract.selectedRecordIds, sourceBacked: true, positiveCases: record.module.positive.length, negativeCases: record.module.negative.length, recoveryCases: record.module.recovery.length, commandCount: results.length, commandBudget, reusedReceiptCount: reusedReceipts.length, sourceUnchanged, fullFamilyParity: false, dynamicInventoryPath, dynamicSourceTreeSha256: dynamicInventory.authority.sourceTreeSha256, dynamicUnitCount: dynamicInventory.summary.unitCount, dynamicVerifiedUnits: dynamicInventory.coverage.verifiedUnits, dynamicUnverifiedUnits: dynamicInventory.coverage.unverifiedUnits, planningEstimate: dynamicInventory.policy.planningEstimate, historicalInventoryTotal: 6900, historicalEffectiveCompleted: 290, historicalEffectivePending: 6609, historicalReproduction: false }, nextTask });
|
||||
if (record.module.coverageBindings?.length) {
|
||||
execFileSync(process.execPath, [abs("tools/web/bind-wbv2-module-coverage.mjs"), "--task", task], { cwd: root, stdio: "inherit" });
|
||||
execFileSync(process.execPath, [abs("tools/web/finalize-wbv2-module-batch.mjs"), "--task", task], { cwd: root, stdio: "inherit" });
|
||||
|
||||
@@ -3,7 +3,7 @@ import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { recordFailure } from "./wbv2-failure-ledger.mjs";
|
||||
import { createFailureLedgerSession } from "./wbv2-failure-ledger.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const controlRoot = "tests/golden/WBV2";
|
||||
@@ -11,6 +11,10 @@ const checkpointPath = `${controlRoot}/continuous-scheduler.json`;
|
||||
const lockPath = `${controlRoot}/continuous-scheduler.lock`;
|
||||
const logPath = `${controlRoot}/continuous-scheduler.log`;
|
||||
const abs = (file) => path.join(root, file);
|
||||
const failureLedger = createFailureLedgerSession(root, {
|
||||
logPaths: [`${controlRoot}/continuous-scheduler.log`, `${controlRoot}/continuous-supervisor.log`, `${controlRoot}/continuous-guardian.log`],
|
||||
});
|
||||
let latestBatch = null;
|
||||
|
||||
const argValue = (name, fallback) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
@@ -60,6 +64,7 @@ function writeCheckpoint(payload) {
|
||||
pid: process.pid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...payload,
|
||||
latestBatch: payload.latestBatch ?? latestBatch,
|
||||
};
|
||||
const stable = (value) => { const { pid, updatedAt, ...rest } = value ?? {}; return rest; };
|
||||
try {
|
||||
@@ -72,6 +77,43 @@ function writeCheckpoint(payload) {
|
||||
fs.renameSync(abs(temporaryPath), abs(checkpointPath));
|
||||
}
|
||||
|
||||
function batchInfo(pointer) {
|
||||
if (!pointer?.task) return null;
|
||||
let manifest = {};
|
||||
try { manifest = JSON.parse(read(`tests/golden/${pointer.task}/manifest.json`)); }
|
||||
catch { /* The active batch may not have produced a manifest yet. */ }
|
||||
let card = "";
|
||||
try { card = read(`docs/tasks/${pointer.task}.md`); }
|
||||
catch { /* Readiness reports the missing card separately. */ }
|
||||
const batchId = manifest.batchId ?? card.match(/^- batchId: `?([^`\n]+)`?/mu)?.[1] ?? null;
|
||||
let selectedRecordIds = Array.isArray(manifest.selectedRecordIds) ? manifest.selectedRecordIds : [];
|
||||
if (selectedRecordIds.length === 0) {
|
||||
try {
|
||||
const selected = card.match(/^- selectedRecordIds:\s*(\[[^\n]+\])/mu)?.[1];
|
||||
selectedRecordIds = selected ? JSON.parse(selected) : [];
|
||||
} catch { selectedRecordIds = []; }
|
||||
}
|
||||
return {
|
||||
task: pointer.task,
|
||||
batchId,
|
||||
selectedRecordIds,
|
||||
completed: manifest.status === "done" && manifest.nextTask === null,
|
||||
};
|
||||
}
|
||||
|
||||
function planNextBatch(pointer) {
|
||||
const result = spawnSync("python3", [abs("my-tools/authorize_next_wbv2_task.py"), "--write"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
||||
if (output) appendLog(`planner task=${pointer?.task ?? "NONE"} exit=${result.status ?? 1}\n${output}`);
|
||||
if ((result.status ?? 1) !== 0 && /no configured unresolved Blender source batch remains/u.test(output)) return { terminal: true };
|
||||
if ((result.status ?? 1) !== 0) throw new Error(`next batch planner failed: ${output}`);
|
||||
return { terminal: false };
|
||||
}
|
||||
|
||||
function appendLog(message) {
|
||||
fs.appendFileSync(abs(logPath), `[${new Date().toISOString()}] ${message}\n`);
|
||||
}
|
||||
@@ -91,10 +133,10 @@ function refreshContinuation(task) {
|
||||
if (!fs.existsSync(continuationPath)) return false;
|
||||
const pointer = currentTask();
|
||||
const index = JSON.parse(read("tests/golden/WBV2/task-index.json"));
|
||||
const plan = JSON.parse(read(index.source.path));
|
||||
const taskIndex = plan.tasks.findIndex((candidate) => candidate.id === task);
|
||||
const terminal = terminalTask(task);
|
||||
const nextTask = terminal ? null : plan.tasks[taskIndex + 1]?.id ?? null;
|
||||
// The compact task index already contains the direct successor. Reading the
|
||||
// full plan here adds no evidence and used to dominate short handoff cycles.
|
||||
const nextTask = terminal ? null : index.entries?.[task]?.next ?? null;
|
||||
const inputFiles = ["docs/EXECUTION_QUEUE.md", `docs/tasks/${task}.md`, pointer.parentManifest, `tests/golden/${task}/task-context.json`];
|
||||
const fingerprint = crypto.createHash("sha256").update(inputFiles.map((file) => `${file}\0${crypto.createHash("sha256").update(fs.readFileSync(abs(file))).digest("hex")}\n`).join("")).digest("hex");
|
||||
try {
|
||||
@@ -246,7 +288,9 @@ let iteration = 0;
|
||||
let completed = false;
|
||||
try {
|
||||
appendLog(`started pid=${process.pid} maxIterations=${maxIterations} commit=${commitEnabled} push=${pushEnabled}`);
|
||||
writeCheckpoint({ state: "started", iteration, current: currentTask(), maxIterations, commit: commitEnabled, push: pushEnabled });
|
||||
const initial = currentTask();
|
||||
latestBatch = batchInfo(initial);
|
||||
writeCheckpoint({ state: "started", iteration, current: initial, maxIterations, commit: commitEnabled, push: pushEnabled });
|
||||
while (maxIterations === 0 || iteration < maxIterations) {
|
||||
if (stopRequested) {
|
||||
writeCheckpoint({ state: "stopped", iteration, current: currentTask(), reason: stopSignal });
|
||||
@@ -254,6 +298,7 @@ try {
|
||||
break;
|
||||
}
|
||||
const before = currentTask();
|
||||
latestBatch = batchInfo(before);
|
||||
if (!before) {
|
||||
writeCheckpoint({ state: "complete", iteration, current: null, reason: "queue_closed" });
|
||||
appendLog(`complete queue_closed iteration=${iteration}`);
|
||||
@@ -266,14 +311,24 @@ try {
|
||||
try {
|
||||
const index = JSON.parse(read("tests/golden/WBV2/task-index.json"));
|
||||
if (index.activeTask === null && index.entries?.[before.task]) {
|
||||
writeCheckpoint({ state: "complete", iteration, current: before, reason: "v2_plan_terminal" });
|
||||
const completedBatchInfo = { ...batchInfo(before), completed: true };
|
||||
latestBatch = completedBatchInfo;
|
||||
const planned = preflightOnly ? { terminal: true } : planNextBatch(before);
|
||||
if (!planned.terminal) {
|
||||
const afterPlan = currentTask();
|
||||
latestBatch = batchInfo(afterPlan);
|
||||
writeCheckpoint({ state: "planned_next_batch", iteration, current: afterPlan, before, reason: "completed_batch_planned" });
|
||||
appendLog(`planned next batch before=${before.task} after=${afterPlan.task}`);
|
||||
continue;
|
||||
}
|
||||
writeCheckpoint({ state: "complete", iteration, current: before, reason: "v2_plan_terminal", latestBatch: completedBatchInfo });
|
||||
appendLog(`complete v2_plan_terminal task=${before.task} iteration=${iteration}`);
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason: "terminal_plan_check_failed", detail: error?.message ?? String(error) });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason: "terminal_plan_check_failed", detail: error?.message ?? String(error) });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, reason: "terminal_plan_check_failed", error: error?.message ?? String(error) });
|
||||
appendLog(`blocked task=${before.task} reason=terminal_plan_check_failed error=${error?.message ?? String(error)}`);
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=terminal_plan_check_failed\n`);
|
||||
@@ -290,7 +345,7 @@ try {
|
||||
writeCheckpoint({ state: "preflight", iteration, before, readiness });
|
||||
appendLog(`preflight task=${before.task} class=${readiness.taskClass ?? "UNKNOWN"} ok=${readiness.ok} reason=${readiness.reason ?? "ready"}`);
|
||||
if (!readiness.ok) {
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason: readiness.reason });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason: readiness.reason });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, readiness });
|
||||
appendLog(`blocked task=${before.task} reason=${readiness.reason}`);
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=${readiness.reason}\n`);
|
||||
@@ -306,7 +361,7 @@ try {
|
||||
refreshTaskContext(before.task);
|
||||
}
|
||||
catch (error) {
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason: "context_rotation_failed", detail: error?.message ?? String(error) });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason: "context_rotation_failed", detail: error?.message ?? String(error) });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, reason: "context_rotation_failed", error: error?.message ?? String(error) });
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=context_rotation_failed\n`);
|
||||
exitCode = 2;
|
||||
@@ -315,6 +370,7 @@ try {
|
||||
writeCheckpoint({ state: "running", iteration: iteration + 1, before, readiness });
|
||||
const child = runOne(before);
|
||||
const after = currentTask();
|
||||
latestBatch = batchInfo(after);
|
||||
appendLog(`child task=${before.task} exit=${child.code} signal=${child.signal ?? "none"} after=${after?.task ?? "NONE"} log=${child.outputPath}`);
|
||||
if (stopRequested) {
|
||||
writeCheckpoint({ state: "stopped", iteration, before, after, reason: stopSignal, childExit: child.code });
|
||||
@@ -323,7 +379,7 @@ try {
|
||||
}
|
||||
if (child.code !== 0) {
|
||||
const reason = after?.task !== before.task ? "child_failed_after_queue_advance" : "child_failed_same_task";
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason, childExit: child.code });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason, childExit: child.code });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, after, reason, childExit: child.code, childLog: child.outputPath });
|
||||
appendLog(`blocked task=${before.task} reason=${reason}`);
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=${reason} childExit=${child.code}\n`);
|
||||
@@ -335,7 +391,7 @@ try {
|
||||
if (refreshContinuation(after?.task ?? before.task) && commitEnabled) commitContinuation();
|
||||
}
|
||||
catch (error) {
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason: "continuation_rotation_failed", detail: error?.message ?? String(error) });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason: "continuation_rotation_failed", detail: error?.message ?? String(error) });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, after, reason: "continuation_rotation_failed", error: error?.message ?? String(error) });
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=continuation_rotation_failed\n`);
|
||||
exitCode = 5;
|
||||
@@ -348,7 +404,7 @@ try {
|
||||
break;
|
||||
}
|
||||
if (!after || after.task === before.task) {
|
||||
recordFailure(root, { task: before.task, phase: "scheduler", reason: "child_exited_without_queue_advance", childExit: child.code });
|
||||
failureLedger.record({ task: before.task, phase: "scheduler", reason: "child_exited_without_queue_advance", childExit: child.code });
|
||||
writeCheckpoint({ state: "blocked", iteration, before, after, reason: "child_exited_without_queue_advance", childExit: child.code, childLog: child.outputPath });
|
||||
appendLog(`blocked task=${before.task} reason=child_exited_without_queue_advance`);
|
||||
process.stderr.write(`scheduler-blocked task=${before.task} reason=child_exited_without_queue_advance\n`);
|
||||
@@ -372,12 +428,13 @@ try {
|
||||
}
|
||||
catch (error) {
|
||||
exitCode = 5;
|
||||
try { recordFailure(root, { task: currentTask()?.task ?? "UNKNOWN", phase: "scheduler", reason: "scheduler_error", detail: error?.message ?? String(error) }); }
|
||||
try { failureLedger.record({ task: currentTask()?.task ?? "UNKNOWN", phase: "scheduler", reason: "scheduler_error", detail: error?.message ?? String(error) }); }
|
||||
catch { /* Preserve the original scheduler failure. */ }
|
||||
writeCheckpoint({ state: "blocked", iteration, reason: "scheduler_error", error: error?.message ?? String(error) });
|
||||
appendLog(`blocked reason=scheduler_error error=${error?.message ?? String(error)}`);
|
||||
}
|
||||
finally {
|
||||
failureLedger.flush();
|
||||
releaseLock();
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { recordFailure } from "./wbv2-failure-ledger.mjs";
|
||||
import { createFailureLedgerSession } from "./wbv2-failure-ledger.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const abs = (file) => path.join(root, file);
|
||||
@@ -10,6 +10,7 @@ const supervisorRoot = "tests/golden/WBV2";
|
||||
const supervisorLog = `${supervisorRoot}/continuous-supervisor.log`;
|
||||
const checkpointPath = `${supervisorRoot}/continuous-supervisor.json`;
|
||||
const lockPath = `${supervisorRoot}/continuous-supervisor.lock`;
|
||||
const failureLedger = createFailureLedgerSession(root, { logPaths: [`${supervisorRoot}/continuous-supervisor.log`] });
|
||||
|
||||
const valueArg = (name, fallback) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
@@ -158,7 +159,7 @@ try {
|
||||
}
|
||||
if (child.code !== 0) {
|
||||
const reason = after.task === before.task ? "child_failed_same_task" : "child_failed_after_queue_advance";
|
||||
recordFailure(root, { task: before.task, phase: "supervisor", reason, childExit: child.code });
|
||||
failureLedger.record({ task: before.task, phase: "supervisor", reason, childExit: child.code });
|
||||
writeCheckpoint({ state: "blocked", iteration: iteration + 1, before, after, reason, childExit: child.code });
|
||||
exitCode = 3;
|
||||
break;
|
||||
@@ -170,7 +171,7 @@ try {
|
||||
break;
|
||||
}
|
||||
if (after.task === before.task) {
|
||||
recordFailure(root, { task: before.task, phase: "supervisor", reason: "child_exited_without_queue_advance", childExit: child.code });
|
||||
failureLedger.record({ task: before.task, phase: "supervisor", reason: "child_exited_without_queue_advance", childExit: child.code });
|
||||
writeCheckpoint({ state: "blocked", iteration: iteration + 1, before, after, reason: "child_exited_without_queue_advance", childExit: child.code });
|
||||
exitCode = 4;
|
||||
break;
|
||||
@@ -193,6 +194,7 @@ try {
|
||||
}
|
||||
}
|
||||
finally {
|
||||
failureLedger.flush();
|
||||
releaseLock();
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
|
||||
@@ -22,6 +22,19 @@ const json = (file) => JSON.parse(read(file));
|
||||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||||
const tokenEstimate = (value) => Math.ceil(Buffer.byteLength(value, "utf8") / 4);
|
||||
|
||||
function inputMetadata(file, includeHash = true) {
|
||||
const relativePath = relative(file);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(file, { bigint: true });
|
||||
} catch (error) {
|
||||
return { path: relativePath, exists: false, bytes: null, mtime_ns: null, sha256: null, error: error?.code ?? "STAT_FAILED" };
|
||||
}
|
||||
const metadata = { path: relativePath, exists: true, bytes: Number(stat.size), mtime_ns: stat.mtimeNs.toString() };
|
||||
if (includeHash) metadata.sha256 = sha256File(file);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const queuePath = path.join(root, "docs/EXECUTION_QUEUE.md");
|
||||
const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json");
|
||||
const legacyTaskIndexPath = path.join(root, "tests/golden/M15-03A/task-index.json");
|
||||
@@ -367,6 +380,13 @@ export function buildTaskContext(requestedTask) {
|
||||
const ownOutputDir = path.join(root, "tests/golden", task);
|
||||
const generatedPaths = [relative(ownOutputDir), relative(ownManifestPath), relative(ownStatusPath)];
|
||||
const selectedInputs = selectInputPaths(candidatePaths, { sourceBytes, sourceTokens, reservedPaths, generatedPaths });
|
||||
const metadataPaths = [...new Set([
|
||||
relative(queuePath),
|
||||
relative(taskPath),
|
||||
relative(parentManifestPath),
|
||||
...(fs.existsSync(parentStatusPath) ? [relative(parentStatusPath)] : []),
|
||||
...selectedInputs.inputPaths,
|
||||
])].map((candidate) => path.join(root, candidate));
|
||||
const nextTask = ownManifest?.nextTask ?? nextFromPlan(task) ?? null;
|
||||
const context = {
|
||||
schemaVersion: 1,
|
||||
@@ -381,6 +401,7 @@ export function buildTaskContext(requestedTask) {
|
||||
},
|
||||
commands,
|
||||
inputPaths: selectedInputs.inputPaths,
|
||||
inputMetadata: Object.fromEntries(metadataPaths.map((file) => [relative(file), inputMetadata(file)])),
|
||||
inputSelection: selectedInputs.inputSelection,
|
||||
exitCriteria: entry?.exitCriteria ?? ["focused command exits 0", "report and manifest are hash-bound", "failure path does not publish a late result"],
|
||||
nextTask,
|
||||
|
||||
60
tools/web/wbv2-batch-contract.mjs
Normal file
60
tools/web/wbv2-batch-contract.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export const COMMAND_BUDGET = Object.freeze({
|
||||
maxCommands: 48,
|
||||
maxReadCommands: 16,
|
||||
});
|
||||
|
||||
/** Return the explicit source/RNA record IDs selected for one module batch. */
|
||||
export function selectedRecordIds(module = {}) {
|
||||
const values = module.selectedRecordIds ?? module.coverageBindings ?? module.sourceUnits ?? [];
|
||||
return [...new Set((Array.isArray(values) ? values : []).filter((value) => typeof value === "string" && value.length > 0))];
|
||||
}
|
||||
|
||||
/** A batch identity is stable across retries and changes when selection changes. */
|
||||
export function batchIdFor(task, module = {}) {
|
||||
const ids = selectedRecordIds(module);
|
||||
const moduleId = module.moduleId ?? "UNKNOWN_MODULE";
|
||||
const digest = crypto.createHash("sha256").update(JSON.stringify(ids)).digest("hex").slice(0, 16);
|
||||
return module.batchId ?? `${task}:${moduleId}:${digest}`;
|
||||
}
|
||||
|
||||
export function batchContract(task, module = {}) {
|
||||
const selected = selectedRecordIds(module);
|
||||
return {
|
||||
batchId: batchIdFor(task, module),
|
||||
selectedRecordIds: selected,
|
||||
selectedCount: selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function completedBatch(manifest, task, module = {}) {
|
||||
const expected = batchContract(task, module);
|
||||
return manifest?.status === "done"
|
||||
&& manifest.batchId === expected.batchId
|
||||
&& JSON.stringify(manifest.selectedRecordIds ?? []) === JSON.stringify(expected.selectedRecordIds);
|
||||
}
|
||||
|
||||
export function commandBudget(commands = []) {
|
||||
const values = Array.isArray(commands) ? commands.filter((value) => typeof value === "string") : [];
|
||||
const readCommands = values.filter((value) => /\b(?:cat|head|tail|sed|rg|find|ls|stat)\b/u.test(value));
|
||||
return {
|
||||
maxCommands: COMMAND_BUDGET.maxCommands,
|
||||
maxReadCommands: COMMAND_BUDGET.maxReadCommands,
|
||||
commandCount: values.length,
|
||||
readCommandCount: readCommands.length,
|
||||
withinBudget: values.length <= COMMAND_BUDGET.maxCommands && readCommands.length <= COMMAND_BUDGET.maxReadCommands,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptiveBatchSize({ currentSize, history = [], minSize = 1, maxSize = currentSize, remaining = Number.MAX_SAFE_INTEGER }) {
|
||||
const size = Math.max(minSize, Math.min(maxSize, currentSize));
|
||||
const recent = history.filter((item) => Number.isFinite(item.durationMs) && Number.isFinite(item.commands)).slice(-3);
|
||||
let next = size;
|
||||
if (recent.some((item) => item.timeout === true || item.durationMs > 900_000 || item.commands > COMMAND_BUDGET.maxCommands)) {
|
||||
next = Math.floor(size / 2);
|
||||
} else if (recent.length > 0 && recent.every((item) => item.durationMs < 300_000 && item.commands < 24 && item.focusedGateFailure !== true)) {
|
||||
next = size + 4;
|
||||
}
|
||||
return Math.max(minSize, Math.min(maxSize, remaining, next));
|
||||
}
|
||||
@@ -5,7 +5,87 @@ import path from "node:path";
|
||||
const ledgerPath = (root) => path.join(root, "tests/golden/WBV2/failure-ledger.json");
|
||||
const keyFor = (entry) => crypto.createHash("sha256").update(JSON.stringify({ task: entry.task, phase: entry.phase, reason: entry.reason, childExit: entry.childExit ?? null })).digest("hex");
|
||||
|
||||
const readLedger = (root) => {
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(ledgerPath(root), "utf8"));
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
entries: Array.isArray(value.entries) ? value.entries : [],
|
||||
hydratedLogs: Array.isArray(value.hydratedLogs) ? value.hydratedLogs : [],
|
||||
};
|
||||
}
|
||||
catch {
|
||||
return { schemaVersion: 2, entries: [], hydratedLogs: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export function createFailureLedgerSession(root, { logPaths = [] } = {}) {
|
||||
const ledger = readLedger(root);
|
||||
const existing = new Set(ledger.entries.map((entry) => entry.key).filter(Boolean));
|
||||
const hydrated = new Set(ledger.hydratedLogs);
|
||||
let dirty = false;
|
||||
|
||||
// Recovery is deliberately narrow: only explicit cycle.completed events can
|
||||
// add entries, and each log is imported at most once.
|
||||
for (const relative of logPaths) {
|
||||
if (hydrated.has(relative)) continue;
|
||||
const file = path.join(root, relative);
|
||||
if (!fs.existsSync(file)) continue;
|
||||
let source;
|
||||
try { source = fs.readFileSync(file, "utf8"); }
|
||||
catch { continue; }
|
||||
if (!/cycle\.completed/u.test(source)) continue;
|
||||
for (const line of source.split("\n")) {
|
||||
if (!/cycle\.completed/u.test(line)) continue;
|
||||
let event;
|
||||
try {
|
||||
const start = line.indexOf("{");
|
||||
event = JSON.parse(start >= 0 ? line.slice(start) : line);
|
||||
}
|
||||
catch { event = null; }
|
||||
const entry = event?.failure ?? event?.entry;
|
||||
if (!entry || typeof entry !== "object" || !entry.task || !entry.phase || !entry.reason) continue;
|
||||
const key = keyFor(entry);
|
||||
if (existing.has(key)) continue;
|
||||
ledger.entries.push({ ...entry, key, recordedAt: event.recordedAt ?? new Date().toISOString(), hydratedFrom: relative });
|
||||
existing.add(key);
|
||||
dirty = true;
|
||||
}
|
||||
hydrated.add(relative);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
const record = (entry) => {
|
||||
const key = keyFor(entry);
|
||||
if (existing.has(key)) return false;
|
||||
ledger.entries.push({ ...entry, key, recordedAt: new Date().toISOString() });
|
||||
ledger.entries = ledger.entries.slice(-200);
|
||||
existing.add(key);
|
||||
dirty = true;
|
||||
return true;
|
||||
};
|
||||
const flush = () => {
|
||||
if (!dirty) return false;
|
||||
const file = ledgerPath(root);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify({ ...ledger, hydratedLogs: [...hydrated] }, null, 2)}\n`);
|
||||
fs.renameSync(temporary, file);
|
||||
dirty = false;
|
||||
return true;
|
||||
};
|
||||
return { record, flush, ledger };
|
||||
}
|
||||
|
||||
export function recordFailure(root, entry) {
|
||||
const session = createFailureLedgerSession(root);
|
||||
const recorded = session.record(entry);
|
||||
session.flush();
|
||||
return recorded;
|
||||
}
|
||||
|
||||
/* Legacy callers should migrate to createFailureLedgerSession so a cycle only writes once. */
|
||||
export function _legacyRecordFailure(root, entry) {
|
||||
const file = ledgerPath(root);
|
||||
let ledger = { schemaVersion: 1, entries: [] };
|
||||
try { ledger = JSON.parse(fs.readFileSync(file, "utf8")); }
|
||||
|
||||
Reference in New Issue
Block a user