Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1696262
fix(plugin): locate everos next to the interpreter before falling bac…
gloryfromca Aug 13, 2026
e11684b
fix(*): start the everos server at its configured address, not the de…
gloryfromca Aug 13, 2026
2873d36
fix(plugin): refuse to spawn an everos server with no memory LLM cred…
gloryfromca Aug 13, 2026
d4013d4
fix(plugin): stop waiting on an everos server that has already exited
gloryfromca Aug 13, 2026
14a24b1
fix(cli): stop treating a failed everos start as a decision to drop m…
gloryfromca Aug 13, 2026
0420eb2
fix(plugin): tell the user when a session is waiting on the memory se…
gloryfromca Aug 13, 2026
9dfa793
feat(config): make the everos root and its ownership recorded decisions
gloryfromca Aug 13, 2026
0c30de6
feat(plugin): discover the everos roots on the machine instead of ass…
gloryfromca Aug 13, 2026
41c23e3
feat(cli): reuse an existing everos in onboarding instead of always s…
gloryfromca Aug 13, 2026
d6dd110
test(plugin): exercise the four OS-touching helpers without mocks
gloryfromca Aug 13, 2026
824470b
fix(*): stop the wizard adopting a root the user declined to share
gloryfromca Aug 14, 2026
f0b9a26
fix(cli): finish the port convergence instead of leaving the service …
gloryfromca Aug 14, 2026
2c4e0d6
test(cli): cover the convergence branches that had none
gloryfromca Aug 14, 2026
b68b1ae
chore(tests): apply ruff format to the new convergence cases
gloryfromca Aug 14, 2026
3aac624
fix(plugin): cut the everos startup wait from 30s to 10s
gloryfromca Aug 14, 2026
9cbe02e
docs(plugin): drop the stale 30s from two everos docstrings
gloryfromca Aug 14, 2026
bb236a4
feat(cli): let doctor answer where the memories are
gloryfromca Aug 14, 2026
f9a2939
feat(plugin): say why a health probe failed, and ask the OS who holds…
gloryfromca Aug 16, 2026
6d1a6b3
feat(plugin): keep memory usable across a service that comes and goes
gloryfromca Aug 16, 2026
a0c9a26
fix(agent): stop the turn waiting on plugin-side indexing
gloryfromca Aug 16, 2026
6d751ea
refactor(plugin): stop discovering an everos the user runs
gloryfromca Aug 16, 2026
68efb58
feat(cli): two ways to get long-term memory, chosen rather than inferred
gloryfromca Aug 16, 2026
16bf8e2
chore(tests): restore blank-line spacing at the rebase seam
gloryfromca Aug 16, 2026
9ea2611
fix(config): keep the legacy everos root out of a relocated installation
gloryfromca Aug 16, 2026
d66de3b
fix(importer): stop an unavailable memory service from reading as a c…
gloryfromca Aug 16, 2026
e72ca87
fix(cli): record the intended port, and retract the root when it stop…
gloryfromca Aug 16, 2026
340f40b
fix(plugin): budget writes by the caller, and report a child that alr…
gloryfromca Aug 16, 2026
d629bba
test(plugin): pin the /proc/locks format against a live linux capture
gloryfromca Aug 17, 2026
8263119
feat(cli): offer a different port when the intended one is taken
gloryfromca Aug 17, 2026
ba2d4f9
fix(cli): a refused self-managed address ends the step instead of fal…
gloryfromca Aug 17, 2026
850e218
feat(cli): offer a retype when a self-managed address does not answer
gloryfromca Aug 17, 2026
74e02a5
chore(tests): sort the imports the retype cases added
gloryfromca Aug 17, 2026
dcb3cfa
fix(cli): say nothing extra after the user skips
gloryfromca Aug 17, 2026
e97a7da
fix(cli): reconfiguring restarts the service that has to read the new…
gloryfromca Aug 17, 2026
d6d3a95
fix(cli): stop the process the lock named, not the one the pidfile re…
gloryfromca Aug 17, 2026
aa59b3d
fix(cli): keep the intent and the current address apart
gloryfromca Aug 17, 2026
a51c181
test(plugin): cover a session picking up a service that arrives late
gloryfromca Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions raven/agent/loop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@
from raven.tracing import semconv, trace
from raven.utils.helpers import estimate_prompt_tokens, is_image_part, is_inline_image

# How long a turn is willing to wait on plugin-side indexing before letting the
# write finish on its own. A budget, not a deadline: the task keeps running.
_STORE_TURN_BUDGET_S: float = 5.0
# Outstanding detached writes past which a turn waits for one to land, so a slow
# memory service cannot grow an unbounded queue behind a fast typist.
_STORE_MAX_INFLIGHT: int = 4
# Teardown's total budget for letting those writes finish.
_STORE_DRAIN_BUDGET_S: float = 15.0

_ABORTED_ACTION_REPLY = (
"The operation was not completed, and no alternative method will be attempted. "
"Would you like me to continue with the remaining parts of the task that do not "
Expand Down Expand Up @@ -427,6 +436,9 @@ def __init__(
# pipeline unchanged. See ``_dispatch_backend_store`` for the call
# site that consumes it.
self.backend: "MemoryBackend | None" = backend
# Writes that outran their turn budget and are still running. Held so
# teardown can drain them instead of dropping whatever was slowest.
self._store_inflight: set[asyncio.Task] = set()

# Tools contributed by activated plugins; registered into the
# ToolRegistry by ``_register_default_tools``.
Expand Down Expand Up @@ -1299,13 +1311,41 @@ async def _dispatch_backend_store(
return
if not messages_slice:
return
try:
await self.backend.store(session_key, messages_slice)
except Exception:
logger.exception(
"backend.store failed for session {}; turn data preserved in session log, plugin-side indexing skipped",
session_key,
)

async def _store() -> None:
try:
await self.backend.store(session_key, messages_slice) # type: ignore[union-attr]
except Exception:
logger.exception(
"backend.store failed for session {}; turn data preserved in session log, "
"plugin-side indexing skipped",
session_key,
)

task = asyncio.create_task(_store())
self._store_inflight.add(task)
task.add_done_callback(self._store_inflight.discard)
if len(self._store_inflight) > _STORE_MAX_INFLIGHT:
# Backpressure rather than unbounded growth: a service slow enough
# to accumulate this many outstanding writes is one whose queue
# should stop growing, not one to keep feeding.
await asyncio.wait(set(self._store_inflight), return_when=asyncio.FIRST_COMPLETED)
# Deliberately not cancelled on timeout: the point is to stop *waiting*,
# not to abandon the write. A turn that indexes quickly still does so
# inline, which keeps ordering intact in the common case.
await asyncio.wait({task}, timeout=_STORE_TURN_BUDGET_S)

async def drain_backend_stores(self, timeout: float = _STORE_DRAIN_BUDGET_S) -> None:
"""Let detached writes finish before the process goes away.

Writes that outran their turn budget are still in flight. Exiting on top
of them loses exactly the turns that were slowest to index, which is a
silent and biased kind of data loss.
"""
pending = {t for t in self._store_inflight if not t.done()}
if not pending:
return
await asyncio.wait(pending, timeout=timeout)

def _collect_injected_skill_ids(
self,
Expand Down
3 changes: 3 additions & 0 deletions raven/cli/agent_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,9 @@ async def run_once():
finally:
if backend is not None:
try:
# Detached indexing writes first: stopping the backend
# closes the HTTP client they still need.
await agent_loop.drain_backend_stores()
await backend.stop()
except Exception:
logger.exception(
Expand Down
17 changes: 16 additions & 1 deletion raven/cli/doctor_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ class MemoryInfo:
"""

backend: Optional[str] = None
root: Optional[str] = None
owned: bool = True
address: Optional[str] = None
server_running: bool = False
reports_capabilities: bool = False
configured: list[str] = field(default_factory=list)
Expand Down Expand Up @@ -223,14 +226,22 @@ def _probe_memory(config: "RavenConfig") -> MemoryInfo:
info = MemoryInfo(backend=backend)
if backend != "everos":
return info
from raven.config.update_everos import everos_role_configured
from raven.config.update_everos import everos_owned, everos_role_configured, everos_root
from raven.plugin.memory.everos._health import (
DEGRADING_SECTIONS,
REQUIRED_SECTIONS,
configured_base_url,
probe_capabilities,
)

# Which memories, and whose. Neither was reachable from any command before:
# the wizard printed the path once while converging and nothing showed it
# again, so "where are my memories" had no answer short of reading
# config.json by hand. This is the place that question gets asked.
info.root = str(everos_root())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] raven doctor fabricates a memory root path for the new self-managed EverOS setup.

_use_self_managed_everos() (raven/cli/onboard_everos.py:1374) deliberately records only {"owned": False, "base_url": ...} — no root — for exactly this reason (its own docstring, onboard_everos.py:1346): "Without a root there is no path on disk a later change could write to by accident."

But everos_root() (raven/config/update_everos.py:129) always returns a path — when no root is recorded it falls back to fallback_everos_root(), i.e. <data dir>/everos or the legacy ~/.everos/raven. info.root = str(everos_root()) here has no guard for the "owned=False, no root recorded" case, so for a self-managed setup it prints a fabricated, unrelated local path next to "Managed by you -- Raven reads this one and never writes or restarts it" (raven/cli/doctor_commands.py:272-274). That directory typically doesn't even exist (it's never created for an unowned root), and it has nothing to do with where the user's actual EverOS data lives — the opposite of what this PR's self-managed path promises.

Repro: raven onboard -> step 4 -> "I run my own EverOS" -> a reachable address -> raven doctor. Memories: prints the default/legacy raven-owned path even though nothing was ever recorded there.

Confirmed this combination has no test coverage: both new doctor tests (tests/test_cli_doctor_commands.py::test_doctor_answers_where_the_memories_are and ::test_doctor_says_when_the_memories_are_not_ravens_to_touch) stub everos_root directly to a fake path in both the owned and unowned cases, so neither exercises everos_root()'s real fallback behavior when a self-managed slice has no root key at all.

Fix direction: in _probe_memory, only set info.root when a root is actually recorded (e.g. everos_owned() or "root" in <raw slice>), and have _render_memory_capabilities describe the unowned-no-root case by address only (which it already prints via info.address) instead of a fabricated "Memories:" path.

Fix this →

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker, unresolved from the 20:23 review] info.root = str(everos_root()) is still unconditional. No commit since that review has touched doctor_commands.py (git log -- raven/cli/doctor_commands.py stops at bb236a4).

For a self-managed install (_use_self_managed_everos, onboard_everos.py:1436), owned=False and root/port are explicitly removed from the recorded slice (remove=("root", "port"), added in e72ca87). everos_root() (update_everos.py:159) then falls back to fallback_everos_root(), i.e. a fabricated raven-owned path unrelated to the actual server, printed here next to "Managed by you -- Raven reads this one and never writes or restarts it." Still reproduces: raven onboard -> step 4 -> "I run my own EverOS" -> raven doctor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] For a self-managed install this prints a root Raven has nothing to do with, and then reports the user's server as having no roles configured.

_use_self_managed_everos (onboard_everos.py:1489) records {"owned": False, "base_url": ...} with remove=("root", "port"), and its docstring says why: "Without a root there is no path on disk for a later change to write to by accident, which turns the read-only promise from a convention into something the code cannot break." everos_root() (update_everos.py) has no such option -- with nothing recorded it returns fallback_everos_root(), which is always a path.

Reproduced on this branch, with a config written exactly the way that path writes it:

config.json: {"memory": {"backend": "everos"},
              "plugins": {"config": {"everos-memory": {"owned": false,
                                                       "base_url": "http://127.0.0.1:9000"}}}}

everos_root()          -> <data dir>/everos          # does not exist
everos_owned()         -> False
toml doctor reads      -> <data dir>/everos/everos.toml   exists: False
info.root              = <data dir>/everos
info.configured        = []
info.retrieval         = keyword-only

So raven doctor prints:

  Memories:   <data dir>/everos
  Managed by you -- Raven reads this one and never writes or restarts it.
  Address:    http://127.0.0.1:9000

Three things are wrong at once, and the second is worse than the path:

  1. Memories: names a directory that does not exist and that the user does not manage -- and on a default install with an old ~/.everos/raven it names Raven's own legacy root instead, i.e. the wrong real directory rather than an absent one. The Managed by you line then attributes it to the user.
  2. info.configured comes from everos_role_configured -> load_everos_config() -> the same wrong root, so it is [] regardless of what the user's server runs. info.retrieval is therefore always keyword-only, and once the server is up and reporting capabilities, _render_memory_capabilities short-circuits on if section not in memory.configured and prints not configured for every role -- including ones /health just reported as available.
  3. The net effect is that the one command that exists to answer "where are my memories and what can they do" gives a confidently wrong answer for the setup this PR adds.

The two tests covering this stub everos_root to return a path (no_memory_server.setattr(ue, "everos_root", lambda: tmp_path / "theirs")), which is the one thing the real self-managed path never produces -- that is why the suite is green over it.

A fix that matches the design: make the recorded slice the source of truth here (root present -> show it; absent -> say the root is the user's and not recorded, and skip the role lines rather than deriving them from a toml that is not the one in use). Reading roles off everos.toml is only meaningful for an owned root anyway; for an unowned one, probe_capabilities already has the answer.

info.owned = everos_owned()
info.address = configured_base_url(config)

info.configured = [s for s in (*REQUIRED_SECTIONS, *DEGRADING_SECTIONS) if everos_role_configured(s)]
# Recall quality is decided by the embedding role in the user-level
# everos.toml: with it recall matches meaning, without it only keywords.
Expand Down Expand Up @@ -261,6 +272,10 @@ def _render_memory_capabilities(memory: MemoryInfo) -> None:

if memory.backend != "everos":
return
console.print(f" Memories: {memory.root}")
if not memory.owned:
console.print(" [dim]Managed by you -- Raven reads this one and never writes or restarts it.[/dim]")
console.print(f" Address: {memory.address}")
if not memory.server_running:
console.print(" Server: [dim]not running (starts on demand)[/dim]")
if memory.configured:
Expand Down
1 change: 1 addition & 0 deletions raven/cli/gateway_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ async def _do_restart() -> None:
# spawned during AgentLoop teardown can complete.
if backend is not None:
try:
await agent.drain_backend_stores()
await backend.stop()
except Exception:
_logger.exception(
Expand Down
39 changes: 30 additions & 9 deletions raven/cli/import_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,32 @@ class ImportRunResult:
skill_error: str = ""


def _require_memory_service_ready(backend: object) -> None:
"""Refuse to import when the memory service is not actually there.

``backend.start()`` no longer raises: a session that cannot reach EverOS
degrades and keeps probing, which is right for a session and wrong here.
An import is one deliberate batch, and running it against nothing writes
nothing while consuming the source list.

Backends that do not report a state -- anything other than the everos one
-- are left alone rather than locked out.
"""
state = getattr(backend, "_state", None)
if state is None:
return
from raven.plugin.memory.everos.backend import ServiceState

if state is ServiceState.READY:
return
from raven.plugin.memory.everos._server import server_log_path

console.print(f"[red]Memory service is not available ({state.value}); nothing would be imported.[/red]")
console.print(f"[dim]Check the server log: {server_log_path()}[/dim]")
console.print("[dim]Retry: raven import run[/dim]")
raise typer.Exit(1)


async def _build_and_run(
items: list[tuple[Scanner, ScanResult]],
state: ImportState,
Expand All @@ -118,15 +144,10 @@ async def _build_and_run(
)
raise typer.Exit(1)

try:
await backend.start()
except Exception as e:
from raven.plugin.memory.everos._server import server_log_path

console.print(f"[red]Failed to start EverOS memory server: {e}[/red]")
console.print(f"[dim]Check the server log: {server_log_path()}[/dim]")
console.print("[dim]Retry: raven import run[/dim]")
raise typer.Exit(1)
await backend.start()
# Asked after start rather than caught around it: start reports through the
# backend's state now, so an except here would never fire.
_require_memory_service_ready(backend)
try:
summary = await run_import(items, backend, state, on_progress=on_progress, cancel_path=cancel_path)
# Both phases below are additive and run after the EverOS pass, so a
Expand Down
Loading
Loading