diff --git a/my-tools/README.md b/my-tools/README.md index c21004a6d..860c521b2 100644 --- a/my-tools/README.md +++ b/my-tools/README.md @@ -13,6 +13,8 @@ commit and push. a missing scheduler and stops on a terminal or blocked checkpoint. - `show_blender_web_scheduler.py`: delegates live RUNNING/IDLE/BLOCKED state to `tools/web/print-wbv2-live-status.mjs`. +- `validate_incremental_context.py`: validates only the active batch metadata; + unchanged inputs return `incremental-context-ok ... context=REUSED`. - `start_unattended.sh`: launches the scheduler and watchdog with `nohup` and `setsid` for process-level continuity. - `check_continuation_state.py` and `validate_web_context.py`: read-only WBV2 @@ -60,6 +62,12 @@ For machine-readable live state: python3 my-tools/show_blender_web_scheduler.py --json ``` +Implementation cycles use a bounded fast path: each module manifest carries a +stable `batchId` and selected record IDs, context metadata compares +`bytes`/`mtime_ns` before hashing, and the outer scheduler owns planner and +continuation rotation. Full API, format, Chromium, performance and release +gates remain an explicit final-audit operation. + `--commit --push` is enabled by the launcher and can be disabled for a local dry run with `WBV2_AUTO_COMMIT=0 WBV2_AUTO_PUSH=0`. diff --git a/my-tools/authorize_next_wbv2_task.py b/my-tools/authorize_next_wbv2_task.py index 0e1ec8e2d..e1dcf472e 100644 --- a/my-tools/authorize_next_wbv2_task.py +++ b/my-tools/authorize_next_wbv2_task.py @@ -168,6 +168,17 @@ PREFERRED_BATCHES = [ "production": "tests/golden/{task}/line-style-reader-production.json", "types": [], }, + { + "moduleId": "FCURVE_MODIFIER_SUBTYPE_READER_BATCH", + "title": "verify FCurve modifier subtype reader batch", + "owner": "ANIMATION", + "identifiers": [], + "rna": ["FModifierEnvelope", "FModifierGenerator", "FModifierLimits", "FModifierNoise", "FModifierSmooth", "FModifierStepped"], + "fixture": "tests/files/web/fcurve_modifier_subtype_batch_scene.blend", + "test": "web/tests/e2e/wbv2-fcurve-modifier-production.spec.ts", + "production": "tests/golden/{task}/fcurve-modifier-production.json", + "types": [], + }, { "moduleId": "MODIFIER_SOURCE_DEPENDENCY_REUSE", "title": "reuse verified modifier receipts for shared source utilities", @@ -260,6 +271,12 @@ def rel(path: Path) -> str: return path.relative_to(ROOT).as_posix() +def batch_contract(task: str, batch: dict) -> dict: + selected = [unit["id"] for unit in batch.get("units", [])] + digest = hashlib.sha256(json.dumps(selected, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()[:16] + return {"batchId": batch.get("batchId") or f"{task}:{batch['moduleId']}:{digest}", "selectedRecordIds": selected} + + def run_node(*args: str) -> None: result = subprocess.run(["node", *args], cwd=ROOT, text=True, capture_output=True, check=False) output = result.stdout or result.stderr @@ -298,7 +315,7 @@ def snapshot(text: str, task: str, parent: str, next_task: str) -> str: return text -def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str) -> str: +def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str, contract: dict) -> str: fixture_paths = batch.get("fixtures") or ([batch["fixture"]] if batch.get("fixture") else []) fixture_text = ", ".join(f"`{fixture}`" for fixture in fixture_paths) if not fixture_text: @@ -312,6 +329,9 @@ def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str) - gap: capability:{batch['moduleId']} - ownerFamily: {batch['owner']} - targetImplementationClass: QUEUE_ONLY +- batchId: {contract['batchId']} +- selectedRecordIds: {json.dumps(contract['selectedRecordIds'], ensure_ascii=False, separators=(',', ':'))} +- commandBudget: max=48; read/exploration max=16 - Implementation entrypoint: `{entrypoint}` ## 目标 @@ -345,6 +365,9 @@ def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str) - gap: capability:{batch['moduleId']} - ownerFamily: {batch['owner']} - targetImplementationClass: LOCAL_EXACT +- batchId: {contract['batchId']} +- selectedRecordIds: {json.dumps(contract['selectedRecordIds'], ensure_ascii=False, separators=(',', ':'))} +- commandBudget: max=48; read/exploration max=16 - Implementation entrypoint: `{entrypoint}` ## 目标 @@ -388,6 +411,9 @@ def make_card(task: str, parent: str, status: str, batch: dict, entrypoint: str) - gap: capability:{batch['moduleId']} - ownerFamily: {batch['owner']} - targetImplementationClass: LOCAL_EXACT +- batchId: {contract['batchId']} +- selectedRecordIds: {json.dumps(contract['selectedRecordIds'], ensure_ascii=False, separators=(',', ':'))} +- commandBudget: max=48; read/exploration max=16 - Implementation entrypoint: `{entrypoint}` ## 目标 @@ -435,7 +461,33 @@ def select_batch(inventory: dict) -> dict: if all(selected): unresolved = [unit for unit in selected if unit.get("coverageStatus") != "VERIFIED"] if unresolved: - return {**candidate, "units": unresolved, "candidateUnits": selected} + key = f"{candidate['moduleId']}:LOCAL_EXACT:{candidate['owner']}" + history = [] + for receipt_path in sorted(ROOT.glob("tests/golden/WBV2-P*-001/module-receipt.json"), key=lambda item: item.parent.name): + receipt = read_json(receipt_path) + if receipt.get("moduleId") != candidate["moduleId"]: + continue + duration = receipt.get("efficiency", {}).get("totalDurationMs") + if not isinstance(duration, (int, float)): + duration = sum(item.get("durationMs", 0) for item in receipt.get("commands", []) if isinstance(item, dict)) + history.append({ + "durationMs": duration, + "commands": len(receipt.get("commands", [])), + "timeout": any("timeout" in str(item.get("output", "")).lower() for item in receipt.get("commands", []) if isinstance(item, dict)), + "focusedGateFailure": receipt.get("status") != "PASS", + }) + recent = history[-3:] + min_size = 1 + max_size = 8 if len(unresolved) > 16 or len(candidate.get("fixtures", [])) > 1 else 16 + current_size = min(len(unresolved), max_size) + if recent and any(item["timeout"] or item["durationMs"] > 900_000 or item["commands"] > 48 for item in recent): + next_size = max(min_size, current_size // 2) + elif recent and all(item["durationMs"] < 300_000 and item["commands"] < 24 and not item["focusedGateFailure"] for item in recent): + next_size = min(max_size, current_size + 4) + else: + next_size = current_size + chosen = unresolved[:max(min_size, min(max_size, len(unresolved), next_size))] + return {**candidate, "units": chosen, "candidateUnits": selected, "adaptiveKey": key, "adaptiveHistory": recent, "adaptiveCurrentSize": current_size, "adaptiveNextSize": len(chosen), "adaptiveMinSize": min_size, "adaptiveMaxSize": max_size} raise SystemExit("no configured unresolved Blender source batch remains") @@ -457,8 +509,10 @@ def main() -> int: root_task, module_task = f"WBV2-P{phase}-000", f"WBV2-P{phase}-001" inventory = read_json(CONTROL_ROOT / "dynamic-inventory.json") batch = select_batch(inventory) + contract = batch_contract(module_task, batch) + batch["batchId"] = contract["batchId"] if not args.write: - print(json.dumps({"status": "ready", "rootTask": root_task, "nextTask": module_task, "moduleId": batch["moduleId"], "batchSize": len(batch["units"]), "sourceUnits": [unit["id"] for unit in batch["units"]]}, ensure_ascii=False)) + print(json.dumps({"status": "ready", "rootTask": root_task, "nextTask": module_task, "moduleId": batch["moduleId"], "batchId": contract["batchId"], "batchSize": len(batch["units"]), "selectedRecordIds": contract["selectedRecordIds"], "sourceUnits": [unit["id"] for unit in batch["units"]], "adaptive": {"key": batch.get("adaptiveKey"), "history": batch.get("adaptiveHistory", []), "nextSize": batch.get("adaptiveNextSize", len(batch["units"]))}}, ensure_ascii=False)) return 0 current_status = ROOT / "docs/status" / f"{current}.md" @@ -497,19 +551,20 @@ def main() -> int: f"WBV2_BATCH_TASK={module_task} {production_env} {fixture_env} WBV2_BATCH_TYPES='{batch_types}' WBV2_BATCH_UNITS='{batch_units}' WEB_TEST_PORT=5371 npm --prefix web run test:e2e -- --workers=1 tests/e2e/{Path(batch['test']).name}", ] root_record = {"id": root_task, "phase": str(phase), "title": "authorize_modifier_reader_batch", "kind": "enabling", "owner": "PROGRAM", "dependsOn": [current], "parityId": None, "implementationClass": "QUEUE_ONLY", "parallelGroup": f"module-root-{phase}", "gate": "MODULE", "card": f"docs/tasks/{root_task}.md", "behavior": "Authorize one shared source-backed modifier reader batch.", "outputs": ["successor authorization", "module plan", "manifest", "status"], "acceptance": ["prior evidence remains hash-bound", "batch source mapping is explicit", "inventory remains source-derived"], "commands": ["node tools/web/check-execution-control-plane.mjs"], "status": "done", "ready": False, "queueMutationAllowed": True} - module_record = {"id": module_task, "phase": str(phase), "title": batch["title"], "kind": "module", "owner": batch["owner"], "dependsOn": [root_task], "parityId": batch["moduleId"], "implementationClass": "LOCAL_EXACT", "parallelGroup": f"module-{batch['moduleId'].lower()}", "gate": "MODULE", "card": f"docs/tasks/{module_task}.md", "behavior": f"Verify the {batch['moduleId']} batch through Blender Main, WASM and Chromium recovery.", "outputs": ["aggregate module receipt", "coverage bindings", "manifest", "status"], "acceptance": ["source and protocol hashes are bound", "every selected Main reader type is proven", "negative no-mutation and recovery are proven", "Chromium and save/reopen evidence are exact"], "status": "in_progress", "ready": True, "queueMutationAllowed": True, "module": {"moduleId": batch["moduleId"], "sourceBacked": True, "capabilities": [f"CAP-{batch['moduleId']}-READER", f"CAP-{batch['moduleId']}-RECOVERY", f"CAP-{batch['moduleId']}-FAIL-CLOSED"], "sourcePaths": source_paths, "reuseReceipts": batch.get("reuseReceipts", [{"manifest": "tests/golden/WBV2-P1107-000/manifest.json", "report": "tests/golden/WBV2-P1107-000/module-receipt.json"}]), "dependencyReuse": batch.get("dependencyReuse", []), "positive": [f"{name} type is read from Blender Main" for name in batch["identifiers"]] + ["shared RNA metadata is preserved", "save/reopen preserves the batch metadata"], "negative": ["malformed blend is rejected without Main mutation", "unsupported batch data fails closed", "non-finite metadata is not synthesized"], "recovery": ["save/reopen", "worker termination", "deterministic second read"], "commands": commands, "fullFamilyParity": False, "evidencePaths": [batch["production"].format(task=module_task)], "coverageBindings": [unit["id"] for unit in batch["units"]]}} + module_record = {"id": module_task, "phase": str(phase), "title": batch["title"], "kind": "module", "owner": batch["owner"], "dependsOn": [root_task], "parityId": batch["moduleId"], "implementationClass": "LOCAL_EXACT", "parallelGroup": f"module-{batch['moduleId'].lower()}", "gate": "MODULE", "card": f"docs/tasks/{module_task}.md", "behavior": f"Verify the {batch['moduleId']} batch through Blender Main, WASM and Chromium recovery.", "outputs": ["aggregate module receipt", "coverage bindings", "manifest", "status"], "acceptance": ["source and protocol hashes are bound", "every selected Main reader type is proven", "negative no-mutation and recovery are proven", "Chromium and save/reopen evidence are exact"], "status": "in_progress", "ready": True, "queueMutationAllowed": True, "batchId": contract["batchId"], "selectedRecordIds": contract["selectedRecordIds"], "module": {"moduleId": batch["moduleId"], "batchId": contract["batchId"], "selectedRecordIds": contract["selectedRecordIds"], "sourceBacked": True, "capabilities": [f"CAP-{batch['moduleId']}-READER", f"CAP-{batch['moduleId']}-RECOVERY", f"CAP-{batch['moduleId']}-FAIL-CLOSED"], "sourcePaths": source_paths, "reuseReceipts": batch.get("reuseReceipts", [{"manifest": "tests/golden/WBV2-P1107-000/manifest.json", "report": "tests/golden/WBV2-P1107-000/module-receipt.json"}]), "dependencyReuse": batch.get("dependencyReuse", []), "positive": [f"{name} type is read from Blender Main" for name in batch["identifiers"]] + ["shared RNA metadata is preserved", "save/reopen preserves the batch metadata"], "negative": ["malformed blend is rejected without Main mutation", "unsupported batch data fails closed", "non-finite metadata is not synthesized"], "recovery": ["save/reopen", "worker termination", "deterministic second read"], "commands": commands, "fullFamilyParity": False, "evidencePaths": [batch["production"].format(task=module_task)], "coverageBindings": contract["selectedRecordIds"]}} + module_record["module"]["commandBudget"] = {"maxCommands": 48, "maxReadCommands": 16} root_out = CONTROL_ROOT.parent / root_task root_plan_path = root_out / "module-execution-plan.json" - root_plan = {**plan, "planId": f"BLENDER_WEB_MODULE_EXECUTION_P{phase}", "status": "ACTIVE_MODULE_QUEUE", "tasks": [*plan["tasks"], root_record, module_record], "modules": [*plan.get("modules", []), {"id": batch["moduleId"], "task": module_task, "owner": batch["owner"], "sourceBacked": True, "capabilities": module_record["module"]["capabilities"], "sourcePaths": source_paths, "reuseReceipts": module_record["module"]["reuseReceipts"], "fullFamilyParity": False}]} + root_plan = {**plan, "planId": f"BLENDER_WEB_MODULE_EXECUTION_P{phase}", "status": "ACTIVE_MODULE_QUEUE", "tasks": [*plan["tasks"], root_record, module_record], "modules": [*plan.get("modules", []), {"id": batch["moduleId"], "task": module_task, "owner": batch["owner"], "sourceBacked": True, "batchId": contract["batchId"], "selectedRecordIds": contract["selectedRecordIds"], "capabilities": module_record["module"]["capabilities"], "sourcePaths": source_paths, "reuseReceipts": module_record["module"]["reuseReceipts"], "fullFamilyParity": False}]} write_json(root_plan_path, root_plan) root_card_path, module_card_path = ROOT / "docs/tasks" / f"{root_task}.md", ROOT / "docs/tasks" / f"{module_task}.md" - write_text(root_card_path, make_card(root_task, current, "done", batch, f"python3 my-tools/authorize_next_wbv2_task.py --write")) - write_text(module_card_path, make_card(module_task, root_task, "in_progress", batch, f"node tools/web/run-wbv2-module-task.mjs --task {module_task} --write")) + write_text(root_card_path, make_card(root_task, current, "done", batch, f"python3 my-tools/authorize_next_wbv2_task.py --write", contract)) + write_text(module_card_path, make_card(module_task, root_task, "in_progress", batch, f"node tools/web/run-wbv2-module-task.mjs --task {module_task} --write", contract)) root_report_path, root_status_path, root_manifest_path = root_out / "successor-authorization-report.json", ROOT / "docs/status" / f"{root_task}.md", root_out / "manifest.json" - write_json(root_report_path, {"schemaVersion": 1, "task": root_task, "operation": "AUTHORIZE_SOURCE_BACKED_MODULE_BATCH", "taskClass": "QUEUE_ENABLEMENT", "status": "PASS", "parent": {"task": current, "manifest": rel(current_manifest_path), "manifestSha256": sha(current_manifest_path)}, "module": {"moduleId": batch["moduleId"], "task": module_task, "batchSize": len(batch["units"]), "candidateBatchSize": len(batch.get("candidateUnits", batch["units"])), "sourceUnits": [unit["id"] for unit in batch["units"]], "sourceBacked": True}, "inventory": {"unitCount": inventory["summary"]["unitCount"], "verifiedUnits": inventory["coverage"]["verifiedUnits"], "unverifiedUnits": inventory["coverage"]["unverifiedUnits"], "sourceTreeSha256": inventory["authority"]["sourceTreeSha256"], "boundUnitDelta": 0}, "efficiency": {"existingBindingsReused": len(batch.get("candidateUnits", batch["units"])) - len(batch["units"]), "selectionPolicy": "all-candidate-units-then-unresolved-only"}, "nextTask": module_task}) + write_json(root_report_path, {"schemaVersion": 1, "task": root_task, "operation": "AUTHORIZE_SOURCE_BACKED_MODULE_BATCH", "taskClass": "QUEUE_ENABLEMENT", "status": "PASS", "parent": {"task": current, "manifest": rel(current_manifest_path), "manifestSha256": sha(current_manifest_path)}, "batchId": contract["batchId"], "selectedRecordIds": contract["selectedRecordIds"], "module": {"moduleId": batch["moduleId"], "task": module_task, "batchSize": len(batch["units"]), "candidateBatchSize": len(batch.get("candidateUnits", batch["units"])), "sourceUnits": contract["selectedRecordIds"], "sourceBacked": True}, "inventory": {"unitCount": inventory["summary"]["unitCount"], "verifiedUnits": inventory["coverage"]["verifiedUnits"], "unverifiedUnits": inventory["coverage"]["unverifiedUnits"], "sourceTreeSha256": inventory["authority"]["sourceTreeSha256"], "boundUnitDelta": 0}, "efficiency": {"existingBindingsReused": len(batch.get("candidateUnits", batch["units"])) - len(batch["units"]), "selectionPolicy": "all-candidate-units-then-unresolved-only", "adaptiveKey": batch.get("adaptiveKey"), "adaptiveHistory": batch.get("adaptiveHistory", []), "adaptiveNextSize": batch.get("adaptiveNextSize", len(batch["units"]))}, "nextTask": module_task}) write_text(root_status_path, f"# {root_task} status\n\n- task: `{root_task}`; parent: `{current}`; status: `done`\n- taskClass: `QUEUE_ENABLEMENT`; no modifier-family parity completion claimed\n- source-backed batch authorized: `{batch['moduleId']}` ({len(batch['units'])} units)\n- dynamic inventory: `{inventory['summary']['unitCount']}` units; verified `{inventory['coverage']['verifiedUnits']}`; unverified `{inventory['coverage']['unverifiedUnits']}`; bound delta `0`\n- nextTask: `{module_task}`\n") - write_json(root_manifest_path, {"schemaVersion": 1, "task": root_task, "parentTask": current, "operation": "AUTHORIZE_SOURCE_BACKED_MODULE_BATCH", "status": "done", "enablingTask": True, "parityStateChange": False, "runtime": "MODULE_EXECUTION_QUEUE_BRIDGE", "artifacts": {"parentManifest": {"path": rel(current_manifest_path), "sha256": sha(current_manifest_path)}, "plan": {"path": rel(root_plan_path), "sha256": sha(root_plan_path)}, "report": {"path": rel(root_report_path), "sha256": sha(root_report_path)}, "status": {"path": rel(root_status_path), "sha256": sha(root_status_path)}}, "assertions": {"sourceBackedSuccessor": True, "moduleId": batch["moduleId"], "batchSize": len(batch["units"]), "inventoryTotal": 6900, "effectiveCompleted": 290, "effectivePending": 6609, "dynamicUnitCount": inventory["summary"]["unitCount"], "dynamicVerifiedUnits": inventory["coverage"]["verifiedUnits"], "dynamicUnverifiedUnits": inventory["coverage"]["unverifiedUnits"], "boundUnitDelta": 0, "fullHistoricalReproduction": False}, "nextTask": module_task}) + write_json(root_manifest_path, {"schemaVersion": 1, "task": root_task, "parentTask": current, "operation": "AUTHORIZE_SOURCE_BACKED_MODULE_BATCH", "status": "done", "enablingTask": True, "parityStateChange": False, "runtime": "MODULE_EXECUTION_QUEUE_BRIDGE", "batchId": contract["batchId"], "selectedRecordIds": contract["selectedRecordIds"], "artifacts": {"parentManifest": {"path": rel(current_manifest_path), "sha256": sha(current_manifest_path)}, "plan": {"path": rel(root_plan_path), "sha256": sha(root_plan_path)}, "report": {"path": rel(root_report_path), "sha256": sha(root_report_path)}, "status": {"path": rel(root_status_path), "sha256": sha(root_status_path)}}, "assertions": {"sourceBackedSuccessor": True, "moduleId": batch["moduleId"], "batchSize": len(batch["units"]), "inventoryTotal": 6900, "effectiveCompleted": 290, "effectivePending": 6609, "dynamicUnitCount": inventory["summary"]["unitCount"], "dynamicVerifiedUnits": inventory["coverage"]["verifiedUnits"], "dynamicUnverifiedUnits": inventory["coverage"]["unverifiedUnits"], "boundUnitDelta": 0, "fullHistoricalReproduction": False}, "nextTask": module_task}) run_node("tools/web/generate-v2-task-index.mjs", "--plan-path", rel(root_plan_path)) queue_text = QUEUE.read_text(encoding="utf-8") diff --git a/my-tools/show_blender_web_scheduler.py b/my-tools/show_blender_web_scheduler.py index ef9ef51cb..9d2b8671c 100755 --- a/my-tools/show_blender_web_scheduler.py +++ b/my-tools/show_blender_web_scheduler.py @@ -39,6 +39,7 @@ def main() -> int: "schedulerWrapperStatus": read_json(CONTROL_ROOT / "my-tools-scheduler.json"), } status["progress"] = project_progress() + status["fastPath"] = fast_path_status(status) if args.json: print(json.dumps(status, ensure_ascii=False, separators=(",", ":"))) if not args.watch: @@ -64,7 +65,7 @@ def main() -> int: print(f" supervisor: pid={supervisor.get('pid') or 'none'} alive={supervisor.get('alive', False)} state={supervisor.get('state') or 'unknown'}") print(f" latest handoff: {status.get('latestHandoff') or 'none'}") print(f" local HEAD: {status.get('localHead') or 'unknown'}") - for line in format_progress(status["progress"]): + for line in format_progress(status["progress"], status["fastPath"]): print(line) print(f" message: {status.get('message', '')}") if not args.watch: @@ -180,12 +181,42 @@ def project_progress() -> dict: } -def format_progress(progress: dict) -> list[str]: +def fast_path_status(status: dict) -> dict: + scheduler = status.get("scheduler", {}) if isinstance(status, dict) else {} + latest_batch = scheduler.get("latestBatch") if isinstance(scheduler.get("latestBatch"), dict) else {} + cache = _read_json(CONTROL_ROOT / "context-cache.json") + ledger = _read_json(CONTROL_ROOT / "failure-ledger.json") + return { + "batch": { + "batchId": latest_batch.get("batchId"), + "selectedRecordCount": len(latest_batch.get("selectedRecordIds", [])), + "completed": bool(latest_batch.get("completed")), + }, + "contextCache": { + "status": cache.get("status", "MISSING"), + "task": cache.get("task"), + "schemaVersion": cache.get("schemaVersion"), + "inputCount": len(cache.get("inputs", {})) if isinstance(cache.get("inputs"), dict) else 0, + }, + "failureLedger": { + "entryCount": len(ledger.get("entries", [])) if isinstance(ledger.get("entries"), list) else 0, + "hydratedLogs": len(ledger.get("hydratedLogs", [])) if isinstance(ledger.get("hydratedLogs"), list) else 0, + }, + "commandBudget": {"maxCommands": 48, "maxReadCommands": 16}, + "verificationPolicy": "incremental-context-only-until-final-audit", + } + + +def format_progress(progress: dict, fast_path: dict | None = None) -> list[str]: estimate = progress.get("estimatedRemainingHours") estimate_text = f"{estimate} h" if estimate is not None else "n/a" total_estimate = progress.get("estimatedTotalHours") total_text = f"{total_estimate} h" if total_estimate is not None else "n/a" plan = progress.get("taskPlan", {}) + fast_path = fast_path or {} + batch = fast_path.get("batch", {}) + context_cache = fast_path.get("contextCache", {}) + ledger = fast_path.get("failureLedger", {}) return [ "", "Project migration progress", @@ -197,6 +228,9 @@ def format_progress(progress: dict) -> list[str]: f" stage medians: {progress.get('stageMedianDurationMs') or 'n/a'} ms (1=build, 2=typecheck, 3=web build, 4=Chromium)", f" estimate: remaining={estimate_text}, total={total_text}", f" estimate basis: {progress.get('estimateBasis', 'n/a')}", + f" fast path: batch={batch.get('batchId') or 'none'} selected={batch.get('selectedRecordCount', 0)} completed={batch.get('completed', False)}", + f" context cache: {context_cache.get('status', 'MISSING')} task={context_cache.get('task') or 'none'} inputs={context_cache.get('inputCount', 0)}; failure ledger entries={ledger.get('entryCount', 0)} hydratedLogs={ledger.get('hydratedLogs', 0)}", + f" command budget: max={fast_path.get('commandBudget', {}).get('maxCommands', 48)}, read/exploration max={fast_path.get('commandBudget', {}).get('maxReadCommands', 16)}; verification={fast_path.get('verificationPolicy', 'n/a')}", ] diff --git a/my-tools/validate_incremental_context.py b/my-tools/validate_incremental_context.py new file mode 100644 index 000000000..7022ad44f --- /dev/null +++ b/my-tools/validate_incremental_context.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Validate only the active task's context metadata and focused pointer. + +The normal path compares stat metadata first. The task-context checker is +invoked only when an input is missing, changed, or lacks the required hash. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from pathlib import Path + +from wbv2_runtime import ROOT, queue_pointer + + +def stat_metadata(path: Path) -> tuple[int, str] | None: + try: + value = path.stat() + except OSError: + return None + return value.st_size, str(value.st_mtime_ns) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--task", help="task id; defaults to the authoritative queue pointer") + args = parser.parse_args() + task = args.task or queue_pointer().get("task") + if not task: + parser.error("queue has no current task") + context_path = ROOT / "tests/golden" / task / "task-context.json" + try: + context = json.loads(context_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + context = {} + metadata = context.get("inputMetadata") if isinstance(context, dict) else None + unchanged = isinstance(metadata, dict) and bool(metadata) + if unchanged: + for relative, expected in metadata.items(): + current = stat_metadata(ROOT / relative) + if ( + not isinstance(expected, dict) + or current is None + or current[0] != expected.get("bytes") + or current[1] != expected.get("mtime_ns") + or not isinstance(expected.get("sha256"), str) + ): + unchanged = False + break + if unchanged: + print(f"incremental-context-ok task={task} context=REUSED metadata={len(metadata)}") + return 0 + command = ["node", "tools/web/check-task-context.mjs", "--task", task, "--write"] + result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False) + output = result.stdout or result.stderr + if output: + print(output, end="" if output.endswith("\n") else "\n") + if result.returncode: + return result.returncode + print(f"incremental-context-ok task={task} context=REFRESHED") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/golden/WBV2-P1161-001/manifest.json b/tests/golden/WBV2-P1161-001/manifest.json index 9f5e9dbfa..68badd117 100644 --- a/tests/golden/WBV2-P1161-001/manifest.json +++ b/tests/golden/WBV2-P1161-001/manifest.json @@ -59,7 +59,7 @@ }, "moduleReceipt": { "path": "tests/golden/WBV2-P1161-001/module-receipt.json", - "sha256": "48d1c06e1f03206fa68bec9db86ecd67fbfd858776b48a82b9505dc6ccd03459" + "sha256": "6b1131b452c509f600536baa2e95f0d9ad30a1fb5951ae9e36b3f540c36326bf" }, "status": { "path": "docs/status/WBV2-P1161-001.md", @@ -75,7 +75,7 @@ }, "taskContext": { "path": "tests/golden/WBV2-P1161-001/task-context.json", - "sha256": "cb7a0b446bfc6c27a2040f42107162c99290927f3115eab029c2a456442b8ad3" + "sha256": "f98e1ee00beed21da1ef51d60caee5629e52c11a4f2d9bda78bb7b0cf52d7d41" } }, "assertions": { @@ -97,7 +97,33 @@ "historicalInventoryTotal": 6900, "historicalEffectiveCompleted": 290, "historicalEffectivePending": 6609, - "historicalReproduction": false + "historicalReproduction": false, + "batchId": "WBV2-P1161-001:FCURVE_MODIFIER_SUBTYPE_READER_BATCH:5eb990874b4e6015", + "selectedRecordIds": [ + "rna-struct:FModifierEnvelope", + "rna-struct:FModifierGenerator", + "rna-struct:FModifierLimits", + "rna-struct:FModifierNoise", + "rna-struct:FModifierSmooth", + "rna-struct:FModifierStepped" + ], + "commandBudget": { + "maxCommands": 48, + "maxReadCommands": 16, + "commandCount": 4, + "readCommandCount": 0, + "withinBudget": true + } }, - "nextTask": null + "nextTask": null, + "batchId": "WBV2-P1161-001:FCURVE_MODIFIER_SUBTYPE_READER_BATCH:5eb990874b4e6015", + "selectedRecordIds": [ + "rna-struct:FModifierEnvelope", + "rna-struct:FModifierGenerator", + "rna-struct:FModifierLimits", + "rna-struct:FModifierNoise", + "rna-struct:FModifierSmooth", + "rna-struct:FModifierStepped" + ], + "phase": "CONTINUE" } diff --git a/tests/golden/WBV2-P1161-001/module-receipt.json b/tests/golden/WBV2-P1161-001/module-receipt.json index db6981616..cb778d52d 100644 --- a/tests/golden/WBV2-P1161-001/module-receipt.json +++ b/tests/golden/WBV2-P1161-001/module-receipt.json @@ -123,5 +123,22 @@ "unverifiedUnits": 3222 } }, - "nextTask": null + "nextTask": null, + "phase": "CONTINUE", + "batchId": "WBV2-P1161-001:FCURVE_MODIFIER_SUBTYPE_READER_BATCH:5eb990874b4e6015", + "selectedRecordIds": [ + "rna-struct:FModifierEnvelope", + "rna-struct:FModifierGenerator", + "rna-struct:FModifierLimits", + "rna-struct:FModifierNoise", + "rna-struct:FModifierSmooth", + "rna-struct:FModifierStepped" + ], + "commandBudget": { + "maxCommands": 48, + "maxReadCommands": 16, + "commandCount": 4, + "readCommandCount": 0, + "withinBudget": true + } } diff --git a/tests/golden/WBV2-P1161-001/task-context.json b/tests/golden/WBV2-P1161-001/task-context.json index 69c565da5..a935b22e2 100644 --- a/tests/golden/WBV2-P1161-001/task-context.json +++ b/tests/golden/WBV2-P1161-001/task-context.json @@ -13,6 +13,36 @@ "node tools/web/check-task-context.mjs --task WBV2-P1161-001 --write" ], "inputPaths": [], + "inputMetadata": { + "docs/EXECUTION_QUEUE.md": { + "path": "docs/EXECUTION_QUEUE.md", + "exists": true, + "bytes": 4030, + "mtime_ns": "1788433617614059639", + "sha256": "80fb937bc99b92fa59013e4ef8777a84139509d06420ab8306142f1d4c1b8c96" + }, + "docs/tasks/WBV2-P1161-001.md": { + "path": "docs/tasks/WBV2-P1161-001.md", + "exists": true, + "bytes": 2201, + "mtime_ns": "1788433606139169929", + "sha256": "d2213ece2f0b5eeb99949ace5cd35ea2c18ba955b4b93d1a9bc5722f59d536fa" + }, + "tests/golden/WBV2-P1161-000/manifest.json": { + "path": "tests/golden/WBV2-P1161-000/manifest.json", + "exists": true, + "bytes": 1391, + "mtime_ns": "1788433606139647344", + "sha256": "cf1b5e152c183463773621096cf34fafd30c9a6f556b1daa7a7258c40d3e0392" + }, + "docs/status/WBV2-P1161-000.md": { + "path": "docs/status/WBV2-P1161-000.md", + "exists": true, + "bytes": 368, + "mtime_ns": "1788433606139326609", + "sha256": "85055f55c507388f8fba17b814bc7d23698bd6b901601699c663b63d867ed5f0" + } + }, "inputSelection": { "schemaVersion": 1, "limits": { @@ -54,8 +84,8 @@ }, { "path": "tests/golden/WBV2-P1161-001/module-receipt.json", - "bytes": null, - "reason": "MISSING_PATH" + "bytes": 5223, + "reason": "CONTEXT_REMAINING_SPACE" }, { "path": "tests/golden/WBV2-P1161-001/modifier-reader-batch-production.json", diff --git a/tools/web/check-wbv2-context-cache.mjs b/tools/web/check-wbv2-context-cache.mjs index 50a48dbdf..e49809876 100644 --- a/tools/web/check-wbv2-context-cache.mjs +++ b/tools/web/check-wbv2-context-cache.mjs @@ -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); diff --git a/tools/web/context-governance.mjs b/tools/web/context-governance.mjs index 81bfcd0e7..93b417afd 100644 --- a/tools/web/context-governance.mjs +++ b/tools/web/context-governance.mjs @@ -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)) { diff --git a/tools/web/print-wbv2-live-status.mjs b/tools/web/print-wbv2-live-status.mjs index 3bec293fa..9aa833248 100644 --- a/tools/web/print-wbv2-live-status.mjs +++ b/tools/web/print-wbv2-live-status.mjs @@ -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"}`, diff --git a/tools/web/run-wbv2-continuous.mjs b/tools/web/run-wbv2-continuous.mjs index fd2afc13d..9cb694d55 100644 --- a/tools/web/run-wbv2-continuous.mjs +++ b/tools/web/run-wbv2-continuous.mjs @@ -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 }); diff --git a/tools/web/run-wbv2-guardian.mjs b/tools/web/run-wbv2-guardian.mjs index 094c1d779..63a499232 100644 --- a/tools/web/run-wbv2-guardian.mjs +++ b/tools/web/run-wbv2-guardian.mjs @@ -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(); } diff --git a/tools/web/run-wbv2-module-task.mjs b/tools/web/run-wbv2-module-task.mjs index 67c30e5af..cf0b8c4a9 100644 --- a/tools/web/run-wbv2-module-task.mjs +++ b/tools/web/run-wbv2-module-task.mjs @@ -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" }); diff --git a/tools/web/run-wbv2-scheduler.mjs b/tools/web/run-wbv2-scheduler.mjs index 2c1bbf8c3..9f950fbcc 100644 --- a/tools/web/run-wbv2-scheduler.mjs +++ b/tools/web/run-wbv2-scheduler.mjs @@ -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; diff --git a/tools/web/run-wbv2-supervisor.mjs b/tools/web/run-wbv2-supervisor.mjs index 59fc7e864..84eff0b15 100644 --- a/tools/web/run-wbv2-supervisor.mjs +++ b/tools/web/run-wbv2-supervisor.mjs @@ -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; diff --git a/tools/web/task-context-lib.mjs b/tools/web/task-context-lib.mjs index 784c5f70d..926184e27 100644 --- a/tools/web/task-context-lib.mjs +++ b/tools/web/task-context-lib.mjs @@ -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, diff --git a/tools/web/wbv2-batch-contract.mjs b/tools/web/wbv2-batch-contract.mjs new file mode 100644 index 000000000..2a013d5cb --- /dev/null +++ b/tools/web/wbv2-batch-contract.mjs @@ -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)); +} diff --git a/tools/web/wbv2-failure-ledger.mjs b/tools/web/wbv2-failure-ledger.mjs index 8be17e6ac..e724dd72b 100644 --- a/tools/web/wbv2-failure-ledger.mjs +++ b/tools/web/wbv2-failure-ledger.mjs @@ -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")); } diff --git a/web/tests/unit/wbv2-fast-paths.test.mjs b/web/tests/unit/wbv2-fast-paths.test.mjs new file mode 100644 index 000000000..488416876 --- /dev/null +++ b/web/tests/unit/wbv2-fast-paths.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + adaptiveBatchSize, + batchContract, + commandBudget, + completedBatch, +} from "../../../tools/web/wbv2-batch-contract.mjs"; +import { createFailureLedgerSession } from "../../../tools/web/wbv2-failure-ledger.mjs"; + +test("batch contract is stable and completion is idempotent", () => { + const module = { moduleId: "FCURVE", coverageBindings: ["rna:a", "rna:b"] }; + const contract = batchContract("WBV2-P1-001", module); + assert.equal(contract.selectedCount, 2); + assert.match(contract.batchId, /^WBV2-P1-001:FCURVE:[a-f0-9]{16}$/u); + assert.equal(completedBatch({ status: "done", batchId: contract.batchId, selectedRecordIds: contract.selectedRecordIds }, "WBV2-P1-001", module), true); +}); + +test("adaptive batches follow the three-cycle fast and slow paths", () => { + assert.equal(adaptiveBatchSize({ currentSize: 24, maxSize: 32, history: [{ durationMs: 120_000, commands: 10, focusedGateFailure: false }] }), 28); + assert.equal(adaptiveBatchSize({ currentSize: 24, maxSize: 32, history: [{ durationMs: 901_000, commands: 10 }] }), 12); + assert.equal(adaptiveBatchSize({ currentSize: 24, maxSize: 32, remaining: 5, history: [] }), 5); +}); + +test("command budget separates exploration from execution", () => { + assert.equal(commandBudget(["cat file", "npm run build"]).withinBudget, true); + assert.equal(commandBudget(Array.from({ length: 49 }, () => "npm run focused")).withinBudget, false); + assert.equal(commandBudget(Array.from({ length: 17 }, () => "rg pattern")).withinBudget, false); +}); + +test("failure ledger hydrates completed cycles and flushes once", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "wbv2-fast-path-ledger-")); + const logPath = "tests/golden/WBV2/cycle.log"; + fs.mkdirSync(path.dirname(path.join(root, logPath)), { recursive: true }); + fs.writeFileSync(path.join(root, logPath), 'prefix {"event":"cycle.completed","failure":{"task":"T","phase":"gate","reason":"failed"}}\n'); + const session = createFailureLedgerSession(root, { logPaths: [logPath] }); + assert.equal(session.ledger.entries.length, 1); + session.record({ task: "T2", phase: "gate", reason: "failed" }); + assert.equal(fs.existsSync(path.join(root, "tests/golden/WBV2/failure-ledger.json")), false); + session.flush(); + const written = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/WBV2/failure-ledger.json"), "utf8")); + assert.equal(written.entries.length, 2); + assert.deepEqual(written.hydratedLogs, [logPath]); +});