Skip to content

Clear Working state when a model call fails before a response - #120

Merged
github-actions[bot] merged 1 commit into
mainfrom
fix/stuck-working-on-provider-error
Aug 1, 2026
Merged

Clear Working state when a model call fails before a response#120
github-actions[bot] merged 1 commit into
mainfrom
fix/stuck-working-on-provider-error

Conversation

@mohitranka

Copy link
Copy Markdown
Contributor

Summary

  • Found in the same usability audit that surfaced Fail turns whose model text looks like an unparsed tool call #119: after a provider/HTTP error (or any error occurring before apply_model_response ever produces a ModelResponse), the session's TaskLifecycle stayed stuck at Working forever. The header showed a permanently-animating "Working" spinner, and every later message silently joined the queue and never dispatched — confirmed to survive switching to a completely different, healthy provider. Only killing and restarting the process cleared it.
  • Root cause: drain_pending_prompt in crates/forge-tui/src/app/turn.rs funnels every non-success outcome (provider/HTTP error, task-join error, or apply_model_response itself returning Err) into a shared outcome_err variable. The success path (apply_model_response returning Ok) has full lifecycle-transition logic; this error path only cleared the TUI-local busy flag and called report_error for the on-screen banner — nothing ever moved AgentSession::active_task.lifecycle out of Working. Since the message queue's dispatch gate (classify_input) and start_new_task both refuse to act while lifecycle == Working, and provider switches never touch that field either, the session was permanently soft-locked.
  • Fix: added AgentSession::mark_model_call_failed (crates/forge-core/src/lib.rs), mirroring the existing mark_cancelled's shape — a lifecycle-only transition (Working|Waiting -> Failed, TransitionReason::TurnFailure) with no synthetic assistant message, since the TUI's report_error already shows the failure to the operator and duplicating it into the transcript would just be noise. Wired into the non-cancel branch of outcome_err handling in turn.rs.

Test plan

  • cargo test --package forge-tui — 671/671, including a new regression test edge_provider_error_unsticks_session_for_the_next_message (fails a turn, then sends a second message and asserts it dispatches immediately rather than queuing) and a new lifecycle assertion added to the pre-existing edge_network_stream_interruption_preserves_partial_response (which exercises the same code path but never checked the session lifecycle before).
  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets --locked -- -D warnings, cargo test --workspace --all-targets --locked — all clean.
  • Re-ran the exact live repro against the real release binary: triggered the still-open cache_control P0 on OpenAI Codex (header went ✗ Failed · Model call failed instead of stuck Working), then switched to Ollama and sent two more messages through two more real errors (gemma3:4b does not support tools, then the dangling-tool-call catch from Fail turns whose model text looks like an unparsed tool call #119) — every message dispatched immediately, none silently queued, no stuck state at any point.

A provider/HTTP error, a task-join error, or apply_model_response
itself returning Err before its own transition logic ran all funnel
into drain_pending_prompt's outcome_err handling in
crates/forge-tui/src/app/turn.rs. Unlike the success path (which goes
through apply_model_response's evaluator and always terminates the
lifecycle one way or another), this error path only cleared the TUI's
local `busy` flag and left the session's TaskLifecycle stuck at
Working.

Since the message queue's dispatch gate and start_new_task both refuse
to act while Working, a single failed request permanently soft-locked
the whole session: every later message queued and never sent, even
after switching to a completely different, healthy provider. Only
killing and restarting the process cleared it.

Add AgentSession::mark_model_call_failed (mirrors mark_cancelled's
shape: a lifecycle-only transition, no synthetic assistant message,
since the TUI already shows the error via its own banner) and call it
from the non-cancel branch of outcome_err handling.

Adds a regression test that fails a turn then sends a second message
and asserts it dispatches immediately rather than queuing, plus a
lifecycle assertion on the pre-existing partial-stream-interruption
test (which exercises the same code path but never checked this).
@github-actions
github-actions Bot enabled auto-merge (squash) August 1, 2026 13:13
@github-actions
github-actions Bot merged commit f2e8f73 into main Aug 1, 2026
6 checks passed
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.

1 participant