← All Pull Requests

Agent close watchdog: exit the resident loop the moment a user-initiated Altium close begins (#4 mitigation) #2

Closed opened by Colby Knox 2026-09-02

Mitigation for the user-initiated half of #4. Base: 0.2.5 source in this repo. One file, src/handlers/agent.py.

The gap

altium_close already stops the agent BEFORE its WM_CLOSE, so the verb path is safe. But a user pressing the window X goes straight to Altium: shutdown then unloads the scripting engine under a LIVE agent loop, and the close can wedge. That is the #4 incident: X2 lingered headless after the X, and the machine hard-froze until the process was killed.

The change

A close watchdog, armed on both success paths of agent.start(): a daemon thread that, while the agent is resident, polls every 2s for the CLOSING state (main window gone while the process still lives, exactly what #4 observed) and drops the agent stop file that instant, so the loop's next tick exits it during the shutdown instead of wedging under it.

  • Read-only detection (running_pids + altium_windows) plus a file drop: no Altium call, no focus, nothing for a healthy session to notice.
  • Exits by itself when the agent stops normally (heartbeat gone) or Altium is fully gone; idempotent re-arm on the next agent start.
  • saw_window guards the cold-start phase (pid up, window not yet created), so it can never fire during a launch.

Honest limits

This is a race, not a guarantee: if the engine teardown wedges the loop before its next tick, the stop-file drop lands too late. It shrinks the window from "always exposed" to "a 2s poll", which should catch the common case where the user closes an idle Altium. The full fix likely needs the agent itself to notice shutdown (DelphiScript side), which I did not touch: that template is actively churning and an untested exception path there is its own modal risk.

Testing

I cannot run this (Linux container; the bridge needs Windows + Altium). It parses clean and only uses existing primitives (is_alive, running_pids, altium_windows, the stop file). Suggested verification on a Windows box: altium_launch, altium_agent_start, then press the X on Altium yourself; the close should complete and agent_status should show the loop gone. Then the #4 repro (close with the agent resident) should no longer wedge an idle session.

Diff Skip to comments (1)

--- a/src/handlers/agent.py+++ b/src/handlers/agent.py@@ -1,756 +1,814 @@⋯ 485 unchanged lines ⋯             stop()             time.sleep(1.0)         else:+            arm_close_watchdog()             return {"success": True, "alreadyRunning": True, "status": status()}     # a stale heartbeat / inbox / responses from a previous life must not confuse this one     ws = workspace_dir()⋯ 35 unchanged lines ⋯             restored = 0             if user_fg and not _is_altium_hwnd(user_fg):                 restored = _restore_fg(user_fg, 3.0)+            arm_close_watchdog()             return {"success": True, "startedInSec": round(time.time() - t0, 2), "install": inst, "status": status(),                     "windowEtiquette": "background" if restored or _fg() == user_fg else "foreground",                     "focusRestores": restored}⋯ 17 unchanged lines ⋯     return {"success": False, "error": "agent did not stop", "_hint": "the loop may be frozen behind a modal — altium_check_dialogs"}  +# ---- user-initiated-close watchdog -----------------------------------------------+# altium_close tears the agent down BEFORE its WM_CLOSE, but a user pressing the+# window X goes straight to Altium: shutdown then unloads the scripting engine+# under a LIVE agent loop, and the close can wedge -- X2 lingered headless and+# hard-froze the desktop until it was killed (#4, 2026-09-02). While the agent+# is resident, watch for the closing state (main window gone, process still+# alive) and drop the stop file THAT INSTANT so the loop's next tick exits it+# during the shutdown instead of wedging under it. Read-only detection plus a+# file drop: no Altium call, no focus, nothing for a healthy session to notice.++_close_watch_lock = threading.Lock()+_close_watch: threading.Thread | None = None+++def _close_watch_loop() -> None:+    global _close_watch+    saw_window = False+    try:+        while True:+            time.sleep(2.0)+            if not is_alive():+                return  # normal stop, or the loop died with Altium+            if not altium_detect.running_pids():+                return  # Altium fully gone; nothing left to guard+            if altium_detect.altium_windows():+                saw_window = True+                continue+            if saw_window:+                # Window gone, process alive, agent resident: a close is in+                # progress. Exit the loop NOW, before the engine teardown+                # wedges under it. If the loop is already past helping, the+                # drop is harmless.+                try:+                    (workspace_dir() / "stop").write_text("1")+                except OSError:+                    pass+                return+    finally:+        with _close_watch_lock:+            _close_watch = None+++def arm_close_watchdog() -> None:+    """Keep a daemon watching for a user-initiated Altium close while the agent+    is resident. Idempotent; the thread exits by itself when the agent or+    Altium stops, and re-arms on the next agent start."""+    global _close_watch+    with _close_watch_lock:+        if _close_watch and _close_watch.is_alive():+            return+        _close_watch = threading.Thread(target=_close_watch_loop, daemon=True,+                                        name="altium-agent-close-watchdog")+        _close_watch.start()++ # ---- focus etiquette ------------------------------------------------------------- # Measured 2026-08-29: the -R RunScript bootstrap opens AdomAgent.PrjScr in the # editor and ACTIVATES Altium's window — a foreground steal. Capture the user's⋯ 198 unchanged lines ⋯                       "for a dialog it opened." if alive else                       "Heartbeat stopped: a modal or a script error froze the engine. altium_check_dialogs, "                       "dismiss, then altium_agent_start.")}+

Comments

John Lauer · 1mo ago

Thank you — the design is exactly right and it is shipped, but not from this PR: the altium-bridge runtime has been Rust since 0.2.0 (bridge-rs/ → altium-bridge.exe, spawn.kind: "exe"). src/server.py and src/handlers/ stay in the repo as the behavioural reference only; nothing under src/handlers/ ships or runs any more, so a merge here would change no user's machine. (That was not visible from the PR base — my fault for not flagging the swap on the page; the 0.2.0 changelog entry is the record.)

Your watchdog is ported line-for-line in intent to bridge-rs/src/lifecycle.rs (start_close_watchdog, 0.2.8, published + live on John's box):

  • standing service at bridge boot (beside the dialog reaper), 2 s poll, read-only detection (instance snapshot: per-instance workspace with a live heartbeat whose main window has vanished while an X2 process still lives) + a stop-file drop — no Altium call, no focus;
  • saw_window semantics kept (a workspace must have been SEEN with a window; cold starts never fire it), per-instance (AD26 / ADDevelop workspaces are separate);
  • every firing is logged in altium_dialog_log so it is auditable;
  • same honest limit stated in the changelog: a 2 s race, not a guarantee.

Closing this PR since the Python tree is retired; the credit is in the changelog and the code comment. If you want to keep working on the bridge, bridge-rs/src/ is the target — cargo test then scripts/release.py, and the altium-bridge-dev skill has the deploy loop (including the exe-lock swap trap).

Log in to comment.