From 0044f5d2a9ff855f294a9fff182bd3f225ba9eb2 Mon Sep 17 00:00:00 2001 From: zhanghui <23442919+gloryfromca@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:47:08 +0800 Subject: [PATCH 1/4] fix(cli): branch the enabled memory menu on ownership Configuring models is not an action that exists for a server the user runs -- that is raven writing into a root it owns, which by definition it does not. The enabled menu did not look at ownership, so a self-managed install got the managed one, and "Reconfigure" was the only plausible button for changing the address. It answered by walking the four model roles and recording raven's own root, flipping owned to true and overwriting the address, none of it confirmed; backing out of the roles then turned memory off after the config had already been rewritten. A self-managed install now gets its own three answers: keep it, point raven at a different address, or hand the job to raven. The handover is an answer rather than a side effect, and it says what it costs first -- their server and its data are untouched, raven just stops reading them. The branch sits ahead of discovery, which is the other half of the same fault. discover() marks raven's own roots owned=True unconditionally, so an abandoned managed root -- the ordinary leftover after switching -- was picked and adopted before any menu appeared. A recorded ownership decision outranks a directory that happens to still exist. Co-Authored-By: Claude Opus 5 (1M context) --- raven/cli/onboard_everos.py | 84 ++++++++++++++++++ tests/test_cli_onboard_commands.py | 133 ++++++++++++++++++++++++++++- 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/raven/cli/onboard_everos.py b/raven/cli/onboard_everos.py index 1e50e74b..91d8bcab 100644 --- a/raven/cli/onboard_everos.py +++ b/raven/cli/onboard_everos.py @@ -1486,6 +1486,69 @@ def _retry_or_skip_address() -> str: return str(choice) +_SWITCH_TO_MANAGED = object() + + +def _enabled_unowned_menu() -> object: + """Keep the server you run, move it, or hand the job to raven.""" + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + + slice_ = _recorded_memory_slice() + where = slice_.get("base_url") or "?" + oc.console.print() + oc.console.print( + oc._t( + f" [green]v Long-term memory uses the EverOS you run at {where}.[/green]", + f" [green]✓ 长期记忆正在使用你自己运行的 EverOS:{where}。[/green]", + ), + highlight=False, + ) + action = questionary.select( + oc._t("What would you like to do?", "想做什么?"), + choices=[ + questionary.Choice(oc._t("Keep it", "保持不变"), value="keep"), + questionary.Choice(oc._t("Point Raven at a different address", "改成别的地址"), value="address"), + # The way out. Without it this menu is a one-way door: the managed + # path is unreachable and the only exit is editing config.json by + # hand. Offered as its own answer rather than as a side effect of + # "reconfigure", which is how ownership used to flip unasked. + questionary.Choice( + oc._t("Let Raven run its own EverOS instead", "改用 Raven 自己运行的 EverOS"), + value="managed", + ), + ], + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if action is None: + raise typer.Exit(1) + if action == "managed": + # Say what it costs before taking it: their server keeps its data and + # keeps running, but raven stops reading it, so the memories behind that + # address stop being the ones it recalls. + oc.console.print( + oc._t( + f" [yellow]! Raven will build its own memory and stop using {where}.[/yellow]\n" + " [dim]That server and its data are untouched -- Raven simply stops reading them.[/dim]", + f" [yellow]⚠ Raven 将建立自己的记忆,不再使用 {where}。[/yellow]\n" + " [dim]那台服务器和它的数据不受影响,只是 Raven 不再读它。[/dim]", + ), + highlight=False, + ) + return _SWITCH_TO_MANAGED + if action == "address" and not _use_self_managed_everos(): + # A refused address leaves the working one in place rather than + # discarding a setup that was fine a moment ago. + oc.console.print( + oc._t( + f" [dim]Keeping {where}.[/dim]", + f" [dim]继续使用 {where}。[/dim]", + ) + ) + return None + + def _use_self_managed_everos() -> bool: """Point raven at an EverOS the user runs. Returns False if it is unreachable. @@ -2029,6 +2092,27 @@ def _step4_memory( oc.console.print( oc._t(" [dim]Looking for an existing memory service...[/dim]", " [dim]正在查找已有的记忆服务...[/dim]") ) + if _memory_enabled() and _recorded_memory_slice().get("owned") is False: + # A server the user runs, and this is settled before discovery is even + # consulted. Two reasons, both of which used to bite. + # + # Configuring models is not an action that exists here -- that is raven + # writing into a root it owns, which by definition it does not. Sharing + # the managed menu made "Reconfigure" the only plausible button for + # changing the address, and it answered by recording raven's own root, + # flipping ownership and overwriting the address, none of it confirmed. + # + # And discovery adds raven's own roots with owned=True unconditionally, + # so an abandoned managed root -- the normal leftover after switching -- + # would be picked and adopted before any menu appeared. A recorded + # ownership decision outranks a directory that happens to still exist. + if _enabled_unowned_menu() is not _SWITCH_TO_MANAGED: + return None + # An explicit handover. Recorded here rather than left to the branch that + # builds the root, because everything downstream -- _memory_enabled(), + # owned_everos_root() -- has to see the new answer first. + _record_root(default_everos_root(), owned=True) + found = _discover.pick(_discover.discover()) if found is not None and not found.owned: diff --git a/tests/test_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index 14423821..6cdf245e 100644 --- a/tests/test_cli_onboard_commands.py +++ b/tests/test_cli_onboard_commands.py @@ -1780,7 +1780,9 @@ def test_declining_falls_through_to_ravens_own_root(self, tmp_env: Path, _no_wri # Two screens: decline the reuse, then answer the keep/reconfigure menu # the way an existing install would. A single stubbed answer let "own" # stand in for both and slipped past the branch under test. - answers = iter(["own", "keep"]) + # Reaching the managed path from a recorded self-managed install is now + # an answer of its own ("managed"), not a side effect of reconfiguring. + answers = iter(["managed", "own", "keep"]) _no_writes.setattr(questionary, "select", lambda *a, **kw: _Answer(next(answers))) reached: list[int] = [] _no_writes.setattr(onboard_everos, "_config_everos_role", lambda **_kw: reached.append(1)) @@ -5874,3 +5876,132 @@ def test_creating_a_root_still_honours_a_recorded_address( monkeypatch.setattr(onboard_everos, "_port_is_free", lambda _p: True) assert onboard_everos._ask_managed_port(Path("/r")) == 1995 + + +class TestAnEnabledInstallBranchesOnOwnership: + """The two paths stay two paths after they are configured. + + The enabled menu did not look at ``owned``, so a self-managed install got + the managed one: Keep or Reconfigure, and Reconfigure is the only plausible + button for "change the address of my own server". It walked the four model + roles -- raven writing into a root it owns, which by definition it does not + here -- and ended with raven's own root recorded, ``owned`` flipped to true + and the user's address replaced, none of it confirmed. + + Configuring models is not an action that exists for a server the user runs. + The only one that does is changing where it is. + """ + + @staticmethod + def _self_managed(tmp_env: Path) -> None: + tmp_env.write_text( + json.dumps( + { + "memory": {"backend": "everos"}, + "plugins": {"config": {"everos-memory": {"owned": False, "base_url": "http://127.0.0.1:8000"}}}, + } + ), + encoding="utf-8", + ) + + def test_reconfigure_never_reaches_the_model_roles(self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import questionary + + from raven.cli import onboard_everos + + self._self_managed(tmp_env) + monkeypatch.setattr(_discover_mod, "pick", lambda _s: None) + monkeypatch.setattr(_discover_mod, "discover", list) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("redo")) + monkeypatch.setattr( + onboard_everos, + "_config_everos_role", + lambda **_kw: pytest.fail("walked a self-managed install through the model roles"), + ) + monkeypatch.setattr(onboard_everos, "_use_self_managed_everos", lambda: True) + + onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) + + def test_ownership_and_address_survive(self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Nothing may flip owned or record a root behind this menu.""" + import questionary + + from raven.cli import onboard_everos + + self._self_managed(tmp_env) + monkeypatch.setattr(_discover_mod, "pick", lambda _s: None) + monkeypatch.setattr(_discover_mod, "discover", list) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("keep")) + + onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) + + slice_ = json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] + assert slice_["owned"] is False + assert slice_["base_url"] == "http://127.0.0.1:8000" + assert "root" not in slice_ + + def test_a_managed_install_still_gets_the_model_roles( + self, tmp_env: Path, everos_isolated: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The managed menu is unchanged; only the unowned one is new.""" + import inspect + + from raven.cli import onboard_everos + + src = inspect.getsource(onboard_everos._step4_memory) + assert "_enabled_unowned_menu" in src, "the enabled branch does not consult ownership" + + +class TestDiscoveryCannotOverrideRecordedOwnership: + """A recorded `owned: false` is a decision; a root on disk is not. + + discover() adds raven's own roots with owned=True unconditionally, so a + self-managed install that still has an abandoned raven root lying around -- + the normal shape after switching -- had pick() return that root, and the + owned branch recorded it and flipped ownership before any menu was shown. + The user is silently moved off their own server by a directory they stopped + using. + """ + + def test_a_leftover_raven_root_does_not_reclaim_a_self_managed_install( + self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + import questionary + + from raven.cli import onboard_everos + from raven.plugin.memory.everos import _discover + + tmp_env.write_text( + json.dumps( + { + "memory": {"backend": "everos"}, + "plugins": {"config": {"everos-memory": {"owned": False, "base_url": "http://127.0.0.1:8000"}}}, + } + ), + encoding="utf-8", + ) + # An abandoned managed root, configured and therefore pickable. + leftover = _discover.RootState( + root=Path("/leftover/everos"), + # As discovery marks raven's own roots: unconditionally owned. + owned=True, + configured=True, + declared_url="http://localhost:18791", + alive=False, + lock_held=False, + ) + monkeypatch.setattr(_discover_mod, "discover", lambda: [leftover]) + monkeypatch.setattr(_discover_mod, "pick", lambda _s: leftover) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("keep")) + monkeypatch.setattr( + onboard_everos, + "_converge_owned_root", + lambda _s: pytest.fail("converged a root a self-managed install does not use"), + ) + + onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) + + slice_ = json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] + assert slice_["owned"] is False + assert slice_["base_url"] == "http://127.0.0.1:8000" + assert "root" not in slice_ From 7bbf01092120168e8dbdc9c0d89687492a8bada4 Mon Sep 17 00:00:00 2001 From: zhanghui <23442919+gloryfromca@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:55:07 +0800 Subject: [PATCH 2/4] fix(plugin): find the holder and its port without depending on lsof _proc_locks_pid exists because minimal Linux images routinely omit lsof, and the port lookup beside it used lsof and nothing else. Without it LockHolder.port is None, which the type documents as "holds the lock but serves no HTTP" -- so in a container a healthy raven-managed server is described as a squatter, with the wizard reporting its own service as "already in use by something else" and convergence telling the user to stop a server that is serving fine. The port now falls back to /proc/net/tcp matched against the process's own socket inodes, needing no external binary. The lock lookup also asked lsof first, which meant the /proc/locks branch never ran on a Linux box that has lsof -- including the one it was validated on. That branch is the one that tells a holder from a blocked waiter, while `lsof -t` lists both and its first line is not reliably the holder, so it is now the second choice rather than the first. Co-Authored-By: Claude Opus 5 (1M context) --- raven/plugin/memory/everos/_server.py | 77 ++++++++++++++++++++++++++- tests/test_everos_server.py | 56 +++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/raven/plugin/memory/everos/_server.py b/raven/plugin/memory/everos/_server.py index ee22fd84..de59a928 100644 --- a/raven/plugin/memory/everos/_server.py +++ b/raven/plugin/memory/everos/_server.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib import json import os import re @@ -399,7 +400,76 @@ def _cmdline_of(pid: int) -> str: return out.stdout.strip() +def _proc_net_rows() -> str: + """``/proc/net/tcp`` and ``tcp6`` concatenated, or an empty string.""" + out = [] + for name in ("tcp", "tcp6"): + try: + out.append(Path(f"/proc/net/{name}").read_text(encoding="utf-8")) + except OSError: + continue + return "".join(out) + + +def _socket_inodes_of(pid: int) -> set[int]: + """The socket inodes open in ``pid``, from ``/proc//fd``.""" + inodes: set[int] = set() + try: + entries = list(Path(f"/proc/{pid}/fd").iterdir()) + except OSError: + return inodes + for fd in entries: + try: + target = os.readlink(fd) + except OSError: + continue + if target.startswith("socket:["): + with contextlib.suppress(ValueError): + inodes.add(int(target[8:-1])) + return inodes + + +def _proc_net_listening_port(pid: int) -> int | None: + """The port ``pid`` listens on, via ``/proc``. No external binary needed. + + Exists because ``_proc_locks_pid`` is right that minimal container images + omit lsof, and a port lookup that needs it reports ``None`` there -- which + the type documents as "holds the lock but serves no HTTP", so a healthy + raven-managed server gets described as a squatter to be stopped. + """ + rows = _proc_net_rows() + if not rows: + return None + mine = _socket_inodes_of(pid) + if not mine: + return None + for line in rows.splitlines()[1:]: + fields = line.split() + # sl local_address rem_address st ... inode + if len(fields) < 10: + continue + # 0A is TCP_LISTEN. + if fields[3] != "0A": + continue + try: + inode = int(fields[9]) + port = int(fields[1].rsplit(":", 1)[-1], 16) + except ValueError: + continue + if inode in mine: + return port + return None + + def _listening_port(pid: int) -> int | None: + """The TCP port ``pid`` listens on, or ``None`` if it serves no HTTP.""" + port = _lsof_listening_port(pid) + if port is not None: + return port + return _proc_net_listening_port(pid) + + +def _lsof_listening_port(pid: int) -> int | None: """The TCP port ``pid`` listens on, or ``None``. ``-a`` is load-bearing: without it ``lsof`` ORs the ``-p`` and ``-i`` @@ -442,7 +512,12 @@ def _lock_holder_pid(lock: Path, root: Path) -> int | None: pidfile is also the only source that can be wrong about the root, so its recorded root is checked before its pid is trusted. """ - pid = _lsof_lock_pid(lock) or _proc_locks_pid(lock) + # /proc/locks first where it exists: it is the source that distinguishes the + # holder from a blocked waiter, which is the distinction the caller acts on. + # Asking lsof first meant that branch never ran on a Linux box that has + # lsof -- including the one it was validated on -- and ``lsof -t`` lists + # holder and waiter alike, so its first pid is not reliably the holder. + pid = _proc_locks_pid(lock) or _lsof_lock_pid(lock) if pid is not None: return pid record = _read_pidfile() diff --git a/tests/test_everos_server.py b/tests/test_everos_server.py index 7eb24f2f..906ce5fd 100644 --- a/tests/test_everos_server.py +++ b/tests/test_everos_server.py @@ -1002,3 +1002,59 @@ def test_it_surfaces_as_runtime_error(self, tmp_path, monkeypatch) -> None: root.chmod(0o755) assert "everos.toml" in str(caught.value) or "write" in str(caught.value).lower() + + +class TestFindingTheHolderWithoutLsof: + """lsof is optional on Linux, and the port lookup assumed it is not. + + ``_proc_locks_pid`` exists because minimal container images routinely omit + lsof; ``_listening_port`` then used lsof and nothing else. Without it + ``LockHolder.port`` is None, and None is documented as "holds the lock but + serves no HTTP" -- so a perfectly healthy raven-managed server is described + as a squatter to be stopped. + + Preferring lsof for the holder lookup had a second effect: on any Linux box + that has lsof the /proc/locks branch never runs, including the one it was + validated on. + """ + + def test_the_port_comes_from_proc_net_tcp_when_lsof_is_gone(self, monkeypatch) -> None: + from raven.plugin.memory.everos import _server + + # /proc/net/tcp: local_address is hex ip:port; 0x4967 == 18791. + rows = ( + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n" + " 0: 0100007F:4967 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 4980767 1 ...\n" + ) + monkeypatch.setattr(_server, "_lsof_listening_port", lambda _p: None) + monkeypatch.setattr(_server, "_proc_net_rows", lambda: rows) + monkeypatch.setattr(_server, "_socket_inodes_of", lambda _p: {4980767}) + + assert _server._listening_port(4242) == 18791 + + def test_lsof_is_still_used_when_present(self, monkeypatch) -> None: + from raven.plugin.memory.everos import _server + + monkeypatch.setattr(_server, "_lsof_listening_port", lambda _p: 31995) + monkeypatch.setattr(_server, "_proc_net_rows", lambda: pytest.fail("went to /proc with lsof available")) + + assert _server._listening_port(4242) == 31995 + + def test_proc_locks_is_preferred_on_linux(self, monkeypatch) -> None: + """The holder/waiter distinction /proc/locks makes is worth ten lines of + comment; asking lsof first threw it away wherever lsof exists.""" + from raven.plugin.memory.everos import _server + + monkeypatch.setattr(_server, "_proc_locks_pid", lambda _l: 111) + monkeypatch.setattr(_server, "_lsof_lock_pid", lambda _l: pytest.fail("asked lsof first")) + + assert _server._lock_holder_pid(Path("/x/ome.db.lock"), Path("/x")) == 111 + + def test_lsof_answers_when_proc_locks_cannot(self, monkeypatch, tmp_path) -> None: + from raven.plugin.memory.everos import _server + + monkeypatch.setattr(_server, "_proc_locks_pid", lambda _l: None) + monkeypatch.setattr(_server, "_lsof_lock_pid", lambda _l: 222) + monkeypatch.setattr(_server, "_read_pidfile", lambda: None) + + assert _server._lock_holder_pid(tmp_path / "ome.db.lock", tmp_path) == 222 From c7f03d0e737f1592c3e1ba918084b99cee7590ee Mon Sep 17 00:00:00 2001 From: zhanghui <23442919+gloryfromca@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:38:24 +0800 Subject: [PATCH 3/4] fix(cli): drop the user's address when the handover takes raven's own root Handing memory over from a self-managed everos recorded raven's root and ownership with a merging write, so the user's base_url stayed in the slice. _ask_managed_port reads exactly that as where raven is meant to listen: port was removed on the way in, so it falls back to urlparse(base_url).port. Raven's own service was therefore configured on the user's port, one screen after promising to stop using it -- silently when their server is stopped, and with "already in use by something else" naming their own everos when it is not. _adopt_own_root retracts base_url and port in the same write, and only when the record says the address was theirs. Declining to share a discovered root reaches the same line with a managed port the user deliberately moved to, and dropping that one would offer the shipped default again on the next run. test_declining_falls_through_to_ravens_own_root now stubs _port_is_free: with the address retracted, the managed default decides its outcome, which made it depend on whether the host happens to run an everos of its own. Co-authored-by: Claude (claude-opus-5[1m]) --- raven/cli/onboard_everos.py | 31 +++++++-- tests/test_cli_onboard_commands.py | 105 +++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/raven/cli/onboard_everos.py b/raven/cli/onboard_everos.py index 91d8bcab..7c987edc 100644 --- a/raven/cli/onboard_everos.py +++ b/raven/cli/onboard_everos.py @@ -1633,6 +1633,29 @@ def _record_root(root: Any, owned: bool) -> None: set_plugin_config_fields("everos-memory", {"root": str(root), "owned": owned}) +def _adopt_own_root() -> None: + """Move to raven's own root, retracting an address that was not raven's. + + A merging write cannot say "this address no longer applies", and the address + recorded for a server the user runs is exactly what ``_ask_managed_port`` + falls back to when no port is recorded -- so leaving it behind parks raven's + own service on the user's port, one screen after promising to stop using it. + + Only retracted when the record says the address is theirs. A managed port + the user deliberately moved to is also reachable here, and dropping that one + would quietly offer the shipped default again on the next run. + """ + from raven.config.update import set_plugin_config_fields + from raven.config.update_everos import default_everos_root + + theirs = _recorded_memory_slice().get("owned") is False + set_plugin_config_fields( + "everos-memory", + {"root": str(default_everos_root()), "owned": True}, + remove=("base_url", "port") if theirs else (), + ) + + def _capability_lines(base_url: str) -> list[str]: """One line per capability the running server actually built. @@ -2086,7 +2109,6 @@ def _step4_memory( questionary = oc._require_questionary() from raven.cli._styles import RAVEN_STYLE - from raven.config.update_everos import default_everos_root from raven.plugin.memory.everos import _discover oc.console.print( @@ -2110,8 +2132,9 @@ def _step4_memory( return None # An explicit handover. Recorded here rather than left to the branch that # builds the root, because everything downstream -- _memory_enabled(), - # owned_everos_root() -- has to see the new answer first. - _record_root(default_everos_root(), owned=True) + # owned_everos_root(), _ask_managed_port() -- has to see the new answer + # first. + _adopt_own_root() found = _discover.pick(_discover.discover()) @@ -2127,7 +2150,7 @@ def _step4_memory( # llm, and everos_root() itself -- has to see the new answer, and # _memory_enabled() returns from this function before the building # branch is ever reached. - _record_root(default_everos_root(), owned=True) + _adopt_own_root() found = None if found is not None and found.owned: diff --git a/tests/test_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index 6cdf245e..e324a60d 100644 --- a/tests/test_cli_onboard_commands.py +++ b/tests/test_cli_onboard_commands.py @@ -1787,6 +1787,10 @@ def test_declining_falls_through_to_ravens_own_root(self, tmp_env: Path, _no_wri reached: list[int] = [] _no_writes.setattr(onboard_everos, "_config_everos_role", lambda **_kw: reached.append(1)) _no_writes.setattr(onboard_everos, "_report_everos_capabilities", lambda: None) + # Which root gets built is the subject; which port it lands on is not. + # Left real, the managed default decides the outcome by whether this + # host happens to be running an everos of its own. + _no_writes.setattr(onboard_everos, "_port_is_free", lambda _p: True) async def _ok(*_a: object, **_kw: object) -> None: return None @@ -1803,6 +1807,9 @@ async def _ok(*_a: object, **_kw: object) -> None: # raven has adopted the user's root and is about to overwrite it. assert Path(slice_["root"]) != theirs, "adopted the root the user declined to share" assert Path(slice_["root"]) == mine + # And its address is raven's, not the one carried in from theirs. + assert slice_["base_url"] == "http://localhost:18791" + assert slice_["port"] == 18791 def test_a_stopped_one_is_probed_again_rather_than_started( self, tmp_env: Path, everos_isolated: Path, _no_writes, capsys: pytest.CaptureFixture @@ -5940,6 +5947,104 @@ def test_ownership_and_address_survive(self, tmp_env: Path, monkeypatch: pytest. assert slice_["base_url"] == "http://127.0.0.1:8000" assert "root" not in slice_ + @pytest.mark.parametrize("recorded_port", [None, 8000]) + def test_the_handover_does_not_inherit_their_address( + self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch, recorded_port: int | None + ) -> None: + """Raven's own service must not be configured on the user's port. + + The screen above the handover promises raven stops using their address. + A merging write kept it, and ``_ask_managed_port`` reads exactly that as + the port raven is meant to listen on -- silently when their server is + stopped (the natural order: shut it down, then re-run onboard), and with + "already in use by something else" pointing at their own EverOS when it + is not. Both cases are parametrized: a reuse recorded through + ``_set_base_url`` leaves an explicit ``port`` behind as well as the + address, and the address alone is what a self-managed setup records. + """ + import questionary + + from raven.cli import onboard_everos + from raven.config import update_everos as ue + + mine = tmp_env.parent / "mine" + monkeypatch.setattr(ue, "default_everos_root", lambda: mine) + monkeypatch.setattr(ue, "legacy_everos_root", lambda: tmp_env.parent / "legacy") + slice_in: dict[str, Any] = {"owned": False, "base_url": "http://127.0.0.1:8000"} + if recorded_port is not None: + slice_in["port"] = recorded_port + tmp_env.write_text( + json.dumps({"memory": {"backend": "everos"}, "plugins": {"config": {"everos-memory": slice_in}}}), + encoding="utf-8", + ) + assert onboard_everos._memory_enabled() is True + + monkeypatch.setattr(_discover_mod, "discover", list) + # Two screens: the ownership menu, then the source question the handover + # falls through into. Both answered "managed" -- the second one is the + # known double-ask, not the subject here. + answers = iter(["managed", "managed"]) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer(next(answers))) + monkeypatch.setattr(onboard_everos, "_config_everos_role", lambda **_kw: None) + monkeypatch.setattr(onboard_everos, "_report_everos_capabilities", lambda: None) + monkeypatch.setattr(onboard_everos, "_stop_for_reload", lambda *_a, **_kw: None) + # Their server is stopped, so every port looks free and nothing prompts: + # the port raven ends up on is whatever the record hands it. + monkeypatch.setattr(onboard_everos, "_port_is_free", lambda _p: True) + + async def _ok(*_a: object, **_kw: object) -> None: + return None + + monkeypatch.setattr("raven.plugin.memory.everos._server.ensure_everos_server", _ok) + + onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) + + slice_ = json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] + assert slice_["owned"] is True + assert Path(slice_["root"]) == mine + assert slice_["port"] == 18791, "raven's own service was parked on the user's port" + assert slice_["base_url"] == "http://localhost:18791" + + def test_a_managed_port_the_user_moved_to_survives_the_switch( + self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Retracting the address is about theirs, not about raven's own. + + Declining to share a discovered root also reaches here, and there the + recorded address can be raven's own on a port the user deliberately + moved to. Dropping that offers 18791 again on the next run, which is the + silent undo ``_ask_managed_port`` exists to prevent. + """ + from raven.cli import onboard_everos + from raven.config import update_everos as ue + + mine = tmp_env.parent / "mine" + monkeypatch.setattr(ue, "default_everos_root", lambda: mine) + tmp_env.write_text( + json.dumps( + { + "plugins": { + "config": { + "everos-memory": { + "owned": True, + "root": str(tmp_env.parent / "old"), + "base_url": "http://localhost:20000", + "port": 20000, + } + } + } + } + ), + encoding="utf-8", + ) + + onboard_everos._adopt_own_root() + + slice_ = json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] + assert Path(slice_["root"]) == mine + assert slice_["port"] == 20000 + assert slice_["base_url"] == "http://localhost:20000" + def test_a_managed_install_still_gets_the_model_roles( self, tmp_env: Path, everos_isolated: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From fbebbc22b990434739cb75f834b271b42d8e2640 Mon Sep 17 00:00:00 2001 From: zhanghui <23442919+gloryfromca@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:52:04 +0800 Subject: [PATCH 4/4] refactor(*): ask who runs everos once and drop discovery-time ownership Ownership was a property the wizard tried to observe: discovery reported it per candidate root, and five nested menus each re-derived what raven was allowed to do from whatever happened to be on disk. Every fix in this area landed on the least-used of those states, and the last two were the same shape again -- a menu that did not consult ownership, and a leftover root adopted before any menu appeared. It is a decision, not an observation. The step now asks one question -- raven runs everos, the user runs it, or neither today -- and ownership follows from the answer, so nothing downstream reads it back off a directory. The managed lane takes over whatever memory directory it finds, whatever an earlier run recorded there, and builds its own when there is none. The found-root question also moved in front of the work it authorises. Convergence used to stop and restart the service before asking, so an install that only wanted confirming was moved to another port unprompted; "use it as it is" now touches nothing and keeps the address the service already answers on, while relocating is what "reconfigure" means. A refused self-managed address returns to the lane question instead of ending the step, and skipping is an answer rather than something reached by backing out of a lane. Two states have no way forward and are reported rather than worked around: a directory held by something that serves no HTTP (pid and command line), and a start that failed (the reason everos gave). One memory directory admits one engine, so there is nothing to offer there. Removed: _enabled_unowned_menu, _reuse_unowned_root, _converge_owned_root, _adopt_or_move, _adopt_running_address, _stop_failure_line, _same_address and RootState.owned. _record_root and _adopt_own_root collapse into _adopt_root. The runtime is untouched: backend, tools and doctor read the recorded owned field, whose meaning is unchanged. Two test-isolation faults surfaced while rewriting: the memory fixture let a real bind test and real discovery decide which branch a test took, so a box already running everos on 18791 met an unscripted prompt and died on EOF, and every managed-lane test probed the developer's own /health. Both are pinned in the fixture; the file now runs in 3s instead of 100s. Co-authored-by: Claude (claude-opus-5[1m]) --- raven/cli/onboard_everos.py | 759 +++++++----------------- raven/plugin/memory/everos/_discover.py | 46 +- tests/test_cli_onboard_commands.py | 607 +++++++------------ tests/test_everos_discover.py | 35 +- 4 files changed, 484 insertions(+), 963 deletions(-) diff --git a/raven/cli/onboard_everos.py b/raven/cli/onboard_everos.py index 7c987edc..84ddca00 100644 --- a/raven/cli/onboard_everos.py +++ b/raven/cli/onboard_everos.py @@ -1314,31 +1314,6 @@ def _config_everos_role( return -_LOOPBACK_NAMES = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"}) - - -def _same_address(a: str | None, b: str | None) -> bool: - """Whether two URLs name the same socket, allowing for spelling. - - The declared address is copied out of the root's toml and the target is - built from a recorded port, so the same endpoint routinely arrives as - ``127.0.0.1`` on one side and ``localhost`` on the other. Comparing the - strings makes a service already on its configured port look like drift, and - the wizard then offers to move it onto itself. - """ - from urllib.parse import urlparse - - if not a or not b: - return False - pa, pb = urlparse(a), urlparse(b) - if pa.port != pb.port: - return False - ha, hb = (pa.hostname or "").lower(), (pb.hostname or "").lower() - if ha == hb: - return True - return ha in _LOOPBACK_NAMES and hb in _LOOPBACK_NAMES - - def _lock_holder(root: Path | str): """The process serving ``root``, or None. Indirected so callers can stub it.""" from raven.plugin.memory.everos._server import lock_holder @@ -1486,69 +1461,6 @@ def _retry_or_skip_address() -> str: return str(choice) -_SWITCH_TO_MANAGED = object() - - -def _enabled_unowned_menu() -> object: - """Keep the server you run, move it, or hand the job to raven.""" - questionary = oc._require_questionary() - from raven.cli._styles import RAVEN_STYLE - - slice_ = _recorded_memory_slice() - where = slice_.get("base_url") or "?" - oc.console.print() - oc.console.print( - oc._t( - f" [green]v Long-term memory uses the EverOS you run at {where}.[/green]", - f" [green]✓ 长期记忆正在使用你自己运行的 EverOS:{where}。[/green]", - ), - highlight=False, - ) - action = questionary.select( - oc._t("What would you like to do?", "想做什么?"), - choices=[ - questionary.Choice(oc._t("Keep it", "保持不变"), value="keep"), - questionary.Choice(oc._t("Point Raven at a different address", "改成别的地址"), value="address"), - # The way out. Without it this menu is a one-way door: the managed - # path is unreachable and the only exit is editing config.json by - # hand. Offered as its own answer rather than as a side effect of - # "reconfigure", which is how ownership used to flip unasked. - questionary.Choice( - oc._t("Let Raven run its own EverOS instead", "改用 Raven 自己运行的 EverOS"), - value="managed", - ), - ], - style=RAVEN_STYLE, - qmark=oc._QMARK, - ).ask() - if action is None: - raise typer.Exit(1) - if action == "managed": - # Say what it costs before taking it: their server keeps its data and - # keeps running, but raven stops reading it, so the memories behind that - # address stop being the ones it recalls. - oc.console.print( - oc._t( - f" [yellow]! Raven will build its own memory and stop using {where}.[/yellow]\n" - " [dim]That server and its data are untouched -- Raven simply stops reading them.[/dim]", - f" [yellow]⚠ Raven 将建立自己的记忆,不再使用 {where}。[/yellow]\n" - " [dim]那台服务器和它的数据不受影响,只是 Raven 不再读它。[/dim]", - ), - highlight=False, - ) - return _SWITCH_TO_MANAGED - if action == "address" and not _use_self_managed_everos(): - # A refused address leaves the working one in place rather than - # discarding a setup that was fine a moment ago. - oc.console.print( - oc._t( - f" [dim]Keeping {where}.[/dim]", - f" [dim]继续使用 {where}。[/dim]", - ) - ) - return None - - def _use_self_managed_everos() -> bool: """Point raven at an EverOS the user runs. Returns False if it is unreachable. @@ -1621,41 +1533,6 @@ def _use_self_managed_everos() -> bool: return True -def _record_root(root: Any, owned: bool) -> None: - """Record which EverOS root is in use and whether raven may write to it. - - Both are decisions rather than derivable facts, and every later reader -- - the wizard, the runtime, doctor -- consults the record instead of guessing - again. Recording ``owned`` is what makes read-only reuse enforceable. - """ - from raven.config.update import set_plugin_config_fields - - set_plugin_config_fields("everos-memory", {"root": str(root), "owned": owned}) - - -def _adopt_own_root() -> None: - """Move to raven's own root, retracting an address that was not raven's. - - A merging write cannot say "this address no longer applies", and the address - recorded for a server the user runs is exactly what ``_ask_managed_port`` - falls back to when no port is recorded -- so leaving it behind parks raven's - own service on the user's port, one screen after promising to stop using it. - - Only retracted when the record says the address is theirs. A managed port - the user deliberately moved to is also reachable here, and dropping that one - would quietly offer the shipped default again on the next run. - """ - from raven.config.update import set_plugin_config_fields - from raven.config.update_everos import default_everos_root - - theirs = _recorded_memory_slice().get("owned") is False - set_plugin_config_fields( - "everos-memory", - {"root": str(default_everos_root()), "owned": True}, - remove=("base_url", "port") if theirs else (), - ) - - def _capability_lines(base_url: str) -> list[str]: """One line per capability the running server actually built. @@ -1681,313 +1558,6 @@ def _capability_lines(base_url: str) -> list[str]: return lines -def _reuse_unowned_root(state: Any) -> object: - """Screens for an EverOS the user manages: reuse it, or leave it alone. - - Read-only throughout. raven records the address and reports what the server - can do; it does not write the config (those are the user's keys and models to - set) and does not start it (starting takes the OME jobstore lock - exclusively, which is theirs to grant). - """ - questionary = oc._require_questionary() - from raven.cli._styles import RAVEN_STYLE - - where = state.declared_url or "?" - oc.console.print() - oc.console.print( - oc._t( - " [dim]No EverOS service managed by Raven was found.[/dim]", - " [dim]未找到 Raven 管理的 EverOS 服务。[/dim]", - ) - ) - - while True: - if state.serving: - caps = " ".join(_capability_lines(where)) - oc.console.print( - oc._t( - f" [green]✓ Found another EverOS service, running[/green]\n" - f" memory dir {state.root} [dim]<- you manage this; " - f"Raven will not modify it, nor start or stop it[/dim]\n" - f" address {where}\n" - f" capability {caps}", - f" [green]✓ 发现另一个 EverOS 服务,正在运行[/green]\n" - f" 记忆目录 {state.root} [dim]<- 由你管理,Raven 不会修改它,也不启停它[/dim]\n" - f" 地址 {where}\n" - f" 能力 {caps}", - ), - highlight=False, - ) - choice = questionary.select( - oc._t("Reuse it for Raven's memory?", "要让 Raven 复用它吗?"), - choices=[ - questionary.Choice( - oc._t( - "Reuse (memory and media parsing both use this config)", - "复用(记忆和多模态解析都走这一份配置)", - ), - value="reuse", - ), - questionary.Choice( - oc._t("No, give Raven its own memory", "不用,让 Raven 建一份独立的记忆"), - value="own", - ), - ], - style=RAVEN_STYLE, - qmark=oc._QMARK, - ).ask() - if choice is None: - raise typer.Exit(1) - if choice == "own": - return _OWN_ROOT_INSTEAD - _record_root(state.root, owned=False) - _set_base_url(where) - _set_memory_backend("everos") - oc.console.print( - oc._t( - f" [green]✓ Recorded: Raven will store and recall memories via {where}.[/green]\n" - " [dim]You manage this EverOS. To change its models, edit its everos.toml\n" - " and restart it -- Raven follows along.[/dim]", - f" [green]✓ 已记录:Raven 将通过 {where} 存取记忆。[/green]\n" - " [dim]这份 EverOS 由你管理。要调整模型,编辑它的 everos.toml 后重启即可,\n" - " Raven 会自动跟随。[/dim]", - ), - highlight=False, - ) - return None - - oc.console.print( - oc._t( - f" [yellow]! Found another EverOS config, but its service is not running[/yellow]\n" - f" memory dir {state.root} [dim]<- you manage this; " - f"Raven will not start it[/dim]\n" - f" address {where} [dim](declared in its config, not answering)[/dim]", - f" [yellow]⚠ 发现另一份 EverOS 配置,但服务未启动[/yellow]\n" - f" 记忆目录 {state.root} [dim]<- 由你管理,Raven 不会替你启动[/dim]\n" - f" 地址 {where} [dim](配置中声明,当前无响应)[/dim]", - ), - highlight=False, - ) - choice = questionary.select( - oc._t("Reuse it?", "要复用它吗?"), - choices=[ - questionary.Choice( - oc._t("I will start it, then probe again", "我去启动它,然后重新探测"), - value="retry", - ), - questionary.Choice( - oc._t("No, give Raven its own memory", "不用,让 Raven 建一份独立的记忆"), - value="own", - ), - ], - style=RAVEN_STYLE, - qmark=oc._QMARK, - ).ask() - if choice is None: - raise typer.Exit(1) - if choice == "own": - return _OWN_ROOT_INSTEAD - oc.console.print( - oc._t( - f" [dim]Start it in another terminal, for example:[/dim]\n" - f" everos server start --root {state.root}", - f" [dim]请在另一个终端启动它,例如:[/dim]\n everos server start --root {state.root}", - ), - highlight=False, - ) - from raven.plugin.memory.everos import _discover - - state = _discover._describe(state.root, owned=False) - - -def _converge_owned_root(state: Any) -> bool: - """Bring a root raven owns onto the standard address. - - Returns False when the step should stop (the data is held by something raven - cannot identify). Nothing is asked here: the process is raven's own, EverOS - replays interrupted strategy runs through crash recovery, and SIGTERM drains - what is in flight before releasing the lock -- so there is no decision for - the user to make, only work to report. - """ - from raven.plugin.memory.everos._server import ( - StopOutcome, - find_recorded_server, - lock_holder, - stop_recorded_server, - ) - - target = _configured_target_url() - - if state.serving and _same_address(state.declared_url, target): - _set_base_url(target) - return True - - if state.serving and not _same_address(state.declared_url, target): - # Stopping a healthy service is the one destructive act in this step, so - # it is the user's call. Adopting is the default because it is the - # reversible one: the address is recorded, nothing is signalled, and a - # later run can still move it. - if _adopt_or_move(state.declared_url, target) == "adopt": - _adopt_running_address(state.declared_url) - return True - outcome = stop_recorded_server(state.root) - if outcome is not StopOutcome.STOPPED: - oc.console.print(_stop_failure_line(outcome, keeping=state.declared_url), highlight=False) - _set_base_url(state.declared_url or target) - return True - return _restart_here(state.root, target) - - if state.busy_elsewhere: - oc.console.print() - oc.console.print( - oc._t( - f" [yellow]! Something is already serving {state.root}, but not at " - f"{state.declared_url or 'any declared address'}.[/yellow]\n" - " [dim]One memory directory admits one EverOS instance; this is not about " - "the port.[/dim]", - f" [yellow]⚠ 已有进程在使用 {state.root},但不在它声明的地址 " - f"{state.declared_url or '(未声明)'} 上。[/yellow]\n" - " [dim]一份记忆数据同时只能由一个 EverOS 实例服务,这与端口无关。[/dim]", - ), - highlight=False, - ) - # Ask the OS who has the data before deciding anything. The lock names a - # holder whatever raven remembers, and the holder usually names a port - # -- which turns the one state that used to be a dead end into "it is - # over there", recoverable without signalling anything. - holder = lock_holder(state.root) - if holder is not None and holder.port: - found_at = f"http://localhost:{holder.port}" - oc.console.print( - oc._t( - f" [dim]It is answering at {found_at} (pid {holder.pid}).[/dim]", - f" [dim]它正在 {found_at} 上提供服务(pid {holder.pid})。[/dim]", - ), - highlight=False, - ) - if _adopt_or_move(found_at, target) == "adopt": - _adopt_running_address(found_at) - return True - elif holder is not None: - oc.console.print( - oc._t( - f" [yellow]! pid {holder.pid} holds it but is not serving HTTP:[/yellow]\n" - f" [dim]{holder.cmdline}[/dim]\n" - " [dim]Stop it and re-run `raven onboard`.[/dim]", - f" [yellow]⚠ pid {holder.pid} 占着它,但没有在提供 HTTP 服务:[/yellow]\n" - f" [dim]{holder.cmdline}[/dim]\n" - " [dim]请先停掉它,然后重跑 raven onboard。[/dim]", - ), - highlight=False, - ) - return False - if find_recorded_server(state.root) is None: - oc.console.print( - oc._t( - " [red]x Cannot take over: Raven cannot confirm it started that process.[/red]\n" - " [dim]Stop it and re-run `raven onboard`.[/dim]", - " [red]✗ 无法接管:无法确认占用它的进程是不是 Raven 启动的。[/red]\n" - " [dim]请先停掉它,然后重跑 raven onboard。[/dim]", - ), - highlight=False, - ) - return False - outcome = stop_recorded_server(state.root) - if outcome is not StopOutcome.STOPPED: - # The lock is still held. Walking on to spawn would put a second - # instance straight into it -- the failure this whole step exists to - # prevent -- so stop here rather than discard the outcome. - oc.console.print(_stop_failure_line(outcome, keeping=state.declared_url)) - return False - return _restart_here(state.root, target) - - # Owned, configured, and simply not running: still converge, or the legacy - # address survives every future run. ``ensure_everos_server`` would otherwise - # start a server at the old address and write it straight back into [api]. - return _restart_here(state.root, target) - - -def _adopt_or_move(running_at: str | None, target: str) -> str: - """Ask whether to keep the running address or move the service to the target. - - Presented as a question rather than done silently because the two cases the - wizard cannot tell apart -- a port an old raven left behind, and a port the - user deliberately set -- want opposite answers, and only the user knows - which one this is. - """ - questionary = oc._require_questionary() - from raven.cli._styles import RAVEN_STYLE - - oc.console.print() - oc.console.print( - oc._t( - f" [green]v Found Raven's EverOS service, running at {running_at}[/green]\n" - f" [dim]The configured address is {target}.[/dim]", - f" [green]✓ 找到 Raven 管理的 EverOS 服务,正在运行于 {running_at}[/green]\n" - f" [dim]配置中的地址是 {target}。[/dim]", - ), - highlight=False, - ) - choice = questionary.select( - oc._t("Which address should Raven use?", "Raven 该用哪个地址?"), - choices=[ - questionary.Choice( - oc._t( - f"Keep {running_at} (update the setting, nothing restarts)", - f"就用 {running_at}(更新配置,不重启)", - ), - value="adopt", - ), - questionary.Choice( - oc._t(f"Move it to {target} (restarts the service)", f"迁移到 {target}(会重启服务)"), - value="move", - ), - ], - style=RAVEN_STYLE, - qmark=oc._QMARK, - ).ask() - if choice is None: - raise typer.Exit(1) - return str(choice) - - -def _adopt_running_address(running_at: str | None) -> None: - """Record the address the service is already on as the intended one. - - Both halves matter: ``base_url`` so this session connects, and ``port`` so - the next run compares against the same intent instead of asking again. - :func:`_set_base_url` writes both. - """ - if not running_at: - return - _set_base_url(running_at) - - -def _stop_failure_line(outcome: Any, *, keeping: str | None) -> str: - """One sentence per stop outcome. Saying the wrong one sends the user - looking for the wrong thing -- a server draining memory work is not a - foreign process.""" - from raven.plugin.memory.everos._server import StopOutcome - - reason = { - StopOutcome.NOT_OURS: oc._t( - "Raven cannot confirm it started this process.", - "无法确认这个进程是不是 Raven 启动的。", - ), - StopOutcome.SIGNAL_FAILED: oc._t("the stop signal could not be delivered.", "停止信号发送失败(权限不足?)。"), - StopOutcome.STILL_DRAINING: oc._t( - "it is shutting down but still finishing memory work.", - "它正在收尾,可能有记忆任务还在跑。", - ), - }[outcome] - tail = oc._t(f"Keeping {keeping}. ", f"继续使用 {keeping}。") if keeping else oc._t("", "") - return oc._t( - f" [yellow]! Could not move it: {reason}[/yellow]\n" - f" [dim]{tail}Re-run `raven onboard` once it has stopped.[/dim]", - f" [yellow]⚠ 无法迁移:{reason}[/yellow]\n [dim]{tail}等它停下后重跑 raven onboard 即可。[/dim]", - ) - - def _restart_here(root: Any, target: str) -> bool: """Start the service at ``target`` and record the address it now serves. @@ -2054,24 +1624,182 @@ def _set_base_url(base_url: str) -> None: set_plugin_config_fields("everos-memory", fields) -_OWN_ROOT_INSTEAD = object() +def _adopt_root(root: Any) -> None: + """Record ``root`` as raven's own, retracting an address that was not raven's. + + Ownership is the lane the user picked, not something discovery can observe: + asking raven to run everos makes the directory raven's whatever an earlier + run recorded there. + + The retraction is the other half. A merging write cannot say "this address + no longer applies", and the address recorded for a server the user runs is + exactly what :func:`_ask_managed_port` falls back to -- so leaving it behind + parks raven's own service on the user's port, one screen after taking the + job over. A managed port the user deliberately moved to is kept. + """ + from raven.config.update import set_plugin_config_fields + + theirs = _recorded_memory_slice().get("owned") is False + set_plugin_config_fields( + "everos-memory", + {"root": str(root), "owned": True}, + remove=("base_url", "port") if theirs else (), + ) + + +def _memory_source_menu() -> str: + """The one question this step asks: who runs EverOS. + + Everything else follows from the answer -- ownership above all, which is why + no later screen has to infer it from a directory that happens to exist. + Skipping is an answer here rather than something reachable only by walking + into a lane and backing out of it. + """ + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + + choice = questionary.select( + oc._t("Where should long-term memory come from?", "长期记忆从哪来?"), + choices=[ + questionary.Choice(oc._t("Let Raven run EverOS for me", "让 Raven 替我运行 EverOS"), value="managed"), + questionary.Choice( + oc._t("I run my own EverOS -- connect to it", "我自己运行 EverOS —— 连过去"), + value="self", + ), + questionary.Choice(oc._t("Skip for now", "暂时跳过"), value="skip"), + ], + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if choice is None: + raise typer.Exit(1) + return str(choice) + + +def _found_root_menu(state: Any) -> str: + """Take the found root as it is, reconfigure it, or go back. + + Asked before anything is written or signalled. The wizard used to converge + the address first and ask afterwards, so a user who only wanted to confirm + an existing setup had the service stopped and restarted on a different port + before the question appeared. + """ + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + + where = state.declared_url or oc._t("(no address declared)", "(未声明地址)") + if state.serving: + status = oc._t(f"running at {where}", f"正在 {where} 上运行") + elif state.busy_elsewhere: + status = oc._t("its data is in use, but not at that address", "数据正被占用,但不在该地址上") + else: + status = oc._t(f"not running ({where} declared)", f"未在运行(配置声明 {where})") + oc.console.print() + oc.console.print( + oc._t( + f" [green]v Found a memory directory Raven can take over[/green]\n" + f" memory dir {state.root}\n" + f" state {status}", + f" [green]✓ 找到一份 Raven 可以接管的记忆目录[/green]\n" + f" 记忆目录 {state.root}\n" + f" 状态 {status}", + ), + highlight=False, + ) + choice = questionary.select( + oc._t("What would you like to do?", "想做什么?"), + choices=[ + questionary.Choice(oc._t("Use it as it is", "直接用它"), value="reuse"), + questionary.Choice( + oc._t("Reconfigure it (port and models)", "重新配置(端口和模型)"), + value="redo", + ), + questionary.Choice(oc._t("Back", "返回上一层"), value="back"), + ], + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if choice is None: + raise typer.Exit(1) + return str(choice) + + +def _use_found_root(state: Any) -> None: + """Serve memory from ``state.root`` at whatever address it is already on. + + Nothing is stopped, moved or reconfigured: the user asked to use this + directory as it is, and the address its own server answers on is the answer. + + One memory directory admits one engine, so a root whose data is held by + something raven cannot reach over HTTP has no way forward here. That one is + reported -- with the pid and the command line -- instead of being worked + around by starting a second instance that could only fail on the lock. + """ + if state.serving: + _set_base_url(str(state.declared_url)) + _set_memory_backend("everos") + _report_everos_capabilities() + return + + if state.busy_elsewhere: + holder = _lock_holder(state.root) + if holder is not None and holder.port: + found_at = f"http://localhost:{holder.port}" + oc.console.print( + oc._t( + f" [dim]It is answering at {found_at} (pid {holder.pid}).[/dim]", + f" [dim]它正在 {found_at} 上提供服务(pid {holder.pid})。[/dim]", + ), + highlight=False, + ) + _set_base_url(found_at) + _set_memory_backend("everos") + _report_everos_capabilities() + return + detail = f" [dim]{holder.cmdline}[/dim]\n" if holder is not None else "" + pid_line = ( + oc._t(f"pid {holder.pid} holds", "pid {} 占用着".format(holder.pid)) + if holder is not None + else oc._t("something holds", "有进程占用着") + ) + oc.console.print( + oc._t( + f" [yellow]! {pid_line} {state.root} but serves no HTTP.[/yellow]\n" + f"{detail}" + " [dim]Stop it and re-run `raven onboard`.[/dim]", + f" [yellow]⚠ {pid_line} {state.root},但没有提供 HTTP 服务。[/yellow]\n" + f"{detail}" + " [dim]请先停掉它,然后重跑 raven onboard。[/dim]", + ), + highlight=False, + ) + return + + if _restart_here(state.root, state.declared_url or _configured_target_url()): + _set_memory_backend("everos") + _report_everos_capabilities() def _step4_memory( *, skip: bool, non_interactive: bool, main_model: Optional[str], warnings: list[str], skip_test: bool = False ) -> object: - """Step 4 -- EverOS long-term memory (model sub-screens). - - The bootstrap seeds ``memory.backend="everos"`` (schema default) and everos - is the only memory backend, so this step does not ask whether to enable it: - it either confirms the seed by configuring the llm role, or resolves it back - to ``None`` on skip / non-interactive / give-up. ``None`` means no long-term - memory at all, not a fallback to something simpler. - - ``_memory_enabled`` gates on the llm role alone, so a fresh modelless seed - reads as "not configured yet" and the keep/reconfigure menu only appears once - that model is actually on disk. embedding and rerank are offered here but - never gate: skipping them costs recall quality, not memory itself. + """Step 4 -- EverOS long-term memory. + + Three lanes, asked once: raven runs everos, the user runs it, or neither + happens today. Which lane decides ownership, so no later screen has to read + it back off a directory that happens to exist -- the mistake behind a managed + reconfigure overwriting an address raven had promised not to touch. + + The managed lane is the one that must always land somewhere usable: it takes + over whatever memory directory it finds, or builds its own. The self-managed + lane writes only after a probe answers, and a refused address returns to the + lane question with nothing written. Skipping leaves the config as it is. + + ``None`` means no long-term memory this session, not a fallback to something + simpler. ``_memory_enabled`` gates on the llm role alone, so a seeded but + modelless config reads as "not configured yet"; embedding and rerank are + offered but never gate, since skipping them costs recall quality rather than + memory itself. """ oc._step_header(4, oc._t("EverOS long-term memory", "EverOS 长期记忆")) @@ -2111,112 +1839,57 @@ def _step4_memory( from raven.cli._styles import RAVEN_STYLE from raven.plugin.memory.everos import _discover - oc.console.print( - oc._t(" [dim]Looking for an existing memory service...[/dim]", " [dim]正在查找已有的记忆服务...[/dim]") - ) - if _memory_enabled() and _recorded_memory_slice().get("owned") is False: - # A server the user runs, and this is settled before discovery is even - # consulted. Two reasons, both of which used to bite. - # - # Configuring models is not an action that exists here -- that is raven - # writing into a root it owns, which by definition it does not. Sharing - # the managed menu made "Reconfigure" the only plausible button for - # changing the address, and it answered by recording raven's own root, - # flipping ownership and overwriting the address, none of it confirmed. - # - # And discovery adds raven's own roots with owned=True unconditionally, - # so an abandoned managed root -- the normal leftover after switching -- - # would be picked and adopted before any menu appeared. A recorded - # ownership decision outranks a directory that happens to still exist. - if _enabled_unowned_menu() is not _SWITCH_TO_MANAGED: - return None - # An explicit handover. Recorded here rather than left to the branch that - # builds the root, because everything downstream -- _memory_enabled(), - # owned_everos_root(), _ask_managed_port() -- has to see the new answer - # first. - _adopt_own_root() - - found = _discover.pick(_discover.discover()) - - if found is not None and not found.owned: - # A root the user manages. Read-only from here: record where it is and - # report what it can do, never configure it and never start or stop it. - outcome = _reuse_unowned_root(found) - if outcome is not _OWN_ROOT_INSTEAD: - return outcome - # Record the decision here rather than leaving it to the branch that - # builds the root. Everything downstream -- _memory_enabled(), which - # reads the recorded root and would still find the user's configured - # llm, and everos_root() itself -- has to see the new answer, and - # _memory_enabled() returns from this function before the building - # branch is ever reached. - _adopt_own_root() - found = None - - if found is not None and found.owned: - _record_root(found.root, owned=True) - if not _converge_owned_root(found): + while True: + source = _memory_source_menu() + + if source == "skip": + # Same rule as ``--skip-memory``: a configured setup is left exactly + # as it is, and a seeded-but-modelless one resolves to off so the + # runtime does not activate everos with no models behind it. + if not _memory_enabled(): + _set_memory_backend(None) + oc.console.print( + oc._t( + " [dim]Long-term memory left as it is.[/dim]\n" + " [dim]Run `raven onboard` again whenever you want to configure it.[/dim]", + " [dim]长期记忆保持原样。[/dim]\n [dim]随时可以重新运行 raven onboard 配置。[/dim]", + ) + ) return None - if found is None and not _memory_enabled(): - # Nothing of raven's to use, so the two ways forward are genuinely - # different setups rather than two spellings of one. Asked once, here, - # instead of inferred later from whatever happened to be on disk. - source = questionary.select( - oc._t("Where should long-term memory come from?", "长期记忆从哪来?"), - choices=[ - questionary.Choice( - oc._t("Let Raven run EverOS for me", "让 Raven 替我运行 EverOS"), - value="managed", - ), - questionary.Choice( - oc._t("I run my own EverOS -- connect to it", "我自己运行 EverOS —— 连过去"), - value="self", - ), - ], - style=RAVEN_STYLE, - qmark=oc._QMARK, - ).ask() - if source is None: - raise typer.Exit(1) if source == "self": if _use_self_managed_everos(): return None - # The step ends here. Falling through to the managed path would - # have walked someone who just said "I run my own EverOS" through - # configuring four model roles and a key for a setup they did not - # ask for, and left a root on disk they will not use -- while the - # line printed a moment earlier told them to start their server and - # re-run. A refused address is a server not started or a port - # mistyped, not a change of mind. - # Nothing printed here: the user picked "skip" one line ago and the - # option said what that means. Restating it is noise on a screen the - # step is already leaving. - _set_memory_backend(None) - return None + # A refused address is a server not started or a port mistyped, not + # a change of mind: back to the one question this step asks. Nothing + # was written, so the setup that was working a moment ago still is. + continue - if _memory_enabled(): - action = questionary.select( + oc.console.print( oc._t( - "EverOS long-term memory is already enabled. What would you like to do?", - "EverOS 长期记忆已启用。想做什么?", - ), - choices=[ - questionary.Choice(oc._t("Keep it enabled", "保持启用"), value="keep"), - questionary.Choice(oc._t("Reconfigure", "重新配置"), value="redo"), - ], - style=RAVEN_STYLE, - qmark=oc._QMARK, - ).ask() - if action is None: - raise typer.Exit(1) - if action == "keep": - return None # backend already "everos" + models on disk; leave as-is - else: - # No enable/decline question: everos is the only memory backend, so the - # step goes straight into configuring it. Leaving is still possible -- - # backing out of the required roles reaches the give-up prompt, which - # spells out what is lost. + " [dim]Looking for a memory directory Raven can take over...[/dim]", + " [dim]正在查找 Raven 可以接管的记忆目录...[/dim]", + ) + ) + found = _discover.pick(_discover.discover()) + if found is None: + break + + action = _found_root_menu(found) + if action == "back": + # Nothing is recorded until an answer other than "back", so this + # leaves the config untouched. + continue + _adopt_root(found.root) + if action == "reuse": + _use_found_root(found) + return None + break + + if not _everos_role_configured("llm"): + # embedding and rerank are both skippable, so what each one buys has to + # be on screen before the first prompt -- otherwise the roles read as + # three questions of equal weight. # Wrapped by hand: rich re-wraps at the terminal width and drops the # two-space indent on continuation lines, which reads as a stray # left-flush sentence under an indented block. @@ -2244,7 +1917,7 @@ def _step4_memory( # owned_everos_root, not everos_root: after a user declined to share theirs, # the recorded root is still theirs, and building there would adopt it. root = owned_everos_root() - _record_root(root, owned=True) + _adopt_root(root) configure_everos_env(root) ensure_everos_home(root) diff --git a/raven/plugin/memory/everos/_discover.py b/raven/plugin/memory/everos/_discover.py index b3ec26a8..363e6457 100644 --- a/raven/plugin/memory/everos/_discover.py +++ b/raven/plugin/memory/everos/_discover.py @@ -1,9 +1,14 @@ """Find the EverOS roots on this machine and say what state each is in. raven used to assume there was exactly one root at one address and start a server -whenever that address did not answer. Both assumptions were wrong in ways that -cost users their memory: a root can already be served on another port, and a root -can belong to the user rather than to raven. +whenever that address did not answer. That cost users their memory: a root can +already be served on another port, by a process this module has to notice rather +than talk over. + +Ownership is deliberately not one of the questions. Only roots raven creates for +itself are scanned, and whether raven may write to the one it picks is settled by +the lane the user chose in the wizard -- a directory that happens to exist cannot +answer that. Discovery answers four questions per candidate root, and deliberately keeps them apart because they fail independently: @@ -42,7 +47,6 @@ class RootState: """One candidate EverOS root and what could be observed about it.""" root: Path - owned: bool configured: bool declared_url: str | None alive: bool @@ -69,7 +73,7 @@ def busy_elsewhere(self) -> bool: return self.lock_held and not self.alive -def _describe(root: Path, *, owned: bool) -> RootState: +def _describe(root: Path) -> RootState: from raven.config.update_everos import role_configured_in data = _read_toml(root) @@ -79,7 +83,6 @@ def _describe(root: Path, *, owned: bool) -> RootState: return RootState( root=root, - owned=owned, # Through the ops layer rather than re-reading the fields here: one # definition of "configured", shared with the wizard and doctor. configured=role_configured_in(data, "llm"), @@ -113,39 +116,30 @@ def discover() -> list[RootState]: even when it is in a worse state than another candidate, because switching roots behind the user's back would silently change which memories raven has. - Only roots raven creates for itself are scanned. An EverOS the user runs is - never discovered: finding one means offering it, offering it means asking - for a decision the user did not come to make, and the only answer raven can - honour -- read-only reuse -- is one it cannot infer from a path anyway. - Pointing raven at such a server is an explicit turn in the wizard where the - person who knows the address types it. + Only roots raven creates for itself are scanned, plus the one the config + records. An EverOS the user runs is never discovered: finding one means + offering it, and offering it means asking for a decision the user did not + come to make. Pointing raven at such a server is an explicit turn in the + wizard where the person who knows the address types it. """ - from raven.config.update_everos import ( - _recorded_slice, - applicable_legacy_root, - default_everos_root, - root_is_raven_owned, - ) + from raven.config.update_everos import _recorded_slice, applicable_legacy_root, default_everos_root states: list[RootState] = [] seen: set[Path] = set() - def add(root: Path, *, owned: bool) -> RootState: - state = _describe(root, owned=owned) + def add(root: Path) -> RootState: + state = _describe(root) states.append(state) seen.add(root) return state - slice_ = _recorded_slice() - recorded = slice_.get("root") + recorded = _recorded_slice().get("root") if recorded: - root = Path(str(recorded)).expanduser() - owned = bool(slice_["owned"]) if "owned" in slice_ else root_is_raven_owned(root) - add(root, owned=owned) + add(Path(str(recorded)).expanduser()) for root in (default_everos_root(), applicable_legacy_root()): if root is not None and root not in seen: - add(root, owned=True) + add(root) return states diff --git a/tests/test_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index e324a60d..8efedde4 100644 --- a/tests/test_cli_onboard_commands.py +++ b/tests/test_cli_onboard_commands.py @@ -15,7 +15,6 @@ import asyncio import json -import os import sys from pathlib import Path from types import SimpleNamespace @@ -971,6 +970,16 @@ def everos_isolated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: root = tmp_path / ".everos" monkeypatch.setattr(ue, "everos_root", lambda: root) monkeypatch.setattr(ue, "everos_owned", lambda: True) + # The managed lane asks for a port only when the intended one is taken, and + # a bind test reads the real machine: on a developer box already running + # everos on 18791 these tests met an unscripted prompt and died on EOF. + # Tests that are about that question patch this themselves, afterwards. + monkeypatch.setattr(onboard_everos, "_port_is_free", lambda _p: True) + # Discovery scans the real ``~/.everos`` and ``~/.raven`` paths, so without + # this a developer box with an everos of its own decides which branch these + # tests take -- and probes its /health while doing it. Tests about a found + # root install their own candidate, afterwards. + monkeypatch.setattr(_discover_mod, "discover", list) return root / "everos.toml" @@ -1667,7 +1676,6 @@ def _root_state(root: Path, **kw: Any) -> Any: defaults = { "root": root, - "owned": True, "configured": True, "declared_url": "http://127.0.0.1:18791", "alive": True, @@ -1683,85 +1691,80 @@ def _found(monkeypatch: pytest.MonkeyPatch, state: Any) -> None: monkeypatch.setattr(_discover, "discover", lambda: [state]) -class TestReusingAnEverosTheUserManages: - """Read-only reuse: record the address, touch nothing else. +class TestTakingOverAFoundRoot: + """The managed lane owns whatever memory directory it finds. - Writing its config would overwrite the user's own models and keys; starting it - would take the OME jobstore lock exclusively, which is theirs to grant. + Ownership is the lane, not a property of the directory: a root an earlier run + recorded as the user's is taken over here, because that is what asking raven + to run everos means. What the answer decides is whether the service is + touched -- "use it as it is" must not stop, move or reconfigure anything, + which is why the question comes before the work rather than after it. """ @pytest.fixture(autouse=True) - def _no_writes(self, monkeypatch: pytest.MonkeyPatch): - from raven.config import update_everos as ue - - self.writes: list[str] = [] - self.signals: list[int] = [] - monkeypatch.setattr(ue, "set_everos_section", lambda s, _f: self.writes.append(s)) - monkeypatch.setattr(ue, "clear_everos_section", lambda s: self.writes.append(s)) - monkeypatch.setattr(ue, "ensure_everos_home", lambda *_a, **_kw: self.writes.append("template")) - monkeypatch.setattr(os, "kill", lambda *_a: self.signals.append(1)) - monkeypatch.setattr(onboard_everos, "_capability_lines", lambda _u: ["ok"]) + def _stubs(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(onboard_everos, "_report_everos_capabilities", lambda: None) return monkeypatch - def test_reuse_records_the_address_and_writes_nothing( - self, tmp_env: Path, everos_isolated: Path, _no_writes - ) -> None: + @staticmethod + def _answers(monkeypatch: pytest.MonkeyPatch, answers: list[str]) -> None: import questionary - theirs = tmp_env.parent / "theirs" - _found(_no_writes, _root_state(theirs, owned=False, declared_url="http://localhost:8000")) - _no_writes.setattr(questionary, "select", lambda *a, **kw: _Answer("reuse")) + it = iter(answers) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer(next(it))) + + def test_using_it_as_it_is_leaves_the_service_where_it_is( + self, tmp_env: Path, everos_isolated: Path, _stubs + ) -> None: + """The address it answers on is the answer: nothing stopped, nothing moved. + + Convergence used to run before the question, so a user who only wanted to + confirm an existing setup had the service stopped and restarted on the + configured port before any menu appeared. + """ + from raven.plugin.memory.everos import _server + + root = tmp_env.parent / "everos" + _found(_stubs, _root_state(root, declared_url="http://localhost:1995")) + touched: list[str] = [] + _stubs.setattr(_server, "stop_recorded_server", lambda *_a, **_kw: touched.append("stop")) + _stubs.setattr(_server, "stop_pid", lambda *_a, **_kw: touched.append("stop")) + + async def _ensure(url: str, **_kw: object) -> None: + touched.append(url) + + _stubs.setattr(_server, "ensure_everos_server", _ensure) + _stubs.setattr(onboard_everos, "_config_everos_role", lambda **_kw: pytest.fail("reconfigured on reuse")) + self._answers(_stubs, ["managed", "reuse"]) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) + assert touched == [], "touched a service the user asked to leave as it is" data = json.loads(tmp_env.read_text()) slice_ = data["plugins"]["config"]["everos-memory"] - assert slice_["root"] == str(theirs) - assert slice_["owned"] is False - assert slice_["base_url"] == "http://localhost:8000" + assert slice_["base_url"] == "http://localhost:1995" + assert slice_["owned"] is True + assert Path(slice_["root"]) == root assert data["memory"]["backend"] == "everos" - assert self.writes == [], "wrote into a root the user manages" - assert self.signals == [], "signalled a process raven does not own" - def test_declining_falls_through_to_ravens_own_root(self, tmp_env: Path, _no_writes) -> None: - """Saying no must leave raven building its OWN memory, not adopting theirs. + def test_a_root_recorded_as_the_users_is_taken_over_all_the_same( + self, tmp_env: Path, everos_isolated: Path, _stubs + ) -> None: + """``owned: false`` from an earlier run does not survive this lane. - Deliberately without the ``everos_isolated`` fixture: that pins ownership - to true, which is the condition under test. Ownership has to resolve for - real from the recorded config here, or the root assertion below cannot - fail -- which is how the first version of this test passed while raven was - taking over the user's root. + Discovery no longer carries ownership, so there is no read-only branch to + fall into and nothing to keep in sync: who runs everos was answered one + screen ago. """ - import questionary - - from raven.config import update_everos as ue - - theirs = tmp_env.parent / "theirs" - mine = tmp_env.parent / "mine" - _no_writes.setattr(ue, "default_everos_root", lambda: mine) - _no_writes.setattr(ue, "legacy_everos_root", lambda: tmp_env.parent / "legacy") - # The state a previous session's reuse leaves behind. - # Their root has to hold a real configured llm, and memory.backend has to - # be on: _memory_enabled() reads both, and if either is missing it is - # false, the keep/reconfigure branch is skipped, and the branch that used - # to discard the decision -- the one this test exists for -- is never - # entered. The first two attempts at this test passed for exactly that - # reason. - theirs.mkdir(parents=True, exist_ok=True) - (theirs / "everos.toml").write_text('[llm]\nmodel = "m"\napi_key = "k"\n', encoding="utf-8") + root = tmp_env.parent / "theirs" tmp_env.write_text( json.dumps( { "memory": {"backend": "everos"}, - # base_url alongside root: an unowned slice is "configured" - # by its address, since raven never reads their toml. A - # recorded unowned *root* only reaches this code from a - # config written before self-managed setups stopped - # recording one. "plugins": { "config": { "everos-memory": { - "root": str(theirs), + "root": str(root), "owned": False, "base_url": "http://localhost:8000", } @@ -1771,215 +1774,142 @@ def test_declining_falls_through_to_ravens_own_root(self, tmp_env: Path, _no_wri ), encoding="utf-8", ) - # Assert the branch is reachable before asserting what it does. Without - # this the test can pass because it never got there -- which is how two - # earlier versions of it passed while the decision was being discarded. - assert onboard_everos._memory_enabled() is True - - _found(_no_writes, _root_state(theirs, owned=False, declared_url="http://localhost:8000")) - # Two screens: decline the reuse, then answer the keep/reconfigure menu - # the way an existing install would. A single stubbed answer let "own" - # stand in for both and slipped past the branch under test. - # Reaching the managed path from a recorded self-managed install is now - # an answer of its own ("managed"), not a side effect of reconfiguring. - answers = iter(["managed", "own", "keep"]) - _no_writes.setattr(questionary, "select", lambda *a, **kw: _Answer(next(answers))) - reached: list[int] = [] - _no_writes.setattr(onboard_everos, "_config_everos_role", lambda **_kw: reached.append(1)) - _no_writes.setattr(onboard_everos, "_report_everos_capabilities", lambda: None) - # Which root gets built is the subject; which port it lands on is not. - # Left real, the managed default decides the outcome by whether this - # host happens to be running an everos of its own. - _no_writes.setattr(onboard_everos, "_port_is_free", lambda _p: True) - - async def _ok(*_a: object, **_kw: object) -> None: - return None - - _no_writes.setattr("raven.plugin.memory.everos._server.ensure_everos_server", _ok) + _found(_stubs, _root_state(root, declared_url="http://localhost:8000")) + self._answers(_stubs, ["managed", "reuse"]) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - assert len(reached) == 4, "did not reach the four role screens" slice_ = json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] assert slice_["owned"] is True - # The point of declining: raven builds its OWN root. Asserting only that - # ownership flipped to true was the hole -- it passes just as well when - # raven has adopted the user's root and is about to overwrite it. - assert Path(slice_["root"]) != theirs, "adopted the root the user declined to share" - assert Path(slice_["root"]) == mine - # And its address is raven's, not the one carried in from theirs. - assert slice_["base_url"] == "http://localhost:18791" - assert slice_["port"] == 18791 + assert slice_["base_url"] == "http://localhost:8000" - def test_a_stopped_one_is_probed_again_rather_than_started( - self, tmp_env: Path, everos_isolated: Path, _no_writes, capsys: pytest.CaptureFixture + def test_reconfiguring_walks_the_roles_and_lands_on_the_configured_port( + self, tmp_env: Path, everos_isolated: Path, _stubs ) -> None: - import questionary - - from raven.plugin.memory.everos import _discover - - theirs = tmp_env.parent / "theirs" - _found(_no_writes, _root_state(theirs, owned=False, alive=False, declared_url="http://localhost:8000")) - _no_writes.setattr(questionary, "select", lambda *a, **kw: _Answer("retry")) - # The user starts it; the re-probe then sees it up. - _no_writes.setattr( - _discover, - "_describe", - lambda root, owned: _root_state(root, owned=owned, alive=True, declared_url="http://localhost:8000"), - ) - - onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - - out = " ".join(capsys.readouterr().out.split()) - assert "everos server start --root" in out, "did not tell the user how to start it" - assert self.writes == [] - assert self.signals == [] - assert json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"]["owned"] is False - - -class TestConvergingRavensOwnPort: - @pytest.fixture(autouse=True) - def _stubs(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(onboard_everos, "_report_everos_capabilities", lambda: None) - monkeypatch.setattr(onboard_everos, "_memory_enabled", lambda: True) - return monkeypatch - - def test_a_legacy_port_is_moved_to_the_standard_one(self, tmp_env: Path, everos_isolated: Path, _stubs) -> None: - """An install seeded with an older default keeps working, on one address.""" - import questionary - + """Moving the port is what reconfiguring is for, and only that.""" from raven.plugin.memory.everos import _server root = tmp_env.parent / "everos" _found(_stubs, _root_state(root, declared_url="http://localhost:1995")) - stopped: list[Path] = [] + reached: list[str] = [] started: list[str] = [] - _stubs.setattr( - _server, - "stop_recorded_server", - lambda r, **_kw: (stopped.append(r), _server.StopOutcome.STOPPED)[1], - ) + _stubs.setattr(onboard_everos, "_config_everos_role", lambda **kw: reached.append(kw["section"])) + _stubs.setattr(onboard_everos, "_stop_for_reload", lambda *_a, **_kw: True) async def _ensure(url: str, **_kw: object) -> None: started.append(url) _stubs.setattr(_server, "ensure_everos_server", _ensure) - # "Keep it enabled" is the first option, and the answer an existing - # install gives -- the path that used to exit with the service stopped. - _stubs.setattr(questionary, "select", lambda *a, **kw: _Answer("keep")) + self._answers(_stubs, ["managed", "redo"]) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - assert stopped == [root] - # Asserting only "it stopped" and "the config moved" is what let the - # service stay down: convergence is stop -> write -> start, and the last - # step has to be part of the same claim. - assert started == ["http://localhost:18791"], "stopped the service and never started it again" + assert reached == ["llm", "embedding", "rerank", "multimodal"] + assert started == ["http://localhost:18791"] slice_ = json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] assert slice_["base_url"] == "http://localhost:18791" + assert slice_["port"] == 18791 - def test_a_process_raven_did_not_start_is_left_alone( - self, tmp_env: Path, everos_isolated: Path, _stubs, capsys: pytest.CaptureFixture - ) -> None: - """Its address stays in use rather than being taken over blindly.""" - import questionary - - from raven.plugin.memory.everos import _server - - root = tmp_env.parent / "everos" - _found(_stubs, _root_state(root, declared_url="http://localhost:1995")) - _stubs.setattr(_server, "stop_recorded_server", lambda _r, **_kw: _server.StopOutcome.NOT_OURS) - _stubs.setattr(questionary, "select", lambda *a, **kw: _Answer("keep")) - - onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - - out = " ".join(capsys.readouterr().out.split()) - # Worded as an inability to confirm rather than a claim of fact: a lost - # pidfile looks exactly like a foreign process, and asserting the latter - # sends the user hunting for something that does not exist. - assert "cannot confirm it started this process" in out or "无法确认这个进程是不是 Raven 启动的" in out - slice_ = json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] - assert slice_["base_url"] == "http://localhost:1995" - - def test_a_root_already_on_the_standard_address_is_left_alone( + def test_a_root_that_is_down_is_started_where_it_declares( self, tmp_env: Path, everos_isolated: Path, _stubs ) -> None: - """The steady state after a convergence: nothing to stop, nothing to start.""" - import questionary + """Reuse starts it at its own address rather than relocating it. + The pre-upgrade port therefore survives a reuse, which is the deliberate + trade: an install that wants the standard port answers "reconfigure", + and one that just wants its memory back is not asked to accept a move it + did not request. + """ from raven.plugin.memory.everos import _server root = tmp_env.parent / "everos" - _found(_stubs, _root_state(root, declared_url="http://localhost:18791")) - touched: list[str] = [] - _stubs.setattr(_server, "stop_recorded_server", lambda *_a, **_kw: touched.append("stop")) + _found(_stubs, _root_state(root, alive=False, lock_held=False, declared_url="http://localhost:1995")) + started: list[str] = [] async def _ensure(url: str, **_kw: object) -> None: - touched.append(url) + started.append(url) _stubs.setattr(_server, "ensure_everos_server", _ensure) - _stubs.setattr(questionary, "select", lambda *a, **kw: _Answer("keep")) + self._answers(_stubs, ["managed", "reuse"]) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - assert touched == [], "restarted a service that was already where it belongs" + assert started == ["http://localhost:1995"] slice_ = json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] - assert slice_["base_url"] == "http://localhost:18791" + assert slice_["base_url"] == "http://localhost:1995" - def test_an_owned_root_that_is_simply_down_still_converges( + def test_the_lock_holders_port_is_used_when_it_can_be_found( self, tmp_env: Path, everos_isolated: Path, _stubs ) -> None: - """Otherwise the legacy address survives every future run: the service - would be started at the old port and set_everos_api would write it - straight back into the toml.""" - import questionary + """Data served on a port nobody recorded is still raven's to use. + + One memory directory admits one engine, so the only way forward is to + talk to the instance that already has it -- which the lock names, and + whose port ``lock_holder`` can usually find. + """ + from types import SimpleNamespace from raven.plugin.memory.everos import _server root = tmp_env.parent / "everos" - _found(_stubs, _root_state(root, alive=False, lock_held=False, declared_url="http://localhost:1995")) + _found(_stubs, _root_state(root, alive=False, lock_held=True, declared_url="http://localhost:1995")) + _stubs.setattr( + onboard_everos, + "_lock_holder", + lambda _r: SimpleNamespace(pid=4242, port=20001, cmdline="everos server start"), + ) started: list[str] = [] async def _ensure(url: str, **_kw: object) -> None: started.append(url) _stubs.setattr(_server, "ensure_everos_server", _ensure) - _stubs.setattr(questionary, "select", lambda *a, **kw: _Answer("keep")) + self._answers(_stubs, ["managed", "reuse"]) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - assert started == ["http://localhost:18791"] + assert started == [], "started a second instance against a directory already in use" slice_ = json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] - assert slice_["base_url"] == "http://localhost:18791" + assert slice_["base_url"] == "http://localhost:20001" - def test_a_lock_that_could_not_be_freed_stops_the_step( + def test_data_held_with_no_http_to_reach_is_reported( self, tmp_env: Path, everos_isolated: Path, _stubs, capsys: pytest.CaptureFixture ) -> None: - """Spawning into a lock that is still held is the failure this whole step - exists to prevent, so a stop that did not stop must not fall through.""" + """The one case with no way forward: say who has it and stop. + + An ``everos demo`` or an embedded engine holds the jobstore lock without + serving HTTP. Spawning into that lock is the failure this whole step + exists to prevent, so the pid and its command line are the deliverable. + """ + from types import SimpleNamespace + from raven.plugin.memory.everos import _server root = tmp_env.parent / "everos" - _found(_stubs, _root_state(root, alive=False, lock_held=True)) - _stubs.setattr(_server, "find_recorded_server", lambda _r: {"pid": 1, "root": str(root)}) - _stubs.setattr(_server, "stop_recorded_server", lambda *_a, **_kw: _server.StopOutcome.STILL_DRAINING) + _found(_stubs, _root_state(root, alive=False, lock_held=True, declared_url="http://localhost:1995")) + _stubs.setattr( + onboard_everos, + "_lock_holder", + lambda _r: SimpleNamespace(pid=4242, port=None, cmdline="everos demo --root x"), + ) started: list[str] = [] async def _ensure(url: str, **_kw: object) -> None: started.append(url) _stubs.setattr(_server, "ensure_everos_server", _ensure) - reached: list[int] = [] - _stubs.setattr(onboard_everos, "_config_everos_role", lambda **_kw: reached.append(1)) + _stubs.setattr(onboard_everos, "_config_everos_role", lambda **_kw: pytest.fail("walked the roles anyway")) + self._answers(_stubs, ["managed", "reuse"]) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - assert started == [], "spawned into a jobstore lock that is still held" - assert reached == [] out = " ".join(capsys.readouterr().out.split()) - assert "still finishing memory work" in out or "还在跑" in out + assert "4242" in out + assert "everos demo --root x" in out, "did not say what is holding the directory" + assert started == [], "spawned into a jobstore lock that is still held" + assert "base_url" not in json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] - def test_a_restart_that_fails_is_reported_and_stops_the_step( + def test_a_start_that_fails_is_reported_with_its_reason( self, tmp_env: Path, everos_isolated: Path, _stubs, capsys: pytest.CaptureFixture ) -> None: from raven.plugin.memory.everos import _server @@ -1988,35 +1918,30 @@ def test_a_restart_that_fails_is_reported_and_stops_the_step( _found(_stubs, _root_state(root, alive=False, lock_held=False, declared_url="http://localhost:1995")) async def _boom(_url: str, **_kw: object) -> None: - raise RuntimeError("port 18791 is occupied") + raise RuntimeError("port 1995 is occupied") _stubs.setattr(_server, "ensure_everos_server", _boom) - reached: list[int] = [] - _stubs.setattr(onboard_everos, "_config_everos_role", lambda **_kw: reached.append(1)) + _stubs.setattr(onboard_everos, "_config_everos_role", lambda **_kw: pytest.fail("walked the roles anyway")) + self._answers(_stubs, ["managed", "reuse"]) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) out = " ".join(capsys.readouterr().out.split()) - assert "port 18791 is occupied" in out, "swallowed the reason the restart failed" - assert reached == [] - - def test_data_held_by_an_unidentifiable_process_stops_the_step( - self, tmp_env: Path, everos_isolated: Path, _stubs, capsys: pytest.CaptureFixture - ) -> None: - """One memory directory admits one instance, so there is nothing to start.""" - from raven.plugin.memory.everos import _server + assert "port 1995 is occupied" in out, "swallowed the reason the start failed" + def test_going_back_writes_nothing(self, tmp_env: Path, everos_isolated: Path, _stubs) -> None: + """Back has to be a real exit from the lane, not a spelling of reuse.""" root = tmp_env.parent / "everos" - _found(_stubs, _root_state(root, alive=False, lock_held=True)) - _stubs.setattr(_server, "find_recorded_server", lambda _r: None) - reached: list[int] = [] - _stubs.setattr(onboard_everos, "_config_everos_role", lambda **_kw: reached.append(1)) + tmp_env.write_text(json.dumps({"memory": {"backend": "everos"}}), encoding="utf-8") + _found(_stubs, _root_state(root, declared_url="http://localhost:1995")) + _stubs.setattr(onboard_everos, "_config_everos_role", lambda **_kw: pytest.fail("configured after Back")) + self._answers(_stubs, ["managed", "back", "skip"]) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - out = " ".join(capsys.readouterr().out.split()) - assert "Cannot take over" in out or "无法接管" in out - assert reached == [], "walked into role configuration on data it cannot serve" + slice_ = (json.loads(tmp_env.read_text()).get("plugins") or {}).get("config", {}).get("everos-memory", {}) + assert "root" not in slice_, "recorded a root the user backed out of" + assert "owned" not in slice_ class _Answer: @@ -5414,38 +5339,6 @@ def test_no_port_default_is_offered(self) -> None: assert "18791" not in src -class TestAddressComparisonIsNotStringComparison: - """``127.0.0.1`` and ``localhost`` are one endpoint spelled two ways. - - The declared address is whatever is in the root's toml and the target is - built from a recorded port, so the two arrive spelled differently even when - they name the same socket. Comparing the strings made a service already on - its configured port look like drift, and offered to move it onto itself. - """ - - def test_loopback_spellings_are_the_same_address(self) -> None: - from raven.cli.onboard_everos import _same_address - - assert _same_address("http://127.0.0.1:20000", "http://localhost:20000") - assert _same_address("http://localhost:20000", "http://127.0.0.1:20000") - assert _same_address("http://[::1]:20000", "http://localhost:20000") - - def test_a_different_port_is_a_different_address(self) -> None: - from raven.cli.onboard_everos import _same_address - - assert not _same_address("http://127.0.0.1:20000", "http://localhost:18791") - - def test_a_non_loopback_host_is_not_loopback(self) -> None: - from raven.cli.onboard_everos import _same_address - - assert not _same_address("http://10.0.0.5:20000", "http://localhost:20000") - - def test_a_missing_address_never_matches(self) -> None: - from raven.cli.onboard_everos import _same_address - - assert not _same_address(None, "http://localhost:18791") - - class TestTheIntendedPortIsAlwaysRecorded: """The target address is only a setting if something writes it. @@ -5584,50 +5477,14 @@ def test_the_build_path_asks(self) -> None: assert "_ask_managed_port" in inspect.getsource(onboard_everos._step4_memory) -class TestAnUpgradeIsAskedBeforeItIsMoved: - """A pre-upgrade install is protected by the question, not by the target. - - An earlier attempt read the recorded address as the intent so that nothing - would be relocated. That over-corrected: with no intent stored the two - addresses always matched, the "keep it or move it" screen never appeared, - and the upgrade ended parked on the old port with the standard one never - mentioned. Silence in the other direction is still silence. - """ - - def test_the_old_address_and_the_target_disagree_so_the_user_is_asked(self, tmp_env: Path) -> None: - from raven.cli import onboard_everos - - tmp_env.write_text( - json.dumps({"plugins": {"config": {"everos-memory": {"base_url": "http://localhost:1995"}}}}), - encoding="utf-8", - ) - - target = onboard_everos._configured_target_url() - assert target == "http://localhost:18791" - assert not onboard_everos._same_address("http://127.0.0.1:1995", target), ( - "a pre-upgrade address must not read as already-at-target, or the question never fires" - ) - - def test_answering_keep_records_the_intent_so_it_is_asked_only_once(self, tmp_env: Path) -> None: - from raven.cli import onboard_everos - - tmp_env.write_text(json.dumps({}), encoding="utf-8") - onboard_everos._adopt_running_address("http://127.0.0.1:1995") - - target = onboard_everos._configured_target_url() - assert target == "http://localhost:1995" - assert onboard_everos._same_address("http://127.0.0.1:1995", target) - - -class TestARefusedSelfManagedAddressEndsTheStep: +class TestARefusedSelfManagedAddressReturnsToTheLaneQuestion: """Choosing self-managed and mistyping the port is not a change of mind. The step used to fall through to the managed path, so a user who had just - said "I run my own EverOS" was walked through configuring four model roles - and an API key for a setup they had not asked for -- and left with a root - built on disk that they would not use. The message printed one line earlier - already says what to do: start it and re-run. Doing the opposite of that in - the same breath is the contradiction worth removing. + said "I run my own EverOS" was walked through four model roles and an API key + for a setup they had not asked for. Ending the step outright was the other + over-correction: the address was refused because the server is not up yet or + a digit is wrong, and both are fixed where the user already is. """ @staticmethod @@ -5649,15 +5506,21 @@ def test_the_wizard_does_not_go_on_to_configure_models( "_config_everos_role", lambda **_kw: pytest.fail("configured a managed model after the user chose self-managed"), ) - monkeypatch.setattr(_discover_mod, "pick", lambda _s: None) monkeypatch.setattr(_discover_mod, "discover", list) - monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("self")) + answers = iter(["self", "skip"]) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer(next(answers))) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - def test_memory_is_left_off_rather_than_half_built(self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Leaving the backend on would have the runtime spawn a managed server - against a root the user never agreed to.""" + assert next(answers, None) is None, "the lane question was not asked again" + + def test_the_refusal_itself_changes_nothing(self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The setup that was working a moment ago still is. + + Only the answer given on the second pass decides -- here "skip", which + resolves a modelless seed to off rather than leaving the runtime to + activate everos with nothing behind it. + """ import questionary from raven.cli import onboard_everos @@ -5665,13 +5528,15 @@ def test_memory_is_left_off_rather_than_half_built(self, tmp_env: Path, monkeypa self._seed(tmp_env) monkeypatch.setattr(onboard_everos, "_use_self_managed_everos", lambda: False) monkeypatch.setattr(onboard_everos, "_memory_enabled", lambda: False) - monkeypatch.setattr(_discover_mod, "pick", lambda _s: None) monkeypatch.setattr(_discover_mod, "discover", list) - monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("self")) + answers = iter(["self", "skip"]) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer(next(answers))) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - assert json.loads(tmp_env.read_text())["memory"]["backend"] is None + data = json.loads(tmp_env.read_text()) + assert data["memory"]["backend"] is None + assert "everos-memory" not in (data.get("plugins") or {}).get("config", {}) class TestARefusedAddressCanBeRetyped: @@ -5747,14 +5612,15 @@ def test_a_nonsense_port_offers_the_same_choice(self, tmp_env: Path, monkeypatch assert onboard_everos._use_self_managed_everos() is True -class TestSkippingIsQuiet: - """The step is leaving; saying so again is noise. +class TestARefusalDoesNotAnnounceAnythingItDidNotDo: + """Between the refused address and the next question, nothing is claimed. - "Skip" was followed by a line restating what skip means, on a screen the - wizard is already walking off. The user chose the option one line earlier. + The old step turned memory off here and said so. It now returns to the lane + question with the config untouched, so a line about memory being off would be + describing something that has not happened. """ - def test_nothing_is_printed_after_the_choice( + def test_nothing_between_the_two_questions_says_memory_is_off( self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture ) -> None: import questionary @@ -5764,17 +5630,25 @@ def test_nothing_is_printed_after_the_choice( tmp_env.write_text(json.dumps({"memory": {"backend": "everos"}}), encoding="utf-8") monkeypatch.setattr(onboard_everos, "_use_self_managed_everos", lambda: False) monkeypatch.setattr(onboard_everos, "_memory_enabled", lambda: False) - monkeypatch.setattr(_discover_mod, "pick", lambda _s: None) monkeypatch.setattr(_discover_mod, "discover", list) - monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("self")) - capsys.readouterr() + answers = iter(["self", "skip"]) + # One snapshot per question, so what follows the refusal can be read on + # its own instead of being mixed with the closing lines of the step. + between: list[str] = [] + + def _select(*_a: object, **_kw: object) -> object: + between.append(capsys.readouterr().out) + return _Answer(next(answers)) + + monkeypatch.setattr(questionary, "select", _select) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - after = capsys.readouterr().out - assert "长期记忆保持关闭" not in after - assert "stays off until" not in after - assert json.loads(tmp_env.read_text())["memory"]["backend"] is None + assert len(between) == 2, "the lane question was not asked again" + after_refusal = " ".join(between[1].split()) + assert "长期记忆保持关闭" not in after_refusal + assert "stays off" not in after_refusal + assert "left as it is" not in after_refusal class TestReconfiguringRestartsOurOwnService: @@ -5885,18 +5759,14 @@ def test_creating_a_root_still_honours_a_recorded_address( assert onboard_everos._ask_managed_port(Path("/r")) == 1995 -class TestAnEnabledInstallBranchesOnOwnership: - """The two paths stay two paths after they are configured. - - The enabled menu did not look at ``owned``, so a self-managed install got - the managed one: Keep or Reconfigure, and Reconfigure is the only plausible - button for "change the address of my own server". It walked the four model - roles -- raven writing into a root it owns, which by definition it does not - here -- and ended with raven's own root recorded, ``owned`` flipped to true - and the user's address replaced, none of it confirmed. +class TestTheLaneDecidesOwnership: + """Who runs everos is answered once, and nothing infers it again. - Configuring models is not an action that exists for a server the user runs. - The only one that does is changing where it is. + The enabled menu used to be shared, so a self-managed install got Keep or + Reconfigure -- and Reconfigure, the only plausible button for "change the + address of my own server", walked the four model roles, recorded raven's own + root, flipped ``owned`` to true and replaced the address, none of it + confirmed. There is now one question, before any of that. """ @staticmethod @@ -5911,15 +5781,18 @@ def _self_managed(tmp_env: Path) -> None: encoding="utf-8", ) - def test_reconfigure_never_reaches_the_model_roles(self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_the_self_managed_lane_never_reaches_the_model_roles( + self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Configuring models is not an action that exists for a server the user + runs: those are their keys and their toml.""" import questionary from raven.cli import onboard_everos self._self_managed(tmp_env) - monkeypatch.setattr(_discover_mod, "pick", lambda _s: None) monkeypatch.setattr(_discover_mod, "discover", list) - monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("redo")) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("self")) monkeypatch.setattr( onboard_everos, "_config_everos_role", @@ -5929,38 +5802,40 @@ def test_reconfigure_never_reaches_the_model_roles(self, tmp_env: Path, monkeypa onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - def test_ownership_and_address_survive(self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Nothing may flip owned or record a root behind this menu.""" + def test_skipping_leaves_a_self_managed_setup_untouched( + self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Nothing may flip owned, record a root, or move the address behind a skip.""" import questionary from raven.cli import onboard_everos self._self_managed(tmp_env) - monkeypatch.setattr(_discover_mod, "pick", lambda _s: None) monkeypatch.setattr(_discover_mod, "discover", list) - monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("keep")) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("skip")) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) - slice_ = json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] + data = json.loads(tmp_env.read_text()) + slice_ = data["plugins"]["config"]["everos-memory"] assert slice_["owned"] is False assert slice_["base_url"] == "http://127.0.0.1:8000" assert "root" not in slice_ + assert data["memory"]["backend"] == "everos", "turned off a working self-managed setup" @pytest.mark.parametrize("recorded_port", [None, 8000]) - def test_the_handover_does_not_inherit_their_address( + def test_the_managed_lane_does_not_inherit_their_address( self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch, recorded_port: int | None ) -> None: """Raven's own service must not be configured on the user's port. - The screen above the handover promises raven stops using their address. - A merging write kept it, and ``_ask_managed_port`` reads exactly that as - the port raven is meant to listen on -- silently when their server is - stopped (the natural order: shut it down, then re-run onboard), and with - "already in use by something else" pointing at their own EverOS when it - is not. Both cases are parametrized: a reuse recorded through - ``_set_base_url`` leaves an explicit ``port`` behind as well as the - address, and the address alone is what a self-managed setup records. + A merging write keeps the old address, and ``_ask_managed_port`` reads + exactly that as the port raven is meant to listen on -- silently when + their server is stopped (the natural order: shut it down, then re-run + onboard), and with "already in use by something else" pointing at their + own EverOS when it is not. Both shapes are parametrized: a reuse recorded + through ``_set_base_url`` leaves an explicit ``port`` behind as well as + the address, and the address alone is what a self-managed setup records. """ import questionary @@ -5980,11 +5855,7 @@ def test_the_handover_does_not_inherit_their_address( assert onboard_everos._memory_enabled() is True monkeypatch.setattr(_discover_mod, "discover", list) - # Two screens: the ownership menu, then the source question the handover - # falls through into. Both answered "managed" -- the second one is the - # known double-ask, not the subject here. - answers = iter(["managed", "managed"]) - monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer(next(answers))) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("managed")) monkeypatch.setattr(onboard_everos, "_config_everos_role", lambda **_kw: None) monkeypatch.setattr(onboard_everos, "_report_everos_capabilities", lambda: None) monkeypatch.setattr(onboard_everos, "_stop_for_reload", lambda *_a, **_kw: None) @@ -6010,16 +5881,14 @@ def test_a_managed_port_the_user_moved_to_survives_the_switch( ) -> None: """Retracting the address is about theirs, not about raven's own. - Declining to share a discovered root also reaches here, and there the - recorded address can be raven's own on a port the user deliberately - moved to. Dropping that offers 18791 again on the next run, which is the - silent undo ``_ask_managed_port`` exists to prevent. + Taking over a found root also reaches here, and there the recorded + address can be raven's own on a port the user deliberately moved to. + Dropping that offers 18791 again on the next run, which is the silent + undo ``_ask_managed_port`` exists to prevent. """ from raven.cli import onboard_everos - from raven.config import update_everos as ue mine = tmp_env.parent / "mine" - monkeypatch.setattr(ue, "default_everos_root", lambda: mine) tmp_env.write_text( json.dumps( { @@ -6038,43 +5907,29 @@ def test_a_managed_port_the_user_moved_to_survives_the_switch( encoding="utf-8", ) - onboard_everos._adopt_own_root() + onboard_everos._adopt_root(mine) slice_ = json.loads(tmp_env.read_text())["plugins"]["config"]["everos-memory"] assert Path(slice_["root"]) == mine assert slice_["port"] == 20000 assert slice_["base_url"] == "http://localhost:20000" - def test_a_managed_install_still_gets_the_model_roles( - self, tmp_env: Path, everos_isolated: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """The managed menu is unchanged; only the unowned one is new.""" - import inspect - from raven.cli import onboard_everos +class TestALeftoverRootIsOnlyTakenOverOnPurpose: + """A directory on disk cannot start a takeover by itself. - src = inspect.getsource(onboard_everos._step4_memory) - assert "_enabled_unowned_menu" in src, "the enabled branch does not consult ownership" - - -class TestDiscoveryCannotOverrideRecordedOwnership: - """A recorded `owned: false` is a decision; a root on disk is not. - - discover() adds raven's own roots with owned=True unconditionally, so a - self-managed install that still has an abandoned raven root lying around -- - the normal shape after switching -- had pick() return that root, and the - owned branch recorded it and flipped ownership before any menu was shown. - The user is silently moved off their own server by a directory they stopped - using. + An abandoned raven root -- the normal shape after switching to a server of + one's own -- was picked by discovery, recorded as owned and converged before + any menu appeared, silently moving the user off their own server. Taking it + over is now something the managed lane does, after being chosen. """ - def test_a_leftover_raven_root_does_not_reclaim_a_self_managed_install( + def test_a_leftover_root_is_untouched_when_the_lane_is_not_chosen( self, tmp_env: Path, monkeypatch: pytest.MonkeyPatch ) -> None: import questionary from raven.cli import onboard_everos - from raven.plugin.memory.everos import _discover tmp_env.write_text( json.dumps( @@ -6085,23 +5940,13 @@ def test_a_leftover_raven_root_does_not_reclaim_a_self_managed_install( ), encoding="utf-8", ) - # An abandoned managed root, configured and therefore pickable. - leftover = _discover.RootState( - root=Path("/leftover/everos"), - # As discovery marks raven's own roots: unconditionally owned. - owned=True, - configured=True, - declared_url="http://localhost:18791", - alive=False, - lock_held=False, - ) + leftover = _root_state(Path("/leftover/everos"), alive=False, lock_held=False) monkeypatch.setattr(_discover_mod, "discover", lambda: [leftover]) - monkeypatch.setattr(_discover_mod, "pick", lambda _s: leftover) - monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("keep")) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("skip")) monkeypatch.setattr( onboard_everos, - "_converge_owned_root", - lambda _s: pytest.fail("converged a root a self-managed install does not use"), + "_found_root_menu", + lambda _s: pytest.fail("offered a takeover the user did not ask for"), ) onboard_everos._step4_memory(skip=False, non_interactive=False, main_model="openai/gpt-4o-mini", warnings=[]) diff --git a/tests/test_everos_discover.py b/tests/test_everos_discover.py index 8fbc7d58..23f850e7 100644 --- a/tests/test_everos_discover.py +++ b/tests/test_everos_discover.py @@ -36,7 +36,7 @@ def test_the_address_is_read_from_the_root(self, tmp_path: Path, _quiet_probes) root = tmp_path / "everos" _write_root(root) - state = _discover._describe(root, owned=True) + state = _discover._describe(root) assert state.declared_url == "http://127.0.0.1:18791" assert state.configured is True @@ -48,7 +48,7 @@ def test_a_root_without_an_api_section_declares_nothing(self, tmp_path: Path, _q root = tmp_path / "everos" _write_root(root, api=None) - state = _discover._describe(root, owned=True) + state = _discover._describe(root) assert state.declared_url is None assert state.alive is False @@ -59,10 +59,10 @@ def test_an_empty_api_key_reads_as_unconfigured(self, tmp_path: Path, _quiet_pro root = tmp_path / "everos" _write_root(root, key="") - assert _discover._describe(root, owned=True).configured is False + assert _discover._describe(root).configured is False def test_an_absent_root_is_described_without_raising(self, tmp_path: Path, _quiet_probes) -> None: - state = _discover._describe(tmp_path / "nope", owned=True) + state = _discover._describe(tmp_path / "nope") assert state.exists is False assert state.configured is False @@ -73,7 +73,7 @@ def test_unparseable_toml_is_treated_as_empty(self, tmp_path: Path, _quiet_probe root.mkdir() (root / "everos.toml").write_text("this is not toml {{{", encoding="utf-8") - assert _discover._describe(root, owned=True).configured is False + assert _discover._describe(root).configured is False def test_locked_but_silent_is_its_own_state(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Data served somewhere other than where it says -- the state that @@ -84,7 +84,7 @@ def test_locked_but_silent_is_its_own_state(self, tmp_path: Path, monkeypatch: p monkeypatch.setattr(_discover, "_probe_health", lambda _u: False) monkeypatch.setattr(_discover, "ome_lock_held", lambda _r: True) - state = _discover._describe(root, owned=True) + state = _discover._describe(root) assert state.busy_elsewhere is True assert state.serving is False @@ -95,7 +95,7 @@ def test_serving_means_reachable_where_it_declares(self, tmp_path: Path, monkeyp monkeypatch.setattr(_discover, "_probe_health", lambda _u: True) monkeypatch.setattr(_discover, "ome_lock_held", lambda _r: True) - state = _discover._describe(root, owned=True) + state = _discover._describe(root) assert state.serving is True assert state.busy_elsewhere is False @@ -138,7 +138,6 @@ def test_the_legacy_root_is_found_when_the_new_default_is_empty(self, _isolate) assert picked is not None assert picked.root == self.legacy - assert picked.owned is True def test_nothing_configured_picks_nothing(self, _isolate) -> None: assert _discover.pick(_discover.discover()) is None @@ -173,14 +172,24 @@ def test_every_discovered_root_is_ravens_own(self, _isolate) -> None: roots = {s.root for s in _discover.discover()} assert roots <= {self.default, self.legacy} - def test_a_recorded_unowned_root_keeps_its_ownership(self, tmp_path: Path, _isolate) -> None: + def test_discovery_says_nothing_about_ownership(self, tmp_path: Path, _isolate) -> None: + """A recorded root is a candidate whatever the config recorded about it. + + Ownership used to be carried on the candidate, so a root recorded as the + user's was offered as read-only and one of raven's was adopted before any + question appeared. It is now decided by the lane the user picks in the + wizard, and this module has no field for it. + """ from raven.config import update_everos as ue - theirs = tmp_path / "theirs" - _write_root(theirs) - _isolate.setattr(ue, "_recorded_slice", lambda: {"root": str(theirs), "owned": False}) + recorded = tmp_path / "recorded" + _write_root(recorded) + _isolate.setattr(ue, "_recorded_slice", lambda: {"root": str(recorded), "owned": False}) + + state = _discover.discover()[0] - assert _discover.discover()[0].owned is False + assert state.root == recorded + assert not hasattr(state, "owned") def test_no_duplicate_candidates(self, _isolate) -> None: from raven.config import update_everos as ue