Skip to content

feat: steer-while-running — inject a user message into an in-flight run (#512) - #570

Merged
jbeda merged 1 commit into
mainfrom
feat/steer-while-running
Aug 20, 2026
Merged

jbeda merged 1 commit into
mainfrom
feat/steer-while-running

Conversation

@jbeda

@jbeda jbeda commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Steer-while-running (#512)

Inject a user message into an in-flight run — Claude Code's "steer while running" — instead of waiting for turn-end and submitting a fresh prompt. See ADR-0114 + the acceptance plan (27 ACs, ac-trace 27/0).

Design principle: the TUI stays maximally dumb — it sends a steer and renders the authoritative echo; all race resolution lives in server + engine plumbing.


Round 2 — addressed Ozz's review (9 findings, all fixed)

JAORMX's panel review requested changes across concurrency, lifecycle, protocol-correlation, and TUI correctness. Each finding was triaged, discussed, and fixed; the rework was independently re-reviewed (security + architecture) — both re-reviews confirm the fixes hold.

# Finding Fix
1 Supersede select non-atomic → deadlock/TOCTOU Inbox → sync.Mutex + {closed,pending,has}; SUPERSEDE DROPPED — a second enqueue on a full slot returns slot_full (reject); replace = explicit cancel-then-resend. The non-atomic replace path is deleted, not guarded. engine/agent/steer.go
2 Clean terminal skips Step 2a → accepted steer dropped Clean-exit continue-run — finishTurnNoTools defers terminateComplete while a steer is parked; the run extends until it drains at the next Step 2a. engine/agent/loop.go:1396
3 Promoted run orphaned when Converse returns; runRelay.sendErr unsync'd Sequential active-run handoff — Converse relays the promoted run on the SAME stream (one streamSender) before returning; sendErr single-owner; mailbox seal Converse-owned. internal/adapter/server/grpc.go
4 Controls stay bound to original run after promote Same handoff — readControl's control target swaps atomically to the promoted run; one "active run" at a time.
5 No correlation key on ack/echo message_id on Steer/SteerCancel/SteerAck/SteerEcho (client-minted, server-echoed, client ignores stale). Engine stays text-only; the id lives in a Service FIFO (steerMsgIDs). No expected_run_id (wire has no run-id concept).
6 TUI edit-back duplicates steer text Edit = cancel-then-recompose — client holds a queue, collapses to ONE bundle per send (one message_id); ↑ (optimistic, not synchronous) cancels the outstanding bundle and pulls the whole not-yet-drained set into one editable blob; resend = new bundle, fresh id. Burned ids (echo seen) open a fresh draft. cmd/mecatui/ui
7/8 Invalid UTF-8 only repaired in mapper session.ToValidUTF8 at ingress in enqueueSteer — history == echo == model-view by construction; the mapper backstop stays last-resort. engine/agent/steer.go
9 Missing docs (architecture.md + ADR-0027 List 1/2) Added the architecture loop section (docs/architecture/agent-loop.md), ADR-0027 List 1 row + List 2 fidelity decision (pending steer restart-lost by design), ADR-0114 updated in place.

⚠️ Protocol changes (round 2) — focused review welcome

  • SteerOutcome enum: SUPERSEDED → SLOT_FULL (harness.proto).
  • message_id added to Steer, SteerCancel, SteerAck, SteerEcho.

How it works (final shape)

A run-scoped mutex inbox (single slot, slot_full) sits on the Run. A mid-run steer frame → Service.Steer (authorize → live-enqueue or terminal-promote-via-funnel). The loop drains it at Step 2a (post-tool-results, pre-next-turn) via recordContinuation, and emits the EvSteer echo (stream-positioned, message_id-tagged) so the client knows where it landed and that it's the one it sent. The TUI shows the steer queued until it lands, then in context — mirrors the #228 queue UX.

Deferred (explicit scope cuts)

HTTP/SSE steer, ACP steer, steer-to-child, the interrupt verb, intent-vs-action timing.

Verification

task lint 0 · task test 155 pkgs / 0 FAILs (-race, both modules + standalone hygiene) · task generate + task api:update committed · task docs matlatl strict clean · task site:build green · task ac-trace-strict steer plan 27 ACs / 0 failures · go run ./cmd/mecademo full session. Round-2 rework re-reviewed post-fix by independent security + architecture reviewers.

Closes #512

@jbeda
jbeda force-pushed the feat/steer-while-running branch from b0f80fe to c2fcbab Compare August 15, 2026 20:30
@jbeda
jbeda requested a review from JAORMX August 15, 2026 20:30
JAORMX
JAORMX previously requested changes Aug 16, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Panel review (Spec / Standards / Domain / prior art): requesting changes for reproducible concurrency, lifecycle, protocol-correlation, and TUI correctness issues. I compared the design against current Codex, Goose, Cline, OpenCode, Claude Code changelog evidence, and Aider. The highest-risk cluster is the run/transport ownership around terminal steering; CI's race-test job also timed out with an agent.test process still alive, consistent with the inbox liveness bug called out inline.

Comment thread engine/agent/steer.go Outdated
return SteerAccepted, nil
default:
// Full slot: supersede — replace the pending steer and report the win.
<-r.steer.slot

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this supersede transition is not atomic and can deadlock. After the non-blocking send observes a full slot, drainSteer or cancelSteer can consume it before this receive; <-r.steer.slot then waits forever. A concurrent enqueue can also refill between the receive and the following send, blocking that send. The separate done check has the same TOCTOU shape: closeSteer can win after the check and this call can still report accepted for a value no loop will drain. A cap-1 channel does not make this compound replace/close operation linearizable. Please use one mutex around {closed, pending} (or an equivalent single-owner state machine) and add deterministic liveness tests that force drain/close between observation and mutation; -race alone cannot detect a channel-protocol deadlock.

Comment thread engine/agent/loop.go Outdated
// permanent is always false here (the ChunkDone StopError path has NO Go error
// to classify — honest fail-open).
func (e *Engine) terminateComplete(ctx context.Context, r *Run, sess *session.Session, reason session.StopReason, text string, usage session.Usage, errMsg string) {
r.closeSteer()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — a steer accepted during a final text response is silently lost on the normal clean-terminal path. finishTurnNoTools goes straight to terminateComplete for meaningful text without another Step 2a pass. If EnqueueSteer accepted while that response was streaming, this closes the inbox with the value still parked; the caller already received accepted, and Service never gets too_late to promote it. That contradicts the next-boundary/never-drop contract. Codex and Goose both re-check pending input before clean exit. Please make the clean-terminal decision atomically observe/consume pending steer and continue the loop, or have terminal close return the pending value into an explicit promotion path.

Comment thread internal/adapter/server/grpc.go Outdated
// control reader keeps processing frames; the run's terminal EvResult is
// relayed onto this stream through the shared runRelay, and its outcome is
// reported back as the steer ack (promoted=true: never a drop).
go h.drivePromotedSteer(ctx, id, text, promotedRun, rl)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this goroutine has no owner keeping Converse alive. The primary relayRun returns as soon as the original run's events close; Converse then returns and cancels the same stream context used to start/relay this promoted run. The promoted terminal event and ack (queued only after its relay completes) can therefore race a closed RPC. TestSteer_NoConcurrentSendOnTerminalRace explicitly permits the ack/result to arrive after stream close, so it does not prove AC3.2/AC5.1. runRelay.sendErr is also accessed by both relays without synchronization; serializing only Send does not fix that race. Please make the RPC own a sequential active-run relay/handoff and wait for the promoted run before returning, rather than launching a second unjoined relay.

Comment thread internal/adapter/server/grpc.go Outdated
// interleaves them onto the stream); a too_late steer the Service promotes
// drives the fresh follow-up run and reports its terminal outcome back as the
// steer ack.
func (h *HarnessServer) readControl(ctx context.Context, id session.SessionID, run *agent.Run, rl *runRelay) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — all subsequent controls remain bound to the original run. Once a steer is promoted, ResumeApproval, Cancel, and CancelChild still target this captured, terminal handle. A promoted follow-up that asks permission cannot be approved on this stream, and Esc/cancel does not stop its active tool work. The active-run handoff should update the single control target atomically with relay ownership; do not let reader and relay disagree about which run owns the stream.

// outcome is the closed-enum result (mirrors agent.SteerOutcome).
SteerOutcome outcome = 1;
// text echoes the steer text the outcome is about (empty for steer_cancel).
string text = 2;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — text is not a safe correlation key. Acks and drain echoes can interleave with newer submissions (the relay explicitly allows random ready-case ordering), so the TUI can apply ack(A) after sending A+B, overwrite the newer state, or clear it on echo(A). The same frame can also be routed to a newer run occupying the session registry after a terminal handoff. Current prior art avoids this: Codex carries expectedTurnId + clientUserMessageId; Goose carries expectedRunId + a generated messageId; Cline uses stable pending-prompt IDs. Please add an expected run/generation and client operation/message ID to steer/cancel, echo them in Ack/SteerEcho, and have clients ignore stale responses.

Comment thread cmd/mecatui/ui/update.go Outdated
combined := text
if m.steer != nil && (m.steer.Phase == steerPending || m.steer.Phase == steerSent) {
// Merge onto the combined in-flight text (the version the engine last
// confirmed, or the un-acked pending text); the supersede replaces it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — edit-back duplicates the instruction instead of replacing it. editBackQueue copies the complete current steer into the textarea but deliberately leaves m.steer.Text unchanged. On resend, text already equals the edited full steer, and this produces old + "\n\n" + edited-old (for example check A becomes check A\n\ncheck A carefully). That contradicts the UI's “resend to replace” contract and sends obsolete guidance to the model. Track that the draft is editing the existing steer and send it as the complete replacement; update the test to assert exact frame equality—the current Contains assertions at steer_test.go:228 mask this bug.

Comment thread cmd/mecatui/ui/update.go
m.steer.Text = msg.Text
m.steer.Phase = steerSent
m.statusMsg = m.deps.Theme.Style("muted").Render("steer queued")
case client.SteerTooLate:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — this reports successful delivery even when promotion failed. The server emits too_late with promoted=false on routing/run-entry errors, but this branch ignores msg.Promoted and always renders “sent as a follow-up.” That can tell the user an instruction ran when it was dropped due to a lease/liveness/funnel error. Branch on Promoted; for false, preserve the text and show an explicit not-sent/retry state (ideally with a bounded error outcome), rather than advancing to steerPromoted.

Comment thread engine/agent/steer.go
// SteerOutcome (accepted/superseded/too_late) — never an error for the ordinary
// too-late race (that outcome is what the Service promotes on).
func (r *Run) EnqueueSteer(text string) (SteerOutcome, error) {
return r.enqueueSteer(text)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standards violation — normalize producer text at the semantic ingress, not only in the protobuf mapper. This exported engine API accepts arbitrary Go strings and later records/emits the raw value; toProtoSteer(valid(...)) repairs only the client projection. An external engine consumer can therefore persist/replay invalid UTF-8 and send it to a provider while the wire echo differs, violating the repo's recorded == streamed == model-view invariant and its explicit two-layer UTF-8 rule. Apply session.ToValidUTF8 once before the value enters the inbox so history, model view, event, and mapper backstop all agree.

- **ADR-0114** landing with this plan — the steer contract: engine inbox (single supersedable slot), the drain echo (`EvSteer`), the auto-promote-on-terminal-race rule, the queue-only awaiting behaviour, and the gRPC-only-v1 / HTTP-deferred scope cut. Copy `docs/adr/template.md`.
- **`docs/usage/http-sse-api.md`** — a note that steer is gRPC-only in v1 (the HTTP run path has no mid-run client→server channel).
- **`docs/tui.md`** — the steer-mode queue card (pending / sent / promoted) and the capability-driven flip vs the local queue.
- **`docs/architecture.md`** — the steer inbox + Step 2a seam under the loop section (living "how it works").

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documented cross-cutting deliverable is missing from this PR. docs/architecture.md is not changed even though the loop injection and Converse lifecycle changed; AGENTS.md requires the living architecture doc to move with behavior. Also, the new run-scoped inbox/channels and promoted-run goroutine outlive one call, so AGENTS.md requires entries in ADR 0027 List 1, and the intentionally restart-lost pending steer needs the List 2 fidelity decision. Please update both canonical inventories rather than leaving the lifecycle only in ADR 0114/implementation notes.

@jbeda

jbeda commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — every finding was legitimate, and several were design-level. All 9 are addressed; the rework was then re-reviewed (targeted security+architecture, then a full 3-axis panel) and one panel-caught defect (the TUI Promoted handling) is also fixed. Per finding:

#1 — supersede select non-atomic → deadlock. The cap-1-channel inbox was the wrong primitive (a channel can't linearize a compound replace-or-report-closed). Reworked to sync.Mutex + {closed, pending, has} with every transition one critical section — and dropped supersede entirely: a second enqueue on a full slot now returns slot_full (reject; replace = explicit cancel-then-resend). The non-atomic replace path you flagged is deleted, not guarded. engine/agent/steer.go.

#2 — clean terminal drops an accepted steer. Confirmed: finishTurnNoTools → terminateComplete skipped Step 2a. Fixed with the clean-exit continue-run rule: a meaningful-text benign stop while a steer is parked defers termination and continues the loop so the steer drains at the next boundary (loop.go:1396). Never-drop holds engine-internally. (We considered promote-to-follow-up and client-handoff; continue-run is the only shape where the steer genuinely steers mid-task.)

#3 — promoted run orphaned when Converse returns + unsync'd runRelay.sendErr. The go h.drivePromotedSteer second relay was the bug. Replaced with a sequential active-run handoff: after the original relay drains, Converse relays the promoted run on the SAME stream (one streamSender) before returning, and sendErr has a single owner. The promoted run's terminal ack is sent inline before RPC return — no ack-after-close.

#4 — controls bound to the original run after promote. Same handoff: readControl's control target swaps atomically to the promoted run at the same moment the relay ownership moves — one "active run" at a time; ResumeApproval/Cancel/CancelChild hit whichever is active.

#5 — no correlation key. Added message_id (client-minted, server-echoed, client ignores stale) on Steer/SteerCancel/SteerAck/SteerEcho. The engine stays text-only; the id lives in a Service FIFO. (No expected_run_id — the wire has no run-id concept; the client tracks session, not run.) I compared against Codex/Goose/Cline; message-id-on-frame matches Codex's clientUserMessageId. The remaining text-match FIFO fragility is documented in ADR-0114 with the move-id-into-engine path for when a second sender surface lands.

#6 — TUI edit-back duplicates steer text. Fixed at root: edit is now cancel-then-recompose — the client holds a queue, collapses to ONE bundle per send (one message_id); ↑ (optimistic, not synchronous) issues steer_cancel for the outstanding bundle and pulls the whole not-yet-drained set into one editable blob; resend = a new bundle with a fresh id. A drained id is burned (editing after the echo opens a fresh draft). The Contains assertions that masked the bug are now exact-equality.

#7/#8 — UTF-8 only repaired in the mapper. session.ToValidUTF8 now applied at ingress in enqueueSteer, before the text enters the inbox — history == echo == model-view by construction; the mapper backstop stays last-resort.

#9 — missing docs. Added the loop section to docs/architecture/agent-loop.md, ADR-0027 List 1 row + List 2 fidelity decision (pending steer restart-lost by design), and updated ADR-0114 in place.

Panel-caught extra (beyond your 9): the full panel found that the TUI ignored Promoted on a too_late ack — a failed promotion (promoted=false) still rendered "sent as a follow-up." Fixed: branch on Promoted; false → a new steerFailed not-sent phase (text preserved, honest retry) + test.

One architectural question your review surfaced but which is bigger than this PR: single-driver-per-session is not enforced across clients (steer/Cancel/Approve are stream-attached, not driver-scoped; run-start is serialized but live signaling isn't). Filed #632 to track enforcing single-driver + explicit frontend handoff (and the observer-can't-see-pending-steer gap). Steer is the forcing case; the enforcement is a follow-up.

Gates green across the board: lint 0 · test 155 pkgs/0 FAILs (-race) · generate+api committed · docs matlatl strict clean · site build · ac-trace 27/0 · mecademo. Ready for re-review.

@jbeda
jbeda force-pushed the feat/steer-while-running branch from dcf4c1d to 1a37017 Compare August 18, 2026 20:28
@jbeda
jbeda force-pushed the feat/steer-while-running branch from b1a9bf7 to 0d6f4a0 Compare August 19, 2026 18:34
@jbeda
jbeda dismissed JAORMX’s stale review August 19, 2026 18:34

Covered in further commits/edits. All comments addressed.

@jbeda
jbeda force-pushed the feat/steer-while-running branch 4 times, most recently from f472f63 to 73dea07 Compare August 19, 2026 22:05
…-running)

Closes #512.

Mid-run steer: the operator injects a message into a *live* run rather than
waiting for turn-end and submitting a fresh prompt. gRPC-only v1 on the bidi
Converse stream (`steer`/`steer_cancel` frames); the TUI is maximally dumb
(it sends a steer and renders the authoritative EvSteer echo) — ALL race
resolution lives in server+engine plumbing.

Engine (engine/agent/steer.go): a Run-scoped, single-slot, append-default
mutex inbox. A second steer on the occupied slot APPENDS into the pending
bundle (merged with a blank-line separator, drained as ONE user message);
supersede/slot_full were dropped through the review rounds. Replacing a
pending bundle is the explicit steer_cancel-then-resend. UTF-8-repaired at
ingress. In-memory, best-effort: a pending steer is lost with the run
(reset-by-design, ADR 0027 rows 57/34). A parked steer blocks a clean
terminal (clean-exit continue-run) and both terminate paths drain-then-close,
so it is never closed unconsumed.

Service (internal/adapter/server/service.go): authorize-first, then
live-enqueue or terminal-race promote via StartRunContent (the hardened
run-entry funnel, with the promote-grace awaiting the just-terminal run's
deregistration). The id lives at this wire-correlation layer only: a
per-session FIFO of ordered frame ids, stamped on the drain echo as the
watermark (the LATEST contributing send's id — positional, never text-match;
pinned by TestLookupSteerMessageIDExactUnderDuplicateTexts). Ids are
64-rune-clamped at track.

gRPC wire (contracts/proto + internal/adapter/server/grpc.go): steer /
steer_cancel oneof arms, the SteerOutcome closed enum (accepted / appended /
retracted / none_pending / too_late), the single-writer streamSender gate for
every Send, the sequential active-run handoff (Converse relays a promoted
run on the SAME stream; the control target swaps atomically before its relay
starts), and the drain-echo watermark stamp. ServerCapabilities.steer = 20
(additive capability bit, computed once in composition).

TUI (cmd/mecatui/ui): when the capability is advertised, each enter sends
ONLY the new fragment with a fresh client-minted id (engine appends
server-side); the ordered queue of sends (id + text) is the single
correlation source (no burn maps; the watermark derives from the tail send).
The drain echo splits the queue at the watermark — prefix drained (rendered
in context at its true stream position), suffix pending. The ack advances
only the lifecycle phase (never the queue). `↑` on empty input is
cancel-then-recompose (steer_cancel on the watermark id; resend as a fresh
fragment under a NEW id). Re-composed fragments render per-part on the card.
Capability absent → the #228 local merge-queue, byte-identical.

Docs: ADR-0228 (the landed contract), updated acceptance plan +
agent-loop.md/IMPLEMENTATION-NOTES.md/tui.md/http-sse-api.md,
user-docs/mecatui.md, engine/CHANGELOG.md consolidated, AGENTS.md list not
widened (no new loop diagnostics lines). The 14-task orchestration spine is
under .claude/plans/steer-while-running/ (left in for the PR's own review
audit; superseded by the ADR on merge).

Deferred (recorded in ADR-0228): HTTP/SSE steer endpoint, ACP steer,
steer-to-child.

Co-Authored-By: mecatl <noreply@stacklok.com>
@jbeda
jbeda force-pushed the feat/steer-while-running branch from 73dea07 to 1c322f5 Compare August 19, 2026 23:32
@jbeda
jbeda marked this pull request as ready for review August 19, 2026 23:34
@jbeda
jbeda merged commit 85e7aa4 into main Aug 20, 2026
26 of 27 checks passed
@jbeda
jbeda deleted the feat/steer-while-running branch August 20, 2026 00:12
jtenniswood added a commit that referenced this pull request Aug 20, 2026
The steer-while-running capability (#570) was gRPC-only; the HTTP/SSE
tier — Studio's only transport — could not steer. This lands the
follow-up ADR 0232 named:

- POST /v1/sessions/{id}/steer {text, message_id} -> 200
  {outcome: accepted|appended|too_late}. The live-run fast path is
  extracted from Service.Steer into Service.SteerEnqueue (single-owner);
  HTTP never promotes — on too_late the caller keeps the text and drives
  its own follow-up prompt, so never-drop holds at the client.
- POST /v1/sessions/{id}/steer-cancel -> {outcome: retracted|none_pending}
  via the existing Service.CancelSteer.
- The EvSteer drain-echo message_id stamp is extracted into one shared
  Service.stampSteerEcho used by BOTH the gRPC relay and relayRunSSE, so
  HTTP clients get watermark correlation too.
- The HTTP capabilities echo gains the steer bit.

Offline httptest coverage for every outcome, the echo correlation, and
the capability echo; Service.Steer's promote behavior pinned unchanged
by the existing steer suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Steer-while-running: inject queued user input into an in-flight turn

2 participants