app
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.
← Commit history
A restart never kills Fusion over unsaved work it could not save
- restartPolicy auto stops short of losing work: when the add-in cannot save, Fusion is closed
gracefully instead of killed, and a save prompt for a document the bridge did not make (or a
never-saved document) ends the restart with restart_needs_decision; nothing is killed or
discarded, and the next 30 minutes ask instead of retrying. A stop that did not finish is no
longer escalated to a kill. The previous auto is restartPolicy force, for AI-only machines.
The ledger summary reports what each step really did.
- fusion_stop waits closeTimeoutSec (default 120, was 15 s) for heavy designs, answers Don't Save
only for the bridge's own documents, and cancels a prompt for anyone else's (unsaved_work).
- A saved cloud document that appears during a verb was opened, not made: it is no longer
registered as bridge scratch, and neither is anything Fusion opens at launch. The registry
evicts its oldest entry, not the alphabetically first.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
10 files changed
+266−50
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": {},
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/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/lifecycle.rs+147−25@@ -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." }}) } @@ -465,3 +544,46 @@ pub fn diagnose_addin_timeout(ctx: &Ctx, command: &str, original: &Value) -> Val 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);+ }+}
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.