Files
willtools-demo/docs/snapshot-cycle.md
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

12 KiB

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:

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 "<name>" is how you pick one. --knowledge "<a job knowledge file>" 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 <path>, but it does not set this" and "none was read (<reason>)" are different sentences, on purpose.

Five steps, each of which journals so a silent failure is not possible:

1 DETACH write <detached_dir>/<title>_<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.

    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 SaveAss 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.