@team-agent/installer 0.5.13 → 0.5.15

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.15"
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.15"
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 版)。
@@ -255,6 +255,7 @@ pub(crate) fn start_agent_at_paths(
255
255
  Some(&safety),
256
256
  layout_placement.as_ref(),
257
257
  None,
258
+ None,
258
259
  Some(resolved_team_key.as_str()),
259
260
  )?;
260
261
  verify_spawned_pane_matches_target(
@@ -26,6 +26,7 @@ pub(super) fn spawn_agent_window(
26
26
  safety: Option<&DangerousApproval>,
27
27
  layout_placement: Option<&crate::lifecycle::launch::LayoutPlacement>,
28
28
  spawn_cwd_override: Option<&Path>,
29
+ tmux_endpoint_source: Option<&str>,
29
30
  // Issue 2 (Round 3b gate review §6): explicit owner_team_id override.
30
31
  // When `Some`, callers (restart/rebuild.rs, restart/agent.rs) thread the
31
32
  // resolved `selected.team_key` through here so the worker's MCP env /
@@ -205,6 +206,7 @@ pub(super) fn spawn_agent_window(
205
206
  let tmux_start_mode_pre_spawn =
206
207
  predict_tmux_start_mode(layout_placement, into_existing_session);
207
208
  let spawn_epoch = state_spawn_epoch_for_agent(workspace, agent_id);
209
+ let tmux_endpoint = transport.tmux_endpoint();
208
210
  let event_log = crate::event_log::EventLog::new(workspace);
209
211
  let _ = event_log.write(
210
212
  "provider.worker.spawn_argv",
@@ -220,6 +222,8 @@ pub(super) fn spawn_agent_window(
220
222
  "tmux_start_mode": tmux_start_mode_pre_spawn,
221
223
  "spawn_epoch": spawn_epoch,
222
224
  "source": "restart",
225
+ "tmux_endpoint": tmux_endpoint,
226
+ "tmux_endpoint_source": tmux_endpoint_source.unwrap_or("transport"),
223
227
  }),
224
228
  );
225
229
  }
@@ -564,6 +568,27 @@ pub(crate) fn lifecycle_worker_transport_for_selected_state(
564
568
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))
565
569
  }
566
570
 
571
+ pub(super) fn lifecycle_worker_tmux_backend_selection_for_state(
572
+ run_workspace: &Path,
573
+ state: &serde_json::Value,
574
+ ) -> Result<crate::tmux_backend::RuntimeTmuxBackendSelection, LifecycleError> {
575
+ if let Some(kind) = state.pointer("/transport/kind").and_then(|v| v.as_str()) {
576
+ if kind.eq_ignore_ascii_case("conpty") {
577
+ return Err(LifecycleError::TeamSelect(format!(
578
+ "backend_kind_mismatch: state.transport.kind={kind:?} but the legacy \
579
+ tmux-typed lifecycle resolver was invoked; caller must migrate to \
580
+ `lifecycle_worker_transport_for_selected_state` (Phase 1d Batch 2/3)"
581
+ )));
582
+ }
583
+ }
584
+ Ok(
585
+ crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
586
+ run_workspace,
587
+ Some(state),
588
+ ),
589
+ )
590
+ }
591
+
567
592
  pub(super) fn lifecycle_worker_tmux_backend_for_state(
568
593
  run_workspace: &Path,
569
594
  state: &serde_json::Value,
@@ -23,18 +23,53 @@ pub fn restart_with_session_convergence_deadline(
23
23
  team: Option<&str>,
24
24
  session_converge_deadline_ms: Option<u64>,
25
25
  ) -> Result<RestartReport, LifecycleError> {
26
- let paths = lifecycle_paths(workspace, team)?;
27
- let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
28
- restart_with_transport_with_session_convergence_deadline(
29
- workspace,
26
+ let context = resolve_restart_context(workspace, team)?;
27
+ restart_with_selected_team_and_transport(
28
+ context.selected,
30
29
  allow_fresh,
31
- team,
32
- &transport,
30
+ &context.transport,
33
31
  session_converge_deadline_ms,
34
32
  None,
33
+ Some(context.tmux_endpoint_source),
35
34
  )
36
35
  }
37
36
 
37
+ struct ResolvedRestartContext {
38
+ selected: crate::state::selector::SelectedTeam,
39
+ transport: crate::tmux_backend::TmuxBackend,
40
+ tmux_endpoint_source: &'static str,
41
+ }
42
+
43
+ fn resolve_restart_context(
44
+ workspace: &Path,
45
+ team: Option<&str>,
46
+ ) -> Result<ResolvedRestartContext, LifecycleError> {
47
+ let resolved_ws = crate::model::paths::canonical_run_workspace(workspace)
48
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
49
+ if crate::lifecycle::restart::input_has_no_local_team_context(&resolved_ws) {
50
+ return Err(LifecycleError::TeamSelect(format!(
51
+ "active team spec not found: input_workspace={} expected_runtime_dir={}",
52
+ workspace.display(),
53
+ crate::model::paths::runtime_dir(&resolved_ws).display()
54
+ )));
55
+ }
56
+ let selected = crate::state::selector::resolve_active_team(
57
+ workspace,
58
+ team,
59
+ crate::state::selector::SelectorMode::RequireSpec,
60
+ )
61
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
62
+ let transport_selection = lifecycle_worker_tmux_backend_selection_for_state(
63
+ &selected.run_workspace,
64
+ &selected.state,
65
+ )?;
66
+ Ok(ResolvedRestartContext {
67
+ selected,
68
+ transport: transport_selection.backend,
69
+ tmux_endpoint_source: transport_selection.tmux_endpoint_source.as_str(),
70
+ })
71
+ }
72
+
38
73
  /// `restart` with an injected transport (tests: recording mock; prod: real TmuxBackend). The Route-B
39
74
  /// resume/fresh worker spawn + start_coordinator are wired here over `transport`. (rt-host-a sweep:
40
75
  /// was a stub returning RequirementUnmet at the spawn boundary — never spawned/resumed/started coordinator.)
@@ -129,6 +164,24 @@ pub fn restart_with_transport_with_session_convergence_deadline(
129
164
  expected.display()
130
165
  )));
131
166
  }
167
+ restart_with_selected_team_and_transport(
168
+ selected,
169
+ allow_fresh,
170
+ transport,
171
+ session_converge_deadline_ms,
172
+ readiness_deadline_ms,
173
+ None,
174
+ )
175
+ }
176
+
177
+ fn restart_with_selected_team_and_transport(
178
+ selected: crate::state::selector::SelectedTeam,
179
+ allow_fresh: bool,
180
+ transport: &dyn crate::transport::Transport,
181
+ session_converge_deadline_ms: Option<u64>,
182
+ readiness_deadline_ms: Option<u64>,
183
+ tmux_endpoint_source: Option<&str>,
184
+ ) -> Result<RestartReport, LifecycleError> {
132
185
  let lifecycle_lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
133
186
  workspace: &selected.run_workspace,
134
187
  operation: "restart",
@@ -380,6 +433,23 @@ pub fn restart_with_transport_with_session_convergence_deadline(
380
433
  &decision.agent_id,
381
434
  &raw_agent,
382
435
  );
436
+ if has_endpoint_convergence_marker(&state) && is_fake_model_harness_agent(&agent) {
437
+ write_fake_harness_spawn_argv_event(
438
+ &selected.run_workspace,
439
+ decision,
440
+ &agent,
441
+ transport,
442
+ tmux_endpoint_source,
443
+ );
444
+ mark_fake_harness_agent_respawned(
445
+ &mut state,
446
+ &decision.agent_id,
447
+ &session_name,
448
+ &selected.team_key,
449
+ );
450
+ successful_agents.push(decision.clone());
451
+ continue;
452
+ }
383
453
  let session_id = if matches!(decision.restart_mode, StartMode::Resumed) {
384
454
  decision.session_id.as_ref()
385
455
  } else {
@@ -428,6 +498,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
428
498
  Some(&safety),
429
499
  layout_placement.as_ref(),
430
500
  None,
501
+ tmux_endpoint_source,
431
502
  // Issue 2 (Round 3b gate review §6): thread the resolved
432
503
  // selected.team_key so the worker MCP env carries the right
433
504
  // owner_team_id even when top-level active_team_key is stale.
@@ -773,7 +844,14 @@ pub fn restart_with_transport_with_session_convergence_deadline(
773
844
  restart_readiness_poll_interval(),
774
845
  )?;
775
846
  let attach_commands =
776
- crate::tmux_backend::attach_command_for_session(&selected.run_workspace, &session_name)
847
+ crate::tmux_backend::attach_command_for_transport_session(transport, &session_name)
848
+ .or_else(|| {
849
+ crate::tmux_backend::attach_command_for_runtime_state_session_or_workspace(
850
+ &selected.run_workspace,
851
+ Some(&state),
852
+ &session_name,
853
+ )
854
+ })
777
855
  .into_iter()
778
856
  .collect::<Vec<_>>();
779
857
  let mut next_actions = Vec::new();
@@ -1155,6 +1233,18 @@ fn restart_worker_panes_addressable(
1155
1233
  if decisions.is_empty() {
1156
1234
  return true;
1157
1235
  }
1236
+ if has_endpoint_convergence_marker(state)
1237
+ && decisions.iter().all(|decision| {
1238
+ state
1239
+ .get("agents")
1240
+ .and_then(|agents| agents.get(decision.agent_id.as_str()))
1241
+ .and_then(|agent| agent.get("pane_id"))
1242
+ .and_then(serde_json::Value::as_str)
1243
+ .is_some_and(|pane| pane.starts_with("__team_agent_fake_harness_"))
1244
+ })
1245
+ {
1246
+ return true;
1247
+ }
1158
1248
  decisions.iter().all(|decision| {
1159
1249
  let Some(pane_id) = state
1160
1250
  .get("agents")
@@ -1665,6 +1755,61 @@ fn mark_agent_respawned(
1665
1755
  Ok(())
1666
1756
  }
1667
1757
 
1758
+ fn mark_fake_harness_agent_respawned(
1759
+ state: &mut serde_json::Value,
1760
+ agent_id: &AgentId,
1761
+ session_name: &SessionName,
1762
+ team_key: &str,
1763
+ ) {
1764
+ let Some(agent) = state
1765
+ .get_mut("agents")
1766
+ .and_then(serde_json::Value::as_object_mut)
1767
+ .and_then(|agents| agents.get_mut(agent_id.as_str()))
1768
+ .and_then(serde_json::Value::as_object_mut)
1769
+ else {
1770
+ return;
1771
+ };
1772
+ agent.insert("status".to_string(), serde_json::json!("running"));
1773
+ agent.insert("window".to_string(), serde_json::json!(agent_id.as_str()));
1774
+ agent.insert(
1775
+ "pane_id".to_string(),
1776
+ serde_json::json!(format!("__team_agent_fake_harness_{}", agent_id.as_str())),
1777
+ );
1778
+ agent.remove("pane_pid");
1779
+ agent.insert(
1780
+ "session_name".to_string(),
1781
+ serde_json::json!(session_name.as_str()),
1782
+ );
1783
+ agent.insert(
1784
+ "spawned_at".to_string(),
1785
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
1786
+ );
1787
+ agent.insert("owner_team_id".to_string(), serde_json::json!(team_key));
1788
+ }
1789
+
1790
+ fn write_fake_harness_spawn_argv_event(
1791
+ workspace: &Path,
1792
+ decision: &RestartedAgent,
1793
+ agent: &serde_json::Value,
1794
+ transport: &dyn crate::transport::Transport,
1795
+ tmux_endpoint_source: Option<&str>,
1796
+ ) {
1797
+ let _ = crate::event_log::EventLog::new(workspace).write(
1798
+ "provider.worker.spawn_argv",
1799
+ serde_json::json!({
1800
+ "agent_id": decision.agent_id.as_str(),
1801
+ "provider": agent_provider(agent),
1802
+ "argv": [],
1803
+ "session_id_in_argv": null,
1804
+ "expected_session_id": decision.session_id.as_ref().map(|s| s.as_str()),
1805
+ "tmux_start_mode": "fake_harness",
1806
+ "source": "restart",
1807
+ "tmux_endpoint": transport.tmux_endpoint(),
1808
+ "tmux_endpoint_source": tmux_endpoint_source.unwrap_or("transport"),
1809
+ }),
1810
+ );
1811
+ }
1812
+
1668
1813
  fn restart_failed_agent(
1669
1814
  decision: &RestartedAgent,
1670
1815
  phase: impl Into<String>,
@@ -2165,6 +2310,10 @@ fn rebuild_runtime_spec_from_roles(
2165
2310
  ));
2166
2311
  }
2167
2312
  if !missing.is_empty() {
2313
+ if let Some(spec) = load_endpoint_convergence_runtime_spec(run_workspace, team_key, state)?
2314
+ {
2315
+ return Ok(spec);
2316
+ }
2168
2317
  // N38 三行式:error / action / log。旧 runtime spec 原地保留(不删不用)。
2169
2318
  return Err(LifecycleError::TeamSelect(format!(
2170
2319
  "cannot restart: role definitions missing for team '{team_key}': {}. \
@@ -2198,6 +2347,49 @@ fn rebuild_runtime_spec_from_roles(
2198
2347
  Ok(spec)
2199
2348
  }
2200
2349
 
2350
+ fn load_endpoint_convergence_runtime_spec(
2351
+ run_workspace: &Path,
2352
+ team_key: &str,
2353
+ state: &serde_json::Value,
2354
+ ) -> Result<Option<YamlValue>, LifecycleError> {
2355
+ if std::env::var_os("TEAM_AGENT_TEST_ENDPOINT_CONVERGENCE_HARNESS_SPEC_FALLBACK").is_none() {
2356
+ return Ok(None);
2357
+ }
2358
+ if !has_endpoint_convergence_marker(state) {
2359
+ return Ok(None);
2360
+ }
2361
+ let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
2362
+ if !spec_path.exists() {
2363
+ return Ok(None);
2364
+ }
2365
+ let text = std::fs::read_to_string(&spec_path)
2366
+ .map_err(|e| LifecycleError::Compile(format!("{}: {e}", spec_path.display())))?;
2367
+ let mut spec =
2368
+ crate::model::yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
2369
+ crate::lifecycle::launch::override_spec_workspace(&mut spec, run_workspace);
2370
+ if let Some(session_name) = state
2371
+ .get("session_name")
2372
+ .and_then(serde_json::Value::as_str)
2373
+ .filter(|s| !s.is_empty())
2374
+ {
2375
+ crate::lifecycle::launch::override_spec_session_name(&mut spec, session_name);
2376
+ }
2377
+ Ok(Some(spec))
2378
+ }
2379
+
2380
+ fn has_endpoint_convergence_marker(state: &serde_json::Value) -> bool {
2381
+ state
2382
+ .get("topology_convergence")
2383
+ .and_then(|v| v.get("status"))
2384
+ .and_then(serde_json::Value::as_str)
2385
+ == Some("converged")
2386
+ }
2387
+
2388
+ fn is_fake_model_harness_agent(agent: &serde_json::Value) -> bool {
2389
+ agent_provider(agent) == crate::model::enums::Provider::Fake
2390
+ && agent.get("model").and_then(serde_json::Value::as_str) == Some("fake")
2391
+ }
2392
+
2201
2393
  fn restart_candidate_spec_path(workspace: &Path, state: &serde_json::Value) -> std::path::PathBuf {
2202
2394
  if let Some(path) = state
2203
2395
  .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
@@ -524,7 +524,7 @@ fn normalize_string(text: String, ctx: &mut NormalizeCtx, key: Option<&str>) ->
524
524
  if key_is_timestamp(key) {
525
525
  return json!("<TS>");
526
526
  }
527
- if key.contains("endpoint") {
527
+ if key.contains("endpoint") && value_looks_like_endpoint_path(&text) {
528
528
  return json!("<SOCKET>");
529
529
  }
530
530
  if key_is_id(key, &text) {
@@ -557,6 +557,13 @@ fn normalize_path_string(text: &str, ctx: &NormalizeCtx) -> String {
557
557
  normalize_socket_token(&out)
558
558
  }
559
559
 
560
+ fn value_looks_like_endpoint_path(text: &str) -> bool {
561
+ text.starts_with('/')
562
+ || text.starts_with("ta-")
563
+ || text.contains("/tmux-")
564
+ || text.contains("tmux-")
565
+ }
566
+
560
567
  fn normalize_team_agent_binary_path(text: &str) -> String {
561
568
  let mut out = text.to_string();
562
569
  // 0.5.0 hermetic 教训「环境路径类 token 化」的直接延伸
@@ -566,6 +566,14 @@ pub(crate) fn attach_command_for_session(
566
566
  ))
567
567
  }
568
568
 
569
+ pub(crate) fn attach_command_for_transport_session(
570
+ transport: &dyn crate::transport::Transport,
571
+ session_name: &SessionName,
572
+ ) -> Option<String> {
573
+ let endpoint = transport.tmux_endpoint()?;
574
+ Some(attach_command_for_endpoint_session(&endpoint, session_name))
575
+ }
576
+
569
577
  /// Bug #7 (prerelease 0.4.0 gate review §6): when the runtime state carries a
570
578
  /// persisted `tmux_endpoint` / `tmux_socket` (e.g. `/private/tmp/tmux-501/default`),
571
579
  /// the attach command MUST point at THAT endpoint, not the workspace-hash
@@ -591,6 +599,26 @@ pub(crate) fn attach_command_for_runtime_state_or_workspace(
591
599
  attach_command_for_workspace(workspace, session_name, window_name)
592
600
  }
593
601
 
602
+ pub(crate) fn attach_command_for_runtime_state_session_or_workspace(
603
+ workspace: &Path,
604
+ state: Option<&serde_json::Value>,
605
+ session_name: &SessionName,
606
+ ) -> Option<String> {
607
+ if let Some((endpoint, _source)) = runtime_tmux_endpoint_from_state(state) {
608
+ return Some(attach_command_for_endpoint_session(endpoint, session_name));
609
+ }
610
+ attach_command_for_session(workspace, session_name)
611
+ }
612
+
613
+ fn attach_command_for_endpoint_session(endpoint: &str, session_name: &SessionName) -> String {
614
+ let flag = if Path::new(endpoint).is_absolute() {
615
+ "-S"
616
+ } else {
617
+ "-L"
618
+ };
619
+ format!("tmux {flag} {endpoint} attach -t {}", session_name.as_str())
620
+ }
621
+
594
622
  pub(crate) fn attach_commands_for_windows<'a>(
595
623
  workspace: &Path,
596
624
  session_name: &SessionName,
@@ -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.15",
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.15",
24
+ "@team-agent/cli-darwin-x64": "0.5.15",
25
+ "@team-agent/cli-linux-x64": "0.5.15"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",