release: 0.47.3 — self-upgrade ignores 24h cache, always re-probes /cli/latest (#227)

## 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 <anything>` 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).
This commit is contained in:
ZdenekSrotyr 2026-05-07 22:08:21 +02:00 committed by GitHub
parent 917f9aaef0
commit 6fe9135cb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 83 additions and 5 deletions

View file

@ -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 <anything>` 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

View file

@ -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 <anything>`) 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

View file

@ -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"

View file

@ -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 recordinvalidate 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