From bee381a1a3329a51aa120ecf7574b62110476986 Mon Sep 17 00:00:00 2001 From: RobitOverlord <193709524+10-X-eng@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:07:23 -0500 Subject: [PATCH 1/5] Fix macOS Codex packaging and pin runtime 0.154.0 for build6 --- package/rattler-build/recipe.yaml | 17 ++++++++++++++- .../scripts/install_vibecad_codex_runtime.sh | 14 ++++++------- src/Mod/VibeCAD/VibeCADCodex.py | 2 +- .../test_codex_runtime_package.py | 2 +- .../test_native_codex_runtime_build.py | 8 +++---- src/Tools/tests/test_macos_build_toolchain.py | 21 +++++++++++++++++++ version.json | 2 +- 7 files changed, 51 insertions(+), 15 deletions(-) diff --git a/package/rattler-build/recipe.yaml b/package/rattler-build/recipe.yaml index 76bf6340..a128a655 100644 --- a/package/rattler-build/recipe.yaml +++ b/package/rattler-build/recipe.yaml @@ -10,7 +10,22 @@ source: use_gitignore: true build: - number: 5 + number: 6 + # Upstream Codex executables are self-contained, with system-only Mach-O + # dependencies. Adding conda RPATHs overflows their unpadded headers. + # Relocate our bin/lib installs and all native modules, not those prebuilt + # executables under Mod. The final recursive bundle audit remains mandatory. + dynamic_linking: + binary_relocation: + - if: osx + then: + - bin/** + - lib/** + - '**/*.so' + - '**/*.dylib' + - '**/*.bundle' + else: + - '**' script: env: # rattler-build runs the build in a sandbox that does not inherit shell diff --git a/package/rattler-build/scripts/install_vibecad_codex_runtime.sh b/package/rattler-build/scripts/install_vibecad_codex_runtime.sh index 6eac7741..bd782f59 100755 --- a/package/rattler-build/scripts/install_vibecad_codex_runtime.sh +++ b/package/rattler-build/scripts/install_vibecad_codex_runtime.sh @@ -15,7 +15,7 @@ download_cache="${VIBECAD_DOWNLOAD_CACHE:-${repository_root}/package/rattler-bui runtime_root="${module_directory}/codex_runtime" stamp="${runtime_root}/runtime-spec.sha256" -codex_version="0.153.4" +codex_version="0.154.0" release_tag="rust-v${codex_version}" release_root="https://github.com/openai/codex/releases/download/${release_tag}" license_url="https://raw.githubusercontent.com/openai/codex/${release_tag}/LICENSE" @@ -31,32 +31,32 @@ machine="$(${python_executable} -c 'import platform; print(platform.machine().lo case "${platform}:${machine}" in linux:x86_64|linux:amd64) archive="codex-app-server-package-x86_64-unknown-linux-musl.tar.gz" - archive_sha256="a5d37ff1fa6953ee6d317b7e69bfafd39f5f53350b631d790fa7531159f22420" + archive_sha256="b2450aaa4004d06790dd8a69d0246f4503ff1258400cc20de7d7a42ffe81b253" executable="${runtime_root}/bin/codex-app-server" ;; linux:aarch64|linux:arm64) archive="codex-app-server-package-aarch64-unknown-linux-musl.tar.gz" - archive_sha256="5673c5a8935ff2f85ca67b489e560fdd5e08fb0f0e2f7426f048ec7449aa4fdc" + archive_sha256="295bb1b94a8b964b2d2461db9736b9907a9e4daa6ceb8e9bbb820b304fa897ed" executable="${runtime_root}/bin/codex-app-server" ;; win32:amd64|win32:x86_64) archive="codex-app-server-package-x86_64-pc-windows-msvc.tar.gz" - archive_sha256="69441ca4c8f6197923dc1b70a8aa870ff912b5367347287d021eaca1f3add971" + archive_sha256="5f8b43e030c0aeeb7bdb3d5e03fff4c68ba94fa2df0ae437c490811f54660d74" executable="${runtime_root}/bin/codex-app-server.exe" ;; win32:arm64|win32:aarch64) archive="codex-app-server-package-aarch64-pc-windows-msvc.tar.gz" - archive_sha256="d5f0ef33223912a1559a7e97012afa18eef3369f1d07dde199edfada062503ee" + archive_sha256="7406aa3745acd5bd639b8921c0ee5e241861763606465cac643683944a849457" executable="${runtime_root}/bin/codex-app-server.exe" ;; darwin:arm64|darwin:aarch64) archive="codex-app-server-package-aarch64-apple-darwin.tar.gz" - archive_sha256="90f0467fd03294896204e8856bf969a0691590e8bef78dc2563a264b186f3265" + archive_sha256="7bf20c1843bdcff086c89a294299833f20146ebbdba03e7f49f020b7adbfff7b" executable="${runtime_root}/bin/codex-app-server" ;; darwin:x86_64|darwin:amd64) archive="codex-app-server-package-x86_64-apple-darwin.tar.gz" - archive_sha256="ee286ca326a0df4a2b81dddb213d61e610d7b9c4f3173cc16f6023683a94ca82" + archive_sha256="4fddde3689d2aa0058c06138a84b05f87bdbff8cd556fba5816a97ac0469a4d4" executable="${runtime_root}/bin/codex-app-server" ;; *) diff --git a/src/Mod/VibeCAD/VibeCADCodex.py b/src/Mod/VibeCAD/VibeCADCodex.py index 1e40364e..f13a0221 100644 --- a/src/Mod/VibeCAD/VibeCADCodex.py +++ b/src/Mod/VibeCAD/VibeCADCodex.py @@ -24,7 +24,7 @@ from typing import Any, Callable, Mapping -CODEX_APP_SERVER_VERSION = "0.153.4" +CODEX_APP_SERVER_VERSION = "0.154.0" CODEX_APP_SERVER_ENV = "VIBECAD_CODEX_APP_SERVER" CODEX_HOME_ENV = "VIBECAD_CODEX_HOME" CODEX_RUNTIME_DIRECTORY = "codex_runtime" diff --git a/src/Mod/VibeCAD/vibecad_tests/test_codex_runtime_package.py b/src/Mod/VibeCAD/vibecad_tests/test_codex_runtime_package.py index 43d0c309..8210e72b 100644 --- a/src/Mod/VibeCAD/vibecad_tests/test_codex_runtime_package.py +++ b/src/Mod/VibeCAD/vibecad_tests/test_codex_runtime_package.py @@ -20,7 +20,7 @@ def test_codex_runtime_release_is_consistent(): for t in node.targets) ) installer = (root / "package/rattler-build/scripts/install_vibecad_codex_runtime.sh").read_text() - assert version == "0.153.4" + assert version == "0.154.0" assert f'codex_version="{version}"' in installer archives = re.findall(r'archive="([^"]+)"\s+archive_sha256="([a-f0-9]{64})"', installer) assert len(archives) == 6 diff --git a/src/Mod/VibeCAD/vibecad_tests/test_native_codex_runtime_build.py b/src/Mod/VibeCAD/vibecad_tests/test_native_codex_runtime_build.py index 3e125193..06b6e568 100644 --- a/src/Mod/VibeCAD/vibecad_tests/test_native_codex_runtime_build.py +++ b/src/Mod/VibeCAD/vibecad_tests/test_native_codex_runtime_build.py @@ -77,12 +77,12 @@ def test_native_runtime_opt_out_does_not_install_or_download(project): def test_fresh_runtime_is_installed_with_companions(installed, tmp_path): run, build, runtime = installed - assert "0.153.4" in run(str(runtime / "bin/codex-app-server"), "--version") + assert "0.154.0" in run(str(runtime / "bin/codex-app-server"), "--version") assert (runtime / "bin/codex-code-mode-host").is_file() prefix = tmp_path / "installed" run("cmake", "--install", str(build), "--prefix", str(prefix)) packaged = prefix / "Mod/VibeCAD/codex_runtime" - assert "0.153.4" in run(str(packaged / "bin/codex-app-server"), "--version") + assert "0.154.0" in run(str(packaged / "bin/codex-app-server"), "--version") assert (packaged / "bin/codex-code-mode-host").is_file() @@ -101,7 +101,7 @@ def test_rebuild_repairs_stale_version_metadata_with_newer_mtime(installed): metadata["version"] = "0.144.5" manifest.write_text(json.dumps(metadata)) run("cmake", "--build", str(build), "--parallel", "12") - assert json.loads(manifest.read_text())["version"] == "0.153.4" + assert json.loads(manifest.read_text())["version"] == "0.154.0" def test_rebuild_replaces_old_executable_even_with_current_metadata(installed): @@ -111,7 +111,7 @@ def test_rebuild_replaces_old_executable_even_with_current_metadata(installed): binary.chmod(0o755) assert "0.144.5" in run(str(binary), "--version") run("cmake", "--build", str(build), "--parallel", "12") - assert "0.153.4" in run(str(binary), "--version") + assert "0.154.0" in run(str(binary), "--version") def test_current_runtime_is_verified_without_replacing_binaries(installed): diff --git a/src/Tools/tests/test_macos_build_toolchain.py b/src/Tools/tests/test_macos_build_toolchain.py index 826040dd..81884d63 100644 --- a/src/Tools/tests/test_macos_build_toolchain.py +++ b/src/Tools/tests/test_macos_build_toolchain.py @@ -21,6 +21,27 @@ class TestMacOSBuildToolchain(unittest.TestCase): + def test_prebuilt_codex_runtime_is_not_relinked_by_rattler(self) -> None: + # These upstream executables use system libraries, not the conda + # prefix. Adding conda RPATHs can overflow rg/zsh's Mach-O headers. + # Keep relocation enabled for every other native file. + recipe = RECIPE.read_text(encoding="utf-8") + self.assertIn( + " dynamic_linking:\n" + " binary_relocation:\n" + " - if: osx\n" + " then:\n" + " - bin/**\n" + " - lib/**\n" + " - '**/*.so'\n" + " - '**/*.dylib'\n" + " - '**/*.bundle'\n" + " else:\n" + " - '**'\n", + recipe, + ) + self.assertNotIn("binary_relocation: false", recipe) + def test_macos_build_exports_flags_to_cmake(self) -> None: # Execute the complete recipe script, replacing external build/install # commands with stubs. CMake's stub starts a child shell so a shell-only diff --git a/version.json b/version.json index 8542a906..5fc9778d 100644 --- a/version.json +++ b/version.json @@ -6,6 +6,6 @@ "version_patch_note": "number of patch release (e.g. 4 for the 0.18.4 release)", "version_suffix": "RC6", "version_suffix_note": "either 'dev' for development snapshot, 'RC1' etc. for release candidate, or empty string for release", - "build_version": 5, + "build_version": 6, "build_version_note": "used when the same VibeCAD version is re-released (for example using an updated LibPack)" } From ed7ffc7a09175bf2f22928549497fec9a007c238 Mon Sep 17 00:00:00 2001 From: RobitOverlord <193709524+10-X-eng@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:59:38 -0500 Subject: [PATCH 2/5] Include macOS QuickLook bundles in binary relocation --- package/rattler-build/recipe.yaml | 4 ++- src/Tools/tests/test_macos_build_toolchain.py | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/package/rattler-build/recipe.yaml b/package/rattler-build/recipe.yaml index a128a655..bbdf2532 100644 --- a/package/rattler-build/recipe.yaml +++ b/package/rattler-build/recipe.yaml @@ -13,7 +13,7 @@ build: number: 6 # Upstream Codex executables are self-contained, with system-only Mach-O # dependencies. Adding conda RPATHs overflows their unpadded headers. - # Relocate our bin/lib installs and all native modules, not those prebuilt + # Relocate our bin/lib installs, app plug-ins and native modules, not prebuilt # executables under Mod. The final recursive bundle audit remains mandatory. dynamic_linking: binary_relocation: @@ -21,6 +21,8 @@ build: then: - bin/** - lib/** + - Library/** + - PlugIns/** - '**/*.so' - '**/*.dylib' - '**/*.bundle' diff --git a/src/Tools/tests/test_macos_build_toolchain.py b/src/Tools/tests/test_macos_build_toolchain.py index 81884d63..fae22a73 100644 --- a/src/Tools/tests/test_macos_build_toolchain.py +++ b/src/Tools/tests/test_macos_build_toolchain.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: LGPL-2.1-or-later +import fnmatch import os import subprocess import tempfile @@ -33,6 +34,8 @@ def test_prebuilt_codex_runtime_is_not_relinked_by_rattler(self) -> None: " then:\n" " - bin/**\n" " - lib/**\n" + " - Library/**\n" + " - PlugIns/**\n" " - '**/*.so'\n" " - '**/*.dylib'\n" " - '**/*.bundle'\n" @@ -42,6 +45,32 @@ def test_prebuilt_codex_runtime_is_not_relinked_by_rattler(self) -> None: ) self.assertNotIn("binary_relocation: false", recipe) + def test_macos_relocation_covers_nested_bundle_executables(self) -> None: + recipe = RECIPE.read_text(encoding="utf-8") + selection = recipe.split(" binary_relocation:\n", 1)[1].split( + " then:\n", 1 + )[1].split(" else:\n", 1)[0] + patterns = [line.strip()[2:].strip("'") for line in selection.splitlines()] + for path in ( + "bin/freecad", + "lib/libFreeCADApp.dylib", + "Mod/Part/Part.so", + "Library/QuickLook/QuicklookFCStd.qlgenerator/Contents/MacOS/QuicklookFCStd", + "PlugIns/FreeCADThumbnailExtension.appex/Contents/MacOS/FreeCADThumbnailExtension", + "PlugIns/FreeCADPreviewExtension.appex/Contents/MacOS/FreeCADPreviewExtension", + ): + with self.subTest(path=path): + self.assertTrue(any(fnmatch.fnmatchcase(path, p) for p in patterns), path) + for path in ( + "bin/codex-app-server", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + ): + with self.subTest(path=path): + installed = "Mod/VibeCAD/codex_runtime/" + path + self.assertFalse(any(fnmatch.fnmatchcase(installed, p) for p in patterns)) + def test_macos_build_exports_flags_to_cmake(self) -> None: # Execute the complete recipe script, replacing external build/install # commands with stubs. CMake's stub starts a child shell so a shell-only From 0ef0140d0044eb948369d5ceb43bea23624484ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 19:46:31 +0000 Subject: [PATCH 3/5] Drain GUI recompute follow-up after /v1/run recompute:true Host recompute can early-return and queue a GUI follow-up while cooperative/pending/presentation gates are active. Stay on the document thread, pump Qt, and hard-fail with RECOMPUTE_DRAIN_TIMEOUT instead of returning stale geometry as success. Co-authored-by: Jason --- src/Mod/VibeCAD/VibeCADAgentControl.py | 133 ++++++++++- .../test_agent_control_recompute_drain.py | 217 ++++++++++++++++++ 2 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py diff --git a/src/Mod/VibeCAD/VibeCADAgentControl.py b/src/Mod/VibeCAD/VibeCADAgentControl.py index e3ad58cd..252448d8 100644 --- a/src/Mod/VibeCAD/VibeCADAgentControl.py +++ b/src/Mod/VibeCAD/VibeCADAgentControl.py @@ -31,6 +31,7 @@ import subprocess import sys import threading +import time import traceback import uuid from typing import Any, Callable @@ -39,6 +40,14 @@ AGENT_HOST = "127.0.0.1" DEFAULT_AGENT_PORT = 8766 +RECOMPUTE_DRAIN_TIMEOUT_S = 30.0 +_RECOMPUTE_DRAIN_GATE_ATTRS = ( + "CooperativeMutationActive", + "RecomputePending", + "PresentationUpdateActive", + "MutationBlockingPresentationUpdateActive", + "GuiRecomputeCoordinatorActive", +) AGENT_PORT_ENV = "VIBECAD_AGENT_PORT" AGENT_HOME_ENV = "VIBECAD_AGENT_HOME" TOKEN_FILENAME = "token" @@ -2948,6 +2957,107 @@ def capture_screenshot( } +def _recompute_gates_block(document: Any) -> bool: + """True while Document::recompute would early-return and queue a follow-up. + + Python already exposes CooperativeMutationActive, RecomputePending, and + PresentationUpdateActive. PresentationUpdateActive is a conservative + proxy for C++ mutation-blocking presentation. Coordinator activity is + covered by CooperativeMutationActive (beginCooperativeMutation) and by + GuiRecomputeCoordinatorActive if that getter is ever bound. + """ + + return any( + bool(getattr(document, name, False)) for name in _RECOMPUTE_DRAIN_GATE_ATTRS + ) + + +def _document_has_touched_objects(document: Any) -> bool: + for obj in getattr(document, "Objects", None) or (): + state = getattr(obj, "State", None) + if state is None: + continue + if "Touched" in list(state): + return True + return False + + +def _pump_recompute_events(gui: Any | None = None) -> None: + """Process queued GUI follow-ups on the document thread. Do not sleep.""" + + host = gui if gui is not None else _gui() + if host is not None: + update = getattr(host, "updateGui", None) + if callable(update): + try: + update() + except Exception: + pass + for module_name in ("PySide.QtWidgets", "PySide6.QtWidgets", "PySide2.QtWidgets"): + module = sys.modules.get(module_name) + if module is None: + continue + application = getattr(module, "QApplication", None) + processor = getattr(application, "processEvents", None) + if callable(processor): + try: + processor() + except Exception: + pass + break + + +def _drain_recompute( + document: Any, + *, + timeout_s: float | None = None, + gui: Any | None = None, + clock: Callable[[], float] | None = None, + pump: Callable[[], None] | None = None, +) -> dict[str, Any]: + """Call recompute and wait until rebuild finished or the drain times out. + + Document::recompute can return 0 on the GUI thread and queue + scheduleGuiRecomputeFollowUp while cooperative / pending / presentation + gates are active. Stay on the document thread and pump Qt so that + follow-up runs before /v1/run returns success. + """ + + now = clock or time.monotonic + started = now() + deadline = started + float( + RECOMPUTE_DRAIN_TIMEOUT_S if timeout_s is None else timeout_s + ) + calls = 0 + recompute_call = getattr(document, "recompute", None) + if callable(recompute_call): + recompute_call() + calls += 1 + + advance = pump if pump is not None else lambda: _pump_recompute_events(gui) + while True: + advance() + blocked = _recompute_gates_block(document) + touched = _document_has_touched_objects(document) + if not blocked and not touched: + return { + "ok": True, + "recompute_calls": calls, + "ms": (now() - started) * 1000.0, + "error": None, + } + if now() >= deadline: + return { + "ok": False, + "recompute_calls": calls, + "ms": (now() - started) * 1000.0, + "error": "RECOMPUTE_DRAIN_TIMEOUT", + } + if not blocked and touched and callable(recompute_call): + recompute_call() + calls += 1 + + def _json_safe(value: Any) -> Any: if value is None or isinstance(value, (bool, int, float, str)): return value @@ -3006,15 +3116,26 @@ def run_script( namespace["FreeCADGui"] = gui stdout = StringIO() stderr = StringIO() + drain = None try: compiled = compile(source, namespace["__file__"], "exec") with redirect_stdout(stdout), redirect_stderr(stderr): exec(compiled, namespace, namespace) if recompute: document = getattr(App, "ActiveDocument", None) - recompute_call = getattr(document, "recompute", None) - if callable(recompute_call): - recompute_call() + if document is not None: + drain = _drain_recompute(document, gui=gui) + if not drain.get("ok"): + return failure( + "RECOMPUTE_DRAIN_TIMEOUT", + "Active document recompute did not finish before the drain timeout.", + stage="recompute", + stdout=stdout.getvalue(), + stderr=stderr.getvalue(), + opened=opened, + recompute_calls=drain.get("recompute_calls"), + recompute_drain_ms=drain.get("ms"), + ) except Exception as exc: return failure( "SCRIPT_FAILED", @@ -3025,7 +3146,7 @@ def run_script( opened=opened, ) result = namespace.get("result", namespace.get("__result__")) - return { + payload: dict[str, Any] = { "ok": True, "stdout": stdout.getvalue(), "stderr": stderr.getvalue(), @@ -3037,6 +3158,10 @@ def run_script( else None ), } + if drain is not None: + payload["recompute_calls"] = drain.get("recompute_calls") + payload["recompute_drain_ms"] = drain.get("ms") + return payload def aero_command(arguments: dict[str, Any] | None = None) -> dict[str, Any]: diff --git a/src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py b/src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py new file mode 100644 index 00000000..5d711a68 --- /dev/null +++ b/src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py @@ -0,0 +1,217 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +"""Unit tests for /v1/run recompute drain after host recompute:true. + +These exercise the drain helper control flow with stub documents. They do +not claim live fem_bracket GUI proof. +""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from typing import Any + +import VibeCADAgentControl as control + + +class _Clock: + def __init__(self, values: list[float]) -> None: + self._values = list(values) + self.calls = 0 + + def __call__(self) -> float: + self.calls += 1 + if not self._values: + return 0.0 + if len(self._values) == 1: + return self._values[0] + return self._values.pop(0) + + +class _GatedDocument: + def __init__( + self, + *, + cooperative: bool = False, + pending: bool = False, + presentation: bool = False, + objects: list[Any] | None = None, + ) -> None: + self.CooperativeMutationActive = cooperative + self.RecomputePending = pending + self.PresentationUpdateActive = presentation + self.Objects = list(objects or []) + self.recompute_calls = 0 + + def recompute(self) -> None: + self.recompute_calls += 1 + + +def test_recompute_gates_use_exposed_document_proxies() -> None: + document = _GatedDocument() + assert control._recompute_gates_block(document) is False + document.CooperativeMutationActive = True + assert control._recompute_gates_block(document) is True + document.CooperativeMutationActive = False + document.RecomputePending = True + assert control._recompute_gates_block(document) is True + document.RecomputePending = False + document.PresentationUpdateActive = True + assert control._recompute_gates_block(document) is True + + +def test_touched_objects_read_state_list() -> None: + clean = SimpleNamespace(State=("Up-to-date",)) + dirty = SimpleNamespace(State=["Touched"]) + assert control._document_has_touched_objects(SimpleNamespace(Objects=[clean])) is False + assert control._document_has_touched_objects(SimpleNamespace(Objects=[dirty])) is True + assert control._document_has_touched_objects(SimpleNamespace(Objects=[])) is False + + +def test_drain_recompute_succeeds_when_already_up_to_date() -> None: + document = _GatedDocument() + pumps = {"count": 0} + + result = control._drain_recompute( + document, + timeout_s=0.0, + pump=lambda: pumps.__setitem__("count", pumps["count"] + 1), + clock=_Clock([0.0, 0.0]), + ) + + assert result["ok"] is True + assert result["error"] is None + assert result["recompute_calls"] == 1 + assert document.recompute_calls == 1 + assert pumps["count"] == 1 + assert result["ms"] >= 0.0 + + +def test_drain_recompute_pumps_while_cooperative_and_pending_block() -> None: + document = _GatedDocument(cooperative=True, pending=True) + pumps = {"count": 0} + + def pump() -> None: + pumps["count"] += 1 + if pumps["count"] >= 3: + document.CooperativeMutationActive = False + document.RecomputePending = False + + result = control._drain_recompute(document, timeout_s=5.0, pump=pump, clock=_Clock([0.0, 0.1, 0.2, 0.3])) + + assert result["ok"] is True + assert document.recompute_calls == 1 + assert result["recompute_calls"] == 1 + assert pumps["count"] == 3 + + +def test_drain_recompute_reissues_when_touched_after_gates_clear() -> None: + touched = SimpleNamespace(State=["Touched"]) + document = _GatedDocument(objects=[touched]) + + def recompute() -> None: + document.recompute_calls += 1 + if document.recompute_calls >= 2: + touched.State = ["Up-to-date"] + + document.recompute = recompute # type: ignore[method-assign] + result = control._drain_recompute( + document, + timeout_s=5.0, + pump=lambda: None, + clock=_Clock([0.0, 0.1, 0.2, 0.3]), + ) + + assert result["ok"] is True + assert result["recompute_calls"] == 2 + assert document.recompute_calls == 2 + assert touched.State == ["Up-to-date"] + + +def test_drain_recompute_times_out_with_structured_error() -> None: + document = _GatedDocument(cooperative=True) + result = control._drain_recompute( + document, + timeout_s=0.0, + pump=lambda: None, + clock=_Clock([10.0]), + ) + + assert result["ok"] is False + assert result["error"] == "RECOMPUTE_DRAIN_TIMEOUT" + assert result["recompute_calls"] == 1 + assert document.recompute_calls == 1 + + +def test_run_script_default_recompute_is_true() -> None: + assert inspect.signature(control.run_script).parameters["recompute"].default is True + + +def test_dispatch_run_defaults_recompute_true(monkeypatch) -> None: + seen: dict[str, Any] = {} + + def fake_run(**kwargs: Any) -> dict[str, Any]: + seen.update(kwargs) + return {"ok": True} + + monkeypatch.setattr(control, "run_script", fake_run) + monkeypatch.setattr(control, "_on_document_thread", lambda operation: operation()) + payload = control.dispatch("run", {"python": "pass"}) + assert payload["ok"] is True + assert seen["recompute"] is True + + +def test_run_script_measure_only_recompute_true_drains(monkeypatch) -> None: + document = _GatedDocument() + pumps = {"count": 0} + + monkeypatch.setattr(control, "_app", lambda: SimpleNamespace(ActiveDocument=document)) + monkeypatch.setattr( + control, + "_gui", + lambda: SimpleNamespace(updateGui=lambda: pumps.__setitem__("count", pumps["count"] + 1)), + ) + monkeypatch.setattr( + control, + "_document_summary", + lambda _document: {"name": "stub"}, + ) + + payload = control.run_script(python="result = 7") + + assert payload["ok"] is True + assert payload["result"] == 7 + assert document.recompute_calls == 1 + assert payload["recompute_calls"] == 1 + assert "recompute_drain_ms" in payload + assert pumps["count"] >= 1 + + +def test_run_script_recompute_false_does_not_drain(monkeypatch) -> None: + document = _GatedDocument() + monkeypatch.setattr(control, "_app", lambda: SimpleNamespace(ActiveDocument=document)) + monkeypatch.setattr(control, "_gui", lambda: SimpleNamespace(updateGui=lambda: None)) + monkeypatch.setattr(control, "_document_summary", lambda _document: {"name": "stub"}) + + payload = control.run_script(python="result = 7", recompute=False) + + assert payload["ok"] is True + assert document.recompute_calls == 0 + assert "recompute_calls" not in payload + assert "recompute_drain_ms" not in payload + + +def test_run_script_drain_timeout_is_hard_failure(monkeypatch) -> None: + document = _GatedDocument(cooperative=True) + monkeypatch.setattr(control, "_app", lambda: SimpleNamespace(ActiveDocument=document)) + monkeypatch.setattr(control, "_gui", lambda: SimpleNamespace(updateGui=lambda: None)) + monkeypatch.setattr(control, "RECOMPUTE_DRAIN_TIMEOUT_S", 0.0) + + payload = control.run_script(python="result = 7") + + assert payload["ok"] is False + assert payload["failure_code"] == "RECOMPUTE_DRAIN_TIMEOUT" + assert payload["failure_stage"] == "recompute" + assert document.recompute_calls == 1 + assert payload["recompute_calls"] == 1 From f17f5152eb9dca5a327b51ff9d32cd0a54d53690 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 20:23:58 +0000 Subject: [PATCH 4/5] Harden /v1/run drain for PolarPattern Body Shape Gates-clear plus Up-to-date is not enough after open: patterned PartDesign tips can hold a stable but incomplete solid, and a later idle recompute is a no-op. After flags go idle, sample Tip/Body Shape signatures, re-issue if volume is still moving or Tip disagrees with Body, and force one rebuild for patterned tips. Co-authored-by: Jason --- src/Mod/VibeCAD/VibeCADAgentControl.py | 272 ++++++++++++++++-- .../test_agent_control_recompute_drain.py | 223 +++++++++++++- 2 files changed, 477 insertions(+), 18 deletions(-) diff --git a/src/Mod/VibeCAD/VibeCADAgentControl.py b/src/Mod/VibeCAD/VibeCADAgentControl.py index 252448d8..d6280db0 100644 --- a/src/Mod/VibeCAD/VibeCADAgentControl.py +++ b/src/Mod/VibeCAD/VibeCADAgentControl.py @@ -48,6 +48,14 @@ "MutationBlockingPresentationUpdateActive", "GuiRecomputeCoordinatorActive", ) +_RECOMPUTE_PATTERN_TYPE_MARKERS = ( + "PartDesign::PolarPattern", + "PartDesign::LinearPattern", + "PartDesign::Mirrored", + "PartDesign::MultiTransform", + "PartDesign::Transformed", +) +_RECOMPUTE_BODY_TYPE_MARKERS = ("PartDesign::Body",) AGENT_PORT_ENV = "VIBECAD_AGENT_PORT" AGENT_HOME_ENV = "VIBECAD_AGENT_HOME" TOKEN_FILENAME = "token" @@ -2982,6 +2990,168 @@ def _document_has_touched_objects(document: Any) -> bool: return False +def _object_type_id(obj: Any) -> str: + return str(getattr(obj, "TypeId", "") or getattr(obj, "Type", "") or "") + + +def _object_has_type(obj: Any, *markers: str) -> bool: + type_id = _object_type_id(obj) + if type_id in markers: + return True + derived = getattr(obj, "isDerivedFrom", None) + if not callable(derived): + return False + for name in markers: + try: + if derived(name): + return True + except Exception: + continue + return False + + +def _is_patterned_feature(obj: Any) -> bool: + return _object_has_type(obj, *_RECOMPUTE_PATTERN_TYPE_MARKERS) + + +def _is_part_design_body(obj: Any) -> bool: + if _object_has_type(obj, *_RECOMPUTE_BODY_TYPE_MARKERS): + return True + tip = getattr(obj, "Tip", None) + return tip is not None and tip is not obj + + +def _safe_shape_float(value: Any) -> float | None: + if value is None: + return None + try: + return round(float(value), 6) + except (TypeError, ValueError): + return None + + +def _shape_signature(shape: Any) -> tuple[Any, ...]: + """Cheap Tip/Body solid fingerprint. Volume is the live /v1/run metric.""" + + if shape is None: + return () + volume = _safe_shape_float(getattr(shape, "Volume", None)) + hash_code = None + hasher = getattr(shape, "hashCode", None) + if callable(hasher): + try: + hash_code = int(hasher()) + except Exception: + hash_code = None + box = getattr(shape, "BoundBox", None) + bounds = None + if box is not None: + bounds = ( + _safe_shape_float(getattr(box, "XLength", None)), + _safe_shape_float(getattr(box, "YLength", None)), + _safe_shape_float(getattr(box, "ZLength", None)), + ) + return (volume, hash_code, bounds) + + +def _pattern_rebuild_incomplete(obj: Any) -> bool: + rejected = getattr(obj, "RejectedSolidCount", None) + try: + if rejected is not None and int(rejected) > 0: + return True + except (TypeError, ValueError): + pass + occurrences = getattr(obj, "Occurrences", None) + generated = getattr(obj, "GeneratedOccurrenceCount", None) + try: + if ( + occurrences is not None + and generated is not None + and int(occurrences) > 0 + and int(generated) > 0 + and int(occurrences) != int(generated) + ): + return True + except (TypeError, ValueError): + pass + return False + + +def _solid_readiness(document: Any) -> dict[str, Any]: + """Tip/Body/pattern readiness beyond document gates and Touched flags. + + PolarPattern / Body can report Up-to-date after open while the stored + solid is still a partial fuse, or while Body.Shape still holds the + restored partner of Tip.Shape (preserveRestoredShape). + """ + + items: list[tuple[str, tuple[Any, ...]]] = [] + force_targets: list[Any] = [] + incomplete = False + tip_mismatch = False + needs_force = False + for obj in getattr(document, "Objects", None) or (): + patterned = _is_patterned_feature(obj) + body = _is_part_design_body(obj) + if not patterned and not body: + continue + needs_force = True + force_targets.append(obj) + name = str(getattr(obj, "Name", "") or id(obj)) + items.append((name, _shape_signature(getattr(obj, "Shape", None)))) + if _pattern_rebuild_incomplete(obj): + incomplete = True + tip = getattr(obj, "Tip", None) + if tip is None or tip is obj: + continue + force_targets.append(tip) + tip_name = str(getattr(tip, "Name", "") or f"{name}.Tip") + tip_sig = _shape_signature(getattr(tip, "Shape", None)) + items.append((tip_name, tip_sig)) + tip_volume = _safe_shape_float(getattr(getattr(tip, "Shape", None), "Volume", None)) + body_volume = _safe_shape_float(getattr(getattr(obj, "Shape", None), "Volume", None)) + if ( + tip_volume is not None + and body_volume is not None + and tip_volume != body_volume + ): + tip_mismatch = True + return { + "items": tuple(items), + "incomplete": incomplete, + "tip_mismatch": tip_mismatch, + "needs_force": needs_force, + "force_targets": tuple(force_targets), + } + + +def _call_recompute( + recompute_call: Any, + *, + targets: tuple[Any, ...] | list[Any] | None = None, + force: bool = False, +) -> None: + if not callable(recompute_call): + return + if force: + try: + if targets: + recompute_call(list(targets), True) + else: + recompute_call(None, True) + return + except TypeError: + pass + for obj in targets or (): + touch = getattr(obj, "touch", None) + if callable(touch): + try: + touch() + except Exception: + pass + recompute_call() + + def _pump_recompute_events(gui: Any | None = None) -> None: """Process queued GUI follow-ups on the document thread. Do not sleep.""" @@ -3020,7 +3190,9 @@ def _drain_recompute( Document::recompute can return 0 on the GUI thread and queue scheduleGuiRecomputeFollowUp while cooperative / pending / presentation gates are active. Stay on the document thread and pump Qt so that - follow-up runs before /v1/run returns success. + follow-up runs. After flags go idle, PolarPattern/Body Tip solids must + also hold a stable Shape (and a forced rebuild runs once for patterned + tips, because idle ``recompute()`` is a no-op when nothing is Touched). """ now = clock or time.monotonic @@ -3035,27 +3207,93 @@ def _drain_recompute( calls += 1 advance = pump if pump is not None else lambda: _pump_recompute_events(gui) + previous_items: tuple[tuple[str, tuple[Any, ...]], ...] | None = None + forced = False while True: advance() blocked = _recompute_gates_block(document) touched = _document_has_touched_objects(document) - if not blocked and not touched: - return { - "ok": True, - "recompute_calls": calls, - "ms": (now() - started) * 1000.0, - "error": None, - } - if now() >= deadline: - return { - "ok": False, - "recompute_calls": calls, - "ms": (now() - started) * 1000.0, - "error": "RECOMPUTE_DRAIN_TIMEOUT", - } - if not blocked and touched and callable(recompute_call): - recompute_call() + if blocked or touched: + previous_items = None + if now() >= deadline: + return { + "ok": False, + "recompute_calls": calls, + "ms": (now() - started) * 1000.0, + "error": "RECOMPUTE_DRAIN_TIMEOUT", + } + if not blocked and touched: + _call_recompute(recompute_call) + calls += 1 + continue + + readiness = _solid_readiness(document) + if readiness["incomplete"] or readiness["tip_mismatch"]: + previous_items = None + if now() >= deadline: + return { + "ok": False, + "recompute_calls": calls, + "ms": (now() - started) * 1000.0, + "error": "RECOMPUTE_DRAIN_TIMEOUT", + } + _call_recompute( + recompute_call, + targets=readiness["force_targets"], + force=True, + ) calls += 1 + forced = True + continue + + items = readiness["items"] + if items: + if previous_items is None: + previous_items = items + if now() >= deadline: + return { + "ok": False, + "recompute_calls": calls, + "ms": (now() - started) * 1000.0, + "error": "RECOMPUTE_DRAIN_TIMEOUT", + } + continue + if previous_items != items: + previous_items = None + if now() >= deadline: + return { + "ok": False, + "recompute_calls": calls, + "ms": (now() - started) * 1000.0, + "error": "RECOMPUTE_DRAIN_TIMEOUT", + } + _call_recompute(recompute_call) + calls += 1 + continue + if readiness["needs_force"] and not forced: + previous_items = None + if now() >= deadline: + return { + "ok": False, + "recompute_calls": calls, + "ms": (now() - started) * 1000.0, + "error": "RECOMPUTE_DRAIN_TIMEOUT", + } + _call_recompute( + recompute_call, + targets=readiness["force_targets"], + force=True, + ) + calls += 1 + forced = True + continue + + return { + "ok": True, + "recompute_calls": calls, + "ms": (now() - started) * 1000.0, + "error": None, + } def _json_safe(value: Any) -> Any: diff --git a/src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py b/src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py index 5d711a68..70c8197f 100644 --- a/src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py +++ b/src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py @@ -43,9 +43,12 @@ def __init__( self.PresentationUpdateActive = presentation self.Objects = list(objects or []) self.recompute_calls = 0 + self.recompute_force_calls = 0 - def recompute(self) -> None: + def recompute(self, *args: Any, **kwargs: Any) -> None: self.recompute_calls += 1 + if args or kwargs.get("force"): + self.recompute_force_calls += 1 def test_recompute_gates_use_exposed_document_proxies() -> None: @@ -202,6 +205,203 @@ def test_run_script_recompute_false_does_not_drain(monkeypatch) -> None: assert "recompute_drain_ms" not in payload +class _TickClock: + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + self.now += 0.01 + return self.now + + +class _Shape: + def __init__(self, volume: float, hash_code: int = 1) -> None: + self.Volume = volume + self._hash_code = hash_code + + def hashCode(self) -> int: + return self._hash_code + + +class _MovingShape: + def __init__(self, volumes: list[float]) -> None: + self._volumes = list(volumes) + + @property + def Volume(self) -> float: + if len(self._volumes) > 1: + return self._volumes.pop(0) + return self._volumes[0] + + def hashCode(self) -> int: + return int(self.Volume) + + +class _Feature: + def __init__( + self, + name: str, + *, + type_id: str, + volume: float | None = None, + shape: Any = None, + rejected: int = 0, + occurrences: int | None = None, + generated: int | None = None, + tip: Any = None, + ) -> None: + self.Name = name + self.TypeId = type_id + self.State = ["Up-to-date"] + self.Shape = _Shape(volume) if shape is None and volume is not None else shape + self.RejectedSolidCount = rejected + if occurrences is not None: + self.Occurrences = occurrences + if generated is not None: + self.GeneratedOccurrenceCount = generated + if tip is not None: + self.Tip = tip + + +def test_shape_signature_uses_volume_and_hash() -> None: + shape = _Shape(3925.23456789, hash_code=44) + assert control._shape_signature(shape)[0] == 3925.234568 + assert control._shape_signature(shape)[1] == 44 + + +def test_solid_readiness_ignores_plain_pads() -> None: + pad = _Feature("Pad001", type_id="PartDesign::Pad", volume=10.0) + readiness = control._solid_readiness(SimpleNamespace(Objects=[pad])) + assert readiness["items"] == () + assert readiness["needs_force"] is False + + +def test_solid_readiness_flags_tip_volume_mismatch() -> None: + tip = _Feature("PolarPattern", type_id="PartDesign::PolarPattern", volume=3925.23) + body = _Feature("Body", type_id="PartDesign::Body", volume=3793.0, tip=tip) + readiness = control._solid_readiness(SimpleNamespace(Objects=[body, tip])) + assert readiness["tip_mismatch"] is True + assert readiness["needs_force"] is True + + +def test_pattern_rebuild_incomplete_uses_diagnostics() -> None: + rejected = _Feature( + "PolarPattern", + type_id="PartDesign::PolarPattern", + volume=3793.0, + rejected=2, + ) + short = _Feature( + "PolarPattern", + type_id="PartDesign::PolarPattern", + volume=3793.0, + occurrences=6, + generated=4, + ) + ok = _Feature( + "PolarPattern", + type_id="PartDesign::PolarPattern", + volume=3925.23, + occurrences=6, + generated=6, + ) + assert control._pattern_rebuild_incomplete(rejected) is True + assert control._pattern_rebuild_incomplete(short) is True + assert control._pattern_rebuild_incomplete(ok) is False + + +def test_drain_recompute_waits_for_moving_tip_volume() -> None: + polar = _Feature( + "PolarPattern", + type_id="PartDesign::PolarPattern", + shape=_MovingShape([3793.0, 3881.0, 3925.23, 3925.23, 3925.23, 3925.23, 3925.23]), + occurrences=6, + generated=6, + ) + document = _GatedDocument(objects=[polar]) + result = control._drain_recompute( + document, + timeout_s=5.0, + pump=lambda: None, + clock=_TickClock(), + ) + assert result["ok"] is True + assert polar.Shape.Volume == 3925.23 + assert result["recompute_calls"] >= 2 + + +def test_drain_recompute_forces_when_body_tip_volumes_differ() -> None: + tip = _Feature("PolarPattern", type_id="PartDesign::PolarPattern", volume=3925.23) + body = _Feature("Body", type_id="PartDesign::Body", volume=3793.0, tip=tip) + document = _GatedDocument(objects=[body, tip]) + + def recompute(*_args: Any, **_kwargs: Any) -> None: + document.recompute_calls += 1 + if document.recompute_calls >= 2: + body.Shape.Volume = 3925.23 + + document.recompute = recompute # type: ignore[method-assign] + result = control._drain_recompute( + document, + timeout_s=5.0, + pump=lambda: None, + clock=_TickClock(), + ) + assert result["ok"] is True + assert body.Shape.Volume == 3925.23 + assert result["recompute_calls"] >= 2 + + +def test_drain_recompute_forces_when_rejected_solids_remain() -> None: + polar = _Feature( + "PolarPattern", + type_id="PartDesign::PolarPattern", + volume=3793.0, + rejected=1, + occurrences=6, + generated=6, + ) + document = _GatedDocument(objects=[polar]) + + def recompute(*_args: Any, **_kwargs: Any) -> None: + document.recompute_calls += 1 + if document.recompute_calls >= 2: + polar.RejectedSolidCount = 0 + polar.Shape.Volume = 3925.23 + + document.recompute = recompute # type: ignore[method-assign] + result = control._drain_recompute( + document, + timeout_s=5.0, + pump=lambda: None, + clock=_TickClock(), + ) + assert result["ok"] is True + assert polar.RejectedSolidCount == 0 + assert result["recompute_calls"] >= 2 + + +def test_drain_recompute_forces_once_for_stable_pattern_tip() -> None: + polar = _Feature( + "PolarPattern", + type_id="PartDesign::PolarPattern", + volume=3925.23, + occurrences=6, + generated=6, + ) + document = _GatedDocument(objects=[polar]) + result = control._drain_recompute( + document, + timeout_s=5.0, + pump=lambda: None, + clock=_TickClock(), + ) + assert result["ok"] is True + assert document.recompute_calls == 2 + assert document.recompute_force_calls >= 1 + assert result["recompute_calls"] == 2 + + def test_run_script_drain_timeout_is_hard_failure(monkeypatch) -> None: document = _GatedDocument(cooperative=True) monkeypatch.setattr(control, "_app", lambda: SimpleNamespace(ActiveDocument=document)) @@ -215,3 +415,24 @@ def test_run_script_drain_timeout_is_hard_failure(monkeypatch) -> None: assert payload["failure_stage"] == "recompute" assert document.recompute_calls == 1 assert payload["recompute_calls"] == 1 + + +def test_run_script_patterned_tip_forces_idle_rebuild(monkeypatch) -> None: + polar = _Feature( + "PolarPattern", + type_id="PartDesign::PolarPattern", + volume=3925.23, + occurrences=6, + generated=6, + ) + document = _GatedDocument(objects=[polar]) + monkeypatch.setattr(control, "_app", lambda: SimpleNamespace(ActiveDocument=document)) + monkeypatch.setattr(control, "_gui", lambda: SimpleNamespace(updateGui=lambda: None)) + monkeypatch.setattr(control, "_document_summary", lambda _document: {"name": "stub"}) + + payload = control.run_script(python="result = 7") + + assert payload["ok"] is True + assert document.recompute_calls == 2 + assert document.recompute_force_calls >= 1 + assert payload["recompute_calls"] == 2 From ce89f450e9166eabc0f436a0ec809d2f965ae994 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 20:28:53 +0000 Subject: [PATCH 5/5] Dirty Tip/Pad before the extra /v1/run rebuild Live propeller probes: idle recompute and document.recompute(force) are no-ops on an Up-to-date PolarPattern. touch(Tip)+touch(Pad) (or re-assigning the same Length) then recompute repairs the incomplete solid. After the primary drain, dirty those features and rebuild until Volume is stable across that pass. Co-authored-by: Jason --- src/Mod/VibeCAD/VibeCADAgentControl.py | 220 ++++++++++-------- .../test_agent_control_recompute_drain.py | 80 ++++++- 2 files changed, 196 insertions(+), 104 deletions(-) diff --git a/src/Mod/VibeCAD/VibeCADAgentControl.py b/src/Mod/VibeCAD/VibeCADAgentControl.py index d6280db0..32db8718 100644 --- a/src/Mod/VibeCAD/VibeCADAgentControl.py +++ b/src/Mod/VibeCAD/VibeCADAgentControl.py @@ -3089,13 +3089,13 @@ def _solid_readiness(document: Any) -> dict[str, Any]: force_targets: list[Any] = [] incomplete = False tip_mismatch = False - needs_force = False + needs_dirty = False for obj in getattr(document, "Objects", None) or (): patterned = _is_patterned_feature(obj) body = _is_part_design_body(obj) if not patterned and not body: continue - needs_force = True + needs_dirty = True force_targets.append(obj) name = str(getattr(obj, "Name", "") or id(obj)) items.append((name, _shape_signature(getattr(obj, "Shape", None)))) @@ -3120,36 +3120,97 @@ def _solid_readiness(document: Any) -> dict[str, Any]: "items": tuple(items), "incomplete": incomplete, "tip_mismatch": tip_mismatch, - "needs_force": needs_force, + "needs_dirty": needs_dirty, "force_targets": tuple(force_targets), } -def _call_recompute( - recompute_call: Any, - *, - targets: tuple[Any, ...] | list[Any] | None = None, - force: bool = False, -) -> None: - if not callable(recompute_call): - return - if force: - try: - if targets: - recompute_call(list(targets), True) - else: - recompute_call(None, True) +def _document_touched_objects(document: Any) -> tuple[Any, ...]: + touched: list[Any] = [] + for obj in getattr(document, "Objects", None) or (): + state = getattr(obj, "State", None) + if state is None: + continue + if "Touched" in list(state): + touched.append(obj) + return tuple(touched) + + +def _linked_rebuild_objects(obj: Any) -> tuple[Any, ...]: + linked: list[Any] = [] + for attr in ("Originals", "BaseFeature"): + value = getattr(obj, attr, None) + if value is None: + continue + get_values = getattr(value, "getValues", None) + if callable(get_values): + try: + value = get_values() + except Exception: + continue + if isinstance(value, (list, tuple)): + linked.extend(item for item in value if item is not None) + else: + linked.append(value) + return tuple(linked) + + +def _collect_dirty_targets( + readiness: dict[str, Any], + extra: tuple[Any, ...] | list[Any] = (), +) -> tuple[Any, ...]: + targets: list[Any] = [] + seen: set[int] = set() + + def add(obj: Any) -> None: + if obj is None: return - except TypeError: - pass - for obj in targets or (): - touch = getattr(obj, "touch", None) - if callable(touch): - try: - touch() - except Exception: - pass - recompute_call() + marker = id(obj) + if marker in seen: + return + seen.add(marker) + targets.append(obj) + + for obj in readiness.get("force_targets") or (): + add(obj) + for linked in _linked_rebuild_objects(obj): + add(linked) + for obj in extra: + add(obj) + return tuple(targets) + + +def _readiness_volume_key( + items: tuple[tuple[str, tuple[Any, ...]], ...], +) -> tuple[tuple[str, Any], ...]: + # hashCode can change on every execute; Volume is the live flake metric. + return tuple((name, signature[0] if signature else None) for name, signature in items) + + +def _touch_objects(targets: tuple[Any, ...] | list[Any]) -> int: + touched = 0 + for obj in targets: + touch = getattr(obj, "touch", None) + if not callable(touch): + continue + try: + touch() + except Exception: + continue + touched += 1 + return touched + + +def _call_recompute(recompute_call: Any) -> None: + if callable(recompute_call): + recompute_call() + + +def _dirty_and_recompute(recompute_call: Any, targets: tuple[Any, ...]) -> None: + """Live propeller: idle recompute is a no-op; touch Tip/Pad then rebuild.""" + + _touch_objects(targets) + _call_recompute(recompute_call) def _pump_recompute_events(gui: Any | None = None) -> None: @@ -3189,10 +3250,10 @@ def _drain_recompute( Document::recompute can return 0 on the GUI thread and queue scheduleGuiRecomputeFollowUp while cooperative / pending / presentation - gates are active. Stay on the document thread and pump Qt so that - follow-up runs. After flags go idle, PolarPattern/Body Tip solids must - also hold a stable Shape (and a forced rebuild runs once for patterned - tips, because idle ``recompute()`` is a no-op when nothing is Touched). + gates are active. Stay on the document thread and pump Qt so follow-up + runs. After flags go idle, a PartDesign Tip/pattern can still hold an + incomplete solid; a second idle recompute is a no-op. Dirty Tip/Pad + (``touch()``) and rebuild until Volume is stable across that pass. """ now = clock or time.monotonic @@ -3202,26 +3263,38 @@ def _drain_recompute( ) calls = 0 recompute_call = getattr(document, "recompute", None) + participants = _document_touched_objects(document) if callable(recompute_call): recompute_call() calls += 1 advance = pump if pump is not None else lambda: _pump_recompute_events(gui) - previous_items: tuple[tuple[str, tuple[Any, ...]], ...] | None = None - forced = False + pre_dirty_volumes: tuple[tuple[str, Any], ...] | None = None + + def timed_out() -> bool: + return now() >= deadline + + def fail() -> dict[str, Any]: + return { + "ok": False, + "recompute_calls": calls, + "ms": (now() - started) * 1000.0, + "error": "RECOMPUTE_DRAIN_TIMEOUT", + } + + def dirty_rebuild(readiness: dict[str, Any]) -> None: + nonlocal calls + targets = _collect_dirty_targets(readiness, participants) + _dirty_and_recompute(recompute_call, targets) + calls += 1 + while True: advance() blocked = _recompute_gates_block(document) touched = _document_has_touched_objects(document) if blocked or touched: - previous_items = None - if now() >= deadline: - return { - "ok": False, - "recompute_calls": calls, - "ms": (now() - started) * 1000.0, - "error": "RECOMPUTE_DRAIN_TIMEOUT", - } + if timed_out(): + return fail() if not blocked and touched: _call_recompute(recompute_call) calls += 1 @@ -3229,63 +3302,20 @@ def _drain_recompute( readiness = _solid_readiness(document) if readiness["incomplete"] or readiness["tip_mismatch"]: - previous_items = None - if now() >= deadline: - return { - "ok": False, - "recompute_calls": calls, - "ms": (now() - started) * 1000.0, - "error": "RECOMPUTE_DRAIN_TIMEOUT", - } - _call_recompute( - recompute_call, - targets=readiness["force_targets"], - force=True, - ) - calls += 1 - forced = True + if timed_out(): + return fail() + dirty_rebuild(readiness) + pre_dirty_volumes = None continue items = readiness["items"] - if items: - if previous_items is None: - previous_items = items - if now() >= deadline: - return { - "ok": False, - "recompute_calls": calls, - "ms": (now() - started) * 1000.0, - "error": "RECOMPUTE_DRAIN_TIMEOUT", - } - continue - if previous_items != items: - previous_items = None - if now() >= deadline: - return { - "ok": False, - "recompute_calls": calls, - "ms": (now() - started) * 1000.0, - "error": "RECOMPUTE_DRAIN_TIMEOUT", - } - _call_recompute(recompute_call) - calls += 1 - continue - if readiness["needs_force"] and not forced: - previous_items = None - if now() >= deadline: - return { - "ok": False, - "recompute_calls": calls, - "ms": (now() - started) * 1000.0, - "error": "RECOMPUTE_DRAIN_TIMEOUT", - } - _call_recompute( - recompute_call, - targets=readiness["force_targets"], - force=True, - ) - calls += 1 - forced = True + if items and readiness["needs_dirty"]: + volumes = _readiness_volume_key(items) + if pre_dirty_volumes is None or volumes != pre_dirty_volumes: + if timed_out(): + return fail() + pre_dirty_volumes = volumes + dirty_rebuild(readiness) continue return { diff --git a/src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py b/src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py index 70c8197f..00edd7c3 100644 --- a/src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py +++ b/src/Mod/VibeCAD/vibecad_tests/test_agent_control_recompute_drain.py @@ -43,12 +43,12 @@ def __init__( self.PresentationUpdateActive = presentation self.Objects = list(objects or []) self.recompute_calls = 0 - self.recompute_force_calls = 0 def recompute(self, *args: Any, **kwargs: Any) -> None: self.recompute_calls += 1 - if args or kwargs.get("force"): - self.recompute_force_calls += 1 + for obj in self.Objects: + if getattr(obj, "State", None) is not None: + obj.State = ["Up-to-date"] def test_recompute_gates_use_exposed_document_proxies() -> None: @@ -261,6 +261,12 @@ def __init__( self.GeneratedOccurrenceCount = generated if tip is not None: self.Tip = tip + self.Originals: list[Any] = [] + self.touch_calls = 0 + + def touch(self) -> None: + self.touch_calls += 1 + self.State = ["Touched"] def test_shape_signature_uses_volume_and_hash() -> None: @@ -273,7 +279,7 @@ def test_solid_readiness_ignores_plain_pads() -> None: pad = _Feature("Pad001", type_id="PartDesign::Pad", volume=10.0) readiness = control._solid_readiness(SimpleNamespace(Objects=[pad])) assert readiness["items"] == () - assert readiness["needs_force"] is False + assert readiness["needs_dirty"] is False def test_solid_readiness_flags_tip_volume_mismatch() -> None: @@ -281,7 +287,7 @@ def test_solid_readiness_flags_tip_volume_mismatch() -> None: body = _Feature("Body", type_id="PartDesign::Body", volume=3793.0, tip=tip) readiness = control._solid_readiness(SimpleNamespace(Objects=[body, tip])) assert readiness["tip_mismatch"] is True - assert readiness["needs_force"] is True + assert readiness["needs_dirty"] is True def test_pattern_rebuild_incomplete_uses_diagnostics() -> None: @@ -337,6 +343,8 @@ def test_drain_recompute_forces_when_body_tip_volumes_differ() -> None: def recompute(*_args: Any, **_kwargs: Any) -> None: document.recompute_calls += 1 + body.State = ["Up-to-date"] + tip.State = ["Up-to-date"] if document.recompute_calls >= 2: body.Shape.Volume = 3925.23 @@ -365,6 +373,7 @@ def test_drain_recompute_forces_when_rejected_solids_remain() -> None: def recompute(*_args: Any, **_kwargs: Any) -> None: document.recompute_calls += 1 + polar.State = ["Up-to-date"] if document.recompute_calls >= 2: polar.RejectedSolidCount = 0 polar.Shape.Volume = 3925.23 @@ -381,7 +390,7 @@ def recompute(*_args: Any, **_kwargs: Any) -> None: assert result["recompute_calls"] >= 2 -def test_drain_recompute_forces_once_for_stable_pattern_tip() -> None: +def test_drain_recompute_dirties_pattern_tip_once_when_already_stable() -> None: polar = _Feature( "PolarPattern", type_id="PartDesign::PolarPattern", @@ -398,10 +407,63 @@ def test_drain_recompute_forces_once_for_stable_pattern_tip() -> None: ) assert result["ok"] is True assert document.recompute_calls == 2 - assert document.recompute_force_calls >= 1 + assert polar.touch_calls >= 1 assert result["recompute_calls"] == 2 +def test_drain_recompute_dirties_until_wrong_pattern_volume_stabilizes() -> None: + polar = _Feature( + "PolarPattern", + type_id="PartDesign::PolarPattern", + volume=3837.27, + occurrences=6, + generated=6, + ) + document = _GatedDocument(objects=[polar]) + + def recompute(*_args: Any, **_kwargs: Any) -> None: + document.recompute_calls += 1 + polar.State = ["Up-to-date"] + if polar.touch_calls: + polar.Shape.Volume = 3925.23 + + document.recompute = recompute # type: ignore[method-assign] + result = control._drain_recompute( + document, + timeout_s=5.0, + pump=lambda: None, + clock=_TickClock(), + ) + assert result["ok"] is True + assert polar.Shape.Volume == 3925.23 + assert polar.touch_calls >= 1 + assert result["recompute_calls"] >= 3 + + +def test_drain_recompute_touches_tip_and_pad_originals() -> None: + pad = _Feature("Pad001", type_id="PartDesign::Pad", volume=8.0) + tip = _Feature( + "PolarPattern", + type_id="PartDesign::PolarPattern", + volume=3925.23, + occurrences=6, + generated=6, + ) + tip.Originals = [pad] + body = _Feature("Body", type_id="PartDesign::Body", volume=3925.23, tip=tip) + document = _GatedDocument(objects=[body, tip, pad]) + result = control._drain_recompute( + document, + timeout_s=5.0, + pump=lambda: None, + clock=_TickClock(), + ) + assert result["ok"] is True + assert tip.touch_calls >= 1 + assert pad.touch_calls >= 1 + assert body.touch_calls >= 1 + + def test_run_script_drain_timeout_is_hard_failure(monkeypatch) -> None: document = _GatedDocument(cooperative=True) monkeypatch.setattr(control, "_app", lambda: SimpleNamespace(ActiveDocument=document)) @@ -417,7 +479,7 @@ def test_run_script_drain_timeout_is_hard_failure(monkeypatch) -> None: assert payload["recompute_calls"] == 1 -def test_run_script_patterned_tip_forces_idle_rebuild(monkeypatch) -> None: +def test_run_script_patterned_tip_dirties_idle_rebuild(monkeypatch) -> None: polar = _Feature( "PolarPattern", type_id="PartDesign::PolarPattern", @@ -434,5 +496,5 @@ def test_run_script_patterned_tip_forces_idle_rebuild(monkeypatch) -> None: assert payload["ok"] is True assert document.recompute_calls == 2 - assert document.recompute_force_calls >= 1 + assert polar.touch_calls >= 1 assert payload["recompute_calls"] == 2