Skip to content

fix(runtime): reliably surface & correlate concurrent elicitation requests (#3584)#3587

Draft
aheritier wants to merge 7 commits into
mainfrom
fix/a6-elicitation-concurrency
Draft

fix(runtime): reliably surface & correlate concurrent elicitation requests (#3584)#3587
aheritier wants to merge 7 commits into
mainfrom
fix/a6-elicitation-concurrency

Conversation

@aheritier

Copy link
Copy Markdown
Contributor

Summary

Fixes #3584 — elicitation (user-input) requests raised from multiple
concurrent background jobs were silently dropped: nothing was displayed to the
user and the requesting handler blocked forever.

This is finding A6 from the internal codebase audit.

Root cause

The elicitation path assumed a single, synchronous, LIFO-nested request at a
time, which broke under concurrency (e.g. run_background_agent jobs running
RunStream on detached concurrent goroutines):

  • elicitationBridge was a single global channel slot with a LIFO-nesting
    assumption — concurrent background jobs caused swaps/restores to interleave.
  • runCollecting silently discarded ElicitationRequestEvents.
  • Interleaved teardown left the channel slot nil/closed → silent failure;
    foreground and background elicitations could bleed into each other.
  • TOCTOU: a response landing before the handler parked hit the
    "no elicitation request in progress" branch.
  • Display side: the tab supervisor's PendingEvent was a single overwritable
    slot.

What changed

  • Per-request correlation — waiters are keyed by a freshly generated
    internal UUID (never the MCP wire ID, so two servers reusing an ID can't
    evict each other), registered before the request event is emitted
    (eliminating the TOCTOU branch).
  • Reliable deliveryelicitationHandler calls the OnElicitationRequest
    sink synchronously and unconditionally first; the best-effort bridge send is
    now ctx-bounded on a detached goroutine and can never block delivery.
  • Exactly-oncerunCollecting no longer re-forwards bridge events;
    elicitationHandler is the sole sink caller; the App-side dedupe map is
    removed.
  • Atomic resolve-vs-cancel — an atomic terminal state ensures a delivered
    response is never discarded by a racing ctx.Done().
  • Server/API sessionsElicitationRequestEvent carries the originating
    session ID; API-created runtimes register a session-scoped sink with a
    replayable event log via GET /api/sessions/:id/events (and the log closes
    on session delete).
  • Detached background prompts survive — the supervisor filters
    PendingEvents by session ID, so a foreground stream stop no longer erases a
    still-live background job's elicitation.
  • Deterministic replayreplayPendingEvent uses tea.Sequence instead
    of tea.Batch.
  • Backward-compatible Go APIResumeElicitation's new elicitationID is
    variadic so pre-Concurrent background-job elicitations are silently dropped — nothing displayed to the user #3584 callers still compile.

Testing

New/updated tests cover: duplicate wire IDs, cancel-vs-resolve race
(300-iteration -race fuzz), wedged/abandoned bridge channel, real-handler
TOCTOU, exactly-once delivery, concurrent RunAgent routing, session-scoped
server sink + delete-closes-stream, foreground-stop vs detached background
prompt, and real TUI FIFO replay ordering.

  • task dev (lint + 19 custom cops + tests + build): green
  • go test -race -count=2 on affected packages: green
  • All commits build independently (bisectable)

Notes

Opened as a draft pending final maintainer review.

…nd non-blocking (#3584)

Resolves blocking review findings 1, 2, 4, 5 and should-fix items 1, 2 from
the A6 concurrency review:

1. Reliable sink no longer gated behind the blocking bridge: elicitationHandler
   now calls the OnElicitationRequest sink synchronously and unconditionally
   BEFORE the best-effort bridge send, and the bridge send itself runs on a
   detached goroutine bounded by ctx (elicitationBridge.send takes a context
   and selects on ctx.Done()), so a full/abandoned bridge channel can never
   delay or block sink delivery or response processing.
2. Waiter registry: (a) the registry key is now always a freshly generated
   internal UUID, never the MCP wire ElicitationID, so two MCP servers
   reusing the same wire ID can no longer evict each other's waiter;
   (b) resolve-vs-cancel is now decided by an atomic terminal state
   (elicitationWaiter.state), so a response ResumeElicitation already
   reported as delivered can never be silently discarded by a racing
   ctx.Done().
4. Supervisor: ElicitationRequestEvent now carries the originating
   (sub-)session ID, and the supervisor's PendingEvents queue filters on it
   so a foreground stream stop/start only clears its OWN attention events,
   preserving a still-live detached background job's elicitation.
5. runCollecting no longer re-forwards a bridge-observed
   ElicitationRequestEvent to the sink (elicitationHandler is now the sole,
   exactly-once caller), and the App-side ElicitationID dedupe map is
   removed since it is no longer needed and could itself drop a legitimate
   delivery.

Should-fix 1 (Go API compatibility): Runtime.ResumeElicitation's new
elicitationID parameter is variadic, so pre-#3584 3-argument call sites keep
compiling; OnElicitationRequest remains a required (no-op-able) interface
method, matching the existing OnToolsChanged/OnBackgroundEvent precedent.
Documented in docs/guides/go-sdk.

Should-fix 2 (deterministic replay): replayPendingEvent now returns
tea.Sequence instead of tea.Batch, so bubbletea replays queued attention
events (e.g. two concurrent background-job elicitations) in the order they
arrived instead of a Batch's concurrent, unordered execution.

This is a large, cohesive commit because these fixes are structurally
entangled: the elicitation request/response types, the runtime.Runtime
interface, and App/supervisor/TUI wiring (plus their test doubles) all
change together, and the repo must build as a whole at every commit.

Refs #3584
…runtimes (#3584)

Resolves blocking review finding 3: API/server-created runtimes
(runtimeForSession) never registered an OnElicitationRequest sink — only
pkg/app (the TUI) did — so elicitationHandler's headless fast-decline path
("no sink means no UI") fired for every background-job elicitation raised
through the API, even though RemoteRuntime.OnElicitationRequest's documented
contract promises a remote/SSE client CAN answer them.

runtimeForSession now registers sessionElicitationSink on every runtime it
builds. The sink appends the event to a lazily-created, session-scoped event
log (ensureEventLog/appendSessionEvent), so:

- hasElicitationSink() is true for every API/server session, opting
  background elicitations back into the normal wait-for-response path
  instead of auto-declining;
- the request becomes replayable via GET /api/sessions/:id/events even for a
  session that was never attached via RegisterEventSource;
- it stays answerable via the existing POST .../elicitation route.

Refs #3584
- docs/guides/go-sdk: document that Runtime.ResumeElicitation gained a
  variadic elicitationID parameter and that OnElicitationRequest is a new
  required (no-op-able) interface method, for anyone implementing their own
  runtime.Runtime per the Go SDK guide (should-fix: Go API compatibility).
- docs/features/api-server: note that GET /api/sessions/:id/events is now
  also available once a session has raised an out-of-band event (e.g. a
  background job's elicitation), not only for a --listen-attached run
  (finding 3).

Refs #3584
… runtime

TestRuntimeForSession_RegistersSessionScopedElicitationSink constructed the
runtime but then invoked a freshly created sm.sessionElicitationSink(...)
directly instead of exercising the sink actually registered on the runtime
runtimeForSession returned, so deleting the real
run.OnElicitationRequest(...) registration in runtimeForSession still
passed.

Add LocalRuntime.EmitElicitationRequestForTesting, a seam that invokes
whatever sink is currently registered (exactly as elicitationHandler
would, without the real MCP handshake), and rewrite the test to drive it
through the concrete *runtime.LocalRuntime the session manager actually
built.

Verified this now fails when the registration line in runtimeForSession
is removed (#3584 re-review should-fix 1).
drainInOrder recognized a sequenceMsg (tea.Sequence's ordered replay
marker) purely by its []tea.Cmd shape, which tea.BatchMsg (tea.Batch's
CONCURRENT, unordered marker) shares — so
TestReplayPendingEvent_ReplaysConcurrentElicitationsInFIFOOrder kept
passing even if replayPendingEvent's tea.Sequence were reverted to
tea.Batch, silently defeating the regression test it exists for.

Reject tea.BatchMsg outright and only recurse into a value whose concrete
type is bubbletea's private sequenceMsg.

Verified this now fails when production is temporarily reverted to
tea.Batch (#3584 re-review should-fix 2).
#3584 re-review doc nits:
- 'raised via POST .../elicitation' described the endpoint that RESPONDS
  to an elicitation as the one that raises it; reworded to 'answered via'.
- The events endpoint's docs still said it only applied to a run attached
  via --listen; it now also applies to any API-created session once it
  has raised an out-of-band event (#3584 review item 3), so both the
  table row and the dedicated section were updated to say so.
- firstElicitationID's comment pointed at a CHANGELOG.md compatibility
  note that doesn't exist (this repo's CHANGELOG.md is generated per
  release from merged PRs, not hand-edited alongside a change); it now
  points solely at the docs/guides/go-sdk/index.md note that does exist,
  with a brief explanation of why CHANGELOG.md carries none pre-release.
…elete

ensureEventLog creates a bare, API-only event log (no pump goroutine) the
first time a session raises an out-of-band event without ever being
attached via RegisterEventSource — the common case for a background job's
elicitation on an API/server-created session (#3584 review item 3). It
handed that log a NO-OP cancel function. DeleteSession (and
BatchDeleteSessions) call pe.cancel() unconditionally and drop the
registry entry, but a client already connected to
GET /api/sessions/:id/events for that session kept its reference to the
underlying eventLog: with a no-op cancel it never received a terminal
session_exited event and never saw its stream close, contradicting the
end-of-session contract documented on Server.sessionEvents and
docs/features/api-server/index.md.

Fix: give the lazily-created log a real cancel that calls
log.close("session ended"), matching what RegisterEventSource's pump
already does on exit.

Adds regression coverage (single and batch deletion) that connects a
StreamEvents consumer to a lazily-created log, deletes the session, and
asserts session_exited is delivered and the stream unblocks. Verified
this fails against the no-op cancel and passes with the fix.

For advanced use (custom elicitation, raw event inspection), call `chat.Runtime()` to access the underlying `runtime.Runtime` directly.

> [!WARNING]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

can it impact AP @simonferquel ?

b.ch <- ev
return nil
select {
case b.ch <- ev:
// means the response will reach the caller, never a discarded one.
func (r *LocalRuntime) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any, elicitationID ...string) error {
id := firstElicitationID(elicitationID)
slog.DebugContext(ctx, "Resuming runtime with elicitation response", "agent", r.currentAgentName(), "action", action, "elicitation_id", id)
ok = r.elicitationWaiters.resolveSingle(result)
}
if !ok {
slog.DebugContext(ctx, "No matching elicitation request in progress", "elicitation_id", id)
slog.DebugContext(ctx, "No matching elicitation request in progress", "elicitation_id", id)
return errNoSuchElicitation
}
slog.DebugContext(ctx, "Elicitation response sent successfully", "action", action)
func (r *RemoteRuntime) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any) error {
slog.DebugContext(ctx, "Resuming remote runtime with elicitation response", "agent", r.currentAgent, "action", action, "session_id", r.sessionID)
func (r *RemoteRuntime) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any, elicitationID ...string) error {
id := firstElicitationID(elicitationID)
slog.DebugContext(ctx, "Resuming remote runtime with elicitation response", "agent", r.currentAgent, "action", action, "session_id", r.sessionID)
func (r *RemoteRuntime) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any, elicitationID ...string) error {
id := firstElicitationID(elicitationID)
slog.DebugContext(ctx, "Resuming remote runtime with elicitation response", "agent", r.currentAgent, "action", action, "session_id", r.sessionID, "elicitation_id", id)
@aheritier aheritier added area/api For features/issues/fixes related to the usage of the cagent API area/cli CLI commands, flags, output formatting area/docs Documentation changes area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/tui For features/issues/fixes related to the TUI kind/fix PR fixes a bug (maps to fix:). Use on PRs only. labels Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api For features/issues/fixes related to the usage of the cagent API area/cli CLI commands, flags, output formatting area/docs Documentation changes area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/tui For features/issues/fixes related to the TUI kind/fix PR fixes a bug (maps to fix:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrent background-job elicitations are silently dropped — nothing displayed to the user

2 participants