@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/mod.rs +75 -24
- package/crates/team-agent/src/leader/lease.rs +141 -2
- package/crates/team-agent/src/leader/types.rs +4 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +25 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +199 -7
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +31 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +8 -1
- package/crates/team-agent/src/tmux_backend.rs +28 -0
- package/crates/team-agent/src/topology.rs +115 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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(
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
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
|
-
} =>
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
"
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
"
|
|
3325
|
-
"
|
|
3326
|
-
"
|
|
3327
|
-
|
|
3328
|
-
|
|
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
|
|
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 版)。
|
|
@@ -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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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::
|
|
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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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",
|