Skip to content

Commit 8ace3d3

Browse files
fix(desktop): serialize provider attestation
Signed-off-by: Joel Robotham <jrobotham@squareup.com>
1 parent 08073b9 commit 8ace3d3

3 files changed

Lines changed: 172 additions & 21 deletions

File tree

desktop/src-tauri/src/app_state.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ pub struct AppState {
4747
pub managed_agents_store_lock: Mutex<()>,
4848
pub channel_templates_store_lock: Mutex<()>,
4949
pub managed_agent_processes: Mutex<HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>>,
50-
pub provider_deploy_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
50+
/// Serializes provider operations per agent. Registration-custody attest
51+
/// retries must not persist completions out of order, and deploy already
52+
/// needs the same per-agent fence before rebuilding its payload.
53+
pub provider_operation_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
5154
pub huddle_state: Mutex<HuddleState>,
5255
pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState,
5356
/// Tauri app handle — stored after setup so huddle commands can emit
@@ -223,7 +226,7 @@ pub fn build_app_state() -> AppState {
223226
managed_agents_store_lock: Mutex::new(()),
224227
channel_templates_store_lock: Mutex::new(()),
225228
managed_agent_processes: Mutex::new(HashMap::new()),
226-
provider_deploy_locks: Mutex::new(HashMap::new()),
229+
provider_operation_locks: Mutex::new(HashMap::new()),
227230
session_config_cache: Mutex::new(HashMap::new()),
228231
huddle_state: Mutex::new(HuddleState::default()),
229232
huddle_audio: Default::default(),

desktop/src-tauri/src/commands/agents/provider_deploy.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ pub(crate) async fn deploy_to_provider(
5353
) -> Result<(), String> {
5454
let deploy_lock = {
5555
let mut locks = state
56-
.provider_deploy_locks
56+
.provider_operation_locks
5757
.lock()
5858
.map_err(|error| error.to_string())?;
5959
Arc::clone(

desktop/src-tauri/src/commands/agents/provider_registration.rs

Lines changed: 166 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
//! Capability-gated provider registration and NIP-OA activation.
22
3+
use std::{future::Future, sync::Arc};
4+
35
use tauri::AppHandle;
46

57
use crate::{
@@ -100,8 +102,53 @@ fn attestation_agent(
100102
})
101103
}
102104

103-
fn attestation_pending_after(was_pending: bool, succeeded: bool) -> bool {
104-
was_pending && !succeeded
105+
fn provider_operation_lock(
106+
state: &AppState,
107+
pubkey: &str,
108+
) -> Result<Arc<tokio::sync::Mutex<()>>, String> {
109+
let mut locks = state
110+
.provider_operation_locks
111+
.lock()
112+
.map_err(|error| error.to_string())?;
113+
Ok(Arc::clone(
114+
locks
115+
.entry(pubkey.to_string())
116+
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
117+
))
118+
}
119+
120+
fn attestation_state_after(
121+
was_pending: bool,
122+
result: &Result<(), String>,
123+
) -> (bool, Option<String>) {
124+
(
125+
was_pending && result.is_err(),
126+
result.as_ref().err().cloned(),
127+
)
128+
}
129+
130+
async fn run_serialized_provider_operation<
131+
T,
132+
ReachedBoundary,
133+
BoundaryFuture,
134+
Operation,
135+
OperationFuture,
136+
>(
137+
state: &AppState,
138+
pubkey: &str,
139+
reached_boundary: ReachedBoundary,
140+
operation: Operation,
141+
) -> Result<T, String>
142+
where
143+
ReachedBoundary: FnOnce() -> BoundaryFuture,
144+
BoundaryFuture: Future<Output = ()>,
145+
Operation: FnOnce() -> OperationFuture,
146+
OperationFuture: Future<Output = Result<T, String>>,
147+
{
148+
let operation_lock = provider_operation_lock(state, pubkey)?;
149+
reached_boundary().await;
150+
let _operation_guard = operation_lock.lock().await;
151+
operation().await
105152
}
106153

107154
/// Attest the saved record in one community and persist a visible error if
@@ -118,7 +165,38 @@ pub(super) async fn attest(
118165
provider_config: &serde_json::Value,
119166
community_relay: &ScopedWorkspaceRelay,
120167
) -> Result<(), String> {
121-
let (auth_tag, was_pending) = {
168+
run_serialized_provider_operation(
169+
state,
170+
pubkey,
171+
|| async {},
172+
|| async {
173+
attest_serialized(
174+
app,
175+
state,
176+
pubkey,
177+
provider_id,
178+
provider_config,
179+
community_relay,
180+
)
181+
.await
182+
},
183+
)
184+
.await
185+
}
186+
187+
/// Run while holding the per-agent provider-operation fence. Snapshot,
188+
/// provider I/O, and result persistence must stay inside this one operation;
189+
/// otherwise a slower failure can land after a newer success and reopen global
190+
/// attestation while overwriting its error state.
191+
async fn attest_serialized(
192+
app: &AppHandle,
193+
state: &AppState,
194+
pubkey: &str,
195+
provider_id: &str,
196+
provider_config: &serde_json::Value,
197+
community_relay: &ScopedWorkspaceRelay,
198+
) -> Result<(), String> {
199+
let auth_tag = {
122200
let _guard = state
123201
.managed_agents_store_lock
124202
.lock()
@@ -128,14 +206,11 @@ pub(super) async fn attest(
128206
.iter()
129207
.find(|record| record.pubkey == pubkey)
130208
.ok_or_else(|| format!("agent {pubkey} not found"))?;
131-
(
132-
record
133-
.auth_tag
134-
.clone()
135-
.filter(|value| !value.is_empty())
136-
.ok_or_else(|| format!("agent {pubkey} has no auth tag"))?,
137-
record.provider_attestation_pending,
138-
)
209+
record
210+
.auth_tag
211+
.clone()
212+
.filter(|value| !value.is_empty())
213+
.ok_or_else(|| format!("agent {pubkey} has no auth tag"))?
139214
};
140215

141216
let binary = resolve_provider_binary(provider_id)?;
@@ -153,19 +228,24 @@ pub(super) async fn attest(
153228
let mut records = load_managed_agents(app)?;
154229
let record = find_managed_agent_mut(&mut records, pubkey)?;
155230
record.updated_at = now_iso();
156-
record.last_error = result.as_ref().err().cloned();
231+
let (pending, last_error) =
232+
attestation_state_after(record.provider_attestation_pending, &result);
233+
record.last_error = last_error;
157234
// A failed first attest leaves activation pending. A later community's
158235
// enrollment failure must not make an already activated agent globally
159236
// undeployed; the scoped Start call still returns the failure to its caller.
160-
record.provider_attestation_pending = attestation_pending_after(was_pending, result.is_ok());
237+
record.provider_attestation_pending = pending;
161238
save_managed_agents(app, &records)?;
162239
result
163240
}
164241

165242
#[cfg(test)]
166243
mod tests {
167244
use super::*;
245+
use crate::app_state::build_app_state;
168246
use crate::relay::bind_expected_relay_scope;
247+
use tokio::sync::{Barrier, Notify};
248+
use tokio::time::{timeout, Duration};
169249

170250
#[test]
171251
fn attestation_payload_binds_the_target_community() {
@@ -185,12 +265,80 @@ mod tests {
185265
);
186266
}
187267

268+
#[tokio::test]
269+
async fn concurrent_attestation_attempts_are_serialized_per_agent() {
270+
let state = Arc::new(build_app_state());
271+
let first_entered = Arc::new(Notify::new());
272+
let release_first = Arc::new(Notify::new());
273+
let first_state = Arc::clone(&state);
274+
let first_entered_task = Arc::clone(&first_entered);
275+
let release_first_task = Arc::clone(&release_first);
276+
let first = tokio::spawn(async move {
277+
run_serialized_provider_operation(
278+
&first_state,
279+
"agent-pubkey",
280+
|| async {},
281+
|| async {
282+
first_entered_task.notify_one();
283+
release_first_task.notified().await;
284+
Ok(())
285+
},
286+
)
287+
.await
288+
.unwrap();
289+
});
290+
first_entered.notified().await;
291+
292+
let second_at_boundary = Arc::new(Barrier::new(2));
293+
let second_entered = Arc::new(Notify::new());
294+
let second_state = Arc::clone(&state);
295+
let second_at_boundary_task = Arc::clone(&second_at_boundary);
296+
let second_entered_task = Arc::clone(&second_entered);
297+
let second = tokio::spawn(async move {
298+
run_serialized_provider_operation(
299+
&second_state,
300+
"agent-pubkey",
301+
|| async {
302+
second_at_boundary_task.wait().await;
303+
},
304+
|| async {
305+
second_entered_task.notify_one();
306+
Ok(())
307+
},
308+
)
309+
.await
310+
.unwrap();
311+
});
312+
313+
second_at_boundary.wait().await;
314+
assert!(
315+
timeout(Duration::from_millis(100), second_entered.notified())
316+
.await
317+
.is_err()
318+
);
319+
release_first.notify_one();
320+
first.await.unwrap();
321+
second.await.unwrap();
322+
}
323+
188324
#[test]
189-
fn later_community_failure_does_not_reopen_global_attestation() {
190-
assert!(attestation_pending_after(true, false));
191-
assert!(!attestation_pending_after(true, true));
192-
assert!(!attestation_pending_after(false, false));
193-
assert!(!attestation_pending_after(false, true));
325+
fn serialized_attestation_results_preserve_the_authoritative_completion() {
326+
let success = Ok(());
327+
let failure = Err("later community failed".to_string());
328+
329+
let (pending, error) = attestation_state_after(true, &success);
330+
assert!(!pending);
331+
assert_eq!(error, None);
332+
let (pending, error) = attestation_state_after(pending, &failure);
333+
assert!(!pending);
334+
assert_eq!(error.as_deref(), Some("later community failed"));
335+
336+
let (pending, error) = attestation_state_after(true, &failure);
337+
assert!(pending);
338+
assert_eq!(error.as_deref(), Some("later community failed"));
339+
let (pending, error) = attestation_state_after(pending, &success);
340+
assert!(!pending);
341+
assert_eq!(error, None);
194342
}
195343

196344
#[test]

0 commit comments

Comments
 (0)