@team-agent/installer 0.5.74 → 0.5.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,41 @@ use super::{
26
26
 
27
27
  // ── leader::lease — attach / claim / takeover / autobind / readopt 统一 CAS 路径 ──
28
28
 
29
+ #[derive(Debug, Clone)]
30
+ pub(crate) struct AppliedLeaderGrant {
31
+ pub(crate) owner: Value,
32
+ pub(crate) receiver: Value,
33
+ }
34
+
35
+ #[derive(Debug)]
36
+ pub(crate) struct AttachLeaderError {
37
+ source: LeaderError,
38
+ applied_grant: Option<AppliedLeaderGrant>,
39
+ }
40
+
41
+ impl AttachLeaderError {
42
+ fn with_applied(source: LeaderError, applied_grant: Option<AppliedLeaderGrant>) -> Self {
43
+ Self {
44
+ source,
45
+ applied_grant,
46
+ }
47
+ }
48
+
49
+ pub(crate) fn into_parts(self) -> (LeaderError, Option<AppliedLeaderGrant>) {
50
+ (self.source, self.applied_grant)
51
+ }
52
+
53
+ fn into_error(self) -> LeaderError {
54
+ self.source
55
+ }
56
+ }
57
+
58
+ impl From<LeaderError> for AttachLeaderError {
59
+ fn from(source: LeaderError) -> Self {
60
+ Self::with_applied(source, None)
61
+ }
62
+ }
63
+
29
64
  /// `attach_leader`(card §42;`__init__.py:19`)。手动 CLI attach;持 `LEADER_OWNERSHIP_LOCK`
30
65
  /// 整段临界区做 state 变更 + 事件 + 双写 + requeue exhausted watchers。
31
66
  pub fn attach_leader(
@@ -454,63 +489,140 @@ pub(crate) fn attach_leader_to_state(
454
489
  source: LeaseSource,
455
490
  require_current: bool,
456
491
  ) -> Result<(LeaderReceiver, Value), LeaderError> {
457
- let _ = (source, require_current);
492
+ attach_leader_to_state_with_target_and_controls(
493
+ workspace,
494
+ state,
495
+ pane,
496
+ provider,
497
+ event_log,
498
+ source,
499
+ require_current,
500
+ None,
501
+ None,
502
+ None,
503
+ None,
504
+ )
505
+ .map_err(AttachLeaderError::into_error)
506
+ }
507
+
508
+ #[allow(clippy::too_many_arguments)]
509
+ pub(crate) fn attach_leader_to_state_with_target(
510
+ workspace: &Path,
511
+ state: &mut Value,
512
+ pane: Option<&PaneId>,
513
+ provider: Provider,
514
+ event_log: &crate::event_log::EventLog,
515
+ source: LeaseSource,
516
+ require_current: bool,
517
+ caller_target: Option<&PaneInfo>,
518
+ ) -> Result<(LeaderReceiver, Value), LeaderError> {
519
+ attach_leader_to_state_with_target_and_controls(
520
+ workspace,
521
+ state,
522
+ pane,
523
+ provider,
524
+ event_log,
525
+ source,
526
+ require_current,
527
+ caller_target,
528
+ None,
529
+ None,
530
+ None,
531
+ )
532
+ .map_err(AttachLeaderError::into_error)
533
+ }
534
+
535
+ #[allow(clippy::too_many_arguments)]
536
+ pub(crate) fn attach_leader_to_state_with_target_and_controls(
537
+ workspace: &Path,
538
+ state: &mut Value,
539
+ pane: Option<&PaneId>,
540
+ provider: Provider,
541
+ event_log: &crate::event_log::EventLog,
542
+ source: LeaseSource,
543
+ require_current: bool,
544
+ caller_target: Option<&PaneInfo>,
545
+ expected_owner: Option<&Value>,
546
+ expected_receiver: Option<&Value>,
547
+ nonce_writer: Option<&dyn Fn(&str, &PaneId, &str) -> Result<String, LeaderError>>,
548
+ ) -> Result<(LeaderReceiver, Value), AttachLeaderError> {
549
+ if expected_owner.is_some() != expected_receiver.is_some() {
550
+ return Err(AttachLeaderError::from(LeaderError::Validation(
551
+ "fresh caller CAS requires both expected owner and receiver".to_string(),
552
+ )));
553
+ }
458
554
  let pane_id = pane
459
555
  .cloned()
460
556
  .ok_or_else(|| LeaderError::Validation("tmux pane not found".to_string()))?;
461
557
  let non_empty_pane_id = NonEmptyPaneId::try_from_pane(&pane_id)?;
462
558
  let identity = leader_identity_context(workspace, None, Some(state))?;
463
559
  let epoch = current_owner_epoch(state);
464
- let receiver = make_receiver(
560
+ let has_owner = state.get("team_owner").is_some();
561
+ let binding_epoch = if has_owner {
562
+ epoch
563
+ } else {
564
+ OwnerEpoch(epoch.0.saturating_add(1))
565
+ };
566
+ let mut receiver = make_receiver(
465
567
  provider,
466
568
  &non_empty_pane_id,
467
569
  &identity.leader_session_uuid,
468
- epoch,
570
+ binding_epoch,
469
571
  Discovery::EnvPane,
470
572
  None,
471
573
  );
472
- if state.get("team_owner").is_some() {
574
+ authorize_fresh_caller_receiver(
575
+ workspace,
576
+ &mut receiver,
577
+ &non_empty_pane_id,
578
+ source,
579
+ require_current,
580
+ caller_target,
581
+ nonce_writer,
582
+ )?;
583
+ let receiver_value = serde_json::to_value(&receiver).map_err(LeaderError::from)?;
584
+ if has_owner {
473
585
  write_receiver_to_state(state, &receiver)?;
474
586
  } else {
475
- let next_epoch = OwnerEpoch(epoch.0.saturating_add(1));
476
- let receiver = make_receiver(
477
- provider,
478
- &non_empty_pane_id,
479
- &identity.leader_session_uuid,
480
- next_epoch,
481
- Discovery::EnvPane,
482
- None,
483
- );
484
- let owner = make_owner(provider, &non_empty_pane_id, &identity, next_epoch);
587
+ let owner = make_owner(provider, &non_empty_pane_id, &identity, binding_epoch);
485
588
  write_binding_to_state(state, &receiver, &owner)?;
486
- write_lease_dual_state(workspace, state)?;
487
- event_log.write(
488
- super::LeaderEvent::ReceiverAttached.name(),
489
- json!({"pane_id": pane_id.as_str(), "owner_epoch": next_epoch.0}),
490
- )?;
491
- let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
492
- crate::messaging::watchers::recover_watchers_for_incident(
589
+ }
590
+ if let (Some(expected_owner), Some(expected_receiver)) = (expected_owner, expected_receiver) {
591
+ write_lease_dual_state_with_expected(
493
592
  workspace,
494
- event_log,
495
- &team_id,
496
- &pane_id,
497
- LeaderIncident::LeaderAttached,
593
+ state,
594
+ expected_owner,
595
+ expected_receiver,
498
596
  )?;
499
- return Ok((receiver, json!({"ok": true})));
597
+ } else {
598
+ write_lease_dual_state(workspace, state)?;
500
599
  }
501
- write_lease_dual_state(workspace, state)?;
502
- event_log.write(
600
+ let applied_grant = state
601
+ .get("team_owner")
602
+ .cloned()
603
+ .map(|owner| AppliedLeaderGrant {
604
+ owner,
605
+ receiver: receiver_value,
606
+ });
607
+ if let Err(error) = event_log.write(
503
608
  super::LeaderEvent::ReceiverAttached.name(),
504
- json!({"pane_id": pane_id.as_str(), "owner_epoch": epoch.0}),
505
- )?;
609
+ json!({"pane_id": pane_id.as_str(), "owner_epoch": binding_epoch.0}),
610
+ ) {
611
+ return Err(AttachLeaderError::with_applied(
612
+ error.into(),
613
+ applied_grant.clone(),
614
+ ));
615
+ }
506
616
  let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
507
- crate::messaging::watchers::recover_watchers_for_incident(
617
+ if let Err(error) = crate::messaging::watchers::recover_watchers_for_incident(
508
618
  workspace,
509
619
  event_log,
510
620
  &team_id,
511
621
  &pane_id,
512
622
  LeaderIncident::LeaderAttached,
513
- )?;
623
+ ) {
624
+ return Err(AttachLeaderError::with_applied(error.into(), applied_grant));
625
+ }
514
626
  Ok((receiver, json!({"ok": true})))
515
627
  }
516
628
 
@@ -1870,6 +1982,72 @@ fn emit_lease_refusal(
1870
1982
  Ok(())
1871
1983
  }
1872
1984
 
1985
+ fn authorize_fresh_caller_receiver(
1986
+ workspace: &Path,
1987
+ receiver: &mut LeaderReceiver,
1988
+ pane: &NonEmptyPaneId,
1989
+ source: LeaseSource,
1990
+ require_current: bool,
1991
+ caller_target: Option<&PaneInfo>,
1992
+ nonce_writer: Option<&dyn Fn(&str, &PaneId, &str) -> Result<String, LeaderError>>,
1993
+ ) -> Result<(), LeaderError> {
1994
+ if !matches!(source, LeaseSource::QuickStart) || !require_current {
1995
+ return Ok(());
1996
+ }
1997
+ let Some(target) = caller_target else {
1998
+ return Ok(());
1999
+ };
2000
+ if target.pane_id.as_str() != pane.as_pane_id().as_str() || !target.active {
2001
+ return Err(LeaderError::Validation(
2002
+ "fresh caller pane observation does not match the bound pane".to_string(),
2003
+ ));
2004
+ }
2005
+ let Some(current_path) = target.current_path.as_deref() else {
2006
+ return Ok(());
2007
+ };
2008
+ if crate::messaging::leader_channel::path_is_in_workspace(current_path, workspace) {
2009
+ return Ok(());
2010
+ }
2011
+ let Some(endpoint) = receiver
2012
+ .tmux_socket
2013
+ .as_deref()
2014
+ .filter(|endpoint| !endpoint.is_empty())
2015
+ else {
2016
+ return Err(LeaderError::Validation(
2017
+ "fresh cross-workspace caller requires a tmux endpoint".to_string(),
2018
+ ));
2019
+ };
2020
+ if !Path::new(endpoint).is_absolute() {
2021
+ return Err(LeaderError::Validation(
2022
+ "fresh cross-workspace caller requires an absolute tmux endpoint".to_string(),
2023
+ ));
2024
+ }
2025
+ let pane_id = pane.as_pane_id();
2026
+ let live_nonce = if let Some(live_nonce) = target
2027
+ .leader_env
2028
+ .get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
2029
+ .filter(|nonce| !nonce.is_empty())
2030
+ .cloned()
2031
+ {
2032
+ live_nonce
2033
+ } else {
2034
+ let nonce = pane_binding_nonce_for_claim(workspace, endpoint, pane_id, None);
2035
+ if let Some(writer) = nonce_writer {
2036
+ writer(endpoint, pane_id, &nonce)?
2037
+ } else {
2038
+ tmux_backend_for_endpoint(endpoint)
2039
+ .set_pane_binding_nonce_if_unset(pane_id, &nonce)
2040
+ .map_err(|error| LeaderError::Tmux(error.to_string()))?
2041
+ }
2042
+ };
2043
+ receiver.scope_authority = Some(
2044
+ crate::messaging::leader_channel::FRESH_CALLER_SCOPE_AUTHORITY.to_string(),
2045
+ );
2046
+ receiver.authorized_team_workspace = Some(canonical_workspace(workspace));
2047
+ receiver.binding_nonce = Some(live_nonce);
2048
+ Ok(())
2049
+ }
2050
+
1873
2051
  fn make_receiver(
1874
2052
  provider: Provider,
1875
2053
  pane: &NonEmptyPaneId,
@@ -2237,6 +2415,24 @@ fn state_owner(state: &Value) -> Option<TeamOwner> {
2237
2415
  /// now persists ONLY the canonical root state; retaining the public
2238
2416
  /// name for 0.5.x call-site stability. The B0 legacy snapshot is
2239
2417
  /// diagnostic-only via `lifecycle::save_team_runtime_snapshot`.
2418
+ pub(crate) fn write_lease_dual_state_with_expected(
2419
+ workspace: &Path,
2420
+ state: &Value,
2421
+ expected_owner: &Value,
2422
+ expected_receiver: &Value,
2423
+ ) -> Result<(), LeaderError> {
2424
+ let team_key = canonical_owner_write_key(state);
2425
+ crate::state::repository::StateRepository::new(workspace).save(
2426
+ crate::state::repository::StateWriteIntent::ClaimLeaderFreshCaller {
2427
+ team_key: team_key.as_str(),
2428
+ expected_owner,
2429
+ expected_receiver,
2430
+ },
2431
+ state,
2432
+ )?;
2433
+ Ok(())
2434
+ }
2435
+
2240
2436
  pub fn write_lease_dual_state(workspace: &Path, state: &Value) -> Result<(), LeaderError> {
2241
2437
  // 0.5.42 S1b (s1b-writer-cluster-locate.md §4.4): terminal root
2242
2438
  // save routes through `StateRepository` with the `ClaimLeader`
@@ -20,7 +20,9 @@
20
20
 
21
21
  #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
22
22
 
23
+ use std::fmt;
23
24
  use std::path::{Path, PathBuf};
25
+ use std::time::{Duration, Instant};
24
26
 
25
27
  use serde::{Deserialize, Serialize};
26
28
  use serde_json::{json, Value};
@@ -100,10 +102,45 @@ pub const AMBIGUOUS_NAMES_FIELD: &str = "ambiguous_names";
100
102
  /// a fully-qualified form.
101
103
  pub const REASON_AMBIGUOUS: &str = "name_ambiguous";
102
104
 
105
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
106
+ pub enum RegistryRollback {
107
+ Restored,
108
+ Superseded,
109
+ }
110
+
111
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
112
+ pub enum RegistryRollbackError {
113
+ InvalidReceipt,
114
+ LockUnavailable,
115
+ ReadFailed,
116
+ RestoreFailed,
117
+ DeleteFailed,
118
+ }
119
+
120
+ impl fmt::Display for RegistryRollbackError {
121
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122
+ let reason = match self {
123
+ Self::InvalidReceipt => "invalid receipt",
124
+ Self::LockUnavailable => "registry lock unavailable",
125
+ Self::ReadFailed => "registry receipt read failed",
126
+ Self::RestoreFailed => "registry restore failed",
127
+ Self::DeleteFailed => "registry delete failed",
128
+ };
129
+ f.write_str(reason)
130
+ }
131
+ }
132
+
133
+ #[derive(Debug, Clone, PartialEq, Eq)]
134
+ pub struct RegistryWriteReceipt {
135
+ pub path: PathBuf,
136
+ pub bytes: Vec<u8>,
137
+ }
138
+
103
139
  #[derive(Debug, Clone, PartialEq, Eq)]
104
140
  pub struct RegistryWriteOutcome {
105
141
  pub status: &'static str,
106
142
  pub path: Option<PathBuf>,
143
+ pub receipt: Option<RegistryWriteReceipt>,
107
144
  pub team_key: String,
108
145
  pub workspace_hash: String,
109
146
  pub source: String,
@@ -242,7 +279,8 @@ pub fn register_binding_from_state_best_effort(
242
279
  chrono::Utc::now().to_rfc3339(),
243
280
  );
244
281
  let event_log = crate::event_log::EventLog::new(workspace);
245
- let write_result = write_entry_best_effort(&entry);
282
+ let write_receipt = write_entry_best_effort_with_receipt(&entry);
283
+ let write_result = write_receipt.as_ref().map(|receipt| receipt.path.clone());
246
284
  let status = if let Some(path) = &write_result {
247
285
  let _ = event_log.write(
248
286
  EVENT_REGISTERED,
@@ -268,6 +306,7 @@ pub fn register_binding_from_state_best_effort(
268
306
  Some(RegistryWriteOutcome {
269
307
  status,
270
308
  path: write_result,
309
+ receipt: write_receipt,
271
310
  team_key,
272
311
  workspace_hash: entry.workspace_hash,
273
312
  source: source.to_string(),
@@ -281,13 +320,19 @@ fn entry_filename(entry: &LeaderRegistryEntry) -> String {
281
320
 
282
321
  /// Registry write is best-effort. Writes to `.<file>.tmp-<pid>-<counter>`
283
322
  /// first, then atomically renames to `<workspace_hash>__<team_key>.json`.
284
- /// Returns the final path when the write succeeded, `None` otherwise —
285
- /// the binding command must never fail on registry errors.
323
+ /// Every writer uses the registry lock so conditional rollback can compare
324
+ /// and restore without racing a concurrent publish.
286
325
  pub fn write_entry_best_effort(entry: &LeaderRegistryEntry) -> Option<PathBuf> {
287
- let dir = registry_dir()?;
288
- if let Err(_error) = std::fs::create_dir_all(&dir) {
289
- return None;
290
- }
326
+ write_entry_best_effort_with_receipt(entry).map(|receipt| receipt.path)
327
+ }
328
+
329
+ fn write_entry_best_effort_with_receipt(
330
+ entry: &LeaderRegistryEntry,
331
+ ) -> Option<RegistryWriteReceipt> {
332
+ with_registry_lock(|dir| write_entry_locked(dir, entry)).flatten()
333
+ }
334
+
335
+ fn write_entry_locked(dir: &Path, entry: &LeaderRegistryEntry) -> Option<RegistryWriteReceipt> {
291
336
  let final_path = dir.join(entry_filename(entry));
292
337
  let tmp_name = format!(
293
338
  ".{}.tmp-{}-{}",
@@ -296,8 +341,8 @@ pub fn write_entry_best_effort(entry: &LeaderRegistryEntry) -> Option<PathBuf> {
296
341
  rand_suffix()
297
342
  );
298
343
  let tmp_path = dir.join(tmp_name);
299
- let serialized = serde_json::to_string_pretty(entry).ok()?;
300
- if std::fs::write(&tmp_path, serialized).is_err() {
344
+ let serialized = serde_json::to_vec_pretty(entry).ok()?;
345
+ if std::fs::write(&tmp_path, &serialized).is_err() {
301
346
  let _ = std::fs::remove_file(&tmp_path);
302
347
  return None;
303
348
  }
@@ -305,7 +350,118 @@ pub fn write_entry_best_effort(entry: &LeaderRegistryEntry) -> Option<PathBuf> {
305
350
  let _ = std::fs::remove_file(&tmp_path);
306
351
  return None;
307
352
  }
308
- Some(final_path)
353
+ Some(RegistryWriteReceipt {
354
+ path: final_path,
355
+ bytes: serialized,
356
+ })
357
+ }
358
+
359
+ /// Restore one registry path only when it still contains this writer's exact
360
+ /// receipt. The comparison and restore/delete are held under the same lock as
361
+ /// ordinary registry writers, so a winner cannot slip in between them.
362
+ pub fn restore_entry_if_current_matches(
363
+ receipt: &RegistryWriteReceipt,
364
+ previous: Option<&[u8]>,
365
+ ) -> Result<RegistryRollback, RegistryRollbackError> {
366
+ with_registry_lock_result(|dir| {
367
+ if receipt.path.parent() != Some(dir) {
368
+ return Err(RegistryRollbackError::InvalidReceipt);
369
+ }
370
+ let current = std::fs::read(&receipt.path)
371
+ .map_err(|_| RegistryRollbackError::ReadFailed)?;
372
+ if current != receipt.bytes {
373
+ return Ok(RegistryRollback::Superseded);
374
+ }
375
+ match previous {
376
+ Some(bytes) if replace_entry_bytes_locked(&receipt.path, bytes) => {
377
+ Ok(RegistryRollback::Restored)
378
+ }
379
+ Some(_) => Err(RegistryRollbackError::RestoreFailed),
380
+ None if std::fs::remove_file(&receipt.path).is_ok() => {
381
+ Ok(RegistryRollback::Restored)
382
+ }
383
+ None => Err(RegistryRollbackError::DeleteFailed),
384
+ }
385
+ })
386
+ }
387
+
388
+ fn remove_entry_if_matches(path: &Path, expected: &LeaderRegistryEntry) -> bool {
389
+ with_registry_lock(|_| {
390
+ let Ok(bytes) = std::fs::read(path) else {
391
+ return false;
392
+ };
393
+ let Ok(current) = serde_json::from_slice::<LeaderRegistryEntry>(&bytes) else {
394
+ return false;
395
+ };
396
+ current == *expected && std::fs::remove_file(path).is_ok()
397
+ })
398
+ .unwrap_or(false)
399
+ }
400
+
401
+ fn replace_entry_bytes_locked(path: &Path, bytes: &[u8]) -> bool {
402
+ let tmp = path.with_extension(format!(
403
+ "rollback-{}-{}",
404
+ std::process::id(),
405
+ rand_suffix()
406
+ ));
407
+ if std::fs::write(&tmp, bytes).is_err() {
408
+ let _ = std::fs::remove_file(&tmp);
409
+ return false;
410
+ }
411
+ if std::fs::rename(&tmp, path).is_err() {
412
+ let _ = std::fs::remove_file(&tmp);
413
+ return false;
414
+ }
415
+ true
416
+ }
417
+
418
+ struct RegistryLock {
419
+ file: std::fs::File,
420
+ }
421
+
422
+ impl RegistryLock {
423
+ fn acquire(dir: &Path) -> Option<Self> {
424
+ let path = dir.join(".registry.lock");
425
+ let file = std::fs::OpenOptions::new()
426
+ .create(true)
427
+ .read(true)
428
+ .write(true)
429
+ .truncate(false)
430
+ .open(path)
431
+ .ok()?;
432
+ let started = Instant::now();
433
+ loop {
434
+ match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
435
+ Ok(true) => return Some(Self { file }),
436
+ Ok(false) if started.elapsed() < Duration::from_secs(2) => {
437
+ std::thread::sleep(Duration::from_millis(10));
438
+ }
439
+ Ok(false) | Err(_) => return None,
440
+ }
441
+ }
442
+ }
443
+ }
444
+
445
+ impl Drop for RegistryLock {
446
+ fn drop(&mut self) {
447
+ let _ = crate::platform::file_lock::unlock(&self.file);
448
+ }
449
+ }
450
+
451
+ fn with_registry_lock<T>(f: impl FnOnce(&Path) -> T) -> Option<T> {
452
+ let dir = registry_dir()?;
453
+ std::fs::create_dir_all(&dir).ok()?;
454
+ let _lock = RegistryLock::acquire(&dir)?;
455
+ Some(f(&dir))
456
+ }
457
+
458
+ fn with_registry_lock_result<T>(
459
+ f: impl FnOnce(&Path) -> Result<T, RegistryRollbackError>,
460
+ ) -> Result<T, RegistryRollbackError> {
461
+ let dir = registry_dir().ok_or(RegistryRollbackError::LockUnavailable)?;
462
+ std::fs::create_dir_all(&dir).map_err(|_| RegistryRollbackError::LockUnavailable)?;
463
+ let _lock = RegistryLock::acquire(&dir).ok_or(RegistryRollbackError::LockUnavailable)?;
464
+ f(&dir)
309
465
  }
310
466
 
311
467
  fn rand_suffix() -> String {
@@ -324,14 +480,16 @@ fn rand_suffix() -> String {
324
480
  /// success — the invariant is "no registry entry after unbind", not
325
481
  /// "must have found something to delete".
326
482
  pub fn unregister_entry(workspace: &Path, team_key: &str) -> Option<PathBuf> {
327
- let dir = registry_dir()?;
328
- let hash = workspace_hash(workspace);
329
- let path = dir.join(format!("{hash}__{team_key}.json"));
330
- if path.exists() {
331
- std::fs::remove_file(&path).ok()?;
332
- return Some(path);
333
- }
334
- None
483
+ with_registry_lock(|dir| {
484
+ let hash = workspace_hash(workspace);
485
+ let path = dir.join(format!("{hash}__{team_key}.json"));
486
+ if path.exists() {
487
+ std::fs::remove_file(&path).ok()?;
488
+ return Some(path);
489
+ }
490
+ None
491
+ })
492
+ .flatten()
335
493
  }
336
494
 
337
495
  /// Read and deserialize every `*.json` file under the registry directory.
@@ -470,7 +628,7 @@ pub fn list_validated_with_gc() -> Vec<(LeaderRegistryEntry, &'static str, Optio
470
628
  || r == "team_not_alive"
471
629
  })
472
630
  {
473
- let _ = std::fs::remove_file(&path);
631
+ let _ = remove_entry_if_matches(&path, &entry);
474
632
  continue;
475
633
  }
476
634
  out.push((entry, status, reason));
@@ -602,7 +602,18 @@ pub(crate) fn run_pi_catalog(
602
602
  .stdout(Stdio::piped())
603
603
  .stderr(Stdio::null())
604
604
  .spawn()
605
- .map_err(|_| "Pi executable is unavailable on PATH".to_string())?;
605
+ .map_err(|error| {
606
+ #[cfg(test)]
607
+ {
608
+ eprintln!(
609
+ "run_pi_catalog spawn failed kind={:?} raw_os_error={:?}",
610
+ error.kind(),
611
+ error.raw_os_error()
612
+ );
613
+ }
614
+ let _ = error;
615
+ "Pi executable is unavailable on PATH".to_string()
616
+ })?;
606
617
  let stdout = child.stdout.take().ok_or_else(|| {
607
618
  let _ = child.kill();
608
619
  let _ = child.wait();