fix: watch presence.json and broadcast Timmy state changes via WS
All checks were successful
Tests / lint (pull_request) Successful in 5s
Tests / test (pull_request) Successful in 1m18s

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:
kimi
2026-03-18 22:13:17 -04:00
committed by hermes
parent 3108971bd5
commit 92080bf845
2 changed files with 147 additions and 1 deletions

View File

@@ -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:

View 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()