Files
hermes-agent/hermes_cli/config_read_errors.py
teknium1 b0369c49a1 fix(config): a save refused over bad YAML says to fix the file, not "try again"
The FailedConfigRead save refusal always said "could not be read ... Try
again", including when the fallback came from a YAML parse error that no
retry fixes. The refusal now matches the cause: bad YAML gets the same
"has a formatting error ... `hermes config edit`" guidance as
require_readable_config_before_write; a PermissionError gets the
fix-permissions hint; only other read errors (EMFILE/EIO) say try again.
2026-09-23 15:05:44 -07:00

154 lines
7.3 KiB
Python

"""What Hermes does when an existing ``config.yaml`` cannot be read or parsed.
Readers fail open (``{}`` / defaults / last-known-good) so a broken file never takes the process
down; this module makes that fallback loud (one warning per file signature, a ``corrupt`` backup),
remembers the failure for :func:`get_active_config_parse_failure`, and marks every fallback as a
:class:`FailedConfigRead` so the writers refuse to persist it.
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from typing import Any, Optional
from utils import file_signature
logger = logging.getLogger(__name__)
# (config_path, mtime_ns, size) tuples already warned about, so concurrent CLI/gateway
# loads of a broken config.yaml don't spam stderr. A changed file (new mtime) warns again.
_CONFIG_PARSE_WARNED: set = set()
# path -> (mtime_ns, size, error message) of active parse failures. Written by
# _warn_config_parse_failure() (the single funnel for every load-path parse failure) and
# probed by get_active_config_parse_failure() so provider auto-resolution can refuse to
# adopt a paid provider from env keys while the user's REAL config is unreadable.
_CONFIG_PARSE_FAILURES: dict = {}
_PARSE_FAILURE_FALLBACK_MSG = {
"last-known-good": "Hermes is running on the settings it loaded before the edit until it is fixed, so recent changes are not applied.",
"last-known-good-backup": "Hermes is running on your last good settings until it is fixed, so recent changes are not applied.",
"refuse-write": "Nothing was written, so the existing file is preserved."}
_PARSE_FAILURE_DEFAULTS_MSG = (
"Hermes is running on default settings until it is fixed, so none of your saved settings are applied.")
_PARSE_FAILURE_REPAIR_MSG = "Open it with `hermes config edit`, fix {where}, then run `hermes config check`."
_FIX_PERMS = "Fix the file permissions or move it aside first."
_FIX_YAML = (
"Fix it with `hermes config edit` and check with `hermes config check`, or copy the newest good "
"file from {backups} over config.yaml.")
def _yaml_error_location(exc: Exception) -> str:
"""``"line 12"`` from a PyYAML problem mark (1-based), else ``""``."""
mark = getattr(exc, "problem_mark", None) or getattr(exc, "context_mark", None)
line = getattr(mark, "line", None)
return f"line {line + 1}" if isinstance(line, int) else ""
def _yaml_error_details(exc: Exception) -> str:
"""Single-line ``Details:`` text: the PyYAML problem, or the exception's first line."""
problem = getattr(exc, "problem", None)
text = f"{problem}" if problem else str(exc).strip()
return " ".join(text.split())
def format_config_parse_failure(config_path: Path, exc: Exception, *, fallback: str = "defaults") -> str:
"""User copy for an unparseable config.yaml: what happened, what Hermes is doing, how to fix.
Only the problem line/column is printed; the raw PyYAML text goes to a ``Details:`` line."""
where = _yaml_error_location(exc)
at = f" at {where}" if where else ""
fallback_msg = _PARSE_FAILURE_FALLBACK_MSG.get(fallback, _PARSE_FAILURE_DEFAULTS_MSG)
if isinstance(exc, OSError): # the file is intact; EMFILE/EIO/sharing violation, not a YAML problem
return f"Your settings file ({config_path}) could not be read. {fallback_msg} {_read_error_fix(exc)}"
repair = _PARSE_FAILURE_REPAIR_MSG.format(where=where or "the problem")
return f"Your settings file ({config_path}) has a formatting error{at}. {fallback_msg} {repair}"
def _warn_config_parse_failure(
config_path: Path, exc: Exception, *, fallback: str = "defaults") -> None:
"""Surface a config.yaml parse failure to log and stderr (once per file signature).
Silent fallback to ``DEFAULT_CONFIG`` drops every user override, so this must be loud.
``fallback`` selects the message wording: ``"defaults"`` (fresh process, nothing else to serve) or
``"last-known-good"`` (in-process retention of the previously loaded config — see the codex#31188 port
in ``hermes_cli.config._load_config_impl``).
"""
try:
st = config_path.stat()
sig = file_signature(st)
key = (str(config_path), *sig)
_CONFIG_PARSE_FAILURES[str(config_path)] = (*sig, str(exc))
except OSError:
key = (str(config_path), 0, 0, 0, 0)
if key in _CONFIG_PARSE_WARNED:
return
_CONFIG_PARSE_WARNED.add(key)
from hermes_cli.config_backups import backup_config
# A read error leaves an intact file behind: no "corrupt" copy of a good file.
backup_path = None if isinstance(exc, OSError) else backup_config(config_path, "corrupt")
msg = format_config_parse_failure(config_path, exc, fallback=fallback)
if backup_path is not None:
msg += f" A copy of the broken file was saved to {backup_path}."
logger.warning("%s Details: %s", msg, _yaml_error_details(exc))
try:
sys.stderr.write(f"⚠️ hermes config: {msg}\n Details: {_yaml_error_details(exc)}\n")
sys.stderr.flush()
except Exception:
pass
def get_active_config_parse_failure() -> Optional[str]:
"""Return the recorded parse error while the ACTIVE config.yaml is still byte-identical
(mtime_ns + size + ino + ctime_ns) to the file that failed to parse; else None."""
from hermes_cli.config import get_config_path
try:
record = _CONFIG_PARSE_FAILURES[str(path := get_config_path())]
st = path.stat()
return record[4] if file_signature(st) == record[:4] else None
except Exception:
return None
class FailedConfigRead(dict):
"""The fail-open fallback a config reader serves when an existing config.yaml could not be read
or parsed (``{}``, defaults or last-known-good). Readers use it like any dict; ``save_config`` /
``atomic_config_write`` refuse to persist it. The writer merges by deletion, so saving a fallback
after one transient EMFILE/EIO replaced the whole file with the fallback plus the caller's edit.
A dict subclass so the refusal survives the load→mutate→save round trip at every call site."""
def __init__(self, data: Any = (), *, error: Exception):
super().__init__(data)
self.read_error = error
def _read_error_fix(exc: OSError) -> str:
return _FIX_PERMS if isinstance(exc, PermissionError) else (
"Try again; run `hermes config check` if it keeps failing.")
def _refuse_failed_read(config_path: Path, data: Any) -> None:
"""Refuse to save a fallback; only a read error is worth retrying, bad YAML needs an edit."""
if not isinstance(data, FailedConfigRead):
return
exc = data.read_error
if isinstance(exc, OSError):
raise _refuse_overwrite(config_path, "could not be read", exc, _read_error_fix(exc))
raise _refuse_overwrite(
config_path, "has a formatting error", exc, _FIX_YAML.format(backups=_backups_dir_display()))
def _refuse_overwrite(config_path: Path, reason: str, exc: Exception, fix: str) -> RuntimeError:
"""Error for a write that must not replace an existing config.yaml. Plain lead + ``Details:``."""
where = _yaml_error_location(exc)
at = f" ({where})" if where else ""
return RuntimeError(
f"Your settings file ({config_path}) {reason}{at}, so this change was not saved. {fix} "
f"Details: {_yaml_error_details(exc)}")
def _backups_dir_display() -> str:
from hermes_constants import display_hermes_home
return f"{display_hermes_home()}/backups/config/"