@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +19 -13
- package/crates/team-agent/src/cli/emit.rs +6 -3
- package/crates/team-agent/src/cli/mod.rs +151 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
- package/crates/team-agent/src/cli/types.rs +6 -0
- package/crates/team-agent/src/compiler.rs +14 -1
- package/crates/team-agent/src/layout/worker_env.rs +366 -0
- package/crates/team-agent/src/leader/lease.rs +334 -53
- package/crates/team-agent/src/leader/registry.rs +177 -108
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +12 -1
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +2272 -128
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +5 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +3 -11
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +31 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
- package/crates/team-agent/src/messaging/delivery.rs +226 -37
- package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
- package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
- package/crates/team-agent/src/model/spec.rs +81 -7
- package/crates/team-agent/src/provider/adapters/pi.rs +14 -0
- package/crates/team-agent/src/state/persist.rs +433 -4
- package/crates/team-agent/src/state/repository.rs +45 -2
- package/crates/team-agent/src/tmux_backend/tests.rs +414 -7
- package/crates/team-agent/src/tmux_backend.rs +274 -26
- package/crates/team-agent/src/transport/test_support.rs +18 -3
- package/crates/team-agent/src/transport.rs +99 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +3 -0
- package/skills/team-agent/command-coverage.json +21 -0
- 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
|
|
246
|
-
|
|
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
|
-
///
|
|
353
|
-
///
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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::
|
|
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(
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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 _ =
|
|
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(|
|
|
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();
|