mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(code): confirm "Launched" after auto-update restart (#4098)
The startup auto-update now updates its `Launching...` line to
`Launched.` once the upgraded version is running.
---
During a startup auto-update, `Updated to v{latest}. Launching...` is
printed immediately before `os.execv` replaces the process, so the
printing generation can never resolve its own status line. The re-exec'd
process already detects the post-update restart by consuming the
`RESTARTED_AFTER_UPDATE` sentinel; once `is_installed_version_at_least`
confirms the upgrade actually took effect, it now rewrites that line in
place to `Updated to v{latest}. Launched.`.
The new `_confirm_launch_after_restart` helper performs the rewrite with
a Rich `Control` cursor-up + erase-line sequence, and is gated on
`console.is_terminal` so redirected (non-terminal) output is never
polluted with escape codes. A failed re-exec that did not change the
running version is correctly left as `Launching...`.
Made by [Open
SWE](https://openswe.vercel.app/agents/27a04727-aae3-d6e0-61ef-358ae09e8d92)
---------
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -482,7 +482,7 @@ The `release_please_scope_check.yml` workflow ([`.github/scripts/check_lockfile_
|
||||
|
||||
### Overriding a Merged Commit's Changelog Entry
|
||||
|
||||
Append a `BEGIN_COMMIT_OVERRIDE` block to the **merged PR's body** when release-please needs to use a different message than the actual squash-merge commit. release-please reads merged PR bodies on every run within its lookback window and uses the override in place of the original commit message — no history rewrite, no force-push.
|
||||
Append a `BEGIN_COMMIT_OVERRIDE` block (shown below) to the **merged PR's body** when release-please needs to use a different message than the actual squash-merge commit. release-please reads merged PR bodies on every run within its lookback window and uses the override in place of the original commit message — no history rewrite, no force-push.
|
||||
|
||||
Two situations call for this:
|
||||
|
||||
|
||||
@@ -129,6 +129,62 @@ def _restart_current_process() -> NoReturn:
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def _terminal_row_count(console: "Console", text: str) -> int:
|
||||
"""Return how many terminal rows Rich renders for `text`.
|
||||
|
||||
Args:
|
||||
console: The Rich console whose current width determines wrapping.
|
||||
text: The string to measure, rendered with no markup.
|
||||
|
||||
Returns:
|
||||
The number of visual rows Rich wraps `text` into, at least 1.
|
||||
"""
|
||||
from rich.text import Text
|
||||
|
||||
return max(1, len(console.render_lines(Text(text), console.options)))
|
||||
|
||||
|
||||
def _confirm_launch_after_restart(console: "Console", version: str) -> None:
|
||||
"""Rewrite the pre-restart `Launching...` line as `Launched.` in-place.
|
||||
|
||||
The `Updated to v{version}. Launching...` line is printed by the previous
|
||||
generation right before `os.execv`; this runs in the re-exec'd process to
|
||||
retroactively confirm the launch once the new version is actually running.
|
||||
|
||||
The in-place rewrite is attempted only on a real terminal: `os.execv` does
|
||||
nothing between that print and this process's first output, so the cursor
|
||||
is parked on the line directly below it. On non-terminals the escape codes
|
||||
would corrupt redirected output, so the line is left as-is.
|
||||
|
||||
The row count is recomputed against the current terminal width, so a resize
|
||||
during the upgrade/re-exec window could make the erase loop clear the wrong
|
||||
number of wrapped rows. This is a benign visual glitch (no exception), and
|
||||
is rare enough not to warrant defending against here.
|
||||
|
||||
Args:
|
||||
console: The Rich console used for startup output.
|
||||
version: The version now running, used in the confirmation line.
|
||||
"""
|
||||
if not console.is_terminal:
|
||||
return
|
||||
from rich.control import Control
|
||||
from rich.segment import ControlType
|
||||
|
||||
launch_status = f"Updated to v{version}. Launching..."
|
||||
launch_rows = _terminal_row_count(console, launch_status)
|
||||
# Move up to the bottom row of the old status and erase each rendered row.
|
||||
# This preserves the rewrite when Rich wrapped the status in a narrow pane.
|
||||
for _ in range(launch_rows):
|
||||
console.control(
|
||||
Control(
|
||||
(ControlType.CURSOR_UP, 1),
|
||||
(ControlType.CURSOR_MOVE_TO_COLUMN, 0),
|
||||
(ControlType.ERASE_IN_LINE, 2),
|
||||
)
|
||||
)
|
||||
console.print(f"[green]Updated to v{version}. Launched.[/green]", highlight=False)
|
||||
|
||||
|
||||
def _run_startup_auto_update(console: "Console") -> None:
|
||||
"""Apply enabled auto-updates before the TUI and server start.
|
||||
|
||||
@@ -168,6 +224,18 @@ def _run_startup_auto_update(console: "Console") -> None:
|
||||
return
|
||||
# Consume the re-exec sentinel recorded before the previous restart.
|
||||
restarted_for = os.environ.pop(RESTARTED_AFTER_UPDATE, None)
|
||||
if restarted_for is not None and is_installed_version_at_least(restarted_for):
|
||||
# The re-exec landed on the upgraded version, so the prior
|
||||
# "Launching..." line is now accurate as "Launched.".
|
||||
try:
|
||||
_confirm_launch_after_restart(console, restarted_for)
|
||||
except Exception:
|
||||
# The upgrade already succeeded; this rewrite is purely
|
||||
# cosmetic. Swallow rendering glitches with their own guard so
|
||||
# the outer fail-soft handler does not misreport a successful
|
||||
# upgrade as "Auto-update failed". The prior "Launching..."
|
||||
# line simply stays.
|
||||
logger.debug("Post-restart launch confirmation failed", exc_info=True)
|
||||
available, latest = get_cached_update_available()
|
||||
if not available or latest is None:
|
||||
return
|
||||
@@ -236,7 +304,10 @@ def _run_startup_auto_update(console: "Console") -> None:
|
||||
)
|
||||
success, output = asyncio.run(perform_upgrade(log_path=log_path))
|
||||
if success:
|
||||
console.print(f"[green]Updated to v{latest}. Launching...[/green]")
|
||||
console.print(
|
||||
f"[green]Updated to v{latest}. Launching...[/green]",
|
||||
highlight=False,
|
||||
)
|
||||
# Record the target version so the re-exec'd process can detect a
|
||||
# no-op upgrade and break the loop (see the `restarted_for` guard).
|
||||
os.environ[RESTARTED_AFTER_UPDATE] = latest
|
||||
|
||||
@@ -5,12 +5,14 @@ import inspect
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from deepagents_code.app import AppResult, DeepAgentsApp, run_textual_app
|
||||
from deepagents_code.config import build_langsmith_thread_url, reset_langsmith_url_cache
|
||||
@@ -20,6 +22,7 @@ from deepagents_code.main import (
|
||||
_restart_current_process,
|
||||
_ripgrep_install_hint,
|
||||
_run_startup_auto_update,
|
||||
_terminal_row_count,
|
||||
build_missing_tool_notification,
|
||||
check_optional_tools,
|
||||
cli_main,
|
||||
@@ -377,6 +380,280 @@ class TestStartupAutoUpdate:
|
||||
assert "automatic restart failed" in printed
|
||||
assert "Auto-update failed" not in printed
|
||||
|
||||
def test_restart_after_update_confirms_launched(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The re-exec'd process rewrites `Launching...` to `Launched.`."""
|
||||
stream = StringIO()
|
||||
console = Console(file=stream, force_terminal=True, no_color=True, width=80)
|
||||
# The prior generation recorded the version it restarted into.
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.9")
|
||||
|
||||
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.is_installed_version_at_least",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.get_cached_update_available",
|
||||
return_value=(False, "9.9.9"),
|
||||
),
|
||||
patch("deepagents_code.update_check.perform_upgrade") as upgrade,
|
||||
patch("deepagents_code.main._restart_current_process") as restart,
|
||||
):
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
upgrade.assert_not_called()
|
||||
restart.assert_not_called()
|
||||
# Sentinel is consumed so the confirmation only fires once.
|
||||
assert os.environ.get("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE") is None
|
||||
# The prior line is erased via a control sequence, then reprinted.
|
||||
output = stream.getvalue()
|
||||
assert output.count("\x1b[1A") == 1
|
||||
assert "Launched." in output
|
||||
assert "9.9.9" in output
|
||||
|
||||
def test_update_launch_status_rewrite_handles_narrow_terminal_wrap(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The status rewrite erases every row in narrow terminal panes."""
|
||||
stream = StringIO()
|
||||
console = Console(file=stream, force_terminal=True, no_color=True, width=10)
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.9")
|
||||
|
||||
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.is_installed_version_at_least",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.get_cached_update_available",
|
||||
return_value=(False, "9.9.9"),
|
||||
),
|
||||
patch("deepagents_code.update_check.perform_upgrade"),
|
||||
patch("deepagents_code.main._restart_current_process"),
|
||||
):
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
launch_rows = _terminal_row_count(console, "Updated to v9.9.9. Launching...")
|
||||
output = stream.getvalue()
|
||||
assert output.count("\x1b[1A") == launch_rows
|
||||
assert "Launched." in output
|
||||
assert "9.9.9" in output
|
||||
|
||||
def test_restart_after_update_skips_rewrite_when_not_terminal(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Redirected (non-terminal) output is not polluted with escape codes."""
|
||||
stream = StringIO()
|
||||
# `force_terminal=False` makes `is_terminal` report False, exactly as a
|
||||
# redirected stream (pipe/file) would. Asserting on the real stream
|
||||
# proves no escape bytes reach redirected output, end-to-end.
|
||||
console = Console(file=stream, force_terminal=False, no_color=True, width=80)
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.9")
|
||||
|
||||
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.is_installed_version_at_least",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.get_cached_update_available",
|
||||
return_value=(False, "9.9.9"),
|
||||
),
|
||||
patch("deepagents_code.main._restart_current_process"),
|
||||
):
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
output = stream.getvalue()
|
||||
assert "\x1b" not in output
|
||||
assert "Launched." not in output
|
||||
|
||||
def test_failed_restart_does_not_confirm_launched(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A re-exec that did not change the version must not claim `Launched.`."""
|
||||
console = MagicMock()
|
||||
console.is_terminal = True
|
||||
console.width = 80
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.9")
|
||||
|
||||
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,
|
||||
),
|
||||
# The install did not change the running version.
|
||||
patch(
|
||||
"deepagents_code.update_check.is_installed_version_at_least",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.get_cached_update_available",
|
||||
return_value=(True, "9.9.9"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.upgrade_command",
|
||||
return_value="uv tool upgrade deepagents-code",
|
||||
),
|
||||
patch("deepagents_code.main._restart_current_process") as restart,
|
||||
):
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
restart.assert_not_called()
|
||||
console.control.assert_not_called()
|
||||
printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
|
||||
assert "Launched." not in printed
|
||||
|
||||
def test_version_check_failure_skips_confirm_in_isolation(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The confirm must be gated solely by `is_installed_version_at_least`.
|
||||
|
||||
With nothing available (`(False, None)`) the function returns before the
|
||||
restart-loop guard, so the only path that could print `Launched.` is the
|
||||
confirm block. This pins the `is_installed_version_at_least(restarted_for)`
|
||||
condition: dropping it would let the confirm fire here and fail the test.
|
||||
"""
|
||||
stream = StringIO()
|
||||
console = Console(file=stream, force_terminal=True, no_color=True, width=80)
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.9")
|
||||
|
||||
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,
|
||||
),
|
||||
# The re-exec did not land on the recorded version.
|
||||
patch(
|
||||
"deepagents_code.update_check.is_installed_version_at_least",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.get_cached_update_available",
|
||||
return_value=(False, None),
|
||||
),
|
||||
patch("deepagents_code.main._restart_current_process") as restart,
|
||||
):
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
restart.assert_not_called()
|
||||
output = stream.getvalue()
|
||||
assert "\x1b[1A" not in output
|
||||
assert "Launched." not in output
|
||||
|
||||
def test_confirm_launched_then_continues_to_available_update(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Confirming the prior launch must not short-circuit a newer update.
|
||||
|
||||
The sentinel is an older version (now running), while a newer version is
|
||||
available: the function should both rewrite the prior line to `Launched.`
|
||||
and proceed into the upgrade path for the newer version.
|
||||
"""
|
||||
stream = StringIO()
|
||||
console = Console(file=stream, force_terminal=True, no_color=True, width=80)
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_RESTARTED_AFTER_UPDATE", "9.9.8")
|
||||
upgrade = AsyncMock(return_value=(True, ""))
|
||||
|
||||
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,
|
||||
),
|
||||
# The running version satisfies the prior restart (9.9.8) but not the
|
||||
# newly available 9.9.9, so the upgrade path must still run.
|
||||
patch(
|
||||
"deepagents_code.update_check.is_installed_version_at_least",
|
||||
side_effect=lambda version: version == "9.9.8",
|
||||
),
|
||||
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.main._restart_current_process",
|
||||
side_effect=SystemExit(0),
|
||||
) as restart,
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
upgrade.assert_awaited_once()
|
||||
restart.assert_called_once_with()
|
||||
output = stream.getvalue()
|
||||
# The prior launch is confirmed for the running version...
|
||||
assert "v9.9.8. Launched." in output
|
||||
# ...and the newer version still goes through the upgrade path.
|
||||
assert "v9.9.9. Launching..." in output
|
||||
|
||||
def test_terminal_row_count_single_row(self) -> None:
|
||||
"""Text that fits on one line counts as a single row."""
|
||||
console = Console(file=StringIO(), force_terminal=True, no_color=True, width=80)
|
||||
assert _terminal_row_count(console, "abc") == 1
|
||||
|
||||
def test_terminal_row_count_wraps_to_multiple_rows(self) -> None:
|
||||
"""Text wider than the pane counts each wrapped row."""
|
||||
console = Console(file=StringIO(), force_terminal=True, no_color=True, width=10)
|
||||
# 20 characters at width 10 wraps to exactly 2 rows.
|
||||
assert _terminal_row_count(console, "abcdefghijklmnopqrst") == 2
|
||||
|
||||
def test_terminal_row_count_floors_at_one(self) -> None:
|
||||
"""Empty text still reports one row, never zero."""
|
||||
console = Console(file=StringIO(), force_terminal=True, no_color=True, width=80)
|
||||
assert _terminal_row_count(console, "") == 1
|
||||
|
||||
def test_startup_auto_update_wired_into_interactive_launch(self) -> None:
|
||||
"""`cli_main` must invoke the startup auto-update on interactive launch.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user