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
79 changes: 72 additions & 7 deletions libs/code/deepagents_code/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
_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.
Expand Down Expand 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,
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
141 changes: 133 additions & 8 deletions libs/code/deepagents_code/update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import re
import shlex
import shutil
import signal
import sys
import tempfile
import time
Expand Down Expand Up @@ -138,6 +139,9 @@
_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."""

Expand All @@ -150,6 +154,20 @@
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]


Expand Down Expand Up @@ -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*.

Expand Down Expand Up @@ -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,
*,
Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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):
Expand All @@ -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):
Expand Down
Loading
Loading