🏠 Home ← Docs Index
Open Beta 📖 Interactive Reader Raw .md llms.txt

Event-Driven Architecture Migration

Date: 2026-03 Focus: reliability, concurrency control, resource management

GoClaw moved the agent runtime from a blocking request/response model to an event-driven actor-style model. The goal was to make streaming, cancellation, tool execution, and WebSocket output safe under concurrent load.

Main Runtime Change

Older paths used Agent.Process() as the main execution primitive. That call blocked until the full result was available.

The current runtime uses:

ProcessStream(ctx, content, history) (<-chan AgentEvent, error)

Consumers read events from the returned channel:

Non-streaming callers use agent.DrainToResult() instead of calling Process() directly.

Bounded Tool Execution

Tool execution is routed through the shared worker pool instead of spawning unbounded goroutines.

pool := agent.GetWorkerPool()
pool.Submit(agent.ToolTask{
    Ctx: ctx,
    ToolName: name,
    Args: args,
    ResultCh: resultCh,
})

Benefits:

A tool call that waits on work which itself needs the pool (a subagent's run, an agent run inside memory_consolidate) waits inside whileBlocked, which adds a stand-in worker for as long as it waits. A call holding its worker while the work it waits for sits queued behind it would otherwise stall until its own timeout.

Single-Writer WebSocket

All WebSocket writes go through WritePump. Producers enqueue messages, and the pump's single goroutine owns the actual socket writes.

This prevents concurrent writes from streaming chunks, final responses, tool events, canvas updates, and error handlers.

Cancellation and Deadlock Prevention

Event producers use context-aware sends:

select {
case ch <- event:
case <-ctx.Done():
}

If the client disconnects or the request context is cancelled, producers stop instead of blocking forever on an unread channel.

When WritePump reports a write failure (the browser went away), the WebSocket handler stops writing but keeps consuming the stream to the end, so the final reply is still saved to the session and shown on reconnect.

Cancellation also has to reach the work itself:

One Turn per Session

A session behaves like an actor with a mailbox: it handles one message at a time. A turn reads the history, runs the agent, and writes the history back with its message and reply. The session store returns a copy and writes the whole session, so two turns of one session at once (two people in one Teams group chat, a message sent twice) would each answer without the other, and the later write would drop the earlier turn.

Every entry point that runs a turn holds session.LockTurn(ctx, key) from reading the history to writing it back: the native channels' common handler, the WebSocket, web chat, REST, app API and webhook handlers, a subagent's run and follow-up on its own session, and a subagent's result announced to its parent, which waits until the parent's turn is over. Later messages wait their turn and see the earlier ones. Discord also keeps a queue per session in front of this, which preserves its message order.

What changes a session from outside a turn holds the same lock: the automatic daily and idle reset, and the sessions API's delete, clear, create and rename. Without it, a turn running at the same moment writes the history it read back over the change, and a reset's media are already deleted by then. The idle reset takes the lock only when it is free (session.TryLockTurn): a session with a turn running or waiting is not idle.

The gateway still persists the inbound user message before the LLM call so a refresh or disconnect does not lose it; the agent receives the pre-append history plus the current message separately.

Failure Isolation

A panic ends the request it happened in, not the process. Both agent loops' goroutines recover (recoverStreamPanic) and end their stream with EventError; so do each WebSocket message's handler, the native channels' handlers, the worker pool and SOP, cron and subagent runs. An unrecovered panic in any goroutine stops the whole gateway: net/http recovers only a handler's own goroutine.

Files to Keep in Sync