@team-agent/installer 0.3.0 → 0.3.2

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 (39) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +38 -7
  4. package/crates/team-agent/src/cli/emit.rs +182 -54
  5. package/crates/team-agent/src/cli/mod.rs +703 -35
  6. package/crates/team-agent/src/cli/status_port.rs +170 -44
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  8. package/crates/team-agent/src/cli/types.rs +1 -0
  9. package/crates/team-agent/src/coordinator/health.rs +130 -0
  10. package/crates/team-agent/src/leader/lease.rs +23 -2
  11. package/crates/team-agent/src/leader/rediscover/tests.rs +1 -0
  12. package/crates/team-agent/src/leader/rediscover.rs +2 -0
  13. package/crates/team-agent/src/leader/tests/byte_findings.rs +9 -6
  14. package/crates/team-agent/src/leader/tests/idle.rs +1 -0
  15. package/crates/team-agent/src/leader/tests/lease_claim.rs +157 -0
  16. package/crates/team-agent/src/leader/types.rs +2 -0
  17. package/crates/team-agent/src/lifecycle/launch.rs +554 -65
  18. package/crates/team-agent/src/lifecycle/restart/common.rs +65 -0
  19. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +57 -15
  20. package/crates/team-agent/src/lifecycle/restart/remove.rs +5 -1
  21. package/crates/team-agent/src/lifecycle/restart.rs +20 -0
  22. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +52 -0
  23. package/crates/team-agent/src/lifecycle/types.rs +25 -0
  24. package/crates/team-agent/src/mcp_server/tests/wire.rs +28 -0
  25. package/crates/team-agent/src/mcp_server/wire.rs +81 -1
  26. package/crates/team-agent/src/messaging/delivery.rs +574 -12
  27. package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
  28. package/crates/team-agent/src/messaging/mod.rs +1 -1
  29. package/crates/team-agent/src/messaging/results.rs +218 -49
  30. package/crates/team-agent/src/messaging/send.rs +15 -19
  31. package/crates/team-agent/src/provider/adapter.rs +95 -10
  32. package/crates/team-agent/src/provider/helpers.rs +10 -1
  33. package/crates/team-agent/src/state/identity.rs +3 -0
  34. package/crates/team-agent/src/state/persist.rs +113 -1
  35. package/crates/team-agent/src/state/projection.rs +127 -3
  36. package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
  37. package/crates/team-agent/src/tmux_backend.rs +124 -12
  38. package/npm/install.mjs +29 -7
  39. package/package.json +4 -4
@@ -277,15 +277,19 @@ impl ProviderAdapter for BasicProviderAdapter {
277
277
  spawn_cwd: &Path,
278
278
  _timeout_s: u64,
279
279
  ) -> Result<Option<CapturedSession>, ProviderError> {
280
- let candidates = candidate_session_files(spawn_cwd, agent_id)?;
281
- for path in candidates {
280
+ let candidates = candidate_session_files(self.provider, spawn_cwd, agent_id)?;
281
+ for candidate in candidates {
282
+ let path = candidate.path;
282
283
  let Ok(text) = std::fs::read_to_string(&path) else {
283
284
  continue;
284
285
  };
285
- let records = parse_jsonl_records(&text);
286
+ let records = parse_session_records(&text);
286
287
  if records.is_empty() {
287
288
  continue;
288
289
  }
290
+ if candidate.requires_cwd_match && !provider_home_records_match_spawn_cwd(&records, spawn_cwd) {
291
+ continue;
292
+ }
289
293
  let session_id = records.iter().find_map(find_session_id);
290
294
  if matches!(self.provider, Provider::Claude | Provider::ClaudeCode)
291
295
  && session_id.is_some()
@@ -518,18 +522,56 @@ fn command_on_path(name: &str) -> bool {
518
522
  std::env::split_paths(&path).any(|dir| dir.join(name).is_file())
519
523
  }
520
524
 
521
- fn candidate_session_files(spawn_cwd: &Path, agent_id: &str) -> Result<Vec<PathBuf>, ProviderError> {
525
+ struct SessionCandidate {
526
+ path: PathBuf,
527
+ requires_cwd_match: bool,
528
+ }
529
+
530
+ fn candidate_session_files(
531
+ provider: Provider,
532
+ spawn_cwd: &Path,
533
+ agent_id: &str,
534
+ ) -> Result<Vec<SessionCandidate>, ProviderError> {
522
535
  let mut out = Vec::new();
523
- collect_candidate_files(spawn_cwd, agent_id, 0, &mut out)?;
524
- out.sort_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy()));
536
+ collect_candidate_files(spawn_cwd, agent_id, 0, false, &mut out)?;
537
+ if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
538
+ match provider {
539
+ Provider::Codex => {
540
+ collect_optional_candidate_files(&home.join(".codex").join("sessions"), agent_id, &mut out)?;
541
+ }
542
+ Provider::Claude | Provider::ClaudeCode => {
543
+ collect_optional_candidate_files(&home.join(".claude").join("sessions"), agent_id, &mut out)?;
544
+ collect_optional_candidate_files(&home.join(".claude").join("projects"), agent_id, &mut out)?;
545
+ }
546
+ Provider::GeminiCli | Provider::Fake => {}
547
+ }
548
+ }
549
+ out.sort_by(|a, b| {
550
+ a.requires_cwd_match
551
+ .cmp(&b.requires_cwd_match)
552
+ .then_with(|| a.path.to_string_lossy().cmp(&b.path.to_string_lossy()))
553
+ });
554
+ out.dedup_by(|a, b| a.path == b.path && a.requires_cwd_match == b.requires_cwd_match);
525
555
  Ok(out)
526
556
  }
527
557
 
558
+ fn collect_optional_candidate_files(
559
+ dir: &Path,
560
+ agent_id: &str,
561
+ out: &mut Vec<SessionCandidate>,
562
+ ) -> Result<(), ProviderError> {
563
+ if dir.exists() {
564
+ let _ = collect_candidate_files(dir, agent_id, 0, true, out);
565
+ }
566
+ Ok(())
567
+ }
568
+
528
569
  fn collect_candidate_files(
529
570
  dir: &Path,
530
571
  agent_id: &str,
531
572
  depth: usize,
532
- out: &mut Vec<PathBuf>,
573
+ requires_cwd_match: bool,
574
+ out: &mut Vec<SessionCandidate>,
533
575
  ) -> Result<(), ProviderError> {
534
576
  if depth > 4 {
535
577
  return Ok(());
@@ -545,9 +587,12 @@ fn collect_candidate_files(
545
587
  };
546
588
  let path = entry.path();
547
589
  if path.is_dir() {
548
- collect_candidate_files(&path, agent_id, depth.saturating_add(1), out)?;
590
+ collect_candidate_files(&path, agent_id, depth.saturating_add(1), requires_cwd_match, out)?;
549
591
  } else if looks_like_session_file(&path, agent_id) {
550
- out.push(path);
592
+ out.push(SessionCandidate {
593
+ path,
594
+ requires_cwd_match,
595
+ });
551
596
  }
552
597
  }
553
598
  Ok(())
@@ -572,6 +617,46 @@ fn looks_like_session_file(path: &Path, agent_id: &str) -> bool {
572
617
  || (!agent_id.is_empty() && name.contains(agent_id))
573
618
  }
574
619
 
620
+ fn parse_session_records(text: &str) -> Vec<serde_json::Value> {
621
+ match serde_json::from_str::<serde_json::Value>(text) {
622
+ Ok(serde_json::Value::Array(items)) => items,
623
+ Ok(value) => vec![value],
624
+ Err(_) => parse_jsonl_records(text),
625
+ }
626
+ }
627
+
628
+ fn provider_home_records_match_spawn_cwd(records: &[serde_json::Value], spawn_cwd: &Path) -> bool {
629
+ let cwd_values: Vec<String> = records.iter().filter_map(record_cwd).collect();
630
+ !cwd_values.is_empty()
631
+ && cwd_values
632
+ .iter()
633
+ .any(|cwd| paths_equivalent(Path::new(cwd), spawn_cwd))
634
+ }
635
+
636
+ fn record_cwd(record: &serde_json::Value) -> Option<String> {
637
+ record
638
+ .get("cwd")
639
+ .and_then(serde_json::Value::as_str)
640
+ .or_else(|| {
641
+ record
642
+ .get("session_meta")
643
+ .and_then(|v| v.get("payload"))
644
+ .or_else(|| record.get("payload"))
645
+ .and_then(|v| v.get("cwd"))
646
+ .and_then(serde_json::Value::as_str)
647
+ })
648
+ .map(ToString::to_string)
649
+ }
650
+
651
+ fn paths_equivalent(left: &Path, right: &Path) -> bool {
652
+ if left == right {
653
+ return true;
654
+ }
655
+ let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
656
+ let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
657
+ left == right || left.starts_with(&right)
658
+ }
659
+
575
660
  /// `true` iff any path component is `.team` (the Team Agent runtime/logs root) — used
576
661
  /// to gate session-file detection so `<workspace>/.team/logs/events.jsonl`,
577
662
  /// `.team/runtime/team.db`, etc. are NEVER mistaken for a provider transcript.
@@ -790,7 +875,7 @@ fn current_team_agent_command() -> String {
790
875
  }
791
876
 
792
877
  fn has_cwd_field(record: &serde_json::Value) -> bool {
793
- record.get("cwd").and_then(serde_json::Value::as_str).is_some()
878
+ record_cwd(record).is_some()
794
879
  }
795
880
 
796
881
  fn next_session_token() -> String {
@@ -22,9 +22,18 @@ pub(crate) fn find_session_id(record: &serde_json::Value) -> Option<String> {
22
22
  if let Some(s) = record.get("sessionId").and_then(serde_json::Value::as_str) {
23
23
  return Some(s.to_string());
24
24
  }
25
- record
25
+ if let Some(s) = record
26
26
  .get("session_id")
27
27
  .and_then(serde_json::Value::as_str)
28
+ {
29
+ return Some(s.to_string());
30
+ }
31
+ record
32
+ .get("session_meta")
33
+ .and_then(|v| v.get("payload"))
34
+ .or_else(|| record.get("payload"))
35
+ .and_then(|v| v.get("id"))
36
+ .and_then(serde_json::Value::as_str)
28
37
  .map(ToString::to_string)
29
38
  }
30
39
 
@@ -332,6 +332,9 @@ pub fn apply_first_time_leader_binding(
332
332
  r.insert("leader_session_uuid".to_string(), id_uuid.clone());
333
333
  r.insert("machine_fingerprint".to_string(), id_fp.clone());
334
334
  r.insert("owner_epoch".to_string(), json!(0));
335
+ if let Some(socket) = crate::tmux_backend::socket_name_from_tmux_env() {
336
+ r.insert("tmux_socket".to_string(), json!(socket));
337
+ }
335
338
  }
336
339
  let owner = json!({
337
340
  "pane_id": receiver.get("pane_id").cloned().unwrap_or(Value::Null),
@@ -20,7 +20,7 @@
20
20
  //! (`identity::migrate_state_identity`,补 leader_session_uuid)→ `_migrate_active_team_key`
21
21
  //! (seed active 指针);任一改动 → `save_runtime_state` 回写。不存在且命中缓存 → 返回缓存 deepcopy。
22
22
 
23
- use std::collections::HashMap;
23
+ use std::collections::{BTreeSet, HashMap};
24
24
  use std::io;
25
25
  use std::path::{Path, PathBuf};
26
26
  use std::sync::{LazyLock, Mutex};
@@ -170,6 +170,14 @@ impl Drop for RuntimeLock {
170
170
  /// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
171
171
  /// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
172
172
  pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
173
+ save_runtime_state_with_deleted_agents(workspace, state, &[])
174
+ }
175
+
176
+ pub(crate) fn save_runtime_state_with_deleted_agents(
177
+ workspace: &Path,
178
+ state: &Value,
179
+ deleted_agent_ids: &[&str],
180
+ ) -> Result<(), StateError> {
173
181
  let path = runtime_state_path(workspace);
174
182
  if cache_equals(&path, state) {
175
183
  return Ok(());
@@ -203,6 +211,15 @@ pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateEr
203
211
  if let Some(parent) = path.parent() {
204
212
  std::fs::create_dir_all(parent)?;
205
213
  }
214
+ if let Some(latest) = read_latest_state_under_lock(workspace, &path) {
215
+ let deleted = deleted_agent_ids
216
+ .iter()
217
+ .copied()
218
+ .filter(|id| !id.is_empty())
219
+ .map(str::to_string)
220
+ .collect::<BTreeSet<_>>();
221
+ preserve_latest_roster_entries(&mut migrated, &latest, &deleted);
222
+ }
206
223
  // 字节对拍 Python json.dumps(indent=2, ensure_ascii=False)(无尾换行)。
207
224
  let payload = serde_json::to_string_pretty(&migrated)?;
208
225
  let delays = [0.05_f64, 0.2, 0.5];
@@ -243,6 +260,101 @@ pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateEr
243
260
  Err(StateError::SaveFailed("retry loop exhausted without return".to_string()))
244
261
  }
245
262
 
263
+ fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value> {
264
+ let text = std::fs::read_to_string(path).ok()?;
265
+ let mut latest = serde_json::from_str::<Value>(&text).ok()?;
266
+ normalize_agent_session_state(&mut latest);
267
+ let _ = migrate_state_identity(&mut latest, &SystemEnv, workspace);
268
+ let _ = migrate_active_team_key(&mut latest);
269
+ Some(latest)
270
+ }
271
+
272
+ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_agent_ids: &BTreeSet<String>) {
273
+ if !same_runtime_projection(incoming, latest) {
274
+ return;
275
+ }
276
+ preserve_missing_agents(incoming.get_mut("agents"), latest.get("agents"), deleted_agent_ids);
277
+
278
+ let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
279
+ if let Some(active_team) = active_team.as_deref() {
280
+ let latest_active_agents = latest
281
+ .get("teams")
282
+ .and_then(Value::as_object)
283
+ .and_then(|teams| teams.get(active_team))
284
+ .and_then(|entry| entry.get("agents"));
285
+ preserve_missing_agents(incoming.get_mut("agents"), latest_active_agents, deleted_agent_ids);
286
+ }
287
+
288
+ let latest_teams = latest.get("teams").and_then(Value::as_object);
289
+ let Some(incoming_teams) = incoming.get_mut("teams").and_then(Value::as_object_mut) else {
290
+ return;
291
+ };
292
+ if let Some(latest_teams) = latest_teams {
293
+ for (team, latest_entry) in latest_teams {
294
+ let Some(incoming_entry) = incoming_teams.get_mut(team) else {
295
+ continue;
296
+ };
297
+ preserve_missing_agents(
298
+ incoming_entry.get_mut("agents"),
299
+ latest_entry.get("agents"),
300
+ deleted_agent_ids,
301
+ );
302
+ }
303
+ }
304
+ if let Some(active_team) = active_team.as_deref() {
305
+ let latest_top_agents = latest.get("agents");
306
+ if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
307
+ preserve_missing_agents(incoming_entry.get_mut("agents"), latest_top_agents, deleted_agent_ids);
308
+ }
309
+ }
310
+ }
311
+
312
+ fn preserve_missing_agents(
313
+ incoming_agents: Option<&mut Value>,
314
+ latest_agents: Option<&Value>,
315
+ deleted_agent_ids: &BTreeSet<String>,
316
+ ) {
317
+ let Some(incoming_agents) = incoming_agents else {
318
+ return;
319
+ };
320
+ let Some(incoming_map) = incoming_agents.as_object_mut() else {
321
+ return;
322
+ };
323
+ let Some(latest_map) = latest_agents.and_then(Value::as_object) else {
324
+ return;
325
+ };
326
+ for (agent_id, latest_agent) in latest_map {
327
+ if deleted_agent_ids.contains(agent_id) {
328
+ continue;
329
+ }
330
+ incoming_map
331
+ .entry(agent_id.clone())
332
+ .or_insert_with(|| latest_agent.clone());
333
+ }
334
+ }
335
+
336
+ fn same_runtime_projection(left: &Value, right: &Value) -> bool {
337
+ let left_session = left.get("session_name").and_then(Value::as_str);
338
+ let right_session = right.get("session_name").and_then(Value::as_str);
339
+ if left_session.is_some() && right_session.is_some() && left_session != right_session {
340
+ return false;
341
+ }
342
+ let left_team = active_team_key(left);
343
+ let right_team = active_team_key(right);
344
+ if left_team.is_some() && right_team.is_some() && left_team != right_team {
345
+ return false;
346
+ }
347
+ true
348
+ }
349
+
350
+ fn active_team_key(state: &Value) -> Option<String> {
351
+ state
352
+ .get("active_team_key")
353
+ .and_then(Value::as_str)
354
+ .filter(|team| !team.is_empty() && *team != "current")
355
+ .map(str::to_string)
356
+ }
357
+
246
358
  /// `_self_heal_runtime_state`:重建 inode(heal-tmp + backup-rename),绝不 in-place truncate。
247
359
  fn self_heal(
248
360
  workspace: &Path,
@@ -14,7 +14,7 @@ use std::path::Path;
14
14
  use serde_json::{json, Map, Value};
15
15
 
16
16
  use super::StateError;
17
- use crate::state::persist::{load_runtime_state, save_runtime_state};
17
+ use crate::state::persist::{load_runtime_state, save_runtime_state_with_deleted_agents};
18
18
 
19
19
  /// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
20
20
  /// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。
@@ -43,6 +43,122 @@ pub fn team_state_key(state: &Value) -> String {
43
43
  .map_or_else(|| "current".to_string(), str::to_string)
44
44
  }
45
45
 
46
+ #[derive(Debug, Clone, PartialEq, Eq)]
47
+ pub enum OwnerTeamResolution {
48
+ Canonical(String),
49
+ LegacyAlias { requested: String, canonical: String },
50
+ Unresolved { requested: String },
51
+ Ambiguous { requested: String, matches: Vec<String> },
52
+ }
53
+
54
+ impl OwnerTeamResolution {
55
+ pub fn canonical_key(&self) -> Option<&str> {
56
+ match self {
57
+ OwnerTeamResolution::Canonical(key)
58
+ | OwnerTeamResolution::LegacyAlias { canonical: key, .. } => Some(key),
59
+ OwnerTeamResolution::Unresolved { .. } | OwnerTeamResolution::Ambiguous { .. } => None,
60
+ }
61
+ }
62
+ }
63
+
64
+ pub fn resolve_owner_team_id(state: &Value, owner_team_id: &str) -> OwnerTeamResolution {
65
+ let requested = owner_team_id.trim();
66
+ if requested.is_empty() {
67
+ return OwnerTeamResolution::Unresolved { requested: owner_team_id.to_string() };
68
+ }
69
+ let teams = state.get("teams").and_then(Value::as_object);
70
+ if teams.is_some_and(|teams| teams.contains_key(requested)) {
71
+ if has_top_level_runtime_content(state) {
72
+ return OwnerTeamResolution::Canonical(requested.to_string());
73
+ }
74
+ }
75
+ if teams.is_none_or(Map::is_empty) {
76
+ let active = state.get("active_team_key").and_then(Value::as_str).unwrap_or("");
77
+ let derived = team_state_key(state);
78
+ if active == requested || derived == requested {
79
+ return OwnerTeamResolution::Canonical(requested.to_string());
80
+ }
81
+ if !active.is_empty() {
82
+ return OwnerTeamResolution::LegacyAlias {
83
+ requested: requested.to_string(),
84
+ canonical: active.to_string(),
85
+ };
86
+ }
87
+ if derived != "current" {
88
+ return OwnerTeamResolution::LegacyAlias {
89
+ requested: requested.to_string(),
90
+ canonical: derived,
91
+ };
92
+ }
93
+ return OwnerTeamResolution::Canonical(requested.to_string());
94
+ }
95
+ let Some(teams) = teams else {
96
+ return OwnerTeamResolution::Unresolved { requested: requested.to_string() };
97
+ };
98
+ let mut matches = Vec::new();
99
+ for (key, entry) in teams {
100
+ if legacy_owner_team_aliases(entry).any(|alias| alias == requested) {
101
+ matches.push(key.clone());
102
+ }
103
+ }
104
+ matches.sort();
105
+ matches.dedup();
106
+ match matches.len() {
107
+ 0 => OwnerTeamResolution::Unresolved { requested: requested.to_string() },
108
+ 1 => OwnerTeamResolution::LegacyAlias {
109
+ requested: requested.to_string(),
110
+ canonical: matches.remove(0),
111
+ },
112
+ _ => OwnerTeamResolution::Ambiguous { requested: requested.to_string(), matches },
113
+ }
114
+ }
115
+
116
+ fn has_top_level_runtime_content(state: &Value) -> bool {
117
+ [
118
+ "session_name",
119
+ "team_dir",
120
+ "spec_path",
121
+ "workspace",
122
+ "agents",
123
+ "tasks",
124
+ "leader_receiver",
125
+ "team_owner",
126
+ ]
127
+ .into_iter()
128
+ .any(|key| state.get(key).is_some_and(super::json_truthy))
129
+ }
130
+
131
+ fn legacy_owner_team_aliases(entry: &Value) -> impl Iterator<Item = String> + '_ {
132
+ let scalar_paths = [
133
+ "/team/name",
134
+ "/team/id",
135
+ "/name",
136
+ "/team_name",
137
+ "/team_id",
138
+ "/spec_name",
139
+ "/legacy_owner_team_id",
140
+ "/legacy_team_id",
141
+ "/legacy_team_name",
142
+ "/legacy_alias",
143
+ ];
144
+ let list_paths = ["/legacy_aliases", "/legacy_team_aliases", "/legacy_owner_team_ids", "/aliases"];
145
+ let scalars = scalar_paths
146
+ .into_iter()
147
+ .filter_map(|path| entry.pointer(path).and_then(Value::as_str));
148
+ let lists = list_paths.into_iter().flat_map(|path| {
149
+ entry
150
+ .pointer(path)
151
+ .and_then(Value::as_array)
152
+ .into_iter()
153
+ .flatten()
154
+ .filter_map(Value::as_str)
155
+ });
156
+ scalars
157
+ .chain(lists)
158
+ .filter(|alias| !alias.is_empty())
159
+ .map(str::to_string)
160
+ }
161
+
46
162
  /// `compact_team_state`(`state.py:105`):剔除 `teams`(team entry 不嵌套全量 teams),保序。
47
163
  pub fn compact_team_state(state: &Value) -> Value {
48
164
  match state.as_object() {
@@ -339,6 +455,14 @@ pub fn resolve_team_scoped_state(
339
455
  /// 纯 `save_runtime_state`(字节等价);多 team 时把本 team 落到 `teams[target_key]=compact(...)`,顶层
340
456
  /// 视图按 golden 的 `existing_primary_key` 逻辑择 incoming/existing。§10:无 unwrap/panic。
341
457
  pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<(), StateError> {
458
+ save_team_scoped_state_with_deleted_agents(workspace, team_state, &[])
459
+ }
460
+
461
+ pub(crate) fn save_team_scoped_state_with_deleted_agents(
462
+ workspace: &Path,
463
+ team_state: &Value,
464
+ deleted_agent_ids: &[&str],
465
+ ) -> Result<(), StateError> {
342
466
  let target_key = team_state_key(team_state);
343
467
  let existing = load_runtime_state(workspace)?;
344
468
  // existing_primary_key = team_state_key(existing) if existing.get("session_name") else None
@@ -367,7 +491,7 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
367
491
  // not existing_teams and existing_primary_key == target_key → 纯 save(剔 teams)。
368
492
  if existing_teams.is_empty() && existing_primary_key.as_deref() == Some(target_key.as_str()) {
369
493
  let merged = compact_team_state(team_state);
370
- return save_runtime_state(workspace, &merged);
494
+ return save_runtime_state_with_deleted_agents(workspace, &merged, deleted_agent_ids);
371
495
  }
372
496
  // teams = deepcopy(incoming_teams or existing_teams)
373
497
  let mut teams = match incoming_teams {
@@ -387,7 +511,7 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
387
511
  if merged.get("teams").and_then(Value::as_object).is_some_and(Map::is_empty) {
388
512
  merged.remove("teams");
389
513
  }
390
- save_runtime_state(workspace, &Value::Object(merged))
514
+ save_runtime_state_with_deleted_agents(workspace, &Value::Object(merged), deleted_agent_ids)
391
515
  }
392
516
 
393
517
  // ---- helpers ----
@@ -98,6 +98,185 @@
98
98
  items.iter().map(|s| (*s).to_string()).collect()
99
99
  }
100
100
 
101
+ struct EnvGuard {
102
+ saved: Vec<(String, Option<String>)>,
103
+ }
104
+
105
+ impl EnvGuard {
106
+ fn apply(vars: &[(&str, Option<&str>)]) -> Self {
107
+ let saved = vars.iter().map(|(k, _)| ((*k).to_string(), std::env::var(k).ok())).collect();
108
+ for (k, v) in vars {
109
+ match v {
110
+ Some(val) => std::env::set_var(k, val),
111
+ None => std::env::remove_var(k),
112
+ }
113
+ }
114
+ Self { saved }
115
+ }
116
+ }
117
+
118
+ impl Drop for EnvGuard {
119
+ fn drop(&mut self) {
120
+ for (k, v) in &self.saved {
121
+ match v {
122
+ Some(val) => std::env::set_var(k, val),
123
+ None => std::env::remove_var(k),
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ #[test]
130
+ #[serial_test::serial(env)]
131
+ fn leader_receiver_endpoint_from_tmux_env_preserves_full_socket_path() {
132
+ let leader_socket = "/tmp/ta-leader-root/tmux-501/dl2f";
133
+ let _env = EnvGuard::apply(&[
134
+ ("TMUX", Some("/tmp/ta-leader-root/tmux-501/dl2f,12345,0")),
135
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
136
+ ]);
137
+
138
+ assert_eq!(
139
+ super::socket_name_from_tmux_env().as_deref(),
140
+ Some(leader_socket),
141
+ "leader receivers must persist the exact tmux endpoint from $TMUX; a short -L socket \
142
+ name is re-rooted under the coordinator's TMUX_TMPDIR and cannot reach an external \
143
+ leader pane"
144
+ );
145
+ }
146
+
147
+ #[test]
148
+ #[serial_test::serial(env)]
149
+ fn leader_receiver_endpoint_from_tmux_env_rejects_short_socket_name() {
150
+ let _env = EnvGuard::apply(&[
151
+ ("TMUX", Some("dl9aa40c88,12345,0")),
152
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
153
+ ]);
154
+
155
+ assert_eq!(
156
+ super::socket_name_from_tmux_env(),
157
+ None,
158
+ "leader_receiver.tmux_socket is a durable physical endpoint: a short socket name from \
159
+ $TMUX must not be persisted because tmux -L <short> is re-rooted under the coordinator"
160
+ );
161
+ }
162
+
163
+ #[test]
164
+ fn leader_receiver_delivery_uses_full_socket_endpoint_not_short_l_reconstruction() {
165
+ let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
166
+ let delivery = std::fs::read_to_string(manifest.join("src/messaging/delivery.rs")).unwrap();
167
+ let leader_receiver =
168
+ std::fs::read_to_string(manifest.join("src/messaging/leader_receiver.rs")).unwrap();
169
+ let tmux_backend = std::fs::read_to_string(manifest.join("src/tmux_backend.rs")).unwrap();
170
+
171
+ assert!(
172
+ tmux_backend.contains("\"-S\""),
173
+ "tmux backend must support `tmux -S <full-socket-path>` for persisted external leader \
174
+ endpoints; `-L <short-name>` is not enough when leader and coordinator TMUX_TMPDIR differ"
175
+ );
176
+ assert!(
177
+ !delivery.contains("TmuxBackend::for_socket_name(socket)"),
178
+ "worker->leader delivery must not reconstruct an external leader endpoint with \
179
+ `tmux -L <short-name>`; it must use the persisted full socket path endpoint"
180
+ );
181
+ assert!(
182
+ !leader_receiver.contains("TmuxBackend::for_socket_name(socket)"),
183
+ "leader_receiver live checks must verify the same full socket endpoint used by delivery, \
184
+ not a short socket name resolved under the coordinator's socket root"
185
+ );
186
+ }
187
+
188
+ #[test]
189
+ fn leader_receiver_full_endpoint_liveness_list_and_inject_use_s_path_command_shape() {
190
+ let endpoint = "/private/tmp/tmux-501/default";
191
+ let stdout = "%7\tteam-x\t0\tleader\t0\t/dev/ttys003\tbash\t1\t/Users/me/work\t1\t0\n";
192
+ let (be, rec, _stdin) = {
193
+ let recorded = Arc::new(Mutex::new(Vec::new()));
194
+ let stdin_recorded = Arc::new(Mutex::new(Vec::new()));
195
+ let runner = MockCommandRunner {
196
+ recorded: Arc::clone(&recorded),
197
+ stdin_recorded: Arc::clone(&stdin_recorded),
198
+ queue: Mutex::new(
199
+ vec![
200
+ MockResp::Out(ok(stdout)),
201
+ MockResp::Out(ok("%7\n")),
202
+ MockResp::Out(ok("")),
203
+ MockResp::Out(ok("")),
204
+ MockResp::Out(ok("")),
205
+ ]
206
+ .into_iter()
207
+ .collect(),
208
+ ),
209
+ default: MockResp::Out(ok("")),
210
+ };
211
+ (
212
+ TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), endpoint),
213
+ recorded,
214
+ stdin_recorded,
215
+ )
216
+ };
217
+
218
+ let _ = be.list_targets().expect("list_targets via endpoint");
219
+ let _ = be.liveness(&PaneId::new("%7")).expect("liveness via endpoint");
220
+ let _ = be
221
+ .inject(
222
+ &Target::Pane(PaneId::new("%7")),
223
+ &InjectPayload::Text("hello leader".to_string()),
224
+ Key::Enter,
225
+ true,
226
+ )
227
+ .expect("inject via endpoint");
228
+
229
+ let calls = rec.lock().unwrap().clone();
230
+ assert!(
231
+ calls.len() >= 5,
232
+ "fixture must exercise list-panes, display-message, buffer/paste, and send-keys; got {calls:?}"
233
+ );
234
+ for call in &calls {
235
+ assert!(
236
+ call.starts_with(&["tmux".to_string(), "-S".to_string(), endpoint.to_string()]),
237
+ "leader receiver list/liveness/inject must use tmux -S <full socket path>; got {call:?}"
238
+ );
239
+ assert!(
240
+ !call.windows(2).any(|w| w == ["-L".to_string(), endpoint.to_string()]),
241
+ "leader receiver full endpoint must never be reconstructed with -L; got {call:?}"
242
+ );
243
+ }
244
+ assert!(
245
+ calls.iter().any(|call| call.iter().any(|arg| arg == "list-panes"))
246
+ && calls.iter().any(|call| call.iter().any(|arg| arg == "display-message"))
247
+ && calls.iter().any(|call| call.iter().any(|arg| arg == "paste-buffer"))
248
+ && calls.iter().any(|call| call.iter().any(|arg| arg == "send-keys")),
249
+ "contract must cover liveness/list/inject command shapes; got {calls:?}"
250
+ );
251
+ }
252
+
253
+ #[test]
254
+ fn leader_receiver_short_endpoint_must_not_reconstruct_tmux_l_socket() {
255
+ let endpoint = "dl9aa40c88";
256
+ let (be, rec) = {
257
+ let recorded = Arc::new(Mutex::new(Vec::new()));
258
+ let runner = MockCommandRunner {
259
+ recorded: Arc::clone(&recorded),
260
+ stdin_recorded: Arc::new(Mutex::new(Vec::new())),
261
+ queue: Mutex::new(vec![MockResp::Out(ok(""))].into_iter().collect()),
262
+ default: MockResp::Out(ok("")),
263
+ };
264
+ (
265
+ TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), endpoint),
266
+ recorded,
267
+ )
268
+ };
269
+
270
+ let _ = be.list_targets().expect("short endpoint should not become -L");
271
+
272
+ let calls = rec.lock().unwrap().clone();
273
+ assert!(
274
+ calls.iter().all(|call| !call.windows(2).any(|w| w == ["-L".to_string(), endpoint.to_string()])),
275
+ "non-canonical leader endpoints must be rejected or left unbound, never reconstructed as \
276
+ tmux -L <short> under the coordinator socket root; calls={calls:?}"
277
+ );
278
+ }
279
+
101
280
  // ── 1. has_session: exit 0 -> true, exit 1 -> false; argv = `tmux has-session -t <s>` ──────────
102
281
  #[test]
103
282
  fn has_session_argv_and_exit_code_maps_to_bool() {