Concurrency and scheduling

The moment you have more than one channel and a scheduler, two events can land on the same session at the same time. Without a lock, they clobber each other. And an assistant that only runs when you message it is only half alive. We will fix both problems.

Locks and schedules side by side

Per-session locks serialize turns. A daemon thread runs scheduled tasks as their own isolated sessions.

09-concurrency-scheduling/bot.py
python
import threading

session_locks: dict[str, threading.Lock] = {}
locks_guard = threading.Lock()

def get_lock(user_id: str) -> threading.Lock:
    with locks_guard:
        if user_id not in session_locks:
            session_locks[user_id] = threading.Lock()
        return session_locks[user_id]

def run_agent_turn_safe(user_id: str, text: str) -> str:
    with get_lock(user_id):
        return run_agent_turn(user_id, text)

One lock per session, created lazily. The guard lock is just to make the dictionary update safe across threads.

09-concurrency-scheduling/bot.py
python
import schedule, time

def setup_heartbeats():
    def daily_brief():
        run_agent_turn_safe(
            user_id='cron:daily-brief',
            text='Summarize what I worked on yesterday and suggest today\'s focus.',
        )

    schedule.every().day.at('08:00').do(daily_brief)

    def scheduler_loop():
        while True:
            schedule.run_pending()
            time.sleep(30)

    t = threading.Thread(target=scheduler_loop, daemon=True)
    t.start()

Scheduled tasks get their own session id so they stay isolated from user conversations. A daemon thread runs the schedule loop in the background.

A global lock serializes every user in the world behind each other. Two unrelated people cannot hold a conversation at the same time. Per-session locks let different users talk in parallel while keeping each individual session consistent. It is a free win.

Quiz: Quiz

Loading practice…