@team-agent/installer 0.5.15 → 0.5.17
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 +7 -69
- package/crates/team-agent/src/leader/lease.rs +169 -5
- package/crates/team-agent/src/leader/registry.rs +104 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +85 -28
- package/crates/team-agent/src/mcp_server/helpers.rs +21 -2
- package/crates/team-agent/src/mcp_server/tests/tools.rs +92 -2
- package/crates/team-agent/src/mcp_server/tools.rs +20 -11
- package/crates/team-agent/src/messaging/delivery.rs +80 -11
- package/crates/team-agent/src/state/persist.rs +54 -0
- package/crates/team-agent/src/tmux_backend.rs +22 -1
- package/crates/team-agent/src/transport.rs +19 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -4060,79 +4060,15 @@ pub mod leader_port {
|
|
|
4060
4060
|
source: &str,
|
|
4061
4061
|
response: &mut Value,
|
|
4062
4062
|
) {
|
|
4063
|
-
let
|
|
4064
|
-
return;
|
|
4065
|
-
};
|
|
4066
|
-
let team_key = match team.filter(|t| !t.is_empty()) {
|
|
4067
|
-
Some(t) => t.to_string(),
|
|
4068
|
-
None => crate::state::projection::team_state_key(&state),
|
|
4069
|
-
};
|
|
4070
|
-
let receiver = state
|
|
4071
|
-
.get("teams")
|
|
4072
|
-
.and_then(|v| v.as_object())
|
|
4073
|
-
.and_then(|teams| teams.get(&team_key))
|
|
4074
|
-
.and_then(|t| t.get("leader_receiver"))
|
|
4075
|
-
.or_else(|| state.get("leader_receiver"))
|
|
4076
|
-
.cloned();
|
|
4077
|
-
let Some(receiver) = receiver else {
|
|
4078
|
-
return;
|
|
4079
|
-
};
|
|
4080
|
-
let transport_kind = receiver
|
|
4081
|
-
.get("transport_kind")
|
|
4082
|
-
.and_then(Value::as_str)
|
|
4083
|
-
.unwrap_or("direct_tmux")
|
|
4084
|
-
.to_string();
|
|
4085
|
-
let owner_epoch = receiver
|
|
4086
|
-
.get("owner_epoch")
|
|
4087
|
-
.and_then(Value::as_u64)
|
|
4088
|
-
.or_else(|| {
|
|
4089
|
-
state
|
|
4090
|
-
.get("teams")
|
|
4091
|
-
.and_then(|v| v.as_object())
|
|
4092
|
-
.and_then(|teams| teams.get(&team_key))
|
|
4093
|
-
.and_then(|t| t.get("owner_epoch"))
|
|
4094
|
-
.and_then(Value::as_u64)
|
|
4095
|
-
})
|
|
4096
|
-
.unwrap_or(0);
|
|
4097
|
-
let entry = crate::leader::registry::build_entry(
|
|
4063
|
+
let Some(outcome) = crate::leader::registry::register_binding_from_state_best_effort(
|
|
4098
4064
|
workspace,
|
|
4099
|
-
|
|
4100
|
-
&transport_kind,
|
|
4101
|
-
receiver,
|
|
4102
|
-
owner_epoch,
|
|
4065
|
+
team,
|
|
4103
4066
|
source,
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
let event_log = crate::event_log::EventLog::new(workspace);
|
|
4107
|
-
let write_result = crate::leader::registry::write_entry_best_effort(&entry);
|
|
4108
|
-
let registry_status = match &write_result {
|
|
4109
|
-
Some(path) => {
|
|
4110
|
-
let _ = event_log.write(
|
|
4111
|
-
crate::leader::registry::EVENT_REGISTERED,
|
|
4112
|
-
json!({
|
|
4113
|
-
"path": path.display().to_string(),
|
|
4114
|
-
"team_key": team_key,
|
|
4115
|
-
"workspace_hash": entry.workspace_hash,
|
|
4116
|
-
"source": source,
|
|
4117
|
-
"owner_epoch": entry.owner_epoch,
|
|
4118
|
-
}),
|
|
4119
|
-
);
|
|
4120
|
-
json!({"status": "registered", "path": path.display().to_string()})
|
|
4121
|
-
}
|
|
4122
|
-
None => {
|
|
4123
|
-
let _ = event_log.write(
|
|
4124
|
-
crate::leader::registry::EVENT_WRITE_FAILED,
|
|
4125
|
-
json!({
|
|
4126
|
-
"team_key": team_key,
|
|
4127
|
-
"workspace_hash": entry.workspace_hash,
|
|
4128
|
-
"source": source,
|
|
4129
|
-
}),
|
|
4130
|
-
);
|
|
4131
|
-
json!({"status": "write_failed"})
|
|
4132
|
-
}
|
|
4067
|
+
) else {
|
|
4068
|
+
return;
|
|
4133
4069
|
};
|
|
4134
4070
|
if let Some(obj) = response.as_object_mut() {
|
|
4135
|
-
obj.insert("leader_registry".to_string(),
|
|
4071
|
+
obj.insert("leader_registry".to_string(), outcome.response_json());
|
|
4136
4072
|
}
|
|
4137
4073
|
}
|
|
4138
4074
|
|
|
@@ -4167,6 +4103,8 @@ pub mod leader_port {
|
|
|
4167
4103
|
"source": source,
|
|
4168
4104
|
"reason": convergence.get("reason").and_then(Value::as_str).unwrap_or("old_endpoint_dead"),
|
|
4169
4105
|
"owner_epoch": convergence.get("owner_epoch").cloned().unwrap_or(Value::Null),
|
|
4106
|
+
"persisted": convergence.get("persisted").cloned().unwrap_or(Value::Bool(false)),
|
|
4107
|
+
"checked_paths": convergence.get("checked_paths").cloned().unwrap_or_else(|| json!([])),
|
|
4170
4108
|
}),
|
|
4171
4109
|
);
|
|
4172
4110
|
}
|
|
@@ -634,10 +634,28 @@ fn claim_lease_no_incident_with_target(
|
|
|
634
634
|
let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
|
|
635
635
|
if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
|
|
636
636
|
let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
|
|
637
|
-
let (topology_convergence, converged) =
|
|
637
|
+
let (mut topology_convergence, converged) =
|
|
638
638
|
apply_endpoint_convergence(state, team_id, current_endpoint.as_deref(), pre_epoch);
|
|
639
639
|
if converged {
|
|
640
640
|
write_claim_state(workspace, state, scoped_team, team)?;
|
|
641
|
+
topology_convergence = verify_persisted_topology_convergence(
|
|
642
|
+
workspace,
|
|
643
|
+
team_id.as_str(),
|
|
644
|
+
topology_convergence,
|
|
645
|
+
pre_epoch,
|
|
646
|
+
)?;
|
|
647
|
+
if topology_convergence
|
|
648
|
+
.as_ref()
|
|
649
|
+
.and_then(|metadata| metadata.get("status"))
|
|
650
|
+
.and_then(Value::as_str)
|
|
651
|
+
!= Some("converged")
|
|
652
|
+
{
|
|
653
|
+
return Ok(convergence_persistence_refusal(
|
|
654
|
+
topology_convergence,
|
|
655
|
+
pre_epoch,
|
|
656
|
+
Some(caller_pane.clone()),
|
|
657
|
+
));
|
|
658
|
+
}
|
|
641
659
|
}
|
|
642
660
|
return Ok(LeaseResult {
|
|
643
661
|
ok: true,
|
|
@@ -788,9 +806,29 @@ fn claim_lease_no_incident_with_target(
|
|
|
788
806
|
);
|
|
789
807
|
let owner = make_owner(provider, &non_empty_caller_pane, &identity, next_epoch);
|
|
790
808
|
write_binding_to_state(state, &receiver, &owner)?;
|
|
791
|
-
let (topology_convergence,
|
|
809
|
+
let (mut topology_convergence, converged) =
|
|
792
810
|
apply_endpoint_convergence(state, team_id, receiver.tmux_socket.as_deref(), next_epoch);
|
|
793
811
|
write_claim_state(workspace, state, scoped_team, team)?;
|
|
812
|
+
if converged {
|
|
813
|
+
topology_convergence = verify_persisted_topology_convergence(
|
|
814
|
+
workspace,
|
|
815
|
+
team_id.as_str(),
|
|
816
|
+
topology_convergence,
|
|
817
|
+
next_epoch,
|
|
818
|
+
)?;
|
|
819
|
+
if topology_convergence
|
|
820
|
+
.as_ref()
|
|
821
|
+
.and_then(|metadata| metadata.get("status"))
|
|
822
|
+
.and_then(Value::as_str)
|
|
823
|
+
!= Some("converged")
|
|
824
|
+
{
|
|
825
|
+
return Ok(convergence_persistence_refusal(
|
|
826
|
+
topology_convergence,
|
|
827
|
+
next_epoch,
|
|
828
|
+
Some(caller_pane.clone()),
|
|
829
|
+
));
|
|
830
|
+
}
|
|
831
|
+
}
|
|
794
832
|
let uuid_prefix = identity.leader_session_uuid.as_str().chars().take(8).collect::<String>();
|
|
795
833
|
if reason == LeaseReason::PreviousOwnerPaneDead {
|
|
796
834
|
event_log.write(
|
|
@@ -962,6 +1000,124 @@ fn write_convergence_marker(state: &mut Value, team_id: &str, metadata: &Value)
|
|
|
962
1000
|
}
|
|
963
1001
|
}
|
|
964
1002
|
|
|
1003
|
+
fn verify_persisted_topology_convergence(
|
|
1004
|
+
workspace: &Path,
|
|
1005
|
+
team_id: &str,
|
|
1006
|
+
metadata: Option<Value>,
|
|
1007
|
+
owner_epoch: OwnerEpoch,
|
|
1008
|
+
) -> Result<Option<Value>, LeaderError> {
|
|
1009
|
+
let Some(mut metadata) = metadata else {
|
|
1010
|
+
return Ok(None);
|
|
1011
|
+
};
|
|
1012
|
+
if metadata.get("status").and_then(Value::as_str) != Some("converged") {
|
|
1013
|
+
return Ok(Some(metadata));
|
|
1014
|
+
}
|
|
1015
|
+
let Some(new_endpoint) = metadata
|
|
1016
|
+
.get("new_tmux_endpoint")
|
|
1017
|
+
.and_then(Value::as_str)
|
|
1018
|
+
.map(str::to_string)
|
|
1019
|
+
else {
|
|
1020
|
+
mark_convergence_persistence_conflict(&mut metadata, team_id);
|
|
1021
|
+
return Ok(Some(metadata));
|
|
1022
|
+
};
|
|
1023
|
+
let state = crate::state::persist::load_runtime_state(workspace)?;
|
|
1024
|
+
if endpoint_convergence_persisted(&state, team_id, &new_endpoint, owner_epoch.0) {
|
|
1025
|
+
mark_convergence_persisted(&mut metadata, team_id);
|
|
1026
|
+
} else {
|
|
1027
|
+
mark_convergence_persistence_conflict(&mut metadata, team_id);
|
|
1028
|
+
}
|
|
1029
|
+
Ok(Some(metadata))
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
fn endpoint_convergence_persisted(
|
|
1033
|
+
state: &Value,
|
|
1034
|
+
team_id: &str,
|
|
1035
|
+
endpoint: &str,
|
|
1036
|
+
owner_epoch: u64,
|
|
1037
|
+
) -> bool {
|
|
1038
|
+
if !endpoint_convergence_node_matches(state, endpoint, owner_epoch) {
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
state
|
|
1042
|
+
.get("teams")
|
|
1043
|
+
.and_then(Value::as_object)
|
|
1044
|
+
.and_then(|teams| teams.get(team_id))
|
|
1045
|
+
.is_some_and(|team| endpoint_convergence_node_matches(team, endpoint, owner_epoch))
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
fn endpoint_convergence_node_matches(node: &Value, endpoint: &str, owner_epoch: u64) -> bool {
|
|
1049
|
+
node.get("tmux_endpoint").and_then(Value::as_str) == Some(endpoint)
|
|
1050
|
+
&& node.get("tmux_socket").and_then(Value::as_str) == Some(endpoint)
|
|
1051
|
+
&& node.get("tmux_socket_source").and_then(Value::as_str) == Some("leader_env")
|
|
1052
|
+
&& node
|
|
1053
|
+
.get("topology_convergence")
|
|
1054
|
+
.and_then(|marker| marker.get("status"))
|
|
1055
|
+
.and_then(Value::as_str)
|
|
1056
|
+
== Some("converged")
|
|
1057
|
+
&& node
|
|
1058
|
+
.get("topology_convergence")
|
|
1059
|
+
.and_then(|marker| marker.get("owner_epoch"))
|
|
1060
|
+
.and_then(Value::as_u64)
|
|
1061
|
+
== Some(owner_epoch)
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
fn mark_convergence_persisted(metadata: &mut Value, team_id: &str) {
|
|
1065
|
+
let Some(obj) = metadata.as_object_mut() else {
|
|
1066
|
+
return;
|
|
1067
|
+
};
|
|
1068
|
+
obj.insert("persisted".to_string(), json!(true));
|
|
1069
|
+
obj.insert("checked_paths".to_string(), json!(convergence_checked_paths(team_id)));
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
fn mark_convergence_persistence_conflict(metadata: &mut Value, team_id: &str) {
|
|
1073
|
+
let Some(obj) = metadata.as_object_mut() else {
|
|
1074
|
+
return;
|
|
1075
|
+
};
|
|
1076
|
+
obj.insert("status".to_string(), json!("persistence_conflict"));
|
|
1077
|
+
obj.insert("persisted".to_string(), json!(false));
|
|
1078
|
+
obj.insert("checked_paths".to_string(), json!(convergence_checked_paths(team_id)));
|
|
1079
|
+
obj.insert(
|
|
1080
|
+
"action".to_string(),
|
|
1081
|
+
json!("endpoint convergence was not durably persisted; retry claim-leader or takeover"),
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
fn convergence_checked_paths(team_id: &str) -> Vec<String> {
|
|
1086
|
+
[
|
|
1087
|
+
"/tmux_endpoint".to_string(),
|
|
1088
|
+
"/tmux_socket".to_string(),
|
|
1089
|
+
"/tmux_socket_source".to_string(),
|
|
1090
|
+
"/topology_convergence".to_string(),
|
|
1091
|
+
format!("/teams/{team_id}/tmux_endpoint"),
|
|
1092
|
+
format!("/teams/{team_id}/tmux_socket"),
|
|
1093
|
+
format!("/teams/{team_id}/tmux_socket_source"),
|
|
1094
|
+
format!("/teams/{team_id}/topology_convergence"),
|
|
1095
|
+
]
|
|
1096
|
+
.into_iter()
|
|
1097
|
+
.collect()
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
fn convergence_persistence_refusal(
|
|
1101
|
+
topology_convergence: Option<Value>,
|
|
1102
|
+
owner_epoch: OwnerEpoch,
|
|
1103
|
+
bound_pane_id: Option<PaneId>,
|
|
1104
|
+
) -> LeaseResult {
|
|
1105
|
+
LeaseResult {
|
|
1106
|
+
ok: false,
|
|
1107
|
+
status: LeaseStatus::Refused,
|
|
1108
|
+
receiver: None,
|
|
1109
|
+
owner: None,
|
|
1110
|
+
owner_epoch: Some(owner_epoch),
|
|
1111
|
+
reason: Some(LeaseReason::OwnerEpochAdvanced),
|
|
1112
|
+
action: Some(
|
|
1113
|
+
"endpoint convergence was not durably persisted; retry claim-leader or takeover"
|
|
1114
|
+
.to_string(),
|
|
1115
|
+
),
|
|
1116
|
+
bound_pane_id,
|
|
1117
|
+
topology_convergence,
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
965
1121
|
fn refused(
|
|
966
1122
|
reason: LeaseReason,
|
|
967
1123
|
action: &str,
|
|
@@ -1531,7 +1687,7 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
|
|
|
1531
1687
|
// preserving helper so the canonical fields survive the compaction.
|
|
1532
1688
|
teams.insert(
|
|
1533
1689
|
target_key.to_string(),
|
|
1534
|
-
|
|
1690
|
+
compact_team_state_preserving_claim_fields(state, target_key),
|
|
1535
1691
|
);
|
|
1536
1692
|
let existing_primary_key = existing
|
|
1537
1693
|
.get("session_name")
|
|
@@ -1584,7 +1740,7 @@ fn value_object(value: &Value) -> serde_json::Map<String, Value> {
|
|
|
1584
1740
|
/// and copy each present field into the compacted entry.
|
|
1585
1741
|
/// - Top-level owner fields stay UNTOUCHED — Stage 3d's canonical-only
|
|
1586
1742
|
/// shape is preserved on disk.
|
|
1587
|
-
fn
|
|
1743
|
+
fn compact_team_state_preserving_claim_fields(state: &Value, target_key: &str) -> Value {
|
|
1588
1744
|
let mut entry = crate::state::projection::compact_team_state(state);
|
|
1589
1745
|
let Some(entry_obj) = entry.as_object_mut() else {
|
|
1590
1746
|
return entry;
|
|
@@ -1597,7 +1753,15 @@ fn compact_team_state_preserving_ownership(state: &Value, target_key: &str) -> V
|
|
|
1597
1753
|
else {
|
|
1598
1754
|
return entry;
|
|
1599
1755
|
};
|
|
1600
|
-
for key in [
|
|
1756
|
+
for key in [
|
|
1757
|
+
"team_owner",
|
|
1758
|
+
"leader_receiver",
|
|
1759
|
+
"owner_epoch",
|
|
1760
|
+
"tmux_endpoint",
|
|
1761
|
+
"tmux_socket",
|
|
1762
|
+
"tmux_socket_source",
|
|
1763
|
+
"topology_convergence",
|
|
1764
|
+
] {
|
|
1601
1765
|
if let Some(value) = owner_obj.get(key) {
|
|
1602
1766
|
entry_obj.insert(key.to_string(), value.clone());
|
|
1603
1767
|
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
use std::path::{Path, PathBuf};
|
|
23
23
|
|
|
24
24
|
use serde::{Deserialize, Serialize};
|
|
25
|
+
use serde_json::{json, Value};
|
|
25
26
|
use sha2::{Digest, Sha256};
|
|
26
27
|
|
|
27
28
|
use crate::transport::Transport;
|
|
@@ -98,6 +99,26 @@ pub const AMBIGUOUS_NAMES_FIELD: &str = "ambiguous_names";
|
|
|
98
99
|
/// a fully-qualified form.
|
|
99
100
|
pub const REASON_AMBIGUOUS: &str = "name_ambiguous";
|
|
100
101
|
|
|
102
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
103
|
+
pub struct RegistryWriteOutcome {
|
|
104
|
+
pub status: &'static str,
|
|
105
|
+
pub path: Option<PathBuf>,
|
|
106
|
+
pub team_key: String,
|
|
107
|
+
pub workspace_hash: String,
|
|
108
|
+
pub source: String,
|
|
109
|
+
pub owner_epoch: u64,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
impl RegistryWriteOutcome {
|
|
113
|
+
#[must_use]
|
|
114
|
+
pub fn response_json(&self) -> Value {
|
|
115
|
+
match &self.path {
|
|
116
|
+
Some(path) => json!({"status": self.status, "path": path.display().to_string()}),
|
|
117
|
+
None => json!({"status": self.status}),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
101
122
|
/// Absolute path to `~/.team-agent/leaders`. Returns `None` when the
|
|
102
123
|
/// caller has no HOME (e.g. some CI shells) — callers should treat this as
|
|
103
124
|
/// "no registry" rather than an error, since registry is derived.
|
|
@@ -170,6 +191,89 @@ pub fn build_entry(
|
|
|
170
191
|
}
|
|
171
192
|
}
|
|
172
193
|
|
|
194
|
+
/// After a canonical leader binding succeeds, write the derived host
|
|
195
|
+
/// discovery entry from the just-persisted state. Registry failure is
|
|
196
|
+
/// non-fatal: callers get a write_failed outcome and the binding remains
|
|
197
|
+
/// successful.
|
|
198
|
+
pub fn register_binding_from_state_best_effort(
|
|
199
|
+
workspace: &Path,
|
|
200
|
+
team: Option<&str>,
|
|
201
|
+
source: &str,
|
|
202
|
+
) -> Option<RegistryWriteOutcome> {
|
|
203
|
+
let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
|
|
204
|
+
return None;
|
|
205
|
+
};
|
|
206
|
+
let team_key = match team.filter(|team| !team.is_empty()) {
|
|
207
|
+
Some(team) => team.to_string(),
|
|
208
|
+
None => crate::state::projection::team_state_key(&state),
|
|
209
|
+
};
|
|
210
|
+
let receiver = state
|
|
211
|
+
.get("teams")
|
|
212
|
+
.and_then(|value| value.as_object())
|
|
213
|
+
.and_then(|teams| teams.get(&team_key))
|
|
214
|
+
.and_then(|team| team.get("leader_receiver"))
|
|
215
|
+
.or_else(|| state.get("leader_receiver"))
|
|
216
|
+
.cloned()?;
|
|
217
|
+
let transport_kind = receiver
|
|
218
|
+
.get("transport_kind")
|
|
219
|
+
.and_then(Value::as_str)
|
|
220
|
+
.unwrap_or("direct_tmux")
|
|
221
|
+
.to_string();
|
|
222
|
+
let owner_epoch = receiver
|
|
223
|
+
.get("owner_epoch")
|
|
224
|
+
.and_then(Value::as_u64)
|
|
225
|
+
.or_else(|| {
|
|
226
|
+
state
|
|
227
|
+
.get("teams")
|
|
228
|
+
.and_then(|value| value.as_object())
|
|
229
|
+
.and_then(|teams| teams.get(&team_key))
|
|
230
|
+
.and_then(|team| team.get("owner_epoch"))
|
|
231
|
+
.and_then(Value::as_u64)
|
|
232
|
+
})
|
|
233
|
+
.unwrap_or(0);
|
|
234
|
+
let entry = build_entry(
|
|
235
|
+
workspace,
|
|
236
|
+
&team_key,
|
|
237
|
+
&transport_kind,
|
|
238
|
+
receiver,
|
|
239
|
+
owner_epoch,
|
|
240
|
+
source,
|
|
241
|
+
chrono::Utc::now().to_rfc3339(),
|
|
242
|
+
);
|
|
243
|
+
let event_log = crate::event_log::EventLog::new(workspace);
|
|
244
|
+
let write_result = write_entry_best_effort(&entry);
|
|
245
|
+
let status = if let Some(path) = &write_result {
|
|
246
|
+
let _ = event_log.write(
|
|
247
|
+
EVENT_REGISTERED,
|
|
248
|
+
json!({
|
|
249
|
+
"path": path.display().to_string(),
|
|
250
|
+
"team_key": team_key,
|
|
251
|
+
"workspace_hash": entry.workspace_hash,
|
|
252
|
+
"source": source,
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
"registered"
|
|
256
|
+
} else {
|
|
257
|
+
let _ = event_log.write(
|
|
258
|
+
EVENT_WRITE_FAILED,
|
|
259
|
+
json!({
|
|
260
|
+
"team_key": team_key,
|
|
261
|
+
"workspace_hash": entry.workspace_hash,
|
|
262
|
+
"source": source,
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
"write_failed"
|
|
266
|
+
};
|
|
267
|
+
Some(RegistryWriteOutcome {
|
|
268
|
+
status,
|
|
269
|
+
path: write_result,
|
|
270
|
+
team_key,
|
|
271
|
+
workspace_hash: entry.workspace_hash,
|
|
272
|
+
source: source.to_string(),
|
|
273
|
+
owner_epoch: entry.owner_epoch,
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
173
277
|
fn entry_filename(entry: &LeaderRegistryEntry) -> String {
|
|
174
278
|
format!("{}__{}.json", entry.workspace_hash, entry.team_key)
|
|
175
279
|
}
|
|
@@ -1404,6 +1404,11 @@ fn try_autobind_leader_after_restart(
|
|
|
1404
1404
|
let team_str = team;
|
|
1405
1405
|
match crate::leader::attach_leader(workspace, team_str, None, provider) {
|
|
1406
1406
|
Ok(result) if result.ok => {
|
|
1407
|
+
let _ = crate::leader::registry::register_binding_from_state_best_effort(
|
|
1408
|
+
workspace,
|
|
1409
|
+
team_str,
|
|
1410
|
+
"restart-auto-attach",
|
|
1411
|
+
);
|
|
1407
1412
|
eprintln!(
|
|
1408
1413
|
"team_agent::restart auto_attach_leader ok pane={:?} team={:?}",
|
|
1409
1414
|
result.bound_pane_id.as_ref().map(|p| p.as_str()),
|
|
@@ -3,6 +3,9 @@ use crate::transport::test_support::OfflineTransport;
|
|
|
3
3
|
use serde_json::json;
|
|
4
4
|
use serial_test::serial;
|
|
5
5
|
|
|
6
|
+
#[allow(dead_code)]
|
|
7
|
+
struct HermeticTestEnv;
|
|
8
|
+
|
|
6
9
|
const QS_TEAM_MD: &str =
|
|
7
10
|
"---\nname: quickteam\nobjective: Quick start.\nprovider: codex\n---\n\nQuick-start team.\n";
|
|
8
11
|
pub(super) const QS_VALID_ROLE: &str = "---\nname: implementer\nrole: Implementation Engineer\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nImplement bounded tasks.\n";
|
|
@@ -19,6 +19,9 @@ use serde_json::json;
|
|
|
19
19
|
use std::collections::BTreeSet;
|
|
20
20
|
use std::time::Duration;
|
|
21
21
|
|
|
22
|
+
#[allow(dead_code)]
|
|
23
|
+
struct HermeticTestEnv;
|
|
24
|
+
|
|
22
25
|
#[test]
|
|
23
26
|
fn concurrent_reset_discard_session_serializes() {
|
|
24
27
|
let ids = (1..=6).map(|n| format!("w{n}")).collect::<Vec<_>>();
|
|
@@ -9,22 +9,89 @@ use crate::transport::WindowName;
|
|
|
9
9
|
use serde_json::{json, Map, Value};
|
|
10
10
|
use std::collections::{BTreeMap, BTreeSet};
|
|
11
11
|
use std::path::{Path, PathBuf};
|
|
12
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
12
13
|
|
|
13
14
|
const ANCESTRY_ENV: &str = "TEAM_AGENT_TEST_PROCESS_ANCESTRY_ARGV_JSON";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
15
|
+
|
|
16
|
+
static HERMETIC_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
17
|
+
|
|
18
|
+
struct HermeticTestEnv {
|
|
19
|
+
root: PathBuf,
|
|
20
|
+
previous: Vec<(&'static str, Option<String>)>,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
impl HermeticTestEnv {
|
|
24
|
+
fn enter(tag: &str) -> Self {
|
|
25
|
+
let base = std::env::var_os("TEAM_AGENT_TEST_TMP")
|
|
26
|
+
.map(PathBuf::from)
|
|
27
|
+
.unwrap_or_else(std::env::temp_dir);
|
|
28
|
+
std::fs::create_dir_all(&base).expect("create hermetic test tmp root");
|
|
29
|
+
let root = base.join(format!(
|
|
30
|
+
"ta-phase-golden-{tag}-{}-{}",
|
|
31
|
+
std::process::id(),
|
|
32
|
+
HERMETIC_COUNTER.fetch_add(1, Ordering::Relaxed)
|
|
33
|
+
));
|
|
34
|
+
let _ = std::fs::remove_dir_all(&root);
|
|
35
|
+
std::fs::create_dir_all(root.join("home/.team-agent/leaders"))
|
|
36
|
+
.expect("create phase golden hermetic root");
|
|
37
|
+
let root = std::fs::canonicalize(root).expect("canonicalize phase golden hermetic root");
|
|
38
|
+
let home = root.join("home");
|
|
39
|
+
let mut previous = Vec::new();
|
|
40
|
+
for key in std::iter::once("HOME").chain(hermetic_caller_envs().iter().copied()) {
|
|
41
|
+
previous.push((key, std::env::var(key).ok()));
|
|
42
|
+
}
|
|
43
|
+
unsafe {
|
|
44
|
+
std::env::set_var("HOME", &home);
|
|
45
|
+
for key in hermetic_caller_envs() {
|
|
46
|
+
std::env::remove_var(key);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
Self { root, previous }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fn workspace(&self, tag: &str) -> PathBuf {
|
|
53
|
+
let path = self.root.join(format!(
|
|
54
|
+
"workspace-{tag}-{}",
|
|
55
|
+
HERMETIC_COUNTER.fetch_add(1, Ordering::Relaxed)
|
|
56
|
+
));
|
|
57
|
+
std::fs::create_dir_all(&path).expect("create phase golden workspace");
|
|
58
|
+
std::fs::canonicalize(path).expect("canonicalize phase golden workspace")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
impl Drop for HermeticTestEnv {
|
|
63
|
+
fn drop(&mut self) {
|
|
64
|
+
for (key, value) in self.previous.drain(..).rev() {
|
|
65
|
+
unsafe {
|
|
66
|
+
if let Some(value) = value {
|
|
67
|
+
std::env::set_var(key, value);
|
|
68
|
+
} else {
|
|
69
|
+
std::env::remove_var(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if std::env::var("TEAM_AGENT_KEEP_TEST_TMP").as_deref() != Ok("1") {
|
|
74
|
+
let _ = std::fs::remove_dir_all(&self.root);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fn hermetic_caller_envs() -> &'static [&'static str] {
|
|
80
|
+
&[
|
|
81
|
+
"TMUX",
|
|
82
|
+
"TMUX_PANE",
|
|
83
|
+
"TEAM_AGENT_LEADER_PANE_ID",
|
|
84
|
+
"TEAM_AGENT_LEADER_SESSION_UUID",
|
|
85
|
+
"TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE",
|
|
86
|
+
"TEAM_AGENT_LEADER_PROVIDER",
|
|
87
|
+
"TEAM_AGENT_MACHINE_FINGERPRINT",
|
|
88
|
+
"TEAM_AGENT_WORKSPACE",
|
|
89
|
+
"TEAM_AGENT_TEAM_ID",
|
|
90
|
+
"TEAM_AGENT_OWNER_TEAM_ID",
|
|
91
|
+
"TEAM_AGENT_ACTIVE_TEAM",
|
|
92
|
+
"TEAM_AGENT_ID",
|
|
93
|
+
]
|
|
94
|
+
}
|
|
28
95
|
|
|
29
96
|
#[test]
|
|
30
97
|
#[serial_test::serial(env)]
|
|
@@ -154,12 +221,9 @@ struct PhaseGolden {
|
|
|
154
221
|
}
|
|
155
222
|
|
|
156
223
|
fn run_phase_golden(spec: PhaseGolden) -> Value {
|
|
224
|
+
let hermetic = HermeticTestEnv::enter(spec.phase);
|
|
157
225
|
let _permission_mode = EnvVarGuard::set(ANCESTRY_ENV, "[]");
|
|
158
|
-
let
|
|
159
|
-
.iter()
|
|
160
|
-
.map(|key| EnvVarGuard::unset(key))
|
|
161
|
-
.collect::<Vec<_>>();
|
|
162
|
-
let team = two_worker_team_dir();
|
|
226
|
+
let team = two_worker_team_dir(&hermetic);
|
|
163
227
|
let workspace = team.parent().expect("workspace").to_path_buf();
|
|
164
228
|
seed_healthy_coordinator(&workspace);
|
|
165
229
|
let launch_transport = codex_ready_transport();
|
|
@@ -298,8 +362,8 @@ fn phase_b_reset_discard_session(
|
|
|
298
362
|
}
|
|
299
363
|
}
|
|
300
364
|
|
|
301
|
-
fn two_worker_team_dir() -> PathBuf {
|
|
302
|
-
let team =
|
|
365
|
+
fn two_worker_team_dir(hermetic: &HermeticTestEnv) -> PathBuf {
|
|
366
|
+
let team = hermetic.workspace("team").join("teamdir");
|
|
303
367
|
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
304
368
|
std::fs::write(
|
|
305
369
|
team.join("TEAM.md"),
|
|
@@ -415,13 +479,6 @@ impl EnvVarGuard {
|
|
|
415
479
|
Self { key, previous }
|
|
416
480
|
}
|
|
417
481
|
|
|
418
|
-
fn unset(key: &'static str) -> Self {
|
|
419
|
-
let previous = std::env::var(key).ok();
|
|
420
|
-
unsafe {
|
|
421
|
-
std::env::remove_var(key);
|
|
422
|
-
}
|
|
423
|
-
Self { key, previous }
|
|
424
|
-
}
|
|
425
482
|
}
|
|
426
483
|
|
|
427
484
|
impl Drop for EnvVarGuard {
|
|
@@ -279,12 +279,31 @@ pub(crate) fn latest_reportable_message_for(
|
|
|
279
279
|
workspace: &Path,
|
|
280
280
|
agent_id: &str,
|
|
281
281
|
owner_team_id: Option<&str>,
|
|
282
|
+
) -> Option<String> {
|
|
283
|
+
current_reportable_message_for(workspace, agent_id, owner_team_id)
|
|
284
|
+
.or_else(|| latest_delivered_direct_message_for(workspace, agent_id, owner_team_id))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
pub(crate) fn current_reportable_message_for(
|
|
288
|
+
workspace: &Path,
|
|
289
|
+
agent_id: &str,
|
|
290
|
+
owner_team_id: Option<&str>,
|
|
282
291
|
) -> Option<String> {
|
|
283
292
|
use crate::db::message_store::MessageStore;
|
|
284
293
|
let store = MessageStore::open(workspace).ok()?;
|
|
285
294
|
let conn = crate::db::schema::open_db(store.db_path()).ok()?;
|
|
286
295
|
current_turn_message_for(workspace, &conn, agent_id, owner_team_id)
|
|
287
|
-
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
pub(crate) fn latest_delivered_direct_message_for(
|
|
299
|
+
workspace: &Path,
|
|
300
|
+
agent_id: &str,
|
|
301
|
+
owner_team_id: Option<&str>,
|
|
302
|
+
) -> Option<String> {
|
|
303
|
+
use crate::db::message_store::MessageStore;
|
|
304
|
+
let store = MessageStore::open(workspace).ok()?;
|
|
305
|
+
let conn = crate::db::schema::open_db(store.db_path()).ok()?;
|
|
306
|
+
latest_reportable_message_from_db(&conn, agent_id, owner_team_id)
|
|
288
307
|
}
|
|
289
308
|
|
|
290
309
|
fn current_turn_message_for(
|
|
@@ -441,6 +460,6 @@ fn latest_reportable_message_from_db(
|
|
|
441
460
|
fn reportable_message_status(status: &str, error: Option<&str>) -> bool {
|
|
442
461
|
matches!(
|
|
443
462
|
status,
|
|
444
|
-
"delivered" | "
|
|
463
|
+
"delivered" | "submitted" | "injected" | "visible"
|
|
445
464
|
) && (status == "delivered" || error.is_none())
|
|
446
465
|
}
|
|
@@ -110,8 +110,16 @@
|
|
|
110
110
|
.unwrap();
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
fn seed_report_scope_state(ws: &std::path::Path, state: &Value) {
|
|
114
|
+
let cws = std::fs::canonicalize(ws).unwrap_or_else(|_| ws.to_path_buf());
|
|
115
|
+
let rt = cws.join(".team").join("runtime");
|
|
116
|
+
std::fs::create_dir_all(&rt).unwrap();
|
|
117
|
+
std::fs::write(rt.join("state.json"), serde_json::to_string_pretty(state).unwrap())
|
|
118
|
+
.unwrap();
|
|
119
|
+
}
|
|
120
|
+
|
|
113
121
|
#[test]
|
|
114
|
-
fn
|
|
122
|
+
fn report_result_prefers_current_turn_message_over_old_delivered_fallback() {
|
|
115
123
|
let ws = unique_ws("report-current-message");
|
|
116
124
|
seed_report_message(
|
|
117
125
|
&ws,
|
|
@@ -134,6 +142,33 @@
|
|
|
134
142
|
"target_resolved",
|
|
135
143
|
"2026-07-06T13:27:35.000000+00:00",
|
|
136
144
|
);
|
|
145
|
+
// 0.5.16 result-attribution-race-locate.md §4/§7.7:
|
|
146
|
+
// target_resolved alone is not physical-submit proof. Current-turn
|
|
147
|
+
// attribution comes from the state pointer armed at physical submit.
|
|
148
|
+
seed_report_scope_state(
|
|
149
|
+
&ws,
|
|
150
|
+
&json!({
|
|
151
|
+
"active_team_key": "gate055",
|
|
152
|
+
"teams": {
|
|
153
|
+
"gate055": {
|
|
154
|
+
"team_key": "gate055",
|
|
155
|
+
"coordinator": {
|
|
156
|
+
"turn_open": {
|
|
157
|
+
"armed": true,
|
|
158
|
+
"node_id": "probe-worker",
|
|
159
|
+
"turn_id": "msg_new"
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
"agents": {
|
|
163
|
+
"probe-worker": {
|
|
164
|
+
"id": "probe-worker",
|
|
165
|
+
"current_turn_message_id": "msg_new"
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
137
172
|
|
|
138
173
|
let tools = TeamOrchestratorTools::with_identity(
|
|
139
174
|
&ws,
|
|
@@ -149,7 +184,62 @@
|
|
|
149
184
|
assert_eq!(
|
|
150
185
|
v.get("task_id"),
|
|
151
186
|
Some(&json!("msg_new")),
|
|
152
|
-
"current
|
|
187
|
+
"current-turn message must beat stale delivered fallback"
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#[test]
|
|
192
|
+
fn report_result_target_resolved_without_current_turn_uses_task_fallback() {
|
|
193
|
+
let ws = unique_ws("report-target-resolved-no-current");
|
|
194
|
+
seed_report_message(
|
|
195
|
+
&ws,
|
|
196
|
+
"msg_target_only",
|
|
197
|
+
"gate055",
|
|
198
|
+
"target_resolved",
|
|
199
|
+
"2026-07-06T13:27:35.000000+00:00",
|
|
200
|
+
);
|
|
201
|
+
seed_report_scope_state(
|
|
202
|
+
&ws,
|
|
203
|
+
&json!({
|
|
204
|
+
"active_team_key": "gate055",
|
|
205
|
+
"teams": {
|
|
206
|
+
"gate055": {
|
|
207
|
+
"team_key": "gate055",
|
|
208
|
+
"tasks": [
|
|
209
|
+
{
|
|
210
|
+
"id": "task_initial",
|
|
211
|
+
"assignee": "probe-worker",
|
|
212
|
+
"status": "pending"
|
|
213
|
+
}
|
|
214
|
+
],
|
|
215
|
+
"agents": {
|
|
216
|
+
"probe-worker": {"id": "probe-worker"}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
let tools = TeamOrchestratorTools::with_identity(
|
|
224
|
+
&ws,
|
|
225
|
+
Some(AgentId::new("probe-worker")),
|
|
226
|
+
Some(TeamKey::new("gate055")),
|
|
227
|
+
);
|
|
228
|
+
let ok = tools.report_result(
|
|
229
|
+
None, Some("target resolved only"), ResultStatus::Success,
|
|
230
|
+
None, None, None, None, None,
|
|
231
|
+
None, None,
|
|
232
|
+
).expect("report ok");
|
|
233
|
+
let v = serde_json::to_value(&ok).unwrap();
|
|
234
|
+
assert_ne!(
|
|
235
|
+
v.get("task_id"),
|
|
236
|
+
Some(&json!("msg_target_only")),
|
|
237
|
+
"0.5.16 locate §4/§7.7: target_resolved is a delivery claim, not physical-submit proof"
|
|
238
|
+
);
|
|
239
|
+
assert_eq!(
|
|
240
|
+
v.get("task_id"),
|
|
241
|
+
Some(&json!("task_initial")),
|
|
242
|
+
"without a current-turn pointer, no-task report_result falls through to task fallback"
|
|
153
243
|
);
|
|
154
244
|
}
|
|
155
245
|
|
|
@@ -20,9 +20,10 @@ use crate::state::persist::{
|
|
|
20
20
|
use crate::messaging::{self, MessageTarget, SendOptions};
|
|
21
21
|
|
|
22
22
|
use super::helpers::{
|
|
23
|
-
delivery_outcome_value, ensure_object, enum_value,
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
current_reportable_message_for, delivery_outcome_value, ensure_object, enum_value,
|
|
24
|
+
insert_array, is_worker_recipient, json_dumps_default, latest_delivered_direct_message_for,
|
|
25
|
+
latest_task_for_assignee, non_empty_string, normalized_envelope_value, object_fields,
|
|
26
|
+
requires_ack_for_target, tool_runtime_error,
|
|
26
27
|
};
|
|
27
28
|
use super::normalize::{
|
|
28
29
|
compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
|
|
@@ -334,18 +335,26 @@ impl TeamOrchestratorTools {
|
|
|
334
335
|
// Blocker-1 (prerelease 0.4.0): scoped-team task inference +
|
|
335
336
|
// message-scoped fallback, before defaulting to "manual".
|
|
336
337
|
// 1. explicit arg
|
|
337
|
-
// 2.
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// 4. "manual" — truly uncorrelated; collect still rejects
|
|
338
|
+
// 2. current physical direct turn for this agent
|
|
339
|
+
// 3. latest delivered direct message with no result yet
|
|
340
|
+
// 4. latest nonterminal assigned task in teams.<owner>.tasks
|
|
341
|
+
// (scoped) → top-level tasks
|
|
342
|
+
// 5. "manual" — truly uncorrelated; collect still rejects
|
|
343
343
|
let owner_team_id_str = self.owner_team_id.as_ref().map(|t| t.as_str().to_string());
|
|
344
344
|
let resolved = task_id
|
|
345
345
|
.map(ToString::to_string)
|
|
346
346
|
.or_else(|| {
|
|
347
347
|
self.agent_id.as_ref().and_then(|agent| {
|
|
348
|
-
|
|
348
|
+
current_reportable_message_for(
|
|
349
|
+
&self.workspace,
|
|
350
|
+
agent.as_str(),
|
|
351
|
+
owner_team_id_str.as_deref(),
|
|
352
|
+
)
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
.or_else(|| {
|
|
356
|
+
self.agent_id.as_ref().and_then(|agent| {
|
|
357
|
+
latest_delivered_direct_message_for(
|
|
349
358
|
&self.workspace,
|
|
350
359
|
agent.as_str(),
|
|
351
360
|
owner_team_id_str.as_deref(),
|
|
@@ -354,7 +363,7 @@ impl TeamOrchestratorTools {
|
|
|
354
363
|
})
|
|
355
364
|
.or_else(|| {
|
|
356
365
|
self.agent_id.as_ref().and_then(|agent| {
|
|
357
|
-
|
|
366
|
+
latest_task_for_assignee(
|
|
358
367
|
&self.workspace,
|
|
359
368
|
agent.as_str(),
|
|
360
369
|
owner_team_id_str.as_deref(),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
//! internal_delivery.py + delivery.py — coordinator/调度器侧 thin wrapper + 单条 tmux 注入投递
|
|
2
2
|
//! + trust 有界重试 + turn-open arm (card §16/§65)。
|
|
3
3
|
|
|
4
|
+
use std::cell::{Cell, RefCell};
|
|
4
5
|
use std::path::Path;
|
|
5
6
|
|
|
6
7
|
use rusqlite::{params, OptionalExtension};
|
|
@@ -14,7 +15,7 @@ use crate::provider::wire::{
|
|
|
14
15
|
};
|
|
15
16
|
use crate::transport::{
|
|
16
17
|
submit_verification_wire, InjectPayload, InjectReport, InjectVerification, Key, PaneId,
|
|
17
|
-
PaneInfo, SessionName, SubmitVerification, Target, Transport, WindowName,
|
|
18
|
+
PaneInfo, SessionName, SubmitObserver, SubmitVerification, Target, Transport, WindowName,
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
use super::helpers::{message_exists, MessageStatusShadow};
|
|
@@ -414,7 +415,22 @@ pub fn deliver_pending_message(
|
|
|
414
415
|
} else {
|
|
415
416
|
InjectPayload::Text(rendered)
|
|
416
417
|
};
|
|
417
|
-
let
|
|
418
|
+
let submit_observer = CurrentTurnSubmitObserver::new(
|
|
419
|
+
workspace,
|
|
420
|
+
&message.sender,
|
|
421
|
+
&message.recipient,
|
|
422
|
+
message_id,
|
|
423
|
+
event_log,
|
|
424
|
+
canonical_owner_team_id.as_deref(),
|
|
425
|
+
resolved.metadata.as_ref(),
|
|
426
|
+
);
|
|
427
|
+
let inject_report = match transport.inject_with_submit_observer(
|
|
428
|
+
&target,
|
|
429
|
+
&payload,
|
|
430
|
+
Key::Enter,
|
|
431
|
+
true,
|
|
432
|
+
Some(&submit_observer),
|
|
433
|
+
) {
|
|
418
434
|
Ok(report) => report,
|
|
419
435
|
Err(error) => {
|
|
420
436
|
let reason = format!("inject_failed:{error}");
|
|
@@ -500,15 +516,9 @@ pub fn deliver_pending_message(
|
|
|
500
516
|
});
|
|
501
517
|
}
|
|
502
518
|
};
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
&message.recipient,
|
|
507
|
-
message_id,
|
|
508
|
-
event_log,
|
|
509
|
-
canonical_owner_team_id.as_deref(),
|
|
510
|
-
resolved.metadata.as_ref(),
|
|
511
|
-
)?;
|
|
519
|
+
if let Some(error) = submit_observer.take_error() {
|
|
520
|
+
return Err(error);
|
|
521
|
+
}
|
|
512
522
|
let submit_verified = inject_submit_verified(&inject_report);
|
|
513
523
|
let readback_verified = pane_readback_verified(&inject_report);
|
|
514
524
|
// Leader pane: inject success is delivery proof. Worker pane: post-submit
|
|
@@ -2109,6 +2119,65 @@ pub fn record_turn_open_if_leader_to_worker(
|
|
|
2109
2119
|
)
|
|
2110
2120
|
}
|
|
2111
2121
|
|
|
2122
|
+
struct CurrentTurnSubmitObserver<'a> {
|
|
2123
|
+
workspace: &'a Path,
|
|
2124
|
+
sender: &'a str,
|
|
2125
|
+
recipient: &'a str,
|
|
2126
|
+
message_id: &'a str,
|
|
2127
|
+
event_log: &'a EventLog,
|
|
2128
|
+
owner_team_id: Option<&'a str>,
|
|
2129
|
+
metadata: Option<&'a TargetMetadata>,
|
|
2130
|
+
fired: Cell<bool>,
|
|
2131
|
+
error: RefCell<Option<MessagingError>>,
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
impl<'a> CurrentTurnSubmitObserver<'a> {
|
|
2135
|
+
fn new(
|
|
2136
|
+
workspace: &'a Path,
|
|
2137
|
+
sender: &'a str,
|
|
2138
|
+
recipient: &'a str,
|
|
2139
|
+
message_id: &'a str,
|
|
2140
|
+
event_log: &'a EventLog,
|
|
2141
|
+
owner_team_id: Option<&'a str>,
|
|
2142
|
+
metadata: Option<&'a TargetMetadata>,
|
|
2143
|
+
) -> Self {
|
|
2144
|
+
Self {
|
|
2145
|
+
workspace,
|
|
2146
|
+
sender,
|
|
2147
|
+
recipient,
|
|
2148
|
+
message_id,
|
|
2149
|
+
event_log,
|
|
2150
|
+
owner_team_id,
|
|
2151
|
+
metadata,
|
|
2152
|
+
fired: Cell::new(false),
|
|
2153
|
+
error: RefCell::new(None),
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
fn take_error(&self) -> Option<MessagingError> {
|
|
2158
|
+
self.error.borrow_mut().take()
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
impl SubmitObserver for CurrentTurnSubmitObserver<'_> {
|
|
2163
|
+
fn after_physical_submit(&self) {
|
|
2164
|
+
if self.fired.replace(true) {
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
if let Err(error) = record_current_turn_after_inject_if_leader_to_worker_scoped(
|
|
2168
|
+
self.workspace,
|
|
2169
|
+
self.sender,
|
|
2170
|
+
self.recipient,
|
|
2171
|
+
self.message_id,
|
|
2172
|
+
self.event_log,
|
|
2173
|
+
self.owner_team_id,
|
|
2174
|
+
self.metadata,
|
|
2175
|
+
) {
|
|
2176
|
+
*self.error.borrow_mut() = Some(error);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2112
2181
|
fn record_current_turn_after_inject_if_leader_to_worker_scoped(
|
|
2113
2182
|
workspace: &Path,
|
|
2114
2183
|
sender: &str,
|
|
@@ -482,6 +482,7 @@ fn apply_persist_merge_contract(
|
|
|
482
482
|
// clobber broke Batch 6 CHECK 6 (`shutdown` couldn't route to
|
|
483
483
|
// the shim pid because `state.transport.shim.pid` was gone).
|
|
484
484
|
preserve_transport_shim(incoming, latest);
|
|
485
|
+
preserve_latest_endpoint_convergence_fields(incoming, latest);
|
|
485
486
|
}
|
|
486
487
|
|
|
487
488
|
let latest_teams = latest.get("teams").and_then(Value::as_object);
|
|
@@ -504,6 +505,7 @@ fn apply_persist_merge_contract(
|
|
|
504
505
|
topology_update_agent_ids,
|
|
505
506
|
)?;
|
|
506
507
|
preserve_latest_ownership_fields(incoming_entry, latest_entry);
|
|
508
|
+
preserve_latest_endpoint_convergence_fields(incoming_entry, latest_entry);
|
|
507
509
|
}
|
|
508
510
|
}
|
|
509
511
|
Ok(())
|
|
@@ -574,6 +576,58 @@ fn preserve_latest_ownership_fields(incoming: &mut Value, latest: &Value) {
|
|
|
574
576
|
}
|
|
575
577
|
}
|
|
576
578
|
|
|
579
|
+
fn preserve_latest_endpoint_convergence_fields(incoming: &mut Value, latest: &Value) {
|
|
580
|
+
if !latest_has_preferable_endpoint_convergence(incoming, latest) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
let Some(incoming_obj) = incoming.as_object_mut() else {
|
|
584
|
+
return;
|
|
585
|
+
};
|
|
586
|
+
for key in [
|
|
587
|
+
"tmux_endpoint",
|
|
588
|
+
"tmux_socket",
|
|
589
|
+
"tmux_socket_source",
|
|
590
|
+
"topology_convergence",
|
|
591
|
+
] {
|
|
592
|
+
if let Some(value) = latest.get(key).filter(|value| json_truthy(value)) {
|
|
593
|
+
incoming_obj.insert(key.to_string(), value.clone());
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
fn latest_has_preferable_endpoint_convergence(incoming: &Value, latest: &Value) -> bool {
|
|
599
|
+
if latest
|
|
600
|
+
.get("topology_convergence")
|
|
601
|
+
.and_then(|marker| marker.get("status"))
|
|
602
|
+
.and_then(Value::as_str)
|
|
603
|
+
!= Some("converged")
|
|
604
|
+
{
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
let latest_epoch = endpoint_convergence_epoch(latest).unwrap_or_else(|| ownership_epoch(latest));
|
|
608
|
+
let incoming_epoch =
|
|
609
|
+
endpoint_convergence_epoch(incoming).unwrap_or_else(|| ownership_epoch(incoming));
|
|
610
|
+
if latest_epoch < incoming_epoch {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
!incoming
|
|
614
|
+
.get("topology_convergence")
|
|
615
|
+
.is_some_and(|marker| {
|
|
616
|
+
marker.get("status").and_then(Value::as_str) == Some("converged")
|
|
617
|
+
&& marker
|
|
618
|
+
.get("owner_epoch")
|
|
619
|
+
.and_then(Value::as_u64)
|
|
620
|
+
.is_some_and(|epoch| epoch >= latest_epoch)
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
fn endpoint_convergence_epoch(state: &Value) -> Option<u64> {
|
|
625
|
+
state
|
|
626
|
+
.get("topology_convergence")
|
|
627
|
+
.and_then(|marker| marker.get("owner_epoch"))
|
|
628
|
+
.and_then(Value::as_u64)
|
|
629
|
+
}
|
|
630
|
+
|
|
577
631
|
fn latest_has_preferable_ownership(incoming: &Value, latest: &Value) -> bool {
|
|
578
632
|
let latest_epoch = ownership_epoch(latest);
|
|
579
633
|
let incoming_epoch = ownership_epoch(incoming);
|
|
@@ -36,7 +36,7 @@ use crate::transport::{
|
|
|
36
36
|
tmux_query_argv, tmux_send_keys_argv, tmux_spawn_argv, AttachOutcome, BackendKind,
|
|
37
37
|
CaptureRange, CapturedText, InjectPayload, InjectReport, InjectStage, InjectVerification, Key,
|
|
38
38
|
PaneField, PaneId, PaneInfo, PaneMode, SessionName, SetEnvOutcome, SpawnResult,
|
|
39
|
-
SubmitAttemptObservation, SubmitVerification, Target, Transport, TransportError,
|
|
39
|
+
SubmitAttemptObservation, SubmitObserver, SubmitVerification, Target, Transport, TransportError,
|
|
40
40
|
TurnVerification, WindowName,
|
|
41
41
|
};
|
|
42
42
|
|
|
@@ -1591,6 +1591,12 @@ fn shell_quote(raw: &str) -> String {
|
|
|
1591
1591
|
quoted
|
|
1592
1592
|
}
|
|
1593
1593
|
|
|
1594
|
+
fn notify_submit_observer(observer: Option<&dyn SubmitObserver>) {
|
|
1595
|
+
if let Some(observer) = observer {
|
|
1596
|
+
observer.after_physical_submit();
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1594
1600
|
impl Transport for TmuxBackend {
|
|
1595
1601
|
fn kind(&self) -> BackendKind {
|
|
1596
1602
|
BackendKind::Tmux
|
|
@@ -1702,6 +1708,17 @@ impl Transport for TmuxBackend {
|
|
|
1702
1708
|
payload: &InjectPayload,
|
|
1703
1709
|
submit: Key,
|
|
1704
1710
|
bracketed: bool,
|
|
1711
|
+
) -> Result<InjectReport, TransportError> {
|
|
1712
|
+
self.inject_with_submit_observer(target, payload, submit, bracketed, None)
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
fn inject_with_submit_observer(
|
|
1716
|
+
&self,
|
|
1717
|
+
target: &Target,
|
|
1718
|
+
payload: &InjectPayload,
|
|
1719
|
+
submit: Key,
|
|
1720
|
+
bracketed: bool,
|
|
1721
|
+
observer: Option<&dyn SubmitObserver>,
|
|
1705
1722
|
) -> Result<InjectReport, TransportError> {
|
|
1706
1723
|
let pane = pane_from_target(target);
|
|
1707
1724
|
// U1 #7: pane readback signal for the non-pasted-prompt text path.
|
|
@@ -1710,6 +1727,7 @@ impl Transport for TmuxBackend {
|
|
|
1710
1727
|
InjectPayload::Empty => {
|
|
1711
1728
|
let argv = tmux_empty_inject_argv(&pane, submit);
|
|
1712
1729
|
self.run_ok(&argv)?;
|
|
1730
|
+
notify_submit_observer(observer);
|
|
1713
1731
|
}
|
|
1714
1732
|
InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text) => {
|
|
1715
1733
|
let buffer = buffer_name_for_text(text);
|
|
@@ -1779,6 +1797,7 @@ impl Transport for TmuxBackend {
|
|
|
1779
1797
|
// check, KeySentAfterVisibleToken verification.
|
|
1780
1798
|
if !matches!(submit, Key::Enter) {
|
|
1781
1799
|
self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
|
|
1800
|
+
notify_submit_observer(observer);
|
|
1782
1801
|
let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
|
|
1783
1802
|
return Ok(InjectReport {
|
|
1784
1803
|
stage_reached: InjectStage::Submit,
|
|
@@ -1808,6 +1827,7 @@ impl Transport for TmuxBackend {
|
|
|
1808
1827
|
let poll_consumption = !payload.skip_consumption_poll();
|
|
1809
1828
|
if !poll_consumption {
|
|
1810
1829
|
if self.run_inject_stage(&submit_argv, InjectStage::Submit).is_ok() {
|
|
1830
|
+
notify_submit_observer(observer);
|
|
1811
1831
|
consumption_attempts = 1;
|
|
1812
1832
|
}
|
|
1813
1833
|
}
|
|
@@ -1858,6 +1878,7 @@ impl Transport for TmuxBackend {
|
|
|
1858
1878
|
consumed = None;
|
|
1859
1879
|
break;
|
|
1860
1880
|
}
|
|
1881
|
+
notify_submit_observer(observer);
|
|
1861
1882
|
consumption_attempts = attempt + 1;
|
|
1862
1883
|
|
|
1863
1884
|
// Post-submit token readback (U1 #7 parity: check token
|
|
@@ -640,6 +640,21 @@ pub trait Transport: Send + Sync {
|
|
|
640
640
|
bracketed: bool,
|
|
641
641
|
) -> Result<InjectReport, TransportError>;
|
|
642
642
|
|
|
643
|
+
fn inject_with_submit_observer(
|
|
644
|
+
&self,
|
|
645
|
+
target: &Target,
|
|
646
|
+
payload: &InjectPayload,
|
|
647
|
+
submit: Key,
|
|
648
|
+
bracketed: bool,
|
|
649
|
+
observer: Option<&dyn SubmitObserver>,
|
|
650
|
+
) -> Result<InjectReport, TransportError> {
|
|
651
|
+
let report = self.inject(target, payload, submit, bracketed)?;
|
|
652
|
+
if let Some(observer) = observer {
|
|
653
|
+
observer.after_physical_submit();
|
|
654
|
+
}
|
|
655
|
+
Ok(report)
|
|
656
|
+
}
|
|
657
|
+
|
|
643
658
|
fn send_keys(&self, target: &Target, keys: &[Key]) -> Result<(), TransportError>;
|
|
644
659
|
|
|
645
660
|
fn capture(
|
|
@@ -718,6 +733,10 @@ pub trait Transport: Send + Sync {
|
|
|
718
733
|
) -> Result<AttachOutcome, TransportError>;
|
|
719
734
|
}
|
|
720
735
|
|
|
736
|
+
pub trait SubmitObserver {
|
|
737
|
+
fn after_physical_submit(&self);
|
|
738
|
+
}
|
|
739
|
+
|
|
721
740
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
722
741
|
// 命令构造 seam(STEP-9 头号目标:exact tmux command construction).
|
|
723
742
|
//
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.17",
|
|
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.17",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.17",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.17"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|