From 175dfd94dce48de14683c9fdffa76c60c51c9a5b Mon Sep 17 00:00:00 2001 From: Mason Daugherty <61371264+mdrxy@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:27:43 +0000 Subject: [PATCH] perf(code): refresh `/threads` cache after each turn The `/threads` picker paints from an in-memory cache that was only filled once at startup, so a thread created mid-session was missing from the first paint until the modal's database query, agent scan, and row rebuild finished. Refreshing the cache at turn end removes that wait. Co-authored-by: open-swe[bot] --- libs/code/deepagents_code/app.py | 21 +++++++++++++ libs/code/deepagents_code/sessions.py | 8 ++++- libs/code/tests/unit_tests/test_app.py | 41 ++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index 9ed71f2f9ad..c88f0968b28 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -5436,6 +5436,24 @@ async def _prewarm_threads_cache(self) -> None: # noqa: PLR6301 # Worker hook await prewarm_thread_message_counts(limit=get_thread_limit()) + def _schedule_thread_cache_refresh(self) -> None: + """Refresh the cached `/threads` rows in the background. + + The selector paints from the in-memory cache first and only re-queries + the session database afterwards, so a thread created mid-session would + otherwise be missing from the first paint until that query, the agent + scan, and a full row rebuild finish. Refreshing once a turn has written + its checkpoints keeps the cache current, so `/threads` shows the new + thread (and its updated timestamp and message count) immediately. + """ + if self._exit or not self.is_running: + return + self.run_worker( + self._prewarm_threads_cache, + exclusive=True, + group="thread-cache-refresh", + ) + async def _prewarm_model_caches(self) -> None: """Prewarm model discovery and profile caches without blocking startup.""" try: @@ -15050,6 +15068,9 @@ async def _cleanup_agent_task( # `_goal_state_lock` — would never be woken and would deadlock. if not self._agent_running and not self._agent_reconciling: self._agent_quiescent.set() + # Scheduled after goal reconciliation so the refreshed rows include + # every checkpoint this turn produced. + self._schedule_thread_cache_refresh() @staticmethod def _convert_messages_to_data(messages: list[Any]) -> list[MessageData]: diff --git a/libs/code/deepagents_code/sessions.py b/libs/code/deepagents_code/sessions.py index 257a622b4df..6f8b952199c 100644 --- a/libs/code/deepagents_code/sessions.py +++ b/libs/code/deepagents_code/sessions.py @@ -499,7 +499,13 @@ async def prewarm_thread_message_counts(limit: int | None = None) -> None: Fetches a bounded list of recent threads and populates checkpoint-derived fields for currently visible columns into the in-memory cache. Intended to - run in a background worker during app startup. + run in a background worker during app startup and again whenever the + session database has changed (e.g. after a turn writes new checkpoints), so + the selector's first paint is never missing a thread the user just created. + + Re-running this is cheap: the per-thread message-count and initial-prompt + caches are keyed on checkpoint freshness, so only threads whose latest + checkpoint changed are read back from disk. Args: limit: Maximum threads to prewarm. Uses `get_thread_limit()` when `None`. diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index d2a54127800..a9f1dcf766b 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -820,6 +820,34 @@ async def test_cleanup_agent_task_schedules_git_branch_refresh(self) -> None: drain_mock.assert_awaited_once() queue_mock.assert_awaited_once() + async def test_cleanup_agent_task_refreshes_thread_cache(self) -> None: + """Agent cleanup should refresh cached `/threads` rows after a turn.""" + app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123") + refresh_mock = MagicMock() + app._process_next_from_queue = AsyncMock() # ty: ignore + app._maybe_drain_deferred = AsyncMock() # ty: ignore + app._set_spinner = AsyncMock() # ty: ignore + app._schedule_git_branch_refresh = MagicMock() # ty: ignore + app._schedule_thread_cache_refresh = refresh_mock # ty: ignore + + await app._cleanup_agent_task() + + refresh_mock.assert_called_once_with() + + async def test_schedule_thread_cache_refresh_noops_during_exit(self) -> None: + """Shutdown should prevent new background thread-cache refreshes.""" + app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123") + + async with app.run_test() as pilot: + await pilot.pause() + app._exit = True + run_worker_mock = MagicMock() + app.run_worker = run_worker_mock # ty: ignore + + app._schedule_thread_cache_refresh() + + run_worker_mock.assert_not_called() + async def test_schedule_git_branch_refresh_noops_during_exit(self) -> None: """Shutdown should prevent new background git refresh tasks.""" app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123") @@ -2331,6 +2359,19 @@ async def test_prewarm_uses_current_thread_limit(self) -> None: mock_prewarm.assert_awaited_once_with(limit=7) + async def test_schedule_refresh_runs_prewarm_in_worker(self) -> None: + """Scheduling a refresh should re-run the prewarm off the event loop.""" + app = DeepAgentsApp() + + async with app.run_test() as pilot: + await pilot.pause() + prewarm_mock = AsyncMock() + app._prewarm_threads_cache = prewarm_mock # ty: ignore + app._schedule_thread_cache_refresh() + await pilot.pause() + + prewarm_mock.assert_awaited_once_with() + async def test_show_thread_selector_uses_cached_rows(self) -> None: """Thread selector should receive prefetched rows when available.""" cached_threads = [