@team-agent/installer 0.5.74 → 0.5.77

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.
Files changed (45) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -0
  4. package/README.zh.md +8 -0
  5. package/crates/team-agent/src/cli/adapters.rs +23 -96
  6. package/crates/team-agent/src/cli/emit.rs +35 -7
  7. package/crates/team-agent/src/cli/mod.rs +63 -42
  8. package/crates/team-agent/src/cli/models.rs +138 -27
  9. package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
  10. package/crates/team-agent/src/cli/status_port.rs +8 -0
  11. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
  13. package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
  14. package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
  15. package/crates/team-agent/src/layout/worker_env.rs +366 -0
  16. package/crates/team-agent/src/leader/lease.rs +274 -40
  17. package/crates/team-agent/src/leader/registry.rs +177 -19
  18. package/crates/team-agent/src/leader/types.rs +4 -0
  19. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +93 -5
  20. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1820 -60
  21. package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
  23. package/crates/team-agent/src/lifecycle/restart/common.rs +3 -0
  24. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +11 -4
  25. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
  26. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +1 -0
  27. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +24 -28
  29. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
  30. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
  31. package/crates/team-agent/src/lifecycle/types.rs +4 -0
  32. package/crates/team-agent/src/messaging/delivery.rs +200 -29
  33. package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
  34. package/crates/team-agent/src/messaging/watchers.rs +48 -4
  35. package/crates/team-agent/src/state/paths.rs +17 -2
  36. package/crates/team-agent/src/state/persist.rs +166 -6
  37. package/crates/team-agent/src/state/projection.rs +18 -2
  38. package/crates/team-agent/src/state/repository.rs +36 -0
  39. package/crates/team-agent/src/state/selector.rs +23 -3
  40. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +122 -0
  41. package/crates/team-agent/src/tmux_backend/tests.rs +350 -7
  42. package/crates/team-agent/src/tmux_backend.rs +217 -26
  43. package/crates/team-agent/src/transport/test_support.rs +18 -3
  44. package/crates/team-agent/src/transport.rs +99 -0
  45. package/package.json +4 -4
@@ -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));
@@ -419,6 +419,10 @@ pub struct LeaseResult {
419
419
  /// paths so existing lease JSON stays stable unless a convergence decision
420
420
  /// was actually evaluated.
421
421
  pub topology_convergence: Option<Value>,
422
+ /// Counts returned by the same attach operation when blocked leader
423
+ /// messages were requeued. Kept typed and team/pane-bound; omitted when
424
+ /// no attach-window debt was observed.
425
+ pub attach_window_failures: Option<Value>,
422
426
  }
423
427
 
424
428
  /// Family A 正源 owner 绑定结果(`bind_owner_from_caller_pane` 返回 dict 的 typed 版)。
@@ -16,6 +16,8 @@
16
16
  use std::collections::BTreeSet;
17
17
  use std::fs::OpenOptions;
18
18
  use std::io::{Read, Write};
19
+ #[cfg(test)]
20
+ use std::cell::RefCell;
19
21
  use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
20
22
  use std::path::{Path, PathBuf};
21
23
  use std::process::{Command, Stdio};
@@ -588,6 +590,58 @@ fn executable_file_type(path: &Path) -> Result<PiExecutableFileType, ProviderErr
588
590
  }
589
591
  }
590
592
 
593
+ #[cfg(test)]
594
+ #[derive(Debug, Default)]
595
+ pub(crate) struct PiCatalogTestObservation {
596
+ pub(crate) spawn_count: usize,
597
+ pub(crate) argv: Vec<String>,
598
+ pub(crate) parent_pid: Option<u32>,
599
+ pub(crate) parent_exit_success: Option<bool>,
600
+ pub(crate) parent_exit_code: Option<i32>,
601
+ pub(crate) reader_timeout: bool,
602
+ }
603
+
604
+ #[cfg(test)]
605
+ struct PiCatalogTestContext {
606
+ receipt: PathBuf,
607
+ helper_mode: Option<String>,
608
+ observation: PiCatalogTestObservation,
609
+ }
610
+
611
+ #[cfg(test)]
612
+ thread_local! {
613
+ static PI_CATALOG_TEST_CONTEXT: RefCell<Option<PiCatalogTestContext>> = RefCell::new(None);
614
+ }
615
+
616
+ #[cfg(test)]
617
+ pub(crate) fn with_pi_catalog_test_observation<T>(
618
+ receipt: &Path,
619
+ helper_mode: Option<&str>,
620
+ action: impl FnOnce() -> T,
621
+ ) -> (T, PiCatalogTestObservation) {
622
+ PI_CATALOG_TEST_CONTEXT.with(|slot| {
623
+ assert!(slot
624
+ .replace(Some(PiCatalogTestContext {
625
+ receipt: receipt.to_path_buf(),
626
+ helper_mode: helper_mode.map(str::to_owned),
627
+ observation: PiCatalogTestObservation::default(),
628
+ }))
629
+ .is_none());
630
+ let result = action();
631
+ let context = slot.take().expect("Pi catalog test context");
632
+ (result, context.observation)
633
+ })
634
+ }
635
+
636
+ #[cfg(test)]
637
+ fn observe_pi_catalog_test(f: impl FnOnce(&mut PiCatalogTestContext)) {
638
+ PI_CATALOG_TEST_CONTEXT.with(|slot| {
639
+ if let Some(context) = slot.borrow_mut().as_mut() {
640
+ f(context);
641
+ }
642
+ });
643
+ }
644
+
591
645
  /// Discover candidates with one PATH-first catalog observation. This deliberately
592
646
  /// does not resolve adapters or scan wrapper chains; callers use it only for
593
647
  /// rejecting an unqualified role model before lifecycle mutation.
@@ -596,13 +650,36 @@ pub(crate) fn run_pi_catalog(
596
650
  timeout: Duration,
597
651
  max_bytes: u64,
598
652
  ) -> Result<Vec<u8>, String> {
599
- let mut child = Command::new(executable)
600
- .arg("--list-models")
653
+ let mut command = Command::new(executable);
654
+ command.arg("--list-models");
655
+ #[cfg(test)]
656
+ observe_pi_catalog_test(|context| {
657
+ context.observation.spawn_count += 1;
658
+ context.observation.argv.push("--list-models".to_string());
659
+ command.env("TEAM_AGENT_MODELS_TIMEOUT_RECEIPT", &context.receipt);
660
+ if let Some(mode) = context.helper_mode.as_deref() {
661
+ command.env("TEAM_AGENT_MODELS_TIMEOUT_MODE", mode);
662
+ }
663
+ });
664
+ let mut child = command
601
665
  .stdin(Stdio::null())
602
666
  .stdout(Stdio::piped())
603
667
  .stderr(Stdio::null())
604
668
  .spawn()
605
- .map_err(|_| "Pi executable is unavailable on PATH".to_string())?;
669
+ .map_err(|error| {
670
+ #[cfg(test)]
671
+ {
672
+ eprintln!(
673
+ "run_pi_catalog spawn failed kind={:?} raw_os_error={:?}",
674
+ error.kind(),
675
+ error.raw_os_error()
676
+ );
677
+ }
678
+ let _ = error;
679
+ "Pi executable is unavailable on PATH".to_string()
680
+ })?;
681
+ #[cfg(test)]
682
+ observe_pi_catalog_test(|context| context.observation.parent_pid = Some(child.id()));
606
683
  let stdout = child.stdout.take().ok_or_else(|| {
607
684
  let _ = child.kill();
608
685
  let _ = child.wait();
@@ -618,8 +695,19 @@ pub(crate) fn run_pi_catalog(
618
695
  loop {
619
696
  match child.try_wait() {
620
697
  Ok(Some(status)) => {
621
- let (result, bytes) = receiver.recv_timeout(timeout.saturating_sub(start.elapsed()))
622
- .map_err(|_| "Pi model catalog command timed out".to_string())?;
698
+ #[cfg(test)]
699
+ observe_pi_catalog_test(|context| {
700
+ context.observation.parent_exit_success = Some(status.success());
701
+ context.observation.parent_exit_code = status.code();
702
+ });
703
+ let (result, bytes) = match receiver.recv_timeout(timeout.saturating_sub(start.elapsed())) {
704
+ Ok(value) => value,
705
+ Err(_) => {
706
+ #[cfg(test)]
707
+ observe_pi_catalog_test(|context| context.observation.reader_timeout = true);
708
+ return Err("Pi model catalog command timed out".to_string());
709
+ }
710
+ };
623
711
  if !status.success() { return Err("Pi model catalog command failed".into()); }
624
712
  result.map_err(|_| "Pi model catalog could not be read".to_string())?;
625
713
  if bytes.len() as u64 > max_bytes { return Err("Pi model catalog exceeds the bounded output limit".into()); }