mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(code): avoid repeated startup auto-update stalls (#4648)
Startup auto-update retried the same cached target on every launch after a failure, which could strand users before the TUI started. This adds a same-version cooldown, skips opportunistic auto-update for `--resume`, and terminates updater process groups on timeout so stale children do not hold locks. Focused unit coverage was added for the cooldown, resume gating, and timeout cleanup paths. Made by [Open SWE](https://openswe.vercel.app/agents/3e0e7320-f512-6d6a-5f53-e5c08d2f899c) --------- 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:
@@ -41,6 +41,11 @@ logger = logging.getLogger(__name__)
|
||||
_SANDBOX_DEFAULT_SENTINEL = "\x00default"
|
||||
"""Marker stored by `--sandbox` with no value, resolved to `[sandboxes].default`."""
|
||||
|
||||
_UNPERSISTED_AUTO_UPDATE_FAILURE_NOTE = (
|
||||
"\n[yellow]Note:[/yellow] this failure could not be recorded, so dcode will "
|
||||
"retry this update on the next launch until the state directory becomes writable."
|
||||
)
|
||||
|
||||
|
||||
def _handle_termination_signal(signum: int, _frame: object) -> NoReturn:
|
||||
"""Unwind dcode on a terminating signal so owned resources are cleaned up.
|
||||
@@ -305,6 +310,7 @@ def _run_startup_auto_update(console: "Console") -> None:
|
||||
from deepagents_code._version import __version__ as cli_version
|
||||
from deepagents_code.config import _is_editable_install
|
||||
from deepagents_code.update_check import (
|
||||
clear_startup_auto_update_failure,
|
||||
create_update_log_path,
|
||||
detect_shadowed_dcode_safe,
|
||||
format_release_age_parenthetical,
|
||||
@@ -314,12 +320,19 @@ def _run_startup_auto_update(console: "Console") -> None:
|
||||
is_installed_version_at_least,
|
||||
is_update_check_enabled,
|
||||
mark_auto_update_default_acknowledged,
|
||||
mark_startup_auto_update_failed,
|
||||
perform_upgrade,
|
||||
release_requires_prereleases,
|
||||
should_announce_auto_update_default,
|
||||
should_skip_startup_auto_update_after_failure,
|
||||
upgrade_command,
|
||||
)
|
||||
|
||||
# Set to the target version while an upgrade attempt is in flight, and
|
||||
# cleared on success. If `perform_upgrade` *raises* instead of returning a
|
||||
# failure, the fail-soft handler below records the cooldown from this so the
|
||||
# same broken target is not retried — and re-stalled — on every launch.
|
||||
pending_failure_version: str | None = None
|
||||
try:
|
||||
if (
|
||||
_is_editable_install()
|
||||
@@ -368,6 +381,23 @@ def _run_startup_auto_update(console: "Console") -> None:
|
||||
highlight=False,
|
||||
)
|
||||
return
|
||||
if should_skip_startup_auto_update_after_failure(latest):
|
||||
# A same-version upgrade failed recently; retrying it here would
|
||||
# very likely fail again and re-stall every launch (this runs before
|
||||
# the TUI). Skip for the cooldown window and point at a manual
|
||||
# command instead.
|
||||
update_needs_prereleases = release_requires_prereleases(latest)
|
||||
cmd = upgrade_command(
|
||||
include_prereleases=True if update_needs_prereleases else None,
|
||||
version=latest if update_needs_prereleases else None,
|
||||
)
|
||||
console.print(
|
||||
f"[bold yellow]Warning:[/bold yellow] Skipping automatic update to "
|
||||
f"v{latest} after a recent failed attempt. Update manually: "
|
||||
f"[cyan]{cmd}[/cyan]\nContinuing with v{cli_version}.",
|
||||
highlight=False,
|
||||
)
|
||||
return
|
||||
if should_announce_auto_update_default():
|
||||
# First-run consent/migration: auto-update is on only because of the
|
||||
# opt-out default, not an explicit choice. Announce it once and skip
|
||||
@@ -411,10 +441,13 @@ def _run_startup_auto_update(console: "Console") -> None:
|
||||
highlight=False,
|
||||
markup=False,
|
||||
)
|
||||
pending_failure_version = latest
|
||||
success, output = asyncio.run(
|
||||
perform_upgrade(log_path=log_path, target_version=latest)
|
||||
)
|
||||
if success:
|
||||
pending_failure_version = None
|
||||
clear_startup_auto_update_failure(latest)
|
||||
# If a stale `dcode` is earlier on PATH, the auto-restart would
|
||||
# re-exec into the old binary and the user would silently keep
|
||||
# running the pre-upgrade version. Detect that *before* the
|
||||
@@ -463,29 +496,43 @@ def _run_startup_auto_update(console: "Console") -> None:
|
||||
highlight=False,
|
||||
)
|
||||
return
|
||||
persisted = mark_startup_auto_update_failed(latest)
|
||||
update_needs_prereleases = release_requires_prereleases(latest)
|
||||
cmd = upgrade_command(
|
||||
include_prereleases=True if update_needs_prereleases else None,
|
||||
version=latest if update_needs_prereleases else None,
|
||||
)
|
||||
detail = f": {escape(output[:200])}" if output else ""
|
||||
console.print(
|
||||
message = (
|
||||
f"[bold red]Auto-update failed{detail}[/bold red]\n"
|
||||
f"Run manually: [cyan]{cmd}[/cyan]\n"
|
||||
f"Continuing with v{cli_version}.",
|
||||
markup=True,
|
||||
highlight=False,
|
||||
f"Continuing with v{cli_version}."
|
||||
)
|
||||
if not persisted:
|
||||
# The cooldown marker could not be saved (e.g. a read-only state
|
||||
# dir), so this same failing upgrade would otherwise be retried on
|
||||
# every launch. Surface it rather than silently re-stalling, the
|
||||
# way the consent-announce path surfaces its un-persisted state.
|
||||
message += _UNPERSISTED_AUTO_UPDATE_FAILURE_NOTE
|
||||
console.print(message, markup=True, highlight=False)
|
||||
except SystemExit:
|
||||
# Process replacement (and test doubles that simulate it) must not be
|
||||
# swallowed by the fail-soft handler below.
|
||||
raise
|
||||
except Exception:
|
||||
logger.warning("Startup auto-update failed", exc_info=True)
|
||||
console.print(
|
||||
message = (
|
||||
"[bold yellow]Warning:[/bold yellow] Auto-update failed before startup; "
|
||||
"continuing with the installed version."
|
||||
)
|
||||
if pending_failure_version is not None:
|
||||
# An exception escaped the upgrade attempt itself (not a returned
|
||||
# failure), so the returned-failure branch never marked it. Record
|
||||
# the cooldown here so the same target is not retried every launch.
|
||||
persisted = mark_startup_auto_update_failed(pending_failure_version)
|
||||
if not persisted:
|
||||
message += _UNPERSISTED_AUTO_UPDATE_FAILURE_NOTE
|
||||
console.print(message, markup=True, highlight=False)
|
||||
|
||||
|
||||
def _resolve_agent_arg(args: argparse.Namespace) -> str:
|
||||
@@ -3672,7 +3719,26 @@ def cli_main() -> None:
|
||||
sys.exit(130)
|
||||
sys.exit(exit_code)
|
||||
else:
|
||||
_run_startup_auto_update(console)
|
||||
resume_thread = args.resume_thread # "__MOST_RECENT__", "<id>", or None
|
||||
if resume_thread is None:
|
||||
# A normal (non-resume) launch runs the update path and resets
|
||||
# the resume grace period, so a later resume-only stretch starts
|
||||
# a fresh deferral window rather than inheriting a stale one.
|
||||
from deepagents_code.update_check import (
|
||||
clear_resume_auto_update_deferral,
|
||||
)
|
||||
|
||||
clear_resume_auto_update_deferral()
|
||||
_run_startup_auto_update(console)
|
||||
else:
|
||||
# Keep immediate resume launches uninterrupted, but do not let
|
||||
# a resume-only workflow bypass startup updates indefinitely.
|
||||
from deepagents_code.update_check import (
|
||||
should_defer_startup_auto_update_for_resume,
|
||||
)
|
||||
|
||||
if not should_defer_startup_auto_update_for_resume():
|
||||
_run_startup_auto_update(console)
|
||||
# Resolve recent-agent fallback only for actual session launches.
|
||||
assistant_id = _resolve_agent_arg(args)
|
||||
# Interactive mode - handle thread resume
|
||||
@@ -3683,7 +3749,6 @@ def cli_main() -> None:
|
||||
# Instead of resolving thread_id here with synchronous asyncio.run()
|
||||
# DB calls, pass the raw resume request to the TUI and let it
|
||||
# resolve asynchronously during startup.
|
||||
resume_thread = args.resume_thread # "__MOST_RECENT__", "<id>", or None
|
||||
thread_id = None if resume_thread else generate_thread_id()
|
||||
|
||||
# Validate sandbox provider deps before spawning server subprocess
|
||||
|
||||
@@ -20,6 +20,7 @@ import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -138,6 +139,9 @@ since that one-liner is the path we promote.
|
||||
_UPGRADE_TIMEOUT = 120 # seconds
|
||||
"""Wall-clock cap for `perform_upgrade` and `perform_install_extra`."""
|
||||
|
||||
_TERMINATE_WAIT_TIMEOUT = 5 # seconds
|
||||
"""Cap on reaping a killed install subprocess so cleanup cannot hang launch."""
|
||||
|
||||
INSTALL_SCRIPT_COMMAND = "curl -LsSf https://langch.in/dcode | bash"
|
||||
"""Promoted public install command for Deep Agents Code."""
|
||||
|
||||
@@ -150,6 +154,20 @@ UPDATE_LOG_RETENTION_DAYS = 14
|
||||
UPDATE_LOG_MAX_FILES = 10
|
||||
"""Keep at most this many newest update logs."""
|
||||
|
||||
STARTUP_AUTO_UPDATE_FAILURE_COOLDOWN = CACHE_TTL
|
||||
"""Seconds to suppress same-version startup auto-update retries after failure."""
|
||||
|
||||
RESUME_AUTO_UPDATE_GRACE_PERIOD = 7 * 24 * 60 * 60
|
||||
"""Seconds resumed sessions may bypass the startup auto-update path."""
|
||||
|
||||
_STARTUP_AUTO_UPDATE_FAILED_VERSION_KEY = "startup_auto_update_failed_version"
|
||||
_STARTUP_AUTO_UPDATE_FAILED_AT_KEY = "startup_auto_update_failed_at"
|
||||
_STARTUP_AUTO_UPDATE_FAILURE_KEYS: tuple[str, ...] = (
|
||||
_STARTUP_AUTO_UPDATE_FAILED_VERSION_KEY,
|
||||
_STARTUP_AUTO_UPDATE_FAILED_AT_KEY,
|
||||
)
|
||||
_RESUME_AUTO_UPDATE_DEFERRED_AT_KEY = "resume_auto_update_deferred_at"
|
||||
|
||||
UpgradeProgressCallback = Callable[[str], Awaitable[None] | None]
|
||||
|
||||
|
||||
@@ -928,6 +946,64 @@ def _write_update_state(
|
||||
return True
|
||||
|
||||
|
||||
def should_defer_startup_auto_update_for_resume() -> bool:
|
||||
"""Return whether a resumed session is still in its update grace period.
|
||||
|
||||
The first resumed launch starts a fixed grace period. Later resumes do not
|
||||
extend it, so a resume-only workflow eventually follows the normal startup
|
||||
auto-update path. If the marker cannot be persisted, fail closed and run
|
||||
the update path rather than allowing an unbounded bypass.
|
||||
"""
|
||||
data = _read_update_state()
|
||||
now = time.time()
|
||||
deferred_at = _coerce_checked_at(data.get(_RESUME_AUTO_UPDATE_DEFERRED_AT_KEY))
|
||||
if deferred_at is None or deferred_at > now:
|
||||
return _write_update_state({_RESUME_AUTO_UPDATE_DEFERRED_AT_KEY: now})
|
||||
return now - deferred_at < RESUME_AUTO_UPDATE_GRACE_PERIOD
|
||||
|
||||
|
||||
def clear_resume_auto_update_deferral() -> None:
|
||||
"""Reset the resume grace period after a normal interactive launch."""
|
||||
data = _read_update_state()
|
||||
if _RESUME_AUTO_UPDATE_DEFERRED_AT_KEY not in data:
|
||||
return
|
||||
_write_update_state({}, remove_keys=(_RESUME_AUTO_UPDATE_DEFERRED_AT_KEY,))
|
||||
|
||||
|
||||
def should_skip_startup_auto_update_after_failure(version: str) -> bool:
|
||||
"""Return whether startup auto-update should skip a recently failed version."""
|
||||
data = _read_update_state()
|
||||
failed_version = data.get(_STARTUP_AUTO_UPDATE_FAILED_VERSION_KEY)
|
||||
failed_at = data.get(_STARTUP_AUTO_UPDATE_FAILED_AT_KEY)
|
||||
return bool(
|
||||
failed_version == version
|
||||
and isinstance(failed_at, (int, float))
|
||||
and time.time() - failed_at < STARTUP_AUTO_UPDATE_FAILURE_COOLDOWN
|
||||
)
|
||||
|
||||
|
||||
def mark_startup_auto_update_failed(version: str) -> bool:
|
||||
"""Persist a same-version startup auto-update retry cooldown marker.
|
||||
|
||||
Returns:
|
||||
`True` if the marker was written, `False` otherwise.
|
||||
"""
|
||||
return _write_update_state(
|
||||
{
|
||||
_STARTUP_AUTO_UPDATE_FAILED_VERSION_KEY: version,
|
||||
_STARTUP_AUTO_UPDATE_FAILED_AT_KEY: time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def clear_startup_auto_update_failure(version: str) -> None:
|
||||
"""Clear a startup auto-update failure marker for `version` if present."""
|
||||
data = _read_update_state()
|
||||
if data.get(_STARTUP_AUTO_UPDATE_FAILED_VERSION_KEY) != version:
|
||||
return
|
||||
_write_update_state({}, remove_keys=_STARTUP_AUTO_UPDATE_FAILURE_KEYS)
|
||||
|
||||
|
||||
def should_notify_update(latest: str) -> bool:
|
||||
"""Return whether the user should be notified about version *latest*.
|
||||
|
||||
@@ -1544,6 +1620,37 @@ async def _read_stream(
|
||||
await _emit_progress(progress, line)
|
||||
|
||||
|
||||
async def _terminate_install_process(proc: asyncio.subprocess.Process) -> None:
|
||||
"""Best-effort kill of an install subprocess and its descendants.
|
||||
|
||||
On POSIX the sole caller starts the child in its own session
|
||||
(`start_new_session=True`), so `proc.pid` doubles as the process-group id
|
||||
and `killpg` reaps descendants too; do not call this on a process not
|
||||
started that way or it would signal the caller's own group.
|
||||
|
||||
This is teardown that runs *under* a timeout or cancellation, so it must
|
||||
never raise — a stray error here would mask the failure it is cleaning up
|
||||
after. Every step therefore swallows benign races (a process that already
|
||||
exited, a group we cannot signal) and the final reap is time-bounded so an
|
||||
unreapable child cannot hang startup before the TUI.
|
||||
"""
|
||||
if os.name == "posix":
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except OSError:
|
||||
# e.g. EPERM if the group contains a privileged descendant; fall
|
||||
# back to killing the direct child so at least it is reaped.
|
||||
with suppress(OSError):
|
||||
proc.kill()
|
||||
elif proc.returncode is None:
|
||||
with suppress(OSError):
|
||||
proc.kill()
|
||||
with suppress(OSError, TimeoutError):
|
||||
await asyncio.wait_for(proc.wait(), timeout=_TERMINATE_WAIT_TIMEOUT)
|
||||
|
||||
|
||||
async def _run_install_subprocess(
|
||||
cmd: str,
|
||||
*,
|
||||
@@ -1568,6 +1675,9 @@ async def _run_install_subprocess(
|
||||
|
||||
Returns:
|
||||
`(success, output)` — *success* is `True` iff the subprocess exited 0.
|
||||
|
||||
Raises:
|
||||
asyncio.CancelledError: If the calling task is cancelled.
|
||||
"""
|
||||
timeout = _UPGRADE_TIMEOUT
|
||||
if log_path is None:
|
||||
@@ -1591,12 +1701,21 @@ async def _run_install_subprocess(
|
||||
log_file = None
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
if os.name == "posix":
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
else:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
_read_stream(
|
||||
@@ -1617,8 +1736,7 @@ async def _run_install_subprocess(
|
||||
)
|
||||
except TimeoutError:
|
||||
if proc is not None:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
await _terminate_install_process(proc)
|
||||
msg = f"Command timed out after {timeout}s: {cmd}"
|
||||
if log_file is not None:
|
||||
with suppress(OSError):
|
||||
@@ -1627,6 +1745,13 @@ async def _run_install_subprocess(
|
||||
await _emit_progress(progress, msg)
|
||||
logger.warning(msg)
|
||||
return False, msg
|
||||
except asyncio.CancelledError:
|
||||
if proc is not None:
|
||||
await _terminate_install_process(proc)
|
||||
if log_file is not None:
|
||||
with suppress(OSError):
|
||||
log_file.close()
|
||||
raise
|
||||
except OSError as exc:
|
||||
if log_file is not None:
|
||||
with suppress(OSError):
|
||||
|
||||
@@ -162,6 +162,9 @@ class TestStartupAutoUpdate:
|
||||
return_value=Path("/tmp/dcode-update.log"),
|
||||
),
|
||||
patch("deepagents_code.update_check.perform_upgrade", upgrade),
|
||||
patch(
|
||||
"deepagents_code.update_check.clear_startup_auto_update_failure"
|
||||
) as clear_failure,
|
||||
patch(
|
||||
"deepagents_code.main._restart_current_process",
|
||||
side_effect=SystemExit(0),
|
||||
@@ -171,6 +174,7 @@ class TestStartupAutoUpdate:
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
upgrade.assert_awaited_once()
|
||||
clear_failure.assert_called_once_with("9.9.9")
|
||||
printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
|
||||
assert "tail -f /tmp/dcode-update.log" in printed
|
||||
restart.assert_called_once_with()
|
||||
@@ -331,16 +335,138 @@ class TestStartupAutoUpdate:
|
||||
return_value="uv tool upgrade deepagents-code",
|
||||
),
|
||||
patch("deepagents_code.update_check.perform_upgrade", upgrade),
|
||||
patch(
|
||||
"deepagents_code.update_check.mark_startup_auto_update_failed"
|
||||
) as mark_failed,
|
||||
patch("deepagents_code.main._restart_current_process") as restart,
|
||||
):
|
||||
# Must not raise: a failed upgrade falls through to launch.
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
upgrade.assert_awaited_once()
|
||||
mark_failed.assert_called_once_with("9.9.9")
|
||||
restart.assert_not_called()
|
||||
printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
|
||||
assert "Auto-update failed" in printed
|
||||
|
||||
def test_unpersisted_failure_marker_warns_user(self) -> None:
|
||||
"""An unwritable cooldown marker must be surfaced, not silently dropped."""
|
||||
console = MagicMock()
|
||||
upgrade = AsyncMock(return_value=(False, "pip exploded"))
|
||||
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_auto_update_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.get_cached_update_available",
|
||||
return_value=(True, "9.9.9"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.format_release_age_parenthetical",
|
||||
return_value="",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.create_update_log_path",
|
||||
return_value=Path("/tmp/dcode-update.log"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.upgrade_command",
|
||||
return_value="uv tool upgrade deepagents-code",
|
||||
),
|
||||
patch("deepagents_code.update_check.perform_upgrade", upgrade),
|
||||
# The marker write fails (e.g. a read-only state dir).
|
||||
patch(
|
||||
"deepagents_code.update_check.mark_startup_auto_update_failed",
|
||||
return_value=False,
|
||||
),
|
||||
patch("deepagents_code.main._restart_current_process") as restart,
|
||||
):
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
restart.assert_not_called()
|
||||
printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
|
||||
assert "could not be recorded" in printed
|
||||
|
||||
def test_exception_during_upgrade_warns_if_failure_marker_is_unpersisted(
|
||||
self,
|
||||
) -> None:
|
||||
"""A raised upgrade must warn when its cooldown marker cannot be saved."""
|
||||
console = MagicMock()
|
||||
upgrade = AsyncMock(side_effect=RuntimeError("uv wedged"))
|
||||
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_auto_update_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.get_cached_update_available",
|
||||
return_value=(True, "9.9.9"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.format_release_age_parenthetical",
|
||||
return_value="",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.create_update_log_path",
|
||||
return_value=Path("/tmp/dcode-update.log"),
|
||||
),
|
||||
patch("deepagents_code.update_check.perform_upgrade", upgrade),
|
||||
patch(
|
||||
"deepagents_code.update_check.mark_startup_auto_update_failed",
|
||||
return_value=False,
|
||||
) as mark_failed,
|
||||
patch("deepagents_code.main._restart_current_process") as restart,
|
||||
):
|
||||
# Must not raise: the fail-soft handler swallows and continues.
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
mark_failed.assert_called_once_with("9.9.9")
|
||||
restart.assert_not_called()
|
||||
printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
|
||||
assert "Auto-update failed before startup" in printed
|
||||
assert "could not be recorded" in printed
|
||||
|
||||
def test_recent_failure_cooldown_skips_startup_update(self) -> None:
|
||||
"""A same-version startup failure cooldown must bypass repeat attempts."""
|
||||
console = MagicMock()
|
||||
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_check_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_auto_update_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.get_cached_update_available",
|
||||
return_value=(True, "9.9.9"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.should_skip_startup_auto_update_after_failure",
|
||||
return_value=True,
|
||||
) as should_skip,
|
||||
patch(
|
||||
"deepagents_code.update_check.upgrade_command",
|
||||
return_value="uv tool install -U deepagents-code",
|
||||
),
|
||||
patch("deepagents_code.update_check.perform_upgrade") as upgrade,
|
||||
patch("deepagents_code.main._restart_current_process") as restart,
|
||||
):
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
should_skip.assert_called_once_with("9.9.9")
|
||||
upgrade.assert_not_called()
|
||||
restart.assert_not_called()
|
||||
printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
|
||||
assert "recent failed attempt" in printed
|
||||
|
||||
def test_editable_install_skips_update(self) -> None:
|
||||
"""Editable installs must short-circuit before any PyPI/install work."""
|
||||
console = MagicMock()
|
||||
@@ -840,7 +966,9 @@ class TestStartupAutoUpdate:
|
||||
no-op.
|
||||
"""
|
||||
source = inspect.getsource(cli_main)
|
||||
assert "_run_startup_auto_update(console)" in source
|
||||
assert "clear_resume_auto_update_deferral()" in source
|
||||
assert "if not should_defer_startup_auto_update_for_resume():" in source
|
||||
assert source.count("_run_startup_auto_update(console)") == 2
|
||||
|
||||
|
||||
class TestAutoUpdateDefaultMigration:
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
@@ -31,8 +33,12 @@ from deepagents_code.update_check import (
|
||||
_note_install_baseline,
|
||||
_parse_version,
|
||||
_requires_prerelease_dependency,
|
||||
_run_install_subprocess,
|
||||
_terminate_install_process,
|
||||
_uv_tool_bin_dir,
|
||||
cleanup_update_logs,
|
||||
clear_resume_auto_update_deferral,
|
||||
clear_startup_auto_update_failure,
|
||||
clear_update_notified,
|
||||
create_update_log_path,
|
||||
dependency_refresh_command,
|
||||
@@ -71,6 +77,7 @@ from deepagents_code.update_check import (
|
||||
is_valid_extra_name,
|
||||
is_valid_package_name,
|
||||
mark_auto_update_default_acknowledged,
|
||||
mark_startup_auto_update_failed,
|
||||
mark_update_notified,
|
||||
mark_version_seen,
|
||||
parse_dependency_changes,
|
||||
@@ -83,7 +90,9 @@ from deepagents_code.update_check import (
|
||||
release_requires_prereleases,
|
||||
set_auto_update,
|
||||
should_announce_auto_update_default,
|
||||
should_defer_startup_auto_update_for_resume,
|
||||
should_notify_update,
|
||||
should_skip_startup_auto_update_after_failure,
|
||||
upgrade_command,
|
||||
upgrade_install_command,
|
||||
)
|
||||
@@ -3847,6 +3856,86 @@ class TestRunInstallSubprocessFailureModes:
|
||||
assert success is False
|
||||
assert "timed out" in output
|
||||
|
||||
async def test_timeout_kills_process_group_after_shell_exits(
|
||||
self, tmp_path
|
||||
) -> None:
|
||||
"""Timeout cleanup kills descendants after the POSIX shell exits."""
|
||||
if os.name != "posix":
|
||||
pytest.skip("process groups are POSIX-specific")
|
||||
log_path = tmp_path / "install.log"
|
||||
with (
|
||||
patch("deepagents_code.update_check._UPGRADE_TIMEOUT", 0.05),
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_install_method",
|
||||
return_value="uv",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.shutil.which",
|
||||
return_value="/usr/bin/uv",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check._install_extra_uv_tool_command",
|
||||
return_value="sleep 5 & exit 0",
|
||||
),
|
||||
patch("deepagents_code.update_check.os.killpg", wraps=os.killpg) as killpg,
|
||||
):
|
||||
success, output = await perform_install_extra("quickjs", log_path=log_path)
|
||||
assert success is False
|
||||
assert "timed out" in output
|
||||
killpg.assert_called_once()
|
||||
assert killpg.call_args.args[1] == signal.SIGKILL
|
||||
|
||||
async def test_cancellation_terminates_process_and_propagates(
|
||||
self, tmp_path
|
||||
) -> None:
|
||||
"""Cancelling mid-install kills the process group and re-raises."""
|
||||
if os.name != "posix":
|
||||
pytest.skip("process groups are POSIX-specific")
|
||||
log_path = tmp_path / "install.log"
|
||||
started = asyncio.Event()
|
||||
|
||||
def progress(line: str) -> None:
|
||||
if "ready" in line:
|
||||
started.set()
|
||||
|
||||
with patch("deepagents_code.update_check.os.killpg", wraps=os.killpg) as killpg:
|
||||
task = asyncio.ensure_future(
|
||||
_run_install_subprocess(
|
||||
"echo ready; sleep 30", progress=progress, log_path=log_path
|
||||
)
|
||||
)
|
||||
# Cancel only once the subprocess is actually running (it emitted a
|
||||
# line), so cancellation lands inside the install, not before it.
|
||||
await asyncio.wait_for(started.wait(), timeout=5)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
# Cancellation must not be swallowed, and the descendant `sleep` must be
|
||||
# killed via the process group rather than orphaned.
|
||||
killpg.assert_called_once()
|
||||
assert killpg.call_args.args[1] == signal.SIGKILL
|
||||
|
||||
async def test_terminate_falls_back_to_direct_kill_on_permission_error(
|
||||
self,
|
||||
) -> None:
|
||||
"""When `killpg` is denied, the direct child is still reaped."""
|
||||
if os.name != "posix":
|
||||
pytest.skip("process groups are POSIX-specific")
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
"sleep 30",
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
with patch(
|
||||
"deepagents_code.update_check.os.killpg",
|
||||
side_effect=PermissionError,
|
||||
):
|
||||
await _terminate_install_process(proc)
|
||||
|
||||
# The EPERM fallback (`proc.kill()`) must have reaped the child.
|
||||
assert proc.returncode is not None
|
||||
|
||||
async def test_oserror_includes_exception_detail(self, tmp_path) -> None:
|
||||
"""An OSError during exec must surface the exception class + message."""
|
||||
log_path = tmp_path / "install.log"
|
||||
@@ -4454,6 +4543,133 @@ class TestAutoUpdateDefaultMigration:
|
||||
assert mark_auto_update_default_acknowledged() is False
|
||||
|
||||
|
||||
class TestStartupAutoUpdateFailureCooldown:
|
||||
@pytest.fixture
|
||||
def state_file(self, tmp_path):
|
||||
"""Override UPDATE_STATE_FILE to use a temporary file."""
|
||||
path = tmp_path / "update_state.json"
|
||||
with patch("deepagents_code.update_check.UPDATE_STATE_FILE", path):
|
||||
yield path
|
||||
|
||||
def test_recent_same_version_skips(self, state_file) -> None: # noqa: ARG002
|
||||
"""A recent startup auto-update failure suppresses the same version."""
|
||||
with patch("deepagents_code.update_check.time.time", return_value=100.0):
|
||||
assert mark_startup_auto_update_failed("2.0.0") is True
|
||||
with patch("deepagents_code.update_check.time.time", return_value=101.0):
|
||||
assert should_skip_startup_auto_update_after_failure("2.0.0") is True
|
||||
|
||||
def test_different_version_does_not_skip(self, state_file) -> None: # noqa: ARG002
|
||||
"""A failure marker is scoped to the failed target version."""
|
||||
with patch("deepagents_code.update_check.time.time", return_value=100.0):
|
||||
mark_startup_auto_update_failed("2.0.0")
|
||||
with patch("deepagents_code.update_check.time.time", return_value=101.0):
|
||||
assert should_skip_startup_auto_update_after_failure("2.0.1") is False
|
||||
|
||||
def test_expired_failure_does_not_skip(self, state_file) -> None: # noqa: ARG002
|
||||
"""The startup failure cooldown expires after the configured window."""
|
||||
with patch("deepagents_code.update_check.time.time", return_value=100.0):
|
||||
mark_startup_auto_update_failed("2.0.0")
|
||||
with patch(
|
||||
"deepagents_code.update_check.time.time",
|
||||
return_value=100.0 + CACHE_TTL + 1,
|
||||
):
|
||||
assert should_skip_startup_auto_update_after_failure("2.0.0") is False
|
||||
|
||||
def test_clear_removes_matching_failure_marker(self, state_file) -> None:
|
||||
"""Clearing a matching marker preserves unrelated update state."""
|
||||
mark_version_seen("1.0.0")
|
||||
mark_startup_auto_update_failed("2.0.0")
|
||||
|
||||
clear_startup_auto_update_failure("2.0.0")
|
||||
|
||||
data = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert data["seen_version"] == "1.0.0"
|
||||
assert "startup_auto_update_failed_version" not in data
|
||||
assert "startup_auto_update_failed_at" not in data
|
||||
|
||||
def test_clear_ignores_different_version(self, state_file) -> None:
|
||||
"""Clearing a different version leaves the failure marker intact."""
|
||||
mark_startup_auto_update_failed("2.0.0")
|
||||
|
||||
clear_startup_auto_update_failure("2.0.1")
|
||||
|
||||
data = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert data["startup_auto_update_failed_version"] == "2.0.0"
|
||||
|
||||
def test_corrupted_timestamp_does_not_skip(self, state_file) -> None:
|
||||
"""A hand-edited non-numeric timestamp must not crash or skip."""
|
||||
state_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"startup_auto_update_failed_version": "2.0.0",
|
||||
"startup_auto_update_failed_at": "not-a-number",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert should_skip_startup_auto_update_after_failure("2.0.0") is False
|
||||
|
||||
|
||||
class TestResumeAutoUpdateGracePeriod:
|
||||
@pytest.fixture
|
||||
def state_file(self, tmp_path):
|
||||
"""Override UPDATE_STATE_FILE to use a temporary file."""
|
||||
path = tmp_path / "update_state.json"
|
||||
with patch("deepagents_code.update_check.UPDATE_STATE_FILE", path):
|
||||
yield path
|
||||
|
||||
def test_first_resume_starts_grace_period(self, state_file) -> None:
|
||||
"""The first resumed launch records and uses the grace period."""
|
||||
with patch("deepagents_code.update_check.time.time", return_value=100.0):
|
||||
assert should_defer_startup_auto_update_for_resume() is True
|
||||
|
||||
data = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert data["resume_auto_update_deferred_at"] == pytest.approx(100.0)
|
||||
|
||||
def test_repeated_resume_does_not_extend_grace_period(self, state_file) -> None:
|
||||
"""Repeated resumes preserve the first deferral timestamp."""
|
||||
with patch("deepagents_code.update_check.time.time", return_value=100.0):
|
||||
should_defer_startup_auto_update_for_resume()
|
||||
with patch("deepagents_code.update_check.time.time", return_value=200.0):
|
||||
assert should_defer_startup_auto_update_for_resume() is True
|
||||
|
||||
data = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert data["resume_auto_update_deferred_at"] == pytest.approx(100.0)
|
||||
|
||||
def test_expired_grace_period_runs_update_path(self, state_file) -> None: # noqa: ARG002
|
||||
"""Resume-only use stops bypassing startup updates after seven days."""
|
||||
from deepagents_code.update_check import RESUME_AUTO_UPDATE_GRACE_PERIOD
|
||||
|
||||
with patch("deepagents_code.update_check.time.time", return_value=100.0):
|
||||
should_defer_startup_auto_update_for_resume()
|
||||
with patch(
|
||||
"deepagents_code.update_check.time.time",
|
||||
return_value=100.0 + RESUME_AUTO_UPDATE_GRACE_PERIOD,
|
||||
):
|
||||
assert should_defer_startup_auto_update_for_resume() is False
|
||||
|
||||
def test_normal_launch_resets_grace_period(self, state_file) -> None:
|
||||
"""A normal launch lets a later resume begin a fresh grace period."""
|
||||
with patch("deepagents_code.update_check.time.time", return_value=100.0):
|
||||
should_defer_startup_auto_update_for_resume()
|
||||
|
||||
clear_resume_auto_update_deferral()
|
||||
|
||||
data = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert "resume_auto_update_deferred_at" not in data
|
||||
with patch("deepagents_code.update_check.time.time", return_value=200.0):
|
||||
assert should_defer_startup_auto_update_for_resume() is True
|
||||
|
||||
def test_unwritable_marker_does_not_bypass_update(self, tmp_path) -> None:
|
||||
"""A persistence failure cannot create an unbounded resume bypass."""
|
||||
state_file = tmp_path / "missing" / "update_state.json"
|
||||
with (
|
||||
patch("deepagents_code.update_check.UPDATE_STATE_FILE", state_file),
|
||||
patch("pathlib.Path.mkdir", side_effect=OSError("read-only")),
|
||||
):
|
||||
assert should_defer_startup_auto_update_for_resume() is False
|
||||
|
||||
|
||||
class TestShouldNotifyUpdate:
|
||||
@pytest.fixture
|
||||
def state_file(self, tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user