# The snapshot cycle — how a sandbox gets made, driven and closed Commission, 2026-09-01: *"snapshot功能重构。功能变成保存一份本地detach模型带 时间戳,然后关闭detach模型的revit session,重开这个新的detach模型,做你需要的 所有操作,然后自动关闭。"* One command does all of it and needs nobody at the keyboard: ```bash python tools/snapshot_cycle.py --fresh-detach ``` `--knowledge` is no longer required. Off-Revit there is no `doc.Title` to pick a job folder by, so `WILLTOOLS_JOBS` is asked directly (see `WillTools.extension/lib/vibe/joblocate.py` for the full rule): a single job folder under it is used with no further input; several are a refusal that names every one of them rather than a guess, and `--job ""` is how you pick one. `--knowledge ""` still names the file outright and beats all of that when you would rather not rely on WILLTOOLS_JOBS at all. A blocker that used to just say `snapshot.detached_dir is not set` now says whether a job layer was ever read in the first place, and where -- "found at ``, but it does not set this" and "none was read (``)" are different sentences, on purpose. Five steps, each of which journals so a silent failure is not possible: | | | | --- | --- | | 1 DETACH | write `/_<stamp>/<title>.rvt` | | 2 CYCLE | close the sandbox Revit, launch a new one on that file | | 3 CONFIRM | find the bridge port that is *actually* the sandbox | | 4 BATCH | run each `--run` command against it, tee'd into the snapshot | | 5 CLOSE | close the sandbox again, journal the run | --- ## Which detach mechanism shipped, and why not the obvious one The route this was commissioned expecting was an in-session background open: `Application.OpenDocumentFile(cloudPath, OpenOptions with DetachFromCentralOption.DetachAndPreserveWorksets)` inside the live session, then `SaveAs`. **That route does not exist for a cloud model.** Three separate facts, and each one is on its own sufficient: 1. **A cloud model cannot be opened detached at all.** Setting any `DetachFromCentralOption` on a cloud `ModelPath` raises *"Detach option is not valid for cloud model."* Autodesk's own answer on the API forum is that the Revit team does not allow cloud models to be detached and that **SaveAs is the recommended way**. The UI agrees — "Detach from Central" is greyed out for anything opened from Autodesk Docs, because cloud worksharing is not the worksharing that option belongs to. - <https://forums.autodesk.com/t5/revit-api-forum/how-to-open-and-detach-a-cloud-workshared-model/td-p/10371092> - <https://www.autodesk.com/support/technical/article/caas/sfdcarticles/sfdcarticles/Detach-from-Central-grayed-out-when-opening-from-BIM-360-shared-file-in-Revit.html> Both read 2026-09-01. `OpenDocumentFile`'s own 2024 reference confirms the background-open semantics ("opens the document into memory but does not make it visible to the user") and documents no detach behaviour for cloud paths at all. 2. **Revit holds one Document per model per session.** The live session already has that model open, so it cannot open it a second time. 3. **`SaveAs` re-points the document it is called on**, and there is no `SaveCopyAs` in the API. Called on the live document it would leave the owner's Revit sitting in a local copy, disconnected from the cloud — the one outcome this whole feature exists not to cause. **So the copy is made by a Revit that is not his.** `pyrevit run` starts a headless session, `WillTools.extension/runners/detach_snapshot.py` opens the cloud model *plainly* in it and `SaveAs`s to a new **local central** — which is what severs it — then quits. The same runner still does a real open-detached for a **file-path** source, where detaching is legal; the branch is `_open_detached(..., is_cloud)`. > That branch is also a **bug fix**. Until 2026-09-01 the runner set > `DetachAndPreserveWorksets` on every open including the cloud path, which is > the path this job actually takes. It would have thrown on the first live > press. Phase C end-to-end had never been run against the cloud job — it was > on the needs-bridge list in `QNA/review-2026-08-30.md`. What is left of "the button does it in-session" is the one thing that genuinely requires the live session: the model's **identity**. A cloud model is found by its region and two GUIDs and nothing on the machine knows them except `doc.GetCloudModelPath()` in a session with the model open. The button reads them and writes `<detached_dir>/source.json`; every later run reads that file and needs no Revit at all. **One button press is the whole bootstrap.** --- ## Memory: never three The old design waited for every Revit to exit, on the premise that the machine cannot hold two copies of a 300 MB model. **That premise is retired** — the live cloud session and a detached sandbox ran side by side for a whole morning on 2026-09-01, and two Revit 2024 processes were resident while this was written. What nobody has measured is *three*, so the pipeline sequences rather than waits: ``` close the sandbox -> run the detach Revit -> it exits -> open the new sandbox ``` At no instant are there more than the owner's live session and one other. That is why the detach comes **before** the relaunch: it is the only ordering where the transient Revit and the sandbox are never resident together. `tools/snapshot_defer.py`'s machine-wide gate is not deleted — it is now `--machine-free require`, opt-in, and the close-model button still passes it. --- ## The port race, and why `/status` can now refuse pyRevit does not pin the routes port: `serverinfo._get_next_available_port` starts at the configured one and walks **up** past every port a running Revit already holds. So 48884 is "whichever Revit started first", not a session anybody chose. Restart the sandbox while the live session is up and every request afterwards lands in the live model — and it all looks normal, because the snapshot copy carries the **same `doc.Title`** as the model it came from (deliberately: `snapshotmeta.plan`'s default layout keeps the title constant so the job folder does not move). Two mechanical fixes, and they need each other: **The bridge refuses.** `startup.py` asks `vibe/bridgeguard.py` before every route — `/status` included — and refuses when the open document's title is on `bridge.refuse_titles`. The refusal names the title and the pid, and carries `refused: true` so a caller can tell "the live Revit holds this port" apart from "the sandbox is not up yet". Those two look identical from outside and need opposite responses. A title alone cannot decide it, so a document on the list can still **prove** it is a copy: - its file lives under `snapshot.detached_dir` or a `bridge.allow_roots` entry, or - its file is the model named by `WILLTOOLS_SANDBOX_MODEL` in that Revit's own environment, which only `tools/snapshot_cycle.py` sets, on the session it launched. The live cloud model has no local `PathName` and can offer neither. **The client can be pointed.** `tools/rvt.py` reads `WILLTOOLS_BRIDGE_PORT`, and the pipeline scans `bridge.port .. +bridge.port_span`, picks the session whose title *and file* match the snapshot it just opened, and hands that port down to every batch command in the environment. There is **no off switch**. If the knowledge layers cannot be read at all, the bridge refuses everything and says so; `WILLTOOLS_BRIDGE_REFUSE_TITLES` (semicolon separated) is the way to answer that, because it *states* the list rather than disabling the check. --- ## What it will not kill `taskkill` is only ever aimed at a pid the pipeline can prove is a sandbox: - one it launched itself this run; - one whose **own bridge** reported a document under a snapshot root — the document's statement, not a human's; - one named with `--sandbox-pid` that is a live `Revit.exe`. Anything else is refused with a sentence saying so. And a pid that any bridge port answered a **live-model refusal** from is refused even when named explicitly: the flag is a human remembering a number, the refusal is the document itself. Graceful `taskkill` first, forced only after it will not go — Revit flushes journals and drops file locks on the way out, and the sandbox has nothing worth saving because nothing ever wrote to it. --- ## The button `SnapshotClose.pushbutton` still indexes the open model (phase A) and now offers **four** choices: | | | | --- | --- | | Snapshot, then run the sandbox cycle | index, write the identity, spawn the pipeline. **Revit stays open.** | | Snapshot, then close Revit | the old ritual, unchanged | | Snapshot only, stay open | index and stop | | Cancel | nothing at all | **`snapshot.after_close` is kept, not retired.** It gates the close-model route and nothing else — that is what it has always meant. The cycle route does not read it, because the cycle is not chained to an exit and "after close" has nothing to say about it; making one key mean two things is how a setting stops being trustworthy. **"After close" means the Revit PROCESS exiting, watched by its pid — not the document closing inside a Revit that stays open.** `tools/snapshot_defer.py --wait-pid <pid>` polls `process_alive(pid)`; there is no document-close event anywhere in this design. Closing one job document and opening a different one in the same still-running Revit — which is a completely ordinary thing to do, and is what actually happened on 2026-09-01 — never starts the wait, because the pid it would watch never goes away. If you picked **CLOSE** from the button and then only closed the *document*, the after-close copy was never requested to begin with; if you picked **CYCLE**, no pid is watched at all (see the row above), so this distinction does not even apply to that route. --- ## Configuration All of it lands in `knowledge.DEFAULTS` with the existing comment discipline. Every **value** that names a model or a machine belongs in the job (or plant) layer; the tracked code knows only the keys. | key | default | what it is | | --- | --- | --- | | `snapshot.detached_dir` | `null` | where copies go. `null` = off | | `snapshot.after_close` | `false` | chain the OLD route from the close | | `snapshot.layout` | `folder` | stamp in the folder, title stays put | | `snapshot.revit_year` | `null` | which Revit the detach opens. Unset falls back to whatever year is folded into `snapshot.revit_exe`'s own path (`vibe.snapshotmeta.revit_year_from_exe`) before falling back to letting `pyrevit run` choose | | `snapshot.revit_exe` | `null` | Revit itself, for the sandbox. `null` = derive from the year (a guess, checked) | | `snapshot.batch` | `['python tools/sweep.py']` | what runs inside the sandbox | | `snapshot.sandbox_wait_minutes` | `15` | how long the sandbox gets to answer | | `snapshot.worksets` | `preserve` | forced to a new central for a cloud source | | `snapshot.wait_minutes` | `20` | the OLD route's wait for Revit to exit | | `snapshot.pyrevit_exe` | `null` | the pyRevit CLI | | `bridge.refuse_titles` | `[]` | titles the bridge must never serve | | `bridge.allow_roots` | `[]` | extra folders whose models are sandboxes | | `bridge.port` | `48884` | where the port scan starts | | `bridge.port_span` | `4` | how far up it walks | `snapshot.source_cloud` / `source_path` / `source_title` may also be stated by hand, if you would rather not bootstrap from a button press. Empty is the only honest default for `refuse_titles`: the tool does not know which of a machine's models is somebody's live work. --- ## Tests - `WillTools.extension/tests/test_bridgeguard.py` — the refusal decision, by calling it, plus AST checks that every route in `startup.py` asks. - `WillTools.extension/tests/test_snapshot_cycle.py` — the pid guard, the status verdicts, the port scan, settings resolution, the source cache, the dry run, and now `resolve_job_layer` / the blocker wording it feeds. - `WillTools.extension/tests/test_joblocate.py` — the job-layer resolution RULE itself (found / not configured / ambiguous / explicit), independent of this pipeline; `tools/knowledge_drift.py` reuses the same module. - `WillTools.extension/tests/test_snapshotmeta.py` — the cycle ledger and the batch log naming, alongside the existing phase ledger. Nothing here can open Revit, so nothing here proves the pipeline works live. The first live run is a scheduled thing, not something to slip into a session that has a sweep in flight — a Revit restart mid-sweep destroys the sweep.