This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/src/dashboard/app.py
Alexander Whitestone 6eefcabc97 feat: Phase 1 autonomy upgrades — introspection, heartbeat, source tagging, Discord auto-detect (#101)
UC-01: Live System Introspection Tool
- Add get_task_queue_status(), get_agent_roster(), get_live_system_status()
  to timmy/tools_intro with graceful degradation
- Enhanced get_memory_status() with line counts, section headers, vault
  directory listing, semantic memory row count, self-coding journal stats
- Register system_status MCP tool (creative/tools/system_status.py)
- Add system_status to Timmy's tool list + Hard Rule #7

UC-02: Fix Offline Status Bug
- Add registry.heartbeat() calls in task_processor run_loop() and
  process_single_task() so health endpoint reflects actual agent status
- health.py now consults swarm registry instead of Ollama connectivity

UC-03: Message Source Tagging
- Add source field to Message dataclass (default "browser")
- Tag all message_log.append() calls: browser, api, system
- Include source in /api/chat/history response

UC-04: Discord Token Auto-Detection & Docker Fix
- Add _discord_token_watcher() background coroutine that polls every 30s
  for DISCORD_TOKEN in env vars, .env file, or state file
- Add --extras discord to all three Dockerfiles (main, dashboard, test)

All 26 Phase 1 tests pass in Docker (make test-docker).
Full suite: 1889 passed, 77 skipped, 0 failed.

Co-authored-by: Alexander Payne <apayne@MM.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:49:24 -05:00

690 lines
25 KiB
Python

"""Optimized dashboard app with improved async handling and non-blocking startup.
Key improvements:
1. Background tasks use asyncio.create_task() to avoid blocking startup
2. Persona spawning is moved to a background task
3. MCP bootstrap is non-blocking
4. Chat integrations start in background
5. All startup operations complete quickly
"""
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from config import settings
from dashboard.routes.agents import router as agents_router
from dashboard.routes.health import router as health_router
from dashboard.routes.swarm import router as swarm_router
from dashboard.routes.swarm import internal_router as swarm_internal_router
from dashboard.routes.marketplace import router as marketplace_router
from dashboard.routes.voice import router as voice_router
from dashboard.routes.mobile import router as mobile_router
from dashboard.routes.briefing import router as briefing_router
from dashboard.routes.telegram import router as telegram_router
from dashboard.routes.tools import router as tools_router
from dashboard.routes.spark import router as spark_router
from dashboard.routes.creative import router as creative_router
from dashboard.routes.discord import router as discord_router
from dashboard.routes.events import router as events_router
from dashboard.routes.ledger import router as ledger_router
from dashboard.routes.memory import router as memory_router
from dashboard.routes.router import router as router_status_router
from dashboard.routes.upgrades import router as upgrades_router
from dashboard.routes.work_orders import router as work_orders_router
from dashboard.routes.tasks import router as tasks_router
from dashboard.routes.scripture import router as scripture_router
from dashboard.routes.self_coding import router as self_coding_router
from dashboard.routes.self_coding import self_modify_router
from dashboard.routes.hands import router as hands_router
from dashboard.routes.grok import router as grok_router
from dashboard.routes.models import router as models_router
from dashboard.routes.models import api_router as models_api_router
from dashboard.routes.chat_api import router as chat_api_router
from dashboard.routes.thinking import router as thinking_router
from dashboard.routes.bugs import router as bugs_router
from infrastructure.router.api import router as cascade_router
def _configure_logging() -> None:
"""Configure logging with console and optional rotating file handler."""
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
console = logging.StreamHandler()
console.setLevel(logging.INFO)
console.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s%(message)s",
datefmt="%H:%M:%S",
)
)
root_logger.addHandler(console)
if settings.error_log_enabled:
from logging.handlers import RotatingFileHandler
log_dir = Path(settings.repo_root) / settings.error_log_dir
log_dir.mkdir(parents=True, exist_ok=True)
error_file = log_dir / "errors.log"
file_handler = RotatingFileHandler(
error_file,
maxBytes=settings.error_log_max_bytes,
backupCount=settings.error_log_backup_count,
)
file_handler.setLevel(logging.ERROR)
file_handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s%(message)s\n"
" File: %(pathname)s:%(lineno)d\n"
" Function: %(funcName)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root_logger.addHandler(file_handler)
_configure_logging()
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent
PROJECT_ROOT = BASE_DIR.parent.parent
_BRIEFING_INTERVAL_HOURS = 6
async def _briefing_scheduler() -> None:
"""Background task: regenerate Timmy's briefing every 6 hours."""
from timmy.briefing import engine as briefing_engine
from infrastructure.notifications.push import notify_briefing_ready
await asyncio.sleep(2)
while True:
try:
if briefing_engine.needs_refresh():
logger.info("Generating morning briefing…")
briefing = briefing_engine.generate()
await notify_briefing_ready(briefing)
else:
logger.info("Briefing is fresh; skipping generation.")
except Exception as exc:
logger.error("Briefing scheduler error: %s", exc)
try:
from infrastructure.error_capture import capture_error
capture_error(exc, source="briefing_scheduler")
except Exception:
pass
await asyncio.sleep(_BRIEFING_INTERVAL_HOURS * 3600)
async def _thinking_loop() -> None:
"""Background task: Timmy's default thinking thread."""
from swarm.task_queue.models import create_task
from datetime import datetime
await asyncio.sleep(10)
while True:
try:
now = datetime.now()
create_task(
title=f"Thought: {now.strftime('%A %B %d, %I:%M %p')}",
description="Continue thinking about your existence, recent events, scripture, creative ideas, or a previous thread of thought.",
assigned_to="timmy",
created_by="timmy",
priority="low",
requires_approval=False,
auto_approve=True,
task_type="thought",
)
logger.debug("Created thought task in queue")
except Exception as exc:
logger.error("Thinking loop error: %s", exc)
try:
from infrastructure.error_capture import capture_error
capture_error(exc, source="thinking_loop")
except Exception:
pass
await asyncio.sleep(settings.thinking_interval_seconds)
def handle_bug_report(task):
"""Process a bug report: log the decision and dispatch a fix task to Forge.
Timmy receives the bug report, decides it needs fixing, and creates
a code_fix task assigned to Forge. Every decision is logged to the
event log so there is a full audit trail of what Timmy decided and why.
"""
from swarm.event_log import EventType, log_event
from swarm.task_queue.models import create_task
decision = {
"action": "dispatch_to_forge",
"reason": f"Bug report received, dispatching fix to Forge: {task.title}",
"priority": task.priority.value,
"source_task_id": task.id,
}
# Dispatch a fix task to Forge
try:
fix_task = create_task(
title=f"[Fix] {task.title}",
description=(
f"## Bug Report\n\n{task.description or task.title}\n\n"
f"## Task\n\nImplement a fix for this bug and write a test proving the fix."
),
assigned_to="forge",
created_by="timmy",
priority=task.priority.value,
task_type="code_fix",
requires_approval=False,
auto_approve=True,
parent_task_id=task.id,
)
decision["outcome"] = "fix_dispatched"
decision["fix_task_id"] = fix_task.id
except Exception as e:
decision["outcome"] = "dispatch_failed"
decision["error"] = str(e)
# Log the decision trail to the event log
try:
log_event(
EventType.BUG_REPORT_CREATED,
source="bug_report_handler",
task_id=task.id,
agent_id="timmy",
data=decision,
)
except Exception:
pass
# Return structured result (stored in task.result)
if decision.get("fix_task_id"):
return (
f"Fix dispatched to Forge (task {decision['fix_task_id']}) | "
f"Decision: {decision['reason']}"
)
return (
f"Bug tracked internally (dispatch failed) | "
f"Decision: {decision['reason']} | Error: {decision.get('error', 'unknown')}"
)
async def _task_processor_loop() -> None:
"""Background task: Timmy's task queue processor."""
from swarm.task_processor import task_processor
from swarm.task_queue.models import update_task_status, list_tasks, TaskStatus
from timmy.session import chat as timmy_chat
from datetime import datetime
import json
await asyncio.sleep(5)
def handle_chat_response(task):
try:
now = datetime.now()
context = f"[System: Current date/time is {now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n\n"
response = timmy_chat(context + task.description)
# Log the real agent response to chat history
try:
from dashboard.store import message_log
timestamp = now.strftime("%H:%M:%S")
message_log.append(role="agent", content=response, timestamp=timestamp, source="system")
except Exception as e:
logger.debug("Failed to log response to message_log: %s", e)
# Push response to chat UI via WebSocket
try:
from infrastructure.ws_manager.handler import ws_manager
asyncio.create_task(
ws_manager.broadcast(
"timmy_response",
{
"task_id": task.id,
"response": response,
},
)
)
except Exception as e:
logger.debug("Failed to push response via WS: %s", e)
return response
except Exception as e:
logger.error("Chat response failed: %s", e)
try:
from infrastructure.error_capture import capture_error
capture_error(e, source="chat_response_handler")
except Exception:
pass
return f"Error: {str(e)}"
def handle_thought(task):
from timmy.thinking import thinking_engine
try:
loop = asyncio.get_event_loop()
future = asyncio.run_coroutine_threadsafe(
thinking_engine.think_once(), loop
)
result = future.result(timeout=120)
return str(result) if result else "Thought completed"
except Exception as e:
logger.error("Thought processing failed: %s", e)
try:
from infrastructure.error_capture import capture_error
capture_error(e, source="thought_handler")
except Exception:
pass
return f"Error: {str(e)}"
def handle_task_request(task):
try:
now = datetime.now()
context = (
f"[System: Current date/time is {now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
f"[System: You have been assigned a task from the queue. "
f"Complete it and provide your response.]\n\n"
f"Task: {task.title}\n"
)
if task.description and task.description != task.title:
context += f"Details: {task.description}\n"
response = timmy_chat(context)
try:
from infrastructure.ws_manager.handler import ws_manager
asyncio.create_task(
ws_manager.broadcast(
"task_response",
{
"task_id": task.id,
"response": response,
},
)
)
except Exception as e:
logger.debug("Failed to push task response via WS: %s", e)
return response
except Exception as e:
logger.error("Task request processing failed: %s", e)
try:
from infrastructure.error_capture import capture_error
capture_error(e, source="task_request_handler")
except Exception:
pass
return f"Error: {str(e)}"
# Register handlers for all known task types
task_processor.register_handler("chat_response", handle_chat_response)
task_processor.register_handler("thought", handle_thought)
task_processor.register_handler("internal", handle_thought)
task_processor.register_handler("bug_report", handle_bug_report)
task_processor.register_handler("task_request", handle_task_request)
task_processor.register_handler("escalation", handle_task_request)
task_processor.register_handler("external", handle_task_request)
# ── Reconcile zombie tasks from previous crash ──
zombie_count = task_processor.reconcile_zombie_tasks()
if zombie_count:
logger.info("Recycled %d zombie task(s) back to approved", zombie_count)
# ── Re-approve tasks backlogged due to missing handlers ──
stale = list_tasks(status=TaskStatus.BACKLOGGED, assigned_to="timmy")
requeued = 0
for t in stale:
if t.backlog_reason and "No handler for task type" in t.backlog_reason:
update_task_status(t.id, TaskStatus.APPROVED, result=None)
requeued += 1
if requeued:
logger.info("Re-queued %d task(s) that were backlogged due to missing handlers", requeued)
# ── Startup drain: iterate through all pending tasks immediately ──
logger.info("Draining task queue on startup...")
try:
summary = await task_processor.drain_queue()
if summary["processed"] or summary["backlogged"]:
logger.info(
"Startup drain: %d processed, %d backlogged, %d skipped, %d failed",
summary["processed"],
summary["backlogged"],
summary["skipped"],
summary["failed"],
)
# Notify via WebSocket so the dashboard updates
try:
from infrastructure.ws_manager.handler import ws_manager
asyncio.create_task(
ws_manager.broadcast_json(
{
"type": "task_event",
"event": "startup_drain_complete",
"summary": summary,
}
)
)
except Exception:
pass
except Exception as exc:
logger.error("Startup drain failed: %s", exc)
try:
from infrastructure.error_capture import capture_error
capture_error(exc, source="task_processor_startup")
except Exception:
pass
# ── Steady-state: poll for new tasks ──
logger.info("Task processor entering steady-state loop")
await task_processor.run_loop(interval_seconds=3.0)
async def _spawn_persona_agents_background() -> None:
"""Background task: spawn persona agents without blocking startup."""
from swarm.coordinator import coordinator as swarm_coordinator
await asyncio.sleep(1) # Let server fully start
if os.environ.get("TIMMY_TEST_MODE") != "1":
logger.info("Auto-spawning persona agents: Echo, Forge, Seer...")
try:
swarm_coordinator.spawn_persona("echo", agent_id="persona-echo")
swarm_coordinator.spawn_persona("forge", agent_id="persona-forge")
swarm_coordinator.spawn_persona("seer", agent_id="persona-seer")
logger.info("Persona agents spawned successfully")
except Exception as exc:
logger.error("Failed to spawn persona agents: %s", exc)
async def _bootstrap_mcp_background() -> None:
"""Background task: bootstrap MCP tools without blocking startup."""
from mcp.bootstrap import auto_bootstrap
await asyncio.sleep(0.5) # Let server start
try:
registered = auto_bootstrap()
if registered:
logger.info("MCP auto-bootstrap: %d tools registered", len(registered))
except Exception as exc:
logger.warning("MCP auto-bootstrap failed: %s", exc)
async def _start_chat_integrations_background() -> None:
"""Background task: start chat integrations without blocking startup."""
from integrations.telegram_bot.bot import telegram_bot
from integrations.chat_bridge.vendors.discord import discord_bot
from integrations.chat_bridge.registry import platform_registry
await asyncio.sleep(0.5)
# Register Discord in the platform registry
platform_registry.register(discord_bot)
if settings.telegram_token:
try:
await telegram_bot.start()
logger.info("Telegram bot started")
except Exception as exc:
logger.warning("Failed to start Telegram bot: %s", exc)
else:
logger.debug("Telegram: no token configured, skipping")
if settings.discord_token or discord_bot.load_token():
try:
await discord_bot.start()
logger.info("Discord bot started")
except Exception as exc:
logger.warning("Failed to start Discord bot: %s", exc)
else:
logger.debug("Discord: no token configured, skipping")
# If Discord isn't connected yet, start a watcher that polls for the
# token to appear in the environment or .env file.
if discord_bot.state.name != "CONNECTED":
asyncio.create_task(_discord_token_watcher())
async def _discord_token_watcher() -> None:
"""Poll for DISCORD_TOKEN appearing in env or .env and auto-start Discord bot."""
from integrations.chat_bridge.vendors.discord import discord_bot
while True:
await asyncio.sleep(30)
if discord_bot.state.name == "CONNECTED":
return # Already running — stop watching
# 1. Check live environment variable
token = os.environ.get("DISCORD_TOKEN", "")
# 2. Re-read .env file for hot-reload
if not token:
try:
from dotenv import dotenv_values
env_path = Path(settings.repo_root) / ".env"
if env_path.exists():
vals = dotenv_values(env_path)
token = vals.get("DISCORD_TOKEN", "")
except ImportError:
pass # python-dotenv not installed
# 3. Check state file (written by /discord/setup)
if not token:
token = discord_bot.load_token() or ""
if token:
try:
success = await discord_bot.start(token=token)
if success:
logger.info("Discord bot auto-started (token detected)")
return # Done — stop watching
except Exception as exc:
logger.warning("Discord auto-start failed: %s", exc)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager with non-blocking startup."""
# Create all background tasks without waiting for them
briefing_task = asyncio.create_task(_briefing_scheduler())
# Run swarm recovery first (offlines all stale agents)
from swarm.coordinator import coordinator as swarm_coordinator
swarm_coordinator.initialize()
rec = swarm_coordinator._recovery_summary
if rec["tasks_failed"] or rec["agents_offlined"]:
logger.info(
"Swarm recovery on startup: %d task(s) → FAILED, %d agent(s) → offline",
rec["tasks_failed"],
rec["agents_offlined"],
)
# Register Timmy AFTER recovery sweep so status sticks as "idle"
from swarm import registry as swarm_registry
swarm_registry.register(
name="Timmy",
capabilities="chat,reasoning,research,planning",
agent_id="timmy",
)
# Spawn persona agents in background
persona_task = asyncio.create_task(_spawn_persona_agents_background())
# Log system startup event
try:
from swarm.event_log import log_event, EventType
log_event(
EventType.SYSTEM_INFO,
source="coordinator",
data={"message": "Timmy Time system started"},
)
except Exception:
pass
# Bootstrap MCP tools in background
mcp_task = asyncio.create_task(_bootstrap_mcp_background())
# Register OpenFang vendor tools (if enabled)
if settings.openfang_enabled:
try:
from infrastructure.openfang.tools import register_openfang_tools
count = register_openfang_tools()
logger.info("OpenFang: registered %d vendor tools", count)
except Exception as exc:
logger.warning("OpenFang tool registration failed: %s", exc)
# Initialize Spark Intelligence engine
from spark.engine import spark_engine
if spark_engine.enabled:
logger.info("Spark Intelligence active — event capture enabled")
# Start thinking thread if enabled
thinking_task = None
if settings.thinking_enabled and os.environ.get("TIMMY_TEST_MODE") != "1":
thinking_task = asyncio.create_task(_thinking_loop())
logger.info(
"Default thinking thread started (interval: %ds)",
settings.thinking_interval_seconds,
)
# Start task processor if not in test mode
task_processor_task = None
if os.environ.get("TIMMY_TEST_MODE") != "1":
task_processor_task = asyncio.create_task(_task_processor_loop())
logger.info("Task queue processor started")
# Start chat integrations in background
chat_task = asyncio.create_task(_start_chat_integrations_background())
logger.info("✓ Timmy Time dashboard ready for requests")
yield
# Cleanup on shutdown
from integrations.telegram_bot.bot import telegram_bot
from integrations.chat_bridge.vendors.discord import discord_bot
await discord_bot.stop()
await telegram_bot.stop()
for task in [thinking_task, task_processor_task, briefing_task, persona_task, mcp_task, chat_task]:
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(
title="Timmy Time — Mission Control",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
openapi_url="/openapi.json",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files
static_dir = PROJECT_ROOT / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# Global templates instance
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
# Include routers
app.include_router(health_router)
app.include_router(agents_router)
app.include_router(swarm_router)
app.include_router(swarm_internal_router)
app.include_router(marketplace_router)
app.include_router(voice_router)
app.include_router(mobile_router)
app.include_router(briefing_router)
app.include_router(telegram_router)
app.include_router(tools_router)
app.include_router(spark_router)
app.include_router(creative_router)
app.include_router(discord_router)
app.include_router(events_router)
app.include_router(ledger_router)
app.include_router(memory_router)
app.include_router(router_status_router)
app.include_router(upgrades_router)
app.include_router(work_orders_router)
app.include_router(tasks_router)
app.include_router(scripture_router)
app.include_router(self_coding_router)
app.include_router(self_modify_router)
app.include_router(hands_router)
app.include_router(grok_router)
app.include_router(models_router)
app.include_router(models_api_router)
app.include_router(chat_api_router)
app.include_router(thinking_router)
app.include_router(bugs_router)
app.include_router(cascade_router)
@app.websocket("/ws")
async def ws_redirect(websocket: WebSocket):
"""Catch stale /ws connections and close cleanly.
Before PR #82, frontend code connected to /ws which never existed as
an endpoint. Stale browser tabs retry forever, spamming 403 errors.
Accept the connection and immediately close with a policy-violation
code so the client stops retrying.
websockets 16.0 dropped the legacy ``transfer_data_task`` attribute,
so calling ``websocket.close()`` after accept triggers an
AttributeError. Use the raw ASGI send instead.
"""
await websocket.accept()
try:
await websocket.close(code=1008, reason="Use /swarm/live instead")
except AttributeError:
# websockets >= 16.0 — close via raw ASGI message
await websocket.send({"type": "websocket.close", "code": 1008})
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Serve the main dashboard page."""
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/shortcuts/setup")
async def shortcuts_setup():
"""Siri Shortcuts setup guide."""
from integrations.shortcuts.siri import get_setup_guide
return get_setup_guide()