Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
latest_version: 3.4.11
latest_version: 3.4.12
released: 2026-07-12
---

Expand All @@ -10,6 +10,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

> **Versioning:** CLI version is tracked in workspace `Cargo.toml`. v3.x is the Rust port of [v2.x (TypeScript/Bun)](https://github.com/onebrain-ai/onebrain). `v3.0.0-alpha.1` is the first user-facing alpha (binary artifacts published to GitHub Releases for 7 platforms).

## [3.4.12] — 2026-07-12 — Self-healing: run smooth under a running daemon

Theme: no command, `serve`, or MCP call should error or fail to run because a daemon is (or isn't) holding the single-process redb lock.

- `token gain` works under a running daemon: default/`--by`/`--history`/`--reset` read the lock-free JSONL raw log; `--all-time`/`--since` route through the daemon's `/api/token/gain` — even across a **version skew**, so it works right after an upgrade without restarting the daemon ([#258](https://github.com/onebrain-ai/onebrain-cli/issues/258))
- A genuinely contended rollup open (or a daemon too old to serve the route) now reports the shared `E_ENGINE_BUSY` (exit 77) with an actionable hint, instead of a raw redb `Database already open` error at exit 1 ([#258](https://github.com/onebrain-ai/onebrain-cli/issues/258))
- `serve` now **reuses or starts** a daemon (restarting a stale/version-mismatched one) instead of an engine-less foreground standalone — so the Token-Gain dashboard is populated rather than dark; the explicit `--port`/`--dir` standalone escape hatch now also opens its token cache ([#257](https://github.com/onebrain-ai/onebrain-cli/issues/257), [#258](https://github.com/onebrain-ai/onebrain-cli/issues/258))
- `search vsearch` is daemon-routable: vector-only search routes through the daemon's new `/api/vault/search?mode=vec` instead of failing `E_ENGINE_BUSY` while an `onebrain mcp` session holds the engine ([#258](https://github.com/onebrain-ai/onebrain-cli/issues/258))
- Fixes: `doctor`'s scoped-key lookup no longer panics on empty segments; remove dead test code; migrate a `chrono` `DateTime::from_timestamp` deprecation (Copilot autoreview)

## [3.4.11] — 2026-07-12 — Token-opt seam fixes

Closes the three real seams that post-ship verification of v3.4.10 surfaced — none was caught by the epic's gates because no test exercised those exact cross-component paths — plus polish.
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ default-members = ["crates/*"]
resolver = "2"

[workspace.package]
version = "3.4.11"
version = "3.4.12"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["OneBrain Contributors"]
Expand Down
88 changes: 88 additions & 0 deletions crates/onebrain-cli/src/commands/daemon_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,42 @@ pub fn discover_matching(expected_vault: Option<&Path>) -> Result<Option<DaemonH
}
}

/// Adopt a same-vault LIVE daemon REGARDLESS of its version — for read routes
/// whose wire shape is frozen across versions (today: `GET /api/token/gain`,
/// which returns the version-stable [`onebrain_token::PivotResult`]).
///
/// Unlike [`discover_matching`] (which version-rejects → `None`) this returns
/// the handle even when the daemon's version differs from ours. Rationale: a
/// version-skewed same-vault daemon still holds `token.redb`'s exclusive lock
/// for its whole lifetime, so a Direct open would fail with the redb lock error
/// — the exact upgrade-without-restart gap (#258 Gap 4). Routing the read to its
/// stable route lets the query succeed WITHOUT restarting the daemon (that's
/// `ensure_running`/`mcp`'s job — a read verb must never kill another session's
/// daemon; the CLI read-verb convention). A too-old daemon whose route 404s is
/// handled by the caller's feature-detect. A DIFFERENT-vault daemon, a corrupt
/// record, or no record → `None` (nothing same-vault holds the lock → Direct).
pub fn discover_same_vault_any_version(
expected_vault: Option<&Path>,
) -> Result<Option<DaemonHandle>> {
let path = discovery_path()?;
let info = match DaemonInfo::read(&path) {
Ok(Some(info)) => info,
Ok(None) => return Ok(None),
Err(_) => {
let _ = DaemonInfo::remove(&path);
return Ok(None);
}
};
// Vault match still gates adoption (never touch another vault's daemon), but
// version is deliberately IGNORED here.
let expected = vault_expectation(expected_vault);
if vault_decision(info.vault.as_deref(), &expected) == VaultDecision::Restart {
return Ok(None);
}
let handle = DaemonHandle::new(info);
Ok(handle.is_live().then_some(handle))
}

/// Ensure a daemon is running at our version and return a connected handle.
///
/// Fast path: [`discover`] returns an existing live daemon. Otherwise spawn
Expand Down Expand Up @@ -1684,6 +1720,58 @@ mod tests {
assert_eq!(still.vault, srv.info.vault, "record must be left intact");
}

/// #258 Gap 4: `discover_same_vault_any_version` adopts a same-vault LIVE
/// daemon even when its version differs from ours (the upgrade-skew case),
/// while `discover_matching` version-rejects the very same record. A
/// different vault is still declined (never disturb another vault's daemon).
#[cfg(unix)]
#[test]
fn discover_same_vault_any_version_adopts_across_version_skew() {
let vault = tempdir().unwrap();
let cache = tempdir().unwrap();
let home = tempdir().unwrap();
let _env = crate::test_env::set_vars(&[
("ONEBRAIN_CACHE_DIR", cache.path().as_os_str()),
("HOME", home.path().as_os_str()),
]);
let mut srv = start_live_server(vault.path(), "dc-anyver-token-1234567890");
// Pin a version DIFFERENT from ours (the post-upgrade skew).
srv.info.version = "0.0.1-old".to_string();
let path = discovery_path().unwrap();
srv.info.write(&path).unwrap();

// Version-gated discovery rejects the skewed daemon …
assert!(
discover_matching(Some(vault.path())).unwrap().is_none(),
"discover_matching must version-reject the skewed daemon"
);
// … but the any-version variant adopts it (same vault, live).
assert!(
discover_same_vault_any_version(Some(vault.path()))
.unwrap()
.is_some(),
"any-version discovery must adopt a same-vault skewed daemon (Gap 4)"
);
// A DIFFERENT vault is still declined.
let other = tempdir().unwrap();
assert!(
discover_same_vault_any_version(Some(other.path()))
.unwrap()
.is_none(),
"must not adopt a different-vault daemon"
);
}

#[cfg(unix)]
#[test]
fn discover_same_vault_any_version_none_without_a_record() {
let home = tempdir().unwrap();
let _env = crate::test_env::set_var("HOME", home.path());
assert!(discover_same_vault_any_version(Some(home.path()))
.unwrap()
.is_none());
}

/// HEADLINE acceptance (Track 2c): with a daemon holding the engine, a
/// client can write a note, reindex it THROUGH the daemon, and then `get` /
/// `status` see it — i.e. in-session indexing works and CLI-during-session
Expand Down
8 changes: 5 additions & 3 deletions crates/onebrain-cli/src/commands/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -570,9 +570,11 @@ fn degrade_vec_error(has_lex: bool, result: anyhow::Result<Vec<Hit>>) -> anyhow:
}

/// The daemon `mode` string for a typed sub-query. The daemon's
/// `GET /api/vault/search` speaks only `lex` (BM25) or `hybrid` (lex + vector),
/// so a `vec`/`hyde` sub-query maps to `hybrid` (which embeds the query and
/// fuses with lex inside the daemon), and `lex` maps to `lex`.
/// `GET /api/vault/search` speaks `lex` (BM25), `hybrid` (lex + vector), and —
/// since v3.4.12 — `vec` (vector-only). The MCP `query` tool deliberately still
/// maps a `vec`/`hyde` sub-query to `hybrid` (which embeds the query and fuses
/// with lex inside the daemon for RRF), and `lex` to `lex`; the standalone
/// `vec` mode is what the CLI `search vsearch` verb routes through, not this.
fn daemon_mode_for(t: &SubQueryType) -> &'static str {
match t {
SubQueryType::Lex => "lex",
Expand Down
53 changes: 42 additions & 11 deletions crates/onebrain-cli/src/commands/search_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,21 +254,52 @@ fn rerank_unreranked_hint(
}
}

/// Route `vsearch` through a warm daemon that holds this vault's engine, if any
/// (#258 Gap 3). Returns `Some(result)` when it routed, `None` to fall through
/// to the direct engine open. Semantic build only — daemon search routing
/// (`route_to_daemon`/`run_query_via_daemon`) is compiled there; the lex-only
/// build has no vec route, so this always returns `None`.
#[cfg(feature = "semantic")]
fn try_vsearch_via_daemon(
resolved: &onebrain_core::ResolvedVault,
args: &SearchQueryArgs,
mode: &OutputMode,
) -> Option<Result<()>> {
route_to_daemon(resolved)
.map(|handle| run_query_via_daemon(&handle, resolved, "vec", args, mode))
}

#[cfg(not(feature = "semantic"))]
fn try_vsearch_via_daemon(
_resolved: &onebrain_core::ResolvedVault,
_args: &SearchQueryArgs,
_mode: &OutputMode,
) -> Option<Result<()>> {
None
}

/// `onebrain search vsearch` — vector-only semantic search. Also embeds the
/// query text (model-download point on first use).
///
/// NOT daemon-routable: the warm daemon's `/api/vault/search` exposes only
/// `lex` + `hybrid` (no vector-only mode), so vsearch always opens the engine
/// directly. When a daemon is holding the engine (an active `onebrain mcp`
/// session), this therefore degrades to T1's honest `E_ENGINE_BUSY` rather than
/// silently returning hybrid results under a different ranking — use `query`
/// (hybrid, daemon-routable) if you need results while a session is live. A
/// dedicated vec-only daemon endpoint is a follow-up (out of scope for 2c).
/// Daemon-routable since v3.4.12 (#258 Gap 3): when a warm daemon holds the
/// engine (an active `onebrain mcp` session), vsearch routes through its
/// `/api/vault/search?mode=vec` endpoint instead of degrading to
/// `E_ENGINE_BUSY`. Only when the daemon serves this exact vault; otherwise it
/// opens the engine directly as before (`$ONEBRAIN_NO_DAEMON` forces direct).
pub fn run_vsearch(
vault_flag: Option<PathBuf>,
mode: &OutputMode,
args: &SearchQueryArgs,
) -> Result<()> {
// Warm-daemon path: route vector-only search through a daemon holding this
// vault's engine, so it works WITHOUT hitting redb's single-writer lock.
// Semantic-build only (daemon search routing is compiled there); the lex-only
// build has no vec route and falls straight to the direct open below.
let resolved = crate::vault_ctx::require(vault_flag.clone())?;
if let Some(result) = try_vsearch_via_daemon(&resolved, args, mode) {
return result;
}

let (mut engine, resolved) = open_engine(vault_flag)?;
let raw_min = apply_rerank_overrides(&mut engine, args);
let vault_info = crate::vault_ctx::info_from(&resolved);
Expand Down Expand Up @@ -439,10 +470,10 @@ fn run_query_via_daemon(
// interpretation either.
rerank_unreranked_hint_data(&hits, rerank_enabled)
};
let command = if daemon_mode == "hybrid" {
"search.query"
} else {
"search.lex"
let command = match daemon_mode {
"hybrid" => "search.query",
"vec" => "search.vec",
_ => "search.lex",
};
emit_hits_data(
command,
Expand Down
Loading
Loading