Context
All conversation/session state is in process-local module globals:
_SESSIONS: dict in apps/api/chat_session.py:394 (chat + TwiML voice sessions)
_PENDING: dict of in-flight LLM asyncio tasks in apps/api/voice_twiml.py:95
- Streaming session registry used by dial-init → TwiML → WS handoff in
apps/api/voice_twiml.py
Consequences: any multi-worker/multi-replica deployment breaks (Twilio's follow-up webhooks land on a different process than the one holding the session), restarts drop mid-call state, and cumulative_cost_usd is process-global rather than per-session. Redis is already provisioned in docker-compose.yml and REDIS_URL exists in agents/settings.py, but nothing uses it. Code comments already say "production would use Redis keyed by borrower_id" (chat_session.py:9).
Implementation plan
- Add a
SessionStore abstraction (apps/api/session_store.py): get/put/delete(session_id), in-memory impl (default, keeps tests hermetic) + Redis impl selected by REDIS_URL presence or a settings flag. Use redis.asyncio.
- Make
ChatSession serializable (it's mostly message history + stage + counters; pydantic model or to_dict/from_dict). Store per-session cumulative_cost_usd in the session, not a global.
- Replace
_SESSIONS reads/writes in chat_session.py and voice_twiml.py with the store; set a TTL (e.g. 2 h) so abandoned sessions expire.
_PENDING (asyncio tasks) cannot live in Redis — instead store the result in Redis when the task completes, and make /voice/wait/{sid}/{turn}/{cycle} poll the store, so any worker can serve the wait webhook. Task handles stay process-local only as an optimization.
- Streaming path: persist the dial-init session record (borrower context, ws_token material) in the store so
/voice/streaming/twiml/{session_id} and the WS handler work cross-worker.
- Tests: store round-trip unit tests (fakeredis or the in-memory impl); integration check running uvicorn with
--workers 2 against scripts/simulate_twiml_call.py.
Acceptance criteria
- TwiML simulator passes against a 2-worker uvicorn with Redis enabled.
- Restarting the API mid-session (between webhooks) resumes the conversation.
- Unit suite stays green with no Redis available (in-memory fallback).
Dependencies
- None (unblocks: WS lifecycle hardening, contact-frequency compliance counter, container-per-call deployment).
Context
All conversation/session state is in process-local module globals:
_SESSIONS: dictinapps/api/chat_session.py:394(chat + TwiML voice sessions)_PENDING: dictof in-flight LLM asyncio tasks inapps/api/voice_twiml.py:95apps/api/voice_twiml.pyConsequences: any multi-worker/multi-replica deployment breaks (Twilio's follow-up webhooks land on a different process than the one holding the session), restarts drop mid-call state, and
cumulative_cost_usdis process-global rather than per-session. Redis is already provisioned indocker-compose.ymlandREDIS_URLexists inagents/settings.py, but nothing uses it. Code comments already say "production would use Redis keyed by borrower_id" (chat_session.py:9).Implementation plan
SessionStoreabstraction (apps/api/session_store.py):get/put/delete(session_id), in-memory impl (default, keeps tests hermetic) + Redis impl selected byREDIS_URLpresence or a settings flag. Useredis.asyncio.ChatSessionserializable (it's mostly message history + stage + counters; pydantic model orto_dict/from_dict). Store per-sessioncumulative_cost_usdin the session, not a global._SESSIONSreads/writes inchat_session.pyandvoice_twiml.pywith the store; set a TTL (e.g. 2 h) so abandoned sessions expire._PENDING(asyncio tasks) cannot live in Redis — instead store the result in Redis when the task completes, and make/voice/wait/{sid}/{turn}/{cycle}poll the store, so any worker can serve the wait webhook. Task handles stay process-local only as an optimization./voice/streaming/twiml/{session_id}and the WS handler work cross-worker.--workers 2againstscripts/simulate_twiml_call.py.Acceptance criteria
Dependencies