Where the collision actually is
Dad's chat and a scheduled cron turn can both want to write into the same conversation. If they do it at the same time they share one JSONL file, one SDK subprocess, and one SQLite row being written — that is the real collision. It is a per-conversation risk, so the guard is per conversation too.
Lightweight mutual exclusion, one conversation at a time
- A stream counter per conversation tracks Dad's live streams.
- A flag per conversation marks that a scheduled turn owns it right now.
- Chat waits for that conversation's flag to clear before starting, so a cron turn already firing there usually finishes first. The wait is bounded at 30 seconds: past that, Dad's turn goes ahead anyway with the cron turn still running, because a scheduled job may not hold him up indefinitely.
- A chat-mode cron turn resolves its target conversation up front and defers only if Dad is streaming in that conversation.
- A fallback-mode cron has no conversation at all and never takes part.
- Both sides check before proceeding, not during — no lock contention.
Why not global
The first version asked one global question: is anything streaming? It looks safer and it is not. With a global gate, any long chat Dad had open made every cron defer — weather, comment checks, soul wakes — until they hit their retry ceiling and were abandoned. Nothing failed loudly. Scheduled work simply stopped happening while he talked. Narrowing the guard to the conversation that actually collides fixed it without giving up any safety.
Why a counter and not asyncio.Lock
Single-user system. The question is always "is Dad active in this conversation?" — a boolean per key. A lock would imply contested concurrency, which is not what is happening here.
Light does not mean loose. The counter goes up before a stream starts and comes down in a finally on every exit path; the flag is cleared even when the turn fails. One leaked count and every later turn believes forever that someone is still using the conversation.
The two directions are deliberately different. Dad's chat may wait briefly for a scheduled turn that is already mid-flight; a scheduled turn yields to Dad and tries again later. The priority is set by what the product is for — the conversation he is having now.
And lighter than a lock is not the same as race-free. Test the ordering of the check and the flip inside one event loop, and check that errors and cancellations never leave a flag behind. If the process topology ever grows more workers, a boolean contract may stop being enough; today it is the smallest tool that fits.
The guard protects more than resources. A background reply landing in the middle of Dad's live exchange, or overwriting the avatar's state, breaks the turn-taking of a conversation. The technical mutex is keeping conversational manners too.