← Commit history

v0.1.19: pre-empt script modals - cross-workspace residency check + in-flight watcher

John Lauer ·72db7b886b ·1mo ago ·parent 07d26aa
7 files changed +125−9
CHANGELOG.md+6
@@ -1,5 +1,11 @@ # Changelog +## 0.1.19 — 2026-09-01+- **Pre-emption, properly.** John: *"you better pre-empt these from ever showing up"*. Two changes so a script modal cannot reach the user:+  1. **A script lane now refuses when ANY resident loop is alive, in ANY workspace** — not just the current instance's. That gap is exactly how "Another script executing now" got on screen after per-instance workspaces landed: the pre-upgrade loop was still resident under the legacy path, `is_alive()` looked only at `agent/<key>`, saw nothing, and fired a bootstrap into an engine that was already running a script. The refusal names the workspace and heartbeat age so the fix is obvious.+  2. **A watcher runs FOR THE DURATION of every `-R` lane** and presses OK the instant one of our own modals appears, so it exists for a few hundred milliseconds instead of sitting on screen until someone goes looking. Cleared modals come back as `modalsPreempted` rather than being hidden. The watcher only ever touches modals this bridge CAUSES (script-busy, another-instance, scripting-system); a save prompt or a licence question is the user's and is never pressed.+- Detection was always the weaker half: the engine is single-threaded, so the modal it raises is also what freezes it — which is precisely why watching during the operation works.+ ## 0.1.18 — 2026-09-01 - **Instances.** More than one Altium can run at once — John runs Altium Designer Develop beside Altium Designer so two AI threads can drive both — and the bridge assumed one in every place it mattered. New verb `altium_instances` lists each running Altium with the pid/key to address it; every other verb takes an optional `instance` (a pid, a key like `ADDevelop`, or a word from the edition/title). One Altium running: omit it, nothing changes. Several: verbs REFUSE with `ambiguous_instance` rather than guess, because guessing means acting inside the other thread's Altium. - **Each instance gets its own agent workspace** (`bridges-cache/altium/agent/<key>`), keyed by install folder so it survives a restart. Two resident loops sharing one `request.txt` is the failure that does not announce itself: whichever loop notices first eats the other's request and answers it from the wrong board.
adom-bridge-altium-manifest.json+4−4
@@ -1,9 +1,9 @@ {   "name": "altium",-  "version": "0.1.18",-  "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.18/adom-bridge-altium-v0.1.18.zip",-  "sha256": "c3cdd963c5b5a872ae83885b69971abc212d1e2115a0299cddb4bd8144a501ce",-  "size": 110023,+  "version": "0.1.19",+  "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.19/adom-bridge-altium-v0.1.19.zip",+  "sha256": "21da53c635db6983af65ef53b0102e76edbd3c9090af348d070262b676ee8b9e",+  "size": 111520,   "verbPrefixes": [     "altium_"   ],
adom-bridge-altium-v0.1.19.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.1.18",+  "version": "0.1.19",   "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.",   "homepage": "https://wiki.adom.inc/adom/altium-bridge",   "docs": "https://wiki.adom.inc/adom/altium-bridge",
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.1.18",+  "version": "0.1.19",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.1.18+0.1.19
src/handlers/agent.py+112−2
@@ -302,9 +302,94 @@ def _reap_busy_notice(wait_sec: float = 4.0) -> list:     return reaped  ++# ---- pre-emption: never fire a script lane that can raise a modal (John, 2026-09-01) ---------++def _any_agent_alive(max_age_sec: float = ALIVE_SEC) -> dict | None:+    """Is ANY resident loop alive, in ANY workspace? Returns {workspace, ageSec, gen} or None.++    `is_alive()` only checks the CURRENT instance's workspace. That is exactly how the "Another+    script executing now" modal reached John's screen after per-instance workspaces landed: the+    pre-upgrade loop was still resident under the legacy path, this module was looking at+    `agent/AD26`, saw no heartbeat, and fired a -R bootstrap into an engine already running a+    script. A script lane must clear EVERY workspace, not just its own."""+    root = cache_dir() / "agent"+    candidates = [root / "hb.txt"]+    try:+        candidates += [d / "hb.txt" for d in root.iterdir() if d.is_dir()]+    except Exception:+        pass+    for hb in candidates:+        try:+            age = time.time() - hb.stat().st_mtime+        except Exception:+            continue+        if age <= max_age_sec:+            gen = None+            try:+                for line in hb.read_text(errors="replace").splitlines():+                    if line.startswith("gen="):+                        gen = line.split("=", 1)[1].strip()+            except Exception:+                pass+            return {"workspace": str(hb.parent), "ageSec": round(age, 2), "gen": gen}+    return None+++# Modals this bridge CAUSES. Only these are auto-cleared while an operation is in flight; anything+# else (a save prompt, a licence question) belongs to the user and must never be pressed.+_OURS = ("another script executing", "another instance of", "is busy at the moment",+         "scripting system is not able")+++def _watch_and_clear(stop_evt, found: list, poll_sec: float = 0.4) -> None:+    """Run WHILE a script lane is in flight: the instant one of OUR modals appears, press OK.++    Detection alone is not pre-emption. The engine is single-threaded, so the modal it raises is+    also what freezes it — watching during the operation means the dialog lives a few hundred+    milliseconds instead of sitting on the user's screen until someone goes looking."""+    from handlers import dialogs as _dl+    while not stop_evt.is_set():+        try:+            for d in _dl.scan():+                blob = f"{d.get('title', '')} {d.get('body', '')}".lower()+                if not any(k in blob for k in _OURS):+                    continue+                caller_identity.ad_callback("desktop_ui_click",+                                            {"hwnd": d.get("hwnd"), "name": "OK", "role": "button"},+                                            on_behalf=False, timeout=8)+                found.append({"title": d.get("title"), "body": (d.get("body") or "")[:120],+                              "clearedInFlight": True})+        except Exception:+            pass+        stop_evt.wait(poll_sec)+++def _guarded(fn):+    """Run `fn()` with the modal watcher live. Returns (result, cleared[])."""+    import threading+    evt = threading.Event()+    cleared: list = []+    th = threading.Thread(target=_watch_and_clear, args=(evt, cleared), daemon=True)+    th.start()+    try:+        return fn(), cleared+    finally:+        evt.set()+        th.join(timeout=2.0)++ def run_script_lane(proc_name: str, prjscr: str | None = None, timeout: float = 30.0) -> dict:     """The -R lane: forward RunScript to the running instance. REFUSES while the     agent is resident (one script at a time — see module docstring)."""+    other = _any_agent_alive()+    if other:+        return {"success": False, "errorCode": "agent_resident", "resident": other,+                "error": "a resident agent loop is already running in this Altium (workspace "+                         f"{other['workspace']}, heartbeat {other['ageSec']}s ago) - a second script "+                         "would raise Altium's 'Another script executing now' modal on the user's screen",+                "_hint": "altium_agent_stop, or altium_agent_recover if it is wedged, then retry. A workspace "+                         "path that is not this instance's means a loop from before an upgrade: stopping it is safe."}     if is_alive():         return {"success": False, "errorCode": "agent_resident",                 "error": "The Adom agent loop is resident; a second script would raise Altium's "@@ -320,19 +405,32 @@ def run_script_lane(proc_name: str, prjscr: str | None = None, timeout: float =         p = _spawn_helper(exe, arg)     except Exception as e:         return {"success": False, "error": f"could not launch X2 helper: {e}"}+    # PRE-EMPT: watch for OUR OWN modals for as long as the helper is in flight and press OK the+    # instant one appears. The user should never have to find it. Anything not in _OURS (a save+    # prompt, a licence question) is left strictly alone.+    import threading as _th+    _evt = _th.Event()+    _cleared: list = []+    _w = _th.Thread(target=_watch_and_clear, args=(_evt, _cleared), daemon=True)+    _w.start()     # The helper exits once the running instance accepted the request (or shows its     # own "another instance is busy" dialog and hangs — we cap that).     t0 = time.time()     while time.time() - t0 < timeout:         if p.poll() is not None:-            return {"success": True, "helperExitCode": p.returncode, "helperSec": round(time.time() - t0, 2), "arg": arg}+            _evt.set(); _w.join(timeout=2.0)+            r = {"success": True, "helperExitCode": p.returncode, "helperSec": round(time.time() - t0, 2), "arg": arg}+            if _cleared:+                r["modalsPreempted"] = _cleared+            return r         time.sleep(0.2)     try:         p.kill()     except Exception:         pass+    _evt.set(); _w.join(timeout=2.0)     reaped = _reap_busy_notice()-    return {"success": False, "errorCode": "instance_busy",+    return {"success": False, "errorCode": "instance_busy", "modalsPreempted": _cleared,             "error": "The X2 helper never exited: the running instance refused the request as busy "                      "(a modal is open, or a script is already running).",             "busyNoticeReaped": reaped,@@ -341,6 +439,14 @@ def run_script_lane(proc_name: str, prjscr: str | None = None, timeout: float =  def run_script_text_lane(text: str, timeout: float = 30.0) -> dict:     """-R RunScriptText: STATEMENTS ONLY (Begin ... End;). Refused while the agent is resident."""+    other = _any_agent_alive()+    if other:+        return {"success": False, "errorCode": "agent_resident", "resident": other,+                "error": "a resident agent loop is already running in this Altium (workspace "+                         f"{other['workspace']}, heartbeat {other['ageSec']}s ago) - a second script "+                         "would raise Altium's 'Another script executing now' modal on the user's screen",+                "_hint": "altium_agent_stop, or altium_agent_recover if it is wedged, then retry. A workspace "+                         "path that is not this instance's means a loop from before an upgrade: stopping it is safe."}     if is_alive():         return {"success": False, "errorCode": "agent_resident",                 "_hint": "Use altium_eval (goes through the agent) while the agent is up."}@@ -398,6 +504,10 @@ def start(wait_sec: float = 45.0) -> dict:     r = run_script_lane("StartAgent")     if killed:         r["strayHelpersKilled"] = killed+    if r.get("modalsPreempted"):+        # the bridge caused these and the bridge cleared them, in flight — say so rather than+        # letting them look like something the user has to deal with+        r.setdefault("_note", f"{len(r['modalsPreempted'])} modal(s) pre-empted during the bootstrap")     if not r.get("success") and r.get("errorCode") == "instance_busy" and not _no_recover:         rec = recover("helper reported busy on start")         r = run_script_lane("StartAgent")