From 8231a732e61675d3027e251eb70b25eafedcf2a0 Mon Sep 17 00:00:00 2001 From: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 14:36:58 -0400 Subject: [PATCH] fix(projects): fail closed on bounded discovery Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/pool.rs | 55 ++++++++++++++++++++---- crates/buzz-cli/src/commands/projects.rs | 37 ++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index d2ec62a982..0f154faad6 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -635,13 +635,13 @@ impl ChannelInfoResolver { let fetched = match fetch_project_home_for_channel(channel_id, &self.rest_client).await { Ok(fetched) => fetched, Err(error) => { - if let Some(stale) = cached { + if let Some(project) = cached.and_then(|stale| stale.value) { tracing::warn!( channel_id = %channel_id, - "project context refresh failed; retaining stale value: {}", + "project context refresh failed; retaining stale project: {}", error.0 ); - return Ok(stale.value); + return Ok(Some(project)); } return Err(error); } @@ -8563,7 +8563,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" } #[tokio::test] - async fn failed_project_lookup_through_resolve_cannot_become_ordinary_context() { + async fn failed_refresh_rejects_expired_absence_but_retains_expired_project() { use std::sync::atomic::Ordering; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -8602,15 +8602,54 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" }, ); + resolver.projects.write().unwrap().insert( + id, + CachedProjectInfo { + fetched_at: std::time::Instant::now() - PROJECT_INFO_CACHE_TTL, + value: None, + }, + ); assert!( resolver.resolve(id).await.is_none(), - "indeterminate project discovery must suppress ordinary-channel context" + "an expired absence plus failed refresh must suppress ordinary-channel context" ); assert!( - !resolver.projects.read().unwrap().contains_key(&id), - "an indeterminate lookup must not become cached absence" + resolver + .projects + .read() + .unwrap() + .get(&id) + .unwrap() + .value + .is_none(), + "failed refresh must not renew the expired absence" + ); + + let stale_project = PromptProjectInfo { + name: "Last known project".into(), + slug: "last-known".into(), + owner: "a".repeat(64), + coordinate: format!("30621:{}:last-known", "a".repeat(64)), + default_repo_owner: None, + default_repo_id: None, + }; + resolver.projects.write().unwrap().insert( + id, + CachedProjectInfo { + fetched_at: std::time::Instant::now() - PROJECT_INFO_CACHE_TTL, + value: Some(stale_project.clone()), + }, + ); + let resolved = resolver + .resolve(id) + .await + .expect("stale project is retained"); + assert_eq!(resolved.project, Some(stale_project)); + assert_eq!( + requests.load(Ordering::SeqCst), + 4, + "each refresh is retried once" ); - assert_eq!(requests.load(Ordering::SeqCst), 2, "one retry is attempted"); server.abort(); } diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index d58c7aa18f..bee8cf8029 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -81,6 +81,7 @@ pub(crate) async fn fetch_projects_for_channel( ) -> Result, CliError> { let filter = serde_json::json!({ "kinds": [KIND_PROJECT], + "#buzz-channel": [channel], }); let events: Vec = client .query_all_bounded(filter, PROJECT_QUERY_EVENT_BOUND) @@ -933,6 +934,42 @@ mod tests { const OWNER_HEX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const OWNER_B_HEX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + #[tokio::test] + async fn project_lookup_scopes_the_production_query_before_the_global_bound() { + use std::sync::{Arc, Mutex}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let channel = "11111111-1111-4111-8111-111111111111"; + let request_body = Arc::new(Mutex::new(None)); + let captured_body = request_body.clone(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = vec![0; 65_536]; + let read = socket.read(&mut buf).await.unwrap(); + let request = String::from_utf8_lossy(&buf[..read]); + let body = request.split("\r\n\r\n").nth(1).unwrap().to_owned(); + *captured_body.lock().unwrap() = Some(body); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]", + ) + .await + .unwrap(); + }); + let client = + crate::client::BuzzClient::new(base_url, nostr::Keys::generate(), None, None).unwrap(); + + let projects = fetch_projects_for_channel(&client, channel).await.unwrap(); + assert!(projects.is_empty()); + server.await.unwrap(); + let body: serde_json::Value = + serde_json::from_str(request_body.lock().unwrap().as_deref().unwrap()).unwrap(); + assert_eq!(body[0]["#buzz-channel"], serde_json::json!([channel])); + assert_eq!(body[0]["kinds"], serde_json::json!([KIND_PROJECT])); + } + #[test] fn project_channel_matching_ignores_unrelated_claims() { let expected = "11111111-1111-4111-8111-111111111111";