Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
36 KiB
Migration — thin in-process gatherer, fat host CPython
Approved by the owner 2026-08-30. This is the order of work, the size of each step, and what each step has to prove before the next one starts. It is not an argument for the direction; the direction is settled.
The rule it implements. In-process IronPython keeps only gatherers — code that must touch the Revit API: views, schedules, geometry, selection, dialogs, export. Everything that churns — check judgement, matching, rendering, config logic — runs on host CPython and is fed by files.
The contract is the file, not the function. A versioned JSON document with
a SCHEMA int (or string), discarded whole on mismatch, carrying provenance.
The pattern is already proven here three times: liftexport → panellift/1,
reinforceindex.extract → reinforcecore.SCHEMA = 6, designspec →
evidence/<DESIGN>.json + specformat.SCHEMA_VERSION. Nothing below invents
a fourth style.
1. What the restarts are actually for
The direction is right; the stated reason for it is only two thirds right, and
the third that is wrong changes which steps pay off first. Measured
2026-08-30, from the pyRevit 5.0.1 source in %APPDATA%\pyRevit-Master\ and
from this machine's own configuration:
| what needs a restart | why | can this plan fix it |
|---|---|---|
| adding, renaming or re-laying-out a button | the ribbon and its command assembly are built once, at pyRevit load | no. Nothing can. |
| a change to a module the bridge session has already imported | startup.py registers the routes handler on the engine that first loaded it; that engine's sys.modules lives as long as Revit. docs/revit-bridge-context.md measured this on 2026-08-30 |
partly — host CPython is re-read on every spawn, so anything that moves across the file boundary stops being cacheable at all |
a change to a lib/vibe module used by a button press |
this is the one that is probably already false — see §8 | already free, if §8's reading is right |
So the payoff of this migration is not mainly "presses see lib edits". It is:
- the bridge stops lying. A judgement layer on the far side of a file is
re-read every spawn; no session can hold a stale copy of it, and the trap in
revit-bridge-context.md("a module read early and edited later is stuck for the rest of the session") cannot apply to it. - the suite can reach it.
tests/conftest.pycan only import a module that does not touch Revit at module scope. Every line moved across the boundary becomes testable without a licence, a model or Windows — which is howpanelliftcame to have its own suite andticketpageits 250-odd tests, all of them running on host CPython. - the restart-needing surface shrinks. TicketQC's six modules
(
ticketqc,ticketchecks,ticketpage,ticketvision,ticketdelta,ticketextract) are all in-process today, and §4 ends with roughly 1,700 lines of them in-process and the rest on the host. Their combined total was measured six times in two days and came out 12,139 → 12,232 → 13,179 → 13,538 → 13,635 → 14,215, the last two both on 2026-08-31 a few hours apart. The argument does not depend on which of those it is, which is why §3 no longer prints per-module counts. Re-measure with §3's command if you need today's.
Write that down honestly in any status note. The plan reduces the surface that needs a restart. It never reaches zero, because a gatherer is still a module in Revit's engine.
2. The contract
One shape, reused. Anything new copies liftexport.SCHEMA / reinforcecore
rather than inventing:
{ "schema": "ticketqc/1", # int or "name/N"; bumped on ANY key change
"written_at": "<iso8601>", # provenance
"written_by": "TicketQC <ver>", # which button, which build
"doc_key": "<stable model key>", # reinforcecore.doc_key idiom
"knowledge_sources": [ ... ], # knowledge.sources(doc), verbatim
... payload ... }
Four rules, all of them already load-bearing somewhere in this repo:
- Discard whole on mismatch.
reinforcecore.load()throws away a file whoseschemais not the current int — never partially, never migrated. A half-read evidence file is a run against a configuration nobody chose. - No live objects. Element ids as ints, marks as strings, view names and
flattened text as strings.
read_ticketcurrently puts the liveDocumentont['doc']and liveViewobjects int['legends']. Corrected while building step 1: those are not the whole list.t['sheets']is a list of liveViewSheetandt['sheet']is one of them, read in thirteen places inticketchecks; and on the no-panel early returnt['strand_points']is still liveXYZ. Four holdouts, not two — which is why the serialiser gates on TYPE rather than on a list of known offenders, and why the test for it walks the whole structure. - Absence is not a value. A gather that could not read something writes
the key with an explicit
nulland a reason beside it. A key that is simply missing reads as "nothing to check" downstream, which is the exact silence-looks-like-a-pass failureticketchecks._SKIP_PROBESexists to prevent. The probe idiom moves across the boundary with the check. - Provenance travels. Which knowledge layers were in force, which spec
source (
spec/regex), which build. A finding whose provenance was left behind on the Revit side cannot be re-judged from the file.
3. Module inventory, with verdicts
Every module under WillTools.extension/lib/vibe/. "Revit at module scope"
comes from an AST walk, not from reading; "heat" is commits touching the file
in the seven days to 2026-08-30.
The line counts are gone from these tables, deliberately. They were walked
three times in three days and were stale all three times. Two measurements
twenty minutes apart on 2026-08-30 disagreed by 479 lines on knowledge.py,
173 on ticketpage.py and −71 on designspec.py, because two agents were
writing to the tree between them. The 20:52 walk that replaced those was
itself wrong by the next morning: re-walked 2026-08-31, yamlkit.py had gone
346 → 791, configform.py 612 → 985, takeoffcount.py 299 → 470, and ten
more had moved — knowledge.py by nearly a thousand lines (2,477 → 3,442),
ticketpage.py by three hundred. Both of those moved again between two
walks a few hours apart the same day, while agents were writing to the tree.
This section has said "the verdicts do not move, the numbers do" since it was
written, and then printed the numbers anyway. The format now matches the
note.
Get them fresh instead — one line, and it is the same walk the table used:
cd WillTools.extension/lib/vibe && wc -l *.py | sort -rn
Rows below are ordered largest-first as of 2026-08-31, so the ordering is a snapshot too. The verdict in each row is not, and that is the column this table exists for.
Counts: 4 gather-bound · 28 already-pure · 7 mixed, needs a split line — 39
modules, re-checked 2026-08-31: none added, none removed since the 20:52 walk.
plantpath.py remains the most recent addition and is already in §3.2. The
one growth worth knowing about is not churn: ticketqc grew by roughly 590
lines when step 1 added the evidence writer to it, which is this plan's own
work.
The defs and pure/Revit splits quoted inside §3.3 are as first measured and
not re-walked, except where a cell says otherwise. The original counted a def
as Revit-bound by what it reaches for, which an import-based AST walk cannot
reproduce, so re-deriving them means redoing the judgement, not rerunning a
script. They are marked in place rather than deleted; treat them as
unverified-since.
3.1 Gather-bound — stays in-process, permanently (4)
| module | heat | note |
|---|---|---|
latextract.py |
1 | Autodesk.Revit.DB at module scope; pure model reading. Feeds latmodel (already pure). |
jobreconscan.py |
2 | ditto; the scan half of JobRecon, whose judgement half (jobrecon.py) is already pure. |
helpers.py |
0 | q p set_p tx selected ft mm. This is the Revit surface. Never migrates. |
__init__.py |
0 | re-exports helpers; the reason conftest.py has to stub the package. |
3.2 Already pure — no migration, only a runner and a feed (28)
These import no Revit API at module scope and none inside their functions. Every one of them could run on the host today given its inputs as a file.
| module | heat | what it is |
|---|---|---|
ticketpage.py |
26 | the whole TicketQC page. Hottest pure module in the repo, and the proof the target works. |
knowledge.py |
12 | five-layer merge. Reads YAML from paths; already file-fed by construction. |
configmodel.py |
4 | what may be edited, provenance, the splice. |
reinforcereport.py |
2 | |
jobrecon.py |
3 | |
reinforcebind.py |
3 | |
configform.py |
new | untracked when this table was first written; tracked since. |
ticketdelta.py |
1 | |
yamlkit.py |
1 | |
takeoffreport.py |
2 | |
reinforcematch.py |
1 | |
ticketextract.py |
1 | |
reinforcecore.py |
2 | owns SCHEMA = 6 — the reference implementation of rule 1. |
configdump.py |
3 | |
takeoffcore.py |
1 | |
configpage.py |
2 | |
snapshotmeta.py |
3 | |
takeoffcount.py |
1 | |
specformat.py |
2 | owns SCHEMA_VERSION. |
foldreport.py |
3 | |
latmodel.py |
1 | |
takeoffgather.py |
1 | pure despite the name — it gathers counts, not elements. |
scope.py |
1 | |
specsave.py |
1 | |
latrisa.py |
1 | |
plantpath.py |
0 | new since the 19:30 walk — the plant-layer path resolution designspec re-exports. Pure, and already file-fed. |
compat.py |
0 | py2/py3 shim; stays in-process as a shim, irrelevant on the host. |
batchrun.py |
3 | pure of Revit but in-process by nature — the queue, the lock and the thread factory must be created on the UI thread while the defining scope is alive (docs/lift-check.md). Do not "migrate" it; it is the thing that drives a migration. Listed last regardless of size, because that note is the reason it is here. |
3.3 Mixed — the split line, named (7)
| module | heat | the split line |
|---|---|---|
ticketchecks.py |
40 — hottest file in the repo | Top-level defs went 128 (19:30 walk) → 131 (20:52) → 138 (midday 2026-08-31); grep -c '^def ' ticketchecks.py for today's, and note that it moved three times in two days. The pure/Revit split is as first measured, not re-walked: 97 pure (1967 lines), 31 Revit-touching (~2000). 27 registered checks in CHECKS — the 26 this table used to say predates check_callout_overlap (checklist row 1.5); re-count by reading the CHECKS tuple, which is one name per line. 20 of the 27 carry a skip probe and 7 do not (see CLAUDE.md §5 for why that matters when you add one). About 21 top-level defs reach t['doc'] or t['sheets'] — that is the real size of step 4's surface. The older "13 of the checks reach t['doc']" figure counted reads through helpers as well as direct ones; only 9 do it in their own body, so the two are not the same measurement and neither is wrong. Split per check: each becomes gather_<check>(doc, t) → dict (in-process, into the evidence file) plus check_<name>(t, intent, know, spec) reading only that dict (host). _SKIP_PROBES moves with the judgement half. |
ticketqc.py |
17 | Top-level defs plus classes, not defs alone: the "76" this table used to give was 74 defs + 2 classes, and the def count has since moved again. The half-and-half below is as first measured, and excludes the evidence writer: 31 Revit-bound (991 lines) — read_ticket, member_boxes, collect_context, _read_bom_schedules, parse_design_summary, strand_layout_of, concrete_extents, element_solids, run, run_for_assemblies; 31 pure (689 lines) — Finding, check_ticket, every _check_*, spec_to_intent, and all the parsers/formatters (parse_feet, fmt_feet, parse_strand_groups, strand_signature, parse_concrete, parse_wwf, _compare). The pure half is what ticketchecks imports at module scope, which is the single reason ticketchecks cannot be imported on the host without a stub. Splitting ticketqc into ticketqc (gather) + ticketrules (pure) deletes the stub in test_ticketchecks_host.py. |
liftexport.py |
10 | already split, correctly. export() and below-export() are separated by design; the file boundary is panellift/1. Precedent, not work. Only new gathers go in above the line. |
ticketvision.py |
4 | gather = rendering a view to an image and shelling out; pure = the ladder, report_block, qc_json, write_run_json. The pure half is where the NOT RUN / WARN invariant lives and it must cross with the judgement. |
designspec.py |
4 | half already across. gather_evidence → evidence/<DESIGN>.json exists. build_spec, new_spec, validate_spec, get_path and the path helpers are pure and can move as soon as a runner exists. Module-scope Autodesk.Revit.DB is what pins the rest. |
reinforceindex.py |
2 | extract gathers → the index file; everything else already reads the file. Same shape as liftexport. Small residue. |
4. Migration order
Sizes: S = one working session · M = two or three · L = a week of sessions. Every step ships alone and leaves the tree working; no step depends on the next one existing.
Step 1 — TicketQC evidence file, written and ignored — M — DONE 2026-08-30
ticketqc grows ticket_to_json(t) and the button writes
<model>/_ticketqc/evidence/run-<stamp>.json on every press, schema
ticketqc/1. Nothing reads it. t itself does not change shape, and no
judgement moves.
The two holdouts named in §2 rule 2 are the work: t['doc'] becomes
doc_key + the model's path stem, t['legends'] becomes
[{id, name, text}] from _flat_text. Everything else in read_ticket is
already strings, numbers and dicts.
Ships alone because a file nobody reads cannot break a run — wrap the write
the way write_run_json is wrapped in the button today, so a failure prints a
line and does not lose the report.
AS BUILT. There is a THIRD live holdout, and the paragraph above is wrong
about it. t['sheets'] is a list of live ViewSheet objects and
t['sheet'] is one of them; ticketchecks reads .Name, .Id and the sheet
number off them in thirteen places, and 20 of its top-level defs reach
t['doc'] or t['sheets']. They serialise the same way the legends do —
[{id, number, name}]. A fourth exists only on a failure branch: when
read_ticket finds no framing member it returns early, and t['strand_points']
is still a list of live XYZ at that point rather than the None the
success path leaves. Both are handled; neither was in the plan.
Also as built, and deliberately beyond the paragraph above:
load_evidenceships now, unused. Rule 1 is a property of the format, not of the writer, and a discard rule that has never been executed is a comment. It is copied fromreinforcecore.load()down to the shape of the failure, and its test asserts that a schema-mismatched file comes back with no tickets — not merely with_load_errorset.- Rule 3 is enforced by construction, not by discipline. Every field goes
through one
put()that will not write a null without writing a sentence beside it, and the test walks the produced document rather than naming keys. - The writer names what it did not serialise.
unserialised_keyslists every ticket key this writer does not know about — today the cachesticketchecksleaves behind (frame,member_boxes,_debond_requirement, ...). That list is step 4's worklist, and it is there so a gather added later cannot fall out of the file in silence. intentandspec_sourceride with each ticket, because §2 rule 4 says a finding whose provenance stayed on the Revit side cannot be re-judged. Findings do NOT:qc-run.jsonowns the verdicts, this file owns the inputs, and step 2 needs them apart to compare one against the other.
Gates: §5.2 / §5.3 / §5.4 / §5.5 met (39 new tests, suite green, scrub clean, one implementation). §5.1 is NOT met and could not be: parity needs a real press and the bridge was held exclusively by another session. What is unproven is only what a press can show — that the write costs nothing visible, that the file is well-formed on real geometry, and its size. The budget in §6 is still a budget; a synthetic single-piece document is 3.7 KB, which says nothing about a real one carrying a full BOM and flattened note text.
Step 2 — the host runner, judging from the file — M
WillTools.extension/runners/ticket_judge.py, host CPython, no Revit
anywhere: reads the evidence file + knowledge.yaml layers + specs, calls
check_ticket and ticketchecks.run_all for the 13 checks that never touch
doc, writes findings.json.
The button does not call it yet. This step's whole output is a parity proof
(§5) run offline against the twelve-piece audit set in
docs/ticket-audit-wp1-12.md.
Step 3 — the button calls the runner for the pure checks — S
liftexport.find_python + liftexport.run already know how to find an
interpreter and shell out with a timeout and a tick callback. Reuse them; do
not write a second spawner. The in-process path stays in the file, behind a
knowledge switch, until step 5.
This is the first press where a ticketchecks judgement edit is visible
without a restart.
Step 4 — the 13 doc-touching checks get gather halves — L, but 13 ships
One check per commit, in churn order. For each: gather_<check> writes its
sub-document into the evidence file under its own key; the judgement half
moves to the host and reads only that key; the skip probe moves with it.
Order by heat and by how much geometry each drags along:
| # | check | why here |
|---|---|---|
| 1 | check_bom_strand_count |
smallest gather (a schedule read), and 56 lines |
| 2 | check_jacking_force |
58 lines, already has a probe |
| 3 | check_wwf_designation |
44 lines |
| 4 | check_ticket_set |
58 lines |
| 5 | check_handling_result |
reads a file liftexport already writes |
| 6 | check_bom_members |
104 lines |
| 7 | check_picking_match |
90 lines |
| 8 | check_note_views |
80 lines |
| 9 | check_cast_extras |
78 lines |
| 10 | check_strand_debond |
via _strand_debond_openings |
| 11 | check_dunnage_openings |
227 lines, drags liftexport |
| 12 | check_bed_plan_reverse |
232 lines |
| 13 | check_member_interference |
last. 300 lines, and the one place where two working-looking versions were both wrong (docs/interference.md). Move it only when the other twelve have proved the gather shape. |
Step 5 — ticketqc splits; the stub dies — M
ticketqc keeps the 31 Revit-bound defs. The 31 pure ones move to
ticketrules.py. ticketchecks imports Finding, fmt_feet and _fmt_num
from there instead, and test_ticketchecks_host.py's _stub_revit() — with
its careful comment about putting sys.modules back — is deleted, not
rewritten. Delete the in-process duplicate path from step 3 in the same
commit; double maintenance ends here.
Step 6 — ticketpage renders on the host — S
It is already pure and already the hottest pure file. The runner returns markdown; the button prints it. Small because the work was done in advance.
Step 7 — ProjectConfig — S/M
Five modules, all already pure (configmodel, configpage, configform,
configdump, knowledge), 4,801 lines, 14 commits in seven days and second
only to TicketQC in churn. The gather is thin: knowledge.load(doc) and the
model's paths. Highest ratio of churn relieved to work done in the whole plan
— it may deserve to run before step 4 if config keeps churning.
Step 8 — ExtractDesign — S
gather_evidence already writes the file. Move build_spec and validation to
a runner.
Step 9 — ReinforceSuggest — M
reinforceindex.extract already writes the index; reinforcematch,
reinforcebind and reinforcereport are already pure. Low heat (3 commits in
seven days) — do it when the churn justifies it, not before.
Never — and say so out loud
| why | |
|---|---|
| SelfCheck | its entire output is what this session can see: engine, knowledge layers, paths, live document. Moving it off-process would make it report a different process's answer. Inherently in-process. |
| SnapshotClose | detaches and saves a document. It is a Revit transaction with a progress dialog. Nothing to migrate. |
| PanelLiftCheck | already migrated. panellift is the model this plan copies. |
batchrun |
see §3.2 — UI-thread coordination, not logic. |
helpers, __init__ |
the Revit surface itself. |
| LateralExtract, GravityLoadChase | unfinished, behind the DesignHub pulldown on Design.panel (the Lab.panel slideout until 2026-08-30). Do not migrate a tool that has not settled what it does. |
5. Acceptance gates
Every step, all five. A step that cannot show all five is not shipped, it is in progress.
5.1 Parity, on real data, with the clock frozen
The standard the enabled work set and the --no-recommend work met: the
old path and the new path produce byte-identical output on the reference
data. panellift/tests/test_no_recommend.py::test_a_default_run_is_byte_for_byte_what_it_was
is the shape to copy — assert it in the suite on one case, and run it out of
band across the whole reference set with the clock frozen.
For a check moving in step 4 the unit is the finding, not the byte: the same
(severity, code, message) tuples in the same order, for all twelve pieces of
the WP1 audit set, in-process vs host. A message that differs by so much as a
rounded digit is a failed step, not a cosmetic difference.
Freeze the clock. Every document in §2 carries written_at, and a parity
diff that is nothing but timestamps teaches nothing and hides everything.
5.2 The honesty invariants, re-asserted after the move
These are the reason the tool is trusted and they are the easiest thing to lose in a refactor. Each needs a test on the host side after the move:
- NOT RUN is never a pass. A check whose inputs did not arrive reports NOT
RUN with a reason. The
_SKIP_PROBESdisambiguation — probe returns text → SKIPPED with the why; probe absent → NOT RUN, reason "no probe" — must survive the split intact. An empty gather key is exactly the case it catches, and it is the case a file boundary makes more likely, not less. - Could-not-judge stays distinct from judged-and-fine. The
computed/model-readseparation inticketvisionnever merges into one list;qc_jsonkeeps two keys. - Provenance stamps survive. Knowledge layers, spec source, build. §2 rule 4.
5.3 Suite green
cd WillTools.extension && python -m pytest — 1440 at time of writing —
and cd panellift && python -m pytest — 272. Mind the root: run from the
repo root instead and pytest also collects panellift/ and the gitignored
_scratch/, which is a different and larger number (1,847 green at
2026-08-30 21:05, all suites, after step 1's 39). Quote which root a count
came from or two people will compare numbers that were never the same
measurement. Both green, and the count goes
up at every step: a step that moves 500 lines to the host and adds no
tests has not banked the reason for moving them.
5.4 Scrub clean
python tools/scrub.py --worktree. New attention needed: evidence files carry
element ids, marks and sheet names. Ids and marks are naming conventions and
stay; a model filename or an absolute user path in a committed sample is a
leak. Evidence files live beside the model and are never committed —
keep it that way, and keep the fixture set synthetic.
5.5 The gate that is specific to this migration
No step may leave two live implementations across a Revit restart. During a step the old path may stay behind a switch; by the end of the step it is deleted. Two paths that both run are two paths that drift, and the drift is invisible until someone reads a finding that only one of them produces.
6. Costs, measured
| cost | measured | note |
|---|---|---|
| interpreter spawn, bare | 0.026 s median of 7, host CPython 3.12 | floor |
spawn + the stdlib a runner imports (json os io re argparse datetime html) |
0.040 s | |
spawn + import panellift.cli — a real package |
0.066 s | closest analogue to a judgement runner |
| a Panel Lift press, end to end | ~0.1 s of overhead | includes .NET Process.Start, writing the job and reading the summary back |
| a reference job file | 8.6 KB median (368 files, 3.0 MB total) | the proven panellift/1 payload |
| a ticket evidence file | budget 30–80 KB per piece, STILL A BUDGET | bigger than a job file: BOM rows, member boxes, flattened note text. Step 1 shipped without the real number — it needs a press, and the bridge was held. A synthetic single-piece document is 3.7 KB, which measures the envelope and nothing else. Measure it on the first real press and replace this row. |
| a 184-piece batch | 6–15 MB on that budget | one file per run, not per piece |
The spawn is per press, not per piece. A batch of 184 pieces pays it once. It is noise next to a 12.6 s position search and next to the geometry reads the gather does anyway. It is not noise on a button someone presses to see one number — if such a button appears, it keeps its logic in-process.
Double maintenance runs from step 3 to step 5 for TicketQC, and for the duration of each later step. The gate in §5.5 bounds it: within a step, not across one.
The residual truth. Steps 1, 4 and every future gather still live in
Revit's engine. Editing read_ticket or a gather_* still needs a restart if
the bridge has imported it. The plan shrinks that surface from 12,139 lines to
roughly 1,700; it does not remove it, and nothing can.
7. What not to do
- No big-bang rewrite. Nine steps, each shippable, each with a parity proof. A branch that moves TicketQC wholesale cannot be verified against anything, because the thing it would be verified against no longer runs.
- Never migrate a module another agent is refactoring this week.
ticketcheckstook 38 commits in seven days. A split landing on top of an in-flight change to the same file loses one of them silently. Checkgit log --sincebefore starting a step; if the file moved today, pick a different step. - The bridge and fork workflow are untouched. No
/reload, no second session log under one process, andrvt.py's refusal is never worked around. Prototype read-only before anything that writes. - The read-only rule is untouched. A gatherer reads. A migration is not a reason to open a transaction on the owner's model.
- Do not invent a fourth file format. Three exist and agree. Copy one.
- Do not move a check and its probe separately. The probe is the honesty invariant; a check that arrives on the host without one renders NOT RUN with reason "no probe" — which is correct behaviour reporting a botched migration, and will be read as a tool bug.
8. The quick win, landed: engine: clean on the three churn-heaviest buttons
8.1 The option is real, and this is exactly what it does
Verified against the installed pyRevit 5.0.1 at %APPDATA%\pyRevit-Master\,
not against folklore:
| key | engine: → clean: — pyrevit/extensions/__init__.py lines 101–102, MDATA_ENGINE = 'engine', MDATA_ENGINE_CLEAN = 'clean' |
| read by | GenericUICommand._read_bundle_metadata, pyrevit/extensions/genericcomps.py ~590 |
| siblings | full_frame, persistent, mainthread (+ Dynamo-only keys) |
| value type | string, always. coreutils/yaml.py wraps YamlDotNet's representation model and returns YamlScalarNode.Value, so clean: true arrives as "true" and the .lower() == 'true' test in genericcomps is safe. Unquoted true is correct and is what pyRevit's own bundles write (pyRevitCore/.../Reload.pushbutton/bundle.yaml). |
| effect | runtime/pythontypemaker._does_need_clean_engine → runtime.create_ipyengine_configs(clean=…) → a fresh IronPython engine per press instead of the cached one. Fresh engine, fresh sys.modules, lib/vibe re-imported from disk. |
| cost | engine construction on every press instead of engine reuse |
Our guard test tolerates it already: test_button_tooltips.py reads only
tooltip / author / authors, and vibe.yamlkit.parse handles the nested
mapping (checked: returns {'engine': {'clean': True}} and leaves tooltip
None). No test change was needed.
8.2 The caveat that changes what this is
Read _does_need_clean_engine in full:
use_clean_engine = True
if user_config.rocket_mode \
and _is_rocketmode_compat(extension.name) \
and not cmd_component.requires_clean_engine:
use_clean_engine = False
A clean engine is the default. Rocket mode is what takes it away — and
only for an extension that extpackages.get_ext_package_by_name() can find.
That lookup drops any package failing ExtensionPackage.is_valid(), which is:
return self.name is not None and self.url is not None
WillTools.extension/extension.json declares name and
rocket_mode_compatible: true but no url. So the package is never
registered, _is_rocketmode_compat('WillTools') returns False, and every
WillTools button already gets a clean engine — even though
pyrevit configs rocketmode reports Rocket Mode is Enabled on this
machine.
That was checked two ways. pyrevit.exe configs rocketmode for the setting;
and the package-discovery and clean-engine decision replayed on host CPython
against the real pyRevit_config.ini and the real manifests — WillTools comes
back is_valid=False, every shipped pyRevit extension is_valid=True.
So this is a pin, not a lever. It buys no restart-freedom today, because
the behaviour is already in force. What it buys is that the behaviour stops
depending on a field being absent from a JSON file. Adding a url to
extension.json — the obvious thing to do the day this ships from its
repository — silently switches every button to the shared engine and brings
back exactly the pain this plan was called for. engine: clean: true makes
the three buttons immune to that.
It is landed on that basis and nothing more. It is not the reason the restarts happened today; §1 is.
8.3 Where it was applied
Churn over the seven days to 2026-08-30, by distinct commits touching a button's modules:
| button | commits, 7d | today | in-process lines (2026-08-30) |
|---|---|---|---|
| TicketQC | 51 | 7 | 13,179 |
| ProjectConfig | 14 | 6 | 5,357 |
| PanelLiftCheck | 13 | 5 | 2,078 |
| ExtractDesign | 4 | 2 | 1,793 |
| ReinforceSuggest | 3 | 3 | 4,126 |
The top three carry the key. The other seven buttons do not — and that ranking comes from the commit columns, which are a closed seven-day window and do not drift.
The line column is dated and unverified as of 2026-08-31. It has moved
(TicketQC's six modules alone are 13,635 today), but it cannot simply be
re-run: whoever measured it did not record which modules each button's total
covers, and the obvious guesses do not reproduce the 2026-08-30 figures.
Re-deriving it means defining the walk first — per button, its script.py
plus the vibe modules it imports transitively — and that is a piece of work,
not a wc -l. Left in place, stamped, rather than deleted: it is the right
order of magnitude and it is what the decision below was taken on.
8.4 What the first press after a restart should show
Nothing measured live: the ribbon is built at load, so this cannot be checked without a restart, and no bridge call can answer it.
The cheap check, no code. pyRevit caches the parsed extension at
%APPDATA%\pyRevit\2024\pyRevit_2024_cache_WillTools.pickle, rebuilt on every
load. Before this change, all ten ribbon buttons cached
requires_clean_engine = False (verified 2026-08-30 19:17). After a restart it
must read True for TicketQC, ProjectConfig and PanelLiftCheck and False
for the other seven. It is a Python-2 pickle of pyrevit.extensions.components
types; unpickle it with an Unpickler whose find_class returns a stub, and
read requires_clean_engine off each component.
The behavioural check, one minute. Press TicketQC. Change a literal string
that renders — a section heading in ticketpage.py will do. Press it again
without restarting. If the new heading appears, the engine is re-importing
lib/vibe on every press and §8.2's reading is confirmed on this machine.
If the old heading persists, §8.2 is wrong, engine: clean is doing real work
after all, and §1's table needs its third row rewritten.
Either answer is worth having. Record it here.
8.4a Recorded — the offline check, run 2026-08-30 20:45
The cache still describes the OLD ribbon, so its flags are not a fact about today. That is the finding, and it is more useful than the flags would have been.
| file | %APPDATA%\pyRevit\2024\pyRevit_2024_cache_WillTools.pickle |
| mtime | 2026-08-30 19:17:43, 14,304 bytes — the same moment §8.4 above records its "verified 19:17" reading. It has not been rewritten since. |
| components carrying the flag | 10 |
requires_clean_engine = True |
0 of 10 |
The ten it lists are ExtractDesign, GravityLoadChase,
LateralExtract, PanelLiftCheck, ProjectConfig, ReinforceSuggest,
SelfCheck, SnapshotClose, TicketQC plus one more no longer on the
ribbon. That set is the ribbon as it stood
before 2026-08-30's restructure:
JobRecon and Takeoff have come back onto the ribbon out of the deleted
parked/, and the two .pulldown containers (Config, DesignHub) do not
appear at all. Meanwhile engine: clean: true is on disk right now in
TicketQC, ProjectConfig and PanelLiftCheck's bundle.yaml.
So the cache is rebuilt at pyRevit load, not on a bundle edit, and Revit has
not been restarted since either change landed. Reporting these False flags
as current state would be reporting a file that predates the thing it is being
asked about. What the check does establish:
- the unpickle-with-a-stub technique works, and is the right way to read
this — no
pyrevitimport needed; - the flags are a post-restart measurement and nothing else. Re-run the
probe after the next Revit restart; §8.2 predicts
Truefor exactly TicketQC, ProjectConfig and PanelLiftCheck,Falsefor the rest, and the component list should show the new ribbon; - §8.2's reading is not tested by this either way. The cache said
Falsefor all ten before theengine: cleankeys were written too, which is what the 19:17 reading already said.
The behavioural check (press, edit a heading, press again) is still not run — it needs a person at the keyboard in Revit, and no bridge call can answer it. It stays open.
8.5 When to take it back out
Per button, as that button stabilises. The comment sits in each
bundle.yaml; the whole of the instruction is "delete these three lines". A
button whose modules have not been touched in a fortnight does not need the
pin — and once §4 and §7 have moved the churn to the host, the pin protects
nothing worth protecting. It is not a permanent setting and it should not
outlive step 6.
9. Where this starts
Step 1, and read docs/plan-ticketqc-refactor.md first — it owns the shape of
the ticket data this plan serialises, and its phases are not finished. Nothing
here reorders that plan; this one starts where a ticket has already been read
correctly.