Skip to content

fix: surface why a launch-time recorder upgrade was skipped - #228

Merged
mikehasa merged 1 commit into
mainfrom
fix/upgrade-observability
Sep 14, 2026
Merged

mikehasa merged 1 commit into
mainfrom
fix/upgrade-observability

Conversation

@mikehasa

Copy link
Copy Markdown
Owner

Problem

The app upgrades its recorder (CLI + daemon) on launch when the app is newer than the installed recorder — but upgradeInstalledCLIIfNeeded returned .notNeeded identically whether the recorder was already current or whether it could not be recognized at all. So when the app is newer than the recorder yet can't build an upgrade context, nothing upgrades and nothing says why — an app-newer-than-recorder skew is invisible to the user and undiagnosable from outside the app. (This is exactly the shape seen on a machine where a 0.10.8 app sat over an older recorder: "recorder unreachable," daemon left behind, no cli-versions/ staged, no signal.)

Related: #216 is what lets a 0.10.8 app recognize a symlinked legacy recorder in the first place (the pre-#216 inspector rejected framework symlinks → the recorder was unrecognizable → this same silent .notNeeded). But there was no regression test for that launch-upgrade path with a real Python.framework-shaped payload, which is why it could regress silently.

Change

  • Add upgradeBlockedReason: when the launch upgrade does nothing and no recovery is pending, name why a present recorder couldn't be carried to this app's version — either the app can't verify its own bundled recorder, or the installed recorder isn't recognized as app-owned. nil when nothing is installed, when the installed recorder already matches, or when a recovery path owns the outcome.
  • upgradeInstalledCLIIfNeeded records it on a new @Published recorderUpgradeDiagnostic and logs Recorder update skipped: … instead of a silent skip; it's cleared once an upgrade proceeds. The upgrade decision itself is unchanged — this only makes the no-op observable.
  • The reason is computed only inside the upgrade check (which already does the identity/provenance work), never during rendering, so it doesn't violate the "rendering must not traverse/hash payloads" rule.

Tests

  • testAutomaticUpgradeOfLegacyInstallWithFrameworkSymlinks — the missing regression guard: a legacy install whose Python.framework keeps its four canonical relative symlinks is auto-upgraded by a newer app, and leaves no false blocked-reason.
  • testLegacyUpgradeReportsWhyWhenInstalledRecorderIsUnrecognized — the installed-recorder recognition failure (the exact fix: keep App, CLI, and public contracts aligned #188 chain) is now reported, not silent.
  • testLaunchUpgradeReportsWhenBundledRecorderCannotBeVerified — the bundled-recorder branch (the one a real signed bundle could trip, invisible from outside) is reported.

swift test — 505 executed, 0 failures, 6 skipped. swift build -c release — clean.

Follow-up

This makes the failing gate observable, which is what lets us finally see which gate nils on a given machine. Surfacing recorderUpgradeDiagnostic prominently in the recorder-health UI (so a user sees "recorder needs reinstall" instead of a bare "unreachable") is a small follow-up left to the app's owner.

The app upgrades the recorder (CLI + daemon) on launch when it is newer than
the installed one, but upgradeInstalledCLIIfNeeded returned .notNeeded
identically whether the recorder was already current or could not be recognized
at all. That silent no-op made an app-newer-than-recorder skew impossible to
diagnose: nothing upgraded, and nothing said why.

Add upgradeBlockedReason: when a recorder is present but no upgrade context can
be built — the app cannot verify its own bundled recorder, or the installed
recorder is not recognized as app-owned — name the reason.
upgradeInstalledCLIIfNeeded records it on recorderUpgradeDiagnostic and logs it
instead of skipping silently, and clears it once an upgrade proceeds. The reason
is computed only inside the upgrade check (which already does the heavy
identity/provenance work), never during rendering, so it does not traverse or
hash payloads.

Add the regression test that was missing: a legacy install whose Python.framework
keeps its symlinks is auto-upgraded by a newer app (the shape #216 enabled and a
pre-#216 inspector could not recognize), plus coverage for both blocked-reason
branches (installed recorder unrecognized; bundled recorder unverifiable).
@mikehasa
mikehasa merged commit 0a0b2cb into main Sep 14, 2026
4 checks passed
@mikehasa
mikehasa deleted the fix/upgrade-observability branch September 14, 2026 20:44
@mikehasa mikehasa mentioned this pull request Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant