feat(tiles): wire tile-lifecycle FSM + dispatch drift check (Phase F)
Phase F wires the FSM scaffolded in Phase A to real lifecycle events
and adds a dev-mode drift check at the dispatch site. The FSM was
inert until now — Phases B-E built up the infrastructure around it;
this commit flips it live.
- `feature-startup.ts` — every registry mutation now fires the
matching FSM trigger for each declared tile entry. INSTALL on
registerFeature (via the listener AND via the boot-time seed walk,
mirroring the manifest-cache wiring), UNINSTALL on unregister. The
seed pass happens before `syncBuiltinFeatures` so the FSM's
REGISTERED state is the baseline dispatch-drift checks see. Transition
failures are caught and warned — a malformed manifest can't crash
startup.
- `tile-launcher.ts` — `createTileBrowserWindow` now fires LOAD
(REGISTERED → LOADING). The render-process-gone handler fires
RENDER_GONE (→ CRASHED). The close handler walks SHUTDOWN →
CLEANUP_KEEP (→ REGISTERED) so repeat launches start from a valid
state. `signalTileReady` fires TILE_READY (LOADING → READY).
Transitions are best-effort: for trustedBuiltin callers (hud-glue,
cmd-glue, etc.) whose entries never went through the feature
registry, the entry is UNREGISTERED, INSTALL/LOAD get rejected by
the FSM, and we silently continue. This is intentional — the FSM
is authoritative for v2 feature tiles only for now; extending it
to trustedBuiltin helpers is a later phase.
- `tile-lazy.ts` — `assertDispatchFsmState` runs at dispatch entry
for both `cmd:execute:*` and lazyEvent paths. Under `isDevProfile()`
it logs loudly when the target's FSM state isn't one of
unregistered/registered/loading/ready/visible (i.e. UNLOADING or
CRASHED). Non-blocking: the hook still proceeds with load/deliver
so a drift warning never masks itself as a functional failure.
Prod profile: silent.
Why non-blocking: the drift check exists to catch *wiring bugs* —
places in the codebase where a tile-lifecycle event happens without
notifying the FSM. A loud warning in dev is enough; escalating to a
thrown error would turn a diagnostic into a landmine because the FSM's
view of the world is only as complete as the wiring. Once every
lifecycle event goes through the FSM (Phase G / future), we can
tighten this.
Per-policy notes (from the FSM design doc):
- Crashed tiles DO NOT auto-recover. The FSM has a RELAUNCH trigger
from CRASHED → LOADING, but no caller fires it automatically.
Recovery is user-initiated (a future "Reload feature" action).
- SHOW/HIDE transitions (READY ↔ VISIBLE) aren't wired yet — we
don't have a clean observable for "tile just became visible"
separate from window show/hide. Deferred; drift checks accept
both READY and VISIBLE today.
Tests: 2249/2249 unit + 238/238 Playwright (desktop + desktop-serial).