From 92080bf8454e723a99bc31ebb4589d157fdec836 Mon Sep 17 00:00:00 2001 From: kimi Date: Wed, 18 Mar 2026 22:13:17 -0400 Subject: [PATCH] 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 --- src/dashboard/app.py | 53 +++++++++++- tests/integrations/test_presence_watcher.py | 95 +++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/integrations/test_presence_watcher.py diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 4c49eab6..0d153073 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -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: diff --git a/tests/integrations/test_presence_watcher.py b/tests/integrations/test_presence_watcher.py new file mode 100644 index 00000000..99db2202 --- /dev/null +++ b/tests/integrations/test_presence_watcher.py @@ -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()