Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 135 additions & 88 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,17 @@ class _ConfigWriteResult:
_UPDATE_RECHECK_INTERVAL_SECONDS = 60 * 60
"""How often long-running TUI sessions quietly re-check for app updates."""

_CONCURRENT_UPDATE_MESSAGE = (
"An update is already being installed. Try again once it finishes."
)
"""Shown when the update install lock is held.

Deliberately does not name *another* session as the holder: the lock also
refuses a second install started from this one (`/update` racing the update
notification), and blaming a nonexistent other terminal would send the user
looking for something to wait on that isn't there.
"""

_MODAL_WATCHDOG_TIMEOUT_SECONDS = 600.0
"""Upper bound on awaiting a confirmation modal's dismissal.

Expand Down Expand Up @@ -6415,15 +6426,26 @@ async def _perform_app_upgrade(
upgrade_include_prereleases: bool | None,
pin_upgrade_version: str | None,
) -> None:
"""Serialize an app upgrade against every other environment mutation."""
"""Serialize an app upgrade against every other environment mutation.

`_environment_mutation_lock` only covers this process, so the
cross-process update lock is taken too: a concurrent terminal may
already be installing into the same tool environment.
"""
from deepagents_code.update_check import update_install_lock

async with self._environment_mutation_lock:
await self._perform_app_upgrade_unlocked(
current=current,
latest=latest,
include_prereleases=include_prereleases,
upgrade_include_prereleases=upgrade_include_prereleases,
pin_upgrade_version=pin_upgrade_version,
)
with update_install_lock() as holding_update_lock:
if not holding_update_lock:
await self._mount_message(AppMessage(_CONCURRENT_UPDATE_MESSAGE))
return
await self._perform_app_upgrade_unlocked(
current=current,
latest=latest,
include_prereleases=include_prereleases,
upgrade_include_prereleases=upgrade_include_prereleases,
pin_upgrade_version=pin_upgrade_version,
)

async def _perform_app_upgrade_unlocked(
self,
Expand Down Expand Up @@ -6547,12 +6569,24 @@ async def _refresh_dependencies(
include_prereleases: bool | None,
app_update_version: str | None = None,
) -> None:
"""Serialize dependency refresh against all environment mutations."""
"""Serialize dependency refresh against all environment mutations.

A refresh runs `uv tool install -U deepagents-code==<current>`, which
replaces the installed distribution just as an upgrade does, so it takes
the cross-process update lock for the same reason `_perform_app_upgrade`
does: `_environment_mutation_lock` only covers this process.
"""
from deepagents_code.update_check import update_install_lock

async with self._environment_mutation_lock:
await self._refresh_dependencies_unlocked(
include_prereleases=include_prereleases,
app_update_version=app_update_version,
)
with update_install_lock() as holding_update_lock:
if not holding_update_lock:
await self._mount_message(AppMessage(_CONCURRENT_UPDATE_MESSAGE))
return
await self._refresh_dependencies_unlocked(
include_prereleases=include_prereleases,
app_update_version=app_update_version,
)

async def _refresh_dependencies_unlocked(
self,
Expand Down Expand Up @@ -22034,6 +22068,7 @@ async def _handle_update_action(
format_shadowed_dcode_warning,
mark_update_notified,
perform_upgrade,
update_install_lock,
)

if action_id == ActionId.INSTALL:
Expand All @@ -22050,92 +22085,104 @@ async def _handle_update_action(

from deepagents_code.tui.widgets.update_progress import UpdateProgressScreen

cmd = payload.upgrade_cmd
log_path = create_update_log_path()
screen = UpdateProgressScreen(
latest=payload.latest,
command=cmd,
log_path=log_path,
)
progress_modal_visible = not isinstance(self.screen, ModalScreen)
if progress_modal_visible:
await self.push_screen(screen)
else:
self.notify(
f"Updating to v{payload.latest}... Logs: {log_path}",
severity="information",
timeout=8,
markup=False,
)
self._update_install_running = True
try:
if os.environ.get(DEBUG_UPDATE):
await self._run_debug_update_install(
entry=entry,
payload=payload,
screen=screen,
log_path=log_path,
show_toast=not progress_modal_visible,
# Claimed before any progress UI is shown so a session that loses
# the race never opens a modal it would have to abandon.
with update_install_lock() as holding_update_lock:
if not holding_update_lock:
self.notify(
_CONCURRENT_UPDATE_MESSAGE,
severity="information",
timeout=6,
markup=False,
)
return
success, output = await perform_upgrade(
progress=screen.append_line,
log_path=log_path,
target_version=payload.latest,
)
if success:
self._notice_registry.remove(entry.key)
# Same shadowing risk as `/update`: if a stale `dcode` is
# earlier on PATH, the user's next launch will silently
# run the old version. Surface that loudly even when only
# a toast is visible. Keep the modal itself out of the
# success state when relaunching would keep using the old
# binary.
shadow = await asyncio.to_thread(detect_shadowed_dcode_safe)
if shadow is not None:
warning = format_shadowed_dcode_warning(shadow)
if progress_modal_visible:
screen.mark_warning(

self._update_install_running = True
try:
cmd = payload.upgrade_cmd
log_path = create_update_log_path()
screen = UpdateProgressScreen(
latest=payload.latest,
command=cmd,
log_path=log_path,
)
progress_modal_visible = not isinstance(self.screen, ModalScreen)
if progress_modal_visible:
await self.push_screen(screen)
else:
self.notify(
f"Updating to v{payload.latest}... Logs: {log_path}",
severity="information",
timeout=8,
markup=False,
)
if os.environ.get(DEBUG_UPDATE):
await self._run_debug_update_install(
entry=entry,
payload=payload,
screen=screen,
log_path=log_path,
show_toast=not progress_modal_visible,
)
return
success, output = await perform_upgrade(
progress=screen.append_line,
log_path=log_path,
target_version=payload.latest,
)
if success:
self._notice_registry.remove(entry.key)
# Same shadowing risk as `/update`: if a stale `dcode` is
# earlier on PATH, the user's next launch will silently
# run the old version. Surface that loudly even when only
# a toast is visible. Keep the modal itself out of the
# success state when relaunching would keep using the old
# binary.
shadow = await asyncio.to_thread(detect_shadowed_dcode_safe)
if shadow is not None:
warning = format_shadowed_dcode_warning(shadow)
if progress_modal_visible:
screen.mark_warning(
warning,
copy_text=format_shadowed_dcode_fix_command(shadow),
)
self.notify(
warning,
copy_text=format_shadowed_dcode_fix_command(shadow),
severity="warning",
timeout=20,
markup=False,
)
return
screen.mark_success()
if progress_modal_visible:
return
self.notify(
warning,
severity="warning",
timeout=20,
f"Updated to v{payload.latest}. "
"Quit and relaunch dcode to use the new version.",
severity="information",
timeout=10,
markup=False,
)
return
screen.mark_success()
if progress_modal_visible:
return
logger.warning(
"Auto-upgrade failed for v%s. Output:\n%s",
payload.latest,
output,
)
self._notice_registry.remove(entry.key)
screen.mark_failure(cmd)
snippet = _truncate(output, limit=160) if output else ""
message = f"Auto-update failed. Run manually: {cmd}"
if snippet:
message = f"{message}\n{snippet}"
self.notify(
f"Updated to v{payload.latest}. "
"Quit and relaunch dcode to use the new version.",
severity="information",
timeout=10,
message,
severity="warning",
timeout=15,
markup=False,
)
return
logger.warning(
"Auto-upgrade failed for v%s. Output:\n%s",
payload.latest,
output,
)
self._notice_registry.remove(entry.key)
screen.mark_failure(cmd)
snippet = _truncate(output, limit=160) if output else ""
message = f"Auto-update failed. Run manually: {cmd}"
if snippet:
message = f"{message}\n{snippet}"
self.notify(
message,
severity="warning",
timeout=15,
markup=False,
)
finally:
self._update_install_running = False
finally:
self._update_install_running = False
return
if action_id == ActionId.SKIP_VERSION:
await asyncio.to_thread(mark_update_notified, payload.latest)
Expand Down
Loading