diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index fafdf3a224..fa6e9a4c68 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -219,9 +219,8 @@ class LogSnapshot: def _primary_log_path(log_name: str) -> Optional[Path]: """Where *log_name* would live if present. Doesn't check existence.""" - from hermes_cli.logs import LOG_FILES - filename = LOG_FILES.get(log_name) - return (get_hermes_home() / "logs" / filename) if filename else None + from hermes_cli.logs import log_file_path + return log_file_path(log_name) # update/handoff: the ROOT home's, whatever profile is active # Logs written by a client process, invisible to a remote/docker/SSH backend running `debug diff --git a/hermes_cli/logs.py b/hermes_cli/logs.py index 42ea725f67..4b98c5389b 100644 --- a/hermes_cli/logs.py +++ b/hermes_cli/logs.py @@ -28,7 +28,7 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Optional, Sequence -from hermes_constants import get_hermes_home, display_hermes_home +from hermes_constants import display_hermes_home, get_default_hermes_root, get_hermes_home # Known log files (name → filename) LOG_FILES = { @@ -53,6 +53,20 @@ LOG_FILES = { "mcp": "mcp-stderr.log", } +# Written to the ROOT home whatever profile is active: ``hermes update`` mutates the checkout every +# profile shares, and its mirror (main_dashboard) + the Desktop hand-off scripts write /logs. +ROOT_HOME_LOGS = frozenset({"update", "handoff"}) + + +def log_file_path(log_name: str) -> Optional[Path]: + """Where *log_name* lives (doesn't check existence); None for an unknown name.""" + filename = LOG_FILES.get(log_name) + if filename is None: + return None + home = get_default_hermes_root() if log_name in ROOT_HOME_LOGS else get_hermes_home() + return home / "logs" / filename + + # "2026-04-05 22:35:00[,123]" at the start of a line; update.log / # desktop-update-handoff.log stamp with the shell's ISO-8601 "T" shape # ("2026-09-29T21:36:18+08:00", "=== hermes update started 2026-09-29T21:36:18 ==="). @@ -174,7 +188,7 @@ def tail_log( print(f"Unknown log: {log_name!r}. Available: {', '.join(sorted(LOG_FILES))}") sys.exit(1) - log_path = get_hermes_home() / "logs" / filename + log_path = log_file_path(log_name) if not log_path.exists(): print(f"Log file not found: {log_path}") print("(Logs are created when Hermes runs — try 'hermes chat' first)") @@ -313,19 +327,23 @@ def _age_label(mtime: datetime) -> str: def list_logs() -> None: - """Print available log files with sizes.""" + """Print available log files with sizes (plus the root home's update logs under a profile).""" log_dir = get_hermes_home() / "logs" - if not log_dir.exists(): + root_logs = [path for name in sorted(ROOT_HOME_LOGS) + if (path := log_file_path(name)).parent != log_dir and path.is_file()] + if not log_dir.exists() and not root_logs: print(f"No logs directory at {display_hermes_home()}/logs/") return print(f"Log files in {display_hermes_home()}/logs/:\n") found = False - for entry in sorted(log_dir.iterdir()): + entries = sorted(log_dir.iterdir()) if log_dir.exists() else [] + for entry in [*entries, *root_logs]: if entry.is_file() and entry.suffix == ".log": st = entry.stat() age_str = _age_label(datetime.fromtimestamp(st.st_mtime)) - print(f" {entry.name:<25} {_size_label(st.st_size):>8} {age_str}") + label = entry.name if entry.parent == log_dir else f"{entry.name} (root)" + print(f" {label:<25} {_size_label(st.st_size):>8} {age_str}") found = True if not found: diff --git a/hermes_cli/update_handoff.py b/hermes_cli/update_handoff.py index c62e9e58e4..4ff89ab049 100644 --- a/hermes_cli/update_handoff.py +++ b/hermes_cli/update_handoff.py @@ -30,10 +30,10 @@ def is_post_swap_child() -> bool: def write_handoff(payload: dict[str, Any]) -> Path: - """Persist the post-swap payload under HERMES_HOME; returns its path.""" - from hermes_constants import get_hermes_home + """Persist the post-swap payload beside the receipts (the ROOT home); returns its path.""" + from hermes_constants import get_default_hermes_root - directory = get_hermes_home() / "logs" / "update_receipts" + directory = get_default_hermes_root() / "logs" / "update_receipts" directory.mkdir(parents=True, exist_ok=True) path = directory / f"post_swap_{os.getpid()}.json" path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8") diff --git a/hermes_cli/web_routers/actions.py b/hermes_cli/web_routers/actions.py index 966662c569..b128705aab 100644 --- a/hermes_cli/web_routers/actions.py +++ b/hermes_cli/web_routers/actions.py @@ -357,7 +357,12 @@ async def get_action_status(name: str, lines: int = 200): durable_update_action_id = None update_receipt_summary = None if name == "hermes-update": - durable_update_action_id = _durable_completed_update_action_id(_tail_lines(log_dir / "update.log", 2000)) + # ``hermes update`` mirrors to the ROOT home's update.log (main_dashboard), never this + # dashboard's profile home: read it where it is written. + from hermes_constants import get_default_hermes_root + + durable_update_action_id = _durable_completed_update_action_id( + _tail_lines(get_default_hermes_root() / "logs" / "update.log", 2000)) if durable_update_action_id: marker = f"=== hermes-update completed {durable_update_action_id} ===" if marker not in tail: diff --git a/pm/receipt.py b/pm/receipt.py index 34cca62782..b20402aa6b 100644 --- a/pm/receipt.py +++ b/pm/receipt.py @@ -123,10 +123,11 @@ def _utc_now_iso() -> str: def _receipt_dir() -> Path: """The receipts dir — a pure path computation, NO mkdir side - effect: readers (latest()) must not create state.""" - from hermes_constants import get_hermes_home + effect: readers (latest()) must not create state. The ROOT home, like the + updater's receipts (hermes_cli.update_receipt): one dir whatever profile runs.""" + from hermes_constants import get_default_hermes_root - return get_hermes_home() / "logs" / "update_receipts" + return get_default_hermes_root() / "logs" / "update_receipts" def begin(kind: str) -> contextvars.Token: diff --git a/tests/hermes_cli/test_debug_share_update_logs.py b/tests/hermes_cli/test_debug_share_update_logs.py index 211cfe7ebe..449f71698c 100644 --- a/tests/hermes_cli/test_debug_share_update_logs.py +++ b/tests/hermes_cli/test_debug_share_update_logs.py @@ -67,6 +67,23 @@ class TestCaptureDefaultLogSnapshots: assert "Desktop GUI build failed" in snaps["update"].tail_text assert "ELIFECYCLE" in snaps["handoff"].tail_text + def test_snapshots_read_the_root_home_update_logs_under_a_profile(self, tmp_path, monkeypatch): + """``hermes update`` and the hand-off scripts write the ROOT home's logs, whatever profile.""" + from hermes_cli.debug import _capture_default_log_snapshots + + root = tmp_path / "root" + profile = root / "profiles" / "coder" + (root / "logs").mkdir(parents=True) + (profile / "logs").mkdir(parents=True) + (root / "logs" / "update.log").write_text(UPDATE_LOG_BODY) + (root / "logs" / "desktop-update-handoff.log").write_text(HANDOFF_LOG_BODY) + monkeypatch.setenv("HERMES_HOME", str(profile)) + + snaps = _capture_default_log_snapshots(100) + + assert "Desktop GUI build failed" in snaps["update"].tail_text + assert "ELIFECYCLE" in snaps["handoff"].tail_text + def test_missing_files_report_absence_without_raising( self, home_without_update_logs ): diff --git a/tests/hermes_cli/test_logs.py b/tests/hermes_cli/test_logs.py index 3ac6b1d65b..9643f7217b 100644 --- a/tests/hermes_cli/test_logs.py +++ b/tests/hermes_cli/test_logs.py @@ -228,3 +228,26 @@ def test_every_log_file_writes_a_stamp_hermes_logs_since_can_read(): # gateway.error.log (launchd stderr, not in LOG_FILES) uses the shared stamper. from hermes_cli.stderr_timestamp import stamp_line assert _parse_line_timestamp(stamp_line("raw gateway stderr")) is not None + + +def test_update_logs_are_read_from_the_root_home_under_a_profile(tmp_path, monkeypatch, capsys): + # ``hermes update`` mirrors to /logs/update.log and the hand-off scripts write the root + # too; `hermes logs update` under a sticky profile read the profile's (absent) copy. + from hermes_cli.logs import list_logs, log_file_path, tail_log + + root = tmp_path / "root" + profile = root / "profiles" / "coder" + (root / "logs").mkdir(parents=True) + (profile / "logs").mkdir(parents=True) + (root / "logs" / "update.log").write_text("=== hermes update started 2026-10-03T10:00:00 ===\n✓ Update complete!\n") + (profile / "logs" / "agent.log").write_text("2026-10-03 10:00:00,000 INFO run_agent: hi\n") + monkeypatch.setenv("HERMES_HOME", str(profile)) + + assert log_file_path("update") == root / "logs" / "update.log" + assert log_file_path("handoff") == root / "logs" / "desktop-update-handoff.log" + assert log_file_path("agent") == profile / "logs" / "agent.log" + tail_log("update", num_lines=5) + assert "✓ Update complete!" in capsys.readouterr().out + list_logs() + listing = capsys.readouterr().out + assert "agent.log" in listing and "update.log (root)" in listing diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 9157381ec5..46ed79fd11 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1455,14 +1455,18 @@ CONFIG_SCHEMA = ProviderConfigSchema( assert check_data["update_command"] == data["update_command"] def test_update_status_recovers_completed_result_after_dashboard_restart(self, monkeypatch, tmp_path): - + # The dashboard runs under a profile home; ``hermes update`` mirrors to the ROOT home's + # update.log (main_dashboard), so the durable completion marker is read there. + root = tmp_path / "root" + (root / "logs").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "coder")) action_id = "c" * 32 (tmp_path / "hermes-update.log").write_text( "=== hermes-update started 2026-08-17 11:19:34 ===\n" "pulling updates...\n", encoding="utf-8", ) - (tmp_path / "update.log").write_text( + (root / "logs" / "update.log").write_text( "=== hermes update started 2026-08-17T11:19:35 ===\n" "✓ Update complete!\n" f"=== hermes-update completed {action_id} ===\n",