diff --git a/docs-site/docs/quick-start.md b/docs-site/docs/quick-start.md
index b50e2134b..2347affc1 100644
--- a/docs-site/docs/quick-start.md
+++ b/docs-site/docs/quick-start.md
@@ -51,6 +51,16 @@ the running WebUI and finishes by starting a new one in the foreground: press
Ctrl-C to stop it, then run `raven web` to keep Raven running in the
background. Your settings and conversations in `~/.raven` are kept.
+### Update from the page
+
+When the bottom of the sidebar says a new version is available, click it and
+confirm. Raven shows the download's progress, installs the new version,
+restarts, and the page reloads by itself. Your settings and conversations are
+kept. While a turn, a sub-agent or a question waiting for an answer is still
+running, including in an IM channel, the page asks you to let it finish first.
+A Raven started by hand with `raven gateway` or `raven web --foreground` has
+nothing to restart it, so update that one from the command line below.
+
### Update a one-line install
Run the same installer again. It installs the newest release over the current
diff --git a/docs-site/docs/quick-start.zh.md b/docs-site/docs/quick-start.zh.md
index 47add31d7..3fa0540b3 100644
--- a/docs-site/docs/quick-start.zh.md
+++ b/docs-site/docs/quick-start.zh.md
@@ -48,6 +48,14 @@ cd Raven
最后在前台启动新的 WebUI:按 Ctrl-C 停止后,运行 `raven web` 即可让 Raven
在后台持续运行。`~/.raven` 中的设置和会话都会保留。
+### 在页面上更新 { #update-from-the-page }
+
+左栏底部出现新版本提示时,点它并确认即可。Raven 会显示下载进度,装好新版本后自动
+重启,页面也会自己刷新,设置和会话都会保留。如果还有回合、子智能体或待回答的问题
+在进行(包括 IM 渠道里的),页面会提示你等它们结束再升级。用 `raven gateway` 或
+`raven web --foreground` 手动启动的 Raven 没有守护进程来重启它,请用下面的命令行
+方式更新。
+
### 更新一行命令安装的 Raven { #update-a-one-line-install }
再运行一次同一个安装命令即可。它会在当前版本之上安装最新发布版,完成后重新启动
diff --git a/i18n/messages.json b/i18n/messages.json
index 7750fed3e..c4a8f20a8 100644
--- a/i18n/messages.json
+++ b/i18n/messages.json
@@ -1344,8 +1344,8 @@
"zh": "Hi,我是 Raven Agent"
},
"gui.composer_ph": {
- "en": "Describe your task, or have Raven build an agent for it",
- "zh": "描述你的任务,或让 Raven 搭一个智能体来做"
+ "en": "Describe your task",
+ "zh": "描述你的任务"
},
"gui.compress.done": {
"en": "Archived {n} messages - {before} to {after} tokens",
@@ -4375,6 +4375,18 @@
"en": "new version available",
"zh": "有新版本"
},
+ "gui.upg.phase.download": {
+ "en": "Downloading the new version: {progress}",
+ "zh": "正在下载新版本:{progress}"
+ },
+ "gui.upg.phase.install": {
+ "en": "Installing the new version...",
+ "zh": "正在安装新版本…"
+ },
+ "gui.upg.phase.restart": {
+ "en": "Restarting Raven...",
+ "zh": "正在重启 Raven…"
+ },
"gui.upg.reauth": {
"en": "Raven restarted but this page is no longer signed in. Reopen Raven.",
"zh": "Raven 已重启,但这个页面的登录失效了,请重新打开 Raven。"
@@ -4383,6 +4395,18 @@
"en": "Upgrade Raven?",
"zh": "升级 Raven?"
},
+ "gui.upg.why.busy": {
+ "en": "Raven is still working on a turn, a sub-agent or a question waiting for an answer, possibly in an IM channel. Upgrading restarts Raven and would cut that off, so let it finish first.",
+ "zh": "Raven 还在处理回合、子智能体或待回答的问题(可能在 IM 渠道里)。升级会重启 Raven 并打断它们,请等它们结束再升级。"
+ },
+ "gui.upg.why.not_serving": {
+ "en": "This page is not being served by a running Raven, so it has nothing here to upgrade.",
+ "zh": "这个页面当前没有正在运行的 Raven 在托管,这里没有可以升级的对象。"
+ },
+ "gui.upg.why.unsupervised": {
+ "en": "This Raven was started by hand in a terminal, so nothing would bring it back after an upgrade. Run raven upgrade there, then start Raven again.",
+ "zh": "这个 Raven 是在终端里手动启动的,升级后没有守护进程能把它重新拉起来。请在那个终端里运行 raven upgrade,然后重新启动 Raven。"
+ },
"gui.upg.working": {
"en": "Upgrading Raven...",
"zh": "正在升级 Raven…"
@@ -8360,8 +8384,8 @@
"zh": "继续对话…"
},
"gui.composer.ph_busy": {
- "en": "Continue the conversation; it is sent once this turn ends",
- "zh": "继续对话,输入会排队到本轮结束再发"
+ "en": "Continue the conversation; Raven will receive your new instruction directly",
+ "zh": "继续对话,Raven 将直接收到你的新指令"
},
"gui.plus.add": {
"en": "Add",
diff --git a/raven/cli/commands.py b/raven/cli/commands.py
index 0c7f83767..2acdfa918 100644
--- a/raven/cli/commands.py
+++ b/raven/cli/commands.py
@@ -73,6 +73,23 @@ def _saved_language() -> str:
return "en"
+def _can_open_a_browser() -> bool:
+ """Whether this machine has a browser to hand the page to.
+
+ ``webbrowser.get()`` raises when nothing is registered, which is exactly the
+ question worth asking before anything is started: on a headless box the page
+ would come up on a URL the reader has no way to open, while the TUI is the
+ surface that still works there.
+ """
+ import webbrowser
+
+ try:
+ webbrowser.get()
+ except webbrowser.Error:
+ return False
+ return True
+
+
@app.callback()
def main(
ctx: typer.Context,
@@ -80,14 +97,26 @@ def main(
):
"""Raven - Agent Framework.
- Bare ``raven`` (no subcommand) is equivalent to ``raven tui``: it runs the
- same startup gate (onboard when provider+model are missing, else launch the
- session) and then enters the native TUI. Both paths share the identical
- pre-launch check by routing through the ``tui`` callback.
+ Bare ``raven`` (no subcommand) opens the page, the same as ``raven web``:
+ that is where the installer ends, so the command a reader types the next day
+ should not land them somewhere else.
+
+ Where no browser can be opened the page would be a URL nobody can reach, so
+ the bare command runs the native TUI instead -- through the ``tui`` callback,
+ so the startup gate (onboard when provider+model are missing, else launch the
+ session) is identical either way. ``raven web`` and ``raven tui`` still name
+ a surface outright and are unaffected.
"""
i18n.set_language(_saved_language())
if ctx.invoked_subcommand is not None:
return
+ if _can_open_a_browser():
+ from raven.cli.serve_commands import _web
+ from raven.rpc.transports.ws import DEFAULT_PORT
+
+ _web(DEFAULT_PORT)
+ return
+ console.print("No browser to open the page with; starting the terminal UI. `raven web` prints the URL instead.")
from raven.cli.tui_commands import tui as _tui_entry
# Delegate to the exact `raven tui` callback so the onboarding gate and
diff --git a/raven/cli/gateway_commands.py b/raven/cli/gateway_commands.py
index 43503623b..47e0007f7 100644
--- a/raven/cli/gateway_commands.py
+++ b/raven/cli/gateway_commands.py
@@ -309,6 +309,41 @@ def _retire_generation_watchers(swaps: "SwapCoordinator", agent) -> None:
logger.exception("skill watcher stop failed during shutdown; continuing")
+def _work_in_flight(agent, brokers, scheduler) -> dict | None:
+ """What a config swap or an upgrade restart would cut off, or None when idle.
+
+ Both refuse on this one answer rather than each keeping a copy: turns in
+ flight, sub-agents still running, and questions waiting on any surface --
+ the IM round-trip's broker and the page's.
+ """
+ questions = sum(broker.pending_count() for broker in brokers if broker is not None)
+ subagents = agent.subagents.get_running_count()
+ in_flight = agent.is_processing or (scheduler is not None and scheduler.has_running())
+ if in_flight or questions or subagents:
+ return {"subagents": subagents, "questions": questions}
+ return None
+
+
+def _hand_page_the_gateway(stop, busy) -> None:
+ """Give the mounted page this gateway's own stop, busy check and supervisor.
+
+ The page's upgrade restarts the whole process, so it needs the stop the
+ control plane uses and the busy check a swap refuses on. The supervisor is
+ trusted only as this process's own parent: web.json outlives the run that
+ wrote it, and a supervisor that is not ours would never bring this gateway
+ back.
+ """
+ import os
+
+ from raven.cli.serve_commands import _read_web_state
+ from raven.rpc.serve_control import SERVE
+
+ supervisor = _read_web_state()
+ if supervisor is not None and supervisor != os.getppid():
+ supervisor = None
+ SERVE.hand_over(stop, busy, supervisor)
+
+
def register(app: typer.Typer) -> None: # noqa: C901 (cc 87: pre-existing, above the ceiling)
"""Attach the ``gateway`` group to ``app``: the daemon as the bare command,
the control-plane verbs (reload / status / stop) as sub-commands."""
@@ -360,6 +395,17 @@ def gateway( # noqa: C901 (cc 86: pre-existing, above the ceiling)
"""Start the Raven gateway."""
if ctx.invoked_subcommand is not None:
return
+ # Before the instance lock, not after: the wait inside can last the
+ # whole install, and holding the lock through it is what would stop the
+ # gateway that the finished install is supposed to bring back.
+ #
+ # `raven serve` has guarded this since the marker existed; the gateway
+ # is the surface `raven web` actually supervises, so a restart landing
+ # inside an upgrade window came up on a half-written environment and
+ # served the placeholder page for the rest of its life.
+ from raven.cli.serve_commands import _refuse_incomplete_install
+
+ _refuse_incomplete_install("raven gateway")
from raven.agent.loop.bundles import HostWiring, TurnPolicy
from raven.agent.loop.recovery import limits_from_defaults
from raven.agent.workdir import WorkdirPolicy, WorkdirResolver, validate_override
@@ -783,6 +829,11 @@ async def _question_to_channel(frame: dict) -> None:
except OSError as exc:
logger.warning("page mount failed ({}); gateway continues without the page", exc)
if page_mount is not None:
+ # On every mount, not once: a swap's teardown disarms the
+ # page and the next generation mounts it afresh. Both names
+ # are bound later in `run`, before any bind runs. Pinned by
+ # source in tests/test_cli_gateway_commands.py.
+ _hand_page_the_gateway(_request_stop, _busy) # pragma: no cover
# The page is what shows the deck template gallery, so its
# covers are drawn now, in the background, rather than on
# the click that opens it; a no-op without the engine or
@@ -1064,25 +1115,27 @@ def _status() -> dict:
"page": {"mounted": page_mount is not None, "url": getattr(page_mount, "url", None)},
}
- async def _shutdown() -> None:
+ def _request_stop() -> None: # pragma: no cover - closure state; pinned by source
nonlocal shutdown_requested
shutdown_requested = True
if main_task is not None:
- # One tick later, so the {ok: true} reply leaves before
- # the teardown closes the control socket.
+ # One tick later, so a reply already queued -- the control
+ # plane's {ok: true}, the page's system.upgrade result --
+ # leaves before the teardown closes its socket.
asyncio.get_running_loop().call_soon(main_task.cancel)
+ async def _shutdown() -> None: # pragma: no cover - closure over run(); pinned by source
+ _request_stop()
+
+ def _busy() -> dict | None: # pragma: no cover - closure over run(); logic in _work_in_flight
+ page_questions = page_mount.question_broker if page_mount is not None else None
+ return _work_in_flight(agent, [question_broker, page_questions], gw_scheduler)
+
async def _reload(force: bool) -> dict:
- # A swap cancels every in-flight turn, sub-agent and pending
- # question; refuse while there is work unless told to force.
if not force:
- questions = question_broker.pending_count() if question_broker is not None else 0
- if page_mount is not None:
- questions += page_mount.question_broker.pending_count()
- subagents = agent.subagents.get_running_count()
- in_flight = agent.is_processing or (gw_scheduler is not None and gw_scheduler.has_running())
- if in_flight or questions or subagents:
- return {"ok": False, "reason": "busy", "subagents": subagents, "questions": questions}
+ busy = _busy() # pragma: no cover
+ if busy is not None: # pragma: no cover
+ return {"ok": False, "reason": "busy", **busy}
return await _request_swap()
control_dispatcher = Dispatcher()
diff --git a/raven/cli/serve_commands.py b/raven/cli/serve_commands.py
index b57d3ec88..b02ef2abe 100644
--- a/raven/cli/serve_commands.py
+++ b/raven/cli/serve_commands.py
@@ -522,9 +522,13 @@ async def _serve_main(port: int, open_browser: bool) -> None:
_UPGRADE_POLL_S = 1.0
-def _refuse_incomplete_install() -> None:
+def _refuse_incomplete_install(label: str = "raven serve") -> None:
"""Stop before binding a port if this environment is not whole.
+ ``label`` names the caller in the refusal, because both surfaces that serve
+ the page reach this: `raven serve` standalone, and `raven gateway`, which is
+ the one `raven web` actually supervises.
+
``build_app`` chooses the page route once, so a serve that starts while uv
is still writing the environment does not merely start slowly -- it answers
``/`` with the placeholder until it is restarted, on an installation that
@@ -542,7 +546,7 @@ def _refuse_incomplete_install() -> None:
return
if fault.reason == "upgrading":
- typer.echo(f"raven serve: {fault.detail}; waiting for it to finish", err=True)
+ typer.echo(f"{label}: {fault.detail}; waiting for it to finish", err=True)
deadline = time.monotonic() + _UPGRADE_WAIT_S
while time.monotonic() < deadline:
time.sleep(_UPGRADE_POLL_S)
@@ -555,14 +559,14 @@ def _refuse_incomplete_install() -> None:
# sound -- but not for this process to run. It already holds half of the
# build that was replaced, and every import still to come would load off
# disk from the other one.
- typer.echo("raven serve: the upgrade finished; start Raven again to run it.", err=True)
+ typer.echo(f"{label}: the upgrade finished; start Raven again to run it.", err=True)
elif fault.reason == "upgrading":
typer.echo(
- "raven serve: the upgrade has not finished; not starting on a half-written installation.",
+ f"{label}: the upgrade has not finished; not starting on a half-written installation.",
err=True,
)
else:
- typer.echo(f"raven serve: {fault.detail}; not starting.", err=True)
+ typer.echo(f"{label}: {fault.detail}; not starting.", err=True)
typer.echo(
"Repair it by rerunning the installer: curl -fsSL https://raven.evermind.ai/install.sh | sh",
err=True,
diff --git a/raven/rpc/methods/system.py b/raven/rpc/methods/system.py
index 711eedd1e..0d2d991d4 100644
--- a/raven/rpc/methods/system.py
+++ b/raven/rpc/methods/system.py
@@ -201,12 +201,16 @@ async def system_version(params: dict, *, send_frame: Any = None) -> dict:
async def system_upgrade(params: dict) -> dict:
- """`system.upgrade` — install the latest release and restart the gateway.
-
- Only meaningful inside `raven serve`: the helper waits for *this* process to
- exit, so the caller must be the process being replaced. Returns as soon as
- the helper owns the install; the shutdown is scheduled a beat later so this
- reply reaches the client first.
+ """`system.upgrade` — install the latest release and restart what serves the page.
+
+ Two hosts, two restarts. Under standalone `raven serve` the helper waits for
+ this process and brings back another `raven serve`. Under `raven gateway` --
+ what `raven web` runs -- this process exits cleanly, its supervisor reads the
+ zero exit as "stand down" and exits too, and the helper waits for *that*
+ before it installs and brings back a fresh `raven web --supervise`: the same
+ gateway, IM channels included, on the same port. Returns as soon as the
+ helper owns the install; the shutdown is scheduled a beat later so this reply
+ reaches the client first.
"""
import asyncio
@@ -220,14 +224,24 @@ def _refuse(reason: str, detail: str) -> ConfigValidationError:
return ConfigValidationError(detail, data={"reason": reason, "detail": detail})
if SERVE.hosted_by_gateway:
- # The page is mounted inside `raven gateway`, and the relaunch below
- # would replace that whole process with a bare `raven serve` -- the IM
- # channels would silently vanish. Restarting the gateway in place is a
- # later phase; until then this refusal is the honest answer.
- raise _refuse(
- "gateway_hosted",
- "This page is hosted by `raven gateway`. Run `raven upgrade`, then restart the gateway.",
- )
+ if SERVE.supervisor_pid is None:
+ # Nothing would bring this gateway back: started by hand, or under
+ # `raven web --foreground`. Starting a resident supervisor on the
+ # reader's behalf would change how their Raven runs, so say so.
+ raise _refuse(
+ "unsupervised",
+ "This Raven was started by hand, so nothing would bring it back after an upgrade. "
+ "Run `raven upgrade` in a terminal, then start Raven again.",
+ )
+ busy = SERVE.busy()
+ if busy is not None:
+ # The restart cancels every turn, sub-agent and pending question on
+ # this engine -- IM channels included, which the page cannot see.
+ raise _refuse(
+ "busy",
+ "Raven is still working on a turn, a sub-agent or a pending question. "
+ "Upgrading restarts it and would cut that off; let it finish first.",
+ )
if not SERVE.running:
raise _refuse("not_serving", "system.upgrade is only available while `raven serve` is running")
@@ -248,9 +262,23 @@ def _refuse(reason: str, detail: str) -> ConfigValidationError:
relaunch: list[str] | None = None
extra_env: dict[str, str] = {}
+ parent_pid = os.getpid()
raven_bin = plan.target.bin_dir / ("raven.exe" if os.name == "nt" else "raven")
+ if SERVE.hosted_by_gateway and (SERVE.port is None or not raven_bin.parent.is_dir()):
+ # Under the gateway there is no degraded path: without a relaunch the
+ # helper would install and leave nothing running at all.
+ raise _refuse("not_upgradable", "Could not find the raven executable to restart the gateway from.")
if SERVE.port is not None and raven_bin.parent.is_dir():
- relaunch = [str(raven_bin), "serve", "--port", str(SERVE.port)]
+ if SERVE.hosted_by_gateway:
+ # Waited for as the parent, not this process: the supervisor's own
+ # cleanup runs after the gateway exits and removes web.json, and a
+ # new supervisor started before that would have its file removed
+ # out from under it -- inviting the next `raven web` to start a
+ # second one beside it.
+ relaunch = [str(raven_bin), "web", "--supervise", "--port", str(SERVE.port)]
+ parent_pid = SERVE.supervisor_pid or parent_pid
+ else:
+ relaunch = [str(raven_bin), "serve", "--port", str(SERVE.port)]
# Same port, same credentials: the browser reconnects to the origin it
# already has and presents the cookie it already holds, so it stays
# signed in across the restart instead of hitting an auth wall. Both
@@ -265,9 +293,10 @@ def _refuse(reason: str, detail: str) -> ConfigValidationError:
try:
spawn_detached_upgrade(
plan,
- parent_pid=os.getpid(),
+ parent_pid=parent_pid,
relaunch=relaunch,
extra_env=extra_env or None,
+ status_port=SERVE.port if relaunch is not None else None,
)
except UpgradeError as exc:
raise _refuse("handoff_failed", str(exc)) from exc
diff --git a/raven/rpc/serve_control.py b/raven/rpc/serve_control.py
index bfd95a538..8fe8f77a2 100644
--- a/raven/rpc/serve_control.py
+++ b/raven/rpc/serve_control.py
@@ -15,7 +15,7 @@
from __future__ import annotations
import asyncio
-from typing import Optional
+from typing import Callable, Optional
class ServeControl:
@@ -24,36 +24,70 @@ def __init__(self) -> None:
self.token: Optional[str] = None
self.cookie: Optional[str] = None
self._stop: Optional[asyncio.Event] = None
+ self._gateway_stop: Optional[Callable[[], None]] = None
+ self._gateway_busy: Optional[Callable[[], Optional[dict]]] = None
+ self.supervisor_pid: Optional[int] = None
self.hosted_by_gateway = False
def arm(self, port: int, token: str, cookie: str, stop: asyncio.Event) -> None:
self.port, self.token, self.cookie, self._stop = port, token, cookie, stop
def arm_hosted(self, port: int, token: str, cookie: str) -> None:
- """The gateway hosts the page: record the endpoint facts, no stop event.
+ """The gateway hosts the page: record the endpoint facts.
- ``system.upgrade``'s restart flow replaces a `raven serve` process with
- another `raven serve`; ending the gateway's loop that way would drop the
- IM channels and bring back the wrong process. So a hosted page carries
- no shutdown handle, and upgrade refuses with its own reason instead
- (see ``raven.rpc.methods.system.system_upgrade``).
+ No stop handle yet. The page is mounted before the gateway has built
+ the graceful stop it answers the control plane with, so that handle
+ arrives later through ``hand_over``. Until it does, a hosted page is
+ not ``running`` and ``system.upgrade`` refuses rather than guess.
"""
self.port, self.token, self.cookie = port, token, cookie
self.hosted_by_gateway = True
+ def hand_over(
+ self,
+ stop: Callable[[], None],
+ busy: Callable[[], Optional[dict]],
+ supervisor_pid: Optional[int],
+ ) -> None:
+ """The gateway's own graceful stop, and its own answer to "is work running?".
+
+ Not the page mount's stop event: that one ends the announcer and
+ nothing else, and the process has to exit for anything to replace it.
+ ``stop`` is the path `gateway.shutdown` takes -- the whole teardown
+ chain, IM channels included, ending in a zero exit that the `raven web`
+ supervisor reads as "stand down" rather than as a crash to undo.
+ ``busy`` is the check the gateway already refuses a config swap on.
+
+ ``supervisor_pid`` is the `raven web` supervisor that will stand down
+ with this process, or None when nothing would bring it back (started by
+ hand, or under `raven web --foreground`). The gateway answers it because
+ only the entrance layer can read the supervisor's state file.
+ """
+ self._gateway_stop, self._gateway_busy = stop, busy
+ self.supervisor_pid = supervisor_pid
+
def disarm(self) -> None:
self.port = self.token = self.cookie = self._stop = None
+ self._gateway_stop = self._gateway_busy = None
+ self.supervisor_pid = None
self.hosted_by_gateway = False
@property
def running(self) -> bool:
- return self._stop is not None
+ return self._stop is not None or self._gateway_stop is not None
+
+ def busy(self) -> Optional[dict]:
+ """What is still running that a restart would cut off, or None."""
+ return self._gateway_busy() if self._gateway_busy is not None else None
def request_shutdown(self) -> bool:
- if self._stop is None:
- return False
- self._stop.set()
- return True
+ if self._stop is not None:
+ self._stop.set()
+ return True
+ if self._gateway_stop is not None:
+ self._gateway_stop()
+ return True
+ return False
SERVE = ServeControl()
diff --git a/raven/rpc/transports/ws.py b/raven/rpc/transports/ws.py
index 7d24c6bdc..df18e364b 100644
--- a/raven/rpc/transports/ws.py
+++ b/raven/rpc/transports/ws.py
@@ -23,7 +23,7 @@
from pathlib import Path
from typing import Any
-from aiohttp import WSMsgType, web
+from aiohttp import WSCloseCode, WSMsgType, web
from loguru import logger
DEFAULT_PORT = 18792
@@ -152,6 +152,21 @@ async def broadcast(self, frame: dict[str, Any] | bytes) -> None:
except Exception:
self._sockets.discard(ws)
+ async def close_all(self, _app: object = None) -> None:
+ """Close every open page socket; registered as the app's ``on_shutdown``.
+
+ aiohttp's cleanup waits for live handlers rather than closing websockets
+ itself, so an open tab held the whole process up for most of a minute
+ after it was told to stop -- and a page-started upgrade cannot begin
+ installing until this process has exited. Closed here, the page sees
+ the drop at once and goes into its reconnect.
+ """
+ for ws in list(self._sockets):
+ try:
+ await ws.close(code=WSCloseCode.GOING_AWAY, message=b"server shutdown")
+ except Exception:
+ self._sockets.discard(ws)
+
# ---- auth helpers -------------------------------------------------------
def _authorized(self, request: web.Request) -> bool:
@@ -580,6 +595,7 @@ def build_app(
app.router.add_get("/file", gateway.handle_file)
app.router.add_get("/knowledge/file", gateway.handle_knowledge_file)
app.router.add_get("/rpc", gateway.handle_ws)
+ app.on_shutdown.append(gateway.close_all)
app.router.add_get("/oauth/callback", handle_oauth_callback)
from raven.a2a.gate import mount_gateway_face
diff --git a/raven/updates/install_guard.py b/raven/updates/install_guard.py
index f5275c52b..86e730315 100644
--- a/raven/updates/install_guard.py
+++ b/raven/updates/install_guard.py
@@ -57,7 +57,7 @@ def marker_path() -> Path:
return raven_home() / MARKER_NAME
-def write_marker(*, to_version: str | None = None, pid: int | None = None) -> Path:
+def write_marker(*, to_version: str | None = None, pid: int | None = None, port: int | None = None) -> Path:
"""Record that the environment is about to be replaced.
Written by the side that still has a working Raven, before the helper is
@@ -70,6 +70,10 @@ def write_marker(*, to_version: str | None = None, pid: int | None = None) -> Pa
payload["to_version"] = to_version
if pid is not None:
payload["pid"] = pid
+ if port is not None:
+ # The page's port, for the helper to answer on with progress once the
+ # process that held it has exited.
+ payload["port"] = port
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
return path
diff --git a/raven/updates/upgrade.py b/raven/updates/upgrade.py
index 5f79b1691..2fffb1c44 100644
--- a/raven/updates/upgrade.py
+++ b/raven/updates/upgrade.py
@@ -16,6 +16,7 @@
import subprocess
import sys
import tomllib
+import zlib
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
@@ -66,6 +67,7 @@ class ToolInstallTarget:
import shutil
import subprocess
import sys
+import threading
import time
# How long to wait for the process being replaced to exit. Sized for a real
@@ -78,6 +80,124 @@ class ToolInstallTarget:
# little costs the user a Raven they have to restart by hand.
PARENT_EXIT_TIMEOUT_S = 300
+# How long one size probe may take. Deliberately well under the socket
+# default: the sizes are a courtesy, and four unanswered probes must not add
+# minutes of silence to the very wait they exist to explain.
+SIZE_TIMEOUT_S = 5
+
+# How long a failure stays readable on the page's port before the Raven it
+# replaced is brought back. The page polls about once a second, so this is
+# several chances to see it; the ceiling is what a reader waits for the old
+# Raven when nobody is looking.
+STATUS_HOLD_S = 5
+
+WAITING_PAGE = (
+ b''
+ b"
RavenRaven is upgrading...
"
+)
+
+
+class Progress:
+ # What the page shows while this helper is the only thing on its port.
+ # Written by the download loop, read by the status server's threads.
+
+ def __init__(self, to_version):
+ self._lock = threading.Lock()
+ self._state = {
+ "upgrading": True,
+ "to": to_version,
+ "phase": "starting",
+ "done": 0,
+ "total": 0,
+ "rate": 0,
+ "message": None,
+ }
+ self._samples = []
+ self.failure_seen = threading.Event()
+
+ def set(self, **fields):
+ with self._lock:
+ self._state.update(fields)
+
+ def advance(self, count):
+ now = time.monotonic()
+ with self._lock:
+ self._state["done"] += count
+ self._samples.append((now, self._state["done"]))
+ self._samples = [sample for sample in self._samples if sample[0] >= now - 3.0]
+ (first_at, first_done), (last_at, last_done) = self._samples[0], self._samples[-1]
+ if last_at > first_at:
+ self._state["rate"] = int((last_done - first_done) / (last_at - first_at))
+
+ def snapshot(self):
+ with self._lock:
+ state = dict(self._state)
+ if state["phase"] == "failed":
+ self.failure_seen.set()
+ return state
+
+
+def recorded_port():
+ # The port the page is on, written into the marker by the side that spawned
+ # this helper. Absent for the terminal, which has no page to answer.
+ if not MARKER_PATH:
+ return None
+ try:
+ with open(MARKER_PATH, encoding="utf-8") as handle:
+ port = json.load(handle).get("port")
+ except (OSError, ValueError, AttributeError):
+ return None
+ return port if isinstance(port, int) and 0 < port < 65536 else None
+
+
+def serve_status(port, progress):
+ # Once the Raven being replaced has exited, nothing answers on the port its
+ # page is pointed at, and the page can only spin. This answers in its place
+ # until the new Raven is started: `/upgrade/status` with the progress, and
+ # 503 for everything else, so the page's "is it back?" probe keeps waiting
+ # instead of reloading onto this helper.
+ import http.server
+
+ allowed = {f"127.0.0.1:{port}", f"localhost:{port}"}
+
+ class Handler(http.server.BaseHTTPRequestHandler):
+ def reply(self, status, body, content_type):
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Content-Length", str(len(body)))
+ if status == 503:
+ self.send_header("Retry-After", "2")
+ self.end_headers()
+ if self.command != "HEAD":
+ self.wfile.write(body)
+
+ def do_GET(self):
+ # Only the page's own origin: a site the reader has open could
+ # otherwise rebind a host name of its own to this port.
+ if self.headers.get("Host") not in allowed:
+ self.reply(421, b"", "text/plain")
+ return
+ if self.path.split("?", 1)[0] == "/upgrade/status":
+ self.reply(200, json.dumps(progress.snapshot()).encode("utf-8"), "application/json")
+ return
+ # A reload during the upgrade lands here, not on the page. It asks
+ # again every few seconds, so it becomes the page once Raven is back.
+ self.reply(503, WAITING_PAGE, "text/html; charset=utf-8")
+
+ do_HEAD = do_GET
+
+ def log_message(self, *args):
+ pass
+
+ try:
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
+ except OSError:
+ return None
+ server.daemon_threads = True
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ return server
+
# Where the spawning side recorded that this environment is being replaced. The
# helper runs under `python -I` outside the environment it is rewriting, so it
# cannot import raven to ask; the path is handed over instead.
@@ -279,6 +399,12 @@ def run(argv=None):
if parent_status != 0:
return parent_status
+ # Only once the parent is gone, which is what frees the port, and only for a
+ # page: the terminal has no port and sees the progress on its own screen.
+ progress = Progress(latest_version)
+ port = recorded_port() if relaunch is not None else None
+ status_server = serve_status(port, progress) if port is not None else None
+
def run_uv(requirement, mode, plugin_list):
command = [uv_path, "tool", "install"] + mode
if constraints_path:
@@ -313,7 +439,7 @@ def install(requirement, plugin_list):
return 0
return run_uv(requirement, ["--force"], plugin_list)
- def restart():
+ def restart(failure=None):
# Called on every path out of the install, not just the successful one.
# Once the parent has exited, this helper holds the only handle to the
# surface the user was sitting in -- so a failed install must still put
@@ -321,6 +447,16 @@ def restart():
# the page the upgrade was clicked from cannot even reconnect to say
# what went wrong, and the user is left with a dead window.
#
+ # A failure is held on the page's port until the page has read it, or
+ # for STATUS_HOLD_S: one nobody saw reads, once the old Raven is back, as
+ # an upgrade that quietly did nothing. Then the port is let go, because
+ # the Raven started below binds it strictly.
+ if status_server is not None:
+ if failure is not None:
+ progress.set(phase="failed", message=failure)
+ progress.failure_seen.wait(STATUS_HOLD_S)
+ status_server.shutdown()
+ status_server.server_close()
# The marker goes first, before anything is launched: the process being
# started here reads it, and would wait out an upgrade that is over.
clear_marker()
@@ -344,6 +480,7 @@ def restart():
# is uninstalled. Installing raven alone would be exactly that loss, so no
# list means no upgrade.
import base64
+ import pathlib
import socket
import tempfile
import urllib.parse
@@ -362,22 +499,119 @@ def authorize(url):
return url
return urllib.parse.urlunsplit(parts._replace(netloc=wheel_parts.netloc))
- def download(url, prefix):
+ def unwrap_credentials(url):
# urllib takes `user:token@host` for the host name, so the credentials
# move into the header and out of the URL before the request is made.
parts = urllib.parse.urlsplit(url)
- headers = {}
- if parts.username:
- creds = urllib.parse.unquote(parts.username) + ":" + urllib.parse.unquote(parts.password or "")
- headers["Authorization"] = "Basic " + base64.b64encode(creds.encode("utf-8")).decode("ascii")
- host = parts.hostname if parts.port is None else f"{parts.hostname}:{parts.port}"
- url = urllib.parse.urlunsplit(parts._replace(netloc=host))
+ if not parts.username:
+ return url, {}
+ creds = urllib.parse.unquote(parts.username) + ":" + urllib.parse.unquote(parts.password or "")
+ host = parts.hostname if parts.port is None else f"{parts.hostname}:{parts.port}"
+ headers = {"Authorization": "Basic " + base64.b64encode(creds.encode("utf-8")).decode("ascii")}
+ return urllib.parse.urlunsplit(parts._replace(netloc=host)), headers
+
+ def download(url, prefix):
+ url, headers = unwrap_credentials(url)
fd, path = tempfile.mkstemp(prefix=prefix, suffix=".txt")
request = urllib.request.Request(url, headers=headers)
with os.fdopen(fd, "wb") as handle, urllib.request.urlopen(request) as response:
handle.write(response.read())
return path
+ def asset_size(url):
+ url, headers = unwrap_credentials(url)
+ request = urllib.request.Request(url, headers=headers, method="HEAD")
+ with urllib.request.urlopen(request, timeout=SIZE_TIMEOUT_S) as response:
+ return int(response.headers.get("Content-Length") or 0)
+
+ def announce_download(assets):
+ # uv draws one bar per package and no overall total, so an upgrade whose
+ # engines are tens of megabytes reads exactly like one that has stopped.
+ # The size up front is what separates a slow link from a dead one, and
+ # the line comes before the probes so a probe that hangs adds no silence.
+ #
+ # Flushed by hand: from the page, this output goes to web.log, a file,
+ # where Python buffers it by the block -- and uv writes to the same file
+ # straight away, so an unflushed manifest landed after uv's own lines.
+ noun = "package" if len(assets) == 1 else "packages"
+ print(f"Downloading {len(assets)} {noun} for Raven {latest_version}.", flush=True)
+ sizes = []
+ for name, url in assets:
+ try:
+ sizes.append((name, asset_size(url)))
+ except Exception:
+ sizes.append((name, 0))
+ if not any(size for _, size in sizes):
+ return [0 for _ in sizes]
+ width = max(len(name) for name, _ in sizes)
+ for name, size in sizes:
+ print(f" {name.ljust(width)} " + (f"{size / 1048576:6.1f} MiB" if size else " unknown"))
+ print(f" {'total'.ljust(width)} {sum(size for _, size in sizes) / 1048576:6.1f} MiB", flush=True)
+ return [size for _, size in sizes]
+
+ def progress_line():
+ state = progress.snapshot()
+ of = f" / {state['total'] / 1048576:.1f}" if state["total"] else ""
+ # No rate until there is one: "0 KB/s" on the first frame reads as a stall.
+ rate = f" {state['rate'] / 1024:.0f} KB/s" if state["rate"] else ""
+ return f" {state['done'] / 1048576:.1f}{of} MiB{rate} "
+
+ def fetch(url, path):
+ url, headers = unwrap_credentials(url)
+ request = urllib.request.Request(url, headers=headers)
+ partial = path + ".part"
+ live = sys.stdout.isatty()
+ shown = 0.0
+ with urllib.request.urlopen(request) as response, open(partial, "wb") as handle:
+ while True:
+ chunk = response.read(65536)
+ if not chunk:
+ break
+ handle.write(chunk)
+ progress.advance(len(chunk))
+ if live and time.monotonic() - shown > 0.25:
+ shown = time.monotonic()
+ print("\r" + progress_line(), end="", flush=True)
+ os.replace(partial, path)
+
+ def download_assets(assets, sizes):
+ # Fetched here rather than by uv, because here the bytes can be counted.
+ # uv draws no progress when it is not writing to a terminal -- the page's
+ # case, where its output is a log file -- and tens of megabytes with
+ # nothing moving on screen reads as a hang. uv is handed the local files
+ # and only resolves dependencies. All or nothing: if any asset cannot be
+ # fetched, uv gets the original URLs and downloads them itself, exactly
+ # as it did before this existed.
+ home = os.path.dirname(MARKER_PATH) if MARKER_PATH else None
+ root = os.path.join(home, "cache", "upgrade") if home else tempfile.mkdtemp(prefix="raven-upgrade-")
+ target = os.path.join(root, latest_version)
+ progress.set(phase="downloading", done=0, total=sum(sizes))
+ local = {}
+ try:
+ os.makedirs(target, exist_ok=True)
+ for _name, url in assets:
+ path = os.path.join(target, urllib.parse.urlsplit(url).path.rsplit("/", 1)[-1])
+ fetch(url, path)
+ local[url] = pathlib.Path(path).as_uri()
+ except Exception as exc:
+ if sys.stdout.isatty():
+ print()
+ print(f"Warning: could not download the release directly ({exc}); uv will fetch it.", file=sys.stderr)
+ shutil.rmtree(target if home else root, ignore_errors=True)
+ progress.set(phase="installing")
+ return None
+ if sys.stdout.isatty():
+ print()
+ print(f"Downloaded {progress.snapshot()['done'] / 1048576:.1f} MiB.", flush=True)
+ # Earlier versions' files go. This version's stay: the tool's receipt
+ # names them as where it was installed from.
+ if home:
+ for entry in os.listdir(root):
+ if entry != latest_version:
+ shutil.rmtree(os.path.join(root, entry), ignore_errors=True)
+ progress.set(phase="installing")
+ return local
+
def write_list(lines, prefix):
fd, path = tempfile.mkstemp(prefix=prefix, suffix=".txt")
with os.fdopen(fd, "w", encoding="utf-8") as handle:
@@ -397,12 +631,9 @@ def write_list(lines, prefix):
with open(download(authorize(release_dir + "/raven-plugins.txt"), "raven-plugins-"), encoding="utf-8") as handle:
plugin_lines = [line.strip() for line in handle if line.strip() and not line.lstrip().startswith("#")]
except Exception as exc:
- print(
- f"Unable to upgrade Raven: could not download the plugin list for {latest_version} ({exc}). "
- "Nothing was changed; retry later.",
- file=sys.stderr,
- )
- restart()
+ why = f"could not download the plugin list for {latest_version} ({exc}). Nothing was changed; retry later."
+ print(f"Unable to upgrade Raven: {why}", file=sys.stderr)
+ restart(why)
return 1
def authorize_line(line):
@@ -410,6 +641,22 @@ def authorize_line(line):
return name + sep + authorize(url) if sep else line
plugin_lines = [authorize_line(line) for line in plugin_lines]
+
+ assets = [("raven", wheel_url)]
+ for line in plugin_lines:
+ name, sep, url = line.partition(" @ ")
+ if sep:
+ assets.append((name.strip(), url.strip()))
+ local = download_assets(assets, announce_download(assets))
+ wheel_source = wheel_url
+ if local is not None:
+ wheel_source = local[wheel_url]
+
+ def relocate(line):
+ name, sep, url = line.partition(" @ ")
+ return f"{name.strip()} @ {local[url.strip()]}" if sep and url.strip() in local else line
+
+ plugin_lines = [relocate(line) for line in plugin_lines]
memory_lines = [line for line in plugin_lines if line.partition(" @ ")[0].strip() == "everos-memory"]
# Two independent things can fail: the channel extras, and the plugins
@@ -449,26 +696,26 @@ def authorize_line(line):
else "these processes are still running from the current install and Windows cannot "
"replace a running executable: pid " + ", ".join(survivors)
)
- print(
- "Unable to upgrade Raven: " + what + ". Stop them and run the upgrade again. Nothing was changed.",
- file=sys.stderr,
- )
- restart()
+ why = what + ". Stop them and run the upgrade again. Nothing was changed."
+ print("Unable to upgrade Raven: " + why, file=sys.stderr)
+ restart(why)
return 1
for plugin_list, spec, losses in rungs:
- requirement = wheel_url if spec == "raven" else f"{spec} @ {wheel_url}"
+ requirement = wheel_source if spec == "raven" else f"{spec} @ {wheel_source}"
status = install(requirement, plugin_list)
if status == 0:
for loss in losses:
print(f"Warning: {loss}", file=sys.stderr)
break
else:
- print(f"Unable to upgrade Raven: uv exited with status {status}.", file=sys.stderr)
- restart()
+ why = f"uv exited with status {status}."
+ print(f"Unable to upgrade Raven: {why}", file=sys.stderr)
+ restart(why)
return status
except OSError as exc:
- print(f"Unable to upgrade Raven: could not run uv: {exc}.", file=sys.stderr)
- restart()
+ why = f"could not run uv: {exc}."
+ print(f"Unable to upgrade Raven: {why}", file=sys.stderr)
+ restart(why)
return 1
if current_version == latest_version:
@@ -487,8 +734,15 @@ def authorize_line(line):
def _upgrade_helper_bootstrap() -> str:
- encoded = base64.b64encode(_UPGRADE_HELPER_SOURCE.encode("utf-8")).decode("ascii")
- return f'exec(compile(__import__("base64").b64decode("{encoded}"),"","exec"))'
+ # Compressed because the whole helper travels on the command line, and
+ # Windows caps a command line at 32,767 characters: base64 of the plain
+ # source grew past that and the helper could not even be started there
+ # (WinError 206). zlib ships with every Python, `-I` included.
+ encoded = base64.b64encode(zlib.compress(_UPGRADE_HELPER_SOURCE.encode("utf-8"), 9)).decode("ascii")
+ return (
+ 'exec(compile(__import__("zlib").decompress(__import__("base64").b64decode('
+ f'"{encoded}")),"","exec"))'
+ )
def _version_key(value: str) -> tuple[int, int, int]:
@@ -984,6 +1238,7 @@ def spawn_detached_upgrade(
parent_pid: int,
relaunch: list[str] | None = None,
extra_env: dict[str, str] | None = None,
+ status_port: int | None = None,
) -> None:
"""Start the upgrade helper as a process that outlives this one.
@@ -1004,7 +1259,7 @@ def spawn_detached_upgrade(
# Written here rather than by the helper: the window this marker exists for
# opens the moment the caller lets go of the port, which is before the
# helper has run its first instruction.
- env["RAVEN_UPGRADE_MARKER"] = str(_install_guard.write_marker(to_version=plan.release.version))
+ env["RAVEN_UPGRADE_MARKER"] = str(_install_guard.write_marker(to_version=plan.release.version, port=status_port))
if extra_env:
env.update(extra_env)
diff --git a/tests/test_cli_gateway_commands.py b/tests/test_cli_gateway_commands.py
index 89395aaa4..53fcd420f 100644
--- a/tests/test_cli_gateway_commands.py
+++ b/tests/test_cli_gateway_commands.py
@@ -114,6 +114,135 @@ def test_run_starts_the_litellm_warm_up_before_the_first_request() -> None:
assert "warm_up_in_background()" in run_body
+def test_every_page_mount_hands_the_gateway_stop_to_the_page() -> None:
+ """The page's upgrade restarts this whole process, so it needs the same
+ graceful stop and busy check the control plane uses. Handed over on every
+ mount, because a swap tears the page down (which disarms it) and mounts it
+ again; a hand-over done once at boot would be gone after the first reload.
+ ``run()`` blocks forever, so its source is pinned rather than executed."""
+ import inspect
+
+ from raven.cli import gateway_commands
+
+ src = inspect.getsource(gateway_commands.register)
+ bind = src.split("async def _bind_generation():", 1)[1].split("def _request_stop() -> None:", 1)[0]
+ mount_at = bind.index("page_mount = await mount_page(")
+ hand_at = bind.index("_hand_page_the_gateway(_request_stop, _busy)")
+ assert mount_at < hand_at
+
+
+class TestHandingThePageTheGateway:
+ """What the page's upgrade needs from the gateway it runs in."""
+
+ @pytest.fixture(autouse=True)
+ def _disarm(self):
+ from raven.rpc.serve_control import SERVE
+
+ yield
+ SERVE.disarm()
+
+ def test_its_own_supervisor_is_handed_over(self, monkeypatch) -> None:
+ import os
+
+ from raven.cli import gateway_commands, serve_commands
+ from raven.rpc.serve_control import SERVE
+
+ monkeypatch.setattr(serve_commands, "_read_web_state", lambda: os.getppid())
+ SERVE.arm_hosted(18792, "tok", "cookie")
+
+ gateway_commands._hand_page_the_gateway(lambda: None, lambda: None)
+
+ assert SERVE.supervisor_pid == os.getppid()
+ assert SERVE.running
+
+ def test_a_supervisor_that_is_not_its_parent_is_not_trusted(self, monkeypatch) -> None:
+ """web.json outlives the run that wrote it. A supervisor that is not
+ this process's parent would never bring the gateway back, so treating
+ it as one would install an upgrade and leave nothing running."""
+ import os
+
+ from raven.cli import gateway_commands, serve_commands
+ from raven.rpc.serve_control import SERVE
+
+ monkeypatch.setattr(serve_commands, "_read_web_state", lambda: os.getppid() + 1)
+ SERVE.arm_hosted(18792, "tok", "cookie")
+
+ gateway_commands._hand_page_the_gateway(lambda: None, lambda: None)
+
+ assert SERVE.supervisor_pid is None
+
+ def test_no_supervisor_at_all_is_handed_over_as_none(self, monkeypatch) -> None:
+ from raven.cli import gateway_commands, serve_commands
+ from raven.rpc.serve_control import SERVE
+
+ monkeypatch.setattr(serve_commands, "_read_web_state", lambda: None)
+ SERVE.arm_hosted(18792, "tok", "cookie")
+
+ gateway_commands._hand_page_the_gateway(lambda: None, lambda: None)
+
+ assert SERVE.supervisor_pid is None
+
+
+class TestWorkInFlight:
+ """The one answer a config swap and an upgrade restart both refuse on."""
+
+ def _agent(self, *, processing=False, subagents=0):
+ from types import SimpleNamespace
+
+ return SimpleNamespace(
+ is_processing=processing,
+ subagents=SimpleNamespace(get_running_count=lambda: subagents),
+ )
+
+ def _broker(self, pending):
+ from types import SimpleNamespace
+
+ return SimpleNamespace(pending_count=lambda: pending)
+
+ def test_an_idle_gateway_reports_nothing(self) -> None:
+ from raven.cli.gateway_commands import _work_in_flight
+
+ assert _work_in_flight(self._agent(), [self._broker(0), None], None) is None
+
+ def test_a_question_waiting_on_either_surface_counts(self) -> None:
+ """The IM round-trip and the page each hold their own broker, and a
+ restart drops what is pending on both."""
+ from raven.cli.gateway_commands import _work_in_flight
+
+ assert _work_in_flight(self._agent(), [self._broker(1), self._broker(2)], None) == {
+ "subagents": 0,
+ "questions": 3,
+ }
+
+ def test_a_running_sub_agent_counts(self) -> None:
+ from raven.cli.gateway_commands import _work_in_flight
+
+ assert _work_in_flight(self._agent(subagents=2), [], None) == {"subagents": 2, "questions": 0}
+
+ def test_a_turn_in_flight_counts_whether_the_agent_or_the_scheduler_holds_it(self) -> None:
+ from types import SimpleNamespace
+
+ from raven.cli.gateway_commands import _work_in_flight
+
+ assert _work_in_flight(self._agent(processing=True), [], None) is not None
+ scheduler = SimpleNamespace(has_running=lambda: True)
+ assert _work_in_flight(self._agent(), [], scheduler) is not None
+
+
+def test_the_swap_and_the_upgrade_refuse_on_one_busy_answer() -> None:
+ """Both cut off in-flight turns, sub-agents and pending questions. Two
+ copies of that check would drift, and the one that drifted would restart
+ over work the other would have protected."""
+ import inspect
+
+ from raven.cli import gateway_commands
+
+ src = inspect.getsource(gateway_commands.register)
+ reload_body = src.split("async def _reload(force: bool) -> dict:", 1)[1].split("control_dispatcher", 1)[0]
+ assert "_busy()" in reload_body
+ assert "pending_count()" not in reload_body
+
+
def test_run_warms_the_deck_template_covers_once_the_page_is_mounted() -> None:
"""`raven web` is `raven gateway --page-port` underneath, so the gallery's
covers are drawn from here, after the page mount, not from `raven serve`
@@ -164,6 +293,88 @@ def _raise(now: float):
assert "4242" in r.stdout
+class TestTheGatewayWillNotServeAHalfWrittenInstall:
+ """`raven web` supervises the gateway, and the supervisor restarts it the
+ moment it exits -- including during an upgrade, when uv has removed the old
+ environment and not yet written the new one. `build_app` picks the page
+ route once, so a gateway that came up in that window answers `/` with the
+ placeholder for the rest of its life, on an installation that was sound
+ seconds later."""
+
+ def test_it_refuses_instead_of_serving(self, monkeypatch) -> None:
+ from raven.cli import serve_commands
+ from raven.updates import install_guard as _install_guard
+
+ monkeypatch.setattr(
+ _install_guard,
+ "inspect_install",
+ lambda: _install_guard.InstallFault("incomplete", "this installation is missing the packaged page"),
+ )
+
+ r = runner.invoke(app, ["gateway"])
+
+ assert r.exit_code == serve_commands.INCOMPLETE_INSTALL_EXIT
+
+ def test_it_refuses_before_it_takes_the_instance_lock(self, monkeypatch) -> None:
+ """The wait inside the guard can last the whole install. Holding the
+ lock through it would block the gateway the finished install is meant
+ to bring back."""
+ from raven.gateway import lock as _gateway_lock
+ from raven.updates import install_guard as _install_guard
+
+ monkeypatch.setattr(
+ _install_guard,
+ "inspect_install",
+ lambda: _install_guard.InstallFault("incomplete", "this installation is missing the packaged page"),
+ )
+
+ def unreachable(**_kwargs):
+ raise AssertionError("the gateway took the lock on a half-written installation")
+
+ monkeypatch.setattr(_gateway_lock, "acquire", unreachable)
+
+ r = runner.invoke(app, ["gateway"])
+
+ assert r.exit_code != 0
+
+ def test_the_refusal_names_the_gateway_not_serve(self, monkeypatch) -> None:
+ """Both surfaces reach the same guard, and a reader told to restart
+ `raven serve` when the gateway refused would restart the wrong one."""
+ from raven.updates import install_guard as _install_guard
+
+ monkeypatch.setattr(
+ _install_guard,
+ "inspect_install",
+ lambda: _install_guard.InstallFault("incomplete", "this installation is missing the packaged page"),
+ )
+
+ r = runner.invoke(app, ["gateway"])
+
+ # The module runner mixes the streams, so the refusal is read off output.
+ assert "raven gateway:" in r.output
+ assert "raven serve:" not in r.output
+
+ def test_a_sound_install_is_not_stopped_here(self, monkeypatch) -> None:
+ """The guard must be invisible on every normal start."""
+ from raven.gateway import lock as _gateway_lock
+ from raven.updates import install_guard as _install_guard
+
+ monkeypatch.setattr(_install_guard, "inspect_install", lambda: None)
+ reached: list[bool] = []
+
+ def _raise(now: float):
+ reached.append(True)
+ raise _gateway_lock.GatewayAlreadyRunningError(
+ _gateway_lock.LockInfo(pid=4242, started_at=0.0, config_path="/tmp/whatever.json")
+ )
+
+ monkeypatch.setattr(_gateway_lock, "acquire", _raise)
+
+ runner.invoke(app, ["gateway"])
+
+ assert reached == [True]
+
+
def test_gateway_log_config_defaults() -> None:
from raven.config.schema import GatewayConfig
diff --git a/tests/test_cli_tui_commands.py b/tests/test_cli_tui_commands.py
index e7e9e8710..fc707c612 100644
--- a/tests/test_cli_tui_commands.py
+++ b/tests/test_cli_tui_commands.py
@@ -827,7 +827,7 @@ def test_every_interactive_spawn_names_the_binary(monkeypatch: pytest.MonkeyPatc
def test_bare_raven_passes_a_plain_value_for_every_tui_option(monkeypatch: pytest.MonkeyPatch) -> None:
- """Bare ``raven`` calls ``tui`` as a plain function, so typer never fills
+ """On the fallback path, bare ``raven`` calls ``tui`` as a plain function, so typer never fills
its defaults: any option the call site forgets arrives as an ``OptionInfo``
sentinel instead. That is not inert -- ``--home``'s sentinel is truthy, so
``load_runtime_config`` assigns it over ``agents.defaults.workspace`` and
@@ -854,6 +854,7 @@ def recorder(_ctx, **kwargs: Any) -> None:
received.update(kwargs)
monkeypatch.setattr(tui_commands, "tui", recorder)
+ monkeypatch.setattr(commands, "_can_open_a_browser", lambda: False)
r = CliRunner(mix_stderr=False).invoke(commands.app, [])
@@ -862,6 +863,42 @@ def recorder(_ctx, **kwargs: Any) -> None:
assert [name for name, value in received.items() if isinstance(value, OptionInfo)] == []
+def test_a_machine_with_no_browser_is_told_apart_before_anything_starts(monkeypatch: pytest.MonkeyPatch) -> None:
+ """`webbrowser.get()` raising is the whole question: no browser registered
+ means the page would come up on a URL nobody on this machine can open."""
+ import webbrowser
+
+ from raven.cli import commands
+
+ def none_registered():
+ raise webbrowser.Error("could not locate runnable browser")
+
+ monkeypatch.setattr(webbrowser, "get", none_registered)
+ assert commands._can_open_a_browser() is False
+
+ monkeypatch.setattr(webbrowser, "get", lambda: object())
+ assert commands._can_open_a_browser() is True
+
+
+def test_bare_raven_opens_the_page_when_a_browser_is_there(monkeypatch: pytest.MonkeyPatch) -> None:
+ """The installer ends in `raven web`, so the bare command has to land on the
+ same surface rather than a second, quieter one."""
+ from typer.testing import CliRunner
+
+ from raven.cli import commands, serve_commands, tui_commands
+ from raven.rpc.transports.ws import DEFAULT_PORT
+
+ opened: list[int] = []
+ monkeypatch.setattr(commands, "_can_open_a_browser", lambda: True)
+ monkeypatch.setattr(serve_commands, "_web", lambda port, **kwargs: opened.append(port))
+ monkeypatch.setattr(tui_commands, "tui", lambda *a, **k: pytest.fail("the TUI ran with a browser available"))
+
+ r = CliRunner(mix_stderr=False).invoke(commands.app, [])
+
+ assert r.exit_code == 0, r.output
+ assert opened == [DEFAULT_PORT]
+
+
# ---------------------------------------------------------------------------
# attaching to a running gateway (G2): which engine a launch gets
# ---------------------------------------------------------------------------
diff --git a/tests/test_rpc_serve_control.py b/tests/test_rpc_serve_control.py
new file mode 100644
index 000000000..16bb5c100
--- /dev/null
+++ b/tests/test_rpc_serve_control.py
@@ -0,0 +1,66 @@
+"""Tests for the handle a running page host gives ``system.upgrade``."""
+
+from __future__ import annotations
+
+import asyncio
+
+from raven.rpc.serve_control import ServeControl
+
+
+def test_a_hosted_page_is_not_running_until_the_gateway_hands_over_its_stop() -> None:
+ """The page is mounted before the gateway has built its graceful stop, so
+ in that gap there is nothing that could end the process."""
+ ctl = ServeControl()
+ ctl.arm_hosted(18792, "tok", "cookie")
+
+ assert ctl.hosted_by_gateway
+ assert not ctl.running
+ assert ctl.request_shutdown() is False
+
+
+def test_the_hand_over_makes_the_gateway_stoppable_through_its_own_stop() -> None:
+ stopped: list[bool] = []
+ ctl = ServeControl()
+ ctl.arm_hosted(18792, "tok", "cookie")
+ ctl.hand_over(lambda: stopped.append(True), lambda: None, 4242)
+
+ assert ctl.running
+ assert ctl.supervisor_pid == 4242
+ assert ctl.request_shutdown() is True
+ assert stopped == [True]
+
+
+def test_busy_is_the_gateway_answer_and_nothing_when_there_is_no_gateway() -> None:
+ ctl = ServeControl()
+ assert ctl.busy() is None
+
+ ctl.arm_hosted(18792, "tok", "cookie")
+ ctl.hand_over(lambda: None, lambda: {"subagents": 2, "questions": 0}, None)
+
+ assert ctl.busy() == {"subagents": 2, "questions": 0}
+
+
+def test_disarm_forgets_the_gateway_handles_too() -> None:
+ """A swap tears the page down and mounts it again. A stop left over from
+ the torn-down generation would end the process on behalf of a page that
+ no longer exists."""
+ ctl = ServeControl()
+ ctl.arm_hosted(18792, "tok", "cookie")
+ ctl.hand_over(lambda: None, lambda: {"subagents": 1, "questions": 0}, 4242)
+
+ ctl.disarm()
+
+ assert not ctl.running
+ assert not ctl.hosted_by_gateway
+ assert ctl.busy() is None
+ assert ctl.supervisor_pid is None
+
+
+def test_standalone_serve_still_stops_through_its_event() -> None:
+ stop = asyncio.Event()
+ ctl = ServeControl()
+ ctl.arm(18792, "tok", "cookie", stop)
+
+ assert ctl.running
+ assert ctl.request_shutdown() is True
+ assert stop.is_set()
diff --git a/tests/test_rpc_system.py b/tests/test_rpc_system.py
index 88629028b..c7a573c7c 100644
--- a/tests/test_rpc_system.py
+++ b/tests/test_rpc_system.py
@@ -213,22 +213,149 @@ async def test_upgrade_refuses_outside_serve():
assert excinfo.value.data["reason"] == "not_serving"
-async def test_upgrade_refuses_on_a_gateway_hosted_page():
- """The relaunch flow replaces this process with a bare `raven serve`. On a
- page the gateway hosts that would silently drop the IM channels, so until
- the gateway can restart in place the page gets a structured refusal the
- front end already renders (reason + human sentence)."""
- from raven.cli.serve_commands import SERVE
- from raven.rpc.methods.system import system_upgrade
+def _plan(tmp_path, monkeypatch):
+ from raven.updates import upgrade as upgrade_commands
- SERVE.arm_hosted(18792, "tok", "cookie")
- try:
- with pytest.raises(ConfigValidationError) as excinfo:
+ bin_dir = tmp_path / "bin"
+ bin_dir.mkdir(exist_ok=True)
+ plan = upgrade_commands.UpgradePlan(
+ current_version="0.1.3",
+ release=upgrade_commands.ReleaseInfo(version="0.1.4", wheel_url="https://example.invalid/x.whl"),
+ target=upgrade_commands.ToolInstallTarget(tool_dir=tmp_path / "tools", bin_dir=bin_dir),
+ )
+ monkeypatch.setattr(upgrade_commands, "plan_upgrade", lambda: plan)
+ spawned: dict[str, object] = {}
+ monkeypatch.setattr(
+ upgrade_commands,
+ "spawn_detached_upgrade",
+ lambda p, **kw: spawned.update({"plan": p, **kw}),
+ )
+ return bin_dir, spawned
+
+
+class TestUpgradingFromAGatewayHostedPage:
+ """`raven web` runs `raven gateway`, so this is the page nearly every reader
+ has. The old relaunch brought back a bare `raven serve`, which builds no
+ ChannelManager: the IM channels would have vanished without a word. The
+ restart now goes through the supervisor instead."""
+
+ async def test_it_brings_back_the_supervised_gateway_not_a_bare_serve(self, monkeypatch, tmp_path):
+ import asyncio
+
+ from raven.cli.serve_commands import SERVE
+ from raven.rpc.methods.system import system_upgrade
+
+ bin_dir, spawned = _plan(tmp_path, monkeypatch)
+ stopped: list[bool] = []
+ SERVE.arm_hosted(18792, "tok", "cookie")
+ SERVE.hand_over(lambda: stopped.append(True), lambda: None, 4242)
+ try:
+ result = await system_upgrade({})
+ assert result["status"] == "started"
+ # The reply must land before the gateway goes down.
+ assert stopped == []
+ await asyncio.sleep(0.9)
+ assert stopped == [True]
+ finally:
+ SERVE.disarm()
+
+ assert spawned["relaunch"] == [str(bin_dir / "raven"), "web", "--supervise", "--port", "18792"]
+ assert spawned["extra_env"] == {
+ "RAVEN_SERVE_PORT_STRICT": "1",
+ "RAVEN_SERVE_TOKEN": "tok",
+ "RAVEN_SERVE_COOKIE": "cookie",
+ }
+
+ async def test_the_helper_waits_for_the_supervisor_not_the_gateway(self, monkeypatch, tmp_path):
+ """The supervisor's own cleanup runs after the gateway exits and removes
+ web.json. A new supervisor started before that finishes has its file
+ removed from under it, and the next `raven web` starts a second one."""
+ from raven.cli.serve_commands import SERVE
+ from raven.rpc.methods.system import system_upgrade
+
+ _bin_dir, spawned = _plan(tmp_path, monkeypatch)
+ SERVE.arm_hosted(18792, "tok", "cookie")
+ SERVE.hand_over(lambda: None, lambda: None, 4242)
+ try:
await system_upgrade({})
- finally:
- SERVE.disarm()
- assert excinfo.value.data["reason"] == "gateway_hosted"
- assert "raven gateway" in excinfo.value.data["detail"]
+ finally:
+ SERVE.disarm()
+
+ assert spawned["parent_pid"] == 4242
+
+ async def test_it_refuses_a_gateway_nothing_would_bring_back(self, monkeypatch, tmp_path):
+ """Started by hand, or under `raven web --foreground`: no supervisor.
+ Installing would end with nothing running at all."""
+ from raven.cli.serve_commands import SERVE
+ from raven.rpc.methods.system import system_upgrade
+
+ _bin_dir, spawned = _plan(tmp_path, monkeypatch)
+ SERVE.arm_hosted(18792, "tok", "cookie")
+ SERVE.hand_over(lambda: pytest.fail("stopped an unsupervised gateway"), lambda: None, None)
+ try:
+ with pytest.raises(ConfigValidationError) as excinfo:
+ await system_upgrade({})
+ finally:
+ SERVE.disarm()
+
+ assert excinfo.value.data["reason"] == "unsupervised"
+ assert spawned == {}
+
+ async def test_it_refuses_while_work_the_page_cannot_see_is_running(self, monkeypatch, tmp_path):
+ """The page checks its own turn. An IM turn or a sub-agent on the same
+ engine is invisible to it, and the restart would cut it off."""
+ from raven.cli.serve_commands import SERVE
+ from raven.rpc.methods.system import system_upgrade
+
+ _bin_dir, spawned = _plan(tmp_path, monkeypatch)
+ SERVE.arm_hosted(18792, "tok", "cookie")
+ SERVE.hand_over(
+ lambda: pytest.fail("stopped a busy gateway"),
+ lambda: {"subagents": 1, "questions": 0},
+ 4242,
+ )
+ try:
+ with pytest.raises(ConfigValidationError) as excinfo:
+ await system_upgrade({})
+ finally:
+ SERVE.disarm()
+
+ assert excinfo.value.data["reason"] == "busy"
+ assert spawned == {}
+
+ async def test_it_refuses_when_there_is_nothing_to_relaunch_from(self, monkeypatch, tmp_path):
+ """Under the gateway there is no degraded path: without a relaunch the
+ helper would install and leave nothing running."""
+ from raven.cli.serve_commands import SERVE
+ from raven.rpc.methods.system import system_upgrade
+
+ bin_dir, spawned = _plan(tmp_path, monkeypatch)
+ bin_dir.rmdir()
+ SERVE.arm_hosted(18792, "tok", "cookie")
+ SERVE.hand_over(lambda: pytest.fail("stopped with nothing to relaunch"), lambda: None, 4242)
+ try:
+ with pytest.raises(ConfigValidationError) as excinfo:
+ await system_upgrade({})
+ finally:
+ SERVE.disarm()
+
+ assert excinfo.value.data["reason"] == "not_upgradable"
+ assert spawned == {}
+
+ async def test_a_page_mounted_before_the_hand_over_does_not_upgrade(self):
+ """The mount happens before the gateway has built its stop. A request in
+ that gap must not be treated as upgradable."""
+ from raven.cli.serve_commands import SERVE
+ from raven.rpc.methods.system import system_upgrade
+
+ SERVE.arm_hosted(18792, "tok", "cookie")
+ try:
+ with pytest.raises(ConfigValidationError) as excinfo:
+ await system_upgrade({})
+ finally:
+ SERVE.disarm()
+
+ assert excinfo.value.data["reason"] in {"unsupervised", "not_serving"}
async def test_upgrade_refuses_when_the_install_cannot_self_upgrade(monkeypatch: pytest.MonkeyPatch):
diff --git a/tests/test_rpc_transport.py b/tests/test_rpc_transport.py
index 6708166f1..a9a514248 100644
--- a/tests/test_rpc_transport.py
+++ b/tests/test_rpc_transport.py
@@ -748,3 +748,58 @@ async def test_a_page_with_no_judgement_of_its_sources_says_nothing(tmp_path: Pa
assert "X-Raven-Page-Behind" not in (await client.get("/")).headers
finally:
await client.close()
+
+
+async def test_stopping_the_server_closes_an_open_page_instead_of_waiting_on_it() -> None:
+ """aiohttp's cleanup waits for live handlers rather than closing websockets,
+ so a tab left open held the whole process up for most of a minute after it
+ was told to stop. A page-started upgrade cannot begin installing until this
+ process exits, so that minute was dead air on the reader's screen."""
+ import asyncio
+
+ from aiohttp import WSCloseCode, WSMsgType
+
+ gateway = WsGateway()
+ server = TestServer(build_app(gateway, None))
+ client = TestClient(server)
+ await client.start_server()
+ gateway.port = server.port
+ ws = await client.ws_connect("/rpc", headers={"X-Raven-Token": gateway.session_token})
+ # Reading already when the server stops, as a page always is: the close
+ # handshake needs the client to answer, and a client that only starts
+ # reading afterwards sees the transport drop (1006) instead of the close.
+ reading = asyncio.ensure_future(ws.receive())
+ try:
+ await asyncio.wait_for(server.close(), timeout=5)
+ message = await asyncio.wait_for(reading, timeout=5)
+ finally:
+ reading.cancel()
+ await client.close()
+
+ assert message.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED)
+ assert ws.close_code == WSCloseCode.GOING_AWAY
+
+
+async def test_a_socket_that_will_not_close_does_not_stop_the_others() -> None:
+ """One tab whose socket errors on close must not keep the rest open, and is
+ forgotten rather than retried on every later broadcast."""
+
+ class Socket:
+ def __init__(self, fails: bool) -> None:
+ self.fails, self.closed_with = fails, None
+
+ async def close(self, *, code, message):
+ if self.fails:
+ raise ConnectionResetError("peer went away")
+ self.closed_with = code
+
+ from aiohttp import WSCloseCode
+
+ gateway = WsGateway()
+ broken, fine = Socket(fails=True), Socket(fails=False)
+ gateway._sockets.update({broken, fine})
+
+ await gateway.close_all()
+
+ assert fine.closed_with == WSCloseCode.GOING_AWAY
+ assert broken not in gateway._sockets
diff --git a/tests/test_updates_upgrade.py b/tests/test_updates_upgrade.py
index 30c14f0ab..3ae2d07cb 100644
--- a/tests/test_updates_upgrade.py
+++ b/tests/test_updates_upgrade.py
@@ -671,6 +671,14 @@ def test_uv_tool_target_rejects_malformed_target_fields(
upgrade_commands._uv_tool_target()
+class _Response(io.BytesIO):
+ """A body with the headers a size probe reads off it."""
+
+ def __init__(self, payload: bytes, headers: dict[str, str]) -> None:
+ super().__init__(payload)
+ self.headers = headers
+
+
class _ReleaseDirectory:
"""The release directory beside the wheel, served in memory to the helper's
downloads. Ships the plugin list by default and no constraints, so the
@@ -679,15 +687,23 @@ class _ReleaseDirectory:
def __init__(self) -> None:
self.files: dict[str, bytes] = {"raven-plugins.txt": PLUGIN_LIST.encode("utf-8")}
+ # Sizes the helper's HEAD probes can see. Empty by default, so the
+ # manifest degrades to its one headline and the older assertions in
+ # this file keep observing the output they were written against.
+ self.sizes: dict[str, int] = {}
self.requests: list[urllib.request.Request] = []
def urlopen(self, request: urllib.request.Request, *args: object, **kwargs: object) -> io.BytesIO:
self.requests.append(request)
url = request.full_url
name = url.rsplit("/", 1)[1]
+ if request.get_method() == "HEAD":
+ if name not in self.sizes:
+ raise urllib.error.HTTPError(url, 404, "Not Found", {}, None) # type: ignore[arg-type]
+ return _Response(b"", {"Content-Length": str(self.sizes[name])})
if name not in self.files:
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None) # type: ignore[arg-type]
- return io.BytesIO(self.files[name])
+ return _Response(self.files[name], {"Content-Length": str(len(self.files[name]))})
@pytest.fixture(autouse=True)
@@ -741,6 +757,25 @@ def _load_upgrade_helper() -> object:
_PARENT_EXIT_TIMEOUT_S = _load_upgrade_helper_namespace()["PARENT_EXIT_TIMEOUT_S"]
+def test_upgrade_helper_bootstrap_fits_a_windows_command_line() -> None:
+ """The whole helper travels as `python -I -c `, and Windows caps a
+ command line at 32,767 characters -- interpreter path, uv path, wheel URL
+ and relaunch argv included. Past it the helper cannot start at all, and only
+ a Windows machine notices, so the budget is held here, on every OS, with
+ room left for the helper to grow."""
+ assert len(upgrade_commands._upgrade_helper_bootstrap()) < 20_000
+
+
+def test_upgrade_helper_bootstrap_carries_the_helper_unchanged() -> None:
+ import re
+ import zlib
+
+ bootstrap = upgrade_commands._upgrade_helper_bootstrap()
+ encoded = re.search(r'b64decode\("([^"]+)"\)', bootstrap).group(1)
+
+ assert zlib.decompress(base64.b64decode(encoded)).decode("utf-8") == upgrade_commands._UPGRADE_HELPER_SOURCE
+
+
def test_upgrade_helper_bootstrap_runs_in_isolated_python() -> None:
bootstrap = upgrade_commands._upgrade_helper_bootstrap()
@@ -782,6 +817,336 @@ def test_upgrade_helper_installs_the_release_plugins_beside_the_wheel(
assert "Raven upgraded: 0.1.3 -> 0.1.4" in capsys.readouterr().out
+def test_upgrade_helper_names_the_total_size_before_uv_takes_the_terminal(
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+ release_directory: _ReleaseDirectory,
+) -> None:
+ """uv reports bytes per package and never a total, so a release whose
+ engines are tens of megabytes looks the same as an install that stopped.
+ The manifest is the only place the whole cost is stated."""
+ release_directory.sizes = {
+ "raven-0.1.4-py3-none-any.whl": 6 * 1048576,
+ "everos_memory-1.2.0-py3-none-any.whl": 1048576 // 2,
+ "design_engine-0.2.0-py3-none-any.whl": 17 * 1048576,
+ "ppt_engine-0.2.0-py3-none-any.whl": 48 * 1048576,
+ }
+ monkeypatch.setattr(subprocess, "run", Mock(return_value=Mock(returncode=0)))
+ helper_main = _load_upgrade_helper()
+
+ status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"])
+
+ assert status == 0
+ out = capsys.readouterr().out
+ assert "Downloading 4 packages for Raven 0.1.4." in out
+ for line in ("raven", "everos-memory", "design-engine", "ppt-engine"):
+ assert line in out
+ assert "48.0 MiB" in out
+ assert "total" in out and "71.5 MiB" in out
+ # Said before uv runs, not after: a total that arrives with the result is
+ # not the reassurance the wait needed.
+ assert out.index("Downloading 4 packages") < out.index("Raven upgraded")
+
+
+def test_upgrade_helper_upgrades_when_no_size_can_be_probed(
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ """The sizes are a courtesy. A release directory that answers no HEAD --
+ or a link that drops them -- must cost the upgrade nothing."""
+ run = Mock(return_value=Mock(returncode=0))
+ monkeypatch.setattr(subprocess, "run", run)
+ helper_main = _load_upgrade_helper()
+
+ status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"])
+
+ assert status == 0
+ out = capsys.readouterr().out
+ assert "Downloading 4 packages for Raven 0.1.4." in out
+ assert "MiB" not in out
+ assert "Raven upgraded: 0.1.3 -> 0.1.4" in out
+ assert _uv_calls(run) == [
+ {
+ "mode": REINSTALL,
+ "constraints": None,
+ "plugins": PLUGIN_LIST,
+ "requirement": f"raven[channels] @ {WHEEL_URL}",
+ },
+ ]
+
+
+def test_upgrade_helper_manifest_reaches_a_log_file_before_uv_does(
+ monkeypatch: pytest.MonkeyPatch,
+ release_directory: _ReleaseDirectory,
+) -> None:
+ """From the page the helper's output is web.log, a file, which Python
+ buffers by the block, while uv writes to the same file at once. Unflushed,
+ the manifest landed after uv's lines -- the one place it exists to precede."""
+ release_directory.sizes = {"raven-0.1.4-py3-none-any.whl": 6 * 1048576}
+ raw = io.BytesIO()
+ monkeypatch.setattr(sys, "stdout", io.TextIOWrapper(raw, encoding="utf-8", write_through=False))
+ seen: dict[str, str] = {}
+
+ def run(argv, check):
+ seen.setdefault("before_uv", raw.getvalue().decode("utf-8"))
+ return Mock(returncode=0)
+
+ monkeypatch.setattr(subprocess, "run", run)
+ helper_main = _load_upgrade_helper()
+
+ helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"])
+
+ assert "Downloading 4 packages for Raven 0.1.4." in seen["before_uv"]
+ assert "total" in seen["before_uv"]
+
+
+def test_upgrade_helper_says_package_for_one(
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+ release_directory: _ReleaseDirectory,
+) -> None:
+ release_directory.files["raven-plugins.txt"] = b"# no plugins in this release\n"
+ monkeypatch.setattr(subprocess, "run", Mock(return_value=Mock(returncode=0)))
+ helper_main = _load_upgrade_helper()
+
+ helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"])
+
+ assert "Downloading 1 package for Raven 0.1.4." in capsys.readouterr().out
+
+
+RELEASE_WHEELS = {
+ "raven-0.1.4-py3-none-any.whl": b"R" * 1000,
+ "everos_memory-1.2.0-py3-none-any.whl": b"E" * 10,
+ "design_engine-0.2.0-py3-none-any.whl": b"D" * 20,
+ "ppt_engine-0.2.0-py3-none-any.whl": b"P" * 30,
+}
+
+
+def _free_port() -> int:
+ import socket as _socket
+
+ with _socket.socket() as probe:
+ probe.bind(("127.0.0.1", 0))
+ return probe.getsockname()[1]
+
+
+def _port_is_free(port: int) -> bool:
+ """Probed the way the relaunched Raven probes it (`pick_port`): with
+ SO_REUSEADDR, so connections the helper closed, still in TIME_WAIT, do not
+ read as the port being held. What matters is that nothing listens on it."""
+ import socket as _socket
+
+ with _socket.socket() as probe:
+ probe.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
+ try:
+ probe.bind(("127.0.0.1", port))
+ except OSError:
+ return False
+ return True
+
+
+def _get(port: int, path: str, host: str | None = None) -> tuple[int, bytes]:
+ """Through http.client, not urllib: the release-directory fixture stands in
+ for urllib's urlopen for the whole file."""
+ import http.client
+
+ connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
+ try:
+ connection.request("GET", path, headers={"Host": host} if host else {})
+ response = connection.getresponse()
+ return response.status, response.read()
+ finally:
+ connection.close()
+
+
+class TestTheHelperOwnsTheDownload:
+ """uv draws no progress when it is not writing to a terminal -- the page's
+ case -- so the helper fetches the release itself, where the bytes can be
+ counted, and hands uv the local files."""
+
+ @pytest.fixture
+ def home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+ marker = tmp_path / "upgrade.json"
+ marker.write_text(json.dumps({"started_at": 1.0}), encoding="utf-8")
+ monkeypatch.setenv("RAVEN_UPGRADE_MARKER", str(marker))
+ return tmp_path
+
+ def test_uv_is_handed_the_files_the_helper_downloaded(
+ self, home: Path, monkeypatch: pytest.MonkeyPatch, release_directory: _ReleaseDirectory
+ ) -> None:
+ release_directory.files.update(RELEASE_WHEELS)
+ run = Mock(return_value=Mock(returncode=0))
+ monkeypatch.setattr(subprocess, "run", run)
+
+ assert _load_upgrade_helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) == 0
+
+ folder = home / "cache" / "upgrade" / "0.1.4"
+ (call,) = _uv_calls(run)
+ assert call["requirement"] == f"raven[channels] @ {(folder / 'raven-0.1.4-py3-none-any.whl').as_uri()}"
+ assert call["plugins"] == "".join(
+ f"{name} @ {(folder / wheel).as_uri()}\n"
+ for name, wheel in (
+ ("everos-memory", "everos_memory-1.2.0-py3-none-any.whl"),
+ ("design-engine", "design_engine-0.2.0-py3-none-any.whl"),
+ ("ppt-engine", "ppt_engine-0.2.0-py3-none-any.whl"),
+ )
+ )
+ for wheel, payload in RELEASE_WHEELS.items():
+ assert (folder / wheel).read_bytes() == payload
+
+ def test_one_asset_it_cannot_fetch_sends_every_asset_back_to_uv(
+ self, home: Path, monkeypatch: pytest.MonkeyPatch, release_directory: _ReleaseDirectory
+ ) -> None:
+ """All or nothing, with the missing asset last so the ones before it
+ were really downloaded and then discarded: uv gets the URLs and fetches
+ them itself, exactly as it did before the helper downloaded anything."""
+ release_directory.files.update(RELEASE_WHEELS)
+ del release_directory.files["ppt_engine-0.2.0-py3-none-any.whl"]
+ run = Mock(return_value=Mock(returncode=0))
+ monkeypatch.setattr(subprocess, "run", run)
+
+ assert _load_upgrade_helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) == 0
+
+ (call,) = _uv_calls(run)
+ assert call["requirement"] == f"raven[channels] @ {WHEEL_URL}"
+ assert call["plugins"] == PLUGIN_LIST
+ assert not (home / "cache" / "upgrade" / "0.1.4").exists()
+
+ def test_an_earlier_version_s_files_are_cleared_and_this_one_s_kept(
+ self, home: Path, monkeypatch: pytest.MonkeyPatch, release_directory: _ReleaseDirectory
+ ) -> None:
+ """This version's stay because the tool's receipt names them as where
+ it was installed from; nothing names the earlier ones any more."""
+ old = home / "cache" / "upgrade" / "0.1.3"
+ old.mkdir(parents=True)
+ (old / "raven-0.1.3-py3-none-any.whl").write_bytes(b"old")
+ release_directory.files.update(RELEASE_WHEELS)
+ monkeypatch.setattr(subprocess, "run", Mock(return_value=Mock(returncode=0)))
+
+ _load_upgrade_helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"])
+
+ assert not old.exists()
+ assert (home / "cache" / "upgrade" / "0.1.4" / "raven-0.1.4-py3-none-any.whl").exists()
+
+
+class TestTheHelperAnswersOnThePagePort:
+ """Once the Raven being replaced exits, nothing answers where its page is
+ pointed, and the page can only spin. The helper answers in its place."""
+
+ RELAUNCH = json.dumps(["raven", "web", "--supervise", "--port", "0"])
+
+ @pytest.fixture
+ def port(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, release_directory: _ReleaseDirectory) -> int:
+ port = _free_port()
+ marker = tmp_path / "upgrade.json"
+ marker.write_text(json.dumps({"started_at": 1.0, "port": port}), encoding="utf-8")
+ monkeypatch.setenv("RAVEN_UPGRADE_MARKER", str(marker))
+ release_directory.files.update(RELEASE_WHEELS)
+ release_directory.sizes = {name: len(payload) for name, payload in RELEASE_WHEELS.items()}
+ return port
+
+ def _helper(self) -> object:
+ helper = _load_upgrade_helper()
+ helper.__globals__["wait_for_parent"] = Mock(return_value=0)
+ return helper
+
+ def test_it_reports_progress_and_holds_everything_else_at_503(
+ self, port: int, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """503, not 200 and not 401: the page reloads on an OK answer to `/`
+ and asks the reader to sign in again on 401 or 403."""
+ seen: dict[str, tuple[int, bytes]] = {}
+
+ def run(_argv, check):
+ seen["status"] = _get(port, "/upgrade/status")
+ seen["root"] = _get(port, "/")
+ return Mock(returncode=0)
+
+ monkeypatch.setattr(subprocess, "run", run)
+ free_at_relaunch: list[bool] = []
+ monkeypatch.setattr(subprocess, "Popen", lambda *_a, **_k: free_at_relaunch.append(_port_is_free(port)))
+
+ assert self._helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4", "4242", self.RELAUNCH]) == 0
+
+ code, body = seen["status"]
+ state = json.loads(body)
+ assert code == 200
+ assert state["upgrading"] is True
+ assert state["to"] == "0.1.4"
+ assert state["phase"] == "installing"
+ assert state["done"] == state["total"] == sum(len(payload) for payload in RELEASE_WHEELS.values())
+ assert seen["root"][0] == 503
+ # Let go before the relaunch, which binds the same port strictly.
+ assert free_at_relaunch == [True]
+
+ def test_it_answers_only_the_page_s_own_origin(self, port: int, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A site the reader has open could otherwise rebind a host name of its
+ own to this port and read the answer."""
+ seen: list[int] = []
+
+ def run(_argv, check):
+ seen.append(_get(port, "/upgrade/status", host=f"attacker.example:{port}")[0])
+ return Mock(returncode=0)
+
+ monkeypatch.setattr(subprocess, "run", run)
+ monkeypatch.setattr(subprocess, "Popen", Mock())
+
+ self._helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4", "4242", self.RELAUNCH])
+
+ assert seen == [421]
+
+ def test_a_failure_is_held_until_the_page_has_read_it(self, port: int, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Otherwise the old Raven comes straight back and the page reloads onto
+ it: an upgrade that failed reads as one that quietly did nothing."""
+ import threading
+ import time as _time
+
+ monkeypatch.setattr(subprocess, "run", Mock(return_value=Mock(returncode=1)))
+ monkeypatch.setattr(subprocess, "Popen", Mock())
+ helper = self._helper()
+ helper.__globals__["STATUS_HOLD_S"] = 20
+ read: dict[str, object] = {}
+
+ def page() -> None:
+ deadline = _time.monotonic() + 15
+ while _time.monotonic() < deadline and "state" not in read:
+ try:
+ code, body = _get(port, "/upgrade/status")
+ except OSError:
+ _time.sleep(0.02)
+ continue
+ state = json.loads(body)
+ if state["phase"] == "failed":
+ read["state"] = state
+ _time.sleep(0.02)
+
+ reader = threading.Thread(target=page)
+ reader.start()
+ started = _time.monotonic()
+ status = helper(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4", "4242", self.RELAUNCH])
+ took = _time.monotonic() - started
+ reader.join()
+
+ assert status == 1
+ assert "uv exited with status 1" in read["state"]["message"]
+ # Released by the read, not by sitting out the whole hold.
+ assert took < 20
+
+ def test_the_terminal_gets_no_server(self, port: int, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The terminal has no page to answer and sees the progress itself."""
+ free_during_install: list[bool] = []
+
+ def run(_argv, check):
+ free_during_install.append(_port_is_free(port))
+ return Mock(returncode=0)
+
+ monkeypatch.setattr(subprocess, "run", run)
+
+ _load_upgrade_helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"])
+
+ assert free_during_install == [True]
+
+
def test_upgrade_helper_refuses_to_upgrade_without_the_plugin_list(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
@@ -939,6 +1304,13 @@ def test_upgrade_helper_pins_constraints_when_download_succeeds(
assert [request.full_url for request in release_directory.requests] == [
f"{RELEASE_DIR}/raven-constraints.txt",
f"{RELEASE_DIR}/raven-plugins.txt",
+ WHEEL_URL,
+ f"{RELEASE_DIR}/everos_memory-1.2.0-py3-none-any.whl",
+ f"{RELEASE_DIR}/design_engine-0.2.0-py3-none-any.whl",
+ f"{RELEASE_DIR}/ppt_engine-0.2.0-py3-none-any.whl",
+ # The helper's own download of the wheel, which this directory does not
+ # serve, so uv is handed the URLs instead.
+ WHEEL_URL,
]
assert _uv_calls(run) == [
{
@@ -1009,7 +1381,12 @@ def test_upgrade_helper_carries_the_beta_credentials_to_the_release_directory(
assert [request.full_url for request in release_directory.requests] == [
f"{BETA_DIR}/raven-constraints.txt",
f"{BETA_DIR}/raven-plugins.txt",
+ f"{BETA_DIR}/raven-0.1.4b1-py3-none-any.whl",
+ f"{BETA_DIR}/everos_memory-1.2.0-py3-none-any.whl",
+ f"{BETA_DIR}/raven-0.1.4b1-py3-none-any.whl",
]
+ # The size probes and the helper's own download are on the same protected
+ # host, so they need the header for the same reason the lists do.
assert all(request.get_header("Authorization") == expected_header for request in release_directory.requests)
(call,) = _uv_calls(run)
assert call["plugins"] == (
@@ -1849,6 +2226,22 @@ def popen(_argv, env=None, **_kwargs):
assert at_spawn == [True]
assert handed == [str(isolated_raven_home / "upgrade.json")]
+ def test_the_marker_names_the_page_port_for_the_helper_to_answer_on(
+ self, plan: object, isolated_raven_home: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Once the process holding the port exits, the helper answers there with
+ progress; the marker is how it learns which port that is."""
+ recorded: list[dict[str, object]] = []
+
+ def popen(_argv, env=None, **_kwargs):
+ recorded.append(json.loads(Path(env["RAVEN_UPGRADE_MARKER"]).read_text(encoding="utf-8")))
+ return Mock()
+
+ monkeypatch.setattr(subprocess, "Popen", popen)
+ upgrade_commands.spawn_detached_upgrade(plan, parent_pid=1234, status_port=18950)
+
+ assert recorded[0]["port"] == 18950
+
def test_a_helper_that_never_started_leaves_no_marker(
self, plan: object, isolated_raven_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts
index 31d76ccf2..ea996c77f 100644
--- a/ui-tui/src/i18n/messages.generated.ts
+++ b/ui-tui/src/i18n/messages.generated.ts
@@ -521,7 +521,7 @@ export const UI_TEXT: Record> = {
'gui.collapse_ws': 'Collapse workspace',
'gui.commands': 'Commands',
'gui.brand.hi': "Hi, I'm Raven Agent",
- 'gui.composer_ph': 'Describe your task, or have Raven build an agent for it',
+ 'gui.composer_ph': 'Describe your task',
'gui.compress.done': 'Archived {n} messages - {before} to {after} tokens',
'gui.compress.fail': 'Compact failed: {err}',
'gui.compress.noop': 'Nothing to compact yet',
@@ -1287,8 +1287,16 @@ export const UI_TEXT: Record> = {
'gui.upg.manual': 'Run this in a terminal instead:',
'gui.upg.note': 'new version {v}',
'gui.upg.note_bare': 'new version available',
+ 'gui.upg.phase.download': 'Downloading the new version: {progress}',
+ 'gui.upg.phase.install': 'Installing the new version...',
+ 'gui.upg.phase.restart': 'Restarting Raven...',
'gui.upg.reauth': 'Raven restarted but this page is no longer signed in. Reopen Raven.',
'gui.upg.title': 'Upgrade Raven?',
+ 'gui.upg.why.busy':
+ 'Raven is still working on a turn, a sub-agent or a question waiting for an answer, possibly in an IM channel. Upgrading restarts Raven and would cut that off, so let it finish first.',
+ 'gui.upg.why.not_serving': 'This page is not being served by a running Raven, so it has nothing here to upgrade.',
+ 'gui.upg.why.unsupervised':
+ 'This Raven was started by hand in a terminal, so nothing would bring it back after an upgrade. Run raven upgrade there, then start Raven again.',
'gui.upg.working': 'Upgrading Raven...',
'gui.wd.back': 'Back',
'gui.wd.blocked': "Not a workspace: Raven's own data is here or beneath it. Its folders may still be.",
@@ -2332,7 +2340,7 @@ export const UI_TEXT: Record> = {
'gui.tasks.copied_dispatch': 'Dispatch prompt copied',
'gui.tasks.copied_answer': 'Answer copied',
'gui.composer.ph_session': 'Continue the conversation...',
- 'gui.composer.ph_busy': 'Continue the conversation; it is sent once this turn ends',
+ 'gui.composer.ph_busy': 'Continue the conversation; Raven will receive your new instruction directly',
'gui.plus.add': 'Add',
'gui.plus.upload': 'Upload files',
'gui.plus.template': 'Pick a deck template',
@@ -3045,7 +3053,7 @@ export const UI_TEXT: Record> = {
'gui.collapse_ws': '收起工作区',
'gui.commands': '命令',
'gui.brand.hi': 'Hi,我是 Raven Agent',
- 'gui.composer_ph': '描述你的任务,或让 Raven 搭一个智能体来做',
+ 'gui.composer_ph': '描述你的任务',
'gui.compress.done': '已归档 {n} 条消息 · {before} → {after} tokens',
'gui.compress.fail': '压缩失败:{err}',
'gui.compress.noop': '暂时没有可压缩的内容',
@@ -3805,8 +3813,16 @@ export const UI_TEXT: Record> = {
'gui.upg.manual': '改在终端里跑这条命令:',
'gui.upg.note': '有新版 {v}',
'gui.upg.note_bare': '有新版本',
+ 'gui.upg.phase.download': '正在下载新版本:{progress}',
+ 'gui.upg.phase.install': '正在安装新版本…',
+ 'gui.upg.phase.restart': '正在重启 Raven…',
'gui.upg.reauth': 'Raven 已重启,但这个页面的登录失效了,请重新打开 Raven。',
'gui.upg.title': '升级 Raven?',
+ 'gui.upg.why.busy':
+ 'Raven 还在处理回合、子智能体或待回答的问题(可能在 IM 渠道里)。升级会重启 Raven 并打断它们,请等它们结束再升级。',
+ 'gui.upg.why.not_serving': '这个页面当前没有正在运行的 Raven 在托管,这里没有可以升级的对象。',
+ 'gui.upg.why.unsupervised':
+ '这个 Raven 是在终端里手动启动的,升级后没有守护进程能把它重新拉起来。请在那个终端里运行 raven upgrade,然后重新启动 Raven。',
'gui.upg.working': '正在升级 Raven…',
'gui.wd.back': '返回',
'gui.wd.blocked': '这里不能作为工作区:Raven 自己的数据在此处或其下;里面的文件夹可以',
@@ -4824,7 +4840,7 @@ export const UI_TEXT: Record> = {
'gui.tasks.copied_dispatch': '已复制派单提示词',
'gui.tasks.copied_answer': '已复制这次的回答',
'gui.composer.ph_session': '继续对话…',
- 'gui.composer.ph_busy': '继续对话,输入会排队到本轮结束再发',
+ 'gui.composer.ph_busy': '继续对话,Raven 将直接收到你的新指令',
'gui.plus.add': '添加',
'gui.plus.upload': '上传文件',
'gui.plus.template': '选一个 PPT 模板',
diff --git a/ui-web/src/app/updates.test.ts b/ui-web/src/app/updates.test.ts
index 11dd94002..7d4cd1668 100644
--- a/ui-web/src/app/updates.test.ts
+++ b/ui-web/src/app/updates.test.ts
@@ -115,3 +115,222 @@ describe('the behind-sources notice', () => {
expect(note.hidden).toBe(true)
})
})
+
+/* A refusal is shown on a card whose title and command line are already in the
+ reader's language. The server's `detail` is one English sentence for every
+ caller, so a reason the page can name is named from the catalogue; a reason
+ whose detail carries facts from this run keeps them. */
+describe('an upgrade the server refused', () => {
+ it('names a refusal it knows in the reader language instead of the server sentence', async () => {
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+ const err = {
+ data: {
+ reason: 'unsupervised',
+ detail: 'This Raven was started by hand, so nothing would bring it back after an upgrade.',
+ },
+ }
+
+ const text = mod.refusalText(err)
+
+ expect(text).toBe(t('gui.upg.why.unsupervised'))
+ expect(text).not.toContain('This Raven was started by hand, so nothing')
+ })
+
+ it('keeps the detail when it carries facts from this run', async () => {
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+ const detail = 'This Raven installation is not managed by uv.'
+
+ expect(mod.refusalText({ data: { reason: 'not_upgradable', detail } })).toBe(detail)
+ })
+
+ it('still says something when the error carries no data at all', async () => {
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+
+ expect(mod.refusalText({ message: 'socket closed' })).toBe('socket closed')
+ })
+
+ it('answers busy work it cannot see with a notice to wait, not the terminal command', async () => {
+ const asked: string[][] = []
+ const failed: string[] = []
+ const shade = { say: () => {}, fail: (text: string) => failed.push(text), close: vi.fn() }
+ const mod = (await loadPart(() => import('./updates'), {
+ fakes: {
+ 'src/state/upgradeShade': { open: () => shade },
+ 'src/state/confirm': {
+ ask: (title: string, body: string) => {
+ asked.push([title, body])
+ },
+ },
+ 'src/rpc/gateway': {
+ gateway: () => ({
+ call: () => Promise.reject({ data: { reason: 'busy', detail: 'Raven is still working' } }),
+ }),
+ },
+ },
+ })) as Updates
+
+ await mod.runUpgrade()
+
+ expect(failed).toEqual([])
+ expect(shade.close).toHaveBeenCalled()
+ expect(asked).toEqual([[t('gui.upg.title'), t('gui.upg.why.busy')]])
+ })
+})
+
+describe('the progress the upgrade helper reports', () => {
+ const report = (fields: Partial): import('./updates').UpStatus => ({
+ upgrading: true,
+ phase: 'downloading',
+ done: 0,
+ total: 0,
+ rate: 0,
+ message: null,
+ ...fields,
+ })
+
+ it('says how much, of how much, and how fast', async () => {
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+
+ const seen = mod.describeStatus(report({ done: 18.4 * 1048576, total: 71.6 * 1048576, rate: 52 * 1024 }))
+
+ expect(seen.text).toBe(t('gui.upg.phase.download', { progress: '18.4 / 71.6 MiB · 52 KB/s' }))
+ expect(seen.fraction).toBeCloseTo(18.4 / 71.6, 5)
+ })
+
+ it('does not pretend to measure a download of unknown size', async () => {
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+
+ const seen = mod.describeStatus(report({ done: 3 * 1048576 }))
+
+ expect(seen.text).toBe(t('gui.upg.phase.download', { progress: '3.0 MiB' }))
+ expect(seen.fraction).toBeNull()
+ })
+
+ it('slides while uv installs, since there are no bytes to count', async () => {
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+
+ expect(mod.describeStatus(report({ phase: 'installing', done: 9, total: 9 }))).toEqual({
+ text: t('gui.upg.phase.install'),
+ fraction: null,
+ })
+ })
+})
+
+describe('waiting out an upgrade the page started', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ })
+
+ type Shade = { say: ReturnType; measure: ReturnType; fail: ReturnType; close: ReturnType }
+ const shade = (): Shade => ({ say: vi.fn(), measure: vi.fn(), fail: vi.fn(), close: vi.fn() })
+
+ /* The helper's port: `/upgrade/status` answers with the report, everything
+ else 503, exactly what the page sees between the two Ravens. `null` is
+ nothing listening at all. */
+ function port(status: () => Record | null): void {
+ vi.stubGlobal('fetch', async (url: string) => {
+ const body = status()
+ if (body === null) throw new TypeError('connection refused')
+ if (url === '/upgrade/status') return { ok: true, status: 200, json: async () => body }
+ return { ok: false, status: 503 }
+ })
+ }
+
+ it('draws the helper report on the bar', async () => {
+ vi.useFakeTimers()
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+ port(() => ({ upgrading: true, phase: 'downloading', done: 50, total: 100, rate: 0, message: null }))
+ const card = shade()
+
+ mod.watchUpgrade(card as never)
+ await vi.advanceTimersByTimeAsync(2600)
+
+ expect(card.measure).toHaveBeenCalledWith(t('gui.upg.phase.download', { progress: '0.0 / 0.0 MiB' }), 0.5)
+ expect(card.fail).not.toHaveBeenCalled()
+ })
+
+ it('shows a failure the helper holds instead of reloading onto the old Raven', async () => {
+ vi.useFakeTimers()
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+ port(() => ({ upgrading: true, phase: 'failed', done: 0, total: 0, rate: 0, message: 'uv exited with status 1.' }))
+ const card = shade()
+
+ mod.watchUpgrade(card as never)
+ await vi.advanceTimersByTimeAsync(2600)
+
+ expect(card.fail).toHaveBeenCalledWith(t('gui.upg.failed'), 'uv exited with status 1.')
+ })
+
+ it('keeps waiting past the ceiling while the download keeps moving', async () => {
+ /* A slow link can take longer than the ceiling to download a release. */
+ vi.useFakeTimers()
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+ let done = 0
+ port(() => ({ upgrading: true, phase: 'downloading', done: (done += 1), total: 1e9, rate: 1, message: null }))
+ const card = shade()
+
+ mod.watchUpgrade(card as never)
+ await vi.advanceTimersByTimeAsync(21 * 60 * 1000)
+
+ expect(card.fail).not.toHaveBeenCalled()
+ })
+
+ it('gives up on a helper that keeps answering while nothing moves', async () => {
+ /* The status server is its own thread: it answers while a download or uv
+ is stuck. An answer is not progress, or the card would never let go. */
+ vi.useFakeTimers()
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+ port(() => ({ upgrading: true, phase: 'downloading', done: 5, total: 100, rate: 0, message: null }))
+ const card = shade()
+
+ mod.watchUpgrade(card as never)
+ await vi.advanceTimersByTimeAsync(21 * 60 * 1000)
+
+ expect(card.fail).toHaveBeenCalledWith(t('gui.upg.failed'), t('gui.upg.gave_up'))
+ })
+
+ it('counts a new phase as progress even when the bytes stay put', async () => {
+ /* uv resolving after the download reports no bytes, and must not inherit
+ the time the download took. */
+ vi.useFakeTimers()
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+ let phase = 'downloading'
+ port(() => ({ upgrading: true, phase, done: 100, total: 100, rate: 0, message: null }))
+ const card = shade()
+
+ mod.watchUpgrade(card as never)
+ await vi.advanceTimersByTimeAsync(15 * 60 * 1000)
+ phase = 'installing'
+ await vi.advanceTimersByTimeAsync(15 * 60 * 1000)
+
+ expect(card.fail).not.toHaveBeenCalled()
+ })
+
+ it('still gives up on twenty minutes with no answer at all', async () => {
+ vi.useFakeTimers()
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+ port(() => null)
+ const card = shade()
+
+ mod.watchUpgrade(card as never)
+ await vi.advanceTimersByTimeAsync(21 * 60 * 1000)
+
+ expect(card.fail).toHaveBeenCalledWith(t('gui.upg.failed'), t('gui.upg.gave_up'))
+ })
+
+ it('says the new Raven is starting once the helper lets the port go', async () => {
+ vi.useFakeTimers()
+ const mod = (await loadPart(() => import('./updates'), {})) as Updates
+ let helperUp = true
+ port(() => (helperUp ? { upgrading: true, phase: 'installing', done: 0, total: 0, rate: 0, message: null } : null))
+ const card = shade()
+
+ mod.watchUpgrade(card as never)
+ await vi.advanceTimersByTimeAsync(2600)
+ helperUp = false
+ await vi.advanceTimersByTimeAsync(1100)
+
+ expect(card.measure).toHaveBeenLastCalledWith(t('gui.upg.phase.restart'), null)
+ })
+})
diff --git a/ui-web/src/app/updates.ts b/ui-web/src/app/updates.ts
index aa8d3d992..bba794097 100644
--- a/ui-web/src/app/updates.ts
+++ b/ui-web/src/app/updates.ts
@@ -146,6 +146,22 @@ export function resumeUpgrade(): void {
if (running) watchUpgrade(upShade(), running.t0)
}
+/* A refusal the page can name in the reader's language. The server's `detail`
+ is one English sentence for every surface that calls it; showing it raw
+ dropped an English paragraph into the middle of a Chinese card. Reasons whose
+ detail carries run-specific facts (the uv error, the failed lookup) keep it,
+ because translating the frame would throw the facts away. */
+const REFUSALS: Record = {
+ unsupervised: 'gui.upg.why.unsupervised',
+ not_serving: 'gui.upg.why.not_serving',
+}
+
+export function refusalText(err: { data?: { reason?: string; detail?: string }; message?: string }): string {
+ const key = err.data && err.data.reason ? REFUSALS[err.data.reason] : undefined
+ if (key) return t(key)
+ return (err.data && err.data.detail) || err.message || String(err)
+}
+
/* `system.upgrade` hands the install to a detached helper and then lets serve
exit, so this page has to survive a gap with no backend: it polls until serve
answers again and only then reloads (the new dist needs a reload anyway). The
@@ -165,8 +181,17 @@ export async function runUpgrade(): Promise {
watchUpgrade(shade)
return
}
+ /* Work is running that the page cannot see -- an IM turn, a sub-agent.
+ Same answer as the page's own busy turn: a notice to wait. The failure
+ card would offer the terminal command, which is not the fix. */
+ if (err.data && err.data.reason === 'busy') {
+ upMarkClear()
+ shade.close()
+ confirmAsk(t('gui.upg.title'), t('gui.upg.why.busy'), t('gui.upg.close'), () => {}, 'notice')
+ return
+ }
upMarkClear()
- shade.fail(t('gui.upg.failed'), (err.data && err.data.detail) || err.message || String(e))
+ shade.fail(t('gui.upg.failed'), refusalText(err))
return
}
upMark(upLatest)
@@ -179,15 +204,85 @@ export async function runUpgrade(): Promise {
byte-compiles every dependency: one measured cold run took nine. The old
three-minute ceiling declared failure over a install that was still running,
which is what taught the reader to click upgrade a second time. */
+/* What the upgrade helper reports while it is the only thing on this page's
+ port: from the moment the old Raven exits until the new one is started.
+ Anything without `upgrading` is not the helper -- the real server may well
+ answer this path with the page itself. */
+export interface UpStatus {
+ upgrading: true
+ phase: string
+ done: number
+ total: number
+ rate: number
+ message: string | null
+}
+
+async function readStatus(): Promise {
+ try {
+ const r = await fetch('/upgrade/status', { cache: 'no-store' })
+ if (!r.ok) return null
+ const body = (await r.json()) as Partial | null
+ return body && body.upgrading === true ? (body as UpStatus) : null
+ } catch { return null }
+}
+
+const mib = (n: number): string => (n / 1048576).toFixed(1)
+const speed = (n: number): string => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB/s` : `${Math.round(n / 1024)} KB/s`)
+
+/* The line and the bar for one report. Numbers and units are the same in every
+ language, so only the frame around them comes from the catalogue. */
+export function describeStatus(s: UpStatus): { text: string; fraction: number | null } {
+ if (s.phase === 'downloading') {
+ const amount = s.total > 0 ? `${mib(s.done)} / ${mib(s.total)} MiB` : `${mib(s.done)} MiB`
+ const rate = s.rate > 0 ? ` · ${speed(s.rate)}` : ''
+ return { text: t('gui.upg.phase.download', { progress: amount + rate }), fraction: s.total > 0 ? s.done / s.total : null }
+ }
+ if (s.phase === 'installing') return { text: t('gui.upg.phase.install'), fraction: null }
+ return { text: t('gui.upg.working'), fraction: null }
+}
+
export function watchUpgrade(shade: UpgradeShade, since?: number): void {
const t0 = since || Date.now()
+ /* The ceiling counts time without progress, not the whole upgrade. A slow
+ link can take longer than the ceiling to download a release, and giving up
+ on one that is visibly still moving would be the wrong answer. But the
+ helper's status server is its own thread and keeps answering while a
+ download or uv is stuck, so an answer alone is not progress: only a new
+ phase or more bytes move the deadline. Otherwise a stalled helper holds
+ the reader under a card with no way out, and a reload resumes it. */
+ let moved: number | null = null
+ let last: { phase: string; done: number } | null = null
+ let helperSeen = false
shade.say(t('gui.upg.working'))
const tick = async (): Promise => {
- if (Date.now() - t0 > UPG_CEILING_MS) {
+ if (Date.now() - (moved ?? t0) > UPG_CEILING_MS) {
upMarkClear()
shade.fail(t('gui.upg.failed'), t('gui.upg.gave_up'))
return
}
+ const status = await readStatus()
+ if (status) {
+ helperSeen = true
+ if (!last || status.phase !== last.phase || status.done > last.done) {
+ moved = Date.now()
+ last = { phase: status.phase, done: status.done }
+ }
+ if (status.phase === 'failed') {
+ /* The helper holds this until it has been read, then brings the old
+ Raven back; without the card the page would reload onto it and the
+ failed upgrade would read as one that did nothing. */
+ upMarkClear()
+ shade.fail(t('gui.upg.failed'), status.message || '')
+ return
+ }
+ const seen = describeStatus(status)
+ shade.measure(seen.text, seen.fraction)
+ setTimeout(tick, 1000)
+ return
+ }
+ /* The helper answered and now does not: it has let the port go to the new
+ Raven, which is starting. */
+ if (helperSeen) shade.measure(t('gui.upg.phase.restart'), null)
let r: Response
try {
r = await fetch('/', { method: 'HEAD', cache: 'no-store' })
diff --git a/ui-web/src/chrome/UpgradeShade.tsx b/ui-web/src/chrome/UpgradeShade.tsx
index 8b17af228..484ea5a69 100644
--- a/ui-web/src/chrome/UpgradeShade.tsx
+++ b/ui-web/src/chrome/UpgradeShade.tsx
@@ -4,9 +4,10 @@
* `.upshade` is the full-window shade itself, so a wrapper around it would take
* the inset and the `--z` step the class carries.
*
- * While the install is running the card is a bar and a line of text. A failure
- * hides the bar and adds the three things only the reader can act on: what
- * went wrong, the command to run by hand, and a way to put the card down.
+ * While the install is running the card is a bar and a line of text. The bar
+ * slides until the helper reports bytes, then measures them. A failure hides
+ * the bar and adds the three things only the reader can act on: what went
+ * wrong, the command to run by hand, and a way to put the card down.
*/
import { useSyncExternalStore } from 'react'
import { createPortal } from 'react-dom'
@@ -22,7 +23,9 @@ export function UpgradeShade(): JSX.Element | null {
return createPortal(
-
+
+
+
{card.text}
{failure ? (
<>
diff --git a/ui-web/src/features/composer/store.ts b/ui-web/src/features/composer/store.ts
index 1a6b30618..1401887ee 100644
--- a/ui-web/src/features/composer/store.ts
+++ b/ui-web/src/features/composer/store.ts
@@ -225,7 +225,9 @@ export function goPaint(): void {
/* What the empty field says: the task on a draft, a continuation once the
conversation exists, and while a turn is running, that a message typed now
- waits for it -- the one fact about the queue a reader cannot see. */
+ goes into that turn (`sendMidTurn` merges it at the turn's next gap) rather
+ than waiting for it to end -- the one fact about sending mid-turn a reader
+ cannot see. */
function placeholder(): string {
if (currentSession() === null) return t('gui.composer_ph')
return t(turn.busy() ? 'gui.composer.ph_busy' : 'gui.composer.ph_session')
diff --git a/ui-web/src/rpc/wsTransport.test.ts b/ui-web/src/rpc/wsTransport.test.ts
index 696f2a4f1..165c47a75 100644
--- a/ui-web/src/rpc/wsTransport.test.ts
+++ b/ui-web/src/rpc/wsTransport.test.ts
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RpcError } from './transport'
-import { WsTransport } from './wsTransport'
+import { probeHealth, WsTransport } from './wsTransport'
import type { ConnectionState } from './transport'
@@ -475,3 +475,34 @@ describe('rejoin', () => {
expect(states(h).at(-1)).toBe('closed')
})
})
+
+/* What decides "absent, keep waiting" against "back, and refused this session".
+ Only the second ends the reconnect, with a bar telling the reader to act. */
+describe('the health probe behind a refused socket', () => {
+ afterEach(() => { vi.unstubAllGlobals() })
+
+ const answering = (status: number): void => {
+ vi.stubGlobal('fetch', async () => ({ status, ok: status < 400 }))
+ }
+
+ it('reads a live gateway as back', async () => {
+ answering(200)
+ expect(await probeHealth()).toBe(true)
+ })
+
+ it('reads an upgrade in progress as not back yet', async () => {
+ /* The helper holds the port and answers 503 while the new Raven installs. */
+ answering(503)
+ expect(await probeHealth()).toBe(false)
+ })
+
+ it('reads nothing listening as not back yet', async () => {
+ vi.stubGlobal('fetch', async () => { throw new TypeError('connection refused') })
+ expect(await probeHealth()).toBe(false)
+ })
+
+ it('does not read its own refusal as the process being back', async () => {
+ answering(401)
+ expect(await probeHealth()).toBe(false)
+ })
+})
diff --git a/ui-web/src/rpc/wsTransport.ts b/ui-web/src/rpc/wsTransport.ts
index f57d348b3..bf528f1ea 100644
--- a/ui-web/src/rpc/wsTransport.ts
+++ b/ui-web/src/rpc/wsTransport.ts
@@ -99,11 +99,17 @@ const defaultSocket = (url: string): WebSocketLike =>
cookie the gateway has already forgotten, so an answer means the process is
back and a socket closing anyway is a real auth refusal. No answer means
keep waiting. (The old client probed '/' with HEAD; the two are equivalent, and
- the dev server owns '/'.) */
-const probeHealth = async (): Promise
=> {
+ the dev server owns '/'.)
+
+ A 5xx is not an answer either. During an upgrade the helper holds the page's
+ port and says 503 to everything but its own status: read as "the process is
+ back", that 503 made the transport give up on a gateway that was minutes
+ from returning and tell the reader to start Raven by hand -- the one thing
+ that, mid-upgrade, starts a second supervisor beside the one coming back. */
+export const probeHealth = async (): Promise => {
try {
const r = await fetch('/health', { cache: 'no-store' })
- return r.status !== 401 && r.status !== 403
+ return r.status < 500 && r.status !== 401 && r.status !== 403
} catch {
return false
}
diff --git a/ui-web/src/state/upgradeShade.test.ts b/ui-web/src/state/upgradeShade.test.ts
index beb3c7021..93518b2f5 100644
--- a/ui-web/src/state/upgradeShade.test.ts
+++ b/ui-web/src/state/upgradeShade.test.ts
@@ -73,3 +73,35 @@ describe('the upgrade shade writer', () => {
expect(current.isConnected).toBe(false)
})
})
+
+/* The bar slides until the helper reports bytes, measures them while it does,
+ and slides again when there is nothing to count -- uv resolving, the new
+ Raven starting. A bar that kept its last width then would be lying. */
+describe('the measured upgrade bar', () => {
+ it('measures what the helper reports', () => {
+ wire()
+ const shade = open()
+ shade.measure('Downloading', 0.5)
+ const bar = document.querySelector('.upshade .upbar') as HTMLElement
+ expect(bar.hasAttribute('data-measured')).toBe(true)
+ expect((bar.querySelector('i') as HTMLElement).style.width).toBe('50.0%')
+ expect(document.querySelector('.upshade .t')!.textContent).toBe('Downloading')
+ })
+
+ it('goes back to sliding when nothing can be measured', () => {
+ wire()
+ const shade = open()
+ shade.measure('Downloading', 0.9)
+ shade.measure('Installing', null)
+ const bar = document.querySelector('.upshade .upbar') as HTMLElement
+ expect(bar.hasAttribute('data-measured')).toBe(false)
+ expect((bar.querySelector('i') as HTMLElement).getAttribute('style')).toBeNull()
+ })
+
+ it('never draws past either end', () => {
+ wire()
+ const shade = open()
+ shade.measure('Downloading', 1.7)
+ expect((document.querySelector('.upshade .upbar i') as HTMLElement).style.width).toBe('100.0%')
+ })
+})
diff --git a/ui-web/src/state/upgradeShade.ts b/ui-web/src/state/upgradeShade.ts
index e3b3b5ede..ce4cc558f 100644
--- a/ui-web/src/state/upgradeShade.ts
+++ b/ui-web/src/state/upgradeShade.ts
@@ -16,6 +16,9 @@ import { makeStore } from './store'
export interface UpgradeShade {
say(text: string): void
+ /** A line with how far the install has got: 0..1, or null while it cannot
+ be measured (the bar goes back to sliding). */
+ measure(text: string, fraction: number | null): void
fail(text: string, detail?: string): void
close(): void
}
@@ -32,6 +35,10 @@ export interface Failure {
export interface Card {
readonly id: number
readonly text: string
+ /** How far the download has got, 0..1. Absent while nothing reports it --
+ the helper answers only once the old Raven has exited -- and the bar
+ then slides rather than pretend to measure. */
+ readonly progress?: number
readonly failure?: Failure
}
@@ -67,6 +74,11 @@ export function open(): UpgradeShade {
say(text: string): void {
if (mine()) set({ ...get()!, text })
},
+ measure(text: string, fraction: number | null): void {
+ if (!mine()) return
+ const { progress: _drop, ...rest } = get()!
+ set(fraction === null ? { ...rest, text } : { ...rest, text, progress: Math.max(0, Math.min(1, fraction)) })
+ },
fail(text: string, detail?: string): void {
if (!mine()) return
set({
diff --git a/ui-web/src/styles/page.css b/ui-web/src/styles/page.css
index 7367ed0dc..9d5929e3d 100644
--- a/ui-web/src/styles/page.css
+++ b/ui-web/src/styles/page.css
@@ -1673,14 +1673,17 @@ button, [role="button"] { -webkit-user-select: none; user-select: none; }
border: 1px solid var(--line); border-radius: 14px; padding: 18px 20px;
box-shadow: var(--shadow);
}
-/* Same indeterminate bar the browser pane uses, for the same reason: the helper
- reports nothing back until it relaunches serve, so there is no progress to
- show and a bar that pretended to measure one would be lying. */
+/* Same indeterminate bar the browser pane uses while nothing can be measured:
+ until the old Raven has exited the helper cannot answer on this port, and
+ while uv resolves there are no bytes to count. `[data-measured]` is the
+ stretch in between, when the helper is downloading and reports how far it has
+ got. An attribute, not a class, so the bar's class stays a plain literal. */
.upshade .upbar { height: 2px; overflow: hidden; border-radius: 2px; background: var(--surface); }
.upshade .upbar i {
display: block; height: 100%; width: 34%; background: var(--amber); border-radius: 2px;
animation: bslide 1.1s ease-in-out infinite;
}
+.upshade .upbar[data-measured] i { animation: none; transform: none; transition: width .4s ease-out; }
@media (prefers-reduced-motion: reduce) {
.upshade { backdrop-filter: none; }
.upshade .upbar i { animation: none; width: 100%; opacity: .45; }