@team-agent/installer 0.4.11 → 0.5.1

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 (80) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +98 -5
  6. package/crates/team-agent/src/cli/mod.rs +2 -0
  7. package/crates/team-agent/src/cli/named_address.rs +864 -0
  8. package/crates/team-agent/src/cli/send.rs +104 -0
  9. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
  12. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  13. package/crates/team-agent/src/cli/types.rs +4 -0
  14. package/crates/team-agent/src/compiler.rs +11 -20
  15. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  16. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  17. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  19. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  20. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  21. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  22. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  23. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  24. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  25. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  26. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  27. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  28. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  29. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  30. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  31. package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
  32. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  33. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  34. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  35. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  36. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  38. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  41. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
  42. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  43. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  44. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  45. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  46. package/crates/team-agent/src/messaging/activity.rs +43 -18
  47. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  48. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  49. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  50. package/crates/team-agent/src/messaging/results.rs +34 -9
  51. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  52. package/crates/team-agent/src/model/spec.rs +10 -6
  53. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  54. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  55. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  56. package/crates/team-agent/src/provider/classify.rs +23 -48
  57. package/crates/team-agent/src/provider/command.rs +16 -7
  58. package/crates/team-agent/src/provider/faults.rs +93 -53
  59. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  60. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  61. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  62. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  63. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  64. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  65. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  66. package/crates/team-agent/src/provider/types.rs +33 -1
  67. package/crates/team-agent/src/provider/wire.rs +171 -1
  68. package/crates/team-agent/src/state/identity.rs +242 -57
  69. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  70. package/crates/team-agent/src/state/mod.rs +5 -1
  71. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  72. package/crates/team-agent/src/state/ownership.rs +12 -11
  73. package/crates/team-agent/src/state/paths.rs +13 -6
  74. package/crates/team-agent/src/state/persist.rs +671 -128
  75. package/crates/team-agent/src/state/projection.rs +240 -49
  76. package/crates/team-agent/src/state/selector.rs +11 -5
  77. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  78. package/crates/team-agent/src/tmux_backend.rs +42 -5
  79. package/crates/team-agent/src/transport/test_support.rs +55 -6
  80. package/package.json +4 -4
@@ -14,7 +14,12 @@ 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_with_deleted_agents, save_runtime_state_with_team_tombstoned_agents};
17
+ use crate::state::persist::{
18
+ load_runtime_state, save_runtime_state_with_deleted_agents,
19
+ save_runtime_state_with_lifecycle_topology_authority,
20
+ save_runtime_state_with_team_tombstone_lifecycle_topology_authority,
21
+ save_runtime_state_with_team_tombstoned_agents,
22
+ };
18
23
 
19
24
  /// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
20
25
  /// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。
@@ -38,7 +43,9 @@ pub fn team_state_key(state: &Value) -> String {
38
43
  } else {
39
44
  path.parent().and_then(Path::file_name)
40
45
  };
41
- let key = key.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
46
+ let key = key
47
+ .map(|s| s.to_string_lossy().into_owned())
48
+ .unwrap_or_default();
42
49
  if !key.is_empty() && key != ".team" && key != "runtime" {
43
50
  return key;
44
51
  }
@@ -53,9 +60,17 @@ pub fn team_state_key(state: &Value) -> String {
53
60
  #[derive(Debug, Clone, PartialEq, Eq)]
54
61
  pub enum OwnerTeamResolution {
55
62
  Canonical(String),
56
- LegacyAlias { requested: String, canonical: String },
57
- Unresolved { requested: String },
58
- Ambiguous { requested: String, matches: Vec<String> },
63
+ LegacyAlias {
64
+ requested: String,
65
+ canonical: String,
66
+ },
67
+ Unresolved {
68
+ requested: String,
69
+ },
70
+ Ambiguous {
71
+ requested: String,
72
+ matches: Vec<String>,
73
+ },
59
74
  }
60
75
 
61
76
  impl OwnerTeamResolution {
@@ -71,14 +86,19 @@ impl OwnerTeamResolution {
71
86
  pub fn resolve_owner_team_id(state: &Value, owner_team_id: &str) -> OwnerTeamResolution {
72
87
  let requested = owner_team_id.trim();
73
88
  if requested.is_empty() {
74
- return OwnerTeamResolution::Unresolved { requested: owner_team_id.to_string() };
89
+ return OwnerTeamResolution::Unresolved {
90
+ requested: owner_team_id.to_string(),
91
+ };
75
92
  }
76
93
  let teams = state.get("teams").and_then(Value::as_object);
77
94
  if teams.is_some_and(|teams| teams.contains_key(requested)) {
78
95
  return OwnerTeamResolution::Canonical(requested.to_string());
79
96
  }
80
97
  if teams.is_none_or(Map::is_empty) {
81
- let active = state.get("active_team_key").and_then(Value::as_str).unwrap_or("");
98
+ let active = state
99
+ .get("active_team_key")
100
+ .and_then(Value::as_str)
101
+ .unwrap_or("");
82
102
  let derived = team_state_key(state);
83
103
  if active == requested || derived == requested {
84
104
  return OwnerTeamResolution::Canonical(requested.to_string());
@@ -98,7 +118,9 @@ pub fn resolve_owner_team_id(state: &Value, owner_team_id: &str) -> OwnerTeamRes
98
118
  return OwnerTeamResolution::Canonical(requested.to_string());
99
119
  }
100
120
  let Some(teams) = teams else {
101
- return OwnerTeamResolution::Unresolved { requested: requested.to_string() };
121
+ return OwnerTeamResolution::Unresolved {
122
+ requested: requested.to_string(),
123
+ };
102
124
  };
103
125
  let mut matches = Vec::new();
104
126
  for (key, entry) in teams {
@@ -109,12 +131,17 @@ pub fn resolve_owner_team_id(state: &Value, owner_team_id: &str) -> OwnerTeamRes
109
131
  matches.sort();
110
132
  matches.dedup();
111
133
  match matches.len() {
112
- 0 => OwnerTeamResolution::Unresolved { requested: requested.to_string() },
134
+ 0 => OwnerTeamResolution::Unresolved {
135
+ requested: requested.to_string(),
136
+ },
113
137
  1 => OwnerTeamResolution::LegacyAlias {
114
138
  requested: requested.to_string(),
115
139
  canonical: matches.remove(0),
116
140
  },
117
- _ => OwnerTeamResolution::Ambiguous { requested: requested.to_string(), matches },
141
+ _ => OwnerTeamResolution::Ambiguous {
142
+ requested: requested.to_string(),
143
+ matches,
144
+ },
118
145
  }
119
146
  }
120
147
 
@@ -131,7 +158,12 @@ fn legacy_owner_team_aliases(entry: &Value) -> impl Iterator<Item = String> + '_
131
158
  "/legacy_team_name",
132
159
  "/legacy_alias",
133
160
  ];
134
- let list_paths = ["/legacy_aliases", "/legacy_team_aliases", "/legacy_owner_team_ids", "/aliases"];
161
+ let list_paths = [
162
+ "/legacy_aliases",
163
+ "/legacy_team_aliases",
164
+ "/legacy_owner_team_ids",
165
+ "/aliases",
166
+ ];
135
167
  let scalars = scalar_paths
136
168
  .into_iter()
137
169
  .filter_map(|path| entry.pointer(path).and_then(Value::as_str));
@@ -196,7 +228,9 @@ pub fn merge_workspace_team_state(existing: &Value, launched: &Value) -> Value {
196
228
  // 异 key → existing 为基,两个 team 都进 teams(existing 仅在缺时 seed)。
197
229
  let mut merged = to_object(existing);
198
230
  let mut teams = take_object(merged.get("teams"));
199
- teams.entry(existing_key).or_insert_with(|| compact_team_state(existing));
231
+ teams
232
+ .entry(existing_key)
233
+ .or_insert_with(|| compact_team_state(existing));
200
234
  teams.insert(launched_key, compact_team_state(launched));
201
235
  merged.insert("teams".to_string(), Value::Object(teams));
202
236
  Value::Object(merged)
@@ -250,7 +284,11 @@ pub fn format_team_candidates(team_states: &Map<String, Value>) -> String {
250
284
  let agents = if agent_keys.is_empty() {
251
285
  "-".to_string()
252
286
  } else {
253
- agent_keys.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(",")
287
+ agent_keys
288
+ .iter()
289
+ .map(|s| s.as_str())
290
+ .collect::<Vec<_>>()
291
+ .join(",")
254
292
  };
255
293
  let session = st
256
294
  .get("session_name")
@@ -294,23 +332,37 @@ fn valid_owner_pane_id(pane_id: &str) -> bool {
294
332
  /// `_project_top_level_view`(`state.py:167`,C8):把 `teams[team_key]` 投影成扁平顶层视图。
295
333
  pub fn project_top_level_view(state: &Value, team_key: &str) -> Value {
296
334
  // entry = _team_entry_from_state(...) or {} —— 空 dict 亦走 {} 分支(同内容)。
297
- let entry_obj: Map<String, Value> =
298
- team_entry_from_state(state, team_key).and_then(Value::as_object).cloned().unwrap_or_default();
335
+ let entry_obj: Map<String, Value> = team_entry_from_state(state, team_key)
336
+ .and_then(Value::as_object)
337
+ .cloned()
338
+ .unwrap_or_default();
299
339
  let mut p = entry_obj.clone(); // projection = deepcopy(entry)
300
- // setdefault session_name/team_dir ← entry.get(...)(缺则插,值可为 null)。
301
- // **0.3.24 excision** (U1-A real-machine RED v2): the ff38ab9 root-state
302
- // `or_else` fallback was reverted along with the wave-2 U1-A drift fallback,
303
- // since this projection change had no other consumer. v0.3.25 will re-introduce
304
- // this together with the writer-shape + rediscover-writer fix — see
305
- // `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
340
+ // setdefault session_name/team_dir ← entry.get(...)(缺则插,值可为 null)。
341
+ // **0.3.24 excision** (U1-A real-machine RED v2): the ff38ab9 root-state
342
+ // `or_else` fallback was reverted along with the wave-2 U1-A drift fallback,
343
+ // since this projection change had no other consumer. v0.3.25 will re-introduce
344
+ // this together with the writer-shape + rediscover-writer fix — see
345
+ // `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
306
346
  if !p.contains_key("session_name") {
307
- p.insert("session_name".to_string(), entry_obj.get("session_name").cloned().unwrap_or(Value::Null));
347
+ p.insert(
348
+ "session_name".to_string(),
349
+ entry_obj
350
+ .get("session_name")
351
+ .cloned()
352
+ .unwrap_or(Value::Null),
353
+ );
308
354
  }
309
355
  if !p.contains_key("team_dir") {
310
- p.insert("team_dir".to_string(), entry_obj.get("team_dir").cloned().unwrap_or(Value::Null));
356
+ p.insert(
357
+ "team_dir".to_string(),
358
+ entry_obj.get("team_dir").cloned().unwrap_or(Value::Null),
359
+ );
311
360
  }
312
361
  // d[k]=v:active_team_key 原位更新或末尾插。
313
- p.insert("active_team_key".to_string(), Value::String(team_key.to_string()));
362
+ p.insert(
363
+ "active_team_key".to_string(),
364
+ Value::String(team_key.to_string()),
365
+ );
314
366
  // 保全全量 teams 供消费者看兄弟 team(`state.get("teams") or {}` 的 truthy 语义)。
315
367
  p.insert("teams".to_string(), py_or_empty_map(state.get("teams")));
316
368
  let has_team_entries = state
@@ -336,8 +388,12 @@ pub fn project_top_level_view(state: &Value, team_key: &str) -> Value {
336
388
  p.insert("leader_receiver".to_string(), v.clone());
337
389
  }
338
390
  let _ = has_team_entries; // silence unused warning; kept for clarity.
339
- // coordinator:仅顶层有 key 时 setdefault(投影里没有才插)。
340
- if state.as_object().is_some_and(|o| o.contains_key("coordinator")) && !p.contains_key("coordinator") {
391
+ // coordinator:仅顶层有 key 时 setdefault(投影里没有才插)。
392
+ if state
393
+ .as_object()
394
+ .is_some_and(|o| o.contains_key("coordinator"))
395
+ && !p.contains_key("coordinator")
396
+ {
341
397
  p.insert("coordinator".to_string(), state["coordinator"].clone());
342
398
  }
343
399
  Value::Object(p)
@@ -354,12 +410,18 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
354
410
  if let Some(team) = team {
355
411
  // 无 alive 但 team 命中 active_team_key / 派生 key → 直接以全量 state 投影。
356
412
  if alive.is_empty() {
357
- let active = state.get("active_team_key").and_then(Value::as_str).unwrap_or("");
413
+ let active = state
414
+ .get("active_team_key")
415
+ .and_then(Value::as_str)
416
+ .unwrap_or("");
358
417
  if team == active || team == team_state_key(&state) {
359
418
  let mut projection = state.clone();
360
419
  match projection.as_object_mut() {
361
420
  Some(o) => {
362
- o.insert("active_team_key".to_string(), Value::String(team.to_string()));
421
+ o.insert(
422
+ "active_team_key".to_string(),
423
+ Value::String(team.to_string()),
424
+ );
363
425
  }
364
426
  None => projection = json!({ "active_team_key": team }),
365
427
  }
@@ -389,7 +451,10 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
389
451
  )));
390
452
  }
391
453
  // 无 team 参数:active 命中 → 投影;唯一 alive → 投影;无 alive → 全量;多 alive → 歧义。
392
- let active = state.get("active_team_key").and_then(Value::as_str).filter(|s| !s.is_empty());
454
+ let active = state
455
+ .get("active_team_key")
456
+ .and_then(Value::as_str)
457
+ .filter(|s| !s.is_empty());
393
458
  if let Some(active) = active {
394
459
  if alive.contains_key(active) {
395
460
  return Ok(project_top_level_view(&state, active));
@@ -412,7 +477,10 @@ fn team_selector_matches(team: &str, key: &str, value: &Value) -> bool {
412
477
  if team == key {
413
478
  return true;
414
479
  }
415
- let session = value.get("session_name").and_then(Value::as_str).unwrap_or("");
480
+ let session = value
481
+ .get("session_name")
482
+ .and_then(Value::as_str)
483
+ .unwrap_or("");
416
484
  if team == session {
417
485
  return true;
418
486
  }
@@ -434,7 +502,10 @@ fn team_selector_matches(team: &str, key: &str, value: &Value) -> bool {
434
502
  /// `ambiguous_team_target_result`(`state.py:226`):无显式 team 且多候选 → 拒绝 dict;否则 None。
435
503
  pub fn ambiguous_team_target_result(state: &Value) -> Option<Value> {
436
504
  let alive = team_state_candidates(state);
437
- let active = state.get("active_team_key").and_then(Value::as_str).filter(|s| !s.is_empty());
505
+ let active = state
506
+ .get("active_team_key")
507
+ .and_then(Value::as_str)
508
+ .filter(|s| !s.is_empty());
438
509
  if let Some(active) = active {
439
510
  if alive.contains_key(active) {
440
511
  return None;
@@ -491,6 +562,26 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
491
562
  save_team_scoped_state_with_deleted_agents(workspace, team_state, &[])
492
563
  }
493
564
 
565
+ pub(crate) fn save_team_scoped_state_reapplying_after_conflict<F>(
566
+ workspace: &Path,
567
+ team_state: &Value,
568
+ reapply: F,
569
+ ) -> Result<(), StateError>
570
+ where
571
+ F: FnOnce(&mut Value),
572
+ {
573
+ match save_team_scoped_state(workspace, team_state) {
574
+ Ok(()) => Ok(()),
575
+ Err(StateError::SaveConflict(_)) => {
576
+ let target_key = team_state_key(team_state);
577
+ let mut latest = select_runtime_state(workspace, Some(&target_key))?;
578
+ reapply(&mut latest);
579
+ save_team_scoped_state(workspace, &latest)
580
+ }
581
+ Err(error) => Err(error),
582
+ }
583
+ }
584
+
494
585
  pub(crate) fn save_team_scoped_state_with_deleted_agents(
495
586
  workspace: &Path,
496
587
  team_state: &Value,
@@ -499,12 +590,20 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
499
590
  save_team_scoped_state_with_merge_exceptions(workspace, team_state, deleted_agent_ids, &[])
500
591
  }
501
592
 
502
- pub(crate) fn save_team_scoped_state_with_tombstoned_agents(
593
+ pub(crate) fn save_team_scoped_state_with_tombstone_lifecycle_topology_authority(
503
594
  workspace: &Path,
504
595
  team_state: &Value,
505
- tombstoned_agent_ids: &[&str],
596
+ agent_ids: &[&str],
597
+ ) -> Result<(), StateError> {
598
+ save_team_scoped_state_with_merge_options(workspace, team_state, &[], agent_ids, agent_ids)
599
+ }
600
+
601
+ pub(crate) fn save_team_scoped_state_with_lifecycle_topology_authority(
602
+ workspace: &Path,
603
+ team_state: &Value,
604
+ agent_ids: &[&str],
506
605
  ) -> Result<(), StateError> {
507
- save_team_scoped_state_with_merge_exceptions(workspace, team_state, &[], tombstoned_agent_ids)
606
+ save_team_scoped_state_with_merge_options(workspace, team_state, &[], &[], agent_ids)
508
607
  }
509
608
 
510
609
  fn save_team_scoped_state_with_merge_exceptions(
@@ -512,6 +611,22 @@ fn save_team_scoped_state_with_merge_exceptions(
512
611
  team_state: &Value,
513
612
  deleted_agent_ids: &[&str],
514
613
  tombstoned_agent_ids: &[&str],
614
+ ) -> Result<(), StateError> {
615
+ save_team_scoped_state_with_merge_options(
616
+ workspace,
617
+ team_state,
618
+ deleted_agent_ids,
619
+ tombstoned_agent_ids,
620
+ &[],
621
+ )
622
+ }
623
+
624
+ fn save_team_scoped_state_with_merge_options(
625
+ workspace: &Path,
626
+ team_state: &Value,
627
+ deleted_agent_ids: &[&str],
628
+ tombstoned_agent_ids: &[&str],
629
+ topology_agent_ids: &[&str],
515
630
  ) -> Result<(), StateError> {
516
631
  let target_key = team_state_key(team_state);
517
632
  let existing = load_runtime_state(workspace)?;
@@ -532,7 +647,11 @@ fn save_team_scoped_state_with_merge_exceptions(
532
647
  .and_then(Value::as_str)
533
648
  .is_some_and(|t| t == s)
534
649
  });
535
- if existing_primary_key.as_deref().is_some_and(|k| k != target_key) && same_session {
650
+ if existing_primary_key
651
+ .as_deref()
652
+ .is_some_and(|k| k != target_key)
653
+ && same_session
654
+ {
536
655
  existing_primary_key = Some(target_key.clone());
537
656
  }
538
657
  let existing_teams = take_object(existing.get("teams"));
@@ -541,6 +660,21 @@ fn save_team_scoped_state_with_merge_exceptions(
541
660
  // not existing_teams and existing_primary_key == target_key → 纯 save(剔 teams)。
542
661
  if existing_teams.is_empty() && existing_primary_key.as_deref() == Some(target_key.as_str()) {
543
662
  let merged = compact_team_state(team_state);
663
+ if !topology_agent_ids.is_empty() {
664
+ if !tombstoned_agent_ids.is_empty() {
665
+ return save_runtime_state_with_team_tombstone_lifecycle_topology_authority(
666
+ workspace,
667
+ &merged,
668
+ &target_key,
669
+ topology_agent_ids,
670
+ );
671
+ }
672
+ return save_runtime_state_with_lifecycle_topology_authority(
673
+ workspace,
674
+ &merged,
675
+ topology_agent_ids,
676
+ );
677
+ }
544
678
  if tombstoned_agent_ids.is_empty() {
545
679
  return save_runtime_state_with_deleted_agents(workspace, &merged, deleted_agent_ids);
546
680
  }
@@ -566,12 +700,36 @@ fn save_team_scoped_state_with_merge_exceptions(
566
700
  };
567
701
  merged.insert("teams".to_string(), Value::Object(teams));
568
702
  // if not merged.get("teams"): merged.pop("teams", None) —— teams 为空 dict(falsy)则剔除。
569
- if merged.get("teams").and_then(Value::as_object).is_some_and(Map::is_empty) {
703
+ if merged
704
+ .get("teams")
705
+ .and_then(Value::as_object)
706
+ .is_some_and(Map::is_empty)
707
+ {
570
708
  merged.remove("teams");
571
709
  }
572
710
  if tombstoned_agent_ids.is_empty() {
573
- save_runtime_state_with_deleted_agents(workspace, &Value::Object(merged), deleted_agent_ids)
711
+ if !topology_agent_ids.is_empty() {
712
+ save_runtime_state_with_lifecycle_topology_authority(
713
+ workspace,
714
+ &Value::Object(merged),
715
+ topology_agent_ids,
716
+ )
717
+ } else {
718
+ save_runtime_state_with_deleted_agents(
719
+ workspace,
720
+ &Value::Object(merged),
721
+ deleted_agent_ids,
722
+ )
723
+ }
574
724
  } else {
725
+ if !topology_agent_ids.is_empty() {
726
+ return save_runtime_state_with_team_tombstone_lifecycle_topology_authority(
727
+ workspace,
728
+ &Value::Object(merged),
729
+ &target_key,
730
+ topology_agent_ids,
731
+ );
732
+ }
575
733
  save_runtime_state_with_team_tombstoned_agents(
576
734
  workspace,
577
735
  &Value::Object(merged),
@@ -608,7 +766,11 @@ fn take_object(v: Option<&Value>) -> Map<String, Value> {
608
766
 
609
767
  /// Python `repr()` of a `str`(与 `model::spec::py_repr_str` 同实现,本地副本免改工作模块)。
610
768
  fn py_repr_str(s: &str) -> String {
611
- let quote = if s.contains('\'') && !s.contains('"') { '"' } else { '\'' };
769
+ let quote = if s.contains('\'') && !s.contains('"') {
770
+ '"'
771
+ } else {
772
+ '\''
773
+ };
612
774
  let mut out = String::new();
613
775
  out.push(quote);
614
776
  for c in s.chars() {
@@ -638,7 +800,10 @@ mod tests {
638
800
 
639
801
  /// 比较紧凑序列化(serde_json::to_string 无空格)—— 同时锁值 + 键插入序。
640
802
  fn same_repr(a: &Value, b: &Value) {
641
- assert_eq!(serde_json::to_string(a).unwrap(), serde_json::to_string(b).unwrap());
803
+ assert_eq!(
804
+ serde_json::to_string(a).unwrap(),
805
+ serde_json::to_string(b).unwrap()
806
+ );
642
807
  }
643
808
 
644
809
  fn temp_ws_with_state(state: &Value) -> std::path::PathBuf {
@@ -652,9 +817,18 @@ mod tests {
652
817
 
653
818
  #[test]
654
819
  fn team_state_key_cases() {
655
- assert_eq!(team_state_key(&json!({"team_dir": "/ws/.team/myteam"})), "myteam");
656
- assert_eq!(team_state_key(&json!({"team_dir": "/ws/.team/runtime", "session_name": "sess"})), "sess");
657
- assert_eq!(team_state_key(&json!({"spec_path": "/ws/.team/alpha/TEAM.md"})), "alpha");
820
+ assert_eq!(
821
+ team_state_key(&json!({"team_dir": "/ws/.team/myteam"})),
822
+ "myteam"
823
+ );
824
+ assert_eq!(
825
+ team_state_key(&json!({"team_dir": "/ws/.team/runtime", "session_name": "sess"})),
826
+ "sess"
827
+ );
828
+ assert_eq!(
829
+ team_state_key(&json!({"spec_path": "/ws/.team/alpha/TEAM.md"})),
830
+ "alpha"
831
+ );
658
832
  assert_eq!(team_state_key(&json!({"session_name": "sname"})), "sname");
659
833
  assert_eq!(team_state_key(&json!({})), "current");
660
834
  assert_eq!(team_state_key(&json!({"session_name": ""})), "current");
@@ -691,11 +865,20 @@ mod tests {
691
865
  format_team_candidates(&team_state_candidates(&s)),
692
866
  "Candidates: ALIVE_CASE session=- agents=-; alive1 session=sa agents=-; nostatus session=sn agents=-"
693
867
  );
694
- assert_eq!(format_team_candidates(&Map::new()), "No team state was found.");
868
+ assert_eq!(
869
+ format_team_candidates(&Map::new()),
870
+ "No team state was found."
871
+ );
695
872
  let mut m = Map::new();
696
- m.insert("k2".to_string(), json!({"session_name": "s2", "agents": {"b": {}, "a": {}}}));
873
+ m.insert(
874
+ "k2".to_string(),
875
+ json!({"session_name": "s2", "agents": {"b": {}, "a": {}}}),
876
+ );
697
877
  m.insert("k1".to_string(), json!({}));
698
- assert_eq!(format_team_candidates(&m), "Candidates: k1 session=- agents=-; k2 session=s2 agents=a,b");
878
+ assert_eq!(
879
+ format_team_candidates(&m),
880
+ "Candidates: k1 session=- agents=-; k2 session=s2 agents=a,b"
881
+ );
699
882
  }
700
883
 
701
884
  #[test]
@@ -717,7 +900,8 @@ mod tests {
717
900
 
718
901
  #[test]
719
902
  fn project_top_level_view_does_not_fall_to_toplevel_owner_when_teams_exist() {
720
- let s = json!({"teams": {"t2": {"session_name": "s2"}}, "team_owner": {"pane_id": "%top2"}});
903
+ let s =
904
+ json!({"teams": {"t2": {"session_name": "s2"}}, "team_owner": {"pane_id": "%top2"}});
721
905
  let expected = json!({
722
906
  "session_name": "s2", "team_dir": null, "active_team_key": "t2",
723
907
  "teams": {"t2": {"session_name": "s2"}},
@@ -728,7 +912,10 @@ mod tests {
728
912
  #[test]
729
913
  fn merge_three_branches_golden() {
730
914
  same_repr(
731
- &merge_workspace_team_state(&json!({}), &json!({"team_dir": "/w/.team/t1", "session_name": "s1", "agents": {}})),
915
+ &merge_workspace_team_state(
916
+ &json!({}),
917
+ &json!({"team_dir": "/w/.team/t1", "session_name": "s1", "agents": {}}),
918
+ ),
732
919
  &json!({"team_dir": "/w/.team/t1", "session_name": "s1", "agents": {},
733
920
  "teams": {"t1": {"team_dir": "/w/.team/t1", "session_name": "s1", "agents": {}}}}),
734
921
  );
@@ -790,7 +977,9 @@ mod tests {
790
977
  let empty = select_runtime_state(&ws, Some("")).unwrap_err();
791
978
  let none = select_runtime_state(&ws, None).unwrap_err();
792
979
  assert_eq!(empty.to_string(), none.to_string());
793
- assert!(empty.to_string().starts_with("multiple teams found in this workspace"));
980
+ assert!(empty
981
+ .to_string()
982
+ .starts_with("multiple teams found in this workspace"));
794
983
  }
795
984
 
796
985
  #[test]
@@ -823,7 +1012,9 @@ mod tests {
823
1012
 
824
1013
  #[test]
825
1014
  fn select_runtime_state_single_alive_projects() {
826
- let ws = temp_ws_with_state(&json!({"teams": {"only": {"status": "alive", "session_name": "so"}}}));
1015
+ let ws = temp_ws_with_state(
1016
+ &json!({"teams": {"only": {"status": "alive", "session_name": "so"}}}),
1017
+ );
827
1018
  let r = select_runtime_state(&ws, None).unwrap();
828
1019
  assert_eq!(r["active_team_key"], json!("only"));
829
1020
  assert_eq!(r["session_name"], json!("so"));
@@ -46,8 +46,8 @@ pub fn resolve_active_team(
46
46
  let state = select_runtime_state(&run, team).or_else(|_| load_runtime_state(&run))?;
47
47
  (run, state)
48
48
  } else {
49
- let run = canonical_run_workspace(input)
50
- .map_err(|e| StateError::TeamSelect(e.to_string()))?;
49
+ let run =
50
+ canonical_run_workspace(input).map_err(|e| StateError::TeamSelect(e.to_string()))?;
51
51
  if !input.exists()
52
52
  && !runtime_state_path(&run).exists()
53
53
  && !run.join(".team").exists()
@@ -78,13 +78,19 @@ pub fn resolve_active_team(
78
78
  let legacy_ws = if explicit_spec.exists() {
79
79
  Some(input.to_path_buf())
80
80
  } else {
81
- spec_workspace_from_state(&state)
82
- .or_else(|| run_workspace.join("team.spec.yaml").exists().then(|| run_workspace.clone()))
81
+ spec_workspace_from_state(&state).or_else(|| {
82
+ run_workspace
83
+ .join("team.spec.yaml")
84
+ .exists()
85
+ .then(|| run_workspace.clone())
86
+ })
83
87
  };
84
88
  let legacy_spec = legacy_ws.as_ref().map(|ws| ws.join("team.spec.yaml"));
85
89
  (legacy_ws, legacy_spec)
86
90
  };
87
- if matches!(mode, SelectorMode::RequireSpec) && !spec_path.as_ref().is_some_and(|path| path.exists()) {
91
+ if matches!(mode, SelectorMode::RequireSpec)
92
+ && !spec_path.as_ref().is_some_and(|path| path.exists())
93
+ {
88
94
  // 期望路径报 canonical runtime spec(重建落点),非用户目录。
89
95
  let expected = spec_path.as_ref().cloned().unwrap_or(runtime_spec);
90
96
  // E5 Bug2 N38:spec=中间产物,运行期由 restart 以角色定义重建;首装走 quick-start;
@@ -313,7 +313,16 @@
313
313
  // ── 2. spawn_first / spawn_into frame via tmux_spawn_argv; canned output parses pane id ────────
314
314
  #[test]
315
315
  fn spawn_first_frames_via_new_session_builder_and_parses_pane_id() {
316
- let (be, rec) = backend_with(MockResp::Out(ok("%3")), vec![]);
316
+ let pane_inventory =
317
+ "%3\tteamsess\t0\tw1\t0\t/dev/ttys003\tnode\t1\t/work/dir\t1\t0\t123\n";
318
+ let (be, rec) = backend_with(
319
+ MockResp::Out(ok("")),
320
+ vec![
321
+ MockResp::Out(ok("")),
322
+ MockResp::Out(ok("%3")),
323
+ MockResp::Out(ok(pane_inventory)),
324
+ ],
325
+ );
317
326
  let s = SessionName::new("teamsess");
318
327
  let w = WindowName::new("w1");
319
328
  let env = BTreeMap::from([("TEAM_AGENT_ID".to_string(), "w1".to_string())]);
@@ -329,11 +338,21 @@
329
338
  );
330
339
  assert!(cmd.contains("provider-bin"), "the provider argv must be in the sh -lc command; got {cmd}");
331
340
  assert_eq!(result.pane_id.as_str(), "%3", "SpawnResult.pane_id must parse from the tmux output");
341
+ assert_eq!(result.child_pid, Some(123));
332
342
  }
333
343
 
334
344
  #[test]
335
345
  fn spawn_into_frames_via_new_window_builder() {
336
- let (be, rec) = backend_with(MockResp::Out(ok("%4")), vec![]);
346
+ let pane_inventory =
347
+ "%4\tteamsess\t1\tw2\t0\t/dev/ttys004\tnode\t1\t/work/dir\t1\t0\t124\n";
348
+ let (be, rec) = backend_with(
349
+ MockResp::Out(ok("")),
350
+ vec![
351
+ MockResp::Out(ok("")),
352
+ MockResp::Out(ok("%4")),
353
+ MockResp::Out(ok(pane_inventory)),
354
+ ],
355
+ );
337
356
  let s = SessionName::new("teamsess");
338
357
  let w = WindowName::new("w2");
339
358
  let result = be
@@ -349,6 +368,36 @@
349
368
  assert_eq!(result.pane_id.as_str(), "%4");
350
369
  }
351
370
 
371
+ #[test]
372
+ fn spawn_with_command_refuses_display_message_pane_owned_by_other_window() {
373
+ let pane_inventory =
374
+ "%5\tteamsess\t1\tw2\t0\t/dev/ttys005\tnode\t1\t/work/dir\t1\t0\t125\n";
375
+ let (be, _rec) = backend_with(
376
+ MockResp::Out(ok("")),
377
+ vec![
378
+ MockResp::Out(ok("")),
379
+ MockResp::Out(ok("%5")),
380
+ MockResp::Out(ok(pane_inventory)),
381
+ ],
382
+ );
383
+ let err = be
384
+ .spawn_into(
385
+ &SessionName::new("teamsess"),
386
+ &WindowName::new("w1"),
387
+ &svec(&["provider-bin"]),
388
+ Path::new("/work/dir"),
389
+ &BTreeMap::new(),
390
+ )
391
+ .expect_err("display-message fallback to w2 pane must fail closed");
392
+ let msg = err.to_string();
393
+ assert!(
394
+ msg.contains("requested=teamsess:w1")
395
+ && msg.contains("observed_pane=%5")
396
+ && msg.contains("observed=teamsess:w2"),
397
+ "error must include requested/observed ownership evidence, got {msg}"
398
+ );
399
+ }
400
+
352
401
  #[test]
353
402
  fn spawn_split_selects_even_horizontal_not_tiled() {
354
403
  let (be, rec) = backend_with(
@@ -675,11 +675,48 @@ impl TmuxBackend {
675
675
  ),
676
676
  });
677
677
  }
678
- Ok(SpawnResult {
679
- pane_id: PaneId::new(pane),
680
- session: session.clone(),
681
- window: window.clone(),
682
- child_pid: None,
678
+ let pane_id = PaneId::new(pane);
679
+ let targets = self.list_targets()?;
680
+ if let Some(target) = targets.iter().find(|target| {
681
+ target.pane_id == pane_id
682
+ && target.session.as_str() == session.as_str()
683
+ && target
684
+ .window_name
685
+ .as_ref()
686
+ .is_some_and(|name| name.as_str() == window.as_str())
687
+ }) {
688
+ return Ok(SpawnResult {
689
+ pane_id,
690
+ session: session.clone(),
691
+ window: window.clone(),
692
+ child_pid: target.pane_pid,
693
+ });
694
+ }
695
+ let observed = targets
696
+ .iter()
697
+ .find(|target| target.pane_id == pane_id)
698
+ .map(|target| {
699
+ format!(
700
+ "{}:{}",
701
+ target.session.as_str(),
702
+ target
703
+ .window_name
704
+ .as_ref()
705
+ .map(WindowName::as_str)
706
+ .unwrap_or("<unknown>")
707
+ )
708
+ })
709
+ .unwrap_or_else(|| "<missing-from-list-targets>".to_string());
710
+ Err(TransportError::Subprocess {
711
+ argv: pane_argv,
712
+ code: output.code,
713
+ stderr: format!(
714
+ "tmux spawn pane ownership mismatch: requested={}:{} observed_pane={} observed={}",
715
+ session.as_str(),
716
+ window.as_str(),
717
+ pane_id.as_str(),
718
+ observed
719
+ ),
683
720
  })
684
721
  }
685
722