From 27b46234803d1e6fabf1d4d3933444b37f16a1c5 Mon Sep 17 00:00:00 2001 From: mes123456 Date: Fri, 4 Sep 2026 07:57:11 +0800 Subject: [PATCH] feat(web): keep unattended native batch planner productive --- my-tools/authorize_next_wbv2_task.py | 246 ++++++++++++++++++- my-tools/generate_wbv2_compositor_fixture.py | 65 +++++ my-tools/generate_wbv2_geometry_fixture.py | 69 ++++++ my-tools/watch_blender_web_scheduler.py | 6 +- 4 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 my-tools/generate_wbv2_compositor_fixture.py create mode 100644 my-tools/generate_wbv2_geometry_fixture.py diff --git a/my-tools/authorize_next_wbv2_task.py b/my-tools/authorize_next_wbv2_task.py index 7971bddb4..b27268158 100644 --- a/my-tools/authorize_next_wbv2_task.py +++ b/my-tools/authorize_next_wbv2_task.py @@ -24,6 +24,28 @@ AUTO_PLANNER_STATUS = CONTROL_ROOT / "auto-planner.json" PREFERRED_BATCHES = [ + { + "moduleId": "COMPOSITOR_NATIVE_NODE_BATCH", + "title": "verify native compositor transform blur alpha reader", + "owner": "MEDIA", + "identifiers": ["CompositorNodeTransform", "CompositorNodeBlur", "CompositorNodeAlphaOver", "CompositorNodeViewer"], + "rna": [], + "fixture": "tests/files/web/compositor_node_batch_scene.blend", + "test": "web/tests/e2e/compositor-main-reader-production.spec.ts", + "production": "tests/golden/{task}/compositor-main-reader-production.json", + "types": ["CompositorNodeTransform", "CompositorNodeBlur", "CompositorNodeAlphaOver", "VIEWER"], + }, + { + "moduleId": "COMPOSITOR_NODE_READER_BATCH", + "title": "verify compositor node reader batch", + "owner": "MEDIA", + "identifiers": [], + "rna": ["CompositorNodeGlare", "CompositorNodeViewer"], + "fixture": "tests/files/web/compositor_scene.blend", + "test": "web/tests/e2e/compositor-main-reader-production.spec.ts", + "production": "tests/golden/{task}/compositor-main-reader-production.json", + "types": ["CompositorNodeGlare", "VIEWER"], + }, { "moduleId": "MODIFIER_READER_BATCH", "title": "verify modifier reader batch", @@ -310,6 +332,210 @@ def batch_contract(task: str, batch: dict) -> dict: return {"batchId": batch.get("batchId") or f"{task}:{batch['moduleId']}:{digest}", "selectedRecordIds": selected} +def native_compositor_batch(inventory: dict) -> dict | None: + """Select a bounded node-reader batch that can generate its own fixture.""" + candidates = [ + unit for unit in inventory.get("units", []) + if isinstance(unit, dict) + and unit.get("coverageStatus") != "VERIFIED" + and unit.get("kind") == "node" + and isinstance(unit.get("identifier"), str) + and unit["identifier"].startswith("CompositorNode") + ] + if not candidates: + return None + # Native reader fixtures remain compact: some compositor node storage + # layouts interact when more than five heterogeneous nodes share one + # group. Keep the upper bound at the empirically stable shape; the + # adaptive history may still reduce it after a focused gate failure. + max_size = 5 + module_id = "AUTO_COMPOSITOR_NATIVE_NODE_READER" + 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") != module_id: + 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:] + current_size = min(len(candidates), max_size) + if recent and any(item["timeout"] or item["durationMs"] > 900_000 or item["commands"] > 48 for item in recent): + next_size = max(1, 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 + units = candidates[:next_size] + return { + "moduleId": module_id, + "title": "verify auto-generated native compositor reader batch", + "owner": "NODES", + "identifiers": [unit["identifier"] for unit in units], + "rna": [], + "test": "web/tests/e2e/compositor-main-reader-production.spec.ts", + "production": "tests/golden/{task}/compositor-main-reader-production.json", + "types": [unit["identifier"] for unit in units], + "units": units, + "candidateUnits": candidates, + "nativeCompositorFixture": True, + "adaptiveKey": f"{module_id}:LOCAL_EXACT:NODES", + "adaptiveHistory": recent, + "adaptiveCurrentSize": current_size, + "adaptiveNextSize": next_size, + "adaptiveMinSize": 1, + "adaptiveMaxSize": max_size, + } + + +def ensure_native_compositor_fixture(batch: dict, contract: dict) -> None: + """Materialize a selected native node batch before its manifest references it.""" + if not batch.get("nativeCompositorFixture"): + return + digest = contract["batchId"].rsplit(":", 1)[-1] + fixture = Path(f"tests/files/web/generated/wbv2-compositor-{digest}.blend") + batch["fixture"] = fixture.as_posix() + if (ROOT / fixture).is_file(): + return + command = ["python3", "my-tools/generate_wbv2_compositor_fixture.py", "--output", fixture.as_posix()] + for unit in batch["units"]: + command.extend(["--node", unit["identifier"]]) + result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False) + if result.returncode: + raise SystemExit(f"native fixture preparation failed for {batch['moduleId']}:\n{result.stdout}{result.stderr}") + + +def ensure_native_geometry_fixture(batch: dict, contract: dict) -> None: + if not batch.get("nativeGeometryFixture"): + return + digest = contract["batchId"].rsplit(":", 1)[-1] + fixture = Path(f"tests/files/web/generated/wbv2-geometry-{digest}.blend") + batch["fixture"] = fixture.as_posix() + if fixture.is_file() or (ROOT / fixture).is_file(): + return + command = ["python3", "my-tools/generate_wbv2_geometry_fixture.py", "--output", fixture.as_posix()] + for unit in batch["units"]: + command.extend(["--node", unit["identifier"]]) + result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False) + if result.returncode: + raise SystemExit(f"native geometry fixture preparation failed for {batch['moduleId']}:\n{result.stdout}{result.stderr}") + + +def native_geometry_batch(inventory: dict) -> dict | None: + candidates = [ + unit for unit in inventory.get("units", []) + if isinstance(unit, dict) + and unit.get("coverageStatus") != "VERIFIED" + and unit.get("kind") == "node" + and isinstance(unit.get("identifier"), str) + and unit["identifier"].startswith("GeometryNode") + ] + if not candidates: + return None + max_size = 5 + units = candidates[:max_size] + return { + "moduleId": "AUTO_GEOMETRY_NATIVE_NODE_READER", + "title": "verify auto-generated native geometry node reader batch", + "owner": "NODES", + "identifiers": [unit["identifier"] for unit in units], + "rna": [], + "types": [unit["identifier"] for unit in units], + "units": units, + "candidateUnits": candidates, + "nativeGeometryFixture": True, + "test": "web/tests/e2e/geometry-node-main-reader.spec.ts", + "production": "tests/golden/{task}/geometry-node-reader-production.json", + "adaptiveKey": "AUTO_GEOMETRY_NATIVE_NODE_READER:LOCAL_EXACT:NODES", + "adaptiveHistory": [], + "adaptiveCurrentSize": len(units), + "adaptiveNextSize": len(units), + "adaptiveMinSize": 1, + "adaptiveMaxSize": max_size, + } + + +def recover_blocked_native_batch(current: str, index: dict, plan: dict, inventory: dict) -> bool: + """Shrink one failed auto-native batch to the last proven fixture shape. + + This only applies to the active task's recorded BLOCKED receipt. The + failed receipt remains immutable evidence; the task contract is repaired + in place so the scheduler can retry the same bounded increment. + """ + record = next((item for item in plan.get("tasks", []) if item.get("id") == current), None) + module = record.get("module") if isinstance(record, dict) else None + receipt_path = ROOT / "tests/golden" / current / "module-receipt.json" + receipt = read_json(receipt_path) if receipt_path.is_file() else {} + if not isinstance(module, dict) or not str(module.get("moduleId", "")).startswith("AUTO_COMPOSITOR_NATIVE_NODE_READER"): + return False + if receipt.get("status") != "BLOCKED" or len(receipt.get("selectedRecordIds", [])) <= 5: + return False + by_id = {unit.get("id"): unit for unit in inventory.get("units", []) if isinstance(unit, dict)} + selected_ids = list(receipt.get("selectedRecordIds", []))[:5] + units = [by_id[unit_id] for unit_id in selected_ids if unit_id in by_id] + if len(units) != len(selected_ids): + return False + batch = { + "moduleId": str(module["moduleId"]), + "title": "recover native compositor node reader batch", + "owner": "NODES", + "identifiers": [unit["identifier"] for unit in units], + "rna": [], + "types": [unit["identifier"] for unit in units], + "units": units, + "nativeCompositorFixture": True, + "test": "web/tests/e2e/compositor-main-reader-production.spec.ts", + "production": "tests/golden/{task}/compositor-main-reader-production.json", + } + contract = batch_contract(current, batch) + ensure_native_compositor_fixture(batch, contract) + fixture = batch["fixture"] + source_paths = [unit["locations"][0]["path"] for unit in units] + source_paths += [ + "blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp", + "web/tests/e2e/compositor-main-reader-production.spec.ts", + fixture, + "web/app/src/vendor/blender/web_engine.wasm", + ] + types = json.dumps(batch["types"], separators=(",", ":")) + ids = json.dumps(selected_ids, separators=(",", ":")) + production = f"tests/golden/{current}/compositor-main-reader-production.json" + command = ( + f"WBV2_BATCH_TASK={current} WBV2_BATCH_PRODUCTION={production} " + f"WBV2_BATCH_FIXTURE={fixture} WBV2_BATCH_TYPES='{types}' WBV2_BATCH_UNITS='{ids}' " + "WEB_TEST_PORT=5371 npm --prefix web run test:e2e -- --workers=1 tests/e2e/compositor-main-reader-production.spec.ts" + ) + module.update({ + "batchId": contract["batchId"], + "selectedRecordIds": selected_ids, + "sourcePaths": source_paths, + "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"], + "commands": [ + "source tools/web/emscripten-env.sh && cmake --build build_web_blender6 --target web_engine -- -j8 && cp build_web_blender6/bin/web_engine.wasm web/app/src/vendor/blender/web_engine.wasm && cp build_web_blender6/bin/web_engine.wasm web/app/public/vendor/blender/web_engine.wasm", + "npm --prefix web run typecheck", + "npm --prefix web run build", + command, + ], + "coverageBindings": selected_ids, + }) + record["batchId"] = contract["batchId"] + record["selectedRecordIds"] = selected_ids + write_json(ROOT / index["source"]["path"], plan) + run_node("tools/web/generate-v2-task-index.mjs", "--plan-path", index["source"]["path"]) + card_path = ROOT / "docs/tasks" / f"{current}.md" + write_text(card_path, make_card(current, record.get("dependsOn", [""])[-1], "in_progress", batch, f"node tools/web/run-wbv2-module-task.mjs --task {current} --write", contract)) + run_node("tools/web/check-task-context.mjs", "--task", current, "--write") + print(f"wbv2-blocked-batch-recovered task={current} batchId={contract['batchId']} selected={len(selected_ids)}") + return True + + def discover_existing_evidence_batch(inventory: dict) -> dict | None: """Find exact Chromium receipts that list currently-unbound records. @@ -588,6 +814,12 @@ def select_batch(inventory: dict) -> dict: discovered = discover_existing_evidence_batch(inventory) if discovered: return discovered + native = native_compositor_batch(inventory) + if native: + return native + geometry = native_geometry_batch(inventory) + if geometry: + return geometry summary = pending_summary(inventory) write_planner_status({ "status": "waiting_for_exact_evidence", @@ -609,15 +841,27 @@ def main() -> int: index = read_json(INDEX) plan_path = ROOT / index["source"]["path"] plan = json.loads(plan_path.read_text(encoding="utf-8")) + inventory = read_json(CONTROL_ROOT / "dynamic-inventory.json") + if recover_blocked_native_batch(current, index, plan, inventory): + return 0 terminal = current_manifest.get("status") == "done" and current_manifest.get("nextTask") is None and index.get("activeTask") is None if not terminal and not current_manifest.get("nextTask"): raise SystemExit(f"current task is not terminal: {current}") phase = max(int(item["id"].split("-")[1][1:]) for item in plan["tasks"]) + 1 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 batch.get("nativeCompositorFixture"): + digest = contract["batchId"].rsplit(":", 1)[-1] + batch["fixture"] = f"tests/files/web/generated/wbv2-compositor-{digest}.blend" + if args.write: + ensure_native_compositor_fixture(batch, contract) + if batch.get("nativeGeometryFixture"): + digest = contract["batchId"].rsplit(":", 1)[-1] + batch["fixture"] = f"tests/files/web/generated/wbv2-geometry-{digest}.blend" + if args.write: + ensure_native_geometry_fixture(batch, contract) write_planner_status({ "status": "ready", "reason": "source_backed_batch_selected", diff --git a/my-tools/generate_wbv2_compositor_fixture.py b/my-tools/generate_wbv2_compositor_fixture.py new file mode 100644 index 000000000..2cdfab4ed --- /dev/null +++ b/my-tools/generate_wbv2_compositor_fixture.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Generate a minimal native Blender compositor fixture for one WBV2 batch.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +NATIVE_BLENDER = ROOT / "build_blender_native/bin/blender" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, help="repository-relative .blend output under tests/files/web") + parser.add_argument("--node", action="append", required=True, help="Blender CompositorNode type identifier") + args = parser.parse_args() + + output = Path(args.output) + if output.is_absolute() or ".." in output.parts or output.suffix != ".blend" or not str(output).startswith("tests/files/web/"): + parser.error("--output must be a repository-relative .blend path under tests/files/web/") + nodes = list(dict.fromkeys(args.node)) + if not nodes or any(not node.startswith("CompositorNode") or not node.replace("_", "").isalnum() for node in nodes): + parser.error("every --node must be a Blender CompositorNode identifier") + if not NATIVE_BLENDER.is_file(): + raise SystemExit(f"native Blender fixture generator is unavailable: {NATIVE_BLENDER.relative_to(ROOT)}") + + destination = ROOT / output + destination.parent.mkdir(parents=True, exist_ok=True) + script = ( + "import bpy\n" + f"destination = {str(destination)!r}\n" + f"node_types = {nodes!r}\n" + "scene = bpy.context.scene\n" + "tree = bpy.data.node_groups.new('WBV2 Compositor Native Batch', 'CompositorNodeTree')\n" + "scene.compositing_node_group = tree\n" + "tree.interface.new_socket(name='Image', in_out='OUTPUT', socket_type='NodeSocketColor')\n" + "source = tree.nodes.new('CompositorNodeRGB')\n" + "source.name = 'WBV2 Batch Source'\n" + "output = tree.nodes.new('NodeGroupOutput')\n" + "output.name = 'WBV2 Batch Output'\n" + "tree.links.new(source.outputs[0], output.inputs[0])\n" + "for node_type in node_types:\n" + " node = tree.nodes.new(node_type)\n" + " node.name = 'WBV2 ' + node_type\n" + "bpy.ops.wm.save_as_mainfile(filepath=destination)\n" + ) + result = subprocess.run( + [str(NATIVE_BLENDER), "--background", "--factory-startup", "--python-expr", script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + if result.returncode or not destination.is_file(): + raise SystemExit(f"native compositor fixture generation failed ({result.returncode}):\n{result.stdout}{result.stderr}") + print(json.dumps({"status": "PASS", "fixture": output.as_posix(), "nodes": nodes, "bytes": destination.stat().st_size})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/my-tools/generate_wbv2_geometry_fixture.py b/my-tools/generate_wbv2_geometry_fixture.py new file mode 100644 index 000000000..eff7111d4 --- /dev/null +++ b/my-tools/generate_wbv2_geometry_fixture.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Generate a native Blender GeometryNodeTree fixture for one WBV2 batch.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +NATIVE_BLENDER = ROOT / "build_blender_native/bin/blender" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True) + parser.add_argument("--node", action="append", required=True) + args = parser.parse_args() + output = Path(args.output) + if output.is_absolute() or ".." in output.parts or output.suffix != ".blend" or not str(output).startswith("tests/files/web/"): + parser.error("--output must be a repository-relative .blend path under tests/files/web/") + nodes = list(dict.fromkeys(args.node)) + if not nodes or any(not node.startswith("GeometryNode") or not node.replace("_", "").isalnum() for node in nodes): + parser.error("every --node must be a Blender GeometryNode identifier") + if not NATIVE_BLENDER.is_file(): + raise SystemExit(f"native Blender fixture generator is unavailable: {NATIVE_BLENDER.relative_to(ROOT)}") + destination = ROOT / output + destination.parent.mkdir(parents=True, exist_ok=True) + script = ( + "import bpy\n" + f"destination = {str(destination)!r}\n" + f"node_types = {nodes!r}\n" + "mesh = bpy.data.meshes.new('WBV2 Geometry Mesh')\n" + "mesh.from_pydata([(-1,-1,0),(1,-1,0),(0,1,0)], [], [(0,1,2)])\n" + "obj = bpy.data.objects.new('WBV2 Geometry Object', mesh)\n" + "bpy.context.collection.objects.link(obj)\n" + "modifier = obj.modifiers.new('WBV2 Geometry Nodes', 'NODES')\n" + "tree = bpy.data.node_groups.new('WBV2 Geometry Batch', 'GeometryNodeTree')\n" + "modifier.node_group = tree\n" + "tree.interface.new_socket(name='Geometry', in_out='INPUT', socket_type='NodeSocketGeometry')\n" + "tree.interface.new_socket(name='Geometry', in_out='OUTPUT', socket_type='NodeSocketGeometry')\n" + "input_node = tree.nodes.new('NodeGroupInput')\n" + "output_node = tree.nodes.new('NodeGroupOutput')\n" + "previous = input_node\n" + "for node_type in node_types:\n" + " node = tree.nodes.new(node_type)\n" + " node.name = 'WBV2 ' + node_type\n" + " geometry_input = next((socket for socket in node.inputs if socket.type == 'GEOMETRY'), None)\n" + " geometry_output = next((socket for socket in node.outputs if socket.type == 'GEOMETRY'), None)\n" + " previous_output = next((socket for socket in previous.outputs if socket.type == 'GEOMETRY'), None)\n" + " if geometry_input and previous_output: tree.links.new(previous_output, geometry_input)\n" + " previous = node\n" + "previous_output = next((socket for socket in previous.outputs if socket.type == 'GEOMETRY'), None)\n" + "if previous_output: tree.links.new(previous_output, output_node.inputs['Geometry'])\n" + "bpy.context.view_layer.objects.active = obj\n" + "obj.select_set(True)\n" + "bpy.ops.wm.save_as_mainfile(filepath=destination)\n" + ) + result = subprocess.run([str(NATIVE_BLENDER), "--background", "--factory-startup", "--python-expr", script], cwd=ROOT, text=True, capture_output=True, check=False) + if result.returncode or not destination.is_file(): + raise SystemExit(f"native geometry fixture generation failed ({result.returncode}):\n{result.stdout}{result.stderr}") + print(json.dumps({"status": "PASS", "fixture": output.as_posix(), "nodes": nodes, "bytes": destination.stat().st_size})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/my-tools/watch_blender_web_scheduler.py b/my-tools/watch_blender_web_scheduler.py index 807ad1201..7578bcd4a 100755 --- a/my-tools/watch_blender_web_scheduler.py +++ b/my-tools/watch_blender_web_scheduler.py @@ -197,8 +197,12 @@ def main() -> int: "authorizationLog": str(AUTHORIZATION_LOG.relative_to(ROOT)), }, ) + # Keep the planner heartbeat responsive while no exact + # source-backed batch is available. Each short cycle + # retries authorization and records the current pending + # inventory instead of entering a long silent sleep. child = None - time.sleep(max(args.interval, 60.0)) + time.sleep(args.interval) continue return 0 if reason == "no_configured_unresolved_source_batch" else 2 if terminal_pointer(pointer) or (applies and state in {"complete", "blocked", "stopped"}):