feat: mid-turn steering and queued follow-up messages in the TUI - #1087
Merged
Conversation
Astro-Han
force-pushed
the
feat/runtime-steering-queue
branch
4 times, most recently
from
July 16, 2026 12:17
11bae0a to
5245712
Compare
…y injection Add two authoritative per-session queues in the runtime kernel so a user can act on a turn while it runs: - steer(sessionId, text): queue a message injected into the running turn at the next step boundary. The backend leases it via BackendSendInput.pullSteering, echoes it as a first-class `steering_message` (persisted as a user event, rendered in place), and injects it into the model context wrapped in a steering envelope so it continues the same turn. With no live steering owner, steer returns a `fallback` outcome so the caller opens a fresh turn and the message is never lost. - queueMessage(sessionId, text): queue a followup that opens the next turn; drainFollowup joins them with blank lines at turn end. - retractQueue / interrupt clear both queues; queue changes emit a `queue_update` snapshot into the active turn's stream, including the final stranded-steering → followup migration at ownership release (emitted before the stream closes). Delivery is a lease state machine whose single atomic commit point is the pull: - pullSteering moves messages to an in-flight set; retract/clear reclaim QUEUED messages only. A pulled lease is committed to the running turn and settles exactly once, decided solely by the persistence fact: the backend acks after the echoed event is durably consumed AND in the injection set, and nacks only when the event provably never persisted (never pushed, or the consumer detached first) — the dying request never carries it. An ambiguous append error is settled by reading the ledger back: present ⇒ durable (ack path); a store that fails both the append and the read-back leaves the message on the queue side (nack). An abort does not settle a pushed lease: the wait rides the teardown's convergence, so a durably appended event is never nacked into a second delivery — the ledger owns it and the next turn replays it. Snapshots keep counting in-flight messages as pending. Each lease is bound to its issuing turn: an overlapping turn that takes the owner slot can neither invalidate the issuer's ack/nack nor reclaim another turn's in-flight lease (release folds only its own turn's leases), and a nack that lands after the issuer lost ownership folds into the followup queue front — the issuer will never pull again, so the steering queue would strand it ownerless. - Steering persistence is fail-CLOSED end to end: AgentRun rethrows a steering append failure (every other non-terminal event stays fail-open), which propagates through the event chain to the backend's durability wait and becomes a nack. - Identity is structural, never textual: the persisted steering event carries a `steering` marker; every model projection (live injection, transport retry, reactive overflow rebuild, mid-turn capacity replacement, future-turn history, the text-only projections, and the degraded StoredMessage fallbacks via a RuntimeEvent sidecar) emits the one canonical envelope form tagged with the ledger event id in a Maka-namespaced providerOptions entry. Dedupe and the steering-free transport retry base work by that id — the retry base strips only THIS turn's injected set (historical ledger-replayed steering stays in the base), and user text equal to the envelope can neither forge nor cancel a steering message. - The capacity owner measures the real payload: steering joins the request BEFORE shaping and the final-request verdict, and the mid-turn fold PINS the current turn's steering events out of the covered span (folding one never shrinks the outgoing request — the accumulator re-appends it), so the verdict always measures the steering-inclusive payload that actually goes out and no post-verdict re-append path exists. - Without a configured RuntimeEventStore the durability ack has no anchor: steer() reports `fallback` and the message opens a fresh turn whose user message the SessionStore persists with the ordinary turn-open guarantee. - Enqueue outcomes carry no queue snapshot: queue contents travel to observers on ONE path only, the `queue_update` event — and the kernel is its only legal producer. A backend-yielded queue_update is forged queue state: the flow drops it at the ingress (not mapped, not forwarded, not persisted), and the event mapper rejects it (BackendSessionEvent excludes it from the backend vocabulary). - Ownership is a single live top-level run: established only after run.begin() succeeds, bound to the run's turnId (the lease/ack/nack hooks re-check identity), released before the stream closes. Threads the lease hooks through InvocationRequest -> FlowInput -> AiSdkFlow -> backend. FakeBackend follows the same lease contract with per-LEASE settlement: each lease acks right after its own echoed event is consumed (its delivery boundary), and a detaching consumer nacks exactly the leases whose events never crossed their yield. New core contracts: SteeringMessageEvent, QueueUpdateEvent, SteeringQueueSnapshot, QueueEnqueueOutcome, SteeringLease, and the `steering` marker on RuntimeEventTextContent.
Wire the TUI to the runtime's steering/followup queues: - Enter during a turn steers the running turn instead of being swallowed; the editor stays submittable and routes non-empty input to driver.steer. - Alt+Enter queues a followup during a turn (submits like Enter when idle); during a control action (busy without a running turn) it returns before touching the editor so the draft is never cleared into the void. - A pending bar above the editor mirrors the queues (`Steering:` / `Queued:`), driven solely by queue_update events; the CLI holds only a mirror. - A `fallback` outcome while the turn is running (the runtime's begin window has no live steering owner yet) moves the text into a CLI-held pending list that retries the SAME enqueue every 100ms and is rendered in the pending bar; the turn boundary flushes any remainder into the next turn's prompt (ahead of queued followups), so delivery is guaranteed — never a bounded wait that a normal turn outlives. - Alt+↑ takes every queued message (both queues plus CLI-held fallback texts) back into the editor, prepended to the draft — always through the authoritative retract, never gated on the render mirror, which lags the queue_update event by design. - Double-Escape interrupt retracts synchronously from driver.retractQueued() — the authoritative QUEUED messages (a message the turn already leased is committed to delivery and lands in the transcript instead) — plus the CLI-held fallback texts, into the editor; never from the render mirror, which can lag a step-boundary consumption. - The interrupt convergence window (stop issued, turn not yet terminal) accepts no new input: submits are disabled and Alt+Enter is gated before touching the editor, so nothing can race the abort. - Queued followups open the next turn at turn end, before goal auto- continuation — but only when the turn COMPLETED. An aborted or errored turn never auto-opens the flush turn; undelivered text becomes an editable draft. The driver exposes steer/queueMessage/takePendingFollowup/retractQueued over the runtime's queue API. New keys were chosen to avoid pi-tui editor bindings (newline is shift+enter/ctrl+j, history is plain up), so they do not collide.
Astro-Han
force-pushed
the
feat/runtime-steering-queue
branch
from
July 16, 2026 13:02
5245712 to
a3e436f
Compare
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The TUI swallowed Enter while a turn was running: the only way to talk to a busy agent was to interrupt it. This adds the two missing delivery semantics, following the pattern pi and codex converged on:
Steering:/Queued:rows), driven solely byqueue_updateevents. The kernel is the only producer of that event: a backend-forgedqueue_updateis dropped at the flow ingress and never reaches the ledger or observers.Delivery is built so a steering message has exactly one owner at all times — the queue, the ledger, or the editor — and is never silently lost:
steering_messageappend is durably consumed it is acked and joins the injection set (if the turn aborted meanwhile, the ledger owns it and the next turn's history replay carries it); a lease whose event never persisted is nacked back, folding to the followup head when its issuing turn no longer owns the queue. An ambiguous append error is settled by reading the ledger back: event present ⇒ durable, ack path; definitively absent ⇒ nack. Abort never converts a durable event into a redelivery, and no path can both refill the editor and still deliver.steering_messageledger append must succeed before any provider request carries the message; every other non-terminal event keeps its fail-open trace semantics. A session configured without a runtime-event store cannot honor that contract, sosteer()falls back to opening a normal turn there.providerOptions-scoped event-id marker; dedup and the retry-safe base strip operate on ids, never on message text, so user text can neither forge nor cancel a steering. Degraded replay paths recover the marker from a runtime-event sidecar.Commits are split by layer: runtime/core and cli. The runtime commit body documents the full invariant set.
Verification
Known windows (documented, accepted)
Review focus
runtime-kernel.ts: the lease/ack/nack state machine, the pull commit point, and its turn-identity guards.model-history.ts/ai-sdk-backend.ts/mid-turn-capacity-compact.ts: the id-marked steering projection, the sidecar recovery on degraded replay, and the fold-pin that keeps steering inside the measured payload.