@team-agent/installer 0.5.73 → 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.
Files changed (38) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +19 -13
  4. package/crates/team-agent/src/cli/emit.rs +6 -3
  5. package/crates/team-agent/src/cli/mod.rs +151 -0
  6. package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
  8. package/crates/team-agent/src/cli/types.rs +6 -0
  9. package/crates/team-agent/src/compiler.rs +14 -1
  10. package/crates/team-agent/src/layout/worker_env.rs +366 -0
  11. package/crates/team-agent/src/leader/lease.rs +334 -53
  12. package/crates/team-agent/src/leader/registry.rs +177 -108
  13. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +12 -1
  14. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +2272 -128
  15. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
  16. package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
  17. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
  18. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +5 -3
  19. package/crates/team-agent/src/lifecycle/restart/common.rs +3 -11
  20. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
  21. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +31 -0
  22. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
  23. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
  24. package/crates/team-agent/src/messaging/delivery.rs +226 -37
  25. package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
  26. package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
  27. package/crates/team-agent/src/model/spec.rs +81 -7
  28. package/crates/team-agent/src/provider/adapters/pi.rs +14 -0
  29. package/crates/team-agent/src/state/persist.rs +433 -4
  30. package/crates/team-agent/src/state/repository.rs +45 -2
  31. package/crates/team-agent/src/tmux_backend/tests.rs +414 -7
  32. package/crates/team-agent/src/tmux_backend.rs +274 -26
  33. package/crates/team-agent/src/transport/test_support.rs +18 -3
  34. package/crates/team-agent/src/transport.rs +99 -0
  35. package/package.json +4 -4
  36. package/skills/team-agent/SKILL.md +3 -0
  37. package/skills/team-agent/command-coverage.json +21 -0
  38. package/skills/team-agent/references/team-agent-operator.md +9 -2
@@ -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,13 +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);
246
- // Fresh quick-start is acquisition-only: it must never displace another
247
- // same-pane row. Its caller performs a validated-LIVE check before this
248
- // write; manual/restart ownership paths keep existing behavior.
249
- if write_result.is_some() && source != "quick-start" {
250
- let _ = mark_displaced_same_pane_entries_unbound(&entry);
251
- }
282
+ let write_receipt = write_entry_best_effort_with_receipt(&entry);
283
+ let write_result = write_receipt.as_ref().map(|receipt| receipt.path.clone());
252
284
  let status = if let Some(path) = &write_result {
253
285
  let _ = event_log.write(
254
286
  EVENT_REGISTERED,
@@ -274,6 +306,7 @@ pub fn register_binding_from_state_best_effort(
274
306
  Some(RegistryWriteOutcome {
275
307
  status,
276
308
  path: write_result,
309
+ receipt: write_receipt,
277
310
  team_key,
278
311
  workspace_hash: entry.workspace_hash,
279
312
  source: source.to_string(),
@@ -281,81 +314,25 @@ pub fn register_binding_from_state_best_effort(
281
314
  })
282
315
  }
283
316
 
284
- /// Stored registry status after another workspace claimed the same pane.
285
- pub const REGISTRY_STATUS_UNBOUND: &str = "unbound";
286
-
287
- /// When workspace B claims a pane, every other attached registry row that
288
- /// still names that pane is no longer deliverable. Mark those rows unbound.
289
- /// Does not change pane-side authorization storage.
290
- pub fn mark_displaced_same_pane_entries_unbound(winner: &LeaderRegistryEntry) -> Vec<PathBuf> {
291
- let Some(winner_pane) = winner
292
- .channel
293
- .get("pane_id")
294
- .and_then(Value::as_str)
295
- .filter(|pane| !pane.is_empty())
296
- else {
297
- return Vec::new();
298
- };
299
- let winner_socket = winner
300
- .channel
301
- .get("tmux_socket")
302
- .and_then(Value::as_str)
303
- .filter(|socket| !socket.is_empty());
304
- let mut displaced = Vec::new();
305
- for (path, mut entry) in read_all_entries() {
306
- if entry.workspace_hash == winner.workspace_hash && entry.team_key == winner.team_key {
307
- continue;
308
- }
309
- if entry.status != "attached" {
310
- continue;
311
- }
312
- let Some(pane) = entry
313
- .channel
314
- .get("pane_id")
315
- .and_then(Value::as_str)
316
- .filter(|pane| !pane.is_empty())
317
- else {
318
- continue;
319
- };
320
- if pane != winner_pane {
321
- continue;
322
- }
323
- let Some(socket) = entry
324
- .channel
325
- .get("tmux_socket")
326
- .and_then(Value::as_str)
327
- .filter(|socket| !socket.is_empty())
328
- else {
329
- continue;
330
- };
331
- let Some(winner_socket) = winner_socket else {
332
- continue;
333
- };
334
- if socket != winner_socket {
335
- continue;
336
- }
337
- entry.status = REGISTRY_STATUS_UNBOUND.to_string();
338
- entry.updated_at = chrono::Utc::now().to_rfc3339();
339
- if write_entry_best_effort(&entry).is_some() {
340
- displaced.push(path);
341
- }
342
- }
343
- displaced
344
- }
345
-
346
317
  fn entry_filename(entry: &LeaderRegistryEntry) -> String {
347
318
  format!("{}__{}.json", entry.workspace_hash, entry.team_key)
348
319
  }
349
320
 
350
321
  /// Registry write is best-effort. Writes to `.<file>.tmp-<pid>-<counter>`
351
322
  /// first, then atomically renames to `<workspace_hash>__<team_key>.json`.
352
- /// Returns the final path when the write succeeded, `None` otherwise —
353
- /// 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.
354
325
  pub fn write_entry_best_effort(entry: &LeaderRegistryEntry) -> Option<PathBuf> {
355
- let dir = registry_dir()?;
356
- if let Err(_error) = std::fs::create_dir_all(&dir) {
357
- return None;
358
- }
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> {
359
336
  let final_path = dir.join(entry_filename(entry));
360
337
  let tmp_name = format!(
361
338
  ".{}.tmp-{}-{}",
@@ -364,8 +341,8 @@ pub fn write_entry_best_effort(entry: &LeaderRegistryEntry) -> Option<PathBuf> {
364
341
  rand_suffix()
365
342
  );
366
343
  let tmp_path = dir.join(tmp_name);
367
- let serialized = serde_json::to_string_pretty(entry).ok()?;
368
- 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() {
369
346
  let _ = std::fs::remove_file(&tmp_path);
370
347
  return None;
371
348
  }
@@ -373,7 +350,118 @@ pub fn write_entry_best_effort(entry: &LeaderRegistryEntry) -> Option<PathBuf> {
373
350
  let _ = std::fs::remove_file(&tmp_path);
374
351
  return None;
375
352
  }
376
- 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)
377
465
  }
378
466
 
379
467
  fn rand_suffix() -> String {
@@ -392,14 +480,16 @@ fn rand_suffix() -> String {
392
480
  /// success — the invariant is "no registry entry after unbind", not
393
481
  /// "must have found something to delete".
394
482
  pub fn unregister_entry(workspace: &Path, team_key: &str) -> Option<PathBuf> {
395
- let dir = registry_dir()?;
396
- let hash = workspace_hash(workspace);
397
- let path = dir.join(format!("{hash}__{team_key}.json"));
398
- if path.exists() {
399
- std::fs::remove_file(&path).ok()?;
400
- return Some(path);
401
- }
402
- 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()
403
493
  }
404
494
 
405
495
  /// Read and deserialize every `*.json` file under the registry directory.
@@ -538,7 +628,7 @@ pub fn list_validated_with_gc() -> Vec<(LeaderRegistryEntry, &'static str, Optio
538
628
  || r == "team_not_alive"
539
629
  })
540
630
  {
541
- let _ = std::fs::remove_file(&path);
631
+ let _ = remove_entry_if_matches(&path, &entry);
542
632
  continue;
543
633
  }
544
634
  out.push((entry, status, reason));
@@ -546,27 +636,6 @@ pub fn list_validated_with_gc() -> Vec<(LeaderRegistryEntry, &'static str, Optio
546
636
  out
547
637
  }
548
638
 
549
- /// Refuse a fresh quick-start binding when the caller pane already backs a
550
- /// canonically LIVE receiver for another workspace/team. This is read-only:
551
- /// quick-start never displaces or takes over an existing binding.
552
- #[must_use]
553
- pub(crate) fn live_same_pane_binding_elsewhere(
554
- pane_id: &str,
555
- workspace: &Path,
556
- team_key: &str,
557
- ) -> bool {
558
- let workspace = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
559
- list_validated_no_gc().into_iter().any(|(entry, status, _)| {
560
- status == "LIVE"
561
- && entry
562
- .channel
563
- .get("pane_id")
564
- .and_then(Value::as_str)
565
- == Some(pane_id)
566
- && (entry.workspace != workspace || entry.team_key != team_key)
567
- })
568
- }
569
-
570
639
  /// Same as `list_validated_with_gc` but preserves all entries. `send
571
640
  /// --to-leader` needs to see stale entries so it can refuse with
572
641
  /// `registry_stale` (and never silently fall through to
@@ -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();