diff --git a/raven/cli/onboard_everos.py b/raven/cli/onboard_everos.py index 1e50e74b..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 @@ -1558,18 +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 _capability_lines(base_url: str) -> list[str]: """One line per capability the running server actually built. @@ -1595,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. @@ -1968,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 长期记忆")) @@ -2023,93 +1837,59 @@ 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( - oc._t(" [dim]Looking for an existing memory service...[/dim]", " [dim]正在查找已有的记忆服务...[/dim]") - ) - 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. - _record_root(default_everos_root(), owned=True) - 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. @@ -2137,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/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_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index 14423821..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,206 +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. - answers = iter(["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) - - 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 + 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 @@ -1979,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: @@ -5405,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. @@ -5575,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 @@ -5640,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 @@ -5656,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: @@ -5738,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 @@ -5755,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: @@ -5874,3 +5757,201 @@ 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 TestTheLaneDecidesOwnership: + """Who runs everos is answered once, and nothing infers it again. + + 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 + 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_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, "discover", list) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("self")) + 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_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, "discover", list) + 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=[]) + + 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_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. + + 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 + + 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) + 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) + # 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. + + 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 + + mine = tmp_env.parent / "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_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" + + +class TestALeftoverRootIsOnlyTakenOverOnPurpose: + """A directory on disk cannot start a takeover by itself. + + 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_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 + + 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", + ) + leftover = _root_state(Path("/leftover/everos"), alive=False, lock_held=False) + monkeypatch.setattr(_discover_mod, "discover", lambda: [leftover]) + monkeypatch.setattr(questionary, "select", lambda *a, **kw: _Answer("skip")) + monkeypatch.setattr( + onboard_everos, + "_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=[]) + + 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_ 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 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