Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1a04252
fix(cli): guard the gateway against a half-written install
arelchan Sep 26, 2026
b7a844b
feat(updates): name the download size before uv takes the terminal
arelchan Sep 26, 2026
a440d83
feat(cli): open the page for bare raven, keeping the tui for headless
arelchan Sep 26, 2026
a6bb013
feat(ui-web): shorten the composer placeholder to the task alone
arelchan Sep 26, 2026
ac70a03
fix(updates): flush the download manifest before uv writes to the log
arelchan Sep 26, 2026
9cc2331
feat(rpc): upgrade a gateway-hosted page by restarting its supervisor
arelchan Sep 26, 2026
d347b13
refactor(cli): lift the gateway busy check and page hand-over out of run
arelchan Sep 26, 2026
5cb6860
fix(rpc): close open page sockets when the server stops
arelchan Sep 26, 2026
c5b0b3f
fix(ui-web): read a 5xx health answer as not back yet
arelchan Sep 26, 2026
7633273
feat(updates): show a page-started upgrade's progress on the page
arelchan Sep 26, 2026
f54fbf9
feat(ui-web): say a mid-turn message reaches the running turn
arelchan Sep 26, 2026
de173c6
fix(updates): leave the rate off the terminal progress until there is…
arelchan Sep 26, 2026
70eee3a
test(rpc): read while the server stops, as a page does
arelchan Sep 26, 2026
c9e8cf8
test(cli): cover the browser check and a socket that fails to close
arelchan Sep 26, 2026
963df99
fix(updates): compress the upgrade helper so Windows can start it
arelchan Sep 26, 2026
ea250e7
fix(ui-web): move the upgrade deadline only when the upgrade moves
arelchan Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs-site/docs/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs-site/docs/quick-start.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

再运行一次同一个安装命令即可。它会在当前版本之上安装最新发布版,完成后重新启动
Expand Down
32 changes: 28 additions & 4 deletions i18n/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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。"
Expand All @@ -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…"
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 33 additions & 4 deletions raven/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,50 @@ 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,
version: bool = typer.Option(None, "--version", "-v", callback=version_callback, is_eager=True),
):
"""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
Expand Down
77 changes: 65 additions & 12 deletions raven/cli/gateway_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 9 additions & 5 deletions raven/cli/serve_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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,
Expand Down
Loading
Loading