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
87 changes: 0 additions & 87 deletions docs/plans/2026-03-18-staging-ci-triage.md

This file was deleted.

63 changes: 54 additions & 9 deletions src/agent/agent_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use std::sync::Arc;

use futures::StreamExt;
use uuid::Uuid;

use crate::agent::context_monitor::ContextMonitor;
use crate::agent::heartbeat::spawn_heartbeat;
Expand Down Expand Up @@ -1010,15 +1011,59 @@ impl Agent {
}
}

// Resolve session and thread
let (session, thread_id) = self
.session_manager
.resolve_thread(
&message.user_id,
&message.channel,
message.conversation_scope(),
)
.await;
// Resolve session and thread. Approval submissions are allowed to
// target an already-loaded owned thread by UUID across channels so the
// web approval UI can approve work that originated from HTTP/other
// owner-scoped channels.
let approval_thread_uuid = if matches!(
submission,
Submission::ExecApproval { .. } | Submission::ApprovalResponse { .. }
) {
message
.conversation_scope()
.and_then(|thread_id| Uuid::parse_str(thread_id).ok())
} else {
None
};

let (session, thread_id) = if let Some(target_thread_id) = approval_thread_uuid {
let session = self
.session_manager
.get_or_create_session(&message.user_id)
.await;
let mut sess = session.lock().await;
if sess.threads.contains_key(&target_thread_id) {
sess.active_thread = Some(target_thread_id);
sess.last_active_at = chrono::Utc::now();
drop(sess);
self.session_manager
.register_thread(
&message.user_id,
&message.channel,
target_thread_id,
Arc::clone(&session),
)
.await;
(session, target_thread_id)
} else {
drop(sess);
self.session_manager
.resolve_thread(
&message.user_id,
&message.channel,
message.conversation_scope(),
)
.await
}
Comment on lines +1048 to +1057
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The self.session_manager.resolve_thread(...) call is duplicated here and in the final else block. While functionally correct, consider extracting this logic into a helper function or a dedicated method within SessionManager to reduce repetition and improve readability. For instance, SessionManager could expose a method like resolve_or_activate_thread_for_approval that encapsulates this specific logic.

Suggested change
} else {
drop(sess);
self.session_manager
.resolve_thread(
&message.user_id,
&message.channel,
message.conversation_scope(),
)
.await
}
} else {
// If the target_thread_id is not in the current session, fall back to generic resolution.
// This might create a new thread or resolve an existing one based on channel/scope.
self.session_manager
.resolve_thread(
&message.user_id,
&message.channel,
message.conversation_scope(),
)
.await
}
References
  1. When an issue is found in duplicated code, prefer refactoring into a shared function over applying localized fixes.
  2. Consolidate related sequences of operations, such as creating, persisting, and scheduling a job, into a single reusable method to improve code consistency and maintainability.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I left this as-is for now to keep the CI fix narrowly scoped, since the duplicated branch is just the fallback into the existing SessionManager path. If we touch this area again, I agree a small helper such as resolve_or_activate_thread_for_approval would make the control flow easier to read.

Comment on lines +1029 to +1057
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the approval-thread fast path you switch active_thread and return the UUID, but you don't register a thread_map entry for the current (user_id, channel, external_thread_id) key. That means subsequent non-approval gateway messages that include the same UUID can still be routed by resolve_thread() into a new thread because this UUID is already mapped under a different channel key. Consider registering the mapping (e.g., via SessionManager::register_thread) when the UUID is accepted so future requests on this channel+thread_id consistently resolve to the same in-memory thread.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in aa426a00. When the approval fast path adopts an already-loaded UUID, it now also registers that thread for the current (user_id, channel, external_thread_id) key via SessionManager::register_thread, so later gateway messages on the same UUID resolve back to the same in-memory thread instead of creating a fresh channel-scoped thread. I also added a focused unit test for the second-channel registration case and reran tests/e2e/scenarios/test_owner_scope.py (3 passed).

} else {
self.session_manager
.resolve_thread(
&message.user_id,
&message.channel,
message.conversation_scope(),
)
.await
};
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
Expand Down
27 changes: 27 additions & 0 deletions src/agent/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,33 @@ mod tests {
assert_ne!(resolved, tid);
}

#[tokio::test]
async fn test_register_then_resolve_same_uuid_on_second_channel_reuses_thread() {
use crate::agent::session::{Session, Thread};

let manager = SessionManager::new();
let tid = Uuid::new_v4();

let session = Arc::new(Mutex::new(Session::new("user-cross")));
{
let mut sess = session.lock().await;
let thread = Thread::with_id(tid, sess.id);
sess.threads.insert(tid, thread);
}

manager
.register_thread("user-cross", "http", tid, Arc::clone(&session))
.await;
manager
.register_thread("user-cross", "gateway", tid, Arc::clone(&session))
.await;

let (_, resolved) = manager
.resolve_thread("user-cross", "gateway", Some(&tid.to_string()))
.await;
assert_eq!(resolved, tid);
}

// === QA Plan P3 - 4.2: Concurrent session stress tests ===

#[tokio::test]
Expand Down
7 changes: 2 additions & 5 deletions src/config/embeddings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,15 +299,12 @@ mod tests {

// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("EMBEDDING_BASE_URL", "https://custom.example.com");
std::env::set_var("EMBEDDING_BASE_URL", "https://8.8.8.8");
}

let settings = Settings::default();
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
assert_eq!(
config.openai_base_url.as_deref(),
Some("https://custom.example.com")
);
assert_eq!(config.openai_base_url.as_deref(), Some("https://8.8.8.8"));
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("EMBEDDING_BASE_URL");
Expand Down
8 changes: 4 additions & 4 deletions src/config/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -855,19 +855,19 @@ mod tests {
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("LLM_BACKEND", "openai_compatible");
std::env::set_var("LLM_BASE_URL", "http://env-url/v1");
std::env::set_var("LLM_BASE_URL", "http://localhost:8000/v1");
}

let settings = Settings {
llm_backend: Some("openai_compatible".to_string()),
openai_compatible_base_url: Some("http://settings-url/v1".to_string()),
openai_compatible_base_url: Some("http://localhost:9000/v1".to_string()),
..Default::default()
};

let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("should have provider config");
assert_eq!(
provider.base_url, "http://env-url/v1",
provider.base_url, "http://localhost:8000/v1",
"env var should take priority over settings"
);

Expand All @@ -879,7 +879,7 @@ mod tests {
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("should have provider config");
assert_eq!(
provider.base_url, "http://settings-url/v1",
provider.base_url, "http://localhost:9000/v1",
"settings should take priority over registry default"
);

Expand Down
Loading
Loading