← Commit history

0.2.36: modal-orphan escape (posted WM_QUIT keeps the licence seat), full save-prompt exchange on close, no -R storm per close, re-send a swallowed agent bootstrap

John Lauer ·68edaa263f ·1mo ago ·parent 0c19fa1
9 files changed +260−28
bridge-rs/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "altium-bridge"-version = "0.2.34"+version = "0.2.36" edition = "2021" description = "Adom altium-bridge — the bridge process, in Rust. Calls ab's verbs for the desktop; speaks file IPC to the resident DelphiScript agent inside Altium." license = "MIT"
bridge-rs/src/lifecycle.rs+99−15
@@ -423,7 +423,7 @@ fn start_modal_watcher() -> (std::sync::Arc<std::sync::atomic::AtomicBool>, std:  // ---- start / stop / recover -------------------------------------------------- -pub fn start(wait: Duration) -> Value {+pub fn start(mut wait: Duration) -> Value {     // NEVER bootstrap into the legacy workspace. Every hard freeze in adom/altium-bridge#6     // traces back to an agent that started with no instance resolved (auto-launch cold starts,     // and once a transient resolve miss right after an Altium restart): the loop lands in the@@ -495,21 +495,54 @@ pub fn start(wait: Duration) -> Value {     if let Some(fg) = user_fg {         restore_foreground(fg);     }-    while t0.elapsed() < wait {-        if agent::is_alive() {-            if let Some(fg) = user_fg {-                restore_foreground(fg);+    // The RunScript hand-off can be ACCEPTED and then quietly dropped while Altium is busy with+    // something of its own — its own startup, or unloading the script it was just told to stop.+    // Measured 2026-09-21 on AD 26.10.1: the first start after a launch, and the verifying start+    // inside `recover` right after a halt, each missed a 40-45 s window and reported no_heartbeat+    // on a perfectly healthy engine (the very next verb started it). A dropped hand-off is not a+    // failure worth surfacing — it is worth sending again. One retry, then the honest answer.+    let mut retried = false;+    loop {+        while t0.elapsed() < wait {+            if agent::is_alive() {+                if let Some(fg) = user_fg {+                    restore_foreground(fg);+                }+                let mut out = json!({"success": true, "startedInSec": t0.elapsed().as_secs_f64(),+                              "generation": resident_generation(), "install": inst});+                if retried {+                    out["resentBootstrap"] = json!(true);+                    out["_hint"] = json!("The first RunScript hand-off was swallowed (Altium was busy); the bridge sent it again and the agent came up. Nothing for the caller to do.");+                }+                return out;             }-            return json!({"success": true, "startedInSec": t0.elapsed().as_secs_f64(),-                          "generation": resident_generation(), "install": inst});+            std::thread::sleep(Duration::from_millis(250));+        }+        let dialogs = crate::dialogs::scan();+        // A dialog is a real answer (a compile error behind a modal): never paper over it.+        if retried || !dialogs.is_empty() || !crate::health::is_running() {+            return json!({"success": false, "errorCode": "no_heartbeat",+                   "error": format!("agent did not heartbeat within {}s{}", t0.elapsed().as_secs(), if retried { ", including one re-sent bootstrap" } else { "" }),+                   "dialogs": dialogs, "resentBootstrap": retried,+                   "_hint": "a compile error halts the engine behind a script Error modal — check dialogs, then agent_recover"});+        }+        if let Some(orphan) = modal_orphan() {+            return json!({"success": false, "errorCode": "altium_modal_orphan", "orphan": orphan,+                   "_hint": "Altium is wedged inside a modal loop whose dialog is gone, so it can never run the agent script: its main window is DISABLED and nothing is on screen to answer. altium_close {discardChanges:true} ends it gracefully and RELEASES the licence seat."});         }-        std::thread::sleep(Duration::from_millis(250));+        retried = true;+        let again = run_script_lane("StartAgent", Duration::from_secs(30));+        if again.get("success").and_then(|b| b.as_bool()) != Some(true) {+            return json!({"success": false, "errorCode": "no_heartbeat",+                   "error": "the agent did not heartbeat, and re-sending the bootstrap failed",+                   "resendLane": again, "resentBootstrap": true,+                   "_hint": "check dialogs, then agent_recover"});+        }+        if let Some(fg) = user_fg {+            restore_foreground(fg);+        }+        wait = t0.elapsed() + std::cmp::max(wait / 2, Duration::from_secs(25));     }-    let dialogs = crate::dialogs::scan();-    json!({"success": false, "errorCode": "no_heartbeat",-           "error": format!("agent did not heartbeat within {}s", wait.as_secs()),-           "dialogs": dialogs,-           "_hint": "a compile error halts the engine behind a script Error modal — check dialogs, then agent_recover"}) }  pub fn stop() -> Value {@@ -558,6 +591,50 @@ pub fn stop_all_live() -> Value {            "stillAlive": stragglers, "_hint": "agent_recover"}) } +/// Is Altium stuck inside a modal loop whose DIALOG IS GONE? Windows disables a process's other+/// top-level windows for the duration of a modal dialog, so "main window disabled + nothing on+/// screen to answer" means the app is waiting on a box that no longer exists. In that state+/// Altium accepts no input at all: no menu, no Alt+F4, no WM_CLOSE, and every `-R` hand-off comes+/// back as "There is another instance ... but it is busy" — which is exactly how arav-rog held the+/// single licence seat hostage for a whole night on 2026-09-21 after a close-time save prompt.+/// Returns `{pid, tid, hwnd}` for the stuck instance.+pub fn modal_orphan() -> Option<Value> {+    let rows = crate::win32::visible_windows()?;+    let pids = crate::win32::pids_of("X2.EXE").unwrap_or_default();+    let pid = pids.into_iter().find(|p| crate::win32::pid_main_window_disabled(*p, &rows))?;+    // A dialog we can SEE is not an orphan: it is a question, and answering it is the right fix.+    if !crate::dialogs::scan().is_empty() {+        return None;+    }+    let tid = crate::win32::pid_ui_thread(pid, &rows)?;+    let hwnd = rows.iter().find(|w| w.pid == pid && w.class == "TDocumentForm").map(|w| w.hwnd);+    Some(json!({"pid": pid, "tid": tid, "hwnd": hwnd}))+}++/// Leave the message loop the way Altium's own Exit does (posted WM_QUIT = Application.Terminate),+/// NOT a process kill: the app runs its shutdown and RELEASES the licence seat. Measured on+/// AD 26.10.1, 2026-09-21: the orphaned instance exited in under 8 s and the next launch was+/// licensed. Never prompts, so callers gate it on the user having declared the documents theirs.+pub fn graceful_terminate(orphan: &Value, wait: Duration) -> Value {+    let tid = orphan.get("tid").and_then(|t| t.as_u64()).unwrap_or(0) as u32;+    let pid = orphan.get("pid").and_then(|p| p.as_u64()).unwrap_or(0) as u32;+    if tid == 0 {+        return json!({"success": false, "error": "no UI thread for the stuck instance"});+    }+    let posted = crate::win32::post_quit_to_thread(tid);+    let t0 = Instant::now();+    while t0.elapsed() < wait {+        if !crate::win32::pids_of("X2.EXE").unwrap_or_default().contains(&pid) {+            instance::invalidate_snapshot();+            return json!({"success": true, "route": "wm_quit", "posted": posted,+                          "exitedInSec": t0.elapsed().as_secs_f64(), "seatReleased": true});+        }+        std::thread::sleep(Duration::from_millis(400));+    }+    json!({"success": false, "route": "wm_quit", "posted": posted,+           "error": "the stuck instance did not exit after a posted WM_QUIT"})+}+ /// Un-halt the script engine: press OK on the script Error modal, stop the editor's script, /// clear stale files. Costs the user a modal, so only on a real halt. pub fn recover(reason: &str) -> Value {@@ -572,6 +649,12 @@ pub fn recover_without_restart(reason: &str) -> Value {  fn recover_impl(reason: &str, verify_by_restart: bool) -> Value {     let mut steps: Vec<Value> = vec![json!({"reason": reason})];+    // A modal orphan answers NOTHING: every -R below would bounce off "another instance is busy"+    // and each bounce spawns a helper X2. Say so instead of thrashing for 10 s.+    if let Some(orphan) = modal_orphan() {+        return json!({"success": false, "errorCode": "altium_modal_orphan", "orphan": orphan, "steps": steps,+            "_hint": "Altium is stuck inside a modal loop whose dialog is gone: its main window is DISABLED and nothing is on screen to answer. No menu, keystroke or -R hand-off can reach it, and it is still holding the licence seat. altium_close {discardChanges:true} now ends it gracefully (a posted WM_QUIT — Altium's own shutdown, which RELEASES the seat; not a kill). Only pass discardChanges when the open documents are yours."});+    }     // 1. the script Error modal, if any     for d in crate::dialogs::scan() {         if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {@@ -647,7 +730,7 @@ fn recover_impl(reason: &str, verify_by_restart: bool) -> Value {     instance::invalidate_snapshot();     let mut verified = Value::Null;     if verify_by_restart && crate::health::is_running() && instances_after <= 1 {-        let started = start(Duration::from_secs(40));+        let started = start(Duration::from_secs(45));         let ok = started.get("success").and_then(|b| b.as_bool()) == Some(true) && agent::is_alive();         verified = json!(ok);         steps.push(json!({"verifiedByRestartingTheAgent": ok}));@@ -655,7 +738,8 @@ fn recover_impl(reason: &str, verify_by_restart: bool) -> Value {     let freed = verified.as_bool().unwrap_or(true);     json!({"success": freed, "engineFree": verified, "steps": steps,            "errorCode": if freed { Value::Null } else { json!("engine_still_parked") },-           "_hint": if freed { "The script engine is free and a fresh agent loop is running (verified by starting it)." }+           "_hint": if !verify_by_restart { "Sent the stop to every agent script; NOT verified (no agent loop was started, because the caller is about to close Altium)." }+                    else if freed { "The script engine is free and a fresh agent loop is running (verified by starting it)." }                     else { "The engine is STILL parked: a new agent loop could not start. Look at the screen (desktop_screenshot_screen): a script document must be the active document for Altium to accept the stop. Click the agent script's tab in Altium, then altium_agent_recover again. Do not altium_close yet: Altium refuses to close over a parked script." }}) } 
bridge-rs/src/main.rs+1−1
@@ -29,7 +29,7 @@ use tiny_http::{Header, Method, Response, Server}; /// Attached to every failed reply (see the /command handler). const REPORT_ISSUES: &str = "If this looks like an altium-bridge BUG (not a refusal you expected), report it: check for a duplicate with `adom-wiki issue list adom/altium-bridge`, then `adom-wiki issue create adom/altium-bridge --title \"<verb>: <one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON (and errorShot if present), bridgeVersion, the Altium version from altium_status, the OS>\"`. A relay, routing, toast or lifecycle problem belongs on adom/adom-bridge instead. Reports from real machines are how this bridge gets correct: three different Altium builds behaved three different ways in one weekend."; -pub const BRIDGE_VERSION: &str = "0.2.34";+pub const BRIDGE_VERSION: &str = "0.2.36";  fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {     let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
bridge-rs/src/misc.rs+49−1
@@ -288,6 +288,9 @@ pub fn close(args: &Value) -> Value {     // X-press (a close dispatched over whatever loops are resident) so the freeze fix stays     // re-testable on every release. Altium's themed title bar exposes no UIA close button and     // synthetic input is approval-gated, so the gesture cannot be reproduced from outside.+    // Parked = no loop was alive BEFORE the teardown below. Asked after it, every close looked+    // parked (the teardown itself makes is_alive false) and paid ~10 s of pointless -R hand-offs.+    let loop_was_alive = agent::is_alive();     let agent_teardown = if args.get("unsafeSkipTeardown").and_then(|b| b.as_bool()) == Some(true) {         json!({"skipped": "unsafeSkipTeardown — the issue-6 acceptance lever: the close goes out over any resident loop, exactly like a user X-press. Never use outside a freeze acceptance run."})     } else {@@ -304,7 +307,7 @@ pub fn close(args: &Value) -> Value {     // document when a script is stopped on a breakpoint", measured on two builds 2026-09-20/21), and     // Altium Develop parks silently. Free the engine first; it is a no-op when nothing is parked.     let mut engine_freed = Value::Null;-    if !agent::is_alive() && agent::is_agent_script_title(&instance::main_window().and_then(|w| w.get("title").and_then(|t| t.as_str()).map(str::to_string)).unwrap_or_default()) {+    if !loop_was_alive && agent::is_agent_script_title(&instance::main_window().and_then(|w| w.get("title").and_then(|t| t.as_str()).map(str::to_string)).unwrap_or_default()) {         engine_freed = lifecycle::recover_without_restart("altium_close: the script engine was parked on a breakpoint");     }     lifecycle::note_close_initiated();@@ -365,10 +368,35 @@ pub fn close(args: &Value) -> Value {                     for d in dl.iter().filter(|d| is_save(d)) {                         if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {                             let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("").to_lowercase();+                            // "Save None" does NOT dismiss this dialog: it unticks every document+                            // and RENAMES the same window to "Confirm Not Saving (N) Modified+                            // Documents", which OK then commits (measured 2026-09-21, AD 26.10.1).+                            // Leaving the second press to the next poll leaves Altium sitting+                            // modal — and a modal Altium disables its own main window, which is+                            // the orphan state above. So finish the exchange here.                             let button = if title.contains("not saving") { "OK" } else { "Save None" };                             let r = dialogs::press(hw, button);                             dialogs::note_event(json!({"title": d.get("title"), "category": "save_prompt_discarded", "pressed": button,                                 "ok": r.get("success"), "note": "altium_close {discardChanges:true}: the caller declared the modified documents its own"}));+                            if button == "Save None" {+                                let t1 = std::time::Instant::now();+                                while t1.elapsed() < Duration::from_secs(5) {+                                    std::thread::sleep(Duration::from_millis(300));+                                    let still = dialogs::scan_scoped(true);+                                    let confirm = still.iter().find(|x| x.get("hwnd").and_then(|h| h.as_i64()) == Some(hw)+                                        && x.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("not saving")).unwrap_or(false));+                                    match confirm {+                                        Some(_) => {+                                            let r2 = dialogs::press(hw, "OK");+                                            dialogs::note_event(json!({"title": "Confirm Not Saving", "category": "save_prompt_discarded",+                                                "pressed": "OK", "ok": r2.get("success"),+                                                "note": "the second half of the Save None exchange, pressed in the same pass"}));+                                            break;+                                        }+                                        None => { if still.iter().all(|x| x.get("hwnd").and_then(|h| h.as_i64()) != Some(hw)) { break; } }+                                    }+                                }+                            }                         }                     }                     std::thread::sleep(Duration::from_millis(1500));@@ -382,6 +410,26 @@ pub fn close(args: &Value) -> Value {             return json!({"success": false, "errorCode": "close_blocked_by_dialog", "dialogs": dl,                 "_hint": "Altium asked something on close (save?). Read dialogs[] and answer via altium_dismiss_dialogs {hwnd,button}, or pass force:true (with acceptSeatLoss:true — a kill strands the seat)."});         }+        // Nothing to answer, yet Altium is disabled: it is inside a modal loop whose dialog is+        // gone. It will never honour the quit, so stop waiting out the clock.+        if dl.is_empty() {+            if let Some(orphan) = lifecycle::modal_orphan() {+                if args.get("discardChanges").and_then(|b| b.as_bool()) == Some(true) || force {+                    let r = lifecycle::graceful_terminate(&orphan, Duration::from_secs(20));+                    if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+                        return json!({"success": true, "closedInSec": support::elapsed_round(t0),+                            "agentTeardown": agent_teardown, "engineFreed": engine_freed,+                            "stuckModalOrphan": orphan, "endedBy": r,+                            "_hint": "Altium was stuck in a modal loop with no dialog left to answer (main window DISABLED). The bridge ended it with a posted WM_QUIT — Altium's own shutdown path, so the licence seat was RELEASED, not stranded."});+                    }+                    return json!({"success": false, "errorCode": "modal_orphan_survived_quit",+                        "stuckModalOrphan": orphan, "endedBy": r,+                        "_hint": "Altium is wedged in a modal loop and did not leave it even on a posted WM_QUIT. Screenshot the desktop, then force:true + acceptSeatLoss:true is the only route left — it kills and STRANDS the seat, so say so to the user first."});+                }+                return json!({"success": false, "errorCode": "altium_modal_orphan", "stuckModalOrphan": orphan,+                    "_hint": "Altium is stuck inside a modal loop whose dialog is gone (main window DISABLED, nothing on screen to answer). It cannot be reached by menu, keystroke or -R, and it is holding the licence seat. If every open document is yours, altium_close {discardChanges:true} ends it gracefully with a posted WM_QUIT and RELEASES the seat. If a document may be the user's, its unsaved content cannot be recovered from this state — ask first."});+            }+        }         std::thread::sleep(Duration::from_millis(500));     }     if force && accept {
bridge-rs/src/verbs.rs+5−3
@@ -168,9 +168,10 @@ fn describe() -> Value {         verb_doc("license", "Drive the License Management page (UIA first - no cursor, no foreground).", json!({"action": "show|refresh|use_license|start_trial|sign_out"}), shot.clone(), 60,             "One On-Demand seat serves EVERY Altium app on this machine. use_license claims a free seat.",             &["ensure_licensed", "license_status", "close"], &["NEVER kill an Altium holding the seat: the lease strands, the page shows Seats 0/1 while every claim fails. altium_close releases it."], json!({"action": "use_license"})),-        verb_doc("close", "Polite close (releases the licence seat). Frees a script engine parked on a breakpoint first (Altium refuses to close over one). discardChanges:true answers Altium's save prompts with Save None for callers whose modified documents are ALL their own; force needs acceptSeatLoss.", json!({"discardChanges": "bool, default false: press Save None + confirm on 'Confirm Save Locally'", "waitSec": "float, default 20", "force": "bool", "acceptSeatLoss": "bool", "instance": "pid, when several Altiums run"}), json!({"closedInSec": "float", "engineFreed": "object|null", "errorCode": "unsaved_changes | close_blocked_by_dialog | close_timeout"}), 120,+        verb_doc("close", "Polite close (releases the licence seat). Frees a script engine parked on a breakpoint first (Altium refuses to close over one). discardChanges:true answers Altium's save prompts with Save None for callers whose modified documents are ALL their own; force needs acceptSeatLoss.", json!({"discardChanges": "bool, default false: press Save None + confirm on 'Confirm Save Locally'", "waitSec": "float, default 20", "force": "bool", "acceptSeatLoss": "bool", "instance": "pid, when several Altiums run"}), json!({"closedInSec": "float", "engineFreed": "object|null", "stuckModalOrphan": "{pid,tid,hwnd} when Altium was wedged in a modal loop and the bridge ended it gracefully", "errorCode": "unsaved_changes | close_blocked_by_dialog | altium_modal_orphan | modal_orphan_survived_quit | close_timeout"}), 120,             "A save prompt on close NAMES the documents - read it; on a shared machine one may belong to another AI thread. Never defaulted.",-            &["license", "launch"], &["force:true without acceptSeatLoss is refused: a kill strands the On-Demand seat."], json!({})),+            &["license", "launch"], &["force:true without acceptSeatLoss is refused: a kill strands the On-Demand seat.",+              "errorCode altium_modal_orphan = Altium is stuck inside a modal loop whose dialog is GONE (its main window is disabled, nothing on screen to answer). No menu, keystroke or -R reaches it and it still holds the seat. With discardChanges:true the bridge ends it by posting WM_QUIT - Altium's own shutdown, which RELEASES the seat. Never reach for force/taskkill first."], json!({})),         verb_doc_long("install_library_bundle", "One-click install of a wiki library bundle (zip of .SchLib/.PcbLib): unpack, register, open the library, prove it with a frame.",             json!({"zipPath": "string - staged zip", "libraryName": "string", "capture": "bool", "captureMaxWidth": "int", "installId": "string - echoed in the receipt so a client can match it"}),             json!({"shown": "bool", "symbolCount": "int", "components": "[names]", "shot": "{image, ...}", "thumbnailDataUrl": "string", "phasesMs": "{launch, licence, unpack, register, open, list, panel, capture, agentStop}", "totalMs": "int", "installId": "string", "atMs": "int"}),@@ -259,7 +260,8 @@ fn describe() -> Value {             json!({"steps": "[{reason} | {dialog, pressed} | {editScriptStop, exited, sec} | busy-notice reap]"}), 60,             "Use after agent_timeout / no_heartbeat / instance_busy, or when Altium sits on AdomAgent_*.pas in its debugger. Then call your verb again: it restarts the agent. Never ask the user to click.",             &["check_dialogs", "agent_start", "dialog_log", "selftest_script_error"],-            &["It presses OK on EVERY Altium dialog it finds, not only the script Error box: altium_check_dialogs first if a save prompt may be up.",+            &["A modal orphan (main window disabled, no dialog on screen) answers nothing: recover refuses with altium_modal_orphan instead of firing -R hand-offs that all bounce off 'another instance is busy'. Escape it with altium_close {discardChanges:true}.",+              "It presses OK on EVERY Altium dialog it finds, not only the script Error box: altium_check_dialogs first if a save prompt may be up.",               "An agent_script_error reply was ALREADY recovered by the bridge (autoRecovered): do not recover again, and do not resend the same command - it will halt again."],             json!({"reason": "agent_timeout on read_board"})),         verb_doc("run_script", "One shot: run a procedure of YOUR OWN .PrjScr through Altium's -R command line. Only for when the agent is NOT resident.",
bridge-rs/src/win32.rs+58−5
@@ -26,6 +26,19 @@ mod imp {         fn GetWindowThreadProcessId(hwnd: Hwnd, pid: *mut u32) -> u32;         fn IsWindowVisible(hwnd: Hwnd) -> i32;         fn IsHungAppWindow(hwnd: Hwnd) -> i32;+        fn IsWindowEnabled(hwnd: Hwnd) -> i32;+        fn PostThreadMessageW(tid: u32, msg: u32, wparam: usize, lparam: isize) -> i32;+    }++    const WM_QUIT: u32 = 0x0012;++    /// Ask a UI thread to leave its message loop — Delphi's `Application.Terminate`, reached from+    /// outside. POSTED (never sent), so it cannot inherit a hang, and unlike TerminateProcess the+    /// app runs its own shutdown: measured 2026-09-21 on AD 26.10.1, Altium exited and RELEASED+    /// the On-Demand licence seat (a taskkill strands it). This is the only way out of a modal+    /// orphan — see `pid_main_window_disabled`.+    pub fn post_quit_to_thread(tid: u32) -> bool {+        unsafe { PostThreadMessageW(tid, WM_QUIT, 0, 0) != 0 }     }      /// One visible top-level window: pid, class, hwnd, and whether its UI thread is HUNG. The@@ -39,6 +52,11 @@ mod imp {         pub class: String,         pub hwnd: i64,         pub hung: bool,+        /// Windows disables EVERY other top-level window of a process while one of them is modal.+        /// A disabled main window with no dialog on screen is the "modal orphan" signature.+        pub enabled: bool,+        /// The window's UI thread, for `post_quit_to_thread`.+        pub tid: u32,     }      // EnumWindows wants a plain function pointer; rows are collected through a mutex the@@ -53,7 +71,7 @@ mod imp {                 return 1; // keep enumerating             }             let mut pid: u32 = 0;-            GetWindowThreadProcessId(hwnd, &mut pid);+            let tid = GetWindowThreadProcessId(hwnd, &mut pid);             if pid == 0 {                 return 1;             }@@ -62,7 +80,8 @@ mod imp {             let class = String::from_utf16_lossy(&buf[..n.max(0) as usize]);             let hung = IsHungAppWindow(hwnd) != 0;             if let Ok(mut rows) = ROWS.lock() {-                rows.push(WinRow { pid, class, hwnd: hwnd as i64, hung });+                let enabled = IsWindowEnabled(hwnd) != 0;+                rows.push(WinRow { pid, class, hwnd: hwnd as i64, hung, enabled, tid });             }         }         1@@ -155,7 +174,7 @@ mod proc_imp { }  #[cfg(windows)]-pub use imp::{visible_windows, WinRow};+pub use imp::{post_quit_to_thread, visible_windows, WinRow}; #[cfg(windows)] pub use proc_imp::{kill, pids_of}; @@ -169,6 +188,11 @@ pub fn kill(_pid: u32) -> bool {     false } +#[cfg(not(windows))]+pub fn post_quit_to_thread(_tid: u32) -> bool {+    false+}+ #[cfg(not(windows))] #[derive(Clone, Debug)] pub struct WinRow {@@ -176,6 +200,8 @@ pub struct WinRow {     pub class: String,     pub hwnd: i64,     pub hung: bool,+    pub enabled: bool,+    pub tid: u32, }  #[cfg(not(windows))]@@ -197,6 +223,21 @@ pub fn pid_main_window_hung(pid: u32, rows: &[WinRow]) -> bool {     rows.iter().any(|w| w.pid == pid && w.class == "TDocumentForm" && w.hung) } +/// Is `pid`'s main window DISABLED? Windows disables a process's other top-level windows while+/// one of them is modal, so with no dialog on screen this means Altium is stuck inside a modal+/// loop whose dialog is gone — the app accepts no input, no menu, no Alt+F4, and every `-R`+/// hand-off bounces off "another instance ... is busy" (measured 2026-09-21 on AD 26.10.1, after+/// a close-time save prompt: the single licence seat stayed held and nothing but a kill looked+/// possible). `close` escapes it with `post_quit_to_thread`.+pub fn pid_main_window_disabled(pid: u32, rows: &[WinRow]) -> bool {+    rows.iter().any(|w| w.pid == pid && w.class == "TDocumentForm" && !w.enabled)+}++/// The UI thread that owns `pid`'s main window.+pub fn pid_ui_thread(pid: u32, rows: &[WinRow]) -> Option<u32> {+    rows.iter().find(|w| w.pid == pid && w.class == "TDocumentForm").map(|w| w.tid)+}+ #[cfg(test)] mod tests {     use super::*;@@ -204,11 +245,23 @@ mod tests {     #[test]     fn pid_matching_is_class_scoped() {         let rows = vec![-            WinRow { pid: 10, class: "TDocumentForm".into(), hwnd: 1, hung: false },-            WinRow { pid: 11, class: "#32770".into(), hwnd: 2, hung: false },+            WinRow { pid: 10, class: "TDocumentForm".into(), hwnd: 1, hung: false, enabled: true, tid: 100 },+            WinRow { pid: 11, class: "#32770".into(), hwnd: 2, hung: false, enabled: false, tid: 101 },         ];         assert!(pid_has_main_window(10, &rows));         assert!(!pid_has_main_window(11, &rows), "a dialog is not a main window");         assert!(!pid_has_main_window(12, &rows));     }++    #[test]+    fn modal_orphan_is_the_disabled_main_window() {+        let rows = vec![+            WinRow { pid: 10, class: "TDocumentForm".into(), hwnd: 1, hung: false, enabled: false, tid: 100 },+            WinRow { pid: 11, class: "TDocumentForm".into(), hwnd: 2, hung: false, enabled: true, tid: 101 },+        ];+        assert!(pid_main_window_disabled(10, &rows), "disabled main window = stuck in a modal loop");+        assert!(!pid_main_window_disabled(11, &rows), "a healthy Altium is never disabled");+        assert_eq!(pid_ui_thread(10, &rows), Some(100));+        assert_eq!(pid_ui_thread(99, &rows), None);+    } }
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.34",+  "version": "0.2.36",   "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",
dev-skills/altium-bridge-dev/SKILL.md+45
@@ -93,6 +93,51 @@ seconds with `errorShot`, and `altium_status` must then read `ready: true` with Test new agent commands on a SECOND Altium build before promoting; one machine proved nothing about 26.9.1. +## The modal orphan: Altium wedged with no dialog on screen (measured 2026-09-21, AD 26.10.1)++The worst state this bridge has produced. On arav-rog an Altium held the single On-Demand licence+seat for eight hours: it refused `altium_close`, `agent_start` answered `no_heartbeat`, and every+`-R` hand-off came back as an "Information" box — *"There is another instance of running, but it is+busy at the moment. Cannot proceed with the request."* Escape, Alt+F4, Ctrl+F3, the File and Run+menus and a direct `-RClient:QuitFromEDAClient` all did nothing. Nothing was on screen.++**What it actually is.** Windows disables every other top-level window of a process while one of+them is modal. `EnumWindows` showed EVERY Altium window `enabled=False` with no dialog anywhere:+Altium was inside a modal loop whose dialog was gone. In that state the app processes no input at+all, and `X2.EXE -R` refuses because the instance is "busy" — which is the same signature as the+open `no_heartbeat` reports.++**How it was made.** `altium_close {discardChanges:true}` half-answered the close-time save prompt.+"Save None" does NOT dismiss *Confirm Save Locally for (N) Modified Documents*: it unticks every+document and RENAMES the same hwnd to *Confirm Not Saving (N) Modified Documents*, which OK then+commits. The old code pressed Save None and went back to polling, so the prompt sat modal past+`waitSec`; `close` returned `close_timeout` and stopped, and once that dialog later went away the+modal loop had nothing left to end it.++Two contributing bugs, both fixed in 0.2.35:+- `close` asked `agent::is_alive()` AFTER its own teardown, so EVERY close thought a script was+  parked and ran the breakpoint recovery: ~10 s of `-R` hand-offs, each bounce spawning a helper+  X2, and the `-R OpenObject` calls added the two `AdomAgent_*.pas` files to the script project —+  which is exactly what made it "(2) Modified Documents" and raised the prompt in the first place.+- `recover` fired that same `-R` storm at an instance that by definition cannot answer it.++**The way out, and it keeps the seat.** Post `WM_QUIT` to the UI thread that owns the main window+(`win32::post_quit_to_thread`): Delphi's `Application.Terminate` reached from outside. Altium runs+its own shutdown and RELEASES the licence seat — measured: exit in 2.1 s, the next launch licensed.+A `taskkill` strands the seat. POSTED, never sent, so it cannot inherit the hang.++**In the bridge now.** `lifecycle::modal_orphan()` returns `{pid, tid, hwnd}` when the main window+is disabled AND `dialogs::scan()` is empty (a dialog you can SEE is a question — answer it, don't+terminate over it). `close` escapes it when the caller passed `discardChanges` (a graceful quit+never prompts, so the caller must already own the documents) and otherwise refuses with+`altium_modal_orphan`; `agent_recover` refuses with the same code in under a second instead of+thrashing. To exercise the path live without waiting for the bug: `EnableWindow(mainHwnd, FALSE)`+on a healthy Altium reproduces the signature exactly.++**The rule.** Never leave a modal dialog half-answered. If a verb presses a button that *changes*+a dialog rather than closing it, finish the exchange in the same pass — polling back later is how+Altium ends up modal with nothing on screen.+ ## The brace rule for JSON-building code  Result strings build JSON with `'{' + ... + '}'`; that's fine in a STRING LITERAL. It is only
page.json+1−1
@@ -140,4 +140,4 @@     "skills/altium-wiki-import/SKILL.md",     "uninstall.sh"   ]-}\ No newline at end of file+}