Forum teuk.org

🪄 MB675 — Claude Leaves the Event Loop Without Losing the Thread

in Mediabot · started by TeuK · 3d ago

TeuK · 3d ago

Version: 3.4dev-20260820_080732
Commit: 620c6d7
Commit message: 🪄 Let Claude Leave the Event Loop Without Losing the Thread

MB675 fixes a problem that was subtle in normal IRC use but important for Mediabot’s runtime architecture:

a Claude / Anthropic request could block the main IRC event loop while the HTTP request was in progress.

The visible AI response was fine.

The problem was what happened while Mediabot was waiting for Anthropic.


🧭 Static audit first

MB675 started with a read-only event-loop audit.

Several commands were already protected by asynchronous boundaries:

news / actualites  → CommandAsync worker
horoscope           → CommandAsync worker
define              → internal async path
version             → AsyncWorker

But Claude still followed a synchronous path:

IRC dispatch
→ claude_ctx()
→ claudeAI()
→ HTTP::Tiny request()

The Anthropic request had a timeout that could reach tens of seconds.

That made Claude one of the strongest candidates for a real IRC event-loop stall.


🧪 Proving the stall in runtime

Rather than relying only on source inspection, a small direct IRC timing probe was created.

It connects directly to:

teuk.org:6667

without using ZNC or WeeChat.

The probe joined #boulets and sent:

m ai reponds uniquement par MB675PROBE

then only about:

100 ms

later:

m uptime

This avoids WeeChat’s own anti-flood pacing and gives precise millisecond timings.

Before MB675

Observed:

SEND #1  m ai ...
SEND #2  m uptime
SEND GAP 100.266 ms

AI reply      : +954.480 ms
uptime reply  : +2163.685 ms

Result:

ORDER=AI_BEFORE_UPTIME

uptime itself does not need an external API call.

Yet it was effectively queued behind the Anthropic request.

That confirmed the architectural problem seen in the source:

Anthropic HTTP was running synchronously in the main IRC process.

🧠 The wrong easy fix

A tempting solution would have been to wrap the complete command with something like:

CommandAsync::run_ctx_async(...)

and execute the whole Claude command in a child process.

That would have freed the IRC loop, but it would also have moved important Claude state into the child.

Claude maintains process-local state such as:

conversation history
prompt cache
rate-limit state
persona / pin state
metrics
response handling

Updating those structures in a forked child would not reliably update the parent process.

That would solve one problem while quietly breaking conversation continuity.

MB675 therefore uses a narrower async boundary.


⚙️ The MB675 architecture

Only the Anthropic HTTP transport leaves the main process.

Conceptually:

main IRC process
    |
    | prepare request
    | preserve conversation/history state
    |
    +----> AsyncWorker
              |
              | HTTP request to Anthropic
              |
              +----> result returned
    |
    | parse result
    | update history
    | update metrics/state
    | emit IRC response

The important distinction is:

network I/O       → worker
conversation state → parent

So Mediabot gets a non-blocking event loop without losing Claude’s thread.


🔒 Conversation-level concurrency guard

Making Claude asynchronous introduces a new possibility that did not really exist while the event loop was blocked:

two AI requests for the same conversation can overlap

That could create inconsistent history ordering.

MB675 therefore also adds a conversation-level in-flight guard.

While one Claude request is running, a second request targeting the same conversation is rejected rather than racing the first one.

The lock is released when the request completes.

This behavior is covered by the new regression tests.


🚀 Runtime result after MB675

The exact same direct IRC timing probe was run again after the migration.

The probe sent:

m ai ...

and then:

m uptime

only:

100.334 ms

later.

Observed:

uptime reply : +131.524 ms from first command
               +31.190 ms from its own send

Claude reply : +932.547 ms

Result:

ORDER=UPTIME_BEFORE_AI

So while Anthropic was still working, Mediabot processed and answered another IRC command almost immediately.

Before

AI       ~954 ms
uptime  ~2063 ms after its own send
ORDER   AI_BEFORE_UPTIME

After

uptime    ~31 ms after its own send
AI       ~933 ms
ORDER    UPTIME_BEFORE_AI

That is the behavior MB675 was designed to achieve.


🧪 Regression coverage

A dedicated test was added:

t/cases/859_mb675_claude_async_transport.t

Existing Claude-related tests were also adapted where necessary to reflect the new async transport boundary.

Focused Claude / AI regression:

PASSED : 1018/1018  (3s)

Fast suite:

PASSED : 5304/5304  (65s)

Full suite:

PASSED : 14139/14139  (221s)

All with:

RC=0

⚡ MB674 is already paying off

MB675 also demonstrates the practical impact of the previous round.

Before the MB674 test-suite acceleration, full validation routinely took around:

768s

The MB675 full suite completed in:

221s

That makes heavy validation practical after every significant architecture change instead of something developers are tempted to postpone.


📦 Files changed

Main implementation:

Mediabot/External/Claude.pm

Adjusted Claude / async regression tests:

t/cases/220_external_claude_api.t
t/cases/224_external_claude_history.t
t/cases/227_external_claude_callback.t
t/cases/534_mb312_ai_chunk_pacing_nonblocking.t
t/cases/578_mb359_claude_content_blocks_parsing.t

New MB675 coverage:

t/cases/859_mb675_claude_async_transport.t

And the normal:

VERSION

update.


🛡️ What MB675 preserves

The change is intentionally architectural, not cosmetic.

It preserves:

Claude command syntax
conversation history
persona behavior
prompt/cache behavior
rate limits
response formatting
metrics
IRC output

while changing where the slow external HTTP operation happens.


🏰 Result

Before MB675:

Claude talks to Anthropic
→ IRC loop waits
→ other commands wait too

After MB675:

Claude request starts
→ Anthropic HTTP runs outside the IRC event loop
→ Mediabot keeps processing IRC
→ result returns
→ parent updates conversation state
→ Claude replies normally

The bot can now keep talking to everyone else while Claude is thinking.

And, importantly, Claude still remembers what the conversation was about. 🪄

You must be logged in to reply.