diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index a6b06fe60f..df6c8fd49f 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -587,14 +587,14 @@ def finalize_turn( # surfaces status="error" (desktop can toast) instead of a quiet complete frame, plus # the machine-readable cause 'session_persistence_failed:'. if failed and str(_turn_exit_reason) == "session_persistence_failed": - if not final_response: - from hermes_constants import profile_cli_selector + from hermes_constants import profile_cli_selector - final_response = ( - "session storage could not be written — check the state database " - f"health (`hermes {profile_cli_selector()}doctor`), then send your message again" - ) - result["error"] = final_response + # A separate local: final_response feeds the memory sync and the background-review + # gate below, which must still see the turn as having produced no text. + result["error"] = final_response or ( + "session storage could not be written — check the state database " + f"health (`hermes {profile_cli_selector()}doctor`), then send your message again" + ) _cause = getattr(agent, "_last_persistence_error_cause", None) result["failure_reason"] = "session_persistence_failed:" + (_cause or "unknown") elif _exit_failure is not None: diff --git a/hermes_state_user_copy.py b/hermes_state_user_copy.py index df4e938973..94a25b2d6f 100644 --- a/hermes_state_user_copy.py +++ b/hermes_state_user_copy.py @@ -20,14 +20,15 @@ class StorageFailure: action: str # what to do, one sentence naming the exact command -_DOCTOR = "Run `hermes doctor --fix` to diagnose and repair." +_DOCTOR = "Run `hermes {profile_arg}doctor --fix` to diagnose and repair." # cause -> (code, gloss, action). "disk" is split by is_disk_full_error at lookup time. _STORAGE_FAILURES: dict[str, tuple[str, str, str]] = { "locked": ( "storage_locked", "the session database is locked by another Hermes process", - "Wait a moment and try again; if it persists, stop the other Hermes process (`hermes gateway stop`).", + "Wait a moment and try again; if it persists, stop the other Hermes process " + "(`hermes {profile_arg}gateway stop`).", ), "disk_full": ( "disk_full", @@ -42,22 +43,22 @@ _STORAGE_FAILURES: dict[str, tuple[str, str, str]] = { "corrupt": ( "storage_corrupt", "the session database file is damaged", - f"{_DOCTOR} Recovery: `hermes sessions recover --source --inspect-only`.", + f"{_DOCTOR} Recovery: `hermes {{profile_arg}}sessions recover --source --inspect-only`.", ), "fts_index": ( "storage_index_corrupt", "the session search index is damaged (the messages themselves are intact)", - "Run `hermes doctor --fix` (or `hermes sessions repair`) to rebuild it.", + "Run `hermes {profile_arg}doctor --fix` (or `hermes {profile_arg}sessions repair`) to rebuild it.", ), "replaced": ( "storage_replaced", "the session database file was replaced while Hermes was running", - "Stop Hermes (`hermes gateway stop`), run `hermes doctor`, then start it again.", + "Stop Hermes (`hermes {profile_arg}gateway stop`), run `hermes {profile_arg}doctor`, then start it again.", ), "deleted_wal": ( "storage_replaced", "the session database file was changed or replaced while Hermes was running", - "Stop Hermes (`hermes gateway stop`), run `hermes doctor`, then start it again.", + "Stop Hermes (`hermes {profile_arg}gateway stop`), run `hermes {profile_arg}doctor`, then start it again.", ), "compression": ( "storage_busy", @@ -87,7 +88,14 @@ def describe_storage_failure(exc_or_str) -> StorageFailure: cause = classify_persistence_error(exc_or_str) key = "disk_full" if cause == "disk" and is_disk_full_error(exc_or_str) else cause code, gloss, action = _STORAGE_FAILURES.get(key, _STORAGE_FAILURES["unknown"]) - return StorageFailure(cause=cause, code=code, gloss=gloss, action=action) + # Pin the copy-pasteable command to the profile whose store failed: a multi-profile backend + # serves sessions whose state.db is not the process default, and a bare `hermes` follows the + # sticky active_profile (#105887). + from hermes_constants import profile_cli_selector + + return StorageFailure( + cause=cause, code=code, gloss=gloss, action=action.replace("{profile_arg}", profile_cli_selector()) + ) def storage_failure_details(exc_or_str, limit: int = 200) -> str: diff --git a/tests/agent/test_skip_background_review.py b/tests/agent/test_skip_background_review.py index 60f9572e67..f57493ee03 100644 --- a/tests/agent/test_skip_background_review.py +++ b/tests/agent/test_skip_background_review.py @@ -126,3 +126,36 @@ def test_cron_construction_sets_skip_background_review() -> None: assert "skip_background_review=True" in text, ( "cron/scheduler.py must construct AIAgent with skip_background_review=True." ) + + +def test_persistence_failure_error_fallback_is_pinned_and_leaves_final_response_empty(monkeypatch, tmp_path) -> None: + """With no model text, result["error"] carries a profile-pinned `hermes doctor`, while the + memory sync and the background-review gate still see the turn as having produced nothing.""" + from hermes_constants import profile_cli_selector + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes" / "profiles" / "research")) + selector = profile_cli_selector() + assert selector.strip() + agent = _make_agent() + _stub_agent_for_finalize(agent) + # Force the fallback: the explainer normally supplies the text, so an empty explainer is + # the only way the hardcoded copy reaches the user. + monkeypatch.setattr(AIAgent, "_format_turn_completion_explanation", staticmethod(lambda *a, **k: "")) + result = finalize_turn( + agent, + final_response="", + api_call_count=1, + interrupted=False, + failed=True, + messages=[{"role": "user", "content": "hi"}], + conversation_history=[], + effective_task_id="test", + turn_id="test-turn", + user_message="hi", + original_user_message="hi", + _should_review_memory=True, + _turn_exit_reason="session_persistence_failed", + ) + assert f"`hermes {selector}doctor`" in result["error"] + assert agent._sync_external_memory_for_turn.call_args.kwargs["final_response"] == "" + agent._spawn_background_review.assert_not_called() diff --git a/tests/agent/test_turn_completion_explainer.py b/tests/agent/test_turn_completion_explainer.py index 7031a14334..b505fe4861 100644 --- a/tests/agent/test_turn_completion_explainer.py +++ b/tests/agent/test_turn_completion_explainer.py @@ -18,8 +18,8 @@ pass identically in CI and locally. """ import hermes_state_errors -import pytest import os +import pytest import uuid from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -230,7 +230,6 @@ def test_persistence_commands_are_pinned_to_the_failing_profile(monkeypatch, tmp from hermes_constants import profile_cli_selector monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes" / "profiles" / "research")) - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) selector = profile_cli_selector() assert selector.strip(), "fixture must resolve to a named profile" out = AIAgent._format_turn_completion_explanation("session_persistence_failed", cause) diff --git a/tests/hermes_state/test_storage_failure_copy.py b/tests/hermes_state/test_storage_failure_copy.py index 0a6fb9de24..d7a0bceb7e 100644 --- a/tests/hermes_state/test_storage_failure_copy.py +++ b/tests/hermes_state/test_storage_failure_copy.py @@ -33,3 +33,17 @@ def test_each_cause_has_a_stable_code_and_an_action(exc, code, command): def test_details_line_is_flattened_and_bounded(): details = storage_failure_details("line one\n line two " + "x" * 400, limit=60) assert "\n" not in details and len(details) == 60 and details.endswith("...") + + +def test_action_command_is_pinned_to_the_failing_profile(monkeypatch, tmp_path): + """The action names the profile whose store failed (multi-profile backends serve sessions + whose state.db is not the process default; a bare ``hermes`` follows active_profile).""" + from hermes_constants import profile_cli_selector + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes" / "profiles" / "research")) + selector = profile_cli_selector() + assert selector.strip() + for exc in (sqlite3.OperationalError("database is locked"), sqlite3.DatabaseError("malformed"), None): + action = describe_storage_failure(exc).action + assert "{profile_arg}" not in action + assert f"`hermes {selector}" in action and "`hermes doctor" not in action and "`hermes gateway" not in action