Files
FabledScribe/src/fabledassistant/services/diagnostics.py
T
bvandeusen 91bafb641f refactor: Phase 8 — backend deletion (chat / voice / push / journal / curator)
Mega-commit. Strips all server-side LLM machinery now that Phase 7 has
removed the corresponding UI surfaces and the MCP HTTP endpoint is the
sole assistant interface.

Deleted (services/):
  chat, generation_buffer, generation_log, generation_task, llm, tools/
  (entire package), stt, tts, voice_config, voice_library, push,
  journal_closeout, journal_pipeline, journal_prep, journal_scheduler,
  journal_search, curator, curator_scheduler, consolidation,
  tag_suggestions, research, weather, article_fetcher, pending_actions,
  moments, assist, wikipedia.

Deleted (routes/):
  chat, voice, push, journal, quick_capture, fable_mcp_dist.

Deleted (models/):
  conversation, generation_tool_log, push_subscription,
  pending_curator_action, moment, weather_cache.

Deleted (tests/):
  test_generation_log, test_journal_*, test_consolidation, test_lookup_tool,
  test_notes_consolidation_trigger, test_record_moment_guards,
  test_research_pipeline, test_tools_*, test_tool_use_fixes,
  test_voice_library, test_weather_service, test_calendar_tool_tz,
  test_wikipedia.

Deleted (top-level):
  fable-mcp/ (legacy standalone stdio package — wheel-build pipeline
  also removed from Dockerfile).

app.py:
  - blueprint registrations for the 6 deleted routes
  - startup hook trimmed: no more Ollama warmup, KV-cache priming,
    journal/curator schedulers, voice model loading
  - shutdown hook simplified
  - httpx import dropped (was for Ollama calls)

pyproject.toml:
  - removed deps: pywebpush, feedparser, html2text, trafilatura
  - removed [voice] extras entirely
  - description updated for the MCP-first architecture

Dockerfile:
  - removed faster-whisper / piper-tts install steps
  - removed bundled piper voice download stage
  - removed fable-mcp wheel build stage

Surviving-file edits:
  - services/auth.py: drop Conversation table claim on first-user setup
  - services/backup.py: drop conversation / push-subscription export+restore;
    v1/v2 restore now silently skip pre-pivot conversation data
  - services/notes.py: drop maybe_consolidate trigger on task done/cancelled;
    drop _maybe_trigger_project_summary (LLM auto-summary)
  - services/projects.py: drop generate_project_summary + backfill_project_summaries
    (both LLM-driven)
  - services/user_profile.py: drop append_observations / consolidate /
    clear_learned_data (curator-tied) and build_profile_context
    (was LLM system-prompt builder)
  - services/notifications.py: stub out _fire_push_notif (was send_push_notification)
  - services/event_scheduler.py: drop event-reminder push + chat-retention
    cleanup job; keep CalDAV pull-sync + reminders job (in-app)
  - services/diagnostics.py: _curator_busy() always False
  - routes/notes.py: drop /assist, /assist/stream, /suggest-tags endpoints
  - routes/tasks.py: drop /<id>/consolidate endpoint
  - routes/settings.py: drop /models, KV-cache-prime-on-save, journal-schedule
    timezone hook, and the SearXNG search-test endpoint; inline _is_private_url
    (was in services/llm.py)
  - routes/admin.py: drop /voice, /voice/reload endpoints
  - routes/profile.py: drop /consolidate, /observations (GET, DELETE)
  - models/__init__.py: drop the 6 dead model imports

Frontend cascade:
  - stores/push.ts: deleted entirely (no callers after Phase 7)
  - stores/settings.ts: drop checkVoiceStatus + voice-status state
  - views/SettingsView.vue: drop Locations section + journalConfig state
    (was tied to /api/journal/config); drop JournalConfig + journal/voice
    api/client imports
  - frontend/api/client.ts: orphaned voice/journal/profile-observation/
    fable-mcp-dist exports are left as dead but harmless (call them and
    they 404; type-check is clean).

Pre-existing v1 backups that contained conversations/messages still
restore — those tables are silently dropped from the import path.
Anyone pulling the new image with a populated database will need the
Phase 9 migration to drop the dead tables (coming next).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:47:18 -04:00

444 lines
18 KiB
Python

"""Lightweight diagnostic instrumentation for crash investigation.
The Scribe app + its Postgres have been crashing recurrently with no
clear cause in the logs. This module adds three things designed to make
the crash class identifiable from logs alone:
1. **Heartbeat** — once per minute, log a snapshot of process resources
(RSS memory, asyncio task count, DB pool checked-in/out, curator
busy state). A sudden silence in heartbeats lets you bound the
crash time to within a minute, and the last snapshot before silence
usually rules in or out memory growth / pool exhaustion / hung
curator pass.
2. **Signal handler** — catches SIGTERM and SIGINT, logs them with the
sender's intent ("docker stop", "swarm restart", "manual ctrl-C")
then lets the normal shutdown proceed. Distinguishes orderly
shutdown from kill-9 / OOM-kill (which can't be caught and will
show as a silent log gap followed by container exit code 137).
3. **Asyncio exception hook** — every Task that raises an uncaught
exception logs a full traceback. Without this, `asyncio.create_task`
exceptions are swallowed silently — the chat crash that locked us
into 409 forever was exactly this pattern.
All three are read-only / log-only — no behavior changes. Safe to
leave running in production indefinitely; the cost is one log line
per minute and ~0.1ms of work per heartbeat.
"""
from __future__ import annotations
import asyncio
import datetime
import json
import logging
import os
import signal
import time
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Heartbeat cadence. 60s is the sweet spot: short enough that a crash
# window is bounded to a useful interval, long enough that the log
# noise is negligible. If we ever need more resolution during active
# debugging, drop it temporarily to 15s.
_HEARTBEAT_INTERVAL_SECS = 60
# Persistent diagnostic state — written to /data so it survives
# container restart, OOM-kill, and Docker log rotation. The point of
# the persistence: the operator may not notice a crash for hours, and
# by then the Docker logs are gone. /data/diagnostics gives them a
# durable place to look post-mortem.
_DIAG_DIR = Path("/data/diagnostics")
_CURRENT_STATE_PATH = _DIAG_DIR / "current.json"
_LAST_SHUTDOWN_PATH = _DIAG_DIR / "last_shutdown.json"
_LAST_EXCEPTION_PATH = _DIAG_DIR / "last_exception.json"
_PREVIOUS_RUN_PATH = _DIAG_DIR / "previous_run.json"
_DIAG_LOG_PATH = _DIAG_DIR / "diag.log"
# Dedicated file logger for the heartbeat/exception/signal stream.
# Separate from the stdout logger so it can't be lost by Docker log
# rotation. Rotates at 10 MB, keeps 5 backups → 50 MB max footprint.
_file_logger: logging.Logger | None = None
_heartbeat_task: asyncio.Task | None = None
_shutdown_logged = False # don't double-log shutdown if multiple signals arrive
_started_at: float | None = None
def _process_rss_mb() -> float | None:
"""Resident-set memory in MB. Read from /proc/self/status — no deps."""
try:
with open("/proc/self/status") as fh:
for line in fh:
if line.startswith("VmRSS:"):
# Format: 'VmRSS: 123456 kB'
kb = int(line.split()[1])
return round(kb / 1024, 1)
except Exception:
pass
return None
def _db_pool_stats() -> dict[str, Any]:
"""Pool checked-in / checked-out / overflow. Direct from SQLAlchemy."""
try:
from fabledassistant.models import engine
pool = engine.pool
# Async engines wrap a sync pool; .checkedin() / .checkedout() exist
# on the underlying QueuePool. Attribute access is documented but
# version-fragile, so wrap in try.
return {
"size": getattr(pool, "size", lambda: None)(),
"checked_in": getattr(pool, "checkedin", lambda: None)(),
"checked_out": getattr(pool, "checkedout", lambda: None)(),
"overflow": getattr(pool, "overflow", lambda: None)(),
}
except Exception:
return {}
def _asyncio_task_count() -> int | None:
try:
return len([t for t in asyncio.all_tasks() if not t.done()])
except Exception:
return None
def _curator_busy() -> bool | None:
# Curator was removed in Phase 8 of the MCP-first pivot. Always False.
return False
def _uptime_secs() -> float | None:
if _started_at is None:
return None
return round(time.monotonic() - _started_at, 1)
def _snapshot_dict(reason: str = "heartbeat") -> dict[str, Any]:
"""Bundle the current resource snapshot into a JSON-safe dict.
This is the canonical 'what was the app doing right now' record —
written to current.json every heartbeat, to last_shutdown.json on
signal, to last_exception.json on uncaught task exception. The
`reason` field tells you which event captured this snapshot.
"""
return {
"reason": reason,
"wall_time_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"uptime_secs": _uptime_secs(),
"rss_mb": _process_rss_mb(),
"asyncio_tasks": _asyncio_task_count(),
"db_pool": _db_pool_stats(),
"curator_busy": _curator_busy(),
"pid": os.getpid(),
}
def _write_state_atomic(path: Path, data: dict[str, Any]) -> None:
"""Write JSON to `path` via a tmp+rename so a crash mid-write can't
leave a half-written / unparseable file. The tmp file is on the same
filesystem as the target so the rename is atomic.
Silent on failure — diagnostic writes must never raise into the
caller. If /data isn't writable, the in-memory + stdout logging
still happens.
"""
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, default=str))
tmp.replace(path)
except Exception:
# Log to stdout — if this fires, /data is bad and the operator
# needs to know. Don't propagate; diagnostics must not crash
# the heartbeat that depends on them.
logger.exception("Failed to write diagnostic state to %s", path)
def _setup_file_logger() -> logging.Logger:
"""Create or return the dedicated rotating file logger for diagnostics.
Separate from the app's stdout logger so an aggressive log rotator
or Docker log cleanup can't take it out. Writes to /data/diagnostics/diag.log
with size-based rotation.
"""
global _file_logger
if _file_logger is not None:
return _file_logger
try:
_DIAG_DIR.mkdir(parents=True, exist_ok=True)
flog = logging.getLogger("fabledassistant.diagnostics.persistent")
flog.setLevel(logging.INFO)
flog.propagate = False # don't double-log into the root stdout stream
# Don't re-add handlers across module reloads / re-runs.
if not flog.handlers:
handler = RotatingFileHandler(
_DIAG_LOG_PATH,
maxBytes=10 * 1024 * 1024,
backupCount=5,
)
handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
flog.addHandler(handler)
_file_logger = flog
except Exception:
logger.exception("Failed to set up persistent diagnostic logger")
# Fall back to the regular stdout logger so callers don't need
# to null-check the return value.
_file_logger = logger
return _file_logger
def _persistent_log(level: int, msg: str, *args: Any) -> None:
"""Emit one line to BOTH the stdout logger AND the persistent file."""
logger.log(level, msg, *args)
try:
_setup_file_logger().log(level, msg, *args)
except Exception:
pass
def _post_mortem_previous_run() -> None:
"""On startup, check the persistent state and tell the operator if
the previous run died abruptly.
Logic: if current.json exists and is newer than last_shutdown.json,
then either there was never a clean shutdown (first ever run, or
SIGKILL/OOM/abrupt termination of the previous run). If current.json
exists AND last_shutdown.json doesn't, OR current.json's timestamp
is newer than last_shutdown.json's → the previous run didn't get
to write its shutdown record. Log the last-known state prominently
so the operator notices on next visit.
"""
try:
if not _CURRENT_STATE_PATH.exists():
return # first ever run
current_mtime = _CURRENT_STATE_PATH.stat().st_mtime
shutdown_mtime = (
_LAST_SHUTDOWN_PATH.stat().st_mtime
if _LAST_SHUTDOWN_PATH.exists() else 0
)
if shutdown_mtime >= current_mtime:
return # previous run shut down cleanly after its last heartbeat
# The previous run had a heartbeat without a subsequent clean
# shutdown — that's a crash, OOM, or otherwise-killed run.
try:
last_snapshot = json.loads(_CURRENT_STATE_PATH.read_text())
except Exception:
last_snapshot = {"_parse_error": "current.json unreadable"}
seconds_since = round(time.time() - current_mtime, 1)
_persistent_log(
logging.WARNING,
"diag post-mortem: PREVIOUS RUN DIED ABRUPTLY. Last heartbeat "
"was %ss before this startup. Last-known state: %s",
seconds_since, json.dumps(last_snapshot),
)
# Preserve the offending snapshot for retrospection. This file
# accumulates one rename per abrupt termination so the user can
# diff sequences over time if it's a recurring pattern.
try:
_PREVIOUS_RUN_PATH.write_text(json.dumps({
"noticed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"seconds_since_last_heartbeat": seconds_since,
"had_shutdown_record": _LAST_SHUTDOWN_PATH.exists(),
"had_exception_record": _LAST_EXCEPTION_PATH.exists(),
"last_known_state": last_snapshot,
}, indent=2, default=str))
except Exception:
logger.exception("Failed to preserve previous_run.json")
except Exception:
logger.exception("Post-mortem check itself failed")
async def _heartbeat_loop() -> None:
"""Forever-running task that emits one snapshot per interval.
Each heartbeat does three things:
1. Logs the snapshot to stdout (Docker logs — short-lived).
2. Appends the snapshot to /data/diagnostics/diag.log (rotating;
survives container restart).
3. Atomically rewrites /data/diagnostics/current.json with the
latest snapshot — so post-crash you can `cat` one file and see
the last known good state instead of scanning the rolling log.
Exceptions inside the loop are caught and logged so the loop itself
can't die silently — the whole point is that this thing keeps
talking even when other things crash around it.
"""
while True:
try:
snap = _snapshot_dict(reason="heartbeat")
_persistent_log(
logging.INFO,
"diag heartbeat: uptime=%ss rss=%sMB asyncio_tasks=%s "
"db_pool=%s curator_busy=%s",
snap["uptime_secs"], snap["rss_mb"], snap["asyncio_tasks"],
snap["db_pool"], snap["curator_busy"],
)
_write_state_atomic(_CURRENT_STATE_PATH, snap)
except Exception:
logger.exception("Heartbeat snapshot crashed (continuing)")
try:
await asyncio.sleep(_HEARTBEAT_INTERVAL_SECS)
except asyncio.CancelledError:
_persistent_log(
logging.INFO, "diag heartbeat: shutting down (CancelledError)"
)
raise
def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None:
"""Log unhandled task exceptions instead of letting them disappear.
Default asyncio behaviour: if a fire-and-forget task raises and
nothing awaits the result, the exception is logged at ERROR but the
surrounding context (which task, what coroutine) can be sparse.
This handler enriches the log line so we can tell WHICH task crashed.
"""
msg = context.get("message", "")
exc = context.get("exception")
task = context.get("task") or context.get("future")
task_name = getattr(task, "get_name", lambda: "?")() if task else "?"
coro = getattr(task, "get_coro", lambda: None)() if task else None
coro_name = getattr(coro, "__qualname__", str(coro)) if coro else "?"
if exc is not None:
logger.error(
"asyncio unhandled exception in task %r (coro=%s): %s",
task_name, coro_name, msg,
exc_info=(type(exc), exc, exc.__traceback__),
)
# Also emit to the persistent file logger so the traceback
# survives even if Docker logs are flushed.
try:
_setup_file_logger().error(
"asyncio unhandled exception in task %r (coro=%s): %s",
task_name, coro_name, msg,
exc_info=(type(exc), exc, exc.__traceback__),
)
except Exception:
pass
# Persist the snapshot at exception time so post-mortem can see
# what the app's state was when it crashed.
snap = _snapshot_dict(reason="asyncio_exception")
snap["task_name"] = task_name
snap["coro_name"] = coro_name
snap["exception_type"] = type(exc).__name__
snap["exception_message"] = str(exc)
_write_state_atomic(_LAST_EXCEPTION_PATH, snap)
else:
logger.error(
"asyncio unhandled event in task %r (coro=%s): %s — context=%r",
task_name, coro_name, msg, context,
)
def _signal_handler(loop: asyncio.AbstractEventLoop, signame: str) -> None:
"""Catch SIGTERM/SIGINT and log them.
Doesn't try to interfere with shutdown — Hypercorn handles its own
graceful exit. We just want a log line so we can tell "orderly
shutdown via signal X" apart from "silent gap then container exit"
(which means kill-9 or OOM, neither of which is catchable).
"""
global _shutdown_logged
if _shutdown_logged:
return
_shutdown_logged = True
snap = _snapshot_dict(reason="signal_shutdown")
snap["signal"] = signame
_persistent_log(
logging.WARNING,
"diag shutdown: received %s, expecting graceful exit. snapshot=%s",
signame, json.dumps(snap),
)
# Write before-exit snapshot so the next startup's post-mortem can
# see this run shut down cleanly via signal (rather than crashed).
_write_state_atomic(_LAST_SHUTDOWN_PATH, snap)
def start_diagnostics(loop: asyncio.AbstractEventLoop) -> None:
"""Install all three diagnostic surfaces. Idempotent."""
global _heartbeat_task, _started_at
if _started_at is None:
_started_at = time.monotonic()
# 0. Set up the persistent file logger FIRST, then run the
# post-mortem check. The post-mortem reads /data state from the
# previous run and surfaces "previous run died abruptly" if
# current.json is newer than last_shutdown.json. Operator catches
# this on next visit even if they missed the crash window.
_setup_file_logger()
_post_mortem_previous_run()
# 1. Asyncio exception hook — install once.
if loop.get_exception_handler() is None:
loop.set_exception_handler(_asyncio_exception_handler)
# 2. Signal handlers. add_signal_handler is Unix-only; skip on
# Windows so dev on a non-Linux machine doesn't blow up.
if os.name == "posix":
for sig in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(
sig,
_signal_handler,
loop,
sig.name,
)
except (RuntimeError, NotImplementedError, ValueError):
# add_signal_handler can fail when not running in the main
# thread or when the loop is already managing the signal.
# Either case is fine — heartbeat + exception hook still work.
pass
# 3. Heartbeat loop. Start if not already running (idempotent on
# restart-style reloads).
if _heartbeat_task is None or _heartbeat_task.done():
_heartbeat_task = asyncio.create_task(
_heartbeat_loop(), name="diag-heartbeat",
)
logger.info(
"diag started: heartbeat every %ds, signal-aware shutdown logging on, "
"asyncio exception hook installed",
_HEARTBEAT_INTERVAL_SECS,
)
def stop_diagnostics() -> None:
"""Cancel the heartbeat. Called from after_serving.
Lets the loop emit one last 'shutting down' log line so the silence
that follows is intentional (not a crash). The exception hook stays
installed but won't fire after the loop closes.
"""
global _heartbeat_task
if _heartbeat_task is not None and not _heartbeat_task.done():
_heartbeat_task.cancel()
_heartbeat_task = None
snap = _snapshot_dict(reason="after_serving_shutdown")
_persistent_log(
logging.INFO,
"diag stopped: heartbeat cancelled. Final snapshot=%s",
json.dumps(snap),
)
# If we got here without the signal handler firing (e.g., Hypercorn
# decided to shut down on its own), still record a clean exit so
# the next startup's post-mortem sees a fresh last_shutdown.json
# and doesn't flag this as an abrupt death.
_write_state_atomic(_LAST_SHUTDOWN_PATH, snap)