@team-agent/installer 0.5.44 → 0.5.46
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/emit.rs +44 -33
- package/crates/team-agent/src/cli/mod.rs +6 -0
- package/crates/team-agent/src/cli/named_address.rs +206 -4
- package/crates/team-agent/src/cli/send.rs +105 -2
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port.rs +70 -2
- package/crates/team-agent/src/cli/tests/run_delegation.rs +7 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +14 -22
- package/crates/team-agent/src/lifecycle/restart/agent.rs +103 -27
- package/crates/team-agent/src/lifecycle/restart/common.rs +136 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +168 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -7
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +3 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +10 -0
- package/crates/team-agent/src/mcp_server/tools.rs +67 -8
- package/crates/team-agent/src/model/mod.rs +6 -0
- package/crates/team-agent/src/model/name_similarity.rs +266 -0
- package/crates/team-agent/src/provider/session/capture.rs +113 -22
- package/crates/team-agent/src/provider/session_scan/codex.rs +51 -4
- package/crates/team-agent/src/tmux_backend/tests.rs +116 -10
- package/crates/team-agent/src/tmux_backend.rs +5 -16
- package/crates/team-agent/src/transport/tests/wire.rs +6 -0
- package/crates/team-agent/src/transport.rs +8 -2
- package/package.json +4 -4
|
@@ -8,6 +8,7 @@ use crate::provider::{
|
|
|
8
8
|
CaptureSessionContext, CapturedSession, CapturedSessionCandidate, Provider, ProviderAdapter,
|
|
9
9
|
ProviderError, SessionId,
|
|
10
10
|
};
|
|
11
|
+
use crate::state::identity_keys::SessionAttributionKey;
|
|
11
12
|
|
|
12
13
|
pub const SESSION_CAPTURE_CONVERGENCE_DEADLINE_MS: u64 = 12_000;
|
|
13
14
|
pub const SESSION_CAPTURE_CONVERGENCE_POLL_MS: u64 = 250;
|
|
@@ -638,6 +639,7 @@ pub fn incomplete_interacted_resumable_agent_ids(state: &Value) -> Vec<String> {
|
|
|
638
639
|
struct PendingSessionCapture {
|
|
639
640
|
agent_id: String,
|
|
640
641
|
provider: Provider,
|
|
642
|
+
team_key: String,
|
|
641
643
|
context: CaptureSessionContext,
|
|
642
644
|
}
|
|
643
645
|
|
|
@@ -673,6 +675,13 @@ where
|
|
|
673
675
|
Some(PendingSessionCapture {
|
|
674
676
|
agent_id: agent_id.to_string(),
|
|
675
677
|
provider,
|
|
678
|
+
team_key: agent
|
|
679
|
+
.get("owner_team_id")
|
|
680
|
+
.or_else(|| agent.get("team_key"))
|
|
681
|
+
.and_then(Value::as_str)
|
|
682
|
+
.filter(|s| !s.is_empty())
|
|
683
|
+
.unwrap_or("current")
|
|
684
|
+
.to_string(),
|
|
676
685
|
context: CaptureSessionContext {
|
|
677
686
|
agent_id: agent_id.to_string(),
|
|
678
687
|
spawn_cwd: PathBuf::from(spawn_cwd),
|
|
@@ -883,14 +892,14 @@ fn allocate_session_candidates(
|
|
|
883
892
|
.as_ref()
|
|
884
893
|
.is_some_and(|sid| sid.as_str() == expected.as_str())
|
|
885
894
|
})
|
|
886
|
-
.filter(|candidate| !candidate_keys_collide(candidate, claimed))
|
|
895
|
+
.filter(|candidate| !candidate_keys_collide(item, candidate, claimed))
|
|
887
896
|
.collect();
|
|
888
897
|
// Uniqueness requirement: only assign when the expected id maps to
|
|
889
898
|
// exactly one available candidate. Multiple matches or a colliding
|
|
890
899
|
// single match leave the agent for the ambiguity path below.
|
|
891
900
|
if exact_matches.len() == 1 {
|
|
892
901
|
let candidate = exact_matches[0].clone();
|
|
893
|
-
claimed.extend(captured_provider_session_keys(&candidate.captured));
|
|
902
|
+
claimed.extend(captured_provider_session_keys(item, &candidate.captured));
|
|
894
903
|
assignments.insert(item.agent_id.clone(), candidate);
|
|
895
904
|
}
|
|
896
905
|
}
|
|
@@ -903,8 +912,9 @@ fn allocate_session_candidates(
|
|
|
903
912
|
candidates_by_agent.get(&item.agent_id),
|
|
904
913
|
claimed,
|
|
905
914
|
CandidateMatchKind::PositiveAgentId,
|
|
915
|
+
item,
|
|
906
916
|
) {
|
|
907
|
-
claimed.extend(captured_provider_session_keys(&candidate.captured));
|
|
917
|
+
claimed.extend(captured_provider_session_keys(item, &candidate.captured));
|
|
908
918
|
assignments.insert(item.agent_id.clone(), candidate);
|
|
909
919
|
}
|
|
910
920
|
}
|
|
@@ -916,8 +926,9 @@ fn allocate_session_candidates(
|
|
|
916
926
|
candidates_by_agent.get(&item.agent_id),
|
|
917
927
|
claimed,
|
|
918
928
|
CandidateMatchKind::PathAgentId,
|
|
929
|
+
item,
|
|
919
930
|
) {
|
|
920
|
-
claimed.extend(captured_provider_session_keys(&candidate.captured));
|
|
931
|
+
claimed.extend(captured_provider_session_keys(item, &candidate.captured));
|
|
921
932
|
assignments.insert(item.agent_id.clone(), candidate);
|
|
922
933
|
}
|
|
923
934
|
}
|
|
@@ -953,9 +964,10 @@ fn allocate_session_candidates(
|
|
|
953
964
|
candidates_by_agent.get(&item.agent_id),
|
|
954
965
|
claimed,
|
|
955
966
|
CandidateMatchKind::Any,
|
|
967
|
+
item,
|
|
956
968
|
) {
|
|
957
969
|
Some(candidate) => {
|
|
958
|
-
claimed.extend(captured_provider_session_keys(&candidate.captured));
|
|
970
|
+
claimed.extend(captured_provider_session_keys(item, &candidate.captured));
|
|
959
971
|
assignments.insert(item.agent_id.clone(), candidate);
|
|
960
972
|
}
|
|
961
973
|
None => {
|
|
@@ -995,11 +1007,14 @@ fn allocate_global_one_to_one(
|
|
|
995
1007
|
let Some(agent_candidates) = candidates_by_agent.get(agent_id) else {
|
|
996
1008
|
return;
|
|
997
1009
|
};
|
|
1010
|
+
let Some(owner) = pending.iter().find(|item| item.agent_id == *agent_id) else {
|
|
1011
|
+
continue;
|
|
1012
|
+
};
|
|
998
1013
|
for candidate in agent_candidates {
|
|
999
|
-
if candidate_keys_collide(candidate, claimed) {
|
|
1014
|
+
if candidate_keys_collide(owner, candidate, claimed) {
|
|
1000
1015
|
continue;
|
|
1001
1016
|
}
|
|
1002
|
-
let key = candidate_key(candidate);
|
|
1017
|
+
let key = candidate_key(owner, candidate);
|
|
1003
1018
|
if key.is_empty() {
|
|
1004
1019
|
continue;
|
|
1005
1020
|
}
|
|
@@ -1010,7 +1025,9 @@ fn allocate_global_one_to_one(
|
|
|
1010
1025
|
return;
|
|
1011
1026
|
}
|
|
1012
1027
|
for (agent_id, candidate) in remaining_agents.into_iter().zip(candidates.into_values()) {
|
|
1013
|
-
|
|
1028
|
+
if let Some(item) = pending.iter().find(|item| item.agent_id == agent_id) {
|
|
1029
|
+
claimed.extend(captured_provider_session_keys(item, &candidate.captured));
|
|
1030
|
+
}
|
|
1014
1031
|
assignments.insert(agent_id, candidate);
|
|
1015
1032
|
}
|
|
1016
1033
|
}
|
|
@@ -1035,6 +1052,7 @@ fn unique_available_candidate(
|
|
|
1035
1052
|
candidates: Option<&Vec<CapturedSessionCandidate>>,
|
|
1036
1053
|
claimed: &BTreeSet<String>,
|
|
1037
1054
|
match_kind: CandidateMatchKind,
|
|
1055
|
+
owner: &PendingSessionCapture,
|
|
1038
1056
|
) -> Option<CapturedSessionCandidate> {
|
|
1039
1057
|
let matches = candidates?
|
|
1040
1058
|
.iter()
|
|
@@ -1043,7 +1061,7 @@ fn unique_available_candidate(
|
|
|
1043
1061
|
CandidateMatchKind::PathAgentId => candidate.agent_path_match,
|
|
1044
1062
|
CandidateMatchKind::Any => true,
|
|
1045
1063
|
})
|
|
1046
|
-
.filter(|candidate| !candidate_keys_collide(candidate, claimed))
|
|
1064
|
+
.filter(|candidate| !candidate_keys_collide(owner, candidate, claimed))
|
|
1047
1065
|
.cloned()
|
|
1048
1066
|
.collect::<Vec<_>>();
|
|
1049
1067
|
if matches.len() == 1 {
|
|
@@ -1061,16 +1079,17 @@ enum CandidateMatchKind {
|
|
|
1061
1079
|
}
|
|
1062
1080
|
|
|
1063
1081
|
fn candidate_keys_collide(
|
|
1082
|
+
owner: &PendingSessionCapture,
|
|
1064
1083
|
candidate: &CapturedSessionCandidate,
|
|
1065
1084
|
claimed: &BTreeSet<String>,
|
|
1066
1085
|
) -> bool {
|
|
1067
|
-
captured_provider_session_keys(&candidate.captured)
|
|
1086
|
+
captured_provider_session_keys(owner, &candidate.captured)
|
|
1068
1087
|
.iter()
|
|
1069
1088
|
.any(|key| claimed.contains(key))
|
|
1070
1089
|
}
|
|
1071
1090
|
|
|
1072
|
-
fn candidate_key(candidate: &CapturedSessionCandidate) -> String {
|
|
1073
|
-
captured_provider_session_keys(&candidate.captured)
|
|
1091
|
+
fn candidate_key(owner: &PendingSessionCapture, candidate: &CapturedSessionCandidate) -> String {
|
|
1092
|
+
captured_provider_session_keys(owner, &candidate.captured)
|
|
1074
1093
|
.into_iter()
|
|
1075
1094
|
.collect::<Vec<_>>()
|
|
1076
1095
|
.join("|")
|
|
@@ -1130,12 +1149,13 @@ fn claimed_provider_session_keys(
|
|
|
1130
1149
|
pending_ids: &BTreeSet<String>,
|
|
1131
1150
|
) -> BTreeSet<String> {
|
|
1132
1151
|
let mut keys = BTreeSet::new();
|
|
1152
|
+
let team_key = crate::state::projection::team_state_key(state);
|
|
1133
1153
|
// 1. Non-pending worker sessions (existing behaviour).
|
|
1134
1154
|
for (agent_id, agent) in agents {
|
|
1135
1155
|
if pending_ids.contains(agent_id) {
|
|
1136
1156
|
continue;
|
|
1137
1157
|
}
|
|
1138
|
-
push_provider_session_keys(&mut keys, agent);
|
|
1158
|
+
push_provider_session_keys(&mut keys, &team_key, agent_id, agent);
|
|
1139
1159
|
}
|
|
1140
1160
|
// 2. P0 (lane-046-capture-gap): leader anchor sessions. The leader's
|
|
1141
1161
|
// own provider transcript must never be attributed to a worker. Scan
|
|
@@ -1152,14 +1172,39 @@ fn claimed_provider_session_keys(
|
|
|
1152
1172
|
keys
|
|
1153
1173
|
}
|
|
1154
1174
|
|
|
1155
|
-
fn push_provider_session_keys(
|
|
1175
|
+
fn push_provider_session_keys(
|
|
1176
|
+
keys: &mut BTreeSet<String>,
|
|
1177
|
+
fallback_team_key: &str,
|
|
1178
|
+
fallback_agent_id: &str,
|
|
1179
|
+
value: &Value,
|
|
1180
|
+
) {
|
|
1181
|
+
let provider = value
|
|
1182
|
+
.get("provider")
|
|
1183
|
+
.and_then(Value::as_str)
|
|
1184
|
+
.and_then(parse_provider)
|
|
1185
|
+
.unwrap_or(Provider::Fake);
|
|
1186
|
+
let team_key = value
|
|
1187
|
+
.get("owner_team_id")
|
|
1188
|
+
.or_else(|| value.get("team_key"))
|
|
1189
|
+
.and_then(Value::as_str)
|
|
1190
|
+
.filter(|s| !s.is_empty())
|
|
1191
|
+
.unwrap_or(fallback_team_key);
|
|
1192
|
+
let agent_id = value
|
|
1193
|
+
.get("agent_id")
|
|
1194
|
+
.and_then(Value::as_str)
|
|
1195
|
+
.filter(|s| !s.is_empty())
|
|
1196
|
+
.unwrap_or(fallback_agent_id);
|
|
1156
1197
|
for field in ["session_id", "provider_session_id"] {
|
|
1157
1198
|
if let Some(session_id) = value
|
|
1158
1199
|
.get(field)
|
|
1159
1200
|
.and_then(Value::as_str)
|
|
1160
1201
|
.filter(|s| !s.is_empty())
|
|
1161
1202
|
{
|
|
1162
|
-
|
|
1203
|
+
if let Some(key) = SessionAttributionKey::new(provider, team_key, agent_id, session_id)
|
|
1204
|
+
{
|
|
1205
|
+
keys.insert(session_attribution_key_string(&key));
|
|
1206
|
+
}
|
|
1207
|
+
keys.insert(global_session_attribution_key_string(session_id));
|
|
1163
1208
|
}
|
|
1164
1209
|
}
|
|
1165
1210
|
for field in ["rollout_path", "transcript_path"] {
|
|
@@ -1168,7 +1213,10 @@ fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
|
|
|
1168
1213
|
.and_then(Value::as_str)
|
|
1169
1214
|
.filter(|s| !s.is_empty())
|
|
1170
1215
|
{
|
|
1171
|
-
keys.insert(
|
|
1216
|
+
keys.insert(transcript_attribution_key_string(
|
|
1217
|
+
provider, team_key, agent_id, path,
|
|
1218
|
+
));
|
|
1219
|
+
keys.insert(global_transcript_attribution_key_string(path));
|
|
1172
1220
|
}
|
|
1173
1221
|
}
|
|
1174
1222
|
}
|
|
@@ -1176,25 +1224,68 @@ fn push_provider_session_keys(keys: &mut BTreeSet<String>, value: &Value) {
|
|
|
1176
1224
|
fn push_leader_provider_session_keys(keys: &mut BTreeSet<String>, scope: &Value) {
|
|
1177
1225
|
for anchor in ["leader_receiver", "team_owner"] {
|
|
1178
1226
|
if let Some(node) = scope.get(anchor) {
|
|
1179
|
-
push_provider_session_keys(keys, node);
|
|
1227
|
+
push_provider_session_keys(keys, "leader", "leader", node);
|
|
1180
1228
|
}
|
|
1181
1229
|
}
|
|
1182
1230
|
}
|
|
1183
1231
|
|
|
1184
|
-
fn captured_provider_session_keys(
|
|
1232
|
+
fn captured_provider_session_keys(
|
|
1233
|
+
owner: &PendingSessionCapture,
|
|
1234
|
+
captured: &CapturedSession,
|
|
1235
|
+
) -> BTreeSet<String> {
|
|
1185
1236
|
let mut keys = BTreeSet::new();
|
|
1237
|
+
let team_key = owner.team_key.as_str();
|
|
1186
1238
|
if let Some(session_id) = &captured.session_id {
|
|
1187
|
-
|
|
1239
|
+
if let Some(key) = SessionAttributionKey::new(
|
|
1240
|
+
owner.provider,
|
|
1241
|
+
team_key,
|
|
1242
|
+
owner.agent_id.as_str(),
|
|
1243
|
+
session_id.as_str(),
|
|
1244
|
+
) {
|
|
1245
|
+
keys.insert(session_attribution_key_string(&key));
|
|
1246
|
+
}
|
|
1247
|
+
keys.insert(global_session_attribution_key_string(session_id.as_str()));
|
|
1188
1248
|
}
|
|
1189
1249
|
if let Some(rollout_path) = &captured.rollout_path {
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1250
|
+
let path = rollout_path.as_path().to_string_lossy();
|
|
1251
|
+
keys.insert(transcript_attribution_key_string(
|
|
1252
|
+
owner.provider,
|
|
1253
|
+
team_key,
|
|
1254
|
+
owner.agent_id.as_str(),
|
|
1255
|
+
&path,
|
|
1193
1256
|
));
|
|
1257
|
+
keys.insert(global_transcript_attribution_key_string(&path));
|
|
1194
1258
|
}
|
|
1195
1259
|
keys
|
|
1196
1260
|
}
|
|
1197
1261
|
|
|
1262
|
+
fn session_attribution_key_string(key: &SessionAttributionKey) -> String {
|
|
1263
|
+
format!(
|
|
1264
|
+
"SessionAttributionKey(provider={:?},team={},agent={},session={})",
|
|
1265
|
+
key.provider(),
|
|
1266
|
+
key.team_key(),
|
|
1267
|
+
key.agent_id(),
|
|
1268
|
+
key.session_id()
|
|
1269
|
+
)
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
fn transcript_attribution_key_string(
|
|
1273
|
+
provider: Provider,
|
|
1274
|
+
team_key: &str,
|
|
1275
|
+
agent_id: &str,
|
|
1276
|
+
path: &str,
|
|
1277
|
+
) -> String {
|
|
1278
|
+
format!("TranscriptAttributionKey(provider={provider:?},team={team_key},agent={agent_id},path={path})")
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
fn global_session_attribution_key_string(session_id: &str) -> String {
|
|
1282
|
+
format!("GlobalSessionAttributionKey(session={session_id})")
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
fn global_transcript_attribution_key_string(path: &str) -> String {
|
|
1286
|
+
format!("GlobalTranscriptAttributionKey(path={path})")
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1198
1289
|
#[cfg(test)]
|
|
1199
1290
|
pub(crate) mod test_support {
|
|
1200
1291
|
use super::*;
|
|
@@ -14,10 +14,13 @@ pub(super) fn apply_spawned_at_filter(
|
|
|
14
14
|
Some(p) => p.as_path(),
|
|
15
15
|
None => return false,
|
|
16
16
|
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
match codex_rollout_created_at(path) {
|
|
18
|
+
Some(created_at) => created_at >= cutoff,
|
|
19
|
+
None => std::fs::metadata(path)
|
|
20
|
+
.and_then(|meta| meta.modified())
|
|
21
|
+
.map(|mtime| mtime >= cutoff)
|
|
22
|
+
.unwrap_or(false),
|
|
23
|
+
}
|
|
21
24
|
});
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -27,6 +30,50 @@ pub(super) fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
|
|
|
27
30
|
.map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
fn codex_rollout_created_at(path: &std::path::Path) -> Option<std::time::SystemTime> {
|
|
34
|
+
created_at_from_rollout_head(path).or_else(|| created_at_from_rollout_filename(path))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn created_at_from_rollout_head(path: &std::path::Path) -> Option<std::time::SystemTime> {
|
|
38
|
+
let text = super::common::read_head_text(path, super::common::CAPTURE_HEAD_BYTES).ok()?;
|
|
39
|
+
super::common::parse_session_records(&text)
|
|
40
|
+
.iter()
|
|
41
|
+
.find_map(record_created_at)
|
|
42
|
+
.and_then(|raw| parse_spawned_at(&raw))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn record_created_at(record: &serde_json::Value) -> Option<String> {
|
|
46
|
+
record
|
|
47
|
+
.get("created_at")
|
|
48
|
+
.and_then(serde_json::Value::as_str)
|
|
49
|
+
.or_else(|| {
|
|
50
|
+
record
|
|
51
|
+
.get("session_meta")
|
|
52
|
+
.and_then(|v| v.get("payload"))
|
|
53
|
+
.or_else(|| record.get("payload"))
|
|
54
|
+
.and_then(|v| v.get("created_at"))
|
|
55
|
+
.and_then(serde_json::Value::as_str)
|
|
56
|
+
})
|
|
57
|
+
.map(ToString::to_string)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fn created_at_from_rollout_filename(path: &std::path::Path) -> Option<std::time::SystemTime> {
|
|
61
|
+
let name = path.file_name()?.to_str()?;
|
|
62
|
+
let start = name.find("rollout-")? + "rollout-".len();
|
|
63
|
+
let stamp = name.get(start..start + 19)?;
|
|
64
|
+
if stamp.as_bytes().get(10).copied() != Some(b'T') {
|
|
65
|
+
return None;
|
|
66
|
+
}
|
|
67
|
+
let raw = format!(
|
|
68
|
+
"{}T{}:{}:{}+00:00",
|
|
69
|
+
&stamp[0..10],
|
|
70
|
+
&stamp[11..13],
|
|
71
|
+
&stamp[14..16],
|
|
72
|
+
&stamp[17..19]
|
|
73
|
+
);
|
|
74
|
+
parse_spawned_at(&raw)
|
|
75
|
+
}
|
|
76
|
+
|
|
30
77
|
#[cfg(test)]
|
|
31
78
|
mod tests {
|
|
32
79
|
use super::*;
|
|
@@ -39,6 +39,36 @@ struct MockCommandRunner {
|
|
|
39
39
|
default: MockResp,
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/// Models the real build-before-destroy overlap: the spawn command creates `%new`, while a
|
|
43
|
+
/// name-based lookup still resolves the same-named old window `%old`.
|
|
44
|
+
struct SameNameSpawnRunner {
|
|
45
|
+
recorded: RecordedArgv,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
impl CommandRunner for SameNameSpawnRunner {
|
|
49
|
+
fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
|
|
50
|
+
self.recorded.lock().unwrap().push(argv.to_vec());
|
|
51
|
+
let output = match argv.get(1).map(String::as_str) {
|
|
52
|
+
Some("new-window") => ok("%new\n"),
|
|
53
|
+
Some("display-message") => ok("%old\n"),
|
|
54
|
+
Some("list-panes") => ok(
|
|
55
|
+
"%old\tteamsess\t0\tdeveloper\t0\t/dev/ttys001\tnode\t1\t/work/dir\t1\t0\t101\n\
|
|
56
|
+
%new\tteamsess\t1\tdeveloper\t0\t/dev/ttys002\tnode\t1\t/work/dir\t1\t0\t202\n",
|
|
57
|
+
),
|
|
58
|
+
_ => fail(64, "unexpected scripted tmux command"),
|
|
59
|
+
};
|
|
60
|
+
Ok(output)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fn run_with_stdin(
|
|
64
|
+
&self,
|
|
65
|
+
argv: &[String],
|
|
66
|
+
_stdin: &str,
|
|
67
|
+
) -> Result<CommandOutput, std::io::Error> {
|
|
68
|
+
self.run(argv)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
42
72
|
impl CommandRunner for MockCommandRunner {
|
|
43
73
|
fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
|
|
44
74
|
self.recorded.lock().unwrap().push(argv.to_vec());
|
|
@@ -366,8 +396,7 @@ fn spawn_first_frames_via_new_session_builder_and_parses_pane_id() {
|
|
|
366
396
|
let (be, rec) = backend_with(
|
|
367
397
|
MockResp::Out(ok("")),
|
|
368
398
|
vec![
|
|
369
|
-
MockResp::Out(ok("")),
|
|
370
|
-
MockResp::Out(ok("%3")),
|
|
399
|
+
MockResp::Out(ok("%3\n")),
|
|
371
400
|
MockResp::Out(ok(pane_inventory)),
|
|
372
401
|
],
|
|
373
402
|
);
|
|
@@ -383,7 +412,8 @@ fn spawn_first_frames_via_new_session_builder_and_parses_pane_id() {
|
|
|
383
412
|
&env,
|
|
384
413
|
)
|
|
385
414
|
.expect("spawn_first");
|
|
386
|
-
let
|
|
415
|
+
let calls = rec.lock().unwrap().clone();
|
|
416
|
+
let argv = calls[0].clone();
|
|
387
417
|
let cmd = argv.last().expect("the sh -lc command string").clone();
|
|
388
418
|
assert_eq!(
|
|
389
419
|
argv,
|
|
@@ -400,6 +430,12 @@ fn spawn_first_frames_via_new_session_builder_and_parses_pane_id() {
|
|
|
400
430
|
"SpawnResult.pane_id must parse from the tmux output"
|
|
401
431
|
);
|
|
402
432
|
assert_eq!(result.child_pid, Some(123));
|
|
433
|
+
assert!(
|
|
434
|
+
calls
|
|
435
|
+
.iter()
|
|
436
|
+
.all(|call| call.get(1).is_none_or(|arg| arg != "display-message")),
|
|
437
|
+
"spawn_first identity must come from new-session stdout, never a display-message lookup; calls={calls:?}"
|
|
438
|
+
);
|
|
403
439
|
}
|
|
404
440
|
|
|
405
441
|
#[test]
|
|
@@ -408,8 +444,7 @@ fn spawn_into_frames_via_new_window_builder() {
|
|
|
408
444
|
let (be, rec) = backend_with(
|
|
409
445
|
MockResp::Out(ok("")),
|
|
410
446
|
vec![
|
|
411
|
-
MockResp::Out(ok("")),
|
|
412
|
-
MockResp::Out(ok("%4")),
|
|
447
|
+
MockResp::Out(ok("%4\n")),
|
|
413
448
|
MockResp::Out(ok(pane_inventory)),
|
|
414
449
|
],
|
|
415
450
|
);
|
|
@@ -424,7 +459,8 @@ fn spawn_into_frames_via_new_window_builder() {
|
|
|
424
459
|
&BTreeMap::new(),
|
|
425
460
|
)
|
|
426
461
|
.expect("spawn_into");
|
|
427
|
-
let
|
|
462
|
+
let calls = rec.lock().unwrap().clone();
|
|
463
|
+
let argv = calls[0].clone();
|
|
428
464
|
let cmd = argv.last().expect("the sh -lc command string").clone();
|
|
429
465
|
assert_eq!(
|
|
430
466
|
argv,
|
|
@@ -432,16 +468,58 @@ fn spawn_into_frames_via_new_window_builder() {
|
|
|
432
468
|
"spawn_into must frame via tmux_spawn_argv first=false (new-window -t <s> -n <w> sh -lc <cmd>)"
|
|
433
469
|
);
|
|
434
470
|
assert_eq!(result.pane_id.as_str(), "%4");
|
|
471
|
+
assert!(
|
|
472
|
+
calls
|
|
473
|
+
.iter()
|
|
474
|
+
.all(|call| call.get(1).is_none_or(|arg| arg != "display-message")),
|
|
475
|
+
"spawn_into identity must come from new-window stdout, never a display-message lookup; calls={calls:?}"
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#[test]
|
|
480
|
+
fn spawn_into_same_named_replacement_returns_identity_created_by_spawn_command() {
|
|
481
|
+
let recorded = Arc::new(Mutex::new(Vec::new()));
|
|
482
|
+
let be = TmuxBackend::with_runner(Box::new(SameNameSpawnRunner {
|
|
483
|
+
recorded: Arc::clone(&recorded),
|
|
484
|
+
}));
|
|
485
|
+
|
|
486
|
+
let result = be
|
|
487
|
+
.spawn_into(
|
|
488
|
+
&SessionName::new("teamsess"),
|
|
489
|
+
&WindowName::new("developer"),
|
|
490
|
+
&svec(&["provider-bin"]),
|
|
491
|
+
Path::new("/work/dir"),
|
|
492
|
+
&BTreeMap::new(),
|
|
493
|
+
)
|
|
494
|
+
.expect("same-name replacement spawn");
|
|
495
|
+
let calls = recorded.lock().unwrap().clone();
|
|
496
|
+
let name_based_identity_lookup = calls.iter().any(|call| {
|
|
497
|
+
call.get(1).is_some_and(|arg| arg == "display-message")
|
|
498
|
+
&& call
|
|
499
|
+
.iter()
|
|
500
|
+
.position(|arg| arg == "-t")
|
|
501
|
+
.and_then(|index| call.get(index + 1))
|
|
502
|
+
.is_some_and(|target| target == "teamsess:developer")
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
assert_eq!(
|
|
506
|
+
result.pane_id.as_str(),
|
|
507
|
+
"%new",
|
|
508
|
+
"spawn_into must return the pane identity atomically emitted by this new-window command, not the same-named old window; calls={calls:?}"
|
|
509
|
+
);
|
|
510
|
+
assert!(
|
|
511
|
+
!name_based_identity_lookup,
|
|
512
|
+
"spawn identity must not be re-derived through ambiguous session:window display-message; calls={calls:?}"
|
|
513
|
+
);
|
|
435
514
|
}
|
|
436
515
|
|
|
437
516
|
#[test]
|
|
438
|
-
fn
|
|
517
|
+
fn spawn_with_command_refuses_spawn_pane_owned_by_other_window() {
|
|
439
518
|
let pane_inventory = "%5\tteamsess\t1\tw2\t0\t/dev/ttys005\tnode\t1\t/work/dir\t1\t0\t125\n";
|
|
440
519
|
let (be, _rec) = backend_with(
|
|
441
520
|
MockResp::Out(ok("")),
|
|
442
521
|
vec![
|
|
443
|
-
MockResp::Out(ok("")),
|
|
444
|
-
MockResp::Out(ok("%5")),
|
|
522
|
+
MockResp::Out(ok("%5\n")),
|
|
445
523
|
MockResp::Out(ok(pane_inventory)),
|
|
446
524
|
],
|
|
447
525
|
);
|
|
@@ -453,7 +531,7 @@ fn spawn_with_command_refuses_display_message_pane_owned_by_other_window() {
|
|
|
453
531
|
Path::new("/work/dir"),
|
|
454
532
|
&BTreeMap::new(),
|
|
455
533
|
)
|
|
456
|
-
.expect_err("
|
|
534
|
+
.expect_err("spawn stdout pane owned by w2 must fail closed");
|
|
457
535
|
let msg = err.to_string();
|
|
458
536
|
assert!(
|
|
459
537
|
msg.contains("requested=teamsess:w1")
|
|
@@ -463,6 +541,34 @@ fn spawn_with_command_refuses_display_message_pane_owned_by_other_window() {
|
|
|
463
541
|
);
|
|
464
542
|
}
|
|
465
543
|
|
|
544
|
+
#[test]
|
|
545
|
+
fn spawn_with_command_empty_stdout_fails_closed_without_identity_fallback() {
|
|
546
|
+
let (be, rec) = backend_with(MockResp::Out(ok("")), vec![MockResp::Out(ok(""))]);
|
|
547
|
+
let err = be
|
|
548
|
+
.spawn_into(
|
|
549
|
+
&SessionName::new("teamsess"),
|
|
550
|
+
&WindowName::new("developer"),
|
|
551
|
+
&svec(&["provider-bin"]),
|
|
552
|
+
Path::new("/work/dir"),
|
|
553
|
+
&BTreeMap::new(),
|
|
554
|
+
)
|
|
555
|
+
.expect_err("empty new-window stdout must fail closed");
|
|
556
|
+
let msg = err.to_string().to_ascii_lowercase();
|
|
557
|
+
let calls = rec.lock().unwrap().clone();
|
|
558
|
+
let used_identity_fallback = calls
|
|
559
|
+
.iter()
|
|
560
|
+
.any(|call| call.get(1).is_some_and(|arg| arg == "display-message"));
|
|
561
|
+
|
|
562
|
+
assert!(
|
|
563
|
+
msg.contains("spawn") && (msg.contains("empty") || msg.contains("no pane id")),
|
|
564
|
+
"empty spawn stdout error must name the missing spawn identity; error={err}; calls={calls:?}"
|
|
565
|
+
);
|
|
566
|
+
assert!(
|
|
567
|
+
!used_identity_fallback,
|
|
568
|
+
"empty spawn stdout must not fall back to ambiguous display-message identity lookup; calls={calls:?}"
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
466
572
|
#[test]
|
|
467
573
|
fn spawn_split_selects_even_horizontal_not_tiled() {
|
|
468
574
|
let (be, rec) = backend_with(
|
|
@@ -765,25 +765,14 @@ impl TmuxBackend {
|
|
|
765
765
|
first: bool,
|
|
766
766
|
) -> Result<SpawnResult, TransportError> {
|
|
767
767
|
let spawn_argv = tmux_spawn_argv(session, window, command, first);
|
|
768
|
-
self.run_spawn(&spawn_argv)?;
|
|
769
|
-
let pane_argv = vec![
|
|
770
|
-
"tmux".to_string(),
|
|
771
|
-
"display-message".to_string(),
|
|
772
|
-
"-p".to_string(),
|
|
773
|
-
"-t".to_string(),
|
|
774
|
-
format!("{}:{}", session.as_str(), window.as_str()),
|
|
775
|
-
"#{pane_id}".to_string(),
|
|
776
|
-
];
|
|
777
|
-
let output = self.run_spawn(&pane_argv)?;
|
|
768
|
+
let output = self.run_spawn(&spawn_argv)?;
|
|
778
769
|
let pane = output.stdout.trim();
|
|
779
|
-
// T3-5 (harvest §1): never fabricate a `%0` pane id on an empty reply — a fake
|
|
780
|
-
// pane id mis-addresses every later inject/capture/kill. Surface the miss.
|
|
781
770
|
if pane.is_empty() {
|
|
782
771
|
return Err(TransportError::Subprocess {
|
|
783
|
-
argv:
|
|
772
|
+
argv: spawn_argv,
|
|
784
773
|
code: output.code,
|
|
785
774
|
stderr: format!(
|
|
786
|
-
"tmux
|
|
775
|
+
"tmux spawn returned no pane id for {}:{}",
|
|
787
776
|
session.as_str(),
|
|
788
777
|
window.as_str()
|
|
789
778
|
),
|
|
@@ -822,10 +811,10 @@ impl TmuxBackend {
|
|
|
822
811
|
})
|
|
823
812
|
.unwrap_or_else(|| "<missing-from-list-targets>".to_string());
|
|
824
813
|
Err(TransportError::Subprocess {
|
|
825
|
-
argv:
|
|
814
|
+
argv: spawn_argv,
|
|
826
815
|
code: output.code,
|
|
827
816
|
stderr: format!(
|
|
828
|
-
"tmux spawn pane
|
|
817
|
+
"tmux spawn pane identity mismatch: requested={}:{} observed_pane={} observed={}",
|
|
829
818
|
session.as_str(),
|
|
830
819
|
window.as_str(),
|
|
831
820
|
pane_id.as_str(),
|
|
@@ -238,6 +238,9 @@ fn spawn_first_returns_stable_addressable_target_then_reachable() {
|
|
|
238
238
|
"tmux",
|
|
239
239
|
"new-session",
|
|
240
240
|
"-d",
|
|
241
|
+
"-P",
|
|
242
|
+
"-F",
|
|
243
|
+
"#{pane_id}",
|
|
241
244
|
"-s",
|
|
242
245
|
"team-sess",
|
|
243
246
|
"-n",
|
|
@@ -287,6 +290,9 @@ fn spawn_into_then_list_targets_enumerates_it() {
|
|
|
287
290
|
"tmux",
|
|
288
291
|
"new-window",
|
|
289
292
|
"-d",
|
|
293
|
+
"-P",
|
|
294
|
+
"-F",
|
|
295
|
+
"#{pane_id}",
|
|
290
296
|
"-t",
|
|
291
297
|
"team-sess",
|
|
292
298
|
"-n",
|
|
@@ -918,8 +918,8 @@ pub fn tmux_query_argv(pane: &PaneId, field: PaneField) -> Vec<String> {
|
|
|
918
918
|
|
|
919
919
|
/// spawn argv:首个 session 用 new-session,后续 worker 用 new-window。
|
|
920
920
|
/// golden(terminal.py:44-45 / runtime.py:1019-1020):
|
|
921
|
-
/// first → `new-session -d -s <s> -n <w> sh -lc <cmd>`
|
|
922
|
-
/// into → `new-window -t <s> -n <w> sh -lc <cmd>`
|
|
921
|
+
/// first → `new-session -d -P -F #{pane_id} -s <s> -n <w> sh -lc <cmd>`
|
|
922
|
+
/// into → `new-window -d -P -F #{pane_id} -t <s> -n <w> sh -lc <cmd>`
|
|
923
923
|
/// `argv` 被组装成单条 `sh -lc` 命令字符串(provider 启动行)。
|
|
924
924
|
pub fn tmux_spawn_argv(
|
|
925
925
|
session: &SessionName,
|
|
@@ -932,6 +932,9 @@ pub fn tmux_spawn_argv(
|
|
|
932
932
|
"tmux".to_string(),
|
|
933
933
|
"new-session".to_string(),
|
|
934
934
|
"-d".to_string(),
|
|
935
|
+
"-P".to_string(),
|
|
936
|
+
"-F".to_string(),
|
|
937
|
+
"#{pane_id}".to_string(),
|
|
935
938
|
"-s".to_string(),
|
|
936
939
|
session.as_str().to_string(),
|
|
937
940
|
"-n".to_string(),
|
|
@@ -952,6 +955,9 @@ pub fn tmux_spawn_argv(
|
|
|
952
955
|
"tmux".to_string(),
|
|
953
956
|
"new-window".to_string(),
|
|
954
957
|
"-d".to_string(),
|
|
958
|
+
"-P".to_string(),
|
|
959
|
+
"-F".to_string(),
|
|
960
|
+
"#{pane_id}".to_string(),
|
|
955
961
|
"-t".to_string(),
|
|
956
962
|
session.as_str().to_string(),
|
|
957
963
|
"-n".to_string(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.46",
|
|
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.46",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.46",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.46"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|