diff --git a/plugins/hermes-achievements/dashboard/plugin_api.py b/plugins/hermes-achievements/dashboard/plugin_api.py index a4891c0a36..92e0d7decd 100644 --- a/plugins/hermes-achievements/dashboard/plugin_api.py +++ b/plugins/hermes-achievements/dashboard/plugin_api.py @@ -711,9 +711,11 @@ def _compute_from_scan(scan: Dict[str, Any], *, is_partial: bool = False) -> Dic """Evaluate every achievement definition against a scan result. Used by ``compute_all`` for finished scans AND by the background progress callback for in-flight snapshots; ``is_partial=True`` skips persisting ``state.json`` unlocks — an "unlock time" from - half a scan could be invalidated by a later session.""" + half a scan could be invalidated by a later session. Persisted unlocks are still read + for partials: the background scan publishes them to the cache, so an earned badge would + otherwise render as locked for the whole rescan (#112273).""" aggregate = scan.get("aggregate", {}) - state = load_state() if not is_partial else {"unlocks": {}} + state = load_state() unlocks = state.setdefault("unlocks", {}) now = int(time.time()) evaluated = [] diff --git a/plugins/hermes-achievements/tests/test_achievement_engine.py b/plugins/hermes-achievements/tests/test_achievement_engine.py index 4458059592..fa20eda0c1 100644 --- a/plugins/hermes-achievements/tests/test_achievement_engine.py +++ b/plugins/hermes-achievements/tests/test_achievement_engine.py @@ -67,15 +67,19 @@ class AchievementEngineTests(unittest.TestCase): "threshold_metric": "total_terminal_calls", "tiers": [{"name": "Copper", "threshold": 40}], } - with TemporaryDirectory() as data_dir, patch.object(plugin_api, "ACHIEVEMENTS", [definition]), patch.object(plugin_api, "_data_dir", return_value=Path(data_dir)): + with TemporaryDirectory() as data_dir, patch.object(plugin_api, "ACHIEVEMENTS", [definition]), patch.object(plugin_api, "_data_dir", return_value=Path(data_dir)), patch.object(plugin_api, "get_hermes_home", return_value=Path(data_dir)): unlocked = plugin_api._compute_from_scan({"aggregate": {"total_terminal_calls": 40}, "sessions": []}) rescanned = plugin_api._compute_from_scan({"aggregate": {"total_terminal_calls": 39}, "sessions": []}) partial = plugin_api._compute_from_scan({"aggregate": {"total_terminal_calls": 39}, "sessions": []}, is_partial=True) + persisted = plugin_api.load_state()["unlocks"] self.assertTrue(unlocked["achievements"][0]["unlocked"]) self.assertTrue(rescanned["achievements"][0]["unlocked"]) self.assertEqual(rescanned["achievements"][0]["state"], "unlocked") - self.assertFalse(partial["achievements"][0]["unlocked"]) + # In-flight snapshots are published to the cache during rescans: the floor applies there too. + self.assertTrue(partial["achievements"][0]["unlocked"]) + self.assertEqual(partial["unlocked_count"], 1) + self.assertEqual(list(persisted), ["durable_unlock"]) def test_secret_achievement_stays_hidden_without_progress(self): definition = { @@ -197,7 +201,7 @@ class CompactionScanTests(unittest.TestCase): import hermes_state from hermes_state import SessionDB - with TemporaryDirectory() as tmp, patch.object(plugin_api, "_data_dir", return_value=Path(tmp) / "data"): + with TemporaryDirectory() as tmp, patch.object(plugin_api, "_data_dir", return_value=Path(tmp) / "data"), patch.object(plugin_api, "get_hermes_home", return_value=Path(tmp)): db = SessionDB(Path(tmp) / "state.db") try: db.create_session("s1", "cli", model="m") diff --git a/tests/plugins/test_achievements_plugin.py b/tests/plugins/test_achievements_plugin.py index 0e5fac9e3e..df2a3101ba 100644 --- a/tests/plugins/test_achievements_plugin.py +++ b/tests/plugins/test_achievements_plugin.py @@ -100,7 +100,7 @@ class _FakeSessionDB: for i in range(effective) ] - def get_messages(self, session_id: str) -> List[Dict[str, Any]]: + def get_messages(self, session_id: str, include_compacted: bool = False) -> List[Dict[str, Any]]: self.messages_calls += 1 return [ {"role": "user", "content": f"ask {session_id}"},