Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 21 additions & 21 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@ aionui-cron = { path = "crates/aionui-cron" }
aionui-assistant = { path = "crates/aionui-assistant" }
aionui-app = { path = "crates/aionui-app" }

aion-agent = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
aion-providers = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
aion-types = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
aion-protocol = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
aion-config = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
aion-mcp = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
aion-agent = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.31" }
aion-providers = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.31" }
aion-types = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.31" }
aion-protocol = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.31" }
aion-config = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.31" }
aion-mcp = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.31" }

# Core framework
tokio = { version = "1", features = ["full"] }
Expand Down
6 changes: 4 additions & 2 deletions crates/aionui-ai-agent/src/factory/aionrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1031,8 +1031,10 @@ mod tests {

#[test]
fn aionrs_guide_prompt_hands_off_after_create_team() {
let mut overrides = AionrsBuildExtra::default();
overrides.system_prompt = Some(team_guide_prompt::build_solo_team_guide_prompt("aionrs"));
let overrides = AionrsBuildExtra {
system_prompt: Some(team_guide_prompt::build_solo_team_guide_prompt("aionrs")),
..Default::default()
};

let prompt = overrides.system_prompt.as_deref().unwrap();
assert!(prompt.contains("aion_create_team"));
Expand Down
2 changes: 1 addition & 1 deletion crates/aionui-ai-agent/src/manager/aionrs/error.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use aion_agent::engine::AgentError as AionrsAgentError;
use aion_agent::error::AgentError as AionrsAgentError;
use aion_providers::ProviderError;
use aionui_api_types::{
AgentErrorCode, AgentErrorOwnership, AgentErrorResolution, AgentErrorResolutionKind, AgentErrorResolutionTarget,
Expand Down
125 changes: 102 additions & 23 deletions crates/aionui-office/src/watch_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::types::DocType;

const POLL_INTERVAL_MS: u64 = 100;
const POLL_MAX_ATTEMPTS: u32 = 150;
const START_PORT_MAX_ATTEMPTS: usize = 3;
const STOP_DELAY_MS: u64 = 500;
const VERSION_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);

Expand Down Expand Up @@ -118,35 +119,58 @@ impl OfficecliWatchManager {
}

async fn try_start(&self, resolved: &str, doc_type: DocType) -> Result<u16, OfficeError> {
let port = allocate_port()?;
for attempt in 1..=START_PORT_MAX_ATTEMPTS {
let port = allocate_port()?;
match self.spawn_officecli_with_install(resolved, port, doc_type).await {
Ok(process) => {
self.poll_port_ready(port, resolved).await?;

let key = session_key(resolved, doc_type);
self.sessions.insert(
key,
WatchSession {
port,
process,
file_path: resolved.to_owned(),
doc_type,
aborted: false,
},
);

return Ok(port);
}
Err(error) if is_port_in_use_start_failure(&error) && attempt < START_PORT_MAX_ATTEMPTS => {
tracing::debug!(
port,
attempt,
max_attempts = START_PORT_MAX_ATTEMPTS,
"allocated preview port was already in use; retrying"
);
}
Err(error) => return Err(error),
}
}

let spawn_result = self.spawner.spawn_officecli(resolved, port, doc_type).await;
Err(OfficeError::StartFailed(
"failed to allocate an available preview port".into(),
))
}

let process = match spawn_result {
Ok(p) => p,
async fn spawn_officecli_with_install(
&self,
resolved: &str,
port: u16,
doc_type: DocType,
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
match self.spawner.spawn_officecli(resolved, port, doc_type).await {
Ok(process) => Ok(process),
Err(OfficeError::OfficecliNotFound) => {
self.broadcast_status(doc_type, PreviewState::Installing, None);
self.spawner.install_officecli().await?;
self.spawner.spawn_officecli(resolved, port, doc_type).await?
self.spawner.spawn_officecli(resolved, port, doc_type).await
}
Err(e) => return Err(e),
};

self.poll_port_ready(port, resolved).await?;

let key = session_key(resolved, doc_type);
self.sessions.insert(
key,
WatchSession {
port,
process,
file_path: resolved.to_owned(),
doc_type,
aborted: false,
},
);

Ok(port)
Err(error) => Err(error),
}
}

async fn poll_port_ready(&self, port: u16, file_path: &str) -> Result<(), OfficeError> {
Expand Down Expand Up @@ -404,6 +428,18 @@ fn session_key(resolved_path: &str, doc_type: DocType) -> String {
format!("{doc_type}:{resolved_path}")
}

fn is_port_in_use_start_failure(error: &OfficeError) -> bool {
matches!(error, OfficeError::StartFailed(message) if is_port_in_use_message(message))
}

fn is_port_in_use_message(message: &str) -> bool {
message.contains("Address already in use")
|| message.contains("Only one usage of each socket address")
|| message.contains("os error 98")
|| message.contains("os error 48")
|| message.contains("os error 10048")
}

fn normalize_officecli_version(raw: &str) -> String {
raw.split_whitespace()
.last()
Expand Down Expand Up @@ -476,6 +512,7 @@ mod tests {
install_count: AtomicU32,
update_count: AtomicU32,
fail_spawn: AtomicBool,
fail_with_address_in_use_once: AtomicBool,
start_listener: AtomicBool,
}

Expand All @@ -487,6 +524,7 @@ mod tests {
install_count: AtomicU32::new(0),
update_count: AtomicU32::new(0),
fail_spawn: AtomicBool::new(false),
fail_with_address_in_use_once: AtomicBool::new(false),
start_listener: AtomicBool::new(true),
}
}
Expand All @@ -506,6 +544,10 @@ mod tests {
return Err(OfficeError::StartFailed("mock spawn failure".into()));
}

if self.fail_with_address_in_use_once.swap(false, Ordering::SeqCst) {
return Err(OfficeError::StartFailed("Address already in use (os error 98)".into()));
}

if !self.installed.load(Ordering::SeqCst) {
return Err(OfficeError::OfficecliNotFound);
}
Expand Down Expand Up @@ -580,6 +622,25 @@ mod tests {
assert_eq!(public_preview_error_message(&err), "officecli install failed");
}

#[test]
fn port_in_use_start_failure_detects_platform_messages() {
for message in [
"Address already in use (os error 98)",
"Address already in use (os error 48)",
"Only one usage of each socket address (protocol/network address/port) is normally permitted. (os error 10048)",
] {
assert!(is_port_in_use_start_failure(&OfficeError::StartFailed(message.into())));
}
}

#[test]
fn port_in_use_start_failure_ignores_other_errors() {
assert!(!is_port_in_use_start_failure(&OfficeError::StartFailed(
"mock spawn failure".into()
)));
assert!(!is_port_in_use_start_failure(&OfficeError::OfficecliNotFound));
}

#[cfg(unix)]
#[tokio::test]
async fn officecli_capability_probe_requires_watch_command() {
Expand Down Expand Up @@ -631,6 +692,24 @@ mod tests {
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 1);
}

#[tokio::test]
async fn start_retries_when_allocated_port_is_taken() {
let spawner = Arc::new(MockSpawner::new());
spawner.fail_with_address_in_use_once.store(true, Ordering::SeqCst);
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = make_manager(Arc::clone(&spawner), Arc::clone(&broadcaster));

let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();

let port = mgr.start(file.to_str().unwrap(), DocType::Word).await.unwrap();

assert!(port > 0);
assert!(mgr.is_active_port(port, DocType::Word));
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 2);
}

#[tokio::test]
async fn start_different_doc_types_independent() {
let spawner = Arc::new(MockSpawner::new());
Expand Down
Loading