← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.79+1.9.80
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {     "manifest_version": 1,     "name": "fusion360",-    "version": "1.9.79",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.91/adom-bridge-fusion-v1.9.79.zip",-    "sha256": "3bb33e40f9a332140a43ad008d3e2cbc76b918d669db7efc07b906e6d1c3942f",-    "size": 418806,+    "version": "1.9.80",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.92/adom-bridge-fusion-v1.9.80.zip",+    "sha256": "976127b414acfdb036046e11b5b0488b0e9eec5d2c374d7fd0ca258d3dd24ef7",+    "size": 420002,     "verbPrefixes": [         "fusion_"     ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.79",+  "version": "1.9.80",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.91"+version = "1.9.92" edition = "2021"  [dependencies]
fusion_detect.py+51−1
@@ -345,11 +345,61 @@ def _has_fusion_process() -> bool:             creationflags=creationflags,         )         # tasklist returns "INFO: No tasks..." when no match-        return "Fusion360.exe" in output+        if "Fusion360.exe" not in output:+            return False+        # ⚠️ TASKLIST LISTS THE DEAD (root-caused live on ConfRoomROG, 2026-08-19).+        # A Fusion360.exe whose parent died without reaping it stays in the process+        # table as a ZOMBIE: tasklist AND WMI still show it (1GB working set, 1+        # thread), but HasExited is True and taskkill answers "There is no running+        # instance of the task". Trusting tasklist alone made this bridge report+        # running:true forever, which meant fusion_start returned "already running"+        # and NEVER LAUNCHED, every kill failed, and readiness waited on an add-in+        # that could never come. The box was unusable for hours with no error+        # anywhere. So confirm at least one PID is GENUINELY alive.+        pids = []+        for line in output.splitlines():+            parts = [p.strip('"') for p in line.split('","')]+            if len(parts) > 1:+                try:+                    pids.append(int(parts[1]))+                except Exception:+                    pass+        if not pids:+            return "Fusion360.exe" in output     # cannot parse -> old behaviour+        return any(_pid_alive(p) for p in pids)     except Exception:         return False  +_STILL_ACTIVE = 259+_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000+++def _pid_alive(pid: int) -> bool:+    """Is this PID a LIVE process, or a zombie the process table has not let go of?++    GetExitCodeProcess is the ground truth: a zombie returns its real exit code,+    a live process returns STILL_ACTIVE (259). If the handle cannot be opened we+    answer True - an access-denied process is running, just not ours to inspect,+    and guessing "dead" there would make the bridge launch a second Fusion.+    """+    try:+        k32 = ctypes.WinDLL("kernel32", use_last_error=True)+        h = k32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))+        if not h:+            return True+        try:+            code = ctypes.wintypes.DWORD()+            ok = k32.GetExitCodeProcess(h, ctypes.byref(code))+            if not ok:+                return True+            return code.value == _STILL_ACTIVE+        finally:+            k32.CloseHandle(h)+    except Exception:+        return True++ def _find_windows_by_title(substring: str) -> list:     """Find all visible windows whose title contains the given substring."""     results = []
handlers/close_fusion.py+20−11
@@ -116,7 +116,7 @@ def _force_kill_fusion() -> int:     # Escalate on survivors: per-PID, whole tree.     try:         time.sleep(1.0)-        for pid in _fusion_pids():+        for pid in _live_fusion_pids():             try:                 r = subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],                                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)@@ -129,16 +129,25 @@ def _force_kill_fusion() -> int:     return killed  +def _live_fusion_pids() -> list:+    """Surviving pids that are GENUINELY alive - a zombie is not something to kill."""+    try:+        from fusion_detect import _pid_alive+    except Exception:+        return _fusion_pids()+    return [p for p in _fusion_pids() if _pid_alive(p)]++ def _has_fusion_process() -> bool:-    """Check if any Fusion process is still running."""+    """Is a GENUINELY LIVE Fusion process still running?++    Deliberately not a raw tasklist check: a zombie Fusion360.exe (exited, parent+    gone, never reaped) still appears there, and counting it made a successful kill+    report "Some Fusion processes survived force-kill" forever - a failure nobody+    could act on, because there was nothing left to kill (ConfRoomROG, 2026-08-19).+    """     try:-        output = subprocess.check_output(-            ["tasklist", "/FI", "IMAGENAME eq Fusion360.exe", "/FO", "CSV", "/NH"],-            stderr=subprocess.DEVNULL,-            text=True,-            timeout=5,-        )-        return "Fusion360.exe" in output+        return bool(_live_fusion_pids())     except Exception:         return False @@ -250,7 +259,7 @@ def handle_close_fusion(fusion_info: dict, args: dict) -> dict:                           "hand this to the user as a Task Manager chore: check whether the survivor "                           "is a crash-report host (fusion_readiness reports fusionCrashed) or is "                           "pinned by a modal, and re-call fusion_kill after clearing that."),-                "survivingPids": _fusion_pids(),+                "survivingPids": _live_fusion_pids(),             }         return {             "success": True,@@ -394,7 +403,7 @@ def handle_fusion_kill(fusion_info: dict, args: dict) -> dict:                       "this to the user as a Task Manager chore: check whether the survivor is a "                       "crash-report host (fusion_readiness reports fusionCrashed) or is pinned by a "                       "modal, and re-call fusion_kill after clearing that."),-            "survivingPids": _fusion_pids(),+            "survivingPids": _live_fusion_pids(),         }     return {         "success": True,
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.91"+DASH_VERSION="1.9.92" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.91",+  "version": "1.9.92",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+11−8
@@ -1,16 +1,20 @@ {   "slug": "fusion-bridge",-  "version": "1.9.91",+  "version": "1.9.92",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {-    "adom/adom-desktop": "*"+    "adom/adom-desktop": "^1.9.0"   },   "scripts": {     "install": "./install.sh",     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -70,7 +74,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -99,9 +106,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }+  ] }\ No newline at end of file
timings.py+7−1
@@ -153,8 +153,14 @@ def progress_block(phase, started, step_label, percent=None, start_kind=None,         if not est0:             percent = 0         elif not overdue:-            percent = int(100 * elapsed / est0)+            # Runs 0 -> 80 across the estimate. It deliberately does NOT reach 100: the+            # estimate is a p50, so half of all runs are still going at this point.+            percent = int(80 * elapsed / est0)         else:+            # Continues 80 -> 95 from exactly where the branch above ended. The two+            # formulas MUST meet at elapsed == est0, or percent walks BACKWARDS at the+            # crossover - which it did (91% then 84% on a live poll, 2026-08-19), breaking+            # the contract's monotonic rule with the very code meant to honour it.             over = elapsed - est0             percent = int(80 + 15 * (over / (over + est0)))     # 80 -> 95, never 100     percent = max(0, min(99 if est else 0, int(percent)))