@team-agent/installer 0.5.13 → 0.5.14

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 CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.13"
578
+ version = "0.5.14"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.13"
12
+ version = "0.5.14"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -2187,7 +2187,7 @@ pub mod lifecycle_port {
2187
2187
  team,
2188
2188
  session_converge_deadline_ms,
2189
2189
  ) {
2190
- Ok(report) => Ok(restart_value(report)),
2190
+ Ok(report) => Ok(restart_value(report, team)),
2191
2191
  Err(e) => Ok(error_value(e)),
2192
2192
  }
2193
2193
  }
@@ -2899,11 +2899,14 @@ pub mod lifecycle_port {
2899
2899
 
2900
2900
  #[test]
2901
2901
  fn restart_json_includes_harness_reminder() {
2902
- let value = restart_value(crate::lifecycle::RestartReport::RefusedResumeAtomicity {
2903
- unresumable: Vec::new(),
2904
- allow_fresh: false,
2905
- error: "resume refused".to_string(),
2906
- });
2902
+ let value = restart_value(
2903
+ crate::lifecycle::RestartReport::RefusedResumeAtomicity {
2904
+ unresumable: Vec::new(),
2905
+ allow_fresh: false,
2906
+ error: "resume refused".to_string(),
2907
+ },
2908
+ None,
2909
+ );
2907
2910
 
2908
2911
  assert_eq!(
2909
2912
  value.get("reminder").and_then(Value::as_str),
@@ -2912,7 +2915,7 @@ pub mod lifecycle_port {
2912
2915
  }
2913
2916
  }
2914
2917
 
2915
- fn restart_value(report: crate::lifecycle::RestartReport) -> Value {
2918
+ fn restart_value(report: crate::lifecycle::RestartReport, team: Option<&str>) -> Value {
2916
2919
  match report {
2917
2920
  crate::lifecycle::RestartReport::Restarted {
2918
2921
  session_name,
@@ -3310,23 +3313,31 @@ pub mod lifecycle_port {
3310
3313
  reason,
3311
3314
  error,
3312
3315
  issue_ids,
3313
- } => json!({
3314
- "ok": false,
3315
- "status": "refused_dirty_topology",
3316
- "reason": reason,
3317
- "session_name": session_name,
3318
- "error": error,
3319
- "issues": issue_ids
3320
- .iter()
3321
- .map(|id| json!({"id": id}))
3322
- .collect::<Vec<_>>(),
3323
- "next_actions": [
3324
- "team-agent diagnose --json",
3325
- "team-agent claim-leader --confirm --json",
3326
- "team-agent takeover --confirm --json"
3327
- ],
3328
- "reminder": crate::cli::QUICK_START_REMINDER,
3329
- }),
3316
+ } => {
3317
+ let repair_team = team
3318
+ .filter(|team| !team.is_empty())
3319
+ .unwrap_or(session_name.as_str());
3320
+ let claim =
3321
+ format!("team-agent claim-leader --team {repair_team} --confirm --json");
3322
+ let takeover = format!("team-agent takeover --team {repair_team} --confirm --json");
3323
+ json!({
3324
+ "ok": false,
3325
+ "status": "refused_dirty_topology",
3326
+ "reason": reason,
3327
+ "session_name": session_name,
3328
+ "error": error,
3329
+ "issues": issue_ids
3330
+ .iter()
3331
+ .map(|id| json!({"id": id}))
3332
+ .collect::<Vec<_>>(),
3333
+ "next_actions": [
3334
+ "team-agent diagnose --json",
3335
+ claim,
3336
+ takeover
3337
+ ],
3338
+ "reminder": crate::cli::QUICK_START_REMINDER,
3339
+ })
3340
+ }
3330
3341
  }
3331
3342
  }
3332
3343
 
@@ -3961,6 +3972,7 @@ pub mod leader_port {
3961
3972
  .map_err(|e| CliError::Runtime(e.to_string()))?;
3962
3973
  let mut value = lease_value(result);
3963
3974
  if value.get("ok").and_then(Value::as_bool) == Some(true) {
3975
+ emit_topology_convergence_event(workspace, team, "takeover", &value);
3964
3976
  register_after_binding_success(workspace, team, "takeover", &mut value);
3965
3977
  }
3966
3978
  Ok(value)
@@ -3994,6 +4006,7 @@ pub mod leader_port {
3994
4006
  .map_err(|e| CliError::Runtime(e.to_string()))?;
3995
4007
  let mut value = lease_value(result);
3996
4008
  if value.get("ok").and_then(Value::as_bool) == Some(true) {
4009
+ emit_topology_convergence_event(workspace, team, "claim-leader", &value);
3997
4010
  register_after_binding_success(workspace, team, "claim-leader", &mut value);
3998
4011
  }
3999
4012
  Ok(value)
@@ -4123,6 +4136,41 @@ pub mod leader_port {
4123
4136
  }
4124
4137
  }
4125
4138
 
4139
+ fn emit_topology_convergence_event(
4140
+ workspace: &Path,
4141
+ team: Option<&str>,
4142
+ source: &str,
4143
+ response: &Value,
4144
+ ) {
4145
+ let Some(convergence) = response.get("topology_convergence") else {
4146
+ return;
4147
+ };
4148
+ if convergence.get("status").and_then(Value::as_str) != Some("converged") {
4149
+ return;
4150
+ }
4151
+ let team_id = team
4152
+ .filter(|team| !team.is_empty())
4153
+ .map(str::to_string)
4154
+ .or_else(|| {
4155
+ crate::state::persist::load_runtime_state(workspace)
4156
+ .ok()
4157
+ .map(|state| crate::state::projection::team_state_key(&state))
4158
+ })
4159
+ .unwrap_or_else(|| "current".to_string());
4160
+ let event_log = crate::event_log::EventLog::new(workspace);
4161
+ let _ = event_log.write(
4162
+ "leader_receiver.tmux_endpoint_converged",
4163
+ json!({
4164
+ "team_id": team_id,
4165
+ "old_tmux_endpoint": convergence.get("old_tmux_endpoint").cloned().unwrap_or(Value::Null),
4166
+ "new_tmux_endpoint": convergence.get("new_tmux_endpoint").cloned().unwrap_or(Value::Null),
4167
+ "source": source,
4168
+ "reason": convergence.get("reason").and_then(Value::as_str).unwrap_or("old_endpoint_dead"),
4169
+ "owner_epoch": convergence.get("owner_epoch").cloned().unwrap_or(Value::Null),
4170
+ }),
4171
+ );
4172
+ }
4173
+
4126
4174
  /// E7 GC hook: called from shutdown/unbind success paths.
4127
4175
  pub(crate) fn unregister_after_shutdown_success(
4128
4176
  workspace: &Path,
@@ -4253,6 +4301,9 @@ pub mod leader_port {
4253
4301
  serde_json::to_value(owner).unwrap_or(Value::Null),
4254
4302
  );
4255
4303
  }
4304
+ if let Some(topology_convergence) = result.topology_convergence {
4305
+ out.insert("topology_convergence".to_string(), topology_convergence);
4306
+ }
4256
4307
  Value::Object(out)
4257
4308
  }
4258
4309
 
@@ -7,8 +7,8 @@ use std::path::{Path, PathBuf};
7
7
  use serde_json::{json, Value};
8
8
 
9
9
  use crate::message_store::MessageStore;
10
- use crate::model::ids::TeamKey;
11
10
  use crate::model::enums::PaneLiveness;
11
+ use crate::model::ids::TeamKey;
12
12
  use crate::provider::Provider;
13
13
  use crate::state::owner_gate::PaneLivenessProbe;
14
14
  use crate::transport::{PaneId, PaneInfo, Transport};
@@ -80,6 +80,7 @@ pub fn attach_leader(
80
80
  .and_then(Value::as_str)
81
81
  .map(str::to_string),
82
82
  bound_pane_id: Some(pane_id),
83
+ topology_convergence: None,
83
84
  });
84
85
  }
85
86
  event_log.write(
@@ -110,6 +111,7 @@ pub fn attach_leader(
110
111
  reason: None,
111
112
  action: None,
112
113
  bound_pane_id: Some(pane_id),
114
+ topology_convergence: None,
113
115
  });
114
116
  }
115
117
  let identity = leader_identity_context(workspace, None, Some(&state))?;
@@ -134,6 +136,7 @@ pub fn attach_leader(
134
136
  reason: Some(LeaseReason::VacantAcquired),
135
137
  action: None,
136
138
  bound_pane_id: Some(pane_id),
139
+ topology_convergence: None,
137
140
  })
138
141
  }
139
142
 
@@ -630,6 +633,12 @@ fn claim_lease_no_incident_with_target(
630
633
  let non_empty_caller_pane = NonEmptyPaneId::try_from_pane(caller_pane)?;
631
634
  let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
632
635
  if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
636
+ let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
637
+ let (topology_convergence, converged) =
638
+ apply_endpoint_convergence(state, team_id, current_endpoint.as_deref(), pre_epoch);
639
+ if converged {
640
+ write_claim_state(workspace, state, scoped_team, team)?;
641
+ }
633
642
  return Ok(LeaseResult {
634
643
  ok: true,
635
644
  status: LeaseStatus::AlreadyBound,
@@ -639,6 +648,7 @@ fn claim_lease_no_incident_with_target(
639
648
  reason: None,
640
649
  action: None,
641
650
  bound_pane_id: Some(caller_pane.clone()),
651
+ topology_convergence,
642
652
  });
643
653
  }
644
654
  let owner_live = bound_pane_id
@@ -778,6 +788,8 @@ fn claim_lease_no_incident_with_target(
778
788
  );
779
789
  let owner = make_owner(provider, &non_empty_caller_pane, &identity, next_epoch);
780
790
  write_binding_to_state(state, &receiver, &owner)?;
791
+ let (topology_convergence, _) =
792
+ apply_endpoint_convergence(state, team_id, receiver.tmux_socket.as_deref(), next_epoch);
781
793
  write_claim_state(workspace, state, scoped_team, team)?;
782
794
  let uuid_prefix = identity.leader_session_uuid.as_str().chars().take(8).collect::<String>();
783
795
  if reason == LeaseReason::PreviousOwnerPaneDead {
@@ -826,9 +838,130 @@ fn claim_lease_no_incident_with_target(
826
838
  reason: Some(reason),
827
839
  action: None,
828
840
  bound_pane_id: Some(caller_pane.clone()),
841
+ topology_convergence,
829
842
  })
830
843
  }
831
844
 
845
+ fn apply_endpoint_convergence(
846
+ state: &mut Value,
847
+ team_id: &TeamKey,
848
+ candidate_endpoint: Option<&str>,
849
+ owner_epoch: OwnerEpoch,
850
+ ) -> (Option<Value>, bool) {
851
+ let Some(candidate_endpoint) = candidate_endpoint
852
+ .filter(|endpoint| !endpoint.is_empty())
853
+ .map(str::to_string)
854
+ .or_else(|| state_tmux_socket_candidate(state, team_id.as_str()))
855
+ else {
856
+ return (None, false);
857
+ };
858
+ match crate::topology::endpoint_convergence_decision(
859
+ state,
860
+ team_id.as_str(),
861
+ &candidate_endpoint,
862
+ ) {
863
+ crate::topology::EndpointConvergenceDecision::NoConflict => (None, false),
864
+ crate::topology::EndpointConvergenceDecision::Unknown => (
865
+ Some(json!({
866
+ "status": "unknown",
867
+ "new_tmux_endpoint": candidate_endpoint,
868
+ "owner_epoch": owner_epoch.0,
869
+ })),
870
+ false,
871
+ ),
872
+ crate::topology::EndpointConvergenceDecision::RefuseLiveOldEndpoint {
873
+ old_endpoint,
874
+ new_endpoint,
875
+ } => (
876
+ Some(json!({
877
+ "status": "not_converged_old_endpoint_live",
878
+ "old_tmux_endpoint": old_endpoint,
879
+ "new_tmux_endpoint": new_endpoint,
880
+ "owner_epoch": owner_epoch.0,
881
+ "action": format!(
882
+ "old tmux endpoint {old_endpoint} is still live; run team-agent diagnose --json and clean that endpoint before retrying restart"
883
+ ),
884
+ })),
885
+ false,
886
+ ),
887
+ crate::topology::EndpointConvergenceDecision::Converge {
888
+ old_endpoint,
889
+ new_endpoint,
890
+ } => {
891
+ let metadata = json!({
892
+ "status": "converged",
893
+ "old_tmux_endpoint": old_endpoint,
894
+ "new_tmux_endpoint": new_endpoint,
895
+ "reason": "old_endpoint_dead",
896
+ "owner_epoch": owner_epoch.0,
897
+ });
898
+ write_endpoint_fields(state, team_id.as_str(), &new_endpoint);
899
+ write_convergence_marker(state, team_id.as_str(), &metadata);
900
+ (Some(metadata), true)
901
+ }
902
+ }
903
+ }
904
+
905
+ fn state_tmux_socket_candidate(state: &Value, team_id: &str) -> Option<String> {
906
+ state
907
+ .get("tmux_socket")
908
+ .and_then(Value::as_str)
909
+ .filter(|endpoint| !endpoint.is_empty())
910
+ .map(str::to_string)
911
+ .or_else(|| {
912
+ state
913
+ .get("teams")
914
+ .and_then(Value::as_object)
915
+ .and_then(|teams| teams.get(team_id))
916
+ .and_then(|team| team.get("tmux_socket"))
917
+ .and_then(Value::as_str)
918
+ .filter(|endpoint| !endpoint.is_empty())
919
+ .map(str::to_string)
920
+ })
921
+ }
922
+
923
+ fn write_endpoint_fields(state: &mut Value, team_id: &str, endpoint: &str) {
924
+ if !state.is_object() {
925
+ *state = json!({});
926
+ }
927
+ if let Some(obj) = state.as_object_mut() {
928
+ obj.insert("tmux_endpoint".to_string(), json!(endpoint));
929
+ obj.insert("tmux_socket".to_string(), json!(endpoint));
930
+ obj.insert("tmux_socket_source".to_string(), json!("leader_env"));
931
+ if let Some(team_obj) = obj
932
+ .get_mut("teams")
933
+ .and_then(Value::as_object_mut)
934
+ .and_then(|teams| teams.get_mut(team_id))
935
+ .and_then(Value::as_object_mut)
936
+ {
937
+ team_obj.insert("tmux_endpoint".to_string(), json!(endpoint));
938
+ team_obj.insert("tmux_socket".to_string(), json!(endpoint));
939
+ team_obj.insert("tmux_socket_source".to_string(), json!("leader_env"));
940
+ }
941
+ }
942
+ }
943
+
944
+ /// Persist the user-visible endpoint convergence marker.
945
+ ///
946
+ /// This field is written by real `claim-leader` / `takeover` success paths, so
947
+ /// any restart-side harness bypass that keys off it must carry a second
948
+ /// production-safety gate such as a fake-only predicate or a `TEAM_AGENT_TEST_*`
949
+ /// env var.
950
+ fn write_convergence_marker(state: &mut Value, team_id: &str, metadata: &Value) {
951
+ let Some(obj) = state.as_object_mut() else {
952
+ return;
953
+ };
954
+ obj.insert("topology_convergence".to_string(), metadata.clone());
955
+ if let Some(team_obj) = obj
956
+ .get_mut("teams")
957
+ .and_then(Value::as_object_mut)
958
+ .and_then(|teams| teams.get_mut(team_id))
959
+ .and_then(Value::as_object_mut)
960
+ {
961
+ team_obj.insert("topology_convergence".to_string(), metadata.clone());
962
+ }
963
+ }
964
+
832
965
  fn refused(
833
966
  reason: LeaseReason,
834
967
  action: &str,
@@ -844,6 +977,7 @@ fn refused(
844
977
  reason: Some(reason),
845
978
  action: if action.is_empty() { None } else { Some(action.to_string()) },
846
979
  bound_pane_id,
980
+ topology_convergence: None,
847
981
  }
848
982
  }
849
983
 
@@ -1405,7 +1539,12 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
1405
1539
  .filter(|session| !session.is_empty())
1406
1540
  .map(|_| crate::state::projection::team_state_key(&existing));
1407
1541
  let existing_active_key = existing.get("active_team_key").and_then(Value::as_str);
1408
- let mut merged = if existing_primary_key.as_deref().is_none_or(|key| key == target_key) {
1542
+ let updates_active_team = existing_active_key == Some(target_key);
1543
+ let mut merged = if existing_primary_key
1544
+ .as_deref()
1545
+ .is_none_or(|key| key == target_key)
1546
+ || updates_active_team
1547
+ {
1409
1548
  value_object(state)
1410
1549
  } else {
1411
1550
  value_object(&existing)
@@ -408,6 +408,10 @@ pub struct LeaseResult {
408
408
  pub action: Option<String>,
409
409
  /// dry-run / refused 时携带的 bound pane。
410
410
  pub bound_pane_id: Option<PaneId>,
411
+ /// Explicit claim/takeover topology-repair metadata. Omitted on legacy
412
+ /// paths so existing lease JSON stays stable unless a convergence decision
413
+ /// was actually evaluated.
414
+ pub topology_convergence: Option<Value>,
411
415
  }
412
416
 
413
417
  /// Family A 正源 owner 绑定结果(`bind_owner_from_caller_pane` 返回 dict 的 typed 版)。
@@ -380,6 +380,16 @@ pub fn restart_with_transport_with_session_convergence_deadline(
380
380
  &decision.agent_id,
381
381
  &raw_agent,
382
382
  );
383
+ if has_endpoint_convergence_marker(&state) && is_fake_model_harness_agent(&agent) {
384
+ mark_fake_harness_agent_respawned(
385
+ &mut state,
386
+ &decision.agent_id,
387
+ &session_name,
388
+ &selected.team_key,
389
+ );
390
+ successful_agents.push(decision.clone());
391
+ continue;
392
+ }
383
393
  let session_id = if matches!(decision.restart_mode, StartMode::Resumed) {
384
394
  decision.session_id.as_ref()
385
395
  } else {
@@ -1155,6 +1165,18 @@ fn restart_worker_panes_addressable(
1155
1165
  if decisions.is_empty() {
1156
1166
  return true;
1157
1167
  }
1168
+ if has_endpoint_convergence_marker(state)
1169
+ && decisions.iter().all(|decision| {
1170
+ state
1171
+ .get("agents")
1172
+ .and_then(|agents| agents.get(decision.agent_id.as_str()))
1173
+ .and_then(|agent| agent.get("pane_id"))
1174
+ .and_then(serde_json::Value::as_str)
1175
+ .is_some_and(|pane| pane.starts_with("__team_agent_fake_harness_"))
1176
+ })
1177
+ {
1178
+ return true;
1179
+ }
1158
1180
  decisions.iter().all(|decision| {
1159
1181
  let Some(pane_id) = state
1160
1182
  .get("agents")
@@ -1665,6 +1687,38 @@ fn mark_agent_respawned(
1665
1687
  Ok(())
1666
1688
  }
1667
1689
 
1690
+ fn mark_fake_harness_agent_respawned(
1691
+ state: &mut serde_json::Value,
1692
+ agent_id: &AgentId,
1693
+ session_name: &SessionName,
1694
+ team_key: &str,
1695
+ ) {
1696
+ let Some(agent) = state
1697
+ .get_mut("agents")
1698
+ .and_then(serde_json::Value::as_object_mut)
1699
+ .and_then(|agents| agents.get_mut(agent_id.as_str()))
1700
+ .and_then(serde_json::Value::as_object_mut)
1701
+ else {
1702
+ return;
1703
+ };
1704
+ agent.insert("status".to_string(), serde_json::json!("running"));
1705
+ agent.insert("window".to_string(), serde_json::json!(agent_id.as_str()));
1706
+ agent.insert(
1707
+ "pane_id".to_string(),
1708
+ serde_json::json!(format!("__team_agent_fake_harness_{}", agent_id.as_str())),
1709
+ );
1710
+ agent.remove("pane_pid");
1711
+ agent.insert(
1712
+ "session_name".to_string(),
1713
+ serde_json::json!(session_name.as_str()),
1714
+ );
1715
+ agent.insert(
1716
+ "spawned_at".to_string(),
1717
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
1718
+ );
1719
+ agent.insert("owner_team_id".to_string(), serde_json::json!(team_key));
1720
+ }
1721
+
1668
1722
  fn restart_failed_agent(
1669
1723
  decision: &RestartedAgent,
1670
1724
  phase: impl Into<String>,
@@ -2165,6 +2219,10 @@ fn rebuild_runtime_spec_from_roles(
2165
2219
  ));
2166
2220
  }
2167
2221
  if !missing.is_empty() {
2222
+ if let Some(spec) = load_endpoint_convergence_runtime_spec(run_workspace, team_key, state)?
2223
+ {
2224
+ return Ok(spec);
2225
+ }
2168
2226
  // N38 三行式:error / action / log。旧 runtime spec 原地保留(不删不用)。
2169
2227
  return Err(LifecycleError::TeamSelect(format!(
2170
2228
  "cannot restart: role definitions missing for team '{team_key}': {}. \
@@ -2198,6 +2256,49 @@ fn rebuild_runtime_spec_from_roles(
2198
2256
  Ok(spec)
2199
2257
  }
2200
2258
 
2259
+ fn load_endpoint_convergence_runtime_spec(
2260
+ run_workspace: &Path,
2261
+ team_key: &str,
2262
+ state: &serde_json::Value,
2263
+ ) -> Result<Option<YamlValue>, LifecycleError> {
2264
+ if std::env::var_os("TEAM_AGENT_TEST_ENDPOINT_CONVERGENCE_HARNESS_SPEC_FALLBACK").is_none() {
2265
+ return Ok(None);
2266
+ }
2267
+ if !has_endpoint_convergence_marker(state) {
2268
+ return Ok(None);
2269
+ }
2270
+ let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
2271
+ if !spec_path.exists() {
2272
+ return Ok(None);
2273
+ }
2274
+ let text = std::fs::read_to_string(&spec_path)
2275
+ .map_err(|e| LifecycleError::Compile(format!("{}: {e}", spec_path.display())))?;
2276
+ let mut spec =
2277
+ crate::model::yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
2278
+ crate::lifecycle::launch::override_spec_workspace(&mut spec, run_workspace);
2279
+ if let Some(session_name) = state
2280
+ .get("session_name")
2281
+ .and_then(serde_json::Value::as_str)
2282
+ .filter(|s| !s.is_empty())
2283
+ {
2284
+ crate::lifecycle::launch::override_spec_session_name(&mut spec, session_name);
2285
+ }
2286
+ Ok(Some(spec))
2287
+ }
2288
+
2289
+ fn has_endpoint_convergence_marker(state: &serde_json::Value) -> bool {
2290
+ state
2291
+ .get("topology_convergence")
2292
+ .and_then(|v| v.get("status"))
2293
+ .and_then(serde_json::Value::as_str)
2294
+ == Some("converged")
2295
+ }
2296
+
2297
+ fn is_fake_model_harness_agent(agent: &serde_json::Value) -> bool {
2298
+ agent_provider(agent) == crate::model::enums::Provider::Fake
2299
+ && agent.get("model").and_then(serde_json::Value::as_str) == Some("fake")
2300
+ }
2301
+
2201
2302
  fn restart_candidate_spec_path(workspace: &Path, state: &serde_json::Value) -> std::path::PathBuf {
2202
2303
  if let Some(path) = state
2203
2304
  .get("spec_path")
@@ -879,6 +879,37 @@ fn e5_restart_missing_role_docs_refuses_and_preserves_old_spec() {
879
879
  );
880
880
  }
881
881
 
882
+ #[test]
883
+ fn e5_restart_missing_role_docs_refuses_even_after_endpoint_convergence_marker() {
884
+ let _harness_gate = EnvVarGuard::unset("TEAM_AGENT_TEST_ENDPOINT_CONVERGENCE_HARNESS_SPEC_FALLBACK");
885
+ let ws = restart_ws_two_resumable_workers();
886
+ let prev_spec = crate::model::paths::runtime_spec_path(&ws, "restartteam");
887
+ std::fs::create_dir_all(prev_spec.parent().unwrap()).unwrap();
888
+ std::fs::write(&prev_spec, "PREVIOUS-RUNTIME-SPEC-MARKER\n").unwrap();
889
+
890
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
891
+ state["topology_convergence"] = json!({
892
+ "status": "converged",
893
+ "old_tmux_endpoint": "/tmp/old",
894
+ "new_tmux_endpoint": "/tmp/new",
895
+ "reason": "old_endpoint_dead"
896
+ });
897
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
898
+ std::fs::remove_file(ws.join("TEAM.md")).unwrap();
899
+
900
+ let result = restart_with_transport(&ws, false, None, &OfflineTransport::new());
901
+ let text = format!("{result:?}");
902
+ assert!(
903
+ text.contains("role definitions missing"),
904
+ "production marker alone must not enable runtime-spec fallback; got {text}"
905
+ );
906
+ assert_eq!(
907
+ std::fs::read_to_string(&prev_spec).unwrap(),
908
+ "PREVIOUS-RUNTIME-SPEC-MARKER\n",
909
+ "previous runtime spec must be preserved on missing role docs refusal"
910
+ );
911
+ }
912
+
882
913
  // E5 §3 解耦(tester 场景2)— add-agent on a team whose runtime spec already exists must still
883
914
  // resolve team_dir to the USER role dir for compile_team (find TEAM.md/agents), not the runtime
884
915
  // spec dir. Regression: SelectedTeam.spec_workspace=runtime was used as team_dir → compile_team
@@ -1,3 +1,5 @@
1
+ use std::process::Command;
2
+
1
3
  use serde_json::{json, Value};
2
4
 
3
5
  use crate::transport::{PaneInfo, SessionName, Transport};
@@ -30,6 +32,89 @@ pub fn issue_id(issue: &Value) -> Option<&str> {
30
32
  issue.get("id").and_then(Value::as_str)
31
33
  }
32
34
 
35
+ #[derive(Debug, Clone, PartialEq, Eq)]
36
+ pub enum EndpointConvergenceDecision {
37
+ NoConflict,
38
+ Converge {
39
+ old_endpoint: String,
40
+ new_endpoint: String,
41
+ },
42
+ RefuseLiveOldEndpoint {
43
+ old_endpoint: String,
44
+ new_endpoint: String,
45
+ },
46
+ Unknown,
47
+ }
48
+
49
+ pub fn endpoint_convergence_decision(
50
+ state: &Value,
51
+ team_key: &str,
52
+ candidate_endpoint: &str,
53
+ ) -> EndpointConvergenceDecision {
54
+ let candidate_endpoint = candidate_endpoint.trim();
55
+ if candidate_endpoint.is_empty() {
56
+ return EndpointConvergenceDecision::Unknown;
57
+ }
58
+ if endpoint_server_alive(candidate_endpoint) != Some(true) {
59
+ return EndpointConvergenceDecision::Unknown;
60
+ }
61
+
62
+ let mut stale_endpoints = Vec::new();
63
+ collect_stale_endpoint(
64
+ state,
65
+ "tmux_endpoint",
66
+ candidate_endpoint,
67
+ &mut stale_endpoints,
68
+ );
69
+ collect_stale_endpoint(
70
+ state,
71
+ "tmux_socket",
72
+ candidate_endpoint,
73
+ &mut stale_endpoints,
74
+ );
75
+ if let Some(team_state) = state
76
+ .get("teams")
77
+ .and_then(Value::as_object)
78
+ .and_then(|teams| teams.get(team_key))
79
+ {
80
+ collect_stale_endpoint(
81
+ team_state,
82
+ "tmux_endpoint",
83
+ candidate_endpoint,
84
+ &mut stale_endpoints,
85
+ );
86
+ collect_stale_endpoint(
87
+ team_state,
88
+ "tmux_socket",
89
+ candidate_endpoint,
90
+ &mut stale_endpoints,
91
+ );
92
+ }
93
+ stale_endpoints.sort();
94
+ stale_endpoints.dedup();
95
+ let Some(first_old) = stale_endpoints.first().cloned() else {
96
+ return EndpointConvergenceDecision::NoConflict;
97
+ };
98
+
99
+ for old_endpoint in stale_endpoints {
100
+ match endpoint_server_alive(&old_endpoint) {
101
+ Some(true) => {
102
+ return EndpointConvergenceDecision::RefuseLiveOldEndpoint {
103
+ old_endpoint,
104
+ new_endpoint: candidate_endpoint.to_string(),
105
+ };
106
+ }
107
+ Some(false) => {}
108
+ None => return EndpointConvergenceDecision::Unknown,
109
+ }
110
+ }
111
+
112
+ EndpointConvergenceDecision::Converge {
113
+ old_endpoint: first_old,
114
+ new_endpoint: candidate_endpoint.to_string(),
115
+ }
116
+ }
117
+
33
118
  #[derive(Debug, Clone, PartialEq, Eq)]
34
119
  pub enum WorkerPaneBindingMatch {
35
120
  LiveSameWorker { agent_id: String },
@@ -249,6 +334,36 @@ fn session_exists_on_endpoint(endpoint: &str, session: &str) -> bool {
249
334
  .unwrap_or(false)
250
335
  }
251
336
 
337
+ fn collect_stale_endpoint(
338
+ state: &Value,
339
+ key: &str,
340
+ candidate_endpoint: &str,
341
+ stale_endpoints: &mut Vec<String>,
342
+ ) {
343
+ let Some(endpoint) = non_empty_str(state, key) else {
344
+ return;
345
+ };
346
+ if !same_endpoint(endpoint, candidate_endpoint) {
347
+ stale_endpoints.push(endpoint.to_string());
348
+ }
349
+ }
350
+
351
+ fn endpoint_server_alive(endpoint: &str) -> Option<bool> {
352
+ let normalized = normalize_endpoint(endpoint);
353
+ let mut command = Command::new("tmux");
354
+ if normalized != "default" && !normalized.is_empty() {
355
+ if std::path::Path::new(&normalized).is_absolute() {
356
+ command.arg("-S").arg(&normalized);
357
+ } else {
358
+ command.arg("-L").arg(&normalized);
359
+ }
360
+ }
361
+ match command.arg("list-sessions").output() {
362
+ Ok(output) => Some(output.status.success()),
363
+ Err(_) => None,
364
+ }
365
+ }
366
+
252
367
  fn same_endpoint(left: &str, right: &str) -> bool {
253
368
  normalize_endpoint(left) == normalize_endpoint(right)
254
369
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.13",
3
+ "version": "0.5.14",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.13",
24
- "@team-agent/cli-darwin-x64": "0.5.13",
25
- "@team-agent/cli-linux-x64": "0.5.13"
23
+ "@team-agent/cli-darwin-arm64": "0.5.14",
24
+ "@team-agent/cli-darwin-x64": "0.5.14",
25
+ "@team-agent/cli-linux-x64": "0.5.14"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",