fix(code): release the turn when an interrupted worker never starts - #5196
Merged
Conversation
An interrupt can leave the session permanently busy: every later message is queued instead of sent, Esc only pops it back into the chat input, and even `/force-clear` cannot recover. Three paths could strand `_agent_running`: a worker cancelled before its first event-loop step never runs its coroutine (so `_run_agent_task`'s `finally` never calls `_cleanup_agent_task`), the early returns and awaited setup in `_run_agent_task` sat outside that `try/finally`, and `_send_to_agent` reports busy before a worker exists to cancel. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Mason Daugherty (mdrxy)
marked this pull request as ready for review
July 30, 2026 18:04
Tie `_agent_turn_started` to the worker's lifetime by clearing it in `_cleanup_agent_task`. It was only ever reset by `_send_to_agent`, so a completed turn left it set and `_recover_unstarted_agent_worker` bailed for every later turn — the wedge returning one turn on. Its docstring now describes the real lifetime, including the second entry point (`_run_goal_criteria_request`) that runs the coroutine with no worker. Separate setup faults from agent failures. Moving turn setup inside the stream's `try` put local TUI and state errors in reach of the handler that renders `Agent error: ...`, so a widget bug read as a model or backend failure and triggered credential-mismatch detection that cannot apply. A `streaming_started` marker now routes those to a distinct internal-error message. Also: the missing-`_ui_adapter` branch reports instead of returning silently; `_release_unstarted_turn` clears the active-message state, tolerates a torn-down DOM, and surfaces a drain failure the user would otherwise never see; `_release` no longer lets a cleanup failure exit the app through Textual's exception handler; the plugin-reload path releases via `_set_agent_running` so the quiescence event follows; `criteria_request_id` is read before the guard clauses so an early return clears the submitted request. Comments claiming a sole caller or sole release path were false — one falsified by a method added alongside them — and the same wedge narrative was restated at six sites for three different failure modes. The attribute docstring is now the single canonical explanation. Tests: cover later-turn recovery, `/force-clear`, `/restart` with an unstarted worker, the stale-worker re-check, and setup-error labeling. The queue-drain test previously passed whether the message was delivered or dropped; it now fails only the abandoned turn's setup and asserts delivery. The interrupt test asserts its never-stepped precondition rather than relying on a comment.
Mason Daugherty (mdrxy)
pushed a commit
that referenced
this pull request
Jul 31, 2026
> [!CAUTION] > Merging this PR will automatically publish to **PyPI** and create a **GitHub release**. For the full release process, see [`.github/RELEASING.md`](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md). --- _Release notes preview: keep this section in sync with the package `CHANGELOG.md`. Publish reads the merged CHANGELOG via `release.yml`, not this PR description — keep them aligned anyway so the PR stays an accurate historical record for reviewers and anyone returning later._ --- ## [0.1.51](deepagents-code==0.1.50...deepagents-code==0.1.51) (2026-07-31) ### Features - The status bar and usage view now show the running session cost. ([#5036](#5036)) - Removed redundant `shell` and `web_search` prompt guidance. ([#5213](#5213)) - After switching threads, Deep Agents now points back to the previous thread. ([#5172](#5172)) - Leaving `/mcp` with pending toggles now prompts you to reconnect. ([#5211](#5211)) - `dcode config get` now accepts configuration sections. ([#5134](#5134)) ### Fixes - Kept the `/goal` criteria prompt responsive. ([#5142](#5142)) - Improved goal handling so underspecified objectives can be resolved from conversation context. ([#5201](#5201)) - Released the turn when an interrupted worker never starts. ([#5196](#5196)) - Hid timestamp footers together with their associated rows. ([#5167](#5167)) - Fixed editable SDK detection by scanning and correlating SDK locations more accurately. ([#5199](#5199)) - Improved `doctor` output to explain why it may not have a latest-version answer. ([#5209](#5209)) _End release notes preview._ --- > [!NOTE] > A **New Contributors** section is appended to the GitHub release notes automatically at publish time (see [Release Pipeline](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md#release-pipeline), step 2). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: langchain-oss-automated-triage[bot] <248757908+langchain-oss-automated-triage[bot]@users.noreply.github.com>
Mason Daugherty (mdrxy)
added a commit
that referenced
this pull request
Aug 4, 2026
Interrupting a turn, or exiting while a background thread-cache refresh is in flight, no longer strands an unawaited coroutine or an open SQLite handle. --- The unit suite emitted three families of warnings, all at teardown. Two are ours and are fixed here; the third is upstream and is filtered. ### An unawaited turn coroutine `_send_to_agent` handed `run_worker` an already-built `_run_agent_task` coroutine. Textual never runs the work of a worker cancelled before its first event-loop step, so that coroutine was finalized unawaited — a `RuntimeWarning`, and (once interpreter teardown had gone far enough to break the import machinery its cleanup relies on) an unraisable `KeyError: '__import__'`. Passing a callable means the coroutine only exists if the worker actually runs, so there is nothing to strand. Several tests were closing that coroutine by hand purely to silence the warning; those workarounds are gone, and a new test pins the callable contract. **Where it started:** #5196. Handing `run_worker` a coroutine dates all the way back to the original Textual REPL (#686), but it was harmless until something cancelled a worker before its first step. #5196 added the recovery path for exactly that situation along with the tests that exercise it. Its parent commit runs the app test module with zero `never awaited` warnings; #5196 itself produces three. ### An unclosed SQLite handle `aiosqlite` opens the database on its worker thread and hands the raw `sqlite3.Connection` back through a future, recording it on the connection only when the awaiting coroutine resumes. A cancel landing anywhere in that window left the handle unreachable from the cleanup that follows, so the garbage collector reported `ResourceWarning: unclosed database`. There are two halves to the window, and both are now covered: - Cancelled while the worker is still opening, the library has no handle recorded yet, so the cleanup it queues closes nothing. The session module now records the handle from the worker thread the moment the connector returns. - Cancelled after the handle is delivered but before the coroutine resumes, the library clears its own reference before that queued cleanup can run — so it again closes nothing. The guard now also queues an explicit close ahead of the library's cleanup, while the handle is still reachable. Both closes run on the thread that opened the handle, and closing twice is a no-op, so neither disturbs a normal shutdown. `get_checkpointer` builds its connection through the same helper rather than `AsyncSqliteSaver.from_conn_string`, so it gets the same guard. **Where it started:** #5174. The prewarm that reads the session database has existed since #1481, but it ran once at startup, so it had normally finished before anything cancelled it. #5174 re-fires it after every turn, which reliably leaves a session-DB read in flight when a test app exits. Counting handles that `aiosqlite` opened and never closed across the goal-command tests: zero on the parent commit, fourteen on #5174. ### A `typing` deprecation from `google-genai` `google.genai.types` builds a union alias out of `typing._UnionGenericAlias`, which CPython 3.14 deprecates, and it fires at import before any of this package's code runs. This one is not a regression from any change here — `deepagents-code` has been tested on the 3.14 leg since the package was created in #3027, and the warning appears wherever a test imports the Google integration. It is tracked upstream as [googleapis/python-genai#1640](googleapis/python-genai#1640) and still unfixed as of `google-genai` 2.13.0, so it is filtered narrowly (message, category, and module) rather than worked around. Made by [Open SWE](https://openswe.vercel.app/agents/f42590ef-0fe4-0b9f-6880-1c48b28446d5) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
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.
Pressing
Escto interrupt the agent could leave the session permanently stuck, unable to accept any new work.What users saw
You interrupt a turn, then try to send a follow-up message. Instead of going to the agent, it shows up dimmed — queued behind a turn that already ended and will never finish. From there:
Escjust pops the text back into the input box ("Queued message moved to input"), and re-submitting queues it again./force-cleardoesn't help.Why it happened
The session tracks whether the agent is busy with a single flag. While that flag is set, typed messages are queued instead of sent. Only one piece of code clears it: the cleanup that runs when a turn finishes.
The bug is that a turn can end without ever reaching its cleanup. Three ways that happened:
RUNNINGthe moment it's created, before its code actually begins. Interrupting inside that window throws the whole turn away without running any of it — including the cleanup — while the worker still looks alive from the outside. Later interrupts just re-cancel something already dead._run_agent_taskhad two early-return guard clauses, and it persisted goal/rubric state before entering itstry. A rejected turn, or an interrupt landing during that setup, skipped cleanup entirely._send_to_agentsets it first so the UI reacts immediately, then does someawaited setup before starting the worker. Bailing out during that setup left the session busy with no worker at all.The fix
Make it explicit who is responsible for releasing the flag in each case:
trywhosefinallydoes the cleanup, so any early exit still releases the turn._send_to_agentreleases the flag itself when it never got as far as starting a worker._agent_turn_startedmarker lets the interrupt paths tell "cancelled before it ran" apart from a live turn, and release it directly.A message queued behind an abandoned turn is now drained rather than stranded.
Made by Open SWE