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:
Mason Daugherty
2026-06-20 19:56:29 -04:00
committed by GitHub
parent ae1de8b076
commit df8db8af6a
3 changed files with 350 additions and 2 deletions
+1 -1
View File
@@ -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:
+72 -1
View File
@@ -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
+277
View File
@@ -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.