Skip to content

Wire the agent's embedding service by construction instead of re-injecting it per engine swap - #1335

Merged
malibio merged 1 commit into
mainfrom
worktree-issue-1328-wire-embedding-handle
Jun 5, 2026
Merged

malibio merged 1 commit into
mainfrom
worktree-issue-1328-wire-embedding-handle

Conversation

@malibio

@malibio malibio commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #1328

…loses #1328)

GraphToolExecutor previously captured a snapshot of the embedding service
at construction. The service loads in a background task after daemon
startup, so an executor built before embeddings finished was stuck with
`None` until the next engine swap — disabling search_semantic and
skill-based select_tools routing (the full tool list was always sent).
Two construction sites had to remember to re-pass the service; one passed
`None`.

Make the executor hold the shared `Arc<RwLock<Option<Arc<NodeEmbeddingService>>>>`
handle (new `SharedEmbeddingService` alias, expressible in the agent crate
with no daemon dependency) and read it per call. The executor now always
sees the current value, so a turn run after embeddings load uses semantic
search and skill routing with no engine swap, and no construction site can
wire a stale or `None` service.

- agent/tools.rs: field type → shared handle; read live in
  exec_search_semantic, exec_search_skills, select_tools, and the
  embedding_service() accessor (now async). Deleted the unused new /
  new_with_optional_services constructors. Added a test asserting the
  executor holds the shared handle (Arc::ptr_eq), not a snapshot.
- daemon/local_agent_service.rs: build_noop_service and replace_engine
  pass the shared handle directly; dropped the snapshot read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@malibio

malibio commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review — PR #1335 (issue #1328)

Review Type: Initial review · Rust-only change (2 files) · race-condition fix for embedding-service wiring

Requirements Check

Acceptance Criterion Status
Executor can never be wired with a stale or None embedding service when one is/becomes available ✅
Startup race (chat model loaded before embeddings) closed — no engine swap needed ✅
search_semantic + skill-based select_tools work when chat model loaded first ✅
No remaining construction site can pass embedding_service: None when the service exists ✅
bun run quality:fix passes ✅ (compiles clean)
Tests pass (test:all) ✅ (all Rust crates pass; one frontend perf benchmark flaked under parallel contention, passes in isolation — unrelated to this Rust-only change)

Verification performed

  • Confirmed embedding_svc_state in main.rs:297 is the SAME Arc<RwLock<..>> passed to LocalAgentServiceImpl::new and cloned into the bg loader (main.rs:303,307); loader publishes None→Some at main.rs:425. The shared-handle claim holds end-to-end.
  • All 4 GraphToolExecutor construction sites (daemon build_noop_service, replace_engine, + 2 tests) now pass the shared handle. Old new / new_with_optional_services constructors deleted with zero dangling references — clean greenfield removal, no compat cruft.
  • cargo build -p nodespace-agent -p nodespace-daemon — clean.
  • embedding_handle_is_shared_not_snapshotted test — passes.

Findings

🟢 Locking is correct — no guard held across .await

In select_tools (tools.rs:1422), exec_search_skills (tools.rs:1164), and the embedding_service() accessor (tools.rs:1377), the pattern self.embedding_service.read().await.clone() drops the RwLockReadGuard at the end of the scrutinee expression and carries only the cloned Arc across the subsequent find_skills().await. No read lock is held across an await point, so there is no deadlock or writer-starvation risk against the bg loader's write().await. Confirmed against find_skills's &Arc<NodeEmbeddingService> signature.

🟢 build_workspace_context path also reads live (consistency, no defect)

local_agent_service.rs:334 independently snapshots the service per-turn via self.inner.embedding_service.read().await.clone() before each turn's context build, so that path is also race-free (reads at turn time, not construction time). It deliberately stays a per-turn Option snapshot rather than adopting the shared handle — acceptable because a single turn's context is a point-in-time read. Noting only so a future reader doesn't mistake it for a missed site.

🟢 Test asserts the structural guarantee well

embedding_handle_is_shared_not_snapshotted can't build a real NodeEmbeddingService, so it asserts Arc::ptr_eq(&handle, &executor.embedding_service) plus the live-empty error paths through both the accessor and the search_skills tool. This is the right altitude for a structural invariant — a None→Some write through the shared handle is observed by construction. A full None→Some-then-succeeds integration test would need an NLP engine and belongs in a higher tier.

🟢 Documentation is intent-focused

The SharedEmbeddingService type doc and the rewritten replace_engine comment explain why (the startup race, no engine-swap) rather than restating mechanics. Good.

Nit

  • 🟢 tools.rs:1164 / tools.rs:1422 share the identical match self.embedding_service.read().await.clone() { Some.. None.. } preamble with slightly different fallbacks (error result vs. full tool list). Not worth a helper given the divergent None-branches, but if a third call site appears, consider an async fn current_embedding(&self) -> Option<Arc<..>> to centralize the read.

Recommendation

APPROVE — The change is a clean net improvement that directly closes the documented startup race. The snapshot→shared-handle refactor is the correct design (SRP: the executor reads current state instead of owning a stale copy), locking is sound, dead constructors are fully removed per greenfield rules, and the new test pins the invariant. No blocking or improvement-level issues found.

🤖 Generated with Claude Code

@malibio malibio left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed via /pragmatic-code-review — APPROVE. Race fix is correct, locking is sound (no guard held across await), dead constructors fully removed, new test pins the shared-handle invariant. Full findings posted as a PR comment.

@malibio
malibio merged commit f2403ee into main Jun 5, 2026
@malibio
malibio deleted the worktree-issue-1328-wire-embedding-handle branch June 5, 2026 15:05
malibio added a commit that referenced this pull request Jun 22, 2026
…loses #1328) (#1335)

GraphToolExecutor previously captured a snapshot of the embedding service
at construction. The service loads in a background task after daemon
startup, so an executor built before embeddings finished was stuck with
`None` until the next engine swap — disabling search_semantic and
skill-based select_tools routing (the full tool list was always sent).
Two construction sites had to remember to re-pass the service; one passed
`None`.

Make the executor hold the shared `Arc<RwLock<Option<Arc<NodeEmbeddingService>>>>`
handle (new `SharedEmbeddingService` alias, expressible in the agent crate
with no daemon dependency) and read it per call. The executor now always
sees the current value, so a turn run after embeddings load uses semantic
search and skill routing with no engine swap, and no construction site can
wire a stale or `None` service.

- agent/tools.rs: field type → shared handle; read live in
  exec_search_semantic, exec_search_skills, select_tools, and the
  embedding_service() accessor (now async). Deleted the unused new /
  new_with_optional_services constructors. Added a test asserting the
  executor holds the shared handle (Arc::ptr_eq), not a snapshot.
- daemon/local_agent_service.rs: build_noop_service and replace_engine
  pass the shared handle directly; dropped the snapshot read.

Co-authored-by: Michael Libio <malibio@Michaels-Mac-mini.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wire the agent's embedding service by construction instead of re-injecting it per engine swap

1 participant