From 104af3abb9e626c5d00ea87d523248d606d523ee Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 28 Aug 2026 14:19:16 +0700 Subject: [PATCH] fix(agent): semantic_search symbol index workspace-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SymbolIndex global sudah melacak workspace_path tapi SemanticSearch dan ListSymbols Cuma rebuild saat index kosong (is_empty). Akibat: setelah mengindeks workspace A, mencari di workspace B diam-diam mengembalikan simbol STALE dari A — menyesatkan coding agent (referensikan simbol yang tidak ada di repo aktif). Fix: - Tambah SymbolIndex::needs_rebuild(workspace) — true bila index kosong ATAU workspace diminta beda dari yang ter-cache. - Pakai di 2 call site (SemanticSearch::run, ListSymbols::run) menggantikan is_empty(), jadi pindah workspace otomatis trigger rebuild. - test: +1 (test_needs_rebuild_workspace_aware — verifikasi flip workspace memicu rebuild bolak-balik A -> B -> A). Catatan (bukan bug, dilaporkan): mutex SYMBOL_INDEX masih dipegang selama full rebuild di run() — bottleneck saat semantic_search dipanggil paralel; perbaikan butuh restrukturisasi double-checked rebuild, tak diubah di sini. Verifikasi: check/clippy -D warnings/fmt clean; test infra 63 (0 gagal). --- Cargo.lock | 22 ++++----- .../src/tools/semantic_search.rs | 49 ++++++++++++++++++- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0198f45..84d1c83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4862,7 +4862,7 @@ dependencies = [ [[package]] name = "zesdex-api" -version = "1.20.1" +version = "1.20.2" dependencies = [ "anyhow", "argon2", @@ -4885,7 +4885,7 @@ dependencies = [ [[package]] name = "zesdex-application" -version = "1.20.1" +version = "1.20.2" dependencies = [ "anyhow", "base64", @@ -4903,7 +4903,7 @@ dependencies = [ [[package]] name = "zesdex-bootstrap" -version = "1.20.1" +version = "1.20.2" dependencies = [ "anyhow", "chrono", @@ -4920,7 +4920,7 @@ dependencies = [ [[package]] name = "zesdex-daemon" -version = "1.20.1" +version = "1.20.2" dependencies = [ "anyhow", "base64", @@ -4944,7 +4944,7 @@ dependencies = [ [[package]] name = "zesdex-domain" -version = "1.20.1" +version = "1.20.2" dependencies = [ "anyhow", "base64", @@ -4960,7 +4960,7 @@ dependencies = [ [[package]] name = "zesdex-gateway" -version = "1.20.1" +version = "1.20.2" dependencies = [ "anyhow", "axum", @@ -4987,7 +4987,7 @@ dependencies = [ [[package]] name = "zesdex-grpc" -version = "1.20.1" +version = "1.20.2" dependencies = [ "anyhow", "axum", @@ -5004,7 +5004,7 @@ dependencies = [ [[package]] name = "zesdex-infrastructure" -version = "1.20.1" +version = "1.20.2" dependencies = [ "anyhow", "argon2", @@ -5052,7 +5052,7 @@ dependencies = [ [[package]] name = "zesdex-tui" -version = "1.20.1" +version = "1.20.2" dependencies = [ "anyhow", "base64", @@ -5078,7 +5078,7 @@ dependencies = [ [[package]] name = "zesdex-web" -version = "1.20.1" +version = "1.20.2" dependencies = [ "anyhow", "axum", @@ -5098,7 +5098,7 @@ dependencies = [ [[package]] name = "zesdex-ws" -version = "1.20.1" +version = "1.20.2" dependencies = [ "anyhow", "axum", diff --git a/apps/infrastructure/src/tools/semantic_search.rs b/apps/infrastructure/src/tools/semantic_search.rs index 55c1e2b..7b92113 100644 --- a/apps/infrastructure/src/tools/semantic_search.rs +++ b/apps/infrastructure/src/tools/semantic_search.rs @@ -270,6 +270,17 @@ impl SymbolIndex { self.symbols.is_empty() } + /// Returns true when the cached index must be rebuilt for the given + /// workspace — either because nothing has been indexed yet, or because the + /// requested workspace differs from the one the index was built for. + /// + /// Without this, searching a *different* workspace after the first one + /// silently returns stale symbols from the previously indexed repo + /// (a misleading result for a coding agent). + pub fn needs_rebuild(&self, workspace: &str) -> bool { + self.is_empty() || self.workspace_path.as_deref() != Some(workspace) + } + pub fn len(&self) -> usize { self.symbols.len() } @@ -1255,7 +1266,7 @@ impl Tool for SemanticSearch { .map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?; let index = guard.get_or_insert_with(SymbolIndex::new); - if rebuild || index.is_empty() { + if rebuild || index.needs_rebuild(&workspace) { let count = index.rebuild(&workspace)?; debug!(symbol_count = count, "symbol index rebuilt"); } @@ -1502,7 +1513,7 @@ impl Tool for ListSymbols { .map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?; let index = guard.get_or_insert_with(SymbolIndex::new); - if rebuild || index.is_empty() { + if rebuild || index.needs_rebuild(&workspace) { let count = index.rebuild(&workspace)?; info!(symbol_count = count, "symbol index rebuilt for list"); } @@ -1746,4 +1757,38 @@ mod tests { let index = SymbolIndex::new(); assert!(index.search("anything", 10).is_empty()); } + + #[test] + fn test_needs_rebuild_workspace_aware() { + let ws_a = std::env::temp_dir().join(format!("ws_a_{}", uuid::Uuid::new_v4())); + let ws_b = std::env::temp_dir().join(format!("ws_b_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&ws_a).unwrap(); + std::fs::create_dir_all(&ws_b).unwrap(); + std::fs::write(ws_a.join("a.rs"), "pub fn fn_in_a() {}\n").unwrap(); + std::fs::write(ws_b.join("b.rs"), "pub fn fn_in_b() {}\n").unwrap(); + + let mut index = SymbolIndex::new(); + let a = ws_a.to_string_lossy().to_string(); + let b = ws_b.to_string_lossy().to_string(); + + // Fresh index: needs rebuild for any workspace. + assert!(index.needs_rebuild(&a)); + + // After rebuilding A, searching A needs no rebuild... + index.rebuild(&a).unwrap(); + assert!(!index.needs_rebuild(&a)); + // ...but searching B DOES (stale index otherwise). + assert!( + index.needs_rebuild(&b), + "workspace switch must trigger rebuild" + ); + + // Rebuilding B flips the cached workspace. + index.rebuild(&b).unwrap(); + assert!(!index.needs_rebuild(&b)); + assert!(index.needs_rebuild(&a)); + + std::fs::remove_dir_all(&ws_a).ok(); + std::fs::remove_dir_all(&ws_b).ok(); + } }