Compare commits

...

1 Commits

Author SHA1 Message Date
hermes
7f13f540b3 fix: loop_guard GITEA_API default + queue.json validation guard (#951, #952) 2026-03-22 13:51:59 -04:00
10 changed files with 61 additions and 44 deletions

View File

@@ -30,7 +30,7 @@ IDLE_STATE_FILE = REPO_ROOT / ".loop" / "idle_state.json"
CYCLE_RESULT_FILE = REPO_ROOT / ".loop" / "cycle_result.json" CYCLE_RESULT_FILE = REPO_ROOT / ".loop" / "cycle_result.json"
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token" TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1") GITEA_API = os.environ.get("GITEA_API", "http://143.198.27.163:3000/api/v1")
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard") REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
# Default cycle duration in seconds (5 min); stale threshold = 2× this # Default cycle duration in seconds (5 min); stale threshold = 2× this

View File

@@ -20,7 +20,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
# ── Config ────────────────────────────────────────────────────────────── # ── Config ──────────────────────────────────────────────────────────────
GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1") GITEA_API = os.environ.get("GITEA_API", "http://143.198.27.163:3000/api/v1")
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard") REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token" TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -327,7 +327,31 @@ def run_triage() -> list[dict]:
not_ready = [s for s in scored if not s["ready"]] not_ready = [s for s in scored if not s["ready"]]
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True) QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
QUEUE_FILE.write_text(json.dumps(ready, indent=2) + "\n") backup_file = QUEUE_FILE.with_suffix(".json.bak")
# Backup existing queue before overwriting
if QUEUE_FILE.exists():
try:
backup_file.write_text(QUEUE_FILE.read_text())
except OSError:
pass
# Write and validate
queue_json = json.dumps(ready, indent=2) + "\n"
QUEUE_FILE.write_text(queue_json)
# Validate by re-reading — restore backup on corruption
try:
validated = json.loads(QUEUE_FILE.read_text())
if not isinstance(validated, list):
raise ValueError("queue.json is not a list")
except (json.JSONDecodeError, ValueError) as e:
print(f"[triage] ERROR: queue.json validation failed: {e}", file=sys.stderr)
if backup_file.exists():
print("[triage] Restoring from backup", file=sys.stderr)
QUEUE_FILE.write_text(backup_file.read_text())
else:
QUEUE_FILE.write_text("[]\n")
# Write retro entry # Write retro entry
retro_entry = { retro_entry = {

View File

@@ -7,7 +7,7 @@ without a running game server.
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from infrastructure.world.interface import WorldInterface from infrastructure.world.interface import WorldInterface
@@ -81,9 +81,7 @@ class MockWorldAdapter(WorldInterface):
def act(self, command: CommandInput) -> ActionResult: def act(self, command: CommandInput) -> ActionResult:
logger.debug("MockWorldAdapter.act(%s)", command.action) logger.debug("MockWorldAdapter.act(%s)", command.action)
self.action_log.append( self.action_log.append(_ActionLog(command=command, timestamp=datetime.now(UTC)))
_ActionLog(command=command, timestamp=datetime.now(UTC))
)
return ActionResult( return ActionResult(
status=ActionStatus.SUCCESS, status=ActionStatus.SUCCESS,
message=f"Mock executed: {command.action}", message=f"Mock executed: {command.action}",
@@ -92,8 +90,10 @@ class MockWorldAdapter(WorldInterface):
def speak(self, message: str, target: str | None = None) -> None: def speak(self, message: str, target: str | None = None) -> None:
logger.debug("MockWorldAdapter.speak(%r, target=%r)", message, target) logger.debug("MockWorldAdapter.speak(%r, target=%r)", message, target)
self.speech_log.append({ self.speech_log.append(
"message": message, {
"target": target, "message": message,
"timestamp": datetime.now(UTC).isoformat(), "target": target,
}) "timestamp": datetime.now(UTC).isoformat(),
}
)

View File

@@ -35,14 +35,10 @@ class TES3MPWorldAdapter(WorldInterface):
# -- lifecycle --------------------------------------------------------- # -- lifecycle ---------------------------------------------------------
def connect(self) -> None: def connect(self) -> None:
raise NotImplementedError( raise NotImplementedError("TES3MPWorldAdapter.connect() — wire up TES3MP server socket")
"TES3MPWorldAdapter.connect() — wire up TES3MP server socket"
)
def disconnect(self) -> None: def disconnect(self) -> None:
raise NotImplementedError( raise NotImplementedError("TES3MPWorldAdapter.disconnect() — close TES3MP server socket")
"TES3MPWorldAdapter.disconnect() — close TES3MP server socket"
)
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
@@ -51,9 +47,7 @@ class TES3MPWorldAdapter(WorldInterface):
# -- core contract (stubs) --------------------------------------------- # -- core contract (stubs) ---------------------------------------------
def observe(self) -> PerceptionOutput: def observe(self) -> PerceptionOutput:
raise NotImplementedError( raise NotImplementedError("TES3MPWorldAdapter.observe() — poll TES3MP for player/NPC state")
"TES3MPWorldAdapter.observe() — poll TES3MP for player/NPC state"
)
def act(self, command: CommandInput) -> ActionResult: def act(self, command: CommandInput) -> ActionResult:
raise NotImplementedError( raise NotImplementedError(
@@ -61,6 +55,4 @@ class TES3MPWorldAdapter(WorldInterface):
) )
def speak(self, message: str, target: str | None = None) -> None: def speak(self, message: str, target: str | None = None) -> None:
raise NotImplementedError( raise NotImplementedError("TES3MPWorldAdapter.speak() — send chat message via TES3MP")
"TES3MPWorldAdapter.speak() — send chat message via TES3MP"
)

View File

@@ -27,14 +27,14 @@ class WorldInterface(ABC):
# -- lifecycle (optional overrides) ------------------------------------ # -- lifecycle (optional overrides) ------------------------------------
def connect(self) -> None: def connect(self) -> None: # noqa: B027
"""Establish connection to the game world. """Establish connection to the game world.
Default implementation is a no-op. Override to open sockets, Default implementation is a no-op. Override to open sockets,
authenticate, etc. authenticate, etc.
""" """
def disconnect(self) -> None: def disconnect(self) -> None: # noqa: B027
"""Tear down the connection. """Tear down the connection.
Default implementation is a no-op. Default implementation is a no-op.

View File

@@ -10,10 +10,10 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import Enum from enum import StrEnum
class ActionStatus(str, Enum): class ActionStatus(StrEnum):
"""Outcome of an action dispatched to the world.""" """Outcome of an action dispatched to the world."""
SUCCESS = "success" SUCCESS = "success"

View File

@@ -17,7 +17,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time import time
from dataclasses import asdict, dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from loop.phase1_gather import gather from loop.phase1_gather import gather
@@ -32,6 +32,7 @@ logger = logging.getLogger(__name__)
# Cycle log entry # Cycle log entry
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass @dataclass
class CycleRecord: class CycleRecord:
"""One observe → reason → act → reflect cycle.""" """One observe → reason → act → reflect cycle."""
@@ -50,6 +51,7 @@ class CycleRecord:
# Heartbeat # Heartbeat
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class Heartbeat: class Heartbeat:
"""Manages the recurring cognitive loop with optional world adapter. """Manages the recurring cognitive loop with optional world adapter.
@@ -268,14 +270,17 @@ class Heartbeat:
try: try:
from infrastructure.ws_manager.handler import ws_manager from infrastructure.ws_manager.handler import ws_manager
await ws_manager.broadcast("heartbeat.cycle", { await ws_manager.broadcast(
"cycle_id": record.cycle_id, "heartbeat.cycle",
"timestamp": record.timestamp, {
"action": record.action_taken, "cycle_id": record.cycle_id,
"action_status": record.action_status, "timestamp": record.timestamp,
"reasoning_summary": record.reasoning_summary[:300], "action": record.action_taken,
"observation": record.observation, "action_status": record.action_status,
"duration_ms": record.duration_ms, "reasoning_summary": record.reasoning_summary[:300],
}) "observation": record.observation,
"duration_ms": record.duration_ms,
},
)
except (ImportError, AttributeError, ConnectionError, RuntimeError) as exc: except (ImportError, AttributeError, ConnectionError, RuntimeError) as exc:
logger.debug("Heartbeat broadcast skipped: %s", exc) logger.debug("Heartbeat broadcast skipped: %s", exc)

View File

@@ -10,7 +10,6 @@ from infrastructure.world.types import (
PerceptionOutput, PerceptionOutput,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Type construction # Type construction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -3,7 +3,6 @@
import pytest import pytest
from infrastructure.world.adapters.mock import MockWorldAdapter from infrastructure.world.adapters.mock import MockWorldAdapter
from infrastructure.world.interface import WorldInterface
from infrastructure.world.registry import AdapterRegistry from infrastructure.world.registry import AdapterRegistry

View File

@@ -6,7 +6,6 @@ Acceptance criteria:
- WebSocket broadcasts include current action and reasoning summary - WebSocket broadcasts include current action and reasoning summary
""" """
import asyncio
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
@@ -81,6 +80,7 @@ class TestHeartbeatWithAdapter:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_on_cycle_callback(self, mock_adapter): async def test_on_cycle_callback(self, mock_adapter):
received = [] received = []
async def callback(record): async def callback(record):
received.append(record) received.append(record)
@@ -145,9 +145,7 @@ class TestHeartbeatBroadcast:
) as mock_ws: ) as mock_ws:
mock_ws.broadcast = AsyncMock() mock_ws.broadcast = AsyncMock()
# Patch the import inside heartbeat # Patch the import inside heartbeat
with patch( with patch("infrastructure.ws_manager.handler.ws_manager") as ws_mod:
"infrastructure.ws_manager.handler.ws_manager"
) as ws_mod:
ws_mod.broadcast = AsyncMock() ws_mod.broadcast = AsyncMock()
hb = Heartbeat(world=mock_adapter) hb = Heartbeat(world=mock_adapter)
await hb.run_once() await hb.run_once()