Skip to content

Resolve update to the latest available version at install time (#6366) - #6853

Merged
austinywang merged 4 commits into
mainfrom
issue-6366-update-flow-should-resolve-to-latest
Jun 29, 2026
Merged

austinywang merged 4 commits into
mainfrom
issue-6366-update-flow-should-resolve-to-latest

Conversation

@austinywang

@austinywang austinywang commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #6366

Problem

When the user installs an update, cmux installs the appcast item that was captured when the prompt was first surfaced, not the latest available at install time. If a newer release ships in the window between when the prompt is generated and when the user installs (e.g. 0.64.15 → 0.64.16), the user installs 0.64.15 and then relaunches straight into another "update available" prompt for 0.64.16 — having to run the update twice.

The stable feed is GitHub's releases/latest/download/appcast.xml, which already resolves to the latest release on each fetch. The bug is purely that the install path reused a stale captured item instead of re-fetching.

Fix

Make every install entrypoint re-resolve to the latest at install time, through a single shared path:

  • AttemptUpdateCoordinator (new, pure/unit-tested): on an install request it ignores whatever update was captured in the prompt and starts a fresh Sparkle check, then installs whatever that check resolves. A mid-install request (downloading/extracting/installing) is a no-op so an in-progress install isn't interrupted.
  • UpdateController.attemptUpdate() is now the one user-facing "install" action and drives the coordinator. The fresh check dismisses the stale prompt (.dismiss), re-fetches the latest-release feed, and the coordinator confirms the newly resolved update.
  • Routed every install affordance through it (shared-behavior policy): the update-available popover "Install and Relaunch", the app menu "Install Update and Relaunch", and the "Apply Update (If Available)" command. The background-detected-update popover already used this path.
  • Removed the now-unused force-install path (installUpdate() / isInstalling) so there is exactly one install path and no footgun that reintroduces the stale-version behavior.

No user-facing strings changed (button/menu text reuses existing localized keys), so no localization changes were required.

Tests

AttemptUpdateCoordinatorTests (CmuxUpdater package, run by CI's swift-package-tests job) pins the behavior. Two-commit red/green:

  • Commit 1 refactors to the coordinator while preserving the bug (the policy still confirms the captured update) and adds the tests → 3 coordinator tests fail.
  • Commit 2 flips the policy to re-resolve → all tests pass.

Key case: requesting an install while Update Available: 0.64.15 is showing must return .startFreshCheck (re-resolve), not .confirmInstall (install the captured 0.64.15). The full-sequence test verifies the freshly resolved 0.64.16 is the version confirmed for install.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Always re-resolves the appcast at install time so we install the latest available release and avoid stale installs and follow-up prompts. Also stops auto-installing after the user cancels the fresh check. Fixes #6366.

  • Bug Fixes

    • Added AttemptUpdateCoordinator to run a fresh Sparkle check on install and confirm the newly resolved update; mid-install requests are no-ops.
    • Made UpdateController.attemptUpdate() the single install path; routed the popover button, app menu item, and “Apply Update (If Available)” command through it.
    • Stops coordinating after a cancelled fresh check (treats .idle as terminal) so later checks aren’t auto-installed; added tests covering the cancel sequences and re-resolve flow.
    • Removed the force-install path (installUpdate(), isInstalling) to eliminate the stale-version install footgun.
  • Migration

    • Update InstallUpdateMenuItem to pass actions: any UpdateActionsHost, and invoke attemptUpdate() for installs.
    • Replace any calls to installUpdate() with attemptUpdate().

Written for commit c9ba438. Summary will update on new commits.

Review in cubic

Update installs currently use the appcast item captured when the prompt was
first surfaced, so a newer release shipped in the meantime is not installed —
the user relaunches straight into another "update available" prompt.

This commit refactors toward the fix without changing behavior: it extracts
the install sequencing into AttemptUpdateCoordinator, routes every install
entrypoint (the update-available popover, the app menu item, and the
"Apply Update" command) through the controller's single re-resolving
attemptUpdate() path, and removes the now-unused force-install path so there
is one install path.

The coordinator policy still confirms the already-captured update, preserving
the bug, while AttemptUpdateCoordinatorTests pins the desired behavior
(re-resolve to the latest before installing). Three coordinator tests fail
here on purpose; the next commit flips the policy to make them pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Canceled Canceled Jun 26, 2026 2:31pm
cmux-staging Building Building Preview, Comment Jun 26, 2026 2:31pm

@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

@austinywang, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 7 minutes and 19 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e2c649fd-e9f5-497f-9fde-c72d79da6759

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6c701 and c9ba438.

📒 Files selected for processing (7)
  • Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/AttemptUpdateCoordinator.swift
  • Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateController.swift
  • Packages/macOS/CmuxUpdater/Tests/CmuxUpdaterTests/AttemptUpdateCoordinatorTests.swift
  • Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdatePill.swift
  • Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdatePopoverView.swift
  • Sources/AppDelegate.swift
  • Sources/cmuxApp.swift
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-6366-update-flow-should-resolve-to-latest

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the stale-version install bug (#6366): when a user clicked "Install and Relaunch" Sparkle was installing the appcast item captured at prompt time rather than the latest available, leading to an immediate second update prompt on relaunch. The fix is a new pure state machine (AttemptUpdateCoordinator) that intercepts every install request, discards the captured item, and runs a fresh Sparkle check before confirming the install.

  • AttemptUpdateCoordinator (new, unit-tested) sequences the re-resolve flow: on any install request it transitions through awaitingCheckRestart → awaitingResult and only calls confirmInstall on the updateAvailable state emitted by the fresh check, not the stale one. Mid-install requests (.downloading/.extracting/.installing) are no-ops.
  • All four install affordances (update-available popover, background-detected popover, app-menu item, "Apply Update If Available" command) now route through a single UpdateController.attemptUpdate() entry point; the old installUpdate()/isInstalling force-install path is removed.
  • Tests (AttemptUpdateCoordinatorTests) cover the full re-resolve sequence, stale-prompt suppression, mid-install no-op, user-cancel termination, error termination, and the idle-termination regression that prevents a later unrelated check from being auto-confirmed.

Confidence Score: 5/5

The change is safe to merge: it removes the stale-item force-install path entirely and routes all four install affordances through a single coordinator that always re-fetches before confirming.

All install affordances now go through AttemptUpdateCoordinator, which has deterministic phase transitions and comprehensive tests. The previously flagged cancel-then-linger bug (.idle in awaitingResult leaving the coordinator monitoring indefinitely) is correctly handled — .idle, .notFound, and .error all set phase to .inactive. The 100 ms Sparkle teardown delay and cancelActiveStateForNewCheck() are unchanged pre-existing mechanisms. No blocking, no globals, no test seams in production source.

No files require special attention.

Important Files Changed

Filename Overview
Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/AttemptUpdateCoordinator.swift New pure @mainactor state machine that sequences the re-resolve-then-install flow. Phase transitions (inactive → awaitingCheckRestart → awaitingResult → inactive) are correct: stale .updateAvailable is suppressed until the fresh check has restarted, user-cancel (.idle in awaitingResult) is treated as terminal, and mid-install requests are no-ops.
Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateController.swift Replaces the three-flag approach with a single AttemptUpdateCoordinator. Removes the public installUpdate()/isInstalling force-install path; attemptUpdate() is now the one install entry point. State-reaction stream wiring is clean and all @mainactor.
Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdatePopoverView.swift UpdateAvailableView's Install and Relaunch button now calls actions.attemptUpdate() (re-resolve) instead of update.reply(.install) (stale install). The actions dependency is threaded through correctly via the existing UpdateActionsHost protocol.
Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdatePill.swift InstallUpdateMenuItem gains an actions: any UpdateActionsHost parameter and calls attemptUpdate() instead of model.state.confirm(). Public initializer change is correctly paired in cmuxApp.swift.
Sources/AppDelegate.swift applyUpdateIfAvailable now calls updateController.attemptUpdate() (re-resolve) instead of installUpdate() (stale force-install). The UpdateActionsHost conformance routes attemptUpdate() through the existing @objc path.
Sources/cmuxApp.swift Single-line change passes appDelegate as the UpdateActionsHost to InstallUpdateMenuItem, matching the updated initializer signature.
Packages/macOS/CmuxUpdater/Tests/CmuxUpdaterTests/AttemptUpdateCoordinatorTests.swift Comprehensive tests covering the regression, full re-resolve sequence, stale-prompt suppression, user-cancel termination in both phases, mid-install no-op, idle coordinator passthrough, error terminal, and explicit cancel().

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant UI as UpdateAvailableView / Menu
    participant AD as AppDelegate (UpdateActionsHost)
    participant UC as UpdateController
    participant Coord as AttemptUpdateCoordinator
    participant Sparkle as SPUUpdater

    User->>UI: Click Install and Relaunch
    UI->>AD: attemptUpdate()
    AD->>UC: attemptUpdate()
    UC->>Coord: requestInstallLatest(.updateAvailable 0.64.15)
    Coord-->>UC: ".startFreshCheck [phase=awaitingCheckRestart]"
    UC->>UC: checkForUpdates()
    UC->>UC: cancelActiveStateForNewCheck()
    Note over UC: 100 ms recheck delay
    UC->>Sparkle: checkForUpdates()
    Sparkle-->>UC: state → .checking
    UC->>Coord: handleStateChange(.checking)
    Coord-->>UC: ".none [phase=awaitingResult]"
    Sparkle-->>UC: state → .updateAvailable 0.64.16
    UC->>Coord: handleStateChange(.updateAvailable 0.64.16)
    Coord-->>UC: ".confirmInstall [phase=inactive]"
    UC->>Sparkle: model.state.confirm()
    Note over Sparkle: Downloads and installs 0.64.16
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant UI as UpdateAvailableView / Menu
    participant AD as AppDelegate (UpdateActionsHost)
    participant UC as UpdateController
    participant Coord as AttemptUpdateCoordinator
    participant Sparkle as SPUUpdater

    User->>UI: Click Install and Relaunch
    UI->>AD: attemptUpdate()
    AD->>UC: attemptUpdate()
    UC->>Coord: requestInstallLatest(.updateAvailable 0.64.15)
    Coord-->>UC: ".startFreshCheck [phase=awaitingCheckRestart]"
    UC->>UC: checkForUpdates()
    UC->>UC: cancelActiveStateForNewCheck()
    Note over UC: 100 ms recheck delay
    UC->>Sparkle: checkForUpdates()
    Sparkle-->>UC: state → .checking
    UC->>Coord: handleStateChange(.checking)
    Coord-->>UC: ".none [phase=awaitingResult]"
    Sparkle-->>UC: state → .updateAvailable 0.64.16
    UC->>Coord: handleStateChange(.updateAvailable 0.64.16)
    Coord-->>UC: ".confirmInstall [phase=inactive]"
    UC->>Sparkle: model.state.confirm()
    Note over Sparkle: Downloads and installs 0.64.16
Loading

Reviews (3): Last reviewed commit: "Stop coordinating after a cancelled fres..." | Re-trigger Greptile

Comment on lines +89 to +99
case .awaitingResult:
switch state {
case .updateAvailable:
phase = .inactive
return .confirmInstall
case .notFound, .error:
phase = .inactive
return .none
case .idle, .checking, .downloading, .extracting, .installing, .permissionRequest:
return .none
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 .idle can reach awaitingResult when the user cancels the fresh check (via the CheckingView Cancel button). Left as .none, the coordinator stays monitoring indefinitely and will auto-install the result of any subsequent user-triggered check — no confirmation, no user intent. Treating .idle as a terminal condition here mirrors how .notFound and .error already exit.

Suggested change
case .awaitingResult:
switch state {
case .updateAvailable:
phase = .inactive
return .confirmInstall
case .notFound, .error:
phase = .inactive
return .none
case .idle, .checking, .downloading, .extracting, .installing, .permissionRequest:
return .none
}
case .awaitingResult:
switch state {
case .updateAvailable:
phase = .inactive
return .confirmInstall
case .notFound, .error, .idle:
// .idle here means the fresh check was cancelled (user hit Cancel in the
// CheckingView) or ended without a result. Stop coordinating so a subsequent
// unrelated check is not auto-confirmed.
phase = .inactive
return .none
case .checking, .downloading, .extracting, .installing, .permissionRequest:
return .none
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in c9ba438. awaitingResult now treats .idle (cancelled fresh check) as terminal alongside .notFound/.error, so the coordinator stops monitoring and a later unrelated check is no longer silently auto-confirmed. Added two regression tests (cancellingFreshCheckStopsMonitoringSoLaterCheckIsNotAutoInstalled, cancellingAfterActivePromptDismissStopsMonitoring).

— Claude Code

@blacksmith-sh

This comment has been minimized.

cmux and others added 2 commits June 26, 2026 02:38
Flip AttemptUpdateCoordinator's policy so an install request always starts a
fresh appcast check and installs whatever that resolves, instead of installing
the update captured when the prompt was first surfaced. If a newer release
shipped between when the prompt was generated and when the user installs, they
now get it directly rather than relaunching into a second update prompt.

The fresh check dismisses the stale prompt, re-fetches the (latest-release)
feed, and the coordinator confirms the newly resolved update. The previously
failing AttemptUpdateCoordinatorTests now pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@austinywang
austinywang force-pushed the issue-6366-update-flow-should-resolve-to-latest branch from 88ce88f to f7822bc Compare June 26, 2026 09:43
Greptile flagged that AttemptUpdateCoordinator's awaitingResult phase did not
treat `.idle` as terminal. If the user cancels the in-flight fresh check (Cancel
in the checking popover returns the model to idle), the coordinator stayed
monitoring and would silently auto-confirm the result of the next unrelated
user-triggered check. Treat `.idle` like `.notFound`/`.error` in awaitingResult
and stop coordinating. Adds two regression tests covering the detected-path and
active-prompt cancel sequences.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@austinywang
austinywang merged commit 07a6c34 into main Jun 29, 2026
67 of 73 checks passed

This branch was successfully deployed

1 active deployment
Preview – cmux — c9ba4388 Deployed Jun 26, 2026 by vercel[bot]
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.

Update flow should resolve to latest available version, not the version that triggered the prompt

1 participant