← Commit history

A busy add-in is not a missing add-in: never restart Fusion for it

Drew Owens ·8915d3d329 ·15d ago ·parent 15f08e0
While a long native Fusion call holds the add-in's main thread, not even /status or /health answers. The gate read that silence as "installed while Fusion was running" and restarted Fusion: on a person's own PC it killed Fusion with their documents open six times between 2026-10-14 and 2026-10-28, the last two with a 93 s script in flight through this same exe. Silence now counts as work whenever there is evidence the add-in is loaded: a request this exe sent is still in flight, or the add-in answered earlier inside the same Fusion process (pids recorded in addin-answered.json) and its port still takes connections. The verb gate, the timeout diagnosis, the readiness launch observer and a warm fusion_start ask for it before any restart and answer fusion_addin_busy; readiness and fusion_state report addinBusy and the dashboard no longer restarts over it. A request in flight counts as a long op for the hang verdict. Co-Authored-By: Claude Opus 5.5 <[email protected]>
9 files changed +212−12
dashboard/src/main.rs+3−1
@@ -667,7 +667,9 @@ fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {             }             if !restarted && t0.elapsed() > Duration::from_secs(90)                 && st["running"].as_bool().unwrap_or(false)-                && !st["addinResponding"].as_bool().unwrap_or(false) {+                && !st["addinResponding"].as_bool().unwrap_or(false)+                // a loaded add-in that is busy is not "did not start": never restart over it+                && st["addinBusy"].is_null() {                 restarted = true;                 app.hub.broadcast("toast", &serde_json::json!({                     "message": "Fusion is up but its bridge add-in did not start - restarting Fusion for you", "kind": ""}));
rust/bridge/src/addin.rs+111−2
@@ -86,7 +86,84 @@ static LONG_COMMAND: Mutex<Option<(String, Instant)>> = Mutex::new(None); fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }  pub fn addin_status_probe(timeout: Duration) -> Option<Value> {-    ureq::get("http://127.0.0.1:8774/status").timeout(timeout).call().ok().and_then(|r| r.into_json().ok())+    let r = ureq::get("http://127.0.0.1:8774/status").timeout(timeout).call().ok().and_then(|r| r.into_json().ok());+    if r.is_some() { note_answered(); }+    r+}++// A loaded add-in can go completely silent: while a long native Fusion call holds Python's lock+// on the main thread, not even /status or /health is served. That silence is work, not an add-in+// Fusion never loaded, and restarting for it closed every open document of a busy Fusion twice on+// 2026-10-28 (a 93 s script in flight). Two facts tell the cases apart without the add-in's help.++/// Add-in requests this exe has sent and not yet seen answered: (id, command, sent). A request in+/// flight means the add-in took the connection, so it is loaded and working.+static IN_FLIGHT: Mutex<Vec<(u64, String, Instant)>> = Mutex::new(Vec::new());+static IN_FLIGHT_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);++/// Holds one request's place in IN_FLIGHT until it is answered, fails or times out.+struct InFlight(u64);+impl InFlight {+    fn begin(cmd: &str) -> InFlight {+        let id = IN_FLIGHT_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+        IN_FLIGHT.lock().unwrap().push((id, cmd.to_string(), Instant::now()));+        InFlight(id)+    }+}+impl Drop for InFlight { fn drop(&mut self) { IN_FLIGHT.lock().unwrap().retain(|(id, _, _)| *id != self.0); } }++/// The oldest add-in request still in flight: (command, seconds since it was sent).+pub fn oldest_in_flight() -> Option<(String, f64)> {+    IN_FLIGHT.lock().unwrap().first().map(|(_, c, at)| (c.clone(), at.elapsed().as_secs_f64()))+}++/// The Fusion360.exe processes the add-in last answered from, and when. Also kept in+/// `<state>/addin-answered.json` so a respawned bridge still knows this Fusion's add-in loaded.+static ANSWERED: Mutex<Option<(Vec<u32>, f64)>> = Mutex::new(None);++fn answered_file() -> std::path::PathBuf { crate::host().bridge_state_dir().join("addin-answered.json") }++/// The add-in just answered: record it against the Fusion processes running now.+pub fn note_answered() {+    let pids: Vec<u32> = crate::host().fusion_processes().iter().map(|p| p.pid).collect();+    if pids.is_empty() { return; }+    let mut g = ANSWERED.lock().unwrap();+    let changed = g.as_ref().map(|(p, _)| *p != pids).unwrap_or(true);+    *g = Some((pids.clone(), now_f()));+    if changed { let _ = std::fs::write(answered_file(), json!({"pids": pids, "at": now_f()}).to_string()); }+}++/// Seconds since the add-in last answered, when it answered from one of these Fusion processes.+pub fn answered_in(pids: &[u32]) -> Option<f64> {+    let mut g = ANSWERED.lock().unwrap();+    if g.is_none() {+        *g = std::fs::read_to_string(answered_file()).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok())+            .and_then(|v| Some((v["pids"].as_array()?.iter().filter_map(|p| p.as_u64().map(|p| p as u32)).collect(), v["at"].as_f64()?)));+    }+    g.as_ref().filter(|(seen, _)| seen.iter().any(|p| pids.contains(p))).map(|(_, at)| (now_f() - at).max(0.0))+}++/// Nothing listens on the add-in's port: no add-in server in this Fusion (never loaded, or it died).+fn port_refused() -> bool {+    matches!(std::net::TcpStream::connect_timeout(&"127.0.0.1:8774".parse().unwrap(), Duration::from_secs(1)), Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused)+}++/// Why a silent add-in is loaded and working rather than missing, or None when nothing says so.+pub fn loaded_evidence_from(in_flight: Option<(String, f64)>, answered_secs_ago: Option<f64>, port_refused: bool) -> Option<Value> {+    if let Some((cmd, secs)) = in_flight {+        return Some(json!({"evidence": "request_in_flight", "busyCommand": cmd, "elapsedSeconds": (secs * 10.0).round() / 10.0}));+    }+    match answered_secs_ago {+        Some(ago) if !port_refused => Some(json!({"evidence": "answered_in_this_fusion", "lastAnswerSecondsAgo": ago.round()})),+        _ => None,+    }+}++/// The same from the live signals, for the Fusion processes running now.+pub fn loaded_evidence(pids: &[u32]) -> Option<Value> {+    if let Some(fl) = oldest_in_flight() { return loaded_evidence_from(Some(fl), None, false); }+    let ago = answered_in(pids)?;+    loaded_evidence_from(None, Some(ago), port_refused()) }  /// The two busy gates, in server.py's order: our own long command, then the add-in's /status.@@ -147,7 +224,11 @@ fn classify_api_error(mut r: Value) -> Value { /// A bare add-in hop (no etiquette, no gate, no diagnosis): banners and probes. pub fn addin_http(cmd: &str, args: &Value, timeout: u64) -> Value {     let body = json!({"command": addin_name(cmd), "args": args});-    match ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(timeout)).set("Content-Type", "application/json").send_string(&body.to_string()) {+    let flight = InFlight::begin(cmd);+    let sent = ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(timeout)).set("Content-Type", "application/json").send_string(&body.to_string());+    drop(flight);+    if matches!(sent, Ok(_) | Err(ureq::Error::Status(..))) { note_answered(); }+    match sent {         Ok(r) => r.into_json::<Value>().unwrap_or(json!({"success": false, "error": "Add-in request failed: non-JSON answer"})),         Err(ureq::Error::Status(_, r)) => r.into_json::<Value>().unwrap_or(json!({"success": false, "error": "Add-in request failed: non-JSON answer"})),         Err(e) => json!({"success": false, "error": format!("Add-in request failed: {e}")}),@@ -160,7 +241,10 @@ fn call_addin(cmd: &str, args: &Value, timeout: u64, sidecar: &dyn Fn(&str, Valu     }     let body = json!({"command": addin_name(cmd), "args": args});     let sentinel = if DOC_SWITCHING.contains(&cmd) { SENTINEL_CTX.lock().unwrap().clone().map(|(sd, ca, pids)| crate::etiquette::Sentinel::start(sd, ca, pids, timeout as f64 + 10.0)) } else { None };+    let flight = InFlight::begin(cmd);     let sent = ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(timeout)).set("Content-Type", "application/json").send_string(&body.to_string());+    drop(flight);+    if matches!(sent, Ok(_) | Err(ureq::Error::Status(..))) { note_answered(); }     if let Some(s) = &sentinel { s.soft_stop(12.0); }     let result: Value = match sent {         Ok(resp) => resp.into_json::<Value>().unwrap_or(json!({"success": false, "error": "Add-in request failed: non-JSON answer"})),@@ -334,6 +418,31 @@ mod tests {         assert!(r.get("errorCode").is_none());     }     #[test]+    fn a_silent_addin_is_loaded_only_on_evidence() {+        // 2026-10-28: a 93 s script was in flight when the gate restarted Fusion+        let ev = loaded_evidence_from(Some(("run_modeling_script".into(), 31.04)), None, true).unwrap();+        assert_eq!(ev["evidence"], "request_in_flight");+        assert_eq!(ev["busyCommand"], "run_modeling_script");+        assert_eq!(ev["elapsedSeconds"], 31.0);+        // it answered earlier in this Fusion and its port still takes connections: busy+        assert_eq!(loaded_evidence_from(None, Some(42.4), false).unwrap()["evidence"], "answered_in_this_fusion");+        // its port refuses: the server is gone, a restart is the remedy+        assert!(loaded_evidence_from(None, Some(42.4), true).is_none());+        // never answered in this Fusion (installed while it ran): the restart path stays open+        assert!(loaded_evidence_from(None, None, false).is_none());+    }+    #[test]+    fn requests_leave_the_in_flight_list_however_they_end() {+        assert!(IN_FLIGHT.lock().unwrap().iter().all(|(_, c, _)| c != "slow_one"));+        let a = InFlight::begin("slow_one");+        let b = InFlight::begin("second");+        assert!(IN_FLIGHT.lock().unwrap().iter().any(|(_, c, _)| c == "slow_one"));+        drop(a);+        assert!(IN_FLIGHT.lock().unwrap().iter().all(|(_, c, _)| c != "slow_one"));+        drop(b);+        assert!(IN_FLIGHT.lock().unwrap().iter().all(|(_, c, _)| c != "second"));+    }+    #[test]     fn timeouts_and_ownership() {         assert_eq!(timeout_secs("walk_cloud_tree"), 600);         assert_eq!(timeout_secs("get_app_state"), 30);
rust/bridge/src/launch.rs+32−1
@@ -13,7 +13,27 @@ use fusion_core::ab::{AbClient, Loopback};  fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) } -fn health_probe(timeout: Duration) -> bool { ureq::get("http://127.0.0.1:8774/health").timeout(timeout).call().is_ok() }+fn health_probe(timeout: Duration) -> bool {+    let ok = ureq::get("http://127.0.0.1:8774/health").timeout(timeout).call().is_ok();+    if ok { crate::addin::note_answered(); }+    ok+}++static BUSY_NOTED_AT: std::sync::Mutex<f64> = std::sync::Mutex::new(0.0);++/// The reply for a silent add-in that is loaded and working, ledgered at most once a minute.+pub fn busy_not_missing(state_dir: &std::path::Path, cmd: &str, ev: &Value) -> Value {+    let mut at = BUSY_NOTED_AT.lock().unwrap();+    if now_f() - *at > 60.0 {+        *at = now_f();+        crate::fusion_prefs::action_ledger(state_dir, "addin_busy_not_restarted", json!({"forCommand": cmd, "evidence": ev}));+    }+    let mut r = json!({"success": false, "errorCode": "fusion_addin_busy", "hostResponding": true, "addinLoaded": true,+        "error": "The Adom add-in is loaded but not answering right now: it is working. A long command or a heavy Fusion operation holds its main thread and starves even its status probe. It was not installed while Fusion was running, so a restart would not help.",+        "_hint": "WAIT and re-poll fusion_readiness; do not restart Fusion (a restart closes every open document and loses the work in flight). The add-in answers again when the operation in front of it finishes."});+    if let Some(o) = ev.as_object() { for (k, v) in o { r[k] = v.clone(); } }+    r+}  /// `wait_for_addin(timeout)`: the health endpoint, once a second. pub fn wait_for_addin(seconds: u64) -> bool {@@ -148,6 +168,13 @@ pub fn start(ctx: &Ctx, _args: &Value) -> Value {             }         }         if !addin_ok {+            // silent is not missing: an add-in that answered inside this Fusion, or holds one of+            // our requests, is working, and a kill would lose that work and every open document+            if let Some(ev) = crate::addin::loaded_evidence(&ctx.pids) {+                let mut r = busy_not_missing(&sd, "start", &ev);+                r["alreadyRunning"] = json!(true); r["startKind"] = json!("warm");+                return r;+            }             eprintln!("[fusion-bridge] warm start found NO add-in after a ~30s persistence window (never busy, never answered) - restarting Fusion so the add-in loads (issue #39, #41 busy guard, + the 2026-08-22 library-matrix persistence gate)");             let _ = crate::lifecycle::kill(ctx, &json!({}));             std::thread::sleep(Duration::from_secs(3));@@ -343,6 +370,10 @@ pub fn gate(ctx: &Ctx, cmd: &str, args: &Value) -> Option<Value> {         }         crate::lifecycle::DialogGate::Clear => {}     }+    // Silence alone never means "not loaded": an add-in that answered inside this Fusion, or that+    // holds one of our requests, is loaded and working. Only an add-in with no such evidence goes+    // on to the restart below.+    if let Some(ev) = crate::addin::loaded_evidence(&ctx.pids) { return Some(busy_not_missing(&ctx.state_dir, cmd, &ev)); }     let ri = crate::chainexec::addin_restart_info(ctx, &json!({"fireToast": true}), &|v, a| crate::addin::addin_http(v, &a, 8));     if ri["autoRestart"].as_bool() == Some(true) {         let started = begin_auto_addin_restart(ctx);
rust/bridge/src/lifecycle.rs+4−1
@@ -449,7 +449,7 @@ pub fn diagnose_addin_timeout(ctx: &Ctx, command: &str, original: &Value) -> Val             "_hint": "Do NOT restart Fusion and do not treat this as a crash. Fusion runs one command at a time on its main thread, and some surfaces genuinely take minutes (a staged first-open has measured 546s here). WAIT and re-poll - fusion_state answers while the add-in is busy and reports mainThreadBusy with the running command. Only treat it as broken if elapsedSeconds keeps climbing past what fusion_state.timings says this machine has ever taken."});     }     let hung = crate::readiness_exec::confirmed_hung(ctx);-    if matches!(hung["phase"].as_str(), Some("suspect") | Some("busy-not-hung")) {+    if matches!(hung["phase"].as_str(), Some("suspect") | Some("busy-not-hung") | Some("long-op-in-flight")) {         return json!({"success": false, "error": "Fusion's main thread is not answering right now - most likely WORKING on something heavy, not hung (the probes it starves are the very ones this gate reads).", "errorCode": "fusion_addin_busy", "hostResponding": hung["phase"].as_str() == Some("busy-not-hung"), "hungHeldSeconds": hung["heldSeconds"],             "_hint": "WAIT and re-poll. A real hang is only declared after 180s of continuous silence including a 20s probe, and then the bridge restarts Fusion itself."});     }@@ -459,6 +459,9 @@ pub fn diagnose_addin_timeout(ctx: &Ctx, command: &str, original: &Value) -> Val         return json!({"success": false, "error": "Fusion 360 itself is NOT RESPONDING (the host process stopped pumping its message queue), so the add-in cannot answer.", "errorCode": "fusion_host_hung", "hungSignals": hung["signals"], "ghostWindow": ghost, "restartAttempted": true,             "_hint": "This is NOT a blocking dialog and NOT an add-in crash - do not go looking for a modal, there is not one. Nothing reaches Fusion over IPC while it is hung; only a restart clears it. The bridge already force-restarted it (kill + start) - poll fusion_readiness until ready:true."});     }+    // a timeout from an add-in that answered inside this Fusion (or holds another of our+    // requests) is a busy add-in, not a crashed one: never tell the caller to restart for it+    if let Some(ev) = crate::addin::loaded_evidence(&ctx.pids) { return crate::launch::busy_not_missing(&ctx.state_dir, command, &ev); }     json!({"success": false, "error": "AdomBridge add-in not responding (it may have crashed).", "errorCode": "fusion_addin_not_responding", "hostResponding": true,         "_hint": "Fix it YOURSELF - never ask the user: restart Fusion via fusion_stop + fusion_start (the bridge installs the add-in to ALL Fusion add-in dirs incl. %APPDATA%\\Autodesk\\FusionAddins, and runOnStartup reloads it). The host process IS responding - this is the add-in only, so no dialog is implied."}) }
rust/bridge/src/main.rs+17−4
@@ -273,8 +273,10 @@ struct Snapshot { at: Instant, body: Value } static SNAPSHOT: Mutex<Option<Snapshot>> = Mutex::new(None);  fn addin_probe() -> Value {-    ureq::get("http://127.0.0.1:8774/status").timeout(Duration::from_millis(1500)).call().ok()-        .and_then(|r| r.into_json::<Value>().ok()).unwrap_or(Value::Null)+    let v = ureq::get("http://127.0.0.1:8774/status").timeout(Duration::from_millis(1500)).call().ok()+        .and_then(|r| r.into_json::<Value>().ok()).unwrap_or(Value::Null);+    if v.is_object() { addin::note_answered(); }+    v }  fn native_status(h: &dyn HostBoth) -> Value {@@ -1517,6 +1519,13 @@ fn gather_readiness_inputs(h: &dyn HostBoth, caller: &Caller) -> fusion_core::re     let aps_signed = aps.valid_access_token().is_some();     let mcp = std::net::TcpStream::connect_timeout(&"127.0.0.1:27182".parse().unwrap(), Duration::from_millis(400)).is_ok();     let addin_ok = addin.is_object();+    // a loaded add-in at work: its /status says busy, or it is starved silent while one of our+    // requests is in flight or after it answered in this Fusion (never a launch to heal)+    let pids: Vec<u32> = if running { h.fusion_processes().iter().map(|p| p.pid).collect() } else { vec![] };+    let addin_busy = if !running { None }+        else if addin["busy"].as_bool() == Some(true) { Some(json!({"evidence": "status_busy", "busyCommand": addin["busyCommand"], "elapsedSeconds": addin["elapsedSeconds"]})) }+        else if !addin_ok { addin::loaded_evidence(&pids) }+        else { None };     let provisional_ready = installed_raw && running && addin_ok && !facts.needs_signin && facts.licensing_dialog.is_none();     let roundtrip_ok = if provisional_ready {         ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(4)).send_json(json!({"command": "get_app_state", "args": {}})).ok()@@ -1540,6 +1549,7 @@ fn gather_readiness_inputs(h: &dyn HostBoth, caller: &Caller) -> fusion_core::re         fusion_user: None, modeling_orientation: None, bridge_version: VERSION.into(), prefs, now, now_iso,         timings_launch: timings_summary(&timings)["launch"].clone(), launch_stats: timings_stats(&timings, "launch", "cold"),         progress_override: None, addin_start_failed, crash_dialogs_cleared: 0, update_applied_button: None, update_offered: false,+        addin_busy,     };     *WINDOW_FACTS_DEGRADED.lock().unwrap() = (facts.degraded, facts.error.clone());     inputs@@ -1588,10 +1598,13 @@ impl chains::ChainHost for ExeChainHost { /// The chain in flight (busy: true on /status, like the Python bridge's _LONG_OP). static LONG_OP: Mutex<Option<String>> = Mutex::new(None); static LONG_OP_SINCE: Mutex<f64> = Mutex::new(0.0);-/// (name, since) of the long op in flight: a chain, else a walk/search on the pass-through.+/// (name, since) of the long op in flight: a chain, else a walk/search on the pass-through, else+/// any add-in request this exe has waited on for 5 s or more (a long script, an import, a close). pub(crate) fn long_op_marker() -> Option<(String, f64)> {     if let Some(n) = LONG_OP.lock().unwrap().clone() { return Some((n, *LONG_OP_SINCE.lock().unwrap())); }-    addin::long_command_active().and_then(|v| Some((v["command"].as_str()?.to_string(), v["startedAt"].as_f64()?)))+    if let Some(m) = addin::long_command_active().and_then(|v| Some((v["command"].as_str()?.to_string(), v["startedAt"].as_f64()?))) { return Some(m); }+    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);+    addin::oldest_in_flight().filter(|(_, secs)| *secs >= 5.0).map(|(c, secs)| (c, now - secs)) }  fn chains_owner_is_exe(h: &dyn HostBoth) -> bool { owner_is_exe(h, "chainsOwner") }
rust/bridge/src/orchestrations.rs+2
@@ -699,6 +699,8 @@ pub fn fusion_state(ctx: &Ctx, args: &Value) -> Value {     let busy = st.as_ref().filter(|s| s["busy"].as_bool() == Some(true)).map(|s| json!({"command": s["busyCommand"], "elapsedSeconds": s["elapsedSeconds"]}));     result["addinResponding"] = json!(st.is_some());     result["mainThreadBusy"] = busy.clone().unwrap_or(Value::Null);+    // silent but loaded (a long native call starves even /status): say so, so no caller restarts it+    result["addinBusy"] = if st.is_none() { crate::addin::loaded_evidence(&ctx.pids).unwrap_or(Value::Null) } else { Value::Null };     if let Some(lsa) = crate::small_verbs::get_pref(&sd, "launchStartedAt").and_then(|v| v.as_f64()) {         if st.is_none() && now_f() - lsa < 900.0 {             let stats = fusion_core::readiness::timings_stats(&crate::timings::load(&sd), "launch", "cold");
rust/core/src/readiness.rs+11−2
@@ -58,6 +58,10 @@ pub struct Inputs {     pub update_applied_button: Option<String>,     /// Fusion is offering an update right now (the sidecar's `_LAST_UPDATE_OFFER`).     pub update_offered: bool,+    /// Why a not-ready add-in is loaded and working rather than missing (its /status says busy, or+    /// it is starved silent with one of our requests in flight or after answering in this Fusion).+    /// A busy add-in is not a launch: the launch observer neither starts nor heals over it.+    pub addin_busy: Option<Value>, }  /// The side effects the Python handler would have taken, for the owner to run.@@ -158,6 +162,9 @@ pub fn compute(i: &Inputs) -> Outcome {     let mut note: Option<String> = None;     if ready && !i.roundtrip_ok { ready = false; note = Some(hint("roundtrip_note")); }     if let Some(n) = &note { hint_text = format!("{hint_text}{}{n}", hint("roundtrip_note_prefix")); }+    // a loaded add-in that is working is not a launch in progress, and never a reason to restart+    let busy = running && !ready && !needs_signin && !licensing && i.addin_busy.is_some();+    if busy { hint_text = hint("addin_busy"); }     let orient_note = if i.modeling_orientation.as_deref() == Some("yup") {         " ⚠ Default Modeling Orientation is Y-UP on this account, but Adom is a Z-UP environment (a fresh Autodesk account defaults to Y-up). Set it with fusion_set_preference {\"modelingOrientation\":\"zup\"} - no restart needed." } else { "" };     let mut result = Map::new();@@ -175,17 +182,19 @@ pub fn compute(i: &Inputs) -> Outcome {     result.insert("_hint".into(), json!(format!("{hint_text}{orient_note}")));     // the launch observer     result.insert("timings".into(), i.timings_launch.clone());+    if busy { result.insert("addinBusy".into(), i.addin_busy.clone().unwrap_or(Value::Null)); }     let now = i.now;     let mut lsa = prefs.get("launchStartedAt").and_then(|v| v.as_f64());+    if busy && lsa.is_some() { prefs.insert("launchStartedAt".into(), Value::Null); lsa = None; }     if let Some(s) = lsa { if !ready && now - s > 900.0 { prefs.insert("launchStartedAt".into(), json!(now)); prefs.insert("launchHealedAt".into(), Value::Null); lsa = Some(now); } }-    if lsa.is_none() && running && !ready { prefs.insert("launchStartedAt".into(), json!(now)); lsa = Some(now); }+    if lsa.is_none() && running && !ready && !busy { prefs.insert("launchStartedAt".into(), json!(now)); lsa = Some(now); }     if !ready && running && i.crash_dialogs_cleared > 0 {         result.insert("crashReportCleared".into(), json!(i.crash_dialogs_cleared));         result.insert("fusionCrashed".into(), json!(true));         prefs.insert("launchHealedAt".into(), Value::Null); prefs.insert("launchStartedAt".into(), json!(now)); lsa = Some(now);     }     let healed = prefs.get("launchHealedAt").map(|v| !v.is_null()).unwrap_or(false);-    if !ready && running && !needs_signin && !licensing && !updating && lsa.is_some() && !healed {+    if !ready && running && !busy && !needs_signin && !licensing && !updating && lsa.is_some() && !healed {         let p90 = i.launch_stats["p90"].as_f64().unwrap_or(0.0);         let overdue_by = (2.0 * p90).max(180.0);         if now - lsa.unwrap() > overdue_by {
rust/core/src/readiness_hints.json+1
@@ -3,6 +3,7 @@  "installing": "Fusion install is STREAMING (10-30 min) - keep polling fusion_readiness until installed:true.",  "installed_not_running": "Fusion is INSTALLED but not running - call fusion_start (blocks until the add-in is ready), then retry.",  "addin_silent": "Fusion is RUNNING but its add-in is NOT responding yet - NOT ready to drive. Either it is still finishing launch / signing in (poll fusion_readiness a few more times), or the add-in did not load (fusion_stop then fusion_start to re-deploy + re-load it). Do NOT fire modeling/export verbs until ready:true.",+ "addin_busy": "Fusion is RUNNING and its add-in is LOADED but busy: a long command or a heavy Fusion operation holds its main thread (addinBusy says which and for how long). It is working, not missing: poll fusion_readiness again and drive when ready is true. Do NOT restart Fusion for this; a restart closes every open document and loses the work in flight.",  "needs_signin": "Fusion is RUNNING but stuck at the first-run Autodesk SIGN-IN (main UI blocked, add-in cannot serve) - NOT ready. FULL PLAYBOOK: the `fusion-autodesk-signin` skill. Proven sequence + the pitfalls that cost hours on a fresh Hyper-V VM (John 2026-07-14): (1) BLANK white webview on first launch? fusion_stop then fusion_start - the sign-in renders 'Welcome to Fusion' + a 'Sign In' button only after a clean relaunch. (2) ASK the user FIRST (AskUserQuestion): do they have a warm Google/Apple/Microsoft session for the account LINKED to their Autodesk login? Offer to drive it. A truly fresh box has NO warm browser session, so 'Continue with Google' still hits a password wall. (3) Click Fusion 'Sign In' -> it opens the DEFAULT browser (Edge) to the OAuth. Edge windows DO exist even if PowerShell EnumWindows looks empty - that is CLIXML progress noise; set $ProgressPreference='SilentlyContinue', and just desktop_screenshot_window the Edge hwnd (PrintWindow works backgrounded). (4) In the browser: 'Continue with Google' -> type the user's EMAIL (not secret) -> if it asks for a PASSWORD/2FA, STOP and fusion_notify_owner {title, body} to toast their MAIN computer so THEY type it; NEVER enter password/2FA yourself. (5) Handoff back to Fusion is via the 'Autodesk Identity Manager' protocol - Edge shows a 'This site is trying to open...' overlay: tick 'Always allow' + click 'Open'. (6) SPEED MATTERS: the sign-in code EXPIRES in ~2 min; on a high-latency VM slow screenshot/click round-trips let it expire ('Sign-in request expired') and you loop. Minimize steps; if it expires, click Fusion 'Sign In' again for a FRESH code (the browser session stays warm). Then poll until ready:true. FIRST-TIME DEMO? Run fusion_demo instead - it drives this sign-in through the user's NATIVE browser (already signed into Autodesk) and then gives them a real guided tour (project -> schematic -> 2D board -> 3D board).",  "licensing": "Fusion is blocked by a SEAT/LICENSING dialog ('Active Sessions Exceeded' / 'Suspend Remote Session') - detected DETERMINISTICALLY as an OWNED POPUP of the main Fusion window (its title is just 'Fusion360' and it can be as short as 262px, so it is found by owner+parent-screenshot, NOT a size guess). This verb JUST TRIED to auto-resolve it in the BACKGROUND (UIA 'Continue', Suspend pre-selected so it reclaims the seat) but it is still up - it may be mid-render (the first UIA Invoke no-ops). Just POLL fusion_readiness again in a few seconds; it keeps auto-resolving. Never coordinate-click or ask the user, and never kill/relaunch (the server keeps the seat).",  "updating": "Fusion is applying an AUTO-UPDATE (multiple webdeploy builds present) and RESTARTS ITSELF every ~30-60s - this is NOT a crash or a bridge failure. Poll fusion_readiness; it stabilizes to ready:true once the update finishes (can take 10-30 min). Meanwhile, wrap modeling/export verbs in a short retry/catch-loop to ride the up-windows rather than treating an intermittent 'not running' as fatal.",
rust/core/tests/readiness_golden.rs+31−1
@@ -19,7 +19,7 @@ fn inputs_from(sc: &Value) -> Inputs {         timings_launch: json!({"cold": {"p50": 45.0, "p90": 53.0, "count": 4, "lastAt": "2026-09-01T10:00:00"}}),         launch_stats: json!({"p50": 45.0, "p90": 53.0, "count": 4}),         progress_override: Some(json!({"phase": "launch", "stepLabel": "", "percent": 40, "elapsedSec": 30, "estimatedSec": 45, "etaSec": 15, "confidence": "measured", "startKind": "cold", "overdue": false})),-        addin_start_failed: None, crash_dialogs_cleared: 0, update_applied_button: None, update_offered: false,+        addin_start_failed: None, crash_dialogs_cleared: 0, update_applied_button: None, update_offered: false, addin_busy: None,     } } @@ -44,3 +44,33 @@ fn readiness_matches_the_python_handler_in_every_scenario() {     }     assert!(n >= 16, "{n} scenarios"); }++/// 2026-10-28: a 93 s script starved the add-in's probes, readiness read it as a launch, and the+/// launch observer would have restarted Fusion after 180 s. A busy, loaded add-in is not a launch.+#[test]+fn a_busy_addin_is_neither_a_launch_nor_a_restart() {+    let base = |busy: Option<Value>, prefs: Map<String, Value>, now: f64| Inputs {+        installed_raw: true, running: true, addin_version: None, expected_addin_version: "1.0.19".into(),+        auto_update_enabled: true, bridge_version: "TEST".into(), prefs, now, now_iso: "2026-10-28T00:09:00".into(),+        timings_launch: json!({}), launch_stats: json!({"p50": 45.0, "p90": 53.0, "count": 4}), addin_busy: busy, ..Default::default()+    };+    let ev = json!({"evidence": "request_in_flight", "busyCommand": "run_modeling_script", "elapsedSeconds": 31.0});+    // busy: no launch timer, a busy hint, the evidence in the reply+    let out = compute(&base(Some(ev.clone()), Map::new(), 1000.0));+    assert_eq!(out.result["ready"], json!(false));+    assert_eq!(out.result["addinBusy"], ev);+    assert!(out.result["_hint"].as_str().unwrap().contains("LOADED but busy"));+    assert!(out.prefs_after.get("launchStartedAt").map(|v| v.is_null()).unwrap_or(true));+    // a timer left from earlier silence is cleared, and 10 minutes later nothing heals+    let mut prefs = Map::new();+    prefs.insert("launchStartedAt".into(), json!(400.0));+    let out = compute(&base(Some(ev.clone()), prefs, 1000.0));+    assert!(out.actions.heal_launch.is_none());+    assert!(out.result.get("addinRestartTriggered").is_none());+    assert_eq!(out.prefs_after["launchStartedAt"], Value::Null);+    // without evidence the observer still heals a launch that never served (unchanged)+    let mut prefs = Map::new();+    prefs.insert("launchStartedAt".into(), json!(400.0));+    let out = compute(&base(None, prefs, 1000.0));+    assert_eq!(out.actions.heal_launch, Some(1));+}