Repository navigation
feat: make TAC safe to run as N replicas with no shared datastore - #123
ryanrouleau wants to merge 1 commit into
Conversation
Messaging and voice needed different answers. Messaging is now stateless. `MessagingChannel` has no session store at all: each webhook derives a `ConversationSession` from the payload, config, and one `list_participants` call it already needed for the self-message check and reconciliation. Any replica serves any webhook. Previously `CONVERSATION_UPDATED` was filtered on locally-tracked state, so with N instances `on_conversation_ended` usually never fired and the session leaked for the process lifetime; it now rebuilds the session from Conversation Orchestrator and fires on whichever replica Twilio picked. Voice state is legitimately instance-local — a WebSocket pins a call to one process — so the fix is guaranteed teardown instead. `_cleanup_connection` no longer skips cleanup in orchestrated mode waiting for a CLOSED webhook that may land elsewhere (the largest leak); it always releases, and a new `on_call_ended` hook carries the live session, which is the only place the transcript is still reachable. `VoiceChannel.aclose()` drains live calls at shutdown, wired to the FastAPI shutdown event. For a call's out-of-band webhooks (status/AMD/recording, `<Connect action>`), `TACConfig.instance_public_domain` makes TAC mint every callback URL against this process, so they return to the replica holding the socket. No Redis, no database, and no extra API calls in the default configuration — `memory_mode="never"` still costs 2 calls per message, and the budget is pinned by a regression test. Breaking changes: - `MessagingChannel.get_agent_address(session)` was `get_agent_address(conversation_id)`. - `MessagingChannel.send_response` takes `conversation` (a session or an id) as its first parameter, was `conversation_id`. Positional calls are unaffected. - Messaging channels reject `memory_mode="once"` at construction. It caches a recall on a long-lived session, which messaging no longer has; statelessly it would cost the same as `"always"` with worse relevance. Use `"always"`. `"once"` is unchanged on Voice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
There is a teardown correctness risk in VoiceChannel._handle_conversation_closed() popping live sessions (potentially preventing on_call_ended on later socket teardown), and the FastAPI server’s voice-domain validation error message is misleading vs the new voice_callback_domain behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR updates Twilio Agent Connect (TAC) to support horizontal scaling behind a load balancer without sticky sessions or a shared datastore by making messaging channels stateless per webhook and tightening voice lifecycle/teardown semantics (including instance-affinity URL generation and shutdown draining).
Changes:
- Refactors
MessagingChannelto deriveConversationSessionper webhook (no in-process session store) and rebuild sessions from Conversation Orchestrator when needed. - Adds voice lifecycle improvements:
on_call_ended, guaranteed teardown via_release_session, instance-affinity viainstance_public_domain, and shutdown draining (VoiceChannel.aclosewired intoTACFastAPIServer). - Introduces
ExpiringDictfor bounded, expiring cross-request state and adds extensive tests + deployment documentation.
File summaries
| File | Description |
|---|---|
| tests/voice_invariants.py | Adds a reusable invariant asserting voice teardown leaves no residual local state. |
| tests/test_whatsapp_channel.py | Updates WhatsApp tests to reflect stateless messaging and CO-rebuilt on_conversation_ended. |
| tests/test_voice_scaling.py | New end-to-end-ish scaling contract tests for voice teardown, CLOSED handling, draining, and instance-affinity. |
| tests/test_voice_channel.py | Aligns existing voice tests with unified teardown + on_call_ended behavior. |
| tests/test_sms_channel.py | Updates SMS tests for stateless sessions, CO-rebuilt CLOSED handling, and session-based send_response. |
| tests/test_rcs_channel.py | Updates RCS tests to match stateless messaging and CO-rebuilt on_conversation_ended. |
| tests/test_profile_retrieval.py | Adjusts expectations for profile trait fetch behavior under stateless messaging sessions. |
| tests/test_participant_reconciliation.py | Refactors reconciliation tests to pass already-fetched participants and a session object. |
| tests/test_outbound.py | Updates outbound tests for “return session to reply with” pattern and new self-message logic. |
| tests/test_openai_realtime_provider.py | Updates Media Streams provider tests to use token-based per-call config propagation and cleanup. |
| tests/test_messaging_scaling.py | New scaling contract tests for stateless messaging + call budget invariants. |
| tests/test_memory_mode_once.py | Moves/reshapes "once" memory-mode coverage to voice-only and enforces rejection on messaging channels. |
| tests/test_integration.py | Updates integration expectations for CLOSED handling now rebuilt from CO rather than local session cleanup. |
| tests/test_expiring_dict.py | Adds tests for bounded, expiring mapping used for cross-request correlation. |
| tests/test_chat_channel.py | Updates chat tests for stateless sessions, participant-based rebuilds, and session-based send_response. |
| src/tac/utils/expiring_dict.py | Adds a bounded, TTL-based mapping for short-lived cross-request state (token correlation, etc.). |
| src/tac/server/fastapi_server.py | Wires TACFastAPIServer.aclose() to FastAPI shutdown; updates voice domain validation to use callback domain. |
| src/tac/server/config.py | Adds shutdown_grace_period to configure voice drain behavior on shutdown. |
| src/tac/core/tac.py | Adds _has_conversation_ended_callback() and gates profile trait fetch behind fetch_profile_traits. |
| src/tac/core/config.py | Adds fetch_profile_traits, instance_public_domain, and voice_callback_domain to support scaling semantics. |
| src/tac/channels/whatsapp.py | Updates get_agent_address signature to accept ConversationSession. |
| src/tac/channels/voice/twiml.py | Uses voice_callback_domain when deriving default WebSocket URL (supports instance pinning). |
| src/tac/channels/voice/provider.py | Introduces provider capability flag for whether CO CLOSED owns on_conversation_ended. |
| src/tac/channels/voice/media_streams/openai_realtime/provider.py | Replaces unbounded per-call config stash with expiring dict + token propagation on TwiML; ensures cleanup uses _release_session. |
| src/tac/channels/voice/conversation_relay/twiml.py | Uses voice_callback_domain for action URL resolution. |
| src/tac/channels/voice/conversation_relay/provider.py | Ensures teardown always releases sessions and properly models CO-owned conversation closure. |
| src/tac/channels/voice/channel.py | Adds on_call_ended, _release_session, CLOSED rebuild path, aclose() drain, and instance-affinity behaviors. |
| src/tac/channels/voice/init.py | Exports new CallEndedHandler. |
| src/tac/channels/sms.py | Updates get_agent_address signature to accept ConversationSession. |
| src/tac/channels/rcs.py | Updates get_agent_address signature to accept ConversationSession. |
| src/tac/channels/messaging.py | Core refactor: stateless session derivation, single participant fetch per message, session-based send_response, and CO-rebuilt CLOSED handling. |
| src/tac/channels/chat.py | Makes agent address derivation session-driven (for per-conversation channelId). |
| src/tac/channels/base.py | Removes shared session storage and centralizes safe on_conversation_ended triggering behavior. |
| mkdocs.yml | Adds the new deployment/scaling document to site navigation. |
| getting_started/examples/features/dashboard/dashboard.py | Updates dashboard mounting to accept app-owned messaging session tracking (channels no longer store messaging sessions). |
| getting_started/examples/features/dashboard/app.py | Implements app-level messaging session tracking and clears it on conversation end for dashboard display. |
| getting_started/examples/features/chat/app.py | Updates example to reply with the callback session to avoid extra lookups. |
| docs/deployment.md | New documentation describing the horizontal scaling model, budgets, hooks, and shutdown drain behavior. |
| CLAUDE.md | Updates repo-level architecture notes to reflect stateless messaging + instance-local voice model. |
Review details
- Files reviewed: 39/39 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| session = self._conversations.pop(conv_id, None) | ||
| if session is None: | ||
| if not self.tac._has_conversation_ended_callback(): | ||
| return | ||
| session = await self._rebuild_session(conv_id, call_sid) | ||
| if session is None: | ||
| return | ||
| await self._trigger_conversation_ended(session) |
| if not self.tac.config.voice_callback_domain: | ||
| raise ValueError( | ||
| "Voice channel is configured but TACConfig.voice_public_domain is " | ||
| "not set. Set it directly or via the TWILIO_VOICE_PUBLIC_DOMAIN env var." | ||
| "not set. Set it directly or via the TWILIO_VOICE_PUBLIC_DOMAIN env var. " | ||
| "(TACConfig.instance_public_domain also satisfies this, if each replica " | ||
| "is directly addressable.)" | ||
| ) |
0829cda to
3a2c450
Compare
|
Heads-up: #132 carries this PR's commit, rebased onto current 🤖 Generated with Claude Code |
#132) * feat: make TAC safe to run as N replicas with no shared datastore Messaging and voice needed different answers. Messaging is now stateless. `MessagingChannel` has no session store at all: each webhook derives a `ConversationSession` from the payload, config, and one `list_participants` call it already needed for the self-message check and reconciliation. Any replica serves any webhook. Previously `CONVERSATION_UPDATED` was filtered on locally-tracked state, so with N instances `on_conversation_ended` usually never fired and the session leaked for the process lifetime; it now rebuilds the session from Conversation Orchestrator and fires on whichever replica Twilio picked. Voice state is legitimately instance-local — a WebSocket pins a call to one process — so the fix is guaranteed teardown instead. `_cleanup_connection` no longer skips cleanup in orchestrated mode waiting for a CLOSED webhook that may land elsewhere (the largest leak); it always releases, and a new `on_call_ended` hook carries the live session, which is the only place the transcript is still reachable. `VoiceChannel.aclose()` drains live calls at shutdown, wired to the FastAPI shutdown event. For a call's out-of-band webhooks (status/AMD/recording, `<Connect action>`), `TACConfig.instance_public_domain` makes TAC mint every callback URL against this process, so they return to the replica holding the socket. No Redis, no database, and no extra API calls in the default configuration — `memory_mode="never"` still costs 2 calls per message, and the budget is pinned by a regression test. Breaking changes: - `MessagingChannel.get_agent_address(session)` was `get_agent_address(conversation_id)`. - `MessagingChannel.send_response` takes `conversation` (a session or an id) as its first parameter, was `conversation_id`. Positional calls are unaffected. - Messaging channels reject `memory_mode="once"` at construction. It caches a recall on a long-lived session, which messaging no longer has; statelessly it would cost the same as `"always"` with worse relevance. Use `"always"`. `"once"` is unchanged on Voice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(voice): take a rebuilt session's call_sid from the VOICE participant CO's CONVERSATION_UPDATED payload has no top-level channelId, so rebuilt sessions always got call_sid=None. Read it from the VOICE participant address's channelId instead, and make the fixture match the documented payload. (G10) Includes review fixes: - fix(voice): prefer the customer's VOICE channelId for a rebuilt call_sid Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(voice): keep a live call's session when CO closes it mid-call A CLOSED webhook for a call still live on this instance popped its session, so on_call_ended never fired and the call's later prompts were dropped. Fire on_conversation_ended now from a snapshot and leave the session for teardown to release. (G1) Updates two pre-existing tests in test_voice_channel.py that asserted the old behavior (session removed on CLOSED while the call was still live), which this change intentionally replaces. Includes review fixes: - fix(voice): document mid-call CLOSED on process_webhook; test end_call and raising callback Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(voice): resolve a call's session by CallSid on any instance Add VoiceChannel.resolve_conversation_session_by_call_sid: the live session when held locally, otherwise (orchestrated ConversationRelay) an identity-only session rebuilt from Conversation Orchestrator, preferring the active conversation. Lets CallSid-only call events find the conversation without per-pod routing. (G4) Includes review fixes: - fix(voice): document rebuilt-session limits and cost; parse created_at as datetime Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(voice): drop GPT-Live's per-token expiry timers The shared Media Streams base now stashes session configs in a bounded ExpiringDict, so GPT-Live's asyncio sleep task per outbound token was a second TTL for the same entries. Unclaimed entries now expire on the shared default, same as OpenAI Realtime. (G6) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs: document mid-call CLOSED behavior and the CallSid fallback Also tidies a test docstring. Includes review fixes: - docs(voice): note the per-replica conversation id after a mid-call close Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(messaging): deprecate memory_mode="once" instead of rejecting it Messaging channels accepted "once" on main, so narrowing the config to never/always and raising in the constructor broke existing apps at startup. Accept it again, emit a DeprecationWarning plus a logged warning, and run it as "always" (memory stays on, with each message's text as the query). Removal is planned for 3.0; Voice is unchanged. (G9) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: add shared ISO 8601 helpers for CO timestamps Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat: share one participant lookup across channels per CO webhook Adds TAC._list_participants_shared (in-flight + 60 s cache, failures not cached) and analytics.analytics_enabled(), for CO-driven lifecycle analytics. Includes review fixes: - fix: evict failed shared participant lookups via a done-callback Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat(analytics): report messaging lifecycle from CO webhooks Conversation Started comes from PARTICIPANT_ADDED for the customer and Conversation Ended from CONVERSATION_UPDATED/CLOSED (duration = CO's updatedAt - createdAt), each channel reporting its own channel type. A CLOSED webhook's participant lookup is shared across channels. (G3) Includes review fixes: - fix(analytics): log unparseable PARTICIPANT_ADDED; document CLOSED reporting Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat(analytics): report orchestrated voice lifecycle from CO webhooks With a CO conversation behind the call (orchestrated ConversationRelay), Conversation Started/Ended now come from PARTICIPANT_ADDED and CLOSED, like messaging, with CO's duration; the call's session start and teardown report them only in relay-only and Media Streams modes. The CLOSED rebuild shares the cross-channel participant lookup. (G3) Includes review fixes: - fix(voice): keep G4's CallSid lookup fresh; tidy lifecycle comments - test(analytics): pin voice sharing the CLOSED lookup; fix rebuild log text Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: describe CO-driven lifecycle analytics Includes review fixes: - docs: note telemetry lookup cost and at-least-once lifecycle reporting Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(voice): require instance_public_domain for per-call Media Streams configs Punts the G2 fix. A per-call session_config lives on the instance that served the TwiML or placed the call, so with several instances the stream can land elsewhere and fall back to default_session_config. Both providers now claim the stashed config through one shared helper that logs a warning naming instance_public_domain when the token isn't held locally. docs/deployment.md lists what needs instance affinity and corrects the per-pod guidance (a headless Service isn't reachable from Twilio). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: support conversation metadata in the CO client Adds metadata to ConversationRequest/ConversationResponse and to create_conversation / create_or_reuse_conversation, plus get_conversation and patch_conversation_metadata (PATCH merge). (G8) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat: fit app metadata to CO conversation-metadata limits Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat: add ConversationSession.conversation_metadata() Reads a conversation's Conversation Orchestrator metadata: answered from what the session already knows, otherwise via a channel-supplied loader whose result is kept for the turn; failures return {} and retry. (G8) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat(messaging): carry outbound metadata across turns Outbound initiation writes the metadata that fits CO's limits onto the conversation (at creation; PATCH merge on reuse). A bounded, per-instance cache shares each conversation's session.metadata across its turns, as on main, and session.conversation_metadata() reads the CO-stored metadata on any instance, fetching only when it isn't known locally. CLOSED carries the payload metadata and evicts the cache entry. (G8) Includes review fixes: - fix(messaging): keep fetched conversation metadata per turn; fetch on bare CLOSED Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: describe conversation metadata across turns Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix: tolerate non-string CO metadata values when parsing conversations ConversationResponse.metadata is typed dict[str, str] | None, which is strict in Pydantic v2. A conversation whose metadata another tool wrote with a non-string or null value (e.g. {"n": 1, "x": None}) raised ValidationError on every parse: the CLOSED webhook (losing on_conversation_ended and analytics), list_conversations (voice resolves by CallSid with it), update_conversation and get_conversation. The field did not exist before G8, so this was a regression from main. Add a before-validator that keeps only str/str entries and turns a non-dict value into None. ConversationRequest stays strict because TAC builds it itself. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(messaging): consistent CLOSED metadata; document accessor freshness A CLOSED payload without metadata now uses the cache entry's co_metadata when this instance wrote it, instead of wiring a fetch. session.metadata for on_conversation_ended no longer depends on the replica: payload metadata, then local entries over it, then the rebuilt session's fields. Document that conversation_metadata() answered from local writes does not see later changes made elsewhere, and note the outbound reuse PATCH sets direction. Includes review fixes: - docs(messaging): don't overclaim CLOSED metadata consistency Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(messaging): pin metadata call budgets; docs and log tidy Pin exact call lists for outbound create, outbound reuse and an inbound turn that reads conversation_metadata() on a non-initiating instance. Correct the docs cost sentence, and stop logging the CO response body from create_conversation when metadata was sent (CO may echo the value); log the status code instead. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: describe sender selection with multiple senders per channel After rebasing onto #128: a reply goes out from the sender the customer messaged (webhook recipient, else the one the conversation's participants hold), now that each message builds its own session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(voice): detach the mid-call CLOSED snapshot from the live session The snapshot passed to on_conversation_ended for a call still live on this instance only copied metadata's top level, so nested values and the session's mutable models were shared: clearing metadata["transcript"] in the callback cleared the live transcript, and the live call's later appends changed a snapshot the callback kept. Deep-copy the callback-visible data (metadata, profile, author/agent info, pending handoff, cached memory) and give the snapshot its own cache_lock instead of the live call's. A metadata value that can't be copied is shared rather than failing the hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(voice): drain waits for calls in setup and for end-of-call hooks aclose() treated an empty session store as a finished drain. But an admitted WebSocket can still be in setup (e.g. the background CO lookup) with no session yet, and create one after aclose() returns; and _release_session pops the session before awaiting on_call_ended, so the drain could return while that callback was still archiving. Track admitted WebSocket handlers from admission to completion and count in-flight releases, and wait on both. After the grace period, cancel handlers still running and give their teardown up to 5 s to fire the end-of-call hooks, then force-release whatever sessions remain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(voice): force-close leftover calls through the provider at shutdown After the drain's grace period, aclose() released leftover sessions at the channel only: on_call_ended fired, but the WebSocket stayed open and the provider's socket registry, session-manager state, stream task, model connection and call state survived. Add VoiceProvider._force_close_call(): ConversationRelay closes the WebSocket and runs _cleanup_connection; the Media Streams providers close the Twilio socket and run _cleanup_call (model socket, call state, stashed config). The base version only releases the channel session, for providers with no transport state. Both cleanups are now idempotent, so a stuck handler finishing after the force-close doesn't fire the hooks or "Websocket Disconnected" twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(voice): take the VOICE customer when a conversation spans channels A conversation grouped across channels can list an SMS- or CHAT-only CUSTOMER before the caller. The CLOSED rebuild and CallSid lookup then took that customer's profile, dropped author_info and preferred the agent's CallSid; ConversationRelay setup took the wrong profile_id. Select the CUSTOMER that has a VOICE address, as the messaging channels already do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(voice): recognize every configured number as TAC's own address _is_own_co_address checked only the default phone_number, so an UNKNOWN VOICE participant at another configured sender counted as a customer and reported an extra Conversation Started. Check the phone_numbers allowlist, as the messaging channels already do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(voice): keep inherited stream parameters when adding the session-config token The token was added to the per-call TwiML options' custom_parameters alone. The builder replaces whole fields, so that per-call dictionary dropped any parameters inherited from default_twiml_options or host_twiml_options. Add the token to the parameters the builder would resolve instead, through one helper shared by inbound and both providers' outbound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: rename the scaling guide to horizontal-scaling.md The page covers running TAC as several instances, not deployment in general. Rename it before its first publish, while no links depend on it, and use the same name for its heading and site navigation entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: correct what instance_public_domain pins and the mid-call close instance_public_domain pins status, AMD and recording callbacks only on calls TAC places; inbound call events come from the number's own configuration and still reach the load balancer. A mid-call CLOSED fires from a live snapshot only on the replica holding the call, and other replicas resolve the call to the new conversation only once CO has started one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: tighten the scaling guide's remaining imprecise claims - The call budget counts turns that get a reply; one without makes no POST /Actions. - profile_id comes off the participant list once CO has resolved it; until then a memory fetch falls back to the address lookup. - Add the 128-character metadata key limit. - Only Media Streams records a transcript; on_conversation_ended fires on the instance holding the call outside orchestrated mode; only ConversationRelay has a text send_response. - VoiceChannel.initiate_outbound_conversation: both Media Streams providers place outbound calls too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(messaging): keep get_agent_address(conversation_id) backward compatible The stateless rewrite changed MessagingChannel.get_agent_address to take a session, which broke callers passing an id and subclasses overriding the old signature. Restore main's get_agent_address(conversation_id). TAC's own paths now call a private _agent_address(session) that defaults to get_agent_address(session.conversation_id), so an override of the old signature keeps receiving ids; the built-in chat channel overrides it to read channelId off the session, and answers get_agent_address by id from what this instance cached, as on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(messaging): keep send_response's conversation_id parameter name The first parameter was renamed to conversation when send_response started accepting a session, which broke keyword calls passing conversation_id=. Name it conversation_id again, typed str | ConversationSession, so both positional and keyword calls from main keep working and a session can still be passed to skip the participant lookup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(voice): give orchestrated on_conversation_ended main's session again On main, an orchestrated ConversationRelay session stayed live until CO's CLOSED, so on_conversation_ended got the full session, metadata included, and end_call() fired it straight away. The stateless rewrite released the session at hangup and rebuilt an identity-only one on CLOSED, and deferred end_call() to CLOSED. Keep each ended orchestrated session in a bounded per-instance store (24 h, 10k) until its CLOSED, which then fires with that session on the instance that held the call; other instances, and expired entries, still rebuild from CO. end_call() fires the hook now and marks the kept session so that instance's CLOSED doesn't refire. A conversation CO closed mid-call is neither kept nor refired at teardown. The bound replaces main's leak of sessions whose CLOSED never came. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: describe messaging on_conversation_ended under several replicas Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(messaging): answer later turns from cached participants when the lookup fails On main a later turn reused its in-memory session, so a failed participant lookup didn't stop it being answered. The stateless rewrite fetched participants every turn and dropped the message whenever that failed. Keep the last reconciled participants (customer, agent, profile_id) in the per-instance conversation cache, and when the lookup fails answer from them with a warning. With nothing cached, or when the webhook names a different sender than the cached one, the message is still dropped, as main dropped a first message. The happy path is unchanged and costs no extra calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(messaging): warn when CO rejects a conversation created with metadata Outbound now sends conversation metadata at creation, which main never did, and the client logs no response body when it does. On a 4xx at creation, log a warning naming the metadata keys (not values) as a possible cause; the error still propagates as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(config): say which callbacks instance_public_domain pins Status, AMD and recording callbacks are pinned only on calls TAC places; an inbound call's come from the number's own configuration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(messaging): stop storing direction in conversation metadata TAC never reads `direction`; storing it on the CO conversation cost apps one of CO's 8 metadata keys and, on reuse, overwrote conversations that began inbound. Only app metadata is written now. session.metadata still carries `direction: outbound`, as on main. With no app metadata, creation sends none (as main did), a reused conversation isn't patched, and a rejected creation no longer names metadata as a possible cause. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(messaging): keep nothing between turns TAC shouldn't hold app state across turns, and a per-instance cache made session.metadata carry over on one replica but not another. On messaging a session now lasts one turn; apps keep cross-turn state themselves, keyed by conversation_id. Outbound metadata stays readable on any replica through conversation_metadata(). Removed with the cache: - the last-known-participants fallback: a failed list_participants now drops the turn (on_error, dropped_inbound) on every replica alike - CO metadata copied into session.metadata on CLOSED; read it through conversation_metadata(), answered from the payload when it has it - chat's get_agent_address(conversation_id) channelId, which came from the cache; TAC's own paths read it off the turn's session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(voice): leave shutdown draining to the platform On SIGTERM, uvicorn closes every open WebSocket with 1012 before the app's shutdown event runs, so VoiceChannel.aclose()'s grace period never kept a call alive; the handler's guaranteed teardown already fires on_call_ended on that disconnect. Keeping calls through a rollout needs SIGTERM delayed, which only the platform can do. Removes VoiceChannel.aclose(), TACFastAPIServer.aclose() and its shutdown hook, TACServerConfig.shutdown_grace_period, the providers' _force_close_call, and the admission/handler-task tracking only the drain used. None of it shipped on main. The scaling guide now gives platform guidance instead: fail readiness, delay SIGTERM with preStop, size the termination grace period. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(chat): recover channelId when replying by conversation id send_response(conv_id) rebuilds the session from list_participants, and Chat's required channelId only arrived on the inbound webhook, so a reply by id raised on Chat where it worked on main. The rebuilt session now takes channelId from the customer's or agent's CHAT participant address. Without one, the error says to pass the ConversationSession. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: document TWILIO_INSTANCE_PUBLIC_DOMAIN Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(voice): keep nothing after a call's teardown Drop VoiceChannel._ended_sessions. It only restored the full session to on_conversation_ended when CLOSED reached the replica that held the call; everywhere else the hook already got a session rebuilt from CO. on_call_ended is the hook that sees the live session. end_call() now releases the call like any hangup: on_call_ended fires right away and, when orchestrated, on_conversation_ended fires once on the CLOSED that follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(readme): drop the end-of-conversation lookup note from Telemetry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: turn the horizontal scaling page into a getting-started guide Move docs/horizontal-scaling.md to getting_started/horizontal_scaling.md, where the other user guides live, so docs/ stays the API reference. Rewrite it as task-ordered steps for running TAC on several replicas, leaving the design rationale to CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: describe webhook retries instead of requiring idempotent handlers TACFastAPIServer acks webhooks before the handler runs, so Twilio only retries on failures and a cross-replica duplicate reply is rare. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: remove the multi-replica guide for now Drop the links to it; TWILIO_INSTANCE_PUBLIC_DOMAIN stays documented in the README, the getting-started env var list and .env.example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(chat): send channelId when lazily adding the AI_AGENT participant Reconciliation rebuilt the agent address from session.ai_agent_info, which drops the channelId that ChatChannel._agent_address supplies. Conversation Orchestrator requires channelId on a CHAT address, so the POST failed and the message was dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Ryan Rouleau <ryan@ryanrouleau.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Makes the SDK safe behind a load balancer with no Redis, no database, and no sticky sessions for messaging. Implements TAC Horizontal Scaling Plan in four phases (P0a/P0b/P1/P2 + docs).
Two problems, two answers:
Messaging is now stateless.
MessagingChannelhas no session store. Each webhook derives aConversationSessionfrom the payload, config, and onelist_participantscall it already needed for the self-message check and reconciliation — so any replica serves any webhook.Voice stays instance-local, correctly — a WebSocket pins a call to one process — so the fix is guaranteed teardown plus a way for the call's other traffic to find it.
Bugs this fixes
CONVERSATION_UPDATED/CLOSEDon_conversation_endedusually never fired and the session leaked for the process lifetime_cleanup_connectiondeliberately skipped_end_conversation, waiting for a CLOSED webhook that lands on a random instance — the socket closed, the session stayedon_call_endedhook fires with the live session_call_session_configs<Stream>custom parameter (as outbound already did); the remaining bridge is bounded + expiringCallSid, resolve toNoneon any instance that didn't hold the callTACConfig.instance_public_domainmints every callback URL against this processVoiceChannel.aclose()drains, wired to the FastAPI shutdown eventAPI-call budget
Statelessness is not paid for with extra calls. Per inbound message, pinned by a regression test:
memory_mode"never"(default)"always"get_profile)"always"+fetch_profile_traits=Falseprofile_idnow comes off the participant list we already fetch — nolookup_profile, and more reliable than the address-based fallback, which infers the identifier type and misses on CHAT and RCS. A conversation's first message is one call cheaper than before (the doublelist_participantsis deduplicated).New API
VoiceChannel.on_call_ended(handler)— fires at WebSocket teardown with the live session. The only place the transcript is still reachable.VoiceChannel.aclose(grace_period=...)/TACFastAPIServer.aclose()TACConfig.instance_public_domain,TwilioMemoryConfig.fetch_profile_traits,TACServerConfig.shutdown_grace_periodType of Change
Breaking changes
MessagingChannel.get_agent_address(session)— wasget_agent_address(conversation_id). The channel no longer holds a session to look up. Affects anyone subclassingMessagingChanneldirectly.MessagingChannel.send_response(conversation, ...)— first parameter was namedconversation_idand now accepts aConversationSessiontoo. Positional calls are unaffected; keyword calls need updating. Pass the session and the send costs no extra API call.memory_mode="once"at construction. It caches a recall on a long-lived session, which messaging no longer has; statelessly it would cost the same as"always"with strictly worse relevance, so it fails loudly rather than degrading silently."always"is the drop-in replacement. Unchanged on Voice, where it's the mode that matters most.Also worth flagging (not breaking, but a behavior change): in orchestrated voice mode
on_conversation_endednow receives a session rebuilt from Conversation Orchestrator, so in-memory state (transcript, applicationmetadata) is gone by then. Useon_call_endedfor that.Checklist
tests/test_voice_scaling.py,tests/test_messaging_scaling.py,tests/test_expiring_dict.py, andtests/voice_invariants.py(theassert_no_residual_stateteardown invariant, asserted by every voice test that opens a socket).tests/test_memory_mode_once.pymoved from SMS to Voice.docs/deployment.md; the CLAUDE.md "horizontal scaling limitation" caveat is now a description of the model.Not verified against a live account
Two items need a real Twilio account and are the main thing to check before merging:
from={address, channel}(Actions API mode 3), gated on a live test. I couldn't run it, sosend_responseprefers the participant id when it has one — which reconciliation always establishes on the inbound path — and falls back to address mode only when there isn't one. That keeps today's verified behavior on the hot path. If the live test passes, this can be simplified to address-only; if it fails, nothing to undo._reconcile_participants' docstring claims it's a v1-bridge-only repair pass; nothing in the repo establishes that. Reconcile is now unconditional (and free — it reuses the fetched participant list and writes nothing on the happy path), so the answer doesn't change behavior, but it would settle a standing unknown in the code's own documentation.SDK Parity
Not Python-specific — the TypeScript SDK has the same architecture and the same leak. A parity PR should follow; not created yet.
🤖 Generated with Claude Code