Skip to content

Steer queue: drain at RunStream entry to close idle-window race #2491

@simonferquel-clanker

Description

@simonferquel-clanker

Problem

runtime.LocalRuntime.Steer enqueues into steerQueue, but the queue is only drained inside the RunStream loop — specifically after a batch of tool calls finishes and before the stop check (pkg/runtime/loop.go around line 425).

This leaves two gaps where a steered message can be stranded or delivered late:

  1. Idle-runtime race. A Steer call can land between the moment the previous RunStream returns and the moment the next RunStream begins. The queue holds the message, but nothing is running to consume it. The caller has no signal that delivery is deferred.
  2. First-LLM-roundtrip miss. When a new RunStream starts, it builds messages := sess.GetMessages(a) and calls the model without touching steerQueue. If that first turn is a plain text response with no tool calls, res.Stopped fires and the loop breaks — the pending steer message is never injected into this turn at all.

Combined, a steered message that arrived while the runtime was idle can be invisible to a subsequent short turn and only surface on a much later multi-round turn.

Why it matters

Any caller that builds message ingestion on top of WithSteerQueue needs to be able to enqueue a message and trust it will be picked up by the next turn, regardless of whether the runtime is currently active. Today that guarantee does not hold.

Proposed fix

Drain steerQueue at the top of the RunStream loop body, symmetrically with the existing mid-loop drain, before the first model call. Pseudocode:

// At the start of the for-loop, before the model call:
if steered := r.steerQueue.Drain(ctx); len(steered) > 0 {
    for _, sm := range steered {
        sess.AddMessage(session.UserMessage(wrap(sm), sm.MultiContent...))
        events <- UserMessage(...)
    }
}

This makes Steer idempotent with respect to runtime-liveness: the caller can always Enqueue and be sure the next turn picks it up, regardless of whether the runtime is currently looping.

Alternative / complement

Expose a documented contract that RunStream will call Drain once at entry, so any MessageQueue implementation can atomically inject messages into the session before the first model call.

Acceptance criteria

  • A Steer call made while no RunStream is active is consumed by the next RunStream's first turn.
  • A Steer call made during a short non-tool turn is still consumed within that same turn (or at worst triggers a follow-on turn) — not stranded until an unrelated future turn.
  • No change to follow-up-queue semantics.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions