perf(code): speed up shutdown after Ctrl+C/Ctrl+D (#4351)

Interactive shutdown could spend several seconds in serial teardown
waits after Ctrl+C/Ctrl+D. This tightens the `dcode` shutdown path by
reducing the server SIGTERM wait (`_SHUTDOWN_TIMEOUT` 5s → 3s), sharing
a single checkpoint-existence query across both teardown hints (the
LangSmith link and the `dcode -r` resume hint) instead of running it
twice, and giving the active agent worker a bounded cancellation window
before the Textual app exits so an in-flight run can finish persisting
its trace instead of being torn down mid-request.

The teardown query runs whenever the session owns a thread. An earlier
iteration skipped it when no model turn had completed (`request_count ==
0`), but that proved unsafe: an interrupted first turn can persist a
checkpoint before any usage metadata is recorded, which would silently
drop the resume hint for a thread the user could still resume. Avoiding
the duplicate query keeps the teardown cost to one event-loop spin-up
regardless.

The agent-worker handoff uses Textual's supported `Worker.wait()` API,
wrapped in `asyncio.shield` so the bounded timeout cancels only the wait
and never the worker's own cancellation handler.

Made by [Open
SWE](https://openswe.vercel.app/agents/a3ed8343-0530-8ee7-dec2-11da90162628)

---------

Co-authored-by: Deep Agent <agent@deepagents.dev>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Mason Daugherty <mason@langchain.dev>
Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
John Kennedy
2026-06-28 16:47:07 -07:00
committed by GitHub
parent aa22d6b6d5
commit db441ed306
5 changed files with 484 additions and 36 deletions
+79 -1
View File
@@ -95,6 +95,7 @@ from deepagents_code.widgets.subagent_panel import SubagentPanel
from deepagents_code.widgets.welcome import WelcomeBanner
logger = logging.getLogger(__name__)
_GRACEFUL_EXIT_WAIT_SECONDS = 2.0
_monotonic = time.monotonic
_DEFERRED_START_NOTICE = (
@@ -2200,6 +2201,9 @@ class DeepAgentsApp(App):
self._git_branch_refresh_task: asyncio.Task[None] | None = None
"""Latest background git-branch refresh task, if one is running."""
self._graceful_exit_task: asyncio.Task[None] | None = None
"""Fire-and-forget task for deferred exit after agent worker cancellation."""
self._last_typed_at: float | None = None
"""Typing-aware approval deferral state."""
@@ -9418,6 +9422,15 @@ class DeepAgentsApp(App):
return_code: Exit code (non-zero for errors).
message: Optional message to display on exit.
"""
# A second exit() while a graceful exit is already pending means the
# user is forcing the issue (e.g. mashing Ctrl+D/Ctrl+C). Tear down
# immediately rather than arming another bounded wait — the first call
# already ran cleanup and cancelled the worker, so re-running it would
# only make the force-quit wait out another window.
if self._graceful_exit_task is not None and not self._graceful_exit_task.done():
super().exit(result=result, return_code=return_code, message=message)
return
# Merge in-flight turn stats before any cleanup that might raise.
# When the agent worker is cancelled (e.g. Ctrl+D during a pending tool
# call), the worker's finally block will see _inflight_turn_stats is
@@ -9479,7 +9492,72 @@ class DeepAgentsApp(App):
exc_info=True,
)
restore_iterm_cursor_guide()
super().exit(result=result, return_code=return_code, message=message)
# Defer super().exit() so the agent worker's cancellation handler
# (which, for remote agents, sends a server-side run cancel, and in all
# cases persists interrupt state) has a bounded window to complete
# before the event loop is torn down. This gives the server a chance to
# finish persisting the in-flight run's trace instead of being
# SIGTERM'd mid-request.
agent_worker = self._agent_worker if self._agent_running else None
if agent_worker is not None and not agent_worker.is_finished:
async def _graceful_exit() -> None:
from textual.worker import WorkerCancelled, WorkerFailed
try:
await asyncio.wait_for(
asyncio.shield(agent_worker.wait()),
timeout=_GRACEFUL_EXIT_WAIT_SECONDS,
)
except (asyncio.CancelledError, WorkerCancelled):
# Expected: exit() cancelled the worker above, so its
# cancellation handler ran to completion. Nothing to flag.
logger.debug(
"Agent worker cancelled cleanly before app exit",
exc_info=True,
)
except (TimeoutError, WorkerFailed):
# The worker did not finish within the window, so the
# in-flight run's server-side trace may be incomplete.
# Surface above debug so the loss isn't silent.
logger.warning(
"Agent worker did not finish persisting before app "
"exit; the in-flight run's trace may be incomplete",
exc_info=True,
)
except Exception:
logger.warning(
"Agent worker wait raised unexpectedly before app exit",
exc_info=True,
)
finally:
# This is the only call that stops the event loop, so it
# must run on every path the try/except can take, including
# an unexpected BaseException (e.g. SystemExit) propagating
# out of the wait. Guard the teardown itself so a failure
# here can't leave this fire-and-forget task with an
# unretrieved exception; a non-Exception (SystemExit,
# KeyboardInterrupt) still propagates. Explicit super()
# form: the zero-arg super() can't resolve its implicit
# __class__/self binding inside this nested coroutine, so
# name the class and instance.
try:
super(DeepAgentsApp, self).exit(
result=result,
return_code=return_code,
message=message,
)
except Exception:
logger.warning(
"super().exit() raised during deferred teardown",
exc_info=True,
)
self._graceful_exit_task = asyncio.ensure_future(_graceful_exit())
else:
super().exit(result=result, return_code=return_code, message=message)
def _get_subagent_panel(self) -> SubagentPanel | None:
"""Return the subagent fan-out panel, or None if not yet mounted.
+89 -34
View File
@@ -135,6 +135,82 @@ def _terminal_row_count(console: "Console", text: str) -> int:
return max(1, len(console.render_lines(Text(text), console.options)))
def _should_check_teardown_thread(
thread_id: str | None,
*,
request_count: int,
resume_thread: str | None,
) -> bool:
"""Return whether teardown should query for checkpointed thread content.
Any session that owns a thread may have persisted a checkpoint, so the only
gate is whether a thread exists. `request_count` and `resume_thread` are
accepted and ignored: an interrupted first turn can checkpoint before any
usage metadata is recorded, so they are not a reliable proxy. They remain in
the signature so callers need not change if the gate later grows selective.
"""
del request_count, resume_thread
return bool(thread_id)
def _render_teardown_thread_hints(
console: "Console",
thread_id: str,
*,
return_code: int,
) -> None:
"""Print the LangSmith link and resume hint for a checkpointed thread.
Both hints share a single `thread_exists` lookup to avoid spinning up a
second event loop and aiosqlite connection during teardown. Every failure is
logged at debug and swallowed: teardown convenience output must never crash
the exit path.
Args:
console: Console to print the hints to.
thread_id: Thread whose checkpoints back the hints.
return_code: Process exit code; the resume hint is shown only on a clean
exit (`0`).
"""
from rich.style import Style
from rich.text import Text
from deepagents_code.config import build_langsmith_thread_url
from deepagents_code.sessions import thread_exists
try:
thread_has_checkpoints = asyncio.run(thread_exists(thread_id))
except Exception:
logger.debug(
"Could not check thread existence on teardown",
exc_info=True,
)
return
if not thread_has_checkpoints:
return
try:
thread_url = build_langsmith_thread_url(thread_id)
if thread_url:
console.print()
ls_hint = Text("View this thread in LangSmith: ", style="dim")
ls_hint.append(thread_url, style=Style(dim=True, link=thread_url))
console.print(ls_hint)
except Exception:
logger.debug(
"Could not display LangSmith thread URL on teardown",
exc_info=True,
)
if return_code == 0:
console.print()
console.print("[dim]Resume this thread with:[/dim]")
hint = Text("dcode -r ", style="cyan")
hint.append(str(thread_id), style="cyan")
console.print(hint)
def _confirm_update_after_restart(console: "Console", version: str) -> None:
"""Rewrite the pre-restart `Launching...` line as a stable update status.
@@ -3265,16 +3341,9 @@ def cli_main() -> None:
# Resolve recent-agent fallback only for actual session launches.
assistant_id = _resolve_agent_arg(args)
# Interactive mode - handle thread resume
from rich.style import Style
from rich.text import Text
from deepagents_code.config import (
build_langsmith_thread_url,
)
from deepagents_code.sessions import (
generate_thread_id,
thread_exists,
)
from deepagents_code.sessions import generate_thread_id
# Instead of resolving thread_id here with synchronous asyncio.run()
# DB calls, pass the raw resume request to the TUI and let it
@@ -3354,32 +3423,18 @@ def cli_main() -> None:
console.print(Text(traceback.format_exc(), style="dim"))
sys.exit(1)
# Show LangSmith thread link for threads with checkpointed
# content (same table that backs the `/threads` listing).
if thread_id:
try:
thread_url = build_langsmith_thread_url(thread_id)
if thread_url and asyncio.run(thread_exists(thread_id)):
console.print()
ls_hint = Text("View this thread in LangSmith: ", style="dim")
ls_hint.append(
thread_url,
style=Style(dim=True, link=thread_url),
)
console.print(ls_hint)
except Exception:
logger.debug(
"Could not display LangSmith thread URL on teardown",
exc_info=True,
)
# Show resume hint on exit for threads with checkpointed content.
if thread_id and return_code == 0 and asyncio.run(thread_exists(thread_id)):
console.print()
console.print("[dim]Resume this thread with:[/dim]")
hint = Text("dcode -r ", style="cyan")
hint.append(str(thread_id), style="cyan")
console.print(hint)
# Show LangSmith thread link and resume hint for threads with
# checkpointed content. The `thread_id is not None` check narrows the
# type to `str` for the helper; `_should_check_teardown_thread` gates
# whether the teardown lookup runs at all.
if thread_id is not None and _should_check_teardown_thread(
thread_id,
request_count=result.session_stats.request_count,
resume_thread=args.resume_thread,
):
_render_teardown_thread_hints(
console, thread_id, return_code=return_code
)
# Warn about available update on exit
try:
+1 -1
View File
@@ -39,7 +39,7 @@ never a typed-in address — so it deliberately avoids binding the well-known
_HEALTH_POLL_INTERVAL_LOCAL = 0.1
_HEALTH_POLL_INTERVAL_REMOTE = 0.3
_HEALTH_TIMEOUT = 60
_SHUTDOWN_TIMEOUT = 5
_SHUTDOWN_TIMEOUT = 3
_LOG_TAIL_CHARS = 3000
"""Max chars of subprocess log appended to the early-exit `RuntimeError`
message. Enough to carry a Python traceback without flooding the TUI banner
+190
View File
@@ -7518,6 +7518,196 @@ class TestTerminalBackgroundSync:
app_exit.assert_called_once()
class TestExitGracefulWorkerHandoff:
"""Verify `exit()` defers teardown for an in-flight agent worker."""
async def test_defers_when_agent_worker_unfinished(self) -> None:
"""A running, unfinished worker arms a deferred graceful exit."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._agent_running = True
worker = MagicMock()
worker.is_finished = False
worker.wait = AsyncMock()
app._agent_worker = worker
with patch.object(App, "exit") as super_exit:
app.exit()
# Teardown is deferred, not synchronous.
super_exit.assert_not_called()
assert app._graceful_exit_task is not None
# The deferred task waits on the worker, then tears down.
await app._graceful_exit_task
super_exit.assert_called_once()
worker.wait.assert_awaited_once()
async def test_timeout_does_not_cancel_worker_wait(self) -> None:
"""A timed-out graceful exit must not abort worker cleanup."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
wait_started = asyncio.Event()
wait_done = asyncio.Event()
wait_future: asyncio.Future[None] = asyncio.Future()
wait_cancelled = False
async def wait_worker() -> None:
nonlocal wait_cancelled
wait_started.set()
try:
await wait_future
except asyncio.CancelledError:
wait_cancelled = True
raise
finally:
wait_done.set()
app._agent_running = True
worker = MagicMock()
worker.is_finished = False
worker.wait = AsyncMock(side_effect=wait_worker)
app._agent_worker = worker
with (
patch("deepagents_code.app._GRACEFUL_EXIT_WAIT_SECONDS", 0.01),
patch.object(App, "exit") as super_exit,
):
app.exit()
assert app._graceful_exit_task is not None
await wait_started.wait()
await app._graceful_exit_task
super_exit.assert_called_once()
assert not wait_cancelled
assert not wait_future.cancelled()
wait_future.set_result(None)
await asyncio.wait_for(wait_done.wait(), timeout=1.0)
worker.wait.assert_awaited_once()
@pytest.mark.parametrize(
("error_key", "expected_level", "expected_substring"),
[
("worker_failed", logging.WARNING, "did not finish persisting"),
("unexpected", logging.WARNING, "raised unexpectedly"),
("worker_cancelled", logging.DEBUG, "cancelled cleanly"),
],
)
async def test_graceful_exit_always_tears_down(
self,
error_key: str,
expected_level: int,
expected_substring: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Every exception path through `_graceful_exit` still tears down.
The `finally` block runs `super().exit()` on all branches (the only
call that stops the event loop), and each branch logs at the
documented level.
"""
from textual.worker import WorkerCancelled, WorkerFailed
errors: dict[str, BaseException] = {
"worker_failed": WorkerFailed(ValueError("boom")),
"unexpected": RuntimeError("boom"),
"worker_cancelled": WorkerCancelled("cancelled"),
}
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._agent_running = True
worker = MagicMock()
worker.is_finished = False
worker.wait = AsyncMock(side_effect=errors[error_key])
app._agent_worker = worker
with (
caplog.at_level(logging.DEBUG, logger="deepagents_code.app"),
patch.object(App, "exit") as super_exit,
):
app.exit()
assert app._graceful_exit_task is not None
await app._graceful_exit_task
# The finally block tears down on every exception path.
super_exit.assert_called_once()
matching = [
record
for record in caplog.records
if record.levelno == expected_level and expected_substring in record.message
]
assert matching, (
f"expected a {logging.getLevelName(expected_level)} log containing "
f"{expected_substring!r}"
)
async def test_synchronous_when_worker_finished(self) -> None:
"""A finished worker tears down synchronously with no deferred task."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._agent_running = True
worker = MagicMock()
worker.is_finished = True
worker.wait = AsyncMock()
app._agent_worker = worker
with patch.object(App, "exit") as super_exit:
app.exit()
super_exit.assert_called_once()
assert app._graceful_exit_task is None
worker.wait.assert_not_awaited()
async def test_synchronous_when_agent_not_running(self) -> None:
"""An idle session tears down synchronously even with a worker set."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._agent_running = False
worker = MagicMock()
worker.is_finished = False
worker.wait = AsyncMock()
app._agent_worker = worker
with patch.object(App, "exit") as super_exit:
app.exit()
super_exit.assert_called_once()
assert app._graceful_exit_task is None
worker.wait.assert_not_awaited()
async def test_second_exit_force_quits_pending_graceful_exit(self) -> None:
"""A second exit() during a pending graceful exit force-quits.
Mashing Ctrl+D/Ctrl+C to bail out must not arm a second bounded wait;
it should tear down immediately and leave the first task untouched.
"""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._agent_running = True
worker = MagicMock()
worker.is_finished = False
worker.wait = AsyncMock()
app._agent_worker = worker
with patch.object(App, "exit") as super_exit:
app.exit()
pending = app._graceful_exit_task
assert pending is not None
super_exit.assert_not_called()
# Second press before the loop runs the deferred task.
app.exit()
super_exit.assert_called_once()
# The pending task is not re-armed or replaced.
assert app._graceful_exit_task is pending
pending.cancel()
class TestSlashCommandBypass:
"""Test that certain slash commands bypass the queue gate."""
+125
View File
@@ -19,9 +19,11 @@ from deepagents_code.config import build_langsmith_thread_url, reset_langsmith_u
from deepagents_code.main import (
_auto_install_ripgrep_cli,
_is_managed_ripgrep_path,
_render_teardown_thread_hints,
_restart_current_process,
_ripgrep_install_hint,
_run_startup_auto_update,
_should_check_teardown_thread,
_terminal_row_count,
build_missing_tool_notification,
check_optional_tools,
@@ -1082,6 +1084,129 @@ class TestResumeHintLogic:
assert not show, "No hint when thread_exists returns False"
class TestTeardownThreadCheckpointLookup:
"""Test teardown checkpoint lookup guard behavior."""
def test_checks_fresh_thread_without_requests(self) -> None:
"""Fresh interrupted sessions can checkpoint before usage is recorded."""
should_check = _should_check_teardown_thread(
"test123",
request_count=0,
resume_thread=None,
)
assert should_check
def test_checks_fresh_thread_after_requests(self) -> None:
"""Sessions that made requests may have checkpointed content."""
should_check = _should_check_teardown_thread(
"test123",
request_count=1,
resume_thread=None,
)
assert should_check
def test_checks_resumed_thread_without_new_requests(self) -> None:
"""Resumed sessions can already have checkpoints before new requests."""
should_check = _should_check_teardown_thread(
"test123",
request_count=0,
resume_thread="test123",
)
assert should_check
def test_skips_when_no_thread_id(self) -> None:
"""No final thread means there is nothing to look up."""
should_check = _should_check_teardown_thread(
None,
request_count=1,
resume_thread="test123",
)
assert not should_check
class TestRenderTeardownThreadHints:
"""Test the teardown hint renderer shares one `thread_exists` lookup."""
def _render(
self,
*,
thread_exists_mock: AsyncMock,
thread_url: str | None,
return_code: int = 0,
) -> str:
"""Render the hints with patched dependencies, returning the output."""
buffer = StringIO()
console = Console(file=buffer, width=200)
with (
patch("deepagents_code.sessions.thread_exists", thread_exists_mock),
patch(
"deepagents_code.config.build_langsmith_thread_url",
return_value=thread_url,
),
):
_render_teardown_thread_hints(console, "test123", return_code=return_code)
return buffer.getvalue()
def test_queries_thread_exists_at_most_once(self) -> None:
"""Both hints must share a single checkpoint lookup, never two.
Guards against a regression that reintroduces a second
`asyncio.run(thread_exists(...))` (a fresh event loop + aiosqlite
connection) during teardown.
"""
thread_exists_mock = AsyncMock(return_value=True)
output = self._render(thread_exists_mock=thread_exists_mock, thread_url=None)
thread_exists_mock.assert_awaited_once()
assert "Resume this thread with:" in output
assert "dcode -r test123" in output
def test_prints_langsmith_link_when_available(self) -> None:
"""A configured LangSmith URL is shown alongside the resume hint."""
thread_exists_mock = AsyncMock(return_value=True)
url = "https://smith.langchain.com/o/org/projects/p/proj/t/test123"
output = self._render(thread_exists_mock=thread_exists_mock, thread_url=url)
assert "View this thread in LangSmith:" in output
assert "Resume this thread with:" in output
thread_exists_mock.assert_awaited_once()
def test_no_hints_without_checkpoints(self) -> None:
"""No checkpoint means no link and no resume hint."""
thread_exists_mock = AsyncMock(return_value=False)
output = self._render(thread_exists_mock=thread_exists_mock, thread_url=None)
assert output == ""
thread_exists_mock.assert_awaited_once()
def test_lookup_failure_is_swallowed(self) -> None:
"""A failed checkpoint lookup must not crash teardown or print hints."""
thread_exists_mock = AsyncMock(side_effect=RuntimeError("db locked"))
output = self._render(thread_exists_mock=thread_exists_mock, thread_url=None)
assert output == ""
thread_exists_mock.assert_awaited_once()
def test_resume_hint_omitted_on_error_exit(self) -> None:
"""The resume hint is only shown on a clean exit (return_code 0)."""
thread_exists_mock = AsyncMock(return_value=True)
output = self._render(
thread_exists_mock=thread_exists_mock, thread_url=None, return_code=1
)
assert "Resume this thread with:" not in output
thread_exists_mock.assert_awaited_once()
class TestLangSmithTeardownUrl:
"""Test LangSmith thread URL display logic on teardown."""