# 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:

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

Consumers read events from the returned channel:

- `EventTokenChunk` for streaming text.
- `EventToolStart` and `EventToolResult` for tool lifecycle updates. The agent
  loop (`runToolLoop`, `tool_loop.go`) sends them on the stream for every
  provider.
- `EventCanvas` for rich UI artifacts. Consumers handle it, but canvas output
  currently reaches them through `WithCanvasHandler` and `WithCanvasCollector`.
- `EventError` when the request failed. It is the last event: the stream closes
  after it, and `DrainToResult` returns it as the error.
- `EventBufferReset` when a retry should discard partial streamed text.
- `EventDone` for the final `ProcessResult`.

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.

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

Benefits:

- Back-pressure when the worker queue is full.
- Predictable goroutine count.
- Panic recovery inside tool workers.
- Shared behavior across Gemini and LangChainGo-based providers.

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:

```go
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:

- Both loops wait for a tool call with `awaitToolResult`. Once the request is
  cancelled, a call still running gets 5 seconds (`toolStopGrace`) and is then
  reported as not stopped. In a SOP step that attempt is treated as possibly
  still running, and the step is not retried beside it.
- Commands run for the agent (`shell_exec`, `git`, a SOP rollback command) are
  built with `toolCommand`: they run in their own process group, cancelling
  kills the whole group, and the wait for their output is bounded. A plain
  `exec.CommandContext` kills only `bash`; what `bash` started keeps running and
  keeps the call waiting.

## 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

- `internal/agent/tool_loop.go`
- `internal/agent/tool_round.go`
- `internal/agent/stream_panic.go`
- `internal/agent/worker_pool.go`
- `internal/agent/command.go`
- `internal/session/turn_lock.go`
- `internal/gateway/handler_websocket.go`
- `internal/gateway/writepump.go`
