From 6fe9135cb55e625c70fe708422087967b0e75ab4 Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr <139972147+ZdenekSrotyr@users.noreply.github.com> Date: Thu, 7 May 2026 22:08:21 +0200 Subject: [PATCH] =?UTF-8?q?release:=200.47.3=20=E2=80=94=20self-upgrade=20?= =?UTF-8?q?ignores=2024h=20cache,=20always=20re-probes=20/cli/latest=20(#2?= =?UTF-8?q?27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `agnes self-upgrade` without `--force` previously short-circuited on the local 24h `update_check.json` cache. After a server-side version bump within that window, the explicit command exited silently as a no-op — empirically observed today when prod 0.47.1 → 0.47.2 didn't propagate. Fix: always invalidate the cache in `_resolve_info`. The cache still gates the implicit warning loop in the root callback (correctly — that runs on every `agnes ` and can't hammer `/cli/latest`). ## Test plan - [x] New `test_self_upgrade_bypasses_24h_cache_without_force` — stale cache claims current; mocked server reports newer; assert UpdateInfo carries the newer version, not the cached one. - [x] Existing self-upgrade tests pass (including `--force` semantics — force is now downstream-only, behavior preserved). --- CHANGELOG.md | 6 +++ cli/commands/self_upgrade.py | 9 ++++- pyproject.toml | 2 +- tests/test_self_upgrade.py | 71 +++++++++++++++++++++++++++++++++++- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be2086..fdbf8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C ## [Unreleased] +## [0.47.3] — 2026-05-07 + +### Fixed + +- `agnes self-upgrade` (without `--force`) previously read the local 24h `update_check.json` cache to decide whether an upgrade was needed — meaning that for up to 24 hours after a server-side version bump, the explicit `agnes self-upgrade` command exited silently as a no-op even though a newer wheel was available. Cache is now always invalidated for the explicit command (the cache still gates the implicit warning loop in the root callback to avoid hammering `/cli/latest` on every `agnes ` invocation). Surfaced when a server bump 0.47.1 → 0.47.2 didn't trigger client-side upgrade. + ## [0.47.2] — 2026-05-07 ### Fixed diff --git a/cli/commands/self_upgrade.py b/cli/commands/self_upgrade.py index 4c06a2a..51b1144 100644 --- a/cli/commands/self_upgrade.py +++ b/cli/commands/self_upgrade.py @@ -175,8 +175,13 @@ def _resolve_info(force: bool) -> Union[UpdateInfo, _Unreachable, None]: _UNREACHABLE — --force specified, server probe failed None — nothing to do (current, or offline without --force) """ - if force: - _invalidate_update_cache() + # Always invalidate the cache — an explicit `agnes self-upgrade` is + # the user asking "is there a newer version RIGHT NOW", not "use the + # 24h cached answer". The cache exists to keep the implicit warning + # loop in the root callback (`agnes `) from re-probing + # `/cli/latest` on every invocation; it has no place gating the + # explicit upgrade command. + _invalidate_update_cache() info = check(get_server_url(), bypass_disabled=True) if info is None: return _UNREACHABLE if force else None diff --git a/pyproject.toml b/pyproject.toml index 7302a4d..6cd859e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agnes-the-ai-analyst" -version = "0.47.2" +version = "0.47.3" description = "Agnes — AI Data Analyst platform for AI analytical systems" requires-python = ">=3.11,<3.14" license = "MIT" diff --git a/tests/test_self_upgrade.py b/tests/test_self_upgrade.py index 929aa5c..8984a95 100644 --- a/tests/test_self_upgrade.py +++ b/tests/test_self_upgrade.py @@ -223,7 +223,13 @@ def test_smoke_fail_no_prior_url_prints_install_sh_recovery(): def test_smoke_pass_records_last_known_good_then_invalidates_cache(): - """Convention: record before invalidate.""" + """Convention in `_do_install_with_smoke_and_rollback`: record, then + invalidate. The OTHER invalidate call here (the FIRST one in call_order) + is the pre-probe invalidate inside `_resolve_info` that ensures + `agnes self-upgrade` always re-probes /cli/latest instead of trusting + the 24h cache — see `test_self_upgrade_bypasses_24h_cache_without_force`. + Both invalidates are intentional; we pin only the record→invalidate pair + of the post-install bookkeeping by looking at the LAST invalidate.""" call_order = [] with patch("cli.commands.self_upgrade.check", return_value=_outdated_info()), \ patch("cli.commands.self_upgrade.shutil.which", return_value="/usr/local/bin/uv"), \ @@ -238,7 +244,10 @@ def test_smoke_pass_records_last_known_good_then_invalidates_cache(): result = runner.invoke(app, ["self-upgrade"]) assert result.exit_code == 0 record_idx = next(i for i, c in enumerate(call_order) if c[0] == "record") - invalidate_idx = next(i for i, c in enumerate(call_order) if c[0] == "invalidate") + # LAST invalidate — the post-install bookkeeping one. + invalidate_idx = max( + i for i, c in enumerate(call_order) if c[0] == "invalidate" + ) assert record_idx < invalidate_idx, call_order assert call_order[record_idx] == ("record", _OUTDATED_URL) @@ -288,6 +297,64 @@ def test_smoke_test_detects_version_mismatch(install_method, patch_target): assert mock_run.call_args.args[0][0] == fake_bin +def test_self_upgrade_bypasses_24h_cache_without_force(tmp_path, monkeypatch): + """Plain `agnes self-upgrade` (no --force) MUST re-probe /cli/latest + even when the local update_check.json cache claims we're current. + + Pre-fix the cache short-circuited and the command was a silent no-op + after a server bump within the 24h window. Empirically observed: + prod 0.47.1 → 0.47.2 didn't propagate to clients with a fresh cache. + """ + import json + import time + from cli.commands import self_upgrade as su + from cli import update_check as uc + + # Redirect the on-disk cache to tmp_path via _config_dir's env override. + monkeypatch.setenv("AGNES_CONFIG_DIR", str(tmp_path)) + + # Arrange: stale cache claims installed=latest=0.47.1, written 1 minute + # ago — well within the 24h positive-cache TTL. + cache_path = tmp_path / "update_check.json" + cache_path.write_text(json.dumps({ + "installed": "0.47.1", + "server_url": "http://server.test", + "latest": "0.47.1", + "download_url": "http://server.test/cli/wheel/agnes-0.47.1-py3-none-any.whl", + "checked_at": time.time() - 60, + }), encoding="utf-8") + + # Mock the network probe to return 0.47.2 — the bumped server. + monkeypatch.setattr(uc, "_fetch_latest", lambda url: { + "version": "0.47.2", + "download_url_path": "/cli/wheel/agnes-0.47.2-py3-none-any.whl", + }) + # Pin the installed version to 0.47.1 (matches the stale cache). + monkeypatch.setattr(uc, "_installed_version", lambda: "0.47.1") + # Pin the server URL so the cache key matches. + monkeypatch.setattr(su, "get_server_url", lambda: "http://server.test") + + # Act: explicit self-upgrade WITHOUT --force. + info = su._resolve_info(force=False) + + # Assert: returns UpdateInfo carrying the FRESH 0.47.2, not cached 0.47.1. + assert info is not None and not isinstance(info, su._Unreachable) + assert info.latest == "0.47.2", ( + f"expected fresh probe to return 0.47.2; got {info.latest} " + "(cache short-circuit regressed)" + ) + assert info.installed == "0.47.1" + assert info.download_url == ( + "http://server.test/cli/wheel/agnes-0.47.2-py3-none-any.whl" + ) + + # Assert: cache was rewritten with the fresh latest. Proves the probe + # actually ran rather than the stale cache satisfying the call via + # some other path that happened to leave 0.47.1 untouched on disk. + refreshed = json.loads(cache_path.read_text(encoding="utf-8")) + assert refreshed["latest"] == "0.47.2" + + def test_smoke_test_passes_with_pep440_local_version(): """Use Version() comparison, not substring (so "0.40.0" doesn't match "0.40.10").""" from pathlib import Path