Stage 4: honest health, explicit error policy, event-bus integrity

Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get
the mock engine; an unconfigured trunk fails startup with guidance
instead of silently degrading. Root-caused why the engine always ran
mock: nested pydantic-settings never read .env (no env_file on the
sub-settings classes) — all 8 now declare it.

/health stops lying: reports engine mode (sippy|mock), a live DB
SELECT 1, trunk registration state with reason, and TTS/STT
availability from their last real request; "healthy" now requires
ready + db + sippy + registered trunk.

Error policy: leaf services (tts/transcription/llm_client) raise and
track availability; call-loop callers catch, publish EventType.ERROR
naming the failed service, and apply an explicit fallback. Persistence
writes get one bounded 3x exponential retry, then an ERROR log — no
more silent data loss.

Event bus: a full subscriber queue drops its oldest event (counted)
instead of silently evicting the subscription; subscribe(replay_last=N)
delivers the advertised history replay, used by /ws/events (25).

Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM;
voicemail polls for early hangup and stops/transcribes/hangs up in
finally; RecordingSession finally keeps its leg_ids so taps detach.

Dead code removed: models/contact.py + Contact table, dtmf_buffer,
transcribe_stream stub, SMS stub in notification.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 07:01:45 -04:00
parent 67a00defc3
commit 4048ce1db6
21 changed files with 492 additions and 357 deletions

View File

@@ -91,6 +91,7 @@ class RecordingService:
filepath_agent=filepath_agent,
started_at=datetime.now(),
sample_rate=self._sample_rate,
leg_ids=leg_ids,
)
# Start PJSUA2 recording if media pipeline is available
@@ -159,26 +160,38 @@ class RecordingService:
@staticmethod
async def _persist_recording(session: "RecordingSession") -> None:
"""Write a recordings row for this session. Failures are non-fatal."""
try:
import uuid as _uuid
from db.database import RecordingRecord, get_session_factory
"""Write a recordings row for this session, with bounded retry.
async with get_session_factory()() as db:
db.add(RecordingRecord(
id=f"rec_{_uuid.uuid4().hex[:10]}",
call_id=session.call_id,
path=session.filepath_mixed or "",
format="wav",
duration_s=float(session.duration_seconds or 0),
size_bytes=int(session.file_size_bytes or 0),
channels=1,
started_at=session.started_at,
ended_at=session.stopped_at,
))
await db.commit()
except Exception as e:
logger.warning(f"Recording persistence failed: {e}")
Non-fatal for the call, but a lost row means the dashboard can
never find the WAV — so failures log at ERROR, not warning.
"""
import uuid as _uuid
from db.database import RecordingRecord, session_scope
for attempt in range(3):
try:
async with session_scope() as db:
db.add(RecordingRecord(
id=f"rec_{_uuid.uuid4().hex[:10]}",
call_id=session.call_id,
path=session.filepath_mixed or "",
format="wav",
duration_s=float(session.duration_seconds or 0),
size_bytes=int(session.file_size_bytes or 0),
channels=1,
started_at=session.started_at,
ended_at=session.stopped_at,
))
return
except Exception as e:
if attempt == 2:
logger.error(
f"Recording row lost for {session.call_id} "
f"(path={session.filepath_mixed}): {e}"
)
return
await asyncio.sleep(2 ** attempt)
async def _recording_timeout(self, call_id: str) -> None:
"""Auto-stop recording after max duration."""
@@ -239,6 +252,7 @@ class RecordingSession:
filepath_agent: Optional[str] = None,
started_at: Optional[datetime] = None,
sample_rate: int = 16000,
leg_ids: Optional[list[str]] = None,
):
self.call_id = call_id
self.filepath_mixed = filepath_mixed
@@ -249,7 +263,7 @@ class RecordingSession:
self.duration_seconds: Optional[int] = None
self.file_size_bytes: Optional[int] = None
self.sample_rate = sample_rate
self._leg_ids: list[str] = []
self._leg_ids: list[str] = list(leg_ids or [])
def to_dict(self) -> dict:
return {