1
0

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"
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")
# 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
# ── 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")
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
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"]]
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
retro_entry = {

View File

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

View File

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

View File

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

View File

@@ -10,10 +10,10 @@ from __future__ import annotations
from dataclasses import dataclass, field
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."""
SUCCESS = "success"

View File

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

View File

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

View File

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

View File

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