@team-agent/installer 0.5.12 → 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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/mod.rs +75 -23
- package/crates/team-agent/src/layout/sessions.rs +3 -1
- package/crates/team-agent/src/leader/lease.rs +194 -55
- package/crates/team-agent/src/leader/types.rs +4 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +101 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +31 -0
- package/crates/team-agent/src/topology.rs +233 -2
- 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,22 +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
|
-
|
|
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
|
+
}
|
|
3329
3341
|
}
|
|
3330
3342
|
}
|
|
3331
3343
|
|
|
@@ -3960,6 +3972,7 @@ pub mod leader_port {
|
|
|
3960
3972
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
3961
3973
|
let mut value = lease_value(result);
|
|
3962
3974
|
if value.get("ok").and_then(Value::as_bool) == Some(true) {
|
|
3975
|
+
emit_topology_convergence_event(workspace, team, "takeover", &value);
|
|
3963
3976
|
register_after_binding_success(workspace, team, "takeover", &mut value);
|
|
3964
3977
|
}
|
|
3965
3978
|
Ok(value)
|
|
@@ -3993,6 +4006,7 @@ pub mod leader_port {
|
|
|
3993
4006
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
3994
4007
|
let mut value = lease_value(result);
|
|
3995
4008
|
if value.get("ok").and_then(Value::as_bool) == Some(true) {
|
|
4009
|
+
emit_topology_convergence_event(workspace, team, "claim-leader", &value);
|
|
3996
4010
|
register_after_binding_success(workspace, team, "claim-leader", &mut value);
|
|
3997
4011
|
}
|
|
3998
4012
|
Ok(value)
|
|
@@ -4122,6 +4136,41 @@ pub mod leader_port {
|
|
|
4122
4136
|
}
|
|
4123
4137
|
}
|
|
4124
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
|
+
|
|
4125
4174
|
/// E7 GC hook: called from shutdown/unbind success paths.
|
|
4126
4175
|
pub(crate) fn unregister_after_shutdown_success(
|
|
4127
4176
|
workspace: &Path,
|
|
@@ -4252,6 +4301,9 @@ pub mod leader_port {
|
|
|
4252
4301
|
serde_json::to_value(owner).unwrap_or(Value::Null),
|
|
4253
4302
|
);
|
|
4254
4303
|
}
|
|
4304
|
+
if let Some(topology_convergence) = result.topology_convergence {
|
|
4305
|
+
out.insert("topology_convergence".to_string(), topology_convergence);
|
|
4306
|
+
}
|
|
4255
4307
|
Value::Object(out)
|
|
4256
4308
|
}
|
|
4257
4309
|
|
|
@@ -160,7 +160,9 @@ pub fn assert_topology_invariants(state: &JsonValue, spec: &YamlValue) -> Vec<To
|
|
|
160
160
|
kind: TopologyViolationKind::LeaderPaneIdCollidesWithAgent,
|
|
161
161
|
detail: format!(
|
|
162
162
|
"leader_receiver.pane_id `{leader_pane}` is also bound to \
|
|
163
|
-
agent `{agent_id}` —
|
|
163
|
+
agent `{agent_id}` — legacy bare-pane advisory only; validate \
|
|
164
|
+
endpoint/session/window/pane_pid tuple before using this as an \
|
|
165
|
+
identity blocker"
|
|
164
166
|
),
|
|
165
167
|
});
|
|
166
168
|
}
|
|
@@ -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
|
|
|
@@ -447,10 +450,11 @@ pub fn claim_leader(
|
|
|
447
450
|
.list_targets()
|
|
448
451
|
.unwrap_or_default(),
|
|
449
452
|
);
|
|
450
|
-
let
|
|
453
|
+
let caller_pane_info = targets
|
|
451
454
|
.iter()
|
|
452
|
-
.find(|target| target.pane_id.as_str() == caller)
|
|
453
|
-
|
|
455
|
+
.find(|target| target.pane_id.as_str() == caller);
|
|
456
|
+
let caller_target =
|
|
457
|
+
caller_pane_info.and_then(|target| claim_target_from_pane_info(workspace, target));
|
|
454
458
|
let env_team = std::env::var("TEAM_AGENT_TEAM_ID")
|
|
455
459
|
.ok()
|
|
456
460
|
.filter(|team| !team.is_empty());
|
|
@@ -496,6 +500,7 @@ pub fn claim_leader(
|
|
|
496
500
|
&event_log,
|
|
497
501
|
&liveness,
|
|
498
502
|
caller_target.as_ref(),
|
|
503
|
+
caller_pane_info,
|
|
499
504
|
scoped_team.map(|_| team_id.as_str()),
|
|
500
505
|
)?;
|
|
501
506
|
if result.ok {
|
|
@@ -556,6 +561,7 @@ pub fn claim_lease_no_incident(
|
|
|
556
561
|
event_log,
|
|
557
562
|
liveness,
|
|
558
563
|
None,
|
|
564
|
+
None,
|
|
559
565
|
scoped_team,
|
|
560
566
|
)
|
|
561
567
|
}
|
|
@@ -586,6 +592,7 @@ fn claim_lease_no_incident_with_target(
|
|
|
586
592
|
event_log: &crate::event_log::EventLog,
|
|
587
593
|
liveness: &dyn crate::state::owner_gate::PaneLivenessProbe,
|
|
588
594
|
caller_target: Option<&LeaderClaimTarget>,
|
|
595
|
+
caller_pane_info: Option<&PaneInfo>,
|
|
589
596
|
scoped_team: Option<&str>,
|
|
590
597
|
) -> Result<LeaseResult, LeaderError> {
|
|
591
598
|
let _ = team;
|
|
@@ -626,6 +633,12 @@ fn claim_lease_no_incident_with_target(
|
|
|
626
633
|
let non_empty_caller_pane = NonEmptyPaneId::try_from_pane(caller_pane)?;
|
|
627
634
|
let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
|
|
628
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
|
+
}
|
|
629
642
|
return Ok(LeaseResult {
|
|
630
643
|
ok: true,
|
|
631
644
|
status: LeaseStatus::AlreadyBound,
|
|
@@ -635,6 +648,7 @@ fn claim_lease_no_incident_with_target(
|
|
|
635
648
|
reason: None,
|
|
636
649
|
action: None,
|
|
637
650
|
bound_pane_id: Some(caller_pane.clone()),
|
|
651
|
+
topology_convergence,
|
|
638
652
|
});
|
|
639
653
|
}
|
|
640
654
|
let owner_live = bound_pane_id
|
|
@@ -705,25 +719,53 @@ fn claim_lease_no_incident_with_target(
|
|
|
705
719
|
// worker's tmux pane overwrites leader_receiver.pane_id with the worker's
|
|
706
720
|
// pane → delivery routes worker messages to itself (loop) and the leader
|
|
707
721
|
// loses its handle (the macmini "hand-handle mapping 灾难" truth source).
|
|
708
|
-
if let Some(
|
|
709
|
-
|
|
710
|
-
event_log,
|
|
711
|
-
LeaseReason::CallerNotLeaderShaped,
|
|
722
|
+
if let Some(caller_pane_info) = caller_pane_info {
|
|
723
|
+
match crate::topology::classify_registered_worker_for_observed_pane(
|
|
712
724
|
state,
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
725
|
+
caller_pane_info,
|
|
726
|
+
) {
|
|
727
|
+
crate::topology::WorkerPaneBindingMatch::LiveSameWorker { agent_id } => {
|
|
728
|
+
emit_lease_refusal(
|
|
729
|
+
event_log,
|
|
730
|
+
LeaseReason::CallerNotLeaderShaped,
|
|
731
|
+
state,
|
|
732
|
+
bound_pane_id.as_deref(),
|
|
733
|
+
Some(caller_pane.as_str()),
|
|
734
|
+
team_id,
|
|
735
|
+
)?;
|
|
736
|
+
return Ok(refused(
|
|
737
|
+
LeaseReason::CallerNotLeaderShaped,
|
|
738
|
+
&format!(
|
|
739
|
+
"pane {} is registered as worker {agent_id}; \
|
|
740
|
+
run claim-leader from the leader's own pane, not a worker pane",
|
|
741
|
+
caller_pane.as_str()
|
|
742
|
+
),
|
|
743
|
+
None,
|
|
744
|
+
bound_pane_id.clone().map(PaneId::new),
|
|
745
|
+
));
|
|
746
|
+
}
|
|
747
|
+
crate::topology::WorkerPaneBindingMatch::Stale { agent_id, reason } => {
|
|
748
|
+
event_log.write(
|
|
749
|
+
"leader_receiver.worker_pane_binding_ignored",
|
|
750
|
+
json!({
|
|
751
|
+
"agent_id": agent_id,
|
|
752
|
+
"pane_id": caller_pane.as_str(),
|
|
753
|
+
"reason": reason,
|
|
754
|
+
}),
|
|
755
|
+
)?;
|
|
756
|
+
}
|
|
757
|
+
crate::topology::WorkerPaneBindingMatch::IncompleteLegacy { agent_id } => {
|
|
758
|
+
event_log.write(
|
|
759
|
+
"leader_receiver.worker_pane_binding_ignored",
|
|
760
|
+
json!({
|
|
761
|
+
"agent_id": agent_id,
|
|
762
|
+
"pane_id": caller_pane.as_str(),
|
|
763
|
+
"reason": "incomplete_legacy_tuple",
|
|
764
|
+
}),
|
|
765
|
+
)?;
|
|
766
|
+
}
|
|
767
|
+
crate::topology::WorkerPaneBindingMatch::NoMatch => {}
|
|
768
|
+
}
|
|
727
769
|
}
|
|
728
770
|
let reason = if bound_pane_id.is_some() {
|
|
729
771
|
LeaseReason::PreviousOwnerPaneDead
|
|
@@ -746,6 +788,8 @@ fn claim_lease_no_incident_with_target(
|
|
|
746
788
|
);
|
|
747
789
|
let owner = make_owner(provider, &non_empty_caller_pane, &identity, next_epoch);
|
|
748
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);
|
|
749
793
|
write_claim_state(workspace, state, scoped_team, team)?;
|
|
750
794
|
let uuid_prefix = identity.leader_session_uuid.as_str().chars().take(8).collect::<String>();
|
|
751
795
|
if reason == LeaseReason::PreviousOwnerPaneDead {
|
|
@@ -794,9 +838,130 @@ fn claim_lease_no_incident_with_target(
|
|
|
794
838
|
reason: Some(reason),
|
|
795
839
|
action: None,
|
|
796
840
|
bound_pane_id: Some(caller_pane.clone()),
|
|
841
|
+
topology_convergence,
|
|
797
842
|
})
|
|
798
843
|
}
|
|
799
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
|
+
|
|
800
965
|
fn refused(
|
|
801
966
|
reason: LeaseReason,
|
|
802
967
|
action: &str,
|
|
@@ -812,6 +977,7 @@ fn refused(
|
|
|
812
977
|
reason: Some(reason),
|
|
813
978
|
action: if action.is_empty() { None } else { Some(action.to_string()) },
|
|
814
979
|
bound_pane_id,
|
|
980
|
+
topology_convergence: None,
|
|
815
981
|
}
|
|
816
982
|
}
|
|
817
983
|
|
|
@@ -1135,38 +1301,6 @@ fn make_owner(
|
|
|
1135
1301
|
}
|
|
1136
1302
|
}
|
|
1137
1303
|
|
|
1138
|
-
/// E51 (0.3.26 P0, lease guard): scan state.agents + teams[*].agents for a worker
|
|
1139
|
-
/// whose pane_id matches the caller. Returns the first matching agent_id, or None
|
|
1140
|
-
/// if no registered worker owns this pane. The check is O(N agents) — negligible
|
|
1141
|
-
/// on a team-agent team which rarely exceeds ~20 workers.
|
|
1142
|
-
fn registered_worker_for_pane(state: &Value, caller_pane: &PaneId) -> Option<String> {
|
|
1143
|
-
if let Some(agent_id) = scan_agents_for_pane(state.get("agents"), caller_pane) {
|
|
1144
|
-
return Some(agent_id);
|
|
1145
|
-
}
|
|
1146
|
-
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
1147
|
-
for (_, team_state) in teams {
|
|
1148
|
-
if let Some(agent_id) = scan_agents_for_pane(team_state.get("agents"), caller_pane) {
|
|
1149
|
-
return Some(agent_id);
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
None
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
fn scan_agents_for_pane(agents: Option<&Value>, caller_pane: &PaneId) -> Option<String> {
|
|
1157
|
-
let map = agents?.as_object()?;
|
|
1158
|
-
for (agent_id, agent) in map {
|
|
1159
|
-
if agent
|
|
1160
|
-
.get("pane_id")
|
|
1161
|
-
.and_then(Value::as_str)
|
|
1162
|
-
.is_some_and(|pane| pane == caller_pane.as_str())
|
|
1163
|
-
{
|
|
1164
|
-
return Some(agent_id.clone());
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
None
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
1304
|
/// Stage 3a (identity-boundary unified plan, architect direction 2026-06-23)
|
|
1171
1305
|
/// + Stage 3 owner persist fix (architect direction 2026-06-24): route the
|
|
1172
1306
|
/// in-memory binding write through the `state::ownership` API. The
|
|
@@ -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 版)。
|
|
@@ -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,6 +1,8 @@
|
|
|
1
|
+
use std::process::Command;
|
|
2
|
+
|
|
1
3
|
use serde_json::{json, Value};
|
|
2
4
|
|
|
3
|
-
use crate::transport::{SessionName, Transport};
|
|
5
|
+
use crate::transport::{PaneInfo, SessionName, Transport};
|
|
4
6
|
|
|
5
7
|
pub const WORKER_PANE_BINDING_STALE: &str = "worker_pane_binding_stale";
|
|
6
8
|
pub const TMUX_ENDPOINT_SOCKET_CONFLICT: &str = "tmux_endpoint_socket_conflict";
|
|
@@ -30,6 +32,186 @@ 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
|
+
|
|
118
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
119
|
+
pub enum WorkerPaneBindingMatch {
|
|
120
|
+
LiveSameWorker { agent_id: String },
|
|
121
|
+
Stale { agent_id: String, reason: &'static str },
|
|
122
|
+
IncompleteLegacy { agent_id: String },
|
|
123
|
+
NoMatch,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
pub fn classify_worker_pane_binding(
|
|
127
|
+
agent_id: &str,
|
|
128
|
+
agent: &Value,
|
|
129
|
+
expected_session: &str,
|
|
130
|
+
observed: &PaneInfo,
|
|
131
|
+
) -> WorkerPaneBindingMatch {
|
|
132
|
+
let Some(cached_pane_id) = non_empty_str(agent, "pane_id") else {
|
|
133
|
+
return WorkerPaneBindingMatch::NoMatch;
|
|
134
|
+
};
|
|
135
|
+
if cached_pane_id != observed.pane_id.as_str() {
|
|
136
|
+
return WorkerPaneBindingMatch::NoMatch;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let Some(observed_window) = observed.window_name.as_ref().map(|window| window.as_str()) else {
|
|
140
|
+
return WorkerPaneBindingMatch::IncompleteLegacy {
|
|
141
|
+
agent_id: agent_id.to_string(),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
let expected_window = non_empty_str(agent, "window").unwrap_or(agent_id);
|
|
145
|
+
if expected_session.is_empty() || expected_window.is_empty() {
|
|
146
|
+
return WorkerPaneBindingMatch::IncompleteLegacy {
|
|
147
|
+
agent_id: agent_id.to_string(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if let (Some(expected_pid), Some(observed_pid)) = (agent_pane_pid(agent), observed.pane_pid) {
|
|
152
|
+
if expected_pid != observed_pid {
|
|
153
|
+
return WorkerPaneBindingMatch::Stale {
|
|
154
|
+
agent_id: agent_id.to_string(),
|
|
155
|
+
reason: "pane_pid_mismatch",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if observed.session.as_str() == expected_session && observed_window == expected_window {
|
|
161
|
+
WorkerPaneBindingMatch::LiveSameWorker {
|
|
162
|
+
agent_id: agent_id.to_string(),
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
WorkerPaneBindingMatch::Stale {
|
|
166
|
+
agent_id: agent_id.to_string(),
|
|
167
|
+
reason: "session_window_mismatch",
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pub fn classify_registered_worker_for_observed_pane(
|
|
173
|
+
state: &Value,
|
|
174
|
+
observed: &PaneInfo,
|
|
175
|
+
) -> WorkerPaneBindingMatch {
|
|
176
|
+
let mut fallback = WorkerPaneBindingMatch::NoMatch;
|
|
177
|
+
classify_agents_for_observed_pane(state, observed, &mut fallback);
|
|
178
|
+
if matches!(fallback, WorkerPaneBindingMatch::LiveSameWorker { .. }) {
|
|
179
|
+
return fallback;
|
|
180
|
+
}
|
|
181
|
+
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
182
|
+
for team_state in teams.values() {
|
|
183
|
+
classify_agents_for_observed_pane(team_state, observed, &mut fallback);
|
|
184
|
+
if matches!(fallback, WorkerPaneBindingMatch::LiveSameWorker { .. }) {
|
|
185
|
+
return fallback;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
fallback
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fn classify_agents_for_observed_pane(
|
|
193
|
+
state: &Value,
|
|
194
|
+
observed: &PaneInfo,
|
|
195
|
+
fallback: &mut WorkerPaneBindingMatch,
|
|
196
|
+
) {
|
|
197
|
+
let expected_session = non_empty_str(state, "session_name").unwrap_or_default();
|
|
198
|
+
let Some(agents) = state.get("agents").and_then(Value::as_object) else {
|
|
199
|
+
return;
|
|
200
|
+
};
|
|
201
|
+
for (agent_id, agent) in agents {
|
|
202
|
+
let candidate = classify_worker_pane_binding(agent_id, agent, expected_session, observed);
|
|
203
|
+
if matches!(candidate, WorkerPaneBindingMatch::LiveSameWorker { .. }) {
|
|
204
|
+
*fallback = candidate;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if matches!(fallback, WorkerPaneBindingMatch::NoMatch)
|
|
208
|
+
&& !matches!(candidate, WorkerPaneBindingMatch::NoMatch)
|
|
209
|
+
{
|
|
210
|
+
*fallback = candidate;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
33
215
|
fn append_socket_split_issues(state: &Value, issues: &mut Vec<Value>, include_readiness: bool) {
|
|
34
216
|
let endpoint = non_empty_str(state, "tmux_endpoint");
|
|
35
217
|
let socket = non_empty_str(state, "tmux_socket");
|
|
@@ -111,9 +293,20 @@ fn append_worker_pane_binding_issues(
|
|
|
111
293
|
.as_ref()
|
|
112
294
|
.map(|window| window.as_str())
|
|
113
295
|
.unwrap_or_default();
|
|
114
|
-
|
|
296
|
+
let classification = classify_worker_pane_binding(agent_id, agent, &session, observed);
|
|
297
|
+
if matches!(
|
|
298
|
+
classification,
|
|
299
|
+
WorkerPaneBindingMatch::LiveSameWorker { .. } | WorkerPaneBindingMatch::NoMatch
|
|
300
|
+
) {
|
|
115
301
|
continue;
|
|
116
302
|
}
|
|
303
|
+
let reason = match classification {
|
|
304
|
+
WorkerPaneBindingMatch::Stale { reason, .. } => reason,
|
|
305
|
+
WorkerPaneBindingMatch::IncompleteLegacy { .. } => "incomplete_legacy_tuple",
|
|
306
|
+
WorkerPaneBindingMatch::LiveSameWorker { .. } | WorkerPaneBindingMatch::NoMatch => {
|
|
307
|
+
"unknown"
|
|
308
|
+
}
|
|
309
|
+
};
|
|
117
310
|
issues.push(json!({
|
|
118
311
|
"id": WORKER_PANE_BINDING_STALE,
|
|
119
312
|
"agent_id": agent_id,
|
|
@@ -123,16 +316,54 @@ fn append_worker_pane_binding_issues(
|
|
|
123
316
|
"observed_session": observed.session.as_str(),
|
|
124
317
|
"observed_window": observed_window,
|
|
125
318
|
"observed_pane_pid": observed.pane_pid,
|
|
319
|
+
"reason": reason,
|
|
126
320
|
}));
|
|
127
321
|
}
|
|
128
322
|
}
|
|
129
323
|
|
|
324
|
+
fn agent_pane_pid(agent: &Value) -> Option<u32> {
|
|
325
|
+
agent
|
|
326
|
+
.get("pane_pid")
|
|
327
|
+
.and_then(Value::as_u64)
|
|
328
|
+
.and_then(|value| u32::try_from(value).ok())
|
|
329
|
+
}
|
|
330
|
+
|
|
130
331
|
fn session_exists_on_endpoint(endpoint: &str, session: &str) -> bool {
|
|
131
332
|
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
|
|
132
333
|
.has_session(&SessionName::new(session.to_string()))
|
|
133
334
|
.unwrap_or(false)
|
|
134
335
|
}
|
|
135
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
|
+
|
|
136
367
|
fn same_endpoint(left: &str, right: &str) -> bool {
|
|
137
368
|
normalize_endpoint(left) == normalize_endpoint(right)
|
|
138
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.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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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",
|