A single lock is the simplest solution — until a slow background job makes your user wait. Chapter 10 fixes this with named lanes that give every concern its own independent queue.
The Big Picture
Chapter 7 (Single-Agent Loop) used a single threading.Lock — it works, but all concerns (user, heartbeat, cron) compete for the same door.
Chapter 10 upgrades to named LaneQueues — each concern has its own lane.
User input
│
▼
CommandQueue.dispatch()
│
├──> Lane "main" ← user messages, FIFO, priority
├──> Lane "heartbeat" ← background check, non-blocking
└──> Lane "cron" ← scheduled jobs, non-blocking
│
LaneQueue.pump()
│
Future → result back to caller
With a single lock, a slow cron job can block the user from getting a response. Named lanes solve this by giving each concern its own independent queue.
Analogy: An airport has separate lanes — priority boarding, fast track, regular. Each lane has its own queue and moves independently.
Four Key Concepts
1. LaneQueue — Data Structure
type Task struct {
fn func() (any, error)
result chan result
generation int
}
type result struct {
value any
err error
}
type LaneQueue struct {
name string
mu sync.Mutex
cond *sync.Cond
queue []Task
activeCount int
generation int // ← restart recovery
}
func NewLaneQueue(name string) *LaneQueue {
lq := &LaneQueue{name: name}
lq.cond = sync.NewCond(&lq.mu)
return lq
}
| Field | Purpose |
|---|---|
| queue | FIFO task queue |
| condition | Thread coordination (notify/wait) |
| activeCount | How many tasks are currently running |
| generation | Detect stale tasks after restart |
2. pump() — Self-Pumping Design
No external scheduler. Each LaneQueue manages itself:
func (cq *CommandQueue) pump(lane *LaneQueue) {
for {
lane.mu.Lock()
for len(lane.queue) == 0 {
lane.cond.Wait() // sleep when empty
}
task := lane.queue[0]
lane.queue = lane.queue[1:]
gen := lane.generation
lane.mu.Unlock()
if task.generation != gen {
task.result <- result{err: errors.New("cancelled: stale generation")} // stale — skip
continue
}
lane.mu.Lock()
lane.activeCount++
lane.mu.Unlock()
val, err := task.fn()
task.result <- result{value: val, err: err}
lane.mu.Lock()
lane.activeCount--
lane.mu.Unlock()
}
}
Thread sleeps when the queue is empty → wakes via condition.notify() when a task arrives. No polling, no busy-wait.
When a new task is dispatched to this lane, it calls cond.Signal() — this wakes the sleeping goroutine so it can pick up the task immediately.
3. Future-Based Results
In a chat bot, blocking means the UI freezes until the LLM responds. Futures let the system stay responsive while the request is in flight.
Callers don’t block directly — they get a Future and can wait whenever they’re ready:
// Dispatch task to lane
ch := commandQueue.Dispatch("main", func() (any, error) {
return llmCall(message)
})
// Wait for result (blocking, with timeout)
select {
case res := <-ch:
result, err = res.value, res.err
case <-time.After(30 * time.Second):
err = errors.New("timeout")
}
// Or check without blocking
select {
case res := <-ch:
result, err = res.value, res.err
default:
// not ready yet
}
This pattern keeps the UI responsive while the LLM is thinking.
4. Generation Tracking — Restart Recovery
When an agent restarts (crash, redeploy), old tasks in the queue become stale:
func (cq *CommandQueue) RestartLane(laneName string) {
lane := cq.lanes[laneName]
lane.mu.Lock()
defer lane.mu.Unlock()
lane.generation++ // bump generation
lane.queue = nil // clear the queue
lane.cond.Broadcast()
}
Tasks from the previous generation check task.generation != lane.generation → auto-cancelled. No zombie tasks running after a restart. The caller receives a cancellation error — so nothing hangs silently.
Chapter 7 (Single-Agent Loop) vs Chapter 10 (Concurrency)
| Chapter 7 (sync.Mutex) | Chapter 10 (LaneQueue) | |
|---|---|---|
| Structure | 1 lock for everything | Named lanes per concern |
| Fairness | User vs heartbeat compete | Each lane is independent |
| Result | Fire and forget | Future — can be awaited |
| Restart | No recovery | Generation tracking |
| Scale | Fine for simple cases | Better for multi-concern |
Graceful Shutdown
A production agent should handle SIGTERM cleanly:
SIGTERM received
│
├── stop accepting new tasks
├── drain active lanes (wait for activeCount → 0)
├── flush delivery-queue to disk (Chapter 8 (Delivery Guarantees) guarantee)
└── exit cleanly
If the LLM is mid-response when the signal arrives, the write-ahead delivery queue from Chapter 8 (Delivery Guarantees) handles the pending message on the next startup. The two patterns compose: graceful shutdown + write-ahead delivery = no lost messages.
Takeaway
- Named lanes — each concern (user/heartbeat/cron) has its own queue — no blocking
pump()self-pumping — no external scheduler needed- Future pattern — caller gets async results without busy-waiting
- Generation tracking — stale tasks from before restart are auto-cancelled
- Graceful shutdown — drain lanes, flush queue — Chapter 8 (Delivery Guarantees) as safety net
- Chapter 10 — the recommended concurrency model once your agent has multiple concerns running in parallel
Cross-References
- Chapter 7 (Single-Agent Loop) — the single-lock pattern this chapter replaces
- Chapter 8 (Delivery Guarantees) — write-ahead queue that pairs with graceful shutdown
- Chapter 9 (Resilience) — retry chain that runs inside a lane task