Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
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.
Never restart Fusion for a busy add-in, and never kill it over unsaved work #21
fix/busy-addin-safe-restart → masterWhat this fixes
The bridge restarted Fusion over an add-in that was busy, not missing. On a person's own PC (Drew's laptop) the ledger shows six automatic restarts between 2026-10-14 and 2026-10-28. In every one the save step failed, and the bridge killed Fusion with the person's documents open:
2026-10-28 00:12:54 auto_restart_done steps: save ok:false -> save-unavailable -> kill -> start
summary: "... Documents were saved first." (they were not)
The 10-28 timeline from verb-times.jsonl:
- 00:08:41: a
run_modeling_scriptstarts. It runs 93 s. - 00:09:04: another caller's verb reaches the gate.
/statusand/healthtime out. While a native Fusion call holds Python's lock, the add-in's HTTP threads cannot run at all.confirmed_hunganswerspumping, because Fusion keeps pumping messages, so Windows never calls it hung.- The gate reads that silence as "installed while Fusion was running" and auto-restarts. The save step goes through the same gate, fails the same way, and Fusion is killed.
Nothing was lost that time only because nothing was modified.
There were two more paths to the same mistake:
- Readiness launch observer. It started a launch timer whenever Fusion ran with a silent add-in, and stopped and started Fusion after about 3 minutes of polling.
- Warm
fusion_start. It killed Fusion after about 30 s of silence.
What changed (two commits)
1. A busy add-in is not a missing add-in. Silence now counts as work whenever there is evidence the add-in is loaded. There are two kinds of evidence:
- A request in flight. One of the exe's own requests to the add-in is still in flight.
InFlighttracks every add-in request, so this proves the add-in took the connection. - An earlier answer. The add-in answered earlier inside the same Fusion process, and its port still takes connections (it does not refuse them). The Fusion360.exe pids at each answer are kept in
addin-answered.json, so the record survives a bridge respawn.
Changes:
- Who asks for the evidence. The verb gate, the timeout diagnosis, the readiness launch observer and a warm
fusion_startall ask for it before any restart. They answerfusion_addin_busywith the evidence, and the ledger recordsaddin_busy_not_restarted(at most once a minute). - Hang verdict. A request in flight for 5 s or more counts as a long op for
confirmed_hung. - Readiness.
fusion_readinessreportsaddinBusywith a newaddin_busyhint, instead ofaddin_silent, which told callers to stop and start Fusion. fusion_stateand the dashboard.fusion_statereportsaddinBusy. The dashboard's self-heal no longer restarts over it.- Real hangs. Unchanged: OS-hung for 180 s with a refused 20 s probe still restarts.
2. A restart never kills Fusion over unsaved work it could not save. This one is a recommendation for restartPolicy and is yours to accept or reshape.
1.10.6 made auto the default on the premise that "all the open docs are just the AI's docs". That holds on a machine only an AI drives. It does not hold on a person's PC, which is where the six kills happened. So in this commit:
autostops short of losing work.- When the add-in cannot save, Fusion is closed with WM_CLOSE (a graceful stop) instead of being killed. It closes at once when nothing is unsaved.
- If a save prompt appears for a document the bridge did not make, or a document has never been saved, the restart ends with
restart_needs_decision. Nothing is killed or discarded. - For 30 minutes after that, the gate asks (the existing toast) instead of retrying.
- A stop that did not finish is no longer escalated to a kill. Before,
start()on a still-running Fusion did exactly that.
restartPolicy: forceis the oldauto, for AI-only machines.- The ledger summary is true. "Documents were saved first" appears only when the save step succeeded.
fusion_stopwaits for heavy designs. It waits up tocloseTimeoutSec: default 120 s, up from 15 s. A save prompt for a bridge document is answered Don't Save. One for anyone else's work is cancelled (WM_CLOSE on the prompt), and the stop fails withunsaved_work. Your 1.12.8saveToClosein the clean close is kept, so changed cloud documents are still saved before the windows close.- Opened documents are not bridge scratch.
- Any document that appeared during a window-raising verb was registered as a bridge document. That included a user's assembly reopened with
fusion_open_by_urn, so its save prompt would get Don't Save and a restart could discard it. - Now saved cloud documents that appear are treated as opened, not made, except for the bridge's own Save As. Nothing Fusion opens at launch is registered.
- The registry now evicts the oldest entry; before, it evicted the alphabetically first.
- Any document that appeared during a window-raising verb was registered as a bridge document. That included a user's assembly reopened with
Your 1.12.8 close_document fix already covers the save-on-close finding, so this PR leaves the closes alone.
How it was verified
tools/rust-ship.sh: Linux build, 110 tests, the Windows build and both guards, all green.New test Covers a_silent_addin_is_loaded_only_on_evidencethe evidence rules requests_leave_the_in_flight_list_however_they_endin-flight bookkeeping a_busy_addin_is_neither_a_launch_nor_a_restartreadiness: no timer, no heal; a launch that never served still heals a_save_prompt_is_answered_only_for_the_bridges_own_documentstop's prompt handling the_restart_summary_says_what_happenedthe ledger summary a_declined_auto_restart_turns_into_the_ask_for_a_whilethe 30-minute ask the_policy_defaults_to_auto_and_names_forcethe policy value opened_documents_are_not_bridge_scratchthe registry The Python unit tests pass.
Live, on the PC where the restarts happened: the same two commits, on the mechanical dev pin 1.12.0-dev.11. Fusion had 93 documents open, one of them with unsaved changes.
- Controlled stall. A
run_modeling_scriptheld Python's lock in 4 s native calls (ctypes.PyDLL), withadsk.doEvents()between them, for 24 s. This is the 10-28 condition. - Readiness during the stall:
ready: false,addinBusy: {busyCommand: run_modeling_script, elapsedSeconds: 9.8, evidence: request_in_flight}, and the busy hint. No launch timer started. - Gated
fusion_get_app_stateduring the stall:fusion_addin_busy(run 1, through the in-flight long op; run 2, throughanswered_in_this_fusion). The ledger showsaddin_busy_not_restarted. - After both runs: no restart, all 93 documents open, the active document unchanged.
- Real traffic. While another session ran 18 to 30 s scripts,
fusion_statereportedaddinResponding: falsewithaddinBusyandrequest_in_flight. That is the exact state that restarted Fusion before.
- Controlled stall. A
Not exercised live: the graceful-restart and
unsaved_workpaths, which need Fusion to close. They are covered by the unit tests above.
Notes
- Python sidecar.
server.pystill has the old preamble. It is the rollback path behindaddinOwner: sidecar, so I left it alone. - Versions. No version bump. The changelog entry is under 1.12.9 (unreleased). No add-in files change, so no Fusion restart is needed to load this.
Diff Skip to comments (1)
CHANGELOG.md+32@@ -3,6 +3,38 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.12.9 (unreleased)++The bridge no longer restarts Fusion over a busy add-in, and a restart it does need no longer kills Fusion over+unsaved work.++- A busy add-in is never mistaken for a missing one. While a long native Fusion call holds the add-in's main+ thread, not even `/status` or `/health` answers, and the gate read that silence as "the add-in was installed+ while Fusion was running" and restarted Fusion. On a person's own PC that killed Fusion with their documents+ open six times between 2026-10-14 and 2026-10-28 (a 93 s script was in flight on 10-28). Now silence counts as+ work whenever there is evidence the add-in is loaded: one of the exe's own requests to it is still in flight,+ or it answered earlier inside the same Fusion process and its port still takes connections. The verb gate, the+ timeout diagnosis, the readiness launch observer (which restarted Fusion after about 3 minutes of silence) and+ a warm `fusion_start` all ask for that evidence first and answer `fusion_addin_busy`. `fusion_readiness` and+ `fusion_state` report it as `addinBusy`, and the dashboard no longer restarts over it. A request in flight also+ counts as a long operation for the hang verdict.+- `restartPolicy` `auto` (the default) stops short of losing work. When the add-in cannot save, Fusion is closed+ the way a person closes it instead of killed: it closes at once when nothing is unsaved, and a save prompt for+ a document the bridge did not make (or a never-saved document) ends the restart with `restart_needs_decision`,+ with nothing killed or discarded. For 30 minutes after that the restart is asked for instead of retried. A stop+ that did not finish is no longer escalated to a kill. The old behaviour is `restartPolicy: force`, for a machine+ only an AI drives. The ledger's restart summary now says what happened: it said "Documents were saved first"+ when the save step had failed and Fusion was killed.+- `fusion_stop` waits for heavy designs: up to `closeTimeoutSec` (default 120; it was 15 s). A save prompt for a+ document the bridge made is answered Don't Save; one for anyone else's work is cancelled and the stop fails+ with `unsaved_work`.+- Documents a caller opens are no longer registered as bridge scratch. Any document that appeared during a+ window-raising verb was registered, so a user's assembly reopened with `fusion_open_by_urn` became "the+ bridge's": its save prompt would be answered Don't Save and a restart could discard it. Now only documents a+ verb made (not saved cloud documents, which were opened) and the bridge's own Save As are registered, and+ nothing Fusion opens at launch. The registry evicts its oldest entry when full; it used to evict the+ alphabetically first.+ ## 1.12.8 (2026-10-28, insiders) KiCad's remaining core and app proposals, and a close that never saves unasked. 271 verbs.
README.md+6−4@@ -98,7 +98,7 @@ Short entries, newest behavior first in each guide. The [changelog](CHANGELOG.md | Native viewport frame | `fusion_state` | The reply carries `nativeViewport`, `geometry` and `windowPixels`, so a blank window capture is reported as blank and the frame Fusion itself renders is there to use. | | APS zero-result fallback | `fusion_aps_search` | A zero from the server index never stands alone. The reply carries `data.fallback` and `data.completeness`, and sibling folders are listed through the Data API. | | Caller-aware prove | `fusion_create_library`, `fusion_install_library_bundle`, `fusion_prove_result` | An AI thread binds and places only (`verdict: bound_not_proven`). Web control proves. `prove:true` or `prove:false` always wins. |-| Restart policy | `restartPolicy`, `allowRestart`, `fusion_restart_for_addin` | Under the default `auto` the bridge restarts Fusion itself when the add-in needs it. Chains never restart Fusion unless the caller passes `allowRestart:true`. |+| Restart policy | `restartPolicy`, `allowRestart`, `fusion_restart_for_addin` | Under the default `auto` the bridge restarts Fusion itself when the add-in needs it, never for a busy add-in and never over unsaved work it could not save. Chains never restart Fusion unless the caller passes `allowRestart:true`. | | Sign-in short-circuit | `needs_signin` | Every add-in verb answers `errorCode: needs_signin` in under a second while Fusion is signing in. | ### The schematic front door: `fusion_schematic`@@ -154,9 +154,11 @@ and expired like any other, and Fusion's own tool windows do not count as dialog - `restartPolicy` (in `~/.adom/fusion-bridge/prefs.json`, or the env var `ADOM_FUSION_RESTART_POLICY`) defaults to `auto`: the bridge restarts Fusion in the background- when the add-in needs it (`addin_restarting`), saves what Fusion can save, and records the- restart in the action ledger (`fusion_action_ledger`). `ask` keeps the confirmation toast and- the wait, for a machine a person shares with the AI.+ when the add-in needs it (`addin_restarting`), saves what Fusion can save, stops short of any+ unsaved work it could not save (`restart_needs_decision`), and records the restart in the action+ ledger (`fusion_action_ledger`). `ask` keeps the confirmation toast and the wait, for a machine a+ person shares with the AI. `force` discards and kills when that is what it takes, for a machine+ only an AI drives. A busy add-in (`fusion_addin_busy`, readiness `addinBusy`) is never restarted. - Chains (`fusion_create_library`, `fusion_install_library_bundle`) never restart Fusion on their own. `allowRestart:true` with `restartReason` is the only way, and the restart is recorded in `restarts[]` on `fusion_prove_result`. A long operation in flight answers `fusion_addin_busy`
contracts/describe.json+6−4@@ -3967,7 +3967,7 @@ "tookSec": "float" }, "statusVerb": null,- "summary": "ASYNC: returns immediately (started:true) and restarts Fusion in the background, SAVING the user's open documents first; poll fusion_readiness until ready with the new addinVersion (a restart takes 60-120s, longer than the relay budget; pass wait:true for the old blocking form). Finish add-in setup by restarting Fusion. Call this when any verb returns errorCode addin_restart_required (the add-in was installed/updated while Fusion was already open, and Fusion only loads add-ins at launch). ASK THE USER FIRST when a human is present - the failing verb's payload carries userMessage plus openDocuments/unsavedDocuments so you can show them what is open. Refuses with unsaved_named_document rather than discarding a never-saved document.",+ "summary": "ASYNC: returns immediately (started:true) and restarts Fusion in the background, SAVING the user's open documents first; poll fusion_readiness until ready with the new addinVersion (a restart takes 60-120s, longer than the relay budget; pass wait:true for the old blocking form). Finish add-in setup by restarting Fusion. Call this when any verb returns errorCode addin_restart_required (the add-in was installed/updated while Fusion was already open, and Fusion only loads add-ins at launch). A busy add-in (errorCode fusion_addin_busy, readiness addinBusy) is NOT a reason: it is loaded and working. ASK THE USER FIRST when a human is present - the failing verb's payload carries userMessage plus openDocuments/unsavedDocuments so you can show them what is open. Refuses with unsaved_named_document rather than discarding a never-saved document, and with restart_needs_decision (nothing killed or discarded) when Fusion asks to save work the bridge did not make; restartPolicy force (an AI-only machine) discards and kills instead.", "timeoutSeconds": 300 }, {@@ -5520,17 +5520,19 @@ { "example": {}, "input": {+ "closeTimeoutSec": "optional number, default 120: how long to wait for Fusion's windows to close", "skipCleanClose": "optional bool" }, "longRunning": false, "name": "fusion_stop", "output": { "output": "str",- "success": "bool"+ "success": "bool",+ "unsavedWork": "[{window, prompt}] when unsaved work stopped it" }, "statusVerb": null,- "summary": "Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait. NO force-kill - the clean opposite of fusion_start. If a window will not close it returns failure; use fusion_kill then.",- "timeoutSeconds": 60+ "summary": "Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait up to closeTimeoutSec (default 120; heavy designs take minutes to close). NO force-kill - the clean opposite of fusion_start. A save prompt for a document the bridge made is answered Don't Save; one for anyone else's unsaved work is cancelled and the stop fails with errorCode unsaved_work (nothing saved or lost). If a window will not close it returns failure; use fusion_kill then.",+ "timeoutSeconds": 300 }, { "example": {},
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": ""}));
describe.py+3−3@@ -354,8 +354,8 @@ _T = [ ("fusion_create_library", "ONE-CALL library creation for web-control (one call = one consent). EVERY LIBRARY IS MULTI-PART - a single-part library is simply parts:[one entry], and there is no separate single-part verb to choose. Pass parts:[{package, lbrPath, modelPath}] for any number of parts; the older {libraryPath, stepPath} shape still works and is normalised into a one-element parts list, so both run the identical code path and the ETA scales with the part count either way. TWO entry shapes: A) {libraryPath, stepPath} - a ready .lbr plus the 3D; B) {kicadSymbolPath, kicadFootprintPath, stepPath, pinMap?} - KiCad sources; needs adom-lbr, which is Linux-container-only by platform rule, so on Windows/Mac boxes shape B refuses IMMEDIATELY (adom_lbr_unavailable, before any staging or Fusion launch - fusion_readiness reports kicadIngest up front). Convert at AUTHORING time in a container and use shape A. pinMap = explicit {pinName: padName} for when pin numbers do not match pad names (BGA-style). Chains convert -> bind 3D -> prove (four evidence frames + verdict) in the BACKGROUND and returns immediately; poll fusion_prove_result (stage narrates; top-level `verdict` proven/not_proven is the score - `stage:done` only means the chain finished). EVERY PART IS PROVED, not just the first: a 10-component library streams 40 evidence frames, FOUR per component (symbol, footprint, 3d, ganged) in assembly order, each tagged with `part` so a front end can group them as they arrive, and `provedParts[]` reports per-part pass/fail. The library is proven only if EVERY part is - one good part does not vouch for nine unchecked ones. On the FINAL fetch pass {inlineFrames:true} to get each evidence frame as a base64 thumbnailDataUrl a browser can render (plain polls return box paths only). The chain closes its library document on success AND failure - batches need no between-part sweeping. stepPath is required for a PROVEN library - no real 3D body without it.", {"parts": "optional [{package, lbrPath, modelPath}] - THE canonical shape, any count", "libraryPath": "optional str (shape A, or output path for B)", "kicadSymbolPath": "optional str", "kicadFootprintPath": "optional str", "stepPath": "optional str (required for proven)", "pinMap": "optional {pinName: padName}", "name": "optional str"}, {"started": "bool", "statusVerb": "str"}, 30, "fusion_prove_result", True, {"libraryPath": "C:/Temp/PART.lbr", "stepPath": "C:/Temp/PART.step"}), ("fusion_start", "Launch Fusion 360 (if needed) and wait for the AdomBridge add-in to be ready.", {"prove": "optional bool. Default from the CALLER: an AI thread (caller headers set) binds and places only; web control / anonymous callers prove (four evidence frames per part, about 300 s per library). Explicit true/false always wins. From an AI thread, look at one part later with fusion_capture_library_views {packages} (issue #88)", "allowRestart": "optional bool (default false). The chain NEVER relaunches Fusion or restarts the add-in on its own: when the add-in does not answer it surfaces addin_restart_required and waits (resumable). allowRestart:true with restartReason lets it restart, recorded in restarts[] on fusion_prove_result (issue #87)", "restartReason": "optional str shown to the human when allowRestart is used"}, {"success": "bool", "addinReady": "bool", "status": "str"}, 150, None, True, {}),- ("fusion_stop", "Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait. NO force-kill - the clean opposite of fusion_start. If a window will not close it returns failure; use fusion_kill then.",- {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 60, None, False, {}),+ ("fusion_stop", "Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait up to closeTimeoutSec (default 120; heavy designs take minutes to close). NO force-kill - the clean opposite of fusion_start. A save prompt for a document the bridge made is answered Don't Save; one for anyone else's unsaved work is cancelled and the stop fails with errorCode unsaved_work (nothing saved or lost). If a window will not close it returns failure; use fusion_kill then.",+ {"skipCleanClose": "optional bool", "closeTimeoutSec": "optional number, default 120: how long to wait for Fusion's windows to close"}, {"success": "bool", "output": "str", "unsavedWork": "[{window, prompt}] when unsaved work stopped it"}, 300, None, False, {}), ("fusion_kill", "Force-KILL Fusion 360 (taskkill /F) - the desperate path for when fusion_stop cannot close it (stuck modal / wedged process). Closes docs cleanly first when reachable.", {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 30, None, False, {}), ("fusion_close_window", "Close a DIALOG window by hwnd (WM_CLOSE, like clicking X) - for dialogs without Cancel/Escape handling (e.g. Recovered Documents). NOT a document closer; refuses without hwnd. Get the hwnd from a screenshot's dialogs array or desktop_list_windows. (Doc drift caught by the verb test suite 2026-08-14: this was described as 'close the active document' while the handler required hwnd.)",@@ -679,7 +679,7 @@ _T = [ ("fusion_live_feed", "A ~1 fps look INTO Fusion while the bridge drives it, kept as a BOX-SIDE ring buffer (~10 min). Each tick records the Fusion main window (kind:main - the video) and every owned popup over it (kind:popup - dialogs, Browser/tool panels, the Adom banner; Fusion draws these as separate windows a main capture omits), each a path on this machine tied to its tick. Drain from the container with {action:frames, since:<tick>}, read_file each path, and publish where a human looks - tools/live_feed_drain.py does exactly that into shotlog. Proof frames are NOT here: fusion_prove_result evidence[] carries kind:proof with step labels, so the three kinds are distinguishable at the source. Exists so a human can see open tabs and dialogs during boot or a restart instead of guessing.", {"action": "start|stop|status|frames", "intervalSec": "optional float (default 1.0)", "maxSec": "optional float (default 600)", "since": "frames only: return ticks newer than this"}, {"success": "bool", "running": "bool", "tick": "int", "frames": "[{tick, kind:main|popup, path, title, at}]"}, 15, None, True, {"action": "frames", "since": 0}),- ("fusion_restart_for_addin", "ASYNC: returns immediately (started:true) and restarts Fusion in the background, SAVING the user's open documents first; poll fusion_readiness until ready with the new addinVersion (a restart takes 60-120s, longer than the relay budget; pass wait:true for the old blocking form). Finish add-in setup by restarting Fusion. Call this when any verb returns errorCode addin_restart_required (the add-in was installed/updated while Fusion was already open, and Fusion only loads add-ins at launch). ASK THE USER FIRST when a human is present - the failing verb's payload carries userMessage plus openDocuments/unsavedDocuments so you can show them what is open. Refuses with unsaved_named_document rather than discarding a never-saved document.",+ ("fusion_restart_for_addin", "ASYNC: returns immediately (started:true) and restarts Fusion in the background, SAVING the user's open documents first; poll fusion_readiness until ready with the new addinVersion (a restart takes 60-120s, longer than the relay budget; pass wait:true for the old blocking form). Finish add-in setup by restarting Fusion. Call this when any verb returns errorCode addin_restart_required (the add-in was installed/updated while Fusion was already open, and Fusion only loads add-ins at launch). A busy add-in (errorCode fusion_addin_busy, readiness addinBusy) is NOT a reason: it is loaded and working. ASK THE USER FIRST when a human is present - the failing verb's payload carries userMessage plus openDocuments/unsavedDocuments so you can show them what is open. Refuses with unsaved_named_document rather than discarding a never-saved document, and with restart_needs_decision (nothing killed or discarded) when Fusion asks to save work the bridge did not make; restartPolicy force (an AI-only machine) discards and kills instead.", {"save": "optional bool (default true) - save open documents before restarting"}, {"success": "bool", "addinReady": "bool", "tookSec": "float", "steps": "[{step, ok}]"}, 300, None, False, {"save": True}),
docs/sign-in-and-updates.md+21−7@@ -19,21 +19,35 @@ where Fusion had been removed. **Never ask the user to restart Fusion or enable the add-in.** That is the bridge's job. -### `restartPolicy`: auto or ask+### `restartPolicy`: auto, ask or force Fusion loads add-ins only at launch, so an add-in that was installed or updated while Fusion was open needs a restart. Who decides is a pref: `restartPolicy` in `~/.adom/fusion-bridge/prefs.json`, or the env var `ADOM_FUSION_RESTART_POLICY`. The default is `auto`. -- **`auto`**: the AI is the only thing driving Fusion on that box, so the open documents are the- AI's documents. The bridge restarts Fusion in the background and answers `addin_restarting`.- No confirmation toast is raised. `fusion_restart_for_addin` never refuses under auto: documents- Fusion can save are saved, never-saved scratch is discarded (named in `discarded` and in the- action ledger), and when the add-in cannot save at all the restart is a kill plus a start. The- restart is recorded in the action ledger (`fusion_action_ledger`) with its summary.+**A busy add-in never needs a restart.** While a long native Fusion call holds the add-in's main+thread, not even its status probe answers. The bridge treats that silence as work, not as an add-in+Fusion never loaded, whenever it has evidence: one of its own requests to the add-in is still in+flight, or the add-in answered earlier inside the same Fusion process (and its port still takes+connections). Every restart path asks for that evidence first: the verb gate, the timeout+diagnosis, the readiness launch observer and a warm `fusion_start`. The reply is+`fusion_addin_busy` with the evidence, and readiness reports it as `addinBusy`. Before this, a+93-second script read as a missing add-in and the bridge restarted Fusion with the user's+documents open.++- **`auto`**: the bridge restarts Fusion in the background and answers `addin_restarting`. No+ confirmation toast is raised. Documents Fusion can save are saved first. It stops short of losing+ work: when the add-in cannot save, Fusion is closed the way a person closes it (it closes at once+ when nothing is unsaved), and a never-saved document or a save prompt for a document the bridge+ did not make ends the restart with `restart_needs_decision`. Nothing is killed or discarded, and+ for the next 30 minutes a restart is asked for (the toast) instead of tried again. The restart is+ recorded in the action ledger (`fusion_action_ledger`) with a summary of what each step did. - **`ask`**: for a machine a person shares with the AI. A verb that needs the restart answers `addin_restart_required` with `userMessage`, `openDocuments` and `unsavedDocuments`, the confirmation toast is raised, and the bridge waits for the person.+- **`force`**: the behaviour `auto` had from 1.10.6 to 1.12.7, for a machine only an AI drives:+ never-saved documents are discarded (named in `discarded` and in the ledger), and when the+ add-in cannot save at all the restart is a kill plus a start. **The bridge never restarts Fusion over a dialog.** A dialog in front of Fusion is read through UIA (title, text, button names) and expired safely: the known ones through their rule, the unknown
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/chainexec.rs+11−3@@ -149,11 +149,19 @@ pub fn addin_restart_info(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> let yours: Vec<String> = match &unames { Some(u) => { let y = user_unsaved_docs(&ctx.state_dir, u); info["userUnsaved"] = json!(y); info["autoRestart"] = json!(y.is_empty()); y } None => { info["autoRestart"] = json!(false); vec![] } }; let policy = crate::lifecycle::restart_policy(&ctx.state_dir); info["restartPolicy"] = json!(policy);- let yours = if policy == "auto" {- // every open document is the AI's own on an Adom box: nothing to ask, the bridge restarts+ let declined = crate::lifecycle::auto_restart_declined_recently(&ctx.state_dir);+ if declined { info["autoRestartDeclinedRecently"] = json!(true); }+ let yours = if policy == "force" {+ // an AI-only machine: every open document is the AI's own, nothing to ask if !yours.is_empty() { info["userUnsavedIgnored"] = json!(yours); info["userUnsaved"] = json!([]); } info["autoRestart"] = json!(true);- info["userMessage"] = json!("Fusion is restarting itself to load the Adom add-in (restartPolicy auto). Open documents are saved where Fusion can save them; never-saved scratch is discarded.");+ info["userMessage"] = json!("Fusion is restarting itself to load the Adom add-in (restartPolicy force). Open documents are saved where Fusion can save them; never-saved documents are discarded.");+ Vec::new()+ } else if policy == "auto" && yours.is_empty() && !declined {+ // no unsaved work that is not the bridge's (or none known): restart without asking; the+ // restart itself stops short of any unsaved work it meets and asks then+ info["autoRestart"] = json!(true);+ info["userMessage"] = json!("Fusion is restarting itself to load the Adom add-in (restartPolicy auto). Open documents are saved first; nobody's unsaved work is discarded."); Vec::new() } else { yours }; if fire_toast && info["autoRestart"].as_bool() != Some(true) {
rust/bridge/src/chains.rs+1−1@@ -131,9 +131,9 @@ fn note_bridge_doc(state_dir: &std::path::Path, name: &str) { let p = state_dir.join("prefs.json"); let mut d: Value = std::fs::read_to_string(&p).ok().and_then(|t| serde_json::from_str(t.trim_start_matches('\u{feff}')).ok()).unwrap_or(json!({})); let mut docs: Vec<String> = d["bridgeDocs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();+ // oldest first: a sorted list evicted the alphabetically first name, not the oldest one if !docs.contains(&n) { docs.push(n); } while docs.len() > 24 { docs.remove(0); }- docs.sort(); d["bridgeDocs"] = json!(docs); let tmp = p.with_extension("json.tmp"); if std::fs::write(&tmp, serde_json::to_string_pretty(&d).unwrap_or_default()).is_ok() { let _ = std::fs::rename(&tmp, &p); }
rust/bridge/src/etiquette.rs+35−1@@ -72,6 +72,23 @@ pub fn snapshot_open_docs(call: &dyn Fn(&str, Value, u64) -> Value) -> Vec<Strin v.sort(); v.dedup(); v } +/// Each open document's lowercase name and whether it is a saved cloud document (empty on failure).+fn snapshot_open_docs_detail(call: &dyn Fn(&str, Value, u64) -> Value) -> Vec<(String, bool)> {+ let r = call("document_info", json!({}), 5);+ if r["success"].as_bool() != Some(true) { return vec![]; }+ r["data"]["openDocuments"].as_array().map(|a| a.iter().filter_map(|d| {+ let n = d["name"].as_str().filter(|n| !n.is_empty())?.trim_end_matches('*').trim().to_lowercase();+ Some((n, d["isCloudDocument"].as_bool() == Some(true)))+ }).collect()).unwrap_or_default()+}++/// Is a document that appeared while `cmd` ran the bridge's own scratch? A saved cloud document+/// that appeared was OPENED (open_by_urn, a script's documents.open): it is its owner's work, and+/// registering it let the save-prompt sentinel answer Don't Save for it and the restart discard+/// it. So were the documents Fusion opens at launch (recovered work). Only a document the verb+/// made, or a Save As the bridge itself did, is scratch.+pub fn appeared_doc_is_scratch(cmd: &str, cloud: bool) -> bool { cmd != "start" && (!cloud || cmd == "save_to_cloud") }+ fn bridge_docs(state_dir: &Path) -> Vec<String> { crate::small_verbs::get_pref(state_dir, "bridgeDocs").and_then(|v| v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).filter(|s| s.len() >= 3).collect())).unwrap_or_default() }@@ -280,7 +297,11 @@ pub fn post(state_dir: &Path, caller: &Caller, cmd: &str, args: &Value, mut res: // call plus a 14 s timeout diagnosis (2026-09-07, the routing test's 60 s relay window) let quick = |verb: &str, a: Value, _t: u64| crate::addin::addin_http(verb, &a, 3); let _ = call;- for name in snapshot_open_docs(&quick) { if !before.contains(&name) && !name.is_empty() && name != "untitled" { crate::chains::note_bridge_doc_pub(state_dir, &name); eprintln!("[fusion-bridge] registered {name:?} as a bridge doc (it appeared while we drove)"); } }+ for (name, cloud) in snapshot_open_docs_detail(&quick) {+ if before.contains(&name) || name.is_empty() || name == "untitled" { continue; }+ if appeared_doc_is_scratch(cmd, cloud) { crate::chains::note_bridge_doc_pub(state_dir, &name); eprintln!("[fusion-bridge] registered {name:?} as a bridge doc (it appeared while we drove)"); }+ else { eprintln!("[fusion-bridge] {name:?} appeared during {cmd} but is an existing document someone opened, not bridge scratch"); }+ } } if SHOW_SURFACE_VERBS.contains(&cmd) { for k in ["name", "deviceset", "libraryPath", "fileName", "query"] { if let Some(v) = args[k].as_str().filter(|s| !s.is_empty()) { crate::chains::note_bridge_doc_pub(state_dir, v); } }@@ -364,5 +385,18 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } #[test]+ fn opened_documents_are_not_bridge_scratch() {+ use super::appeared_doc_is_scratch as scratch;+ // 2026-10-28: four of the user's assemblies, reopened with open_by_urn, were registered+ assert!(!scratch("open_by_urn", true));+ assert!(!scratch("run_modeling_script", true));+ // Fusion's own launch (recovered documents) is never the bridge's+ assert!(!scratch("start", false));+ // what a verb made is scratch, and so is the bridge's own Save As+ assert!(scratch("import_step", false));+ assert!(scratch("make_3d_package", false));+ assert!(scratch("save_to_cloud", true));+ }+ #[test] fn b64_matches_python() { assert_eq!(super::b64(b"hello"), "aGVsbG8="); assert_eq!(super::b64(b""), ""); assert_eq!(super::b64(b"ab"), "YWI="); } }
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+151−26@@ -90,16 +90,42 @@ fn fusion_windows(ctx: &Ctx) -> Vec<(i64, String)> { .filter_map(|w| w["hwnd"].as_i64().map(|h| (h, w["title"].as_str().unwrap_or("").to_string()))).collect() } -/// `_close_and_wait`: WM_CLOSE, then up to 15s for the window to go.-fn close_and_wait(ctx: &Ctx, hwnd: i64) -> bool {- let Some(ab) = Loopback::from_env() else { return false };+/// What became of a WM_CLOSE on a Fusion window.+#[derive(Debug, PartialEq)]+pub enum Closed { Gone, UnsavedWork(String), StillClosing }++/// What to do with a save prompt Fusion raised while closing: the bridge's own document is+/// answered Don't Save; anyone else's unsaved work (or a prompt naming no document) is cancelled+/// and ends the stop, so the decision goes back to the caller.+pub fn prompt_answer(owned_by_bridge: bool) -> &'static str { if owned_by_bridge { "dont_save" } else { "cancel_and_stop" } }++/// `_close_and_wait`: WM_CLOSE, then wait while Fusion closes. A heavy design takes far longer than+/// the old 15 s to close, so the wait is `max_secs` (fusion_stop's `closeTimeoutSec`, default 120),+/// and it ends early on a save prompt for work the bridge does not own.+fn close_and_wait(ctx: &Ctx, hwnd: i64, max_secs: f64) -> Closed {+ let Some(ab) = Loopback::from_env() else { return Closed::StillClosing }; let _ = ab.call("desktop_close_window", json!({"hwnd": hwnd}), &ctx.caller, 15);- let deadline = Instant::now() + Duration::from_secs(15);+ let deadline = Instant::now() + Duration::from_secs_f64(max_secs);+ let mut next_look = Instant::now() + Duration::from_secs(1); while Instant::now() < deadline { std::thread::sleep(Duration::from_millis(300));- if !fusion_windows(ctx).iter().any(|(h, _)| *h == hwnd) { return true; }+ if !fusion_windows(ctx).iter().any(|(h, _)| *h == hwnd) { return Closed::Gone; }+ if Instant::now() < next_look { continue; }+ next_look = Instant::now() + Duration::from_secs(1);+ let p = crate::etiquette::find_save_prompt(&ab, &ctx.caller, &ctx.pids);+ if p["found"].as_bool() != Some(true) { continue; }+ let text = p["text"].as_str().unwrap_or("").to_string();+ match prompt_answer(crate::etiquette::bridge_doc_in(&ctx.state_dir, &text).is_some()) {+ "dont_save" => { let _ = crate::etiquette::dismiss_save_prompt_if_bridge_doc(&ctx.state_dir, &ctx.caller, &ctx.pids, ""); }+ _ => {+ // WM_CLOSE on the prompt is its Cancel: nothing saved, nothing lost, no dialog left up+ if let Some(h) = p["hwnd"].as_i64() { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10); }+ crate::fusion_prefs::action_ledger(&ctx.state_dir, "stop_cancelled_for_unsaved_work", json!({"prompt": text}));+ return Closed::UnsavedWork(text);+ }+ } }- false+ Closed::StillClosing } fn survivors(ctx: &Ctx) -> Value {@@ -112,13 +138,25 @@ fn survivors(ctx: &Ctx) -> Value { pub fn stop(ctx: &Ctx, args: &Value) -> Value { let mut report = json!({"closed": [], "errors": [], "count": 0}); if args["skipCleanClose"].as_bool() != Some(true) { report = close_all_documents_via_addin(); if !report["closed"].as_array().map(|a| a.is_empty()).unwrap_or(true) { std::thread::sleep(Duration::from_millis(500)); } }+ let max_secs = args["closeTimeoutSec"].as_f64().unwrap_or(120.0).clamp(5.0, 900.0); let windows = fusion_windows(ctx); if windows.is_empty() { if has_fusion_process(ctx) { return json!({"success": false, "error": "No Fusion windows found, but Fusion processes are still running.", "_hint": "A graceful stop cannot reach them - use fusion_kill to force-kill."}); } return json!({"success": true, "output": "Fusion 360 is not running."}); }- let (mut closed, mut failed) = (Vec::new(), Vec::new());- for (h, t) in &windows { if close_and_wait(ctx, *h) { closed.push(t.clone()); } else { failed.push(t.clone()); } }+ let (mut closed, mut failed, mut unsaved) = (Vec::new(), Vec::new(), Vec::new());+ for (h, t) in &windows {+ match close_and_wait(ctx, *h, max_secs) {+ Closed::Gone => closed.push(t.clone()),+ Closed::UnsavedWork(prompt) => { unsaved.push(json!({"window": t, "prompt": prompt})); failed.push(t.clone()); }+ Closed::StillClosing => failed.push(t.clone()),+ }+ }+ if !unsaved.is_empty() {+ return json!({"success": false, "errorCode": "unsaved_work", "unsavedWork": unsaved,+ "error": format!("Fusion was not stopped: it asked to save changes the bridge did not make ({}). The prompt was cancelled, so nothing was saved, discarded or closed past that point.", unsaved.iter().map(|u| u["prompt"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join("; ")),+ "_hint": "Those changes are someone's work. Ask whoever owns them, or save them (fusion_save_document) and stop again. fusion_kill force-closes Fusion and loses them."});+ } if !failed.is_empty() { let bridge_only = failed.iter().all(|t| { let l = t.to_lowercase(); BRIDGE_MARKS.iter().any(|m| l.contains(m)) }); return json!({"success": false, "error": format!("Graceful stop failed for: {}", failed.join(", ")), "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) }, "bridgeDocsOnly": bridge_only,@@ -142,17 +180,41 @@ pub fn kill(ctx: &Ctx, args: &Value) -> Value { /// `fusion_restart_for_addin`: save, stop (or kill when only bridge scratch is unsaved), start, wait. /// Who owns a Fusion restart. "auto" (the default): the bridge restarts Fusion itself whenever-/// the add-in needs it, saving what can be saved and discarding the rest, because on an Adom box-/// the AI is the only thing driving Fusion and every open document is its own (John,-/// 2026-09-07: "you should literally know to just allow fully restarting fusion ... all the open-/// docs are just the ai's docs"). "ask" keeps the toast with the Save-and-restart choice for a-/// machine a person shares with the AI. Pref key `restartPolicy` in prefs.json, or the env-/// ADOM_FUSION_RESTART_POLICY.+/// the add-in needs it, without a toast, saving what can be saved (John, 2026-09-07: "you should+/// literally know to just allow fully restarting fusion"), but it stops short of losing work it+/// could not save: a never-saved document or a save prompt for a document the bridge did not+/// make turns the restart into the ask below. The machines it ran on are not all the AI's alone:+/// on a person's own PC it killed Fusion six times between 2026-10-14 and 10-28 with their+/// documents open, each time because the save step could not run. "force" is the old auto, for a+/// machine only an AI drives: never-saved documents are discarded, and Fusion is killed when+/// nothing can be saved. "ask" keeps the toast with the Save-and-restart choice. Pref key+/// `restartPolicy` in prefs.json, or the env ADOM_FUSION_RESTART_POLICY. pub fn restart_policy(state_dir: &std::path::Path) -> String { if let Ok(v) = std::env::var("ADOM_FUSION_RESTART_POLICY") { if !v.trim().is_empty() { return v.trim().to_lowercase(); } } crate::small_verbs::get_pref(state_dir, "restartPolicy").and_then(|v| v.as_str().map(|s| s.to_lowercase())).unwrap_or_else(|| "auto".into()) } +/// An auto restart stopped short of someone's unsaved work: for the next 30 minutes the gate asks+/// (the toast with Save and restart) instead of trying again behind the person's back.+pub fn note_auto_restart_declined(state_dir: &std::path::Path, why: &Value) {+ let _ = std::fs::create_dir_all(state_dir);+ let _ = std::fs::write(state_dir.join("auto_restart_declined.json"), json!({"at": now_secs(), "why": why}).to_string());+ crate::fusion_prefs::action_ledger(state_dir, "auto_restart_declined", json!({"why": why}));+}+pub fn auto_restart_declined_recently(state_dir: &std::path::Path) -> bool {+ std::fs::read_to_string(state_dir.join("auto_restart_declined.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v["at"].as_f64()).map(|at| now_secs() - at < 1800.0).unwrap_or(false)+}++/// The ledger's one-line account of a restart, true to what each step did.+pub fn restart_summary(why: &str, policy: &str, steps: &[Value], discarded: &[String]) -> String {+ let saved = steps.iter().any(|s| s["step"] == "save" && s["ok"] == json!(true));+ let killed = steps.iter().any(|s| s["step"] == "kill");+ format!("The bridge restarted Fusion {why} (restartPolicy {policy}). {}{}",+ if saved { "Documents were saved first. " } else if killed { "Nothing could be saved first, and Fusion was force-closed. " } else { "Nothing could be saved first, so Fusion was closed the way a person closes it, with nobody's unsaved work at risk. " },+ if discarded.is_empty() { String::new() } else { format!("Discarded never-saved documents: {}. ", discarded.join(", ")) })+}+ /// A plain informational toast on this machine (no buttons, nothing to answer). pub fn notify_info(ctx: &Ctx, title: &str, body: &str) -> bool { match Loopback::from_env() {@@ -310,9 +372,12 @@ pub fn restart_for_addin(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> } let t0 = Instant::now(); let save = args["save"].as_bool().unwrap_or(true);- let auto_policy = args["auto"].as_bool() == Some(true) || restart_policy(&sd) == "auto";+ let policy = restart_policy(&sd);+ let force = policy == "force";+ let auto_policy = args["auto"].as_bool() == Some(true) || policy == "auto" || force; let mut steps: Vec<Value> = Vec::new(); let mut hard = false;+ let mut graceful = false; let mut discarded: Vec<String> = Vec::new(); if save { let sv = pass("close_all_documents", json!({"save": true}));@@ -321,43 +386,57 @@ pub fn restart_for_addin(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> if !wp.is_empty() { let users: Vec<&String> = wp.iter().filter(|n| { let bare = n.replace('*', "").split(" (").next().unwrap_or("").trim().to_lowercase(); !bare.is_empty() && crate::etiquette::bridge_doc_in(&sd, &bare).is_none() && !bare.starts_with("untitled") && !bare.ends_with("_3d") }).collect(); if users.is_empty() { steps.push(json!({"step": "discard-bridge-scratch", "docs": wp})); hard = true; }- else if auto_policy {- // restartPolicy auto: every open document is the AI's own; a never-saved one is+ else if force {+ // restartPolicy force: every open document is the AI's own; a never-saved one is // scratch, so it is discarded rather than left to block the restart discarded = users.iter().map(|s| s.to_string()).collect(); crate::fusion_prefs::action_ledger(&sd, "restart_discarded_unsaved", json!({"docs": discarded})); steps.push(json!({"step": "discard-under-auto-policy", "docs": discarded})); hard = true; } else {+ if auto_policy { note_auto_restart_declined(&sd, &json!({"neverSaved": users})); } return json!({"success": false, "errorCode": "unsaved_named_document", "error": format!("Cannot restart safely: {} has never been saved, so Fusion would raise its Save box and block the restart.", users.iter().map(|n| fusion_lbr::py_repr_str(n)).collect::<Vec<_>>().join(", ")), "steps": steps, "_hint": "Ask the user where to save it (fusion_save_to_cloud {name, projectName}), then retry. Nothing was closed and no dialog was raised."}); } } if sv["success"].as_bool() != Some(true) && auto_policy {- // the add-in cannot save (it is the thing that is not answering): restart anyway,- // hard, and say so; Fusion's own recovery keeps cloud documents' last autosave+ // the add-in cannot save (it is the thing that is not answering). Close Fusion the way+ // a person would: it closes at once when nothing is unsaved, and a save prompt for+ // someone's work stops the restart instead of a kill losing it (force: kill, as before) steps.push(json!({"step": "save-unavailable", "error": sv["error"].as_str().unwrap_or("").chars().take(160).collect::<String>()}));- hard = true;+ if force { hard = true; } else { graceful = true; } } if sv["success"].as_bool() != Some(true) && !auto_policy && sv["errorCode"].is_string() && sv["error"].as_str().map(|e| e.contains("Add-in request failed")).unwrap_or(false) { return json!({"success": false, "errorCode": "save_failed", "steps": steps, "error": format!("Refusing to restart because saving failed: {}", sv["error"].as_str().unwrap_or("").chars().take(200).collect::<String>()), "_hint": "Nothing was restarted; the user's documents are untouched."}); } } if hard || !save { let st = kill(ctx, &json!({"reason": "restart for add-in; only bridge scratch unsaved"})); steps.push(json!({"step": "kill", "ok": st["success"].as_bool().unwrap_or(true)})); }- else { let st = stop(ctx, &json!({})); steps.push(json!({"step": "stop", "ok": st["success"].as_bool().unwrap_or(true)})); }+ else {+ let st = stop(ctx, &json!({"skipCleanClose": graceful}));+ steps.push(json!({"step": "stop", "ok": st["success"].as_bool().unwrap_or(true), "errorCode": st["errorCode"]}));+ if st["success"].as_bool() == Some(false) && !force {+ // a stop that did not finish is never escalated to a kill (start() used to do exactly+ // that on a Fusion still running): the work it stopped for is someone's to decide on+ let why = json!({"stop": st["errorCode"], "unsavedWork": st["unsavedWork"], "error": st["error"]});+ if auto_policy { note_auto_restart_declined(&sd, &why); }+ crate::fusion_prefs::action_ledger(&sd, "restart_stopped_short", json!({"steps": steps, "why": why}));+ return json!({"success": false, "errorCode": "restart_needs_decision", "restartAborted": true, "steps": steps, "unsavedWork": st["unsavedWork"],+ "error": format!("Fusion was not restarted: {}", st["error"].as_str().unwrap_or("it did not close")),+ "userMessage": "Fusion needs a restart to load the Adom add-in, and it has unsaved changes the bridge will not discard. Save or close them, then restart.",+ "_hint": "Ask the user (userMessage). Nothing was killed or discarded. When the changes are saved, call fusion_restart_for_addin {save:true} again."});+ }+ } std::thread::sleep(Duration::from_secs(3)); let lr = crate::launch::start(ctx, &json!({"waitForAddin": true})); steps.push(json!({"step": "start", "ok": lr["success"].as_bool().unwrap_or(false)})); let ready = crate::launch::round_trip_ready_gap(6, 5.0); if auto_policy { let why = args["why"].as_str().unwrap_or("to load the Adom add-in");- let body = format!("The bridge restarted Fusion {why} (restartPolicy auto: every open document is the AI's own). {}{}",- if steps.iter().any(|s| s["step"] == "save") { "Documents were saved first. " } else { "Nothing could be saved first. " },- if discarded.is_empty() { String::new() } else { format!("Discarded never-saved scratch: {}. ", discarded.join(", ")) });+ let body = restart_summary(why, &policy, &steps, &discarded); // the ledger is the record; no toast (John, 2026-09-19: "fairly pointless") crate::fusion_prefs::action_ledger(&sd, "auto_restart_done", json!({"ready": ready, "why": why, "steps": steps, "discarded": discarded, "summary": body})); }- json!({"success": ready, "addinReady": ready, "restartPolicy": if auto_policy { "auto" } else { "ask" }, "discarded": discarded, "tookSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "steps": steps, "errorCode": if ready { Value::Null } else { json!("addin_still_not_responding") },+ json!({"success": ready, "addinReady": ready, "restartPolicy": if force { "force" } else if auto_policy { "auto" } else { "ask" }, "discarded": discarded, "tookSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "steps": steps, "errorCode": if ready { Value::Null } else { json!("addin_still_not_responding") }, "_hint": if ready { "Fusion restarted and the add-in answered - re-run the verb that failed." } else { "Fusion restarted but the add-in still is not answering. That is a real defect, not a user problem: check fusion_addin_status and file it." }}) } @@ -449,7 +528,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 +538,52 @@ 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."}) }++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn a_save_prompt_is_answered_only_for_the_bridges_own_document() {+ assert_eq!(prompt_answer(true), "dont_save");+ assert_eq!(prompt_answer(false), "cancel_and_stop");+ }+ #[test]+ fn the_restart_summary_says_what_happened() {+ // 2026-10-28: the save step failed and Fusion was killed, yet the ledger said "saved first"+ let failed_then_killed = [json!({"step": "save", "ok": false}), json!({"step": "save-unavailable"}), json!({"step": "kill", "ok": true}), json!({"step": "start", "ok": true})];+ let s = restart_summary("to load the Adom add-in", "force", &failed_then_killed, &[]);+ assert!(s.contains("Nothing could be saved first, and Fusion was force-closed") && !s.contains("were saved first"), "{s}");+ let saved = [json!({"step": "save", "ok": true}), json!({"step": "stop", "ok": true})];+ assert!(restart_summary("x", "auto", &saved, &[]).contains("Documents were saved first"));+ let graceful = [json!({"step": "save", "ok": false}), json!({"step": "stop", "ok": true})];+ assert!(restart_summary("x", "auto", &graceful, &[]).contains("the way a person closes it"));+ }+ #[test]+ fn a_declined_auto_restart_turns_into_the_ask_for_a_while() {+ let d = std::env::temp_dir().join(format!("fb-restart-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&d);+ assert!(!auto_restart_declined_recently(&d));+ note_auto_restart_declined(&d, &json!({"unsavedWork": [{"prompt": "Save changes to Bracket?"}]}));+ assert!(auto_restart_declined_recently(&d));+ let _ = std::fs::write(d.join("auto_restart_declined.json"), json!({"at": now_secs() - 1900.0}).to_string());+ assert!(!auto_restart_declined_recently(&d));+ let _ = std::fs::remove_dir_all(&d);+ }+ #[test]+ fn the_policy_defaults_to_auto_and_names_force() {+ let d = std::env::temp_dir().join(format!("fb-policy-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&d);+ if std::env::var("ADOM_FUSION_RESTART_POLICY").is_err() {+ assert_eq!(restart_policy(&d), "auto");+ crate::small_verbs::set_pref(&d, "restartPolicy", json!("Force"));+ assert_eq!(restart_policy(&d), "force");+ }+ let _ = std::fs::remove_dir_all(&d);+ }+}
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) = ¬e { 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));+}
skills/fusion-web-control/SKILL.md+4−2@@ -80,7 +80,9 @@ the four questions a caller needs before starting: | `installed_not_running` | Just needs launching | Proceed; launch is a stage, ~60s, folded into the ETA | | `needs_signin` | Running, but at the Autodesk sign-in screen (bridge 1.10.1+) | Drive the sign-in (fusion-autodesk-signin skill); board verbs return this at once, never a restart diagnosis | | `blocked_by_dialog` | A dialog survived the bridge's safe expiry (rules, then WM_CLOSE) | Read `dialogs[]` (text, buttons); answer by button name, then re-run; never restart |-| `addin_restarting` | Running and signed in, add-in not loaded; the bridge is restarting Fusion itself (restartPolicy auto, the default since 1.10.6: every open document is the AI's own) | Poll `fusion_readiness`, then continue |+| `addin_restarting` | Running and signed in, add-in not loaded; the bridge is restarting Fusion itself (restartPolicy auto, the default: documents are saved first, and nobody's unsaved work is discarded) | Poll `fusion_readiness`, then continue |+| `fusion_addin_busy` | Running, add-in loaded but working (a long command starves even its status probe); readiness says `addinBusy` | Wait and poll `fusion_readiness`; never restart for it |+| `restart_needs_decision` | A restart stopped short of unsaved work the bridge could not save (nothing killed or discarded) | Show `userMessage`; restart again once the work is saved | | `addin_restart_required` | Same, on a box whose prefs.json sets `restartPolicy: ask` | Show `userMessage`, then `fusion_restart_for_addin` on a yes | | `ready` | Good to go | Start | @@ -262,7 +264,7 @@ promised "we will save your documents first" while its only button was an acknow the text made an offer the UI could not keep. That is lazy, and it leaves the user exactly where Adonis was. -Under `restartPolicy: ask` (NOT the default since 1.10.6: the default `auto` raises no toast and the bridge restarts Fusion itself when the add-in is truly gone, never over a dialog) the restart notice offers **"Save and restart"** and **"I'll handle it"**, and the+Under `restartPolicy: ask` (NOT the default since 1.10.6: the default `auto` raises no toast and the bridge restarts Fusion itself when the add-in is truly gone, never over a dialog and never over unsaved work it could not save) the restart notice offers **"Save and restart"** and **"I'll handle it"**, and the bridge honours whichever is pressed (`_watch_restart_choice` polls `notify_response` and either runs `fusion_restart_for_addin {save:true}` or records the decline). Both land in the action ledger, so "why did my Fusion restart" has a timestamped answer.
Comments
Log in to comment.