1
0

feat: automatic error feedback loop with bug report tracker (#80)

Errors and uncaught exceptions are now automatically captured, deduplicated,
persisted to a rotating log file, and filed as bug report tasks in the
existing task queue — giving Timmy a sovereign, local issue tracker with
zero new dependencies.

- Add RotatingFileHandler writing errors to logs/errors.log (5MB rotate, 5 backups)
- Add error capture module with stack-trace hashing and 5-min dedup window
- Add FastAPI exception middleware + global exception handler
- Instrument all background loops (briefing, thinking, task processor) with capture_error()
- Extend task queue with bug_report task type and auto-approve rule
- Fix auto-approve type matching (was ignoring task_type field entirely)
- Add /bugs dashboard page and /api/bugs JSON endpoints
- Add ERROR_CAPTURED and BUG_REPORT_CREATED event types for real-time feed
- Add BUGS nav link to desktop and mobile navigation
- Add 16 tests covering error capture, deduplication, and bug report routes

Co-authored-by: Alexander Payne <apayne@MM.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-02-27 19:51:37 -05:00
committed by GitHub
parent 6545b7e26a
commit aa3263bc3b
12 changed files with 765 additions and 6 deletions

View File

@@ -40,13 +40,51 @@ 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
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s%(message)s",
datefmt="%H:%M:%S",
)
def _configure_logging() -> None:
"""Configure logging with console and optional rotating file handler."""
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
# Console handler (existing behavior)
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)
# Rotating file handler for errors
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
@@ -77,6 +115,11 @@ async def _briefing_scheduler() -> None:
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)
@@ -110,6 +153,11 @@ async def _thinking_loop() -> None:
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)
@@ -156,6 +204,11 @@ async def _task_processor_loop() -> None:
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):
@@ -167,12 +220,22 @@ async def _task_processor_loop() -> None:
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_bug_report(task):
"""Handler for bug_report tasks - acknowledge and mark completed."""
return f"Bug report acknowledged: {task.title}"
# Register handlers
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)
# ── Startup drain: iterate through all pending tasks immediately ──
logger.info("Draining task queue on startup…")
@@ -204,6 +267,11 @@ async def _task_processor_loop() -> None:
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")
@@ -388,6 +456,55 @@ app.include_router(models_api_router)
app.include_router(chat_api_router)
app.include_router(thinking_router)
app.include_router(cascade_router)
app.include_router(bugs_router)
# ── Error capture middleware ──────────────────────────────────────────────
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request as StarletteRequest
from fastapi.responses import JSONResponse
class ErrorCaptureMiddleware(BaseHTTPMiddleware):
"""Catch unhandled exceptions and feed them into the error feedback loop."""
async def dispatch(self, request: StarletteRequest, call_next):
try:
return await call_next(request)
except Exception as exc:
logger.error(
"Unhandled exception on %s %s: %s",
request.method, request.url.path, exc,
)
try:
from infrastructure.error_capture import capture_error
capture_error(
exc,
source="http_middleware",
context={
"method": request.method,
"path": request.url.path,
"query": str(request.query_params),
},
)
except Exception:
pass # Never crash the middleware itself
raise # Re-raise so FastAPI's default handler returns 500
app.add_middleware(ErrorCaptureMiddleware)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Safety net for uncaught exceptions."""
logger.error("Unhandled exception: %s", exc, exc_info=True)
try:
from infrastructure.error_capture import capture_error
capture_error(exc, source="exception_handler", context={"path": str(request.url)})
except Exception:
pass
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.get("/", response_class=HTMLResponse)

View File

@@ -0,0 +1,86 @@
"""Bug Report routes -- error feedback loop dashboard.
GET /bugs -- Bug reports dashboard page
GET /api/bugs -- List bug reports (JSON)
GET /api/bugs/stats -- Bug report statistics
"""
import logging
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from swarm.task_queue.models import list_tasks
logger = logging.getLogger(__name__)
router = APIRouter(tags=["bugs"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
def _get_bug_reports(status: Optional[str] = None, limit: int = 50) -> list:
"""Get bug report tasks from the task queue."""
all_tasks = list_tasks(limit=limit)
bugs = [t for t in all_tasks if t.task_type == "bug_report"]
if status:
bugs = [t for t in bugs if t.status.value == status]
return bugs
@router.get("/bugs", response_class=HTMLResponse)
async def bugs_page(request: Request, status: Optional[str] = None):
"""Bug reports dashboard page."""
bugs = _get_bug_reports(status=status, limit=200)
# Count by status
all_bugs = _get_bug_reports(limit=500)
stats: dict[str, int] = {}
for bug in all_bugs:
s = bug.status.value
stats[s] = stats.get(s, 0) + 1
return templates.TemplateResponse(
request,
"bugs.html",
{
"page_title": "Bug Reports",
"bugs": bugs,
"stats": stats,
"total": len(all_bugs),
"filter_status": status,
},
)
@router.get("/api/bugs", response_class=JSONResponse)
async def api_list_bugs(status: Optional[str] = None, limit: int = 50):
"""List bug reports as JSON."""
bugs = _get_bug_reports(status=status, limit=limit)
return {
"bugs": [
{
"id": b.id,
"title": b.title,
"description": b.description,
"status": b.status.value,
"priority": b.priority.value,
"created_at": b.created_at,
"result": b.result,
}
for b in bugs
],
"count": len(bugs),
}
@router.get("/api/bugs/stats", response_class=JSONResponse)
async def api_bug_stats():
"""Bug report statistics."""
all_bugs = _get_bug_reports(limit=500)
stats: dict[str, int] = {}
for bug in all_bugs:
s = bug.status.value
stats[s] = stats.get(s, 0) + 1
return {"stats": stats, "total": len(all_bugs)}

View File

@@ -37,6 +37,7 @@
<a href="/marketplace/ui" class="mc-test-link">MARKET</a>
<a href="/tools" class="mc-test-link">TOOLS</a>
<a href="/swarm/events" class="mc-test-link">EVENTS</a>
<a href="/bugs" class="mc-test-link" style="color:#ff6b6b;">BUGS</a>
<a href="/lightning/ledger" class="mc-test-link">LEDGER</a>
<a href="/memory" class="mc-test-link">MEMORY</a>
<a href="/router/status" class="mc-test-link">ROUTER</a>
@@ -73,6 +74,7 @@
<a href="/marketplace/ui" class="mc-mobile-link">MARKET</a>
<a href="/tools" class="mc-mobile-link">TOOLS</a>
<a href="/swarm/events" class="mc-mobile-link">EVENTS</a>
<a href="/bugs" class="mc-mobile-link">BUGS</a>
<a href="/lightning/ledger" class="mc-mobile-link">LEDGER</a>
<a href="/memory" class="mc-mobile-link">MEMORY</a>
<a href="/work-orders/queue" class="mc-mobile-link">WORK ORDERS</a>

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Bug Reports — Timmy Time{% endblock %}
{% block content %}
<div class="mc-panel">
<div class="mc-panel-header">
<h1 class="page-title" style="color:#ff6b6b;">BUG REPORTS</h1>
<p class="mc-text-secondary">Automatic error feedback loop — errors are captured, deduped, and filed here.</p>
</div>
<!-- Stats -->
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px;">
<div class="mc-stat-card" style="min-width:80px;text-align:center;padding:8px 12px;">
<div style="font-size:1.4rem;font-weight:700;">{{ total }}</div>
<div style="font-size:0.65rem;opacity:0.7;">TOTAL</div>
</div>
{% for status_name, count in stats.items() %}
<div class="mc-stat-card" style="min-width:80px;text-align:center;padding:8px 12px;">
<div style="font-size:1.4rem;font-weight:700;">{{ count }}</div>
<div style="font-size:0.65rem;opacity:0.7;">{{ status_name | replace("_", " ") | upper }}</div>
</div>
{% endfor %}
</div>
<!-- Filter -->
<div style="margin-bottom:16px;">
<form method="get" action="/bugs" style="display:inline-flex;gap:8px;align-items:center;">
<label style="font-size:0.75rem;opacity:0.7;">Filter:</label>
<select name="status" class="mc-select" style="font-size:0.75rem;padding:4px 8px;background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:4px;" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="approved" {% if filter_status == 'approved' %}selected{% endif %}>Open</option>
<option value="completed" {% if filter_status == 'completed' %}selected{% endif %}>Resolved</option>
<option value="failed" {% if filter_status == 'failed' %}selected{% endif %}>Failed</option>
<option value="pending_approval" {% if filter_status == 'pending_approval' %}selected{% endif %}>Pending</option>
</select>
</form>
</div>
<!-- Bug list -->
{% if bugs %}
{% for bug in bugs %}
<div style="background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:6px;padding:12px;margin-bottom:8px;border-left:3px solid #ff6b6b;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div style="font-size:0.85rem;font-weight:500;flex:1;">{{ bug.title | e }}</div>
<div style="display:flex;gap:4px;flex-shrink:0;">
<span style="font-size:0.6rem;padding:2px 6px;border-radius:3px;background:{% if bug.status.value == 'completed' %}#22c55e{% elif bug.status.value == 'failed' %}#ef4444{% elif bug.status.value == 'approved' %}#3b82f6{% else %}#6b7280{% endif %};color:#fff;">{{ bug.status.value | replace("_"," ") | upper }}</span>
<span style="font-size:0.6rem;padding:2px 6px;border-radius:3px;background:{% if bug.priority.value == 'urgent' %}#ef4444{% elif bug.priority.value == 'high' %}#f59e0b{% else %}#374151{% endif %};color:#fff;">{{ bug.priority.value | upper }}</span>
</div>
</div>
{% if bug.description %}
<details style="margin-top:6px;">
<summary style="cursor:pointer;font-size:0.7rem;color:var(--text-secondary);">Stack trace &amp; details</summary>
<pre style="font-size:0.65rem;white-space:pre-wrap;word-break:break-all;max-height:300px;overflow:auto;margin-top:4px;padding:8px;background:var(--bg-tertiary,#111);border-radius:4px;border:1px solid var(--border-color);">{{ bug.description | e }}</pre>
</details>
{% endif %}
<div style="font-size:0.6rem;opacity:0.5;margin-top:4px;">{{ bug.created_at[:19].replace("T", " ") }} UTC</div>
</div>
{% endfor %}
{% else %}
<div style="text-align:center;padding:40px 20px;opacity:0.6;">
<p style="font-size:1.2rem;">No bug reports found.</p>
<p style="font-size:0.8rem;">The system is running clean.</p>
</div>
{% endif %}
</div>
{% endblock %}