After a dashboard restart the only record of which update THIS dashboard started was the `=== hermes-update started ... <id> ===` header, read from a 2,000-line tail. A build that printed more than 2,000 lines (or a rotated log) hid it: spawned_action_id became None, the matching root completion marker and the matching archived success receipt were both refused, and the route answered running=false/exit_code=null, which the web poller shows as "Action failed (exit ?)". _spawn_hermes_action now also writes the id to a `<name>.action_id` sidecar (atomic) next to the log; the status route falls back to it when no header is visible. A visible header still wins (a dashboard predating the sidecar wrote only the header, and the newest header is the latest spawn). The identity selects B's own archived receipt even when another profile's run is latest; failed/unfinished actions still report exit null.
198 lines
11 KiB
Python
198 lines
11 KiB
Python
"""Dashboard update status reads the ROOT home's shared update.log, correlated by action id."""
|
|
|
|
import types
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.web_server_gateway as _web_server_gateway
|
|
|
|
|
|
class TestUpdateStatusRootLog:
|
|
@pytest.fixture(autouse=True)
|
|
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
|
|
"""Create a TestClient and isolate the state DB under the test HERMES_HOME."""
|
|
try:
|
|
from starlette.testclient import TestClient
|
|
except ImportError:
|
|
pytest.skip("fastapi/starlette not installed")
|
|
|
|
import hermes_state
|
|
from hermes_constants import get_hermes_home
|
|
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
|
|
|
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
|
|
|
self.client = TestClient(app)
|
|
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
|
|
|
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(
|
|
f"=== hermes-update started 2026-08-17 11:19:34 {action_id} ===\n"
|
|
"pulling updates...\n",
|
|
encoding="utf-8",
|
|
)
|
|
(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",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", tmp_path)
|
|
monkeypatch.setattr(_web_server_gateway, "_ACTION_PROCS", {})
|
|
monkeypatch.setattr(_web_server_gateway, "_ACTION_RESULTS", {})
|
|
monkeypatch.setattr(_web_server_gateway, "_ACTION_COMMANDS", {})
|
|
monkeypatch.setattr(_web_server_gateway, "_ACTION_IDS", {})
|
|
|
|
status = self.client.get("/api/actions/hermes-update/status?lines=2000")
|
|
|
|
assert status.status_code == 200
|
|
data = status.json()
|
|
assert data["running"] is False
|
|
assert data["exit_code"] == 0
|
|
assert data["action_id"] == action_id
|
|
assert f"=== hermes-update completed {action_id} ===" in data["lines"]
|
|
|
|
|
|
def test_update_status_ignores_another_profiles_completion_in_the_shared_root_log(self, monkeypatch, tmp_path):
|
|
# Every profile's ``hermes update`` mirrors into the ROOT update.log; profile A's success
|
|
# there must not certify the action profile B's dashboard started (and lost on restart).
|
|
root = tmp_path / "root"
|
|
(root / "logs").mkdir(parents=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "b"))
|
|
b_logs = tmp_path / "b-logs"
|
|
monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", b_logs)
|
|
for registry in ("_ACTION_PROCS", "_ACTION_RESULTS", "_ACTION_COMMANDS", "_ACTION_IDS"):
|
|
monkeypatch.setattr(_web_server_gateway, registry, {})
|
|
monkeypatch.setattr(_web_server_gateway.subprocess, "Popen", lambda *a, **kw: types.SimpleNamespace(pid=7))
|
|
b_id = "b" * 32
|
|
_web_server_gateway._spawn_hermes_action(["update"], "hermes-update", env_overrides={"HERMES_ACTION_ID": b_id})
|
|
_web_server_gateway._ACTION_PROCS.clear() # B's dashboard restarted: in-memory result lost
|
|
|
|
def status_after_root_completion(action_id):
|
|
(root / "logs" / "update.log").write_text(
|
|
"=== hermes update started 2026-08-17T11:19:35 ===\n"
|
|
f"=== hermes-update completed {action_id} ===\n", encoding="utf-8")
|
|
return self.client.get("/api/actions/hermes-update/status?lines=2000").json()
|
|
|
|
foreign = status_after_root_completion("a" * 32)
|
|
assert foreign["exit_code"] is None
|
|
assert "action_id" not in foreign
|
|
own = status_after_root_completion(b_id)
|
|
assert own["exit_code"] == 0
|
|
assert own["action_id"] == b_id
|
|
|
|
@pytest.mark.parametrize("log_shape,b_outcome", [
|
|
("short", "success"), ("long", "success"), ("rotated", "success"), ("long", "failed"), ("long", None)])
|
|
def test_restarted_dashboard_keeps_its_action_identity_past_the_log_tail(
|
|
self, monkeypatch, tmp_path, log_shape, b_outcome):
|
|
# The status route reads at most 2,000 log lines: build output (or rotation) pushing the
|
|
# start header out must not turn B's own success into exit null ("Action failed (exit ?)"),
|
|
# nor let profile A's newer latest receipt answer for B.
|
|
from hermes_cli import update_receipt
|
|
|
|
root, b_id, a_id = tmp_path / "root", "b" * 32, "a" * 32
|
|
(root / "logs").mkdir(parents=True)
|
|
b_logs = tmp_path / "b-logs"
|
|
monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", b_logs)
|
|
for registry in ("_ACTION_PROCS", "_ACTION_RESULTS", "_ACTION_COMMANDS", "_ACTION_IDS"):
|
|
monkeypatch.setattr(_web_server_gateway, registry, {})
|
|
monkeypatch.setattr(_web_server_gateway.subprocess, "Popen", lambda *a, **kw: types.SimpleNamespace(pid=7))
|
|
monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "b"))
|
|
_web_server_gateway._spawn_hermes_action(["update"], "hermes-update", env_overrides={"HERMES_ACTION_ID": b_id})
|
|
for profile, action_id, outcome in (("b", b_id, b_outcome), ("a", a_id, "success")):
|
|
if outcome:
|
|
monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / profile))
|
|
monkeypatch.setenv("HERMES_ACTION_ID", action_id)
|
|
update_receipt.begin_update_receipt()
|
|
update_receipt.finalize_update_receipt(outcome)
|
|
monkeypatch.delenv("HERMES_ACTION_ID")
|
|
monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "b"))
|
|
log = b_logs / "hermes-update.log"
|
|
if log_shape == "long":
|
|
with log.open("a", encoding="utf-8") as handle:
|
|
handle.write("build output\n" * 2001)
|
|
elif log_shape == "rotated":
|
|
log.unlink()
|
|
if b_outcome == "success":
|
|
(root / "logs" / "update.log").write_text(f"=== hermes-update completed {b_id} ===\n", encoding="utf-8")
|
|
_web_server_gateway._ACTION_PROCS.clear() # dashboard restarted: in-memory registries lost
|
|
_web_server_gateway._ACTION_IDS.clear()
|
|
|
|
data = self.client.get("/api/actions/hermes-update/status?lines=2000").json()
|
|
|
|
assert data["running"] is False
|
|
assert data["exit_code"] == (0 if b_outcome == "success" else None)
|
|
assert data.get("action_id") == (b_id if b_outcome == "success" else None)
|
|
assert data["receipt"]["action_id"] == (b_id if b_outcome else a_id)
|
|
|
|
@pytest.mark.parametrize("b_outcome", [None, "failed", "success"])
|
|
def test_root_latest_receipt_of_another_profile_never_certifies_this_action(self, monkeypatch, tmp_path, b_outcome):
|
|
# The receipt store is root-wide too: A's later success in latest.json must not become B's
|
|
# exit 0. Only a receipt naming B's action (found in the archive when A is latest) counts.
|
|
from hermes_cli import update_receipt
|
|
|
|
root = tmp_path / "root"
|
|
(root / "logs").mkdir(parents=True)
|
|
b_id, a_id = "b" * 32, "a" * 32
|
|
for profile, action_id, outcome in (("b", b_id, b_outcome), ("a", a_id, "success")):
|
|
if outcome:
|
|
monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / profile))
|
|
monkeypatch.setenv("HERMES_ACTION_ID", action_id)
|
|
update_receipt.begin_update_receipt()
|
|
update_receipt.finalize_update_receipt(outcome)
|
|
monkeypatch.delenv("HERMES_ACTION_ID")
|
|
monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "b"))
|
|
assert update_receipt.read_latest_receipt()["action_id"] == a_id
|
|
(tmp_path / "hermes-update.log").write_text(
|
|
f"=== hermes-update started 2026-08-17 11:19:34 {b_id} ===\n", encoding="utf-8")
|
|
(root / "logs" / "update.log").write_text(f"=== hermes-update completed {a_id} ===\n", encoding="utf-8")
|
|
monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", tmp_path)
|
|
for registry in ("_ACTION_PROCS", "_ACTION_RESULTS", "_ACTION_COMMANDS", "_ACTION_IDS"):
|
|
monkeypatch.setattr(_web_server_gateway, registry, {})
|
|
|
|
data = self.client.get("/api/actions/hermes-update/status?lines=2000").json()
|
|
|
|
assert data["exit_code"] == (0 if b_outcome == "success" else None)
|
|
assert "action_id" not in data # A's root completion marker is not B's
|
|
# The attached receipt names its writer: B's own when it exists, else A's (never B's).
|
|
assert data["receipt"]["action_id"] == (b_id if b_outcome else a_id)
|
|
assert data["receipt"]["outcome"] == (b_outcome or "success")
|
|
|
|
@pytest.mark.parametrize("debt", ["none", "followups", "user_action"])
|
|
def test_status_receipt_carries_owed_steps_of_a_committed_update(self, monkeypatch, tmp_path, debt):
|
|
# C3: owed post-commit steps keep the run a success (exit 0); the summary must still name
|
|
# them so the dashboard and Desktop report the debt instead of plain success.
|
|
from hermes_cli import update_receipt
|
|
|
|
root, action_id = tmp_path / "root", "c" * 32
|
|
monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "coder"))
|
|
monkeypatch.setenv("HERMES_ACTION_ID", action_id)
|
|
update_receipt.begin_update_receipt()
|
|
if debt == "followups":
|
|
update_receipt.record_followup("dependencies", "synthetic selected-interpreter sync failed")
|
|
update_receipt.record_followup("config_migration", "disk full")
|
|
elif debt == "user_action":
|
|
update_receipt.record_user_action("autostash", "re-apply the parked stash")
|
|
update_receipt.finalize_update_receipt("success")
|
|
(tmp_path / "hermes-update.log").write_text(
|
|
f"=== hermes-update started 2026-08-17 11:19:34 {action_id} ===\n", encoding="utf-8")
|
|
monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", tmp_path)
|
|
for registry in ("_ACTION_PROCS", "_ACTION_RESULTS", "_ACTION_COMMANDS", "_ACTION_IDS"):
|
|
monkeypatch.setattr(_web_server_gateway, registry, {})
|
|
|
|
data = self.client.get("/api/actions/hermes-update/status?lines=2000").json()
|
|
|
|
receipt = data["receipt"]
|
|
assert [f["step"] for f in receipt["followups"]] == (
|
|
["dependencies", "config_migration"] if debt == "followups" else [])
|
|
assert receipt["user_action"] == (
|
|
{"step": "autostash", "reason": "re-apply the parked stash"} if debt == "user_action" else None)
|
|
# Debt never changes the C3 exit mapping: owed follow-ups stay exit 0, a user action is partial.
|
|
assert data["exit_code"] == (1 if debt == "user_action" else 0)
|