feat: Workshop state heartbeat for presence.json (#377)

Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit is contained in:
2026-03-18 22:07:32 -04:00
committed by hermes
parent c1f939ef22
commit 864be20dde
3 changed files with 342 additions and 0 deletions

View File

@@ -373,6 +373,12 @@ async def lifespan(app: FastAPI):
except Exception as exc:
logger.debug("Vault size check skipped: %s", exc)
# Start Workshop presence heartbeat
from timmy.workshop_state import WorkshopHeartbeat
workshop_heartbeat = WorkshopHeartbeat()
await workshop_heartbeat.start()
# Start chat integrations in background
chat_task = asyncio.create_task(_start_chat_integrations_background())
@@ -404,6 +410,8 @@ async def lifespan(app: FastAPI):
except Exception as exc:
logger.debug("MCP shutdown: %s", exc)
await workshop_heartbeat.stop()
for task in [briefing_task, thinking_task, chat_task, loop_qa_task]:
if task:
task.cancel()

155
src/timmy/workshop_state.py Normal file
View File

@@ -0,0 +1,155 @@
"""Workshop presence heartbeat — periodic writer for ``~/.timmy/presence.json``.
Maintains Timmy's observable presence state for the Workshop 3D renderer.
Writes the presence file every 30 seconds (or on cognitive state change),
skipping writes when state is unchanged.
See ADR-023 for the schema contract.
"""
import asyncio
import hashlib
import json
import logging
from datetime import UTC, datetime
from pathlib import Path
logger = logging.getLogger(__name__)
PRESENCE_FILE = Path.home() / ".timmy" / "presence.json"
HEARTBEAT_INTERVAL = 30 # seconds
# Cognitive mood → presence mood mapping
_MOOD_MAP = {
"curious": "exploring",
"settled": "focused",
"hesitant": "uncertain",
"energized": "excited",
}
def get_state_dict() -> dict:
"""Build presence state dict from current cognitive state.
Returns a v1 presence schema dict suitable for JSON serialisation.
"""
from timmy.cognitive_state import cognitive_tracker
state = cognitive_tracker.get_state()
# Map cognitive engagement to presence mood fallback
mood = _MOOD_MAP.get(state.mood, "focused")
if state.engagement == "idle" and state.mood == "settled":
mood = "idle"
# Build active threads from commitments
threads = []
for commitment in state.active_commitments[:10]:
threads.append({"type": "thinking", "ref": commitment[:80], "status": "active"})
return {
"version": 1,
"liveness": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
"current_focus": state.focus_topic or "",
"active_threads": threads,
"recent_events": [],
"concerns": [],
"mood": mood,
}
def write_state(state_dict: dict | None = None, path: Path | None = None) -> None:
"""Write presence state to ``~/.timmy/presence.json``.
Gracefully degrades if the file cannot be written.
"""
if state_dict is None:
state_dict = get_state_dict()
target = path or PRESENCE_FILE
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(state_dict, indent=2) + "\n")
except OSError as exc:
logger.warning("Failed to write presence state: %s", exc)
def _state_hash(state_dict: dict) -> str:
"""Compute hash of state dict, ignoring liveness timestamp."""
stable = {k: v for k, v in state_dict.items() if k != "liveness"}
return hashlib.md5(json.dumps(stable, sort_keys=True).encode()).hexdigest()
class WorkshopHeartbeat:
"""Async background task that keeps ``presence.json`` fresh.
- Writes every ``interval`` seconds (default 30).
- Reacts to cognitive state changes via sensory bus.
- Skips write if state hasn't changed (hash comparison).
"""
def __init__(
self,
interval: int = HEARTBEAT_INTERVAL,
path: Path | None = None,
) -> None:
self._interval = interval
self._path = path or PRESENCE_FILE
self._last_hash: str | None = None
self._task: asyncio.Task | None = None
self._trigger = asyncio.Event()
async def start(self) -> None:
"""Start the heartbeat background loop."""
self._subscribe_to_events()
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
"""Cancel the heartbeat task gracefully."""
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
def notify(self) -> None:
"""Signal an immediate state write (e.g. on cognitive state change)."""
self._trigger.set()
async def _run(self) -> None:
"""Main loop: write state on interval or trigger."""
await asyncio.sleep(1) # Initial stagger
while True:
try:
# Wait for interval OR early trigger
try:
await asyncio.wait_for(self._trigger.wait(), timeout=self._interval)
self._trigger.clear()
except TimeoutError:
pass # Normal periodic tick
self._write_if_changed()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.error("Workshop heartbeat error: %s", exc)
def _write_if_changed(self) -> None:
"""Build state, compare hash, write only if changed."""
state_dict = get_state_dict()
current_hash = _state_hash(state_dict)
if current_hash == self._last_hash:
return
self._last_hash = current_hash
write_state(state_dict, self._path)
def _subscribe_to_events(self) -> None:
"""Subscribe to cognitive state change events on the sensory bus."""
try:
from timmy.event_bus import get_sensory_bus
bus = get_sensory_bus()
bus.subscribe("cognitive_state_changed", lambda _: self.notify())
except Exception as exc:
logger.debug("Heartbeat event subscription skipped: %s", exc)

View File

@@ -0,0 +1,179 @@
"""Tests for Workshop presence heartbeat."""
import json
from unittest.mock import patch
import pytest
from timmy.workshop_state import (
WorkshopHeartbeat,
_state_hash,
get_state_dict,
write_state,
)
# ---------------------------------------------------------------------------
# get_state_dict
# ---------------------------------------------------------------------------
def test_get_state_dict_returns_v1_schema():
state = get_state_dict()
assert state["version"] == 1
assert "liveness" in state
assert "current_focus" in state
assert "mood" in state
assert isinstance(state["active_threads"], list)
assert isinstance(state["recent_events"], list)
assert isinstance(state["concerns"], list)
def test_get_state_dict_idle_mood():
"""Idle engagement + settled mood → 'idle' presence mood."""
from timmy.cognitive_state import CognitiveState, CognitiveTracker
tracker = CognitiveTracker.__new__(CognitiveTracker)
tracker.state = CognitiveState(engagement="idle", mood="settled")
with patch("timmy.cognitive_state.cognitive_tracker", tracker):
state = get_state_dict()
assert state["mood"] == "idle"
def test_get_state_dict_maps_mood():
"""Cognitive moods map to presence moods."""
from timmy.cognitive_state import CognitiveState, CognitiveTracker
for cog_mood, expected in [
("curious", "exploring"),
("hesitant", "uncertain"),
("energized", "excited"),
]:
tracker = CognitiveTracker.__new__(CognitiveTracker)
tracker.state = CognitiveState(engagement="deep", mood=cog_mood)
with patch("timmy.cognitive_state.cognitive_tracker", tracker):
state = get_state_dict()
assert state["mood"] == expected, f"Expected {expected} for {cog_mood}"
# ---------------------------------------------------------------------------
# write_state
# ---------------------------------------------------------------------------
def test_write_state_creates_file(tmp_path):
target = tmp_path / "presence.json"
state = {"version": 1, "liveness": "2026-01-01T00:00:00Z", "current_focus": ""}
write_state(state, path=target)
assert target.exists()
data = json.loads(target.read_text())
assert data["version"] == 1
def test_write_state_creates_parent_dirs(tmp_path):
target = tmp_path / "deep" / "nested" / "presence.json"
write_state({"version": 1}, path=target)
assert target.exists()
# ---------------------------------------------------------------------------
# _state_hash
# ---------------------------------------------------------------------------
def test_state_hash_ignores_liveness():
a = {"version": 1, "mood": "focused", "liveness": "2026-01-01T00:00:00Z"}
b = {"version": 1, "mood": "focused", "liveness": "2026-12-31T23:59:59Z"}
assert _state_hash(a) == _state_hash(b)
def test_state_hash_detects_mood_change():
a = {"version": 1, "mood": "focused", "liveness": "t1"}
b = {"version": 1, "mood": "idle", "liveness": "t1"}
assert _state_hash(a) != _state_hash(b)
# ---------------------------------------------------------------------------
# WorkshopHeartbeat — _write_if_changed
# ---------------------------------------------------------------------------
def test_write_if_changed_writes_on_first_call(tmp_path):
target = tmp_path / "presence.json"
hb = WorkshopHeartbeat(interval=60, path=target)
with patch("timmy.workshop_state.get_state_dict") as mock_state:
mock_state.return_value = {
"version": 1,
"liveness": "t1",
"current_focus": "testing",
"mood": "focused",
}
hb._write_if_changed()
assert target.exists()
data = json.loads(target.read_text())
assert data["version"] == 1
assert data["current_focus"] == "testing"
def test_write_if_changed_skips_when_unchanged(tmp_path):
target = tmp_path / "presence.json"
hb = WorkshopHeartbeat(interval=60, path=target)
fixed_state = {"version": 1, "liveness": "t1", "mood": "idle"}
with patch("timmy.workshop_state.get_state_dict", return_value=fixed_state):
hb._write_if_changed() # First write
target.write_text("") # Clear to detect if second write happens
hb._write_if_changed() # Should skip — state unchanged
# File should still be empty (second write was skipped)
assert target.read_text() == ""
def test_write_if_changed_writes_on_state_change(tmp_path):
target = tmp_path / "presence.json"
hb = WorkshopHeartbeat(interval=60, path=target)
state_a = {"version": 1, "liveness": "t1", "mood": "idle"}
state_b = {"version": 1, "liveness": "t2", "mood": "focused"}
with patch("timmy.workshop_state.get_state_dict", return_value=state_a):
hb._write_if_changed()
with patch("timmy.workshop_state.get_state_dict", return_value=state_b):
hb._write_if_changed()
data = json.loads(target.read_text())
assert data["mood"] == "focused"
# ---------------------------------------------------------------------------
# WorkshopHeartbeat — notify
# ---------------------------------------------------------------------------
def test_notify_sets_trigger():
hb = WorkshopHeartbeat(interval=60)
assert not hb._trigger.is_set()
hb.notify()
assert hb._trigger.is_set()
# ---------------------------------------------------------------------------
# WorkshopHeartbeat — start/stop lifecycle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_heartbeat_start_stop_lifecycle(tmp_path):
target = tmp_path / "presence.json"
hb = WorkshopHeartbeat(interval=60, path=target)
with patch("timmy.workshop_state.get_state_dict", return_value={"version": 1}):
await hb.start()
assert hb._task is not None
assert not hb._task.done()
await hb.stop()
assert hb._task is None