fix(#3915): warn on STATE_DIR divergence at startup when session store is empty
When SESSION_DIR has no session .json files and the index is empty/absent, print_startup_config() now scans sibling state directories and prints a diagnostic pointing the user at the populated sibling + the HERMES_WEBUI_STATE_DIR to set — fully fail-safe (try/except, warning-only, no behavior change). Closes #3915. Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
@@ -574,6 +574,65 @@ DEFAULT_MODEL = os.getenv("HERMES_WEBUI_DEFAULT_MODEL", "") # Empty = use provi
|
||||
|
||||
|
||||
# ── Startup diagnostics ───────────────────────────────────────────────────────
|
||||
def _warn_state_dir_divergence(warn_prefix: str) -> None:
|
||||
"""Check if SESSION_DIR is empty but a sibling state directory has session data.
|
||||
|
||||
If the session store looks empty (no *.json files besides _index.json in SESSION_DIR,
|
||||
or SESSION_INDEX_FILE is absent/empty/contains only {}|[]|null), scan STATE_DIR.parent
|
||||
for sibling directories with a sessions/ child that has .json files.
|
||||
|
||||
Prints a diagnostic warning if a divergence is detected, helping users identify when
|
||||
they may have switched launch methods and the HERMES_WEBUI_STATE_DIR env var differs.
|
||||
"""
|
||||
try:
|
||||
# Check if session store is empty
|
||||
session_dir_empty = False
|
||||
|
||||
# Check for .json files in SESSION_DIR (excluding _index.json)
|
||||
if SESSION_DIR.exists():
|
||||
json_files = [f for f in SESSION_DIR.glob("*.json") if f.name != "_index.json"]
|
||||
session_dir_empty = len(json_files) == 0
|
||||
else:
|
||||
session_dir_empty = True
|
||||
|
||||
# Check if SESSION_INDEX_FILE is absent, empty, or contains only {}|[]|null
|
||||
index_file_empty = True
|
||||
if SESSION_INDEX_FILE.exists():
|
||||
try:
|
||||
with open(SESSION_INDEX_FILE, "r") as f:
|
||||
content = f.read().strip()
|
||||
if content and content not in ("{}", "[]", "null"):
|
||||
index_file_empty = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If session store looks empty, scan for siblings with sessions
|
||||
if session_dir_empty and index_file_empty:
|
||||
state_parent = STATE_DIR.parent
|
||||
if state_parent.exists():
|
||||
for sibling in state_parent.iterdir():
|
||||
if not sibling.is_dir() or sibling == STATE_DIR:
|
||||
continue
|
||||
sibling_sessions = sibling / "sessions"
|
||||
if sibling_sessions.exists():
|
||||
json_files = [f for f in sibling_sessions.glob("*.json") if f.name != "_index.json"]
|
||||
if json_files:
|
||||
# Found a sibling with session data
|
||||
print(
|
||||
f"{warn_prefix} STATE_DIR is empty but a sibling state directory has session data.\n"
|
||||
f" Current : {STATE_DIR}\n"
|
||||
f" Sibling : {sibling}\n"
|
||||
f" If you switched launch methods (bootstrap.py / ctl.sh / systemd),\n"
|
||||
f" the active HERMES_WEBUI_STATE_DIR env var may differ from the\n"
|
||||
f" previous run. Set it explicitly to restore access:\n"
|
||||
f" export HERMES_WEBUI_STATE_DIR={sibling}",
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def print_startup_config() -> None:
|
||||
"""Print detected configuration at startup so the user can verify what was found."""
|
||||
ok = "\033[32m[ok]\033[0m"
|
||||
@@ -595,6 +654,11 @@ def print_startup_config() -> None:
|
||||
]
|
||||
print("\n".join(lines), flush=True)
|
||||
|
||||
try:
|
||||
_warn_state_dir_divergence(warn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not _HERMES_FOUND:
|
||||
print(
|
||||
f"{err} Could not find the Hermes agent directory.\n"
|
||||
|
||||
366
tests/test_issue3915_state_dir_warning.py
Normal file
366
tests/test_issue3915_state_dir_warning.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""Tests for STATE_DIR divergence warning (issue #3915).
|
||||
|
||||
When a user switches launch methods (bootstrap.py / ctl.sh / systemd), the
|
||||
HERMES_WEBUI_STATE_DIR env var may differ from the previous run, leaving
|
||||
the current state directory empty while session data exists in a sibling.
|
||||
|
||||
This test suite verifies that _warn_state_dir_divergence() detects this
|
||||
condition and prints a diagnostic warning, helping users recover their sessions.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
import pytest
|
||||
|
||||
|
||||
def test_warn_when_session_dir_empty_and_sibling_has_data(capsys):
|
||||
"""Warning should print when SESSION_DIR is empty but a sibling has sessions."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create current (empty) state directory
|
||||
current_state = tmpdir / "current"
|
||||
current_state.mkdir()
|
||||
(current_state / "sessions").mkdir()
|
||||
|
||||
# Create sibling state directory with session data
|
||||
sibling_state = tmpdir / "sibling"
|
||||
sibling_state.mkdir()
|
||||
(sibling_state / "sessions").mkdir()
|
||||
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
||||
|
||||
# Mock the global config variables
|
||||
with mock.patch("api.config.STATE_DIR", current_state):
|
||||
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
||||
with mock.patch("api.config.SESSION_INDEX_FILE", current_state / "sessions" / "_index.json"):
|
||||
# Import after patching to get the patched values
|
||||
import api.config as config
|
||||
|
||||
warn_prefix = "\033[33m[!!]\033[0m"
|
||||
config._warn_state_dir_divergence(warn_prefix)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
||||
assert str(current_state) in captured.out
|
||||
assert str(sibling_state) in captured.out
|
||||
assert "HERMES_WEBUI_STATE_DIR" in captured.out
|
||||
|
||||
|
||||
def test_no_warn_when_session_dir_has_files(capsys):
|
||||
"""No warning should print when SESSION_DIR has session files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create current state directory with session data
|
||||
current_state = tmpdir / "current"
|
||||
current_state.mkdir()
|
||||
(current_state / "sessions").mkdir()
|
||||
(current_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
||||
|
||||
# Create sibling state directory with session data
|
||||
sibling_state = tmpdir / "sibling"
|
||||
sibling_state.mkdir()
|
||||
(sibling_state / "sessions").mkdir()
|
||||
(sibling_state / "sessions" / "session2.json").write_text('{"id": "s2"}')
|
||||
|
||||
# Mock the global config variables
|
||||
with mock.patch("api.config.STATE_DIR", current_state):
|
||||
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
||||
with mock.patch("api.config.SESSION_INDEX_FILE", current_state / "sessions" / "_index.json"):
|
||||
import api.config as config
|
||||
|
||||
warn_prefix = "\033[33m[!!]\033[0m"
|
||||
config._warn_state_dir_divergence(warn_prefix)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "STATE_DIR is empty but a sibling state directory has session data" not in captured.out
|
||||
|
||||
|
||||
def test_no_warn_when_index_file_has_valid_content(capsys):
|
||||
"""No warning should print when SESSION_INDEX_FILE has valid JSON content."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create current state directory with valid index file but no session files
|
||||
current_state = tmpdir / "current"
|
||||
current_state.mkdir()
|
||||
(current_state / "sessions").mkdir()
|
||||
index_file = current_state / "sessions" / "_index.json"
|
||||
index_file.write_text('{"sessions": ["s1", "s2"]}')
|
||||
|
||||
# Create sibling state directory with session data
|
||||
sibling_state = tmpdir / "sibling"
|
||||
sibling_state.mkdir()
|
||||
(sibling_state / "sessions").mkdir()
|
||||
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
||||
|
||||
# Mock the global config variables
|
||||
with mock.patch("api.config.STATE_DIR", current_state):
|
||||
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
||||
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
||||
import api.config as config
|
||||
|
||||
warn_prefix = "\033[33m[!!]\033[0m"
|
||||
config._warn_state_dir_divergence(warn_prefix)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "STATE_DIR is empty but a sibling state directory has session data" not in captured.out
|
||||
|
||||
|
||||
def test_no_warn_when_no_sibling_with_data(capsys):
|
||||
"""No warning should print when no sibling has session data."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create current (empty) state directory
|
||||
current_state = tmpdir / "current"
|
||||
current_state.mkdir()
|
||||
(current_state / "sessions").mkdir()
|
||||
|
||||
# Create sibling without sessions directory
|
||||
sibling_state = tmpdir / "sibling"
|
||||
sibling_state.mkdir()
|
||||
|
||||
# Mock the global config variables
|
||||
with mock.patch("api.config.STATE_DIR", current_state):
|
||||
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
||||
with mock.patch("api.config.SESSION_INDEX_FILE", current_state / "sessions" / "_index.json"):
|
||||
import api.config as config
|
||||
|
||||
warn_prefix = "\033[33m[!!]\033[0m"
|
||||
config._warn_state_dir_divergence(warn_prefix)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "STATE_DIR is empty but a sibling state directory has session data" not in captured.out
|
||||
|
||||
|
||||
def test_warn_with_empty_index_file(capsys):
|
||||
"""Warning should print when SESSION_INDEX_FILE is empty."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create current state directory with empty index file
|
||||
current_state = tmpdir / "current"
|
||||
current_state.mkdir()
|
||||
(current_state / "sessions").mkdir()
|
||||
index_file = current_state / "sessions" / "_index.json"
|
||||
index_file.write_text("")
|
||||
|
||||
# Create sibling state directory with session data
|
||||
sibling_state = tmpdir / "sibling"
|
||||
sibling_state.mkdir()
|
||||
(sibling_state / "sessions").mkdir()
|
||||
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
||||
|
||||
# Mock the global config variables
|
||||
with mock.patch("api.config.STATE_DIR", current_state):
|
||||
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
||||
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
||||
import api.config as config
|
||||
|
||||
warn_prefix = "\033[33m[!!]\033[0m"
|
||||
config._warn_state_dir_divergence(warn_prefix)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
||||
|
||||
|
||||
def test_warn_with_index_file_containing_null(capsys):
|
||||
"""Warning should print when SESSION_INDEX_FILE contains only 'null'."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create current state directory with null index file
|
||||
current_state = tmpdir / "current"
|
||||
current_state.mkdir()
|
||||
(current_state / "sessions").mkdir()
|
||||
index_file = current_state / "sessions" / "_index.json"
|
||||
index_file.write_text("null")
|
||||
|
||||
# Create sibling state directory with session data
|
||||
sibling_state = tmpdir / "sibling"
|
||||
sibling_state.mkdir()
|
||||
(sibling_state / "sessions").mkdir()
|
||||
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
||||
|
||||
# Mock the global config variables
|
||||
with mock.patch("api.config.STATE_DIR", current_state):
|
||||
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
||||
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
||||
import api.config as config
|
||||
|
||||
warn_prefix = "\033[33m[!!]\033[0m"
|
||||
config._warn_state_dir_divergence(warn_prefix)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
||||
|
||||
|
||||
def test_warn_with_index_file_containing_empty_object(capsys):
|
||||
"""Warning should print when SESSION_INDEX_FILE contains only '{}'."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create current state directory with {} index file
|
||||
current_state = tmpdir / "current"
|
||||
current_state.mkdir()
|
||||
(current_state / "sessions").mkdir()
|
||||
index_file = current_state / "sessions" / "_index.json"
|
||||
index_file.write_text("{}")
|
||||
|
||||
# Create sibling state directory with session data
|
||||
sibling_state = tmpdir / "sibling"
|
||||
sibling_state.mkdir()
|
||||
(sibling_state / "sessions").mkdir()
|
||||
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
||||
|
||||
# Mock the global config variables
|
||||
with mock.patch("api.config.STATE_DIR", current_state):
|
||||
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
||||
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
||||
import api.config as config
|
||||
|
||||
warn_prefix = "\033[33m[!!]\033[0m"
|
||||
config._warn_state_dir_divergence(warn_prefix)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
||||
|
||||
|
||||
def test_warn_with_index_file_containing_empty_array(capsys):
|
||||
"""Warning should print when SESSION_INDEX_FILE contains only '[]'."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create current state directory with [] index file
|
||||
current_state = tmpdir / "current"
|
||||
current_state.mkdir()
|
||||
(current_state / "sessions").mkdir()
|
||||
index_file = current_state / "sessions" / "_index.json"
|
||||
index_file.write_text("[]")
|
||||
|
||||
# Create sibling state directory with session data
|
||||
sibling_state = tmpdir / "sibling"
|
||||
sibling_state.mkdir()
|
||||
(sibling_state / "sessions").mkdir()
|
||||
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
||||
|
||||
# Mock the global config variables
|
||||
with mock.patch("api.config.STATE_DIR", current_state):
|
||||
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
||||
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
||||
import api.config as config
|
||||
|
||||
warn_prefix = "\033[33m[!!]\033[0m"
|
||||
config._warn_state_dir_divergence(warn_prefix)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
||||
|
||||
|
||||
def test_exception_handling_missing_session_dir(capsys):
|
||||
"""Function should not crash if SESSION_DIR doesn't exist."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create current state directory (without sessions subdir)
|
||||
current_state = tmpdir / "current"
|
||||
current_state.mkdir()
|
||||
|
||||
# Mock the global config variables
|
||||
with mock.patch("api.config.STATE_DIR", current_state):
|
||||
with mock.patch("api.config.SESSION_DIR", current_state / "nonexistent"):
|
||||
with mock.patch("api.config.SESSION_INDEX_FILE", current_state / "nonexistent" / "_index.json"):
|
||||
import api.config as config
|
||||
|
||||
warn_prefix = "\033[33m[!!]\033[0m"
|
||||
# Should not raise
|
||||
config._warn_state_dir_divergence(warn_prefix)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# No output expected since SESSION_DIR doesn't exist and parent iteration is safe
|
||||
assert "STATE_DIR is empty but a sibling state directory has session data" not in captured.out
|
||||
|
||||
|
||||
def test_exception_handling_permission_error(capsys):
|
||||
"""Function should not crash on permission errors when reading files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create current state directory with unreadable index file
|
||||
current_state = tmpdir / "current"
|
||||
current_state.mkdir()
|
||||
(current_state / "sessions").mkdir()
|
||||
index_file = current_state / "sessions" / "_index.json"
|
||||
index_file.write_text("")
|
||||
|
||||
# Create sibling state directory with session data
|
||||
sibling_state = tmpdir / "sibling"
|
||||
sibling_state.mkdir()
|
||||
(sibling_state / "sessions").mkdir()
|
||||
(sibling_state / "sessions" / "session1.json").write_text('{"id": "s1"}')
|
||||
|
||||
# Mock the global config variables and patch open to raise PermissionError
|
||||
with mock.patch("api.config.STATE_DIR", current_state):
|
||||
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
||||
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
||||
with mock.patch("builtins.open", side_effect=PermissionError("Access denied")):
|
||||
import api.config as config
|
||||
|
||||
warn_prefix = "\033[33m[!!]\033[0m"
|
||||
# Should not raise
|
||||
config._warn_state_dir_divergence(warn_prefix)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Warning should print since index_file_empty stays True when open fails
|
||||
assert "STATE_DIR is empty but a sibling state directory has session data" in captured.out
|
||||
|
||||
|
||||
def test_exception_handling_glob_error(capsys):
|
||||
"""Function should not crash on exception during glob operations."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir = Path(tmpdir)
|
||||
|
||||
# Create current state directory
|
||||
current_state = tmpdir / "current"
|
||||
current_state.mkdir()
|
||||
(current_state / "sessions").mkdir()
|
||||
index_file = current_state / "sessions" / "_index.json"
|
||||
|
||||
# Create sibling with sessions
|
||||
sibling_state = tmpdir / "sibling"
|
||||
sibling_state.mkdir()
|
||||
(sibling_state / "sessions").mkdir()
|
||||
|
||||
# Mock the global config variables
|
||||
with mock.patch("api.config.STATE_DIR", current_state):
|
||||
with mock.patch("api.config.SESSION_DIR", current_state / "sessions"):
|
||||
with mock.patch("api.config.SESSION_INDEX_FILE", index_file):
|
||||
import api.config as config
|
||||
|
||||
warn_prefix = "\033[33m[!!]\033[0m"
|
||||
|
||||
# Patch glob to raise an exception
|
||||
original_glob = Path.glob
|
||||
call_count = [0]
|
||||
|
||||
def mock_glob(self, pattern):
|
||||
call_count[0] += 1
|
||||
# First glob is in _warn_state_dir_divergence for SESSION_DIR
|
||||
# Let it succeed for the first, fail on sibling scan
|
||||
if call_count[0] > 1:
|
||||
raise OSError("Simulated glob error")
|
||||
return original_glob(self, pattern)
|
||||
|
||||
with mock.patch.object(Path, "glob", mock_glob):
|
||||
# Should not raise due to outer try/except
|
||||
config._warn_state_dir_divergence(warn_prefix)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# No output expected since exception is caught in the try/except
|
||||
assert "STATE_DIR is empty but a sibling state directory has session data" not in captured.out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user