diff --git a/agent/turn_explainers.py b/agent/turn_explainers.py index feda8388cf..8d78279a31 100644 --- a/agent/turn_explainers.py +++ b/agent/turn_explainers.py @@ -139,10 +139,10 @@ _PERSISTENCE_CAUSE_EXPLANATIONS: Dict[str, str] = { "reported structural corruption (the transcript would " "have been lost on restart). Freeing disk space will " "not help. Recovery options:\n" - "1. Run `hermes doctor --fix`\n" + "1. Run `hermes {profile_arg}doctor --fix`\n" "2. Stop the gateway, then recover with:\n" - " hermes sessions recover --source {db_path} --inspect-only\n" - " (if it reports recoverable) hermes sessions recover " + " hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n" + " (if it reports recoverable) hermes {profile_arg}sessions recover " "--source {db_path} --output recovered-state.db\n" " — recovery snapshots the damaged file first; do NOT " "run `sqlite3 ... \".recover\"` against the live " @@ -157,7 +157,7 @@ _PERSISTENCE_CAUSE_EXPLANATIONS: Dict[str, str] = { "the turn was stopped because the session search index (FTS5) " "is corrupt and could not be detached, so this message was not " "saved. The message store itself is not damaged: do not run " - "recovery tools or restore a backup. Run `hermes doctor --fix` " + "recovery tools or restore a backup. Run `hermes {profile_arg}doctor --fix` " "(or restart Hermes, which repairs the index on open), then " "send your message again." ), @@ -328,14 +328,14 @@ class TurnExplainersMixin: body = _PERSISTENCE_CAUSE_EXPLANATIONS.get( persistence_cause or "unknown", _PERSISTENCE_DEFAULT_EXPLANATION ) - if persistence_cause == "corrupt": - # Copy-pasteable, so name the store that actually failed: the agent's own - # SessionDB. A multi-profile backend (Desktop serve) hosts sessions whose - # state.db is NOT the process default, so the default would send the operator - # to inspect/repair the wrong profile's database (#105887). - from hermes_constants import get_default_hermes_root + if persistence_cause in ("corrupt", "fts_index"): + # Copy-pasteable, so name the store that actually failed and pin the profile: + # a multi-profile backend (Desktop serve) hosts sessions whose state.db is NOT + # the process default, and a bare `hermes` follows active_profile (#105887). + from hermes_constants import get_default_hermes_root, profile_cli_selector from hermes_state import _default_db_path + body = body.replace("{profile_arg}", profile_cli_selector()) body = body.replace("{db_path}", str(db_path or _default_db_path())) body = body.replace( "{backups_dir}", str(get_default_hermes_root() / "backups") diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index 65e89351e6..2af9cb78a5 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -833,27 +833,29 @@ class GatewayNotificationsMixin: if not error: logger.info("state.db recovered before the home-channel warning went out; not broadcasting") return - from hermes_constants import get_default_hermes_root + from hermes_constants import get_default_hermes_root, profile_cli_selector from hermes_state import _default_db_path, classify_persistence_error, format_session_db_unavailable cause = classify_persistence_error(error) + # Copy-pasteable, so name the real store and pin the profile: a bare `hermes` follows + # active_profile, which may be a different database (#105887). + profile_arg = profile_cli_selector() if cause == "corrupt": - # Copy-pasteable, so name the real store (profiles / HERMES_HOME do not live under ~/.hermes). db_path = _default_db_path() backups_dir = get_default_hermes_root() / "backups" message = ( "⚠️ Session database corruption detected. Messages may not be " "persisted. Recovery options:\n" - "1. Run `hermes doctor --fix`\n" + f"1. Run `hermes {profile_arg}doctor --fix`\n" "2. Stop the gateway, then recover with:\n" - f" hermes sessions recover --source {db_path} " + f" hermes {profile_arg}sessions recover --source {db_path} " "--inspect-only\n" - " (if it reports recoverable) hermes sessions recover " + f" (if it reports recoverable) hermes {profile_arg}sessions recover " f"--source {db_path} --output recovered-state.db\n" " — recovery snapshots the damaged file first; do NOT run " "`sqlite3 ... \".recover\"` against the live state.db, a " "vulnerable sqlite3 CLI can corrupt it further\n" f"3. Restore from a backup in {backups_dir}/\n" - "Run `hermes doctor` for sanitized diagnostics." + f"Run `hermes {profile_arg}doctor` for sanitized diagnostics." ) elif cause == "fts_index": # Index-scoped corruption: the message tables are not damaged, so the recover / @@ -861,7 +863,7 @@ class GatewayNotificationsMixin: message = ( "⚠️ Session database reported a corruption error confined to the search index " "(FTS5); the message tables are not damaged. Messages may not be persisted until " - "it is repaired: run `hermes doctor --fix`, then restart the gateway. Do not run " + f"it is repaired: run `hermes {profile_arg}doctor --fix`, then restart the gateway. Do not run " "recovery tools or restore a backup unless `hermes doctor` confirms damage." ) else: diff --git a/hermes_state_repair.py b/hermes_state_repair.py index 7062534366..e7918d0d40 100644 --- a/hermes_state_repair.py +++ b/hermes_state_repair.py @@ -391,11 +391,14 @@ def _persistent_repair_attempts_exhausted(db_path: Path) -> bool: def _persistent_repair_exhausted_error(db_path: Path) -> str: - """The stable operator-facing diagnostic for an exhausted repair budget.""" + """The stable operator-facing diagnostic for an exhausted repair budget. The ``hermes`` commands + carry the profile selector: a bare ``hermes`` follows ``active_profile`` (#105887).""" + from hermes_constants import profile_cli_selector + profile_arg = profile_cli_selector() return (f"automatic repair has already failed {_MAX_PERSISTENT_REPAIR_ATTEMPTS} times on this exact file — the " f"corruption is beyond the schema/FTS repair strategies (likely b-tree page damage). Manual recovery " - f"required: restore a backup, or salvage with `hermes sessions recover --source {db_path} " - f"--inspect-only`, then (if it reports recoverable) `hermes sessions recover --source {db_path} " + f"required: restore a backup, or salvage with `hermes {profile_arg}sessions recover --source {db_path} " + f"--inspect-only`, then (if it reports recoverable) `hermes {profile_arg}sessions recover --source {db_path} " f"--output recovered-state.db` (recovery snapshots the damaged file first, then runs the page-level " f"`.recover` lane on the copy; do NOT point a raw `sqlite3` shell at the live database). " f"Delete {_repair_ledger_path(db_path).name} to force another automatic attempt.") diff --git a/tests/run_agent/test_corruption_recovery_guidance.py b/tests/run_agent/test_corruption_recovery_guidance.py index e942bdfa6c..2204de8cd5 100644 --- a/tests/run_agent/test_corruption_recovery_guidance.py +++ b/tests/run_agent/test_corruption_recovery_guidance.py @@ -123,3 +123,47 @@ def test_format_turn_completion_locked_still_advises_retry(): ) assert "busy" in explanation assert "send it again" in explanation + + +def test_corrupt_guidance_pins_the_failing_profile(tmp_path, monkeypatch): + """#105887: every `hermes ...` command in the recovery guidance (turn explainer, gateway + home-channel notice, exhausted-repair diagnostic) carries the active profile selector and + names that profile's state.db. A bare `hermes` follows the sticky ``active_profile`` file, + so with another profile active the operator would repair the wrong database.""" + import asyncio + + import gateway.run as gateway_run + from hermes_state import _default_db_path + from hermes_state_repair import _persistent_repair_exhausted_error + from run_agent import AIAgent + + root = tmp_path / "hermes" + home = root / "profiles" / "research" + home.mkdir(parents=True) + (root / "config.yaml").write_text("") + (root / "active_profile").write_text("other\n") + monkeypatch.setenv("HERMES_HOME", str(home)) + + explanation = AIAgent._format_turn_completion_explanation("session_persistence_failed", "corrupt") + commands = [line.strip() for line in explanation.splitlines() if "hermes " in line] + assert commands and all("hermes -p research " in line for line in commands), commands + # The conftest pins hermes_state.DEFAULT_DB_PATH, so the store named is whatever the + # process resolves — the contract is "the same path the runtime would open". + assert f"--source {_default_db_path()} " in explanation + + runner = object.__new__(gateway_run.GatewayRunner) + runner._session_db_init_error = "database disk image is malformed" + sent = [] + monkeypatch.setattr(runner, "_home_channel_transports", lambda: [("telegram", {}, "home-chat", object())]) + + async def _capture_send(_platform, _home, _transport, message, _log_fmt): + sent.append(message) + + monkeypatch.setattr(runner, "_send_home_channel_message", _capture_send) + asyncio.run(runner._send_session_db_warning_notifications()) + notice_commands = [line.strip() for line in sent[0].splitlines() if "hermes " in line] + assert notice_commands and all("hermes -p research " in line for line in notice_commands), notice_commands + assert f"--source {_default_db_path()} " in sent[0] + + exhausted = _persistent_repair_exhausted_error(home / "state.db") + assert "`hermes -p research sessions recover --source" in exhausted