diff --git a/api/updates.py b/api/updates.py index 1d9bc2a1..048925c5 100644 --- a/api/updates.py +++ b/api/updates.py @@ -327,10 +327,12 @@ def apply_clear_lock(target: str) -> dict: if not inv['well_known_lock_present']: # Lock is gone. Run the normal non-destructive update flow and # annotate the response with what we found for the user's - # records. + # records. Pass the configured channel through — otherwise an + # experimental-channel WebUI lock-recovery retry silently falls back + # to stable (Codex gate: _apply_update_inner defaults to stable). with _cache_lock: _update_cache['checked_at'] = 0 - retry_result = _apply_update_inner(target) + retry_result = _apply_update_inner(target, _read_update_channel()) retry_result = dict(retry_result) retry_result['lock_recovery'] = { 'action': 'no-lock-found', diff --git a/tests/test_update_channels.py b/tests/test_update_channels.py index 53f6e7fc..2ec98972 100644 --- a/tests/test_update_channels.py +++ b/tests/test_update_channels.py @@ -249,3 +249,40 @@ def test_update_cache_scoped_by_channel(channel_repo, monkeypatch): # Both channels triggered a real check (no stale cross-channel cache hit). assert ('webui', 'stable') in calls assert ('webui', 'experimental') in calls + + +# ── Lock-recovery preserves the channel (Codex round-2 SILENT) ─────────────── + +def test_clear_lock_retry_preserves_experimental_channel(tmp_path, monkeypatch): + """apply_clear_lock re-runs the normal update once the lock is gone. It must + pass the configured channel through — otherwise an experimental WebUI + lock-recovery retry silently falls back to stable (_apply_update_inner + defaults to stable).""" + (tmp_path / '.git').mkdir() + monkeypatch.setattr(updates, 'REPO_ROOT', tmp_path) + monkeypatch.setattr( + updates, '_restart_blocker_snapshot', + lambda: {'restart_blocked': False, 'active_streams': 0, 'active_runs': 0}, + ) + # No lock present → clear-lock takes the "re-run normal update" branch. + monkeypatch.setattr( + updates, '_inventory_locks', + lambda path: {'well_known_lock_present': False, + 'well_known_lock_path': str(tmp_path / '.git/index.lock'), + 'other_locks': []}, + ) + # User's configured channel is experimental. + monkeypatch.setattr(updates, '_read_update_channel', lambda: 'experimental') + seen = {} + + def fake_inner(target, channel='stable'): + seen['channel'] = channel + return {'ok': True, 'target': target, 'channel': channel} + + monkeypatch.setattr(updates, '_apply_update_inner', fake_inner) + result = updates.apply_clear_lock('webui') + assert seen.get('channel') == 'experimental', ( + 'clear-lock retry must preserve the experimental channel, not default to stable' + ) + assert result['ok'] is True + assert result['lock_recovery']['action'] == 'no-lock-found'