"""Per-slot watchdog store for safety-sell (REQ-034 / BAT-010). WatchdogStore tracks the last time a snapshot RPC response included each holding slot. BAT-011 trigger loops use is_expired() to decide whether a slot is unmonitored (strategy module absent) and therefore eligible for autonomous safety-sell actions. Thread-safety: dict reads and writes in CPython are GIL-atomic for simple assignments, which is sufficient here — one writer (snapshot-rpc thread) and one or more readers (BAT-011 trigger-loop threads). No explicit lock is needed. """ from __future__ import annotations from datetime import datetime, timezone from typing import Optional class WatchdogStore: """In-memory per-slot last_seen_at tracker (REQ-034).""" def __init__(self) -> None: self._store: dict[str, datetime] = {} self._started_at: datetime = datetime.now(tz=timezone.utc) def update(self, slot_id: str) -> None: """Record that slot_id appeared in a snapshot RPC response.""" self._store[slot_id] = datetime.now(tz=timezone.utc) def last_seen(self, slot_id: str) -> Optional[datetime]: """Return the last time slot_id was seen, or None if never (dcy-017). Read-only accessor for watchdog-ownership visibility (REQ-034).""" return self._store.get(slot_id) def is_expired(self, slot_id: str, timeout_sec: int) -> bool: """Return True if slot_id has not been seen for longer than timeout_sec. A slot that has never been seen is treated as expired (returns True) unless in_grace_period() is True — callers must check grace period before calling this method when they want to suppress false positives. """ last = self._store.get(slot_id) if last is None: return True elapsed = (datetime.now(tz=timezone.utc) - last).total_seconds() return elapsed > timeout_sec def in_grace_period(self, grace_sec: int) -> bool: """Return True if the daemon started less than grace_sec seconds ago. During the grace period ALL expiry judgements must be deferred — no slot should be treated as watchdog-expired (REQ-034 AC-4). """ elapsed = (datetime.now(tz=timezone.utc) - self._started_at).total_seconds() return elapsed < grace_sec