fix: watch presence.json and broadcast Timmy state changes via WS
Adds a background task that polls ~/.timmy/presence.json every 30s and broadcasts changes to WebSocket clients as "timmy_state" events. When the file is absent (Timmy's loop not running), a synthesised idle state is broadcast instead. Malformed JSON is logged and skipped gracefully. Fixes #375 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ Key improvements:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
@@ -189,6 +190,55 @@ async def _loop_qa_scheduler() -> None:
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
_PRESENCE_FILE = Path.home() / ".timmy" / "presence.json"
|
||||
_PRESENCE_POLL_SECONDS = 30
|
||||
_PRESENCE_INITIAL_DELAY = 3
|
||||
|
||||
_SYNTHESIZED_STATE: dict = {
|
||||
"version": 1,
|
||||
"liveness": None,
|
||||
"current_focus": "",
|
||||
"mood": "idle",
|
||||
"active_threads": [],
|
||||
"recent_events": [],
|
||||
"concerns": [],
|
||||
}
|
||||
|
||||
|
||||
async def _presence_watcher() -> None:
|
||||
"""Background task: watch ~/.timmy/presence.json and broadcast changes via WS.
|
||||
|
||||
Polls the file every 30 seconds (matching Timmy's write cadence).
|
||||
If the file doesn't exist, broadcasts a synthesised idle state.
|
||||
"""
|
||||
from infrastructure.ws_manager.handler import ws_manager as ws_mgr
|
||||
|
||||
await asyncio.sleep(_PRESENCE_INITIAL_DELAY) # Stagger after other schedulers
|
||||
|
||||
last_mtime: float = 0.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
if _PRESENCE_FILE.exists():
|
||||
mtime = _PRESENCE_FILE.stat().st_mtime
|
||||
if mtime != last_mtime:
|
||||
last_mtime = mtime
|
||||
raw = await asyncio.to_thread(_PRESENCE_FILE.read_text)
|
||||
state = json.loads(raw)
|
||||
await ws_mgr.broadcast("timmy_state", state)
|
||||
else:
|
||||
# File absent — broadcast synthesised state once per cycle
|
||||
if last_mtime != -1.0:
|
||||
last_mtime = -1.0
|
||||
await ws_mgr.broadcast("timmy_state", _SYNTHESIZED_STATE)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("presence.json parse error: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.warning("Presence watcher error: %s", exc)
|
||||
|
||||
await asyncio.sleep(_PRESENCE_POLL_SECONDS)
|
||||
|
||||
|
||||
async def _start_chat_integrations_background() -> None:
|
||||
"""Background task: start chat integrations without blocking startup."""
|
||||
from integrations.chat_bridge.registry import platform_registry
|
||||
@@ -297,6 +347,7 @@ async def lifespan(app: FastAPI):
|
||||
briefing_task = asyncio.create_task(_briefing_scheduler())
|
||||
thinking_task = asyncio.create_task(_thinking_scheduler())
|
||||
loop_qa_task = asyncio.create_task(_loop_qa_scheduler())
|
||||
presence_task = asyncio.create_task(_presence_watcher())
|
||||
|
||||
# Initialize Spark Intelligence engine
|
||||
from spark.engine import get_spark_engine
|
||||
@@ -413,7 +464,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
await workshop_heartbeat.stop()
|
||||
|
||||
for task in [briefing_task, thinking_task, chat_task, loop_qa_task]:
|
||||
for task in [briefing_task, thinking_task, chat_task, loop_qa_task, presence_task]:
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
|
||||
95
tests/integrations/test_presence_watcher.py
Normal file
95
tests/integrations/test_presence_watcher.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Tests for the presence file watcher in dashboard.app."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Common patches to eliminate delays and inject mock ws_manager
|
||||
_FAST = {
|
||||
"dashboard.app._PRESENCE_POLL_SECONDS": 0.01,
|
||||
"dashboard.app._PRESENCE_INITIAL_DELAY": 0,
|
||||
}
|
||||
|
||||
|
||||
def _patches(mock_ws, presence_file):
|
||||
"""Return a combined context manager for presence watcher patches."""
|
||||
from contextlib import ExitStack
|
||||
|
||||
stack = ExitStack()
|
||||
stack.enter_context(patch("dashboard.app._PRESENCE_FILE", presence_file))
|
||||
stack.enter_context(patch("infrastructure.ws_manager.handler.ws_manager", mock_ws))
|
||||
for key, val in _FAST.items():
|
||||
stack.enter_context(patch(key, val))
|
||||
return stack
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_presence_watcher_broadcasts_on_file_change(tmp_path):
|
||||
"""Watcher reads presence.json and broadcasts via ws_manager."""
|
||||
from dashboard.app import _presence_watcher
|
||||
|
||||
presence_file = tmp_path / "presence.json"
|
||||
state = {
|
||||
"version": 1,
|
||||
"liveness": "2026-03-18T21:47:12Z",
|
||||
"current_focus": "Reviewing PR #267",
|
||||
"mood": "focused",
|
||||
}
|
||||
presence_file.write_text(json.dumps(state))
|
||||
|
||||
mock_ws = AsyncMock()
|
||||
|
||||
with _patches(mock_ws, presence_file):
|
||||
task = asyncio.create_task(_presence_watcher())
|
||||
await asyncio.sleep(0.15)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
mock_ws.broadcast.assert_called_with("timmy_state", state)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_presence_watcher_synthesised_state_when_missing(tmp_path):
|
||||
"""Watcher broadcasts synthesised idle state when file is absent."""
|
||||
from dashboard.app import _SYNTHESIZED_STATE, _presence_watcher
|
||||
|
||||
missing_file = tmp_path / "no-such-file.json"
|
||||
mock_ws = AsyncMock()
|
||||
|
||||
with _patches(mock_ws, missing_file):
|
||||
task = asyncio.create_task(_presence_watcher())
|
||||
await asyncio.sleep(0.15)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
mock_ws.broadcast.assert_called_with("timmy_state", _SYNTHESIZED_STATE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_presence_watcher_handles_bad_json(tmp_path):
|
||||
"""Watcher logs warning on malformed JSON and doesn't crash."""
|
||||
from dashboard.app import _presence_watcher
|
||||
|
||||
presence_file = tmp_path / "presence.json"
|
||||
presence_file.write_text("{bad json!!!")
|
||||
mock_ws = AsyncMock()
|
||||
|
||||
with _patches(mock_ws, presence_file):
|
||||
task = asyncio.create_task(_presence_watcher())
|
||||
await asyncio.sleep(0.15)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Should not have broadcast anything on bad JSON
|
||||
mock_ws.broadcast.assert_not_called()
|
||||
Reference in New Issue
Block a user