fix(runtime): isolate agent state by project (#101)
This commit is contained in:
@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use domain::{
|
||||
AgentId, AgentInbox, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource,
|
||||
InputMediator, Ticket, TicketId,
|
||||
InputMediator, ProjectId, RuntimeAgentKey, Ticket, TicketId,
|
||||
};
|
||||
use infrastructure::{
|
||||
start_background_ready_inbox_bridge, BackgroundTaskReadyToDeliver, InMemoryMailbox,
|
||||
@ -26,6 +26,14 @@ fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn project_id(n: u128) -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn key(project: u128, target: AgentId) -> RuntimeAgentKey {
|
||||
RuntimeAgentKey::new(project_id(project), target)
|
||||
}
|
||||
|
||||
fn ticket_id(n: u128) -> TicketId {
|
||||
TicketId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
@ -70,7 +78,7 @@ fn completion_item(id: u128, target: AgentId, task: u128) -> InboxItem {
|
||||
|
||||
fn make_busy(inbox: &MediatedInbox, target: AgentId) {
|
||||
let ticket = Ticket::new(ticket_id(900), "active", "active turn");
|
||||
let _pending = inbox.enqueue(target, ticket);
|
||||
let _pending = inbox.enqueue(key(1, target), ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -80,12 +88,12 @@ fn enqueue_message_while_busy_is_queued_not_rejected() {
|
||||
make_busy(&inbox, target);
|
||||
|
||||
let receipt = inbox
|
||||
.enqueue_message(target, user_item(1, target, "queued"))
|
||||
.enqueue_message(key(1, target), user_item(1, target, "queued"))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(receipt.status, InboxReceiptStatus::Queued);
|
||||
assert_eq!(receipt.depth, 2);
|
||||
let snapshot = inbox.snapshot(target);
|
||||
let snapshot = inbox.snapshot(key(1, target));
|
||||
assert_eq!(snapshot.depth, 2);
|
||||
assert_eq!(snapshot.items.len(), 1);
|
||||
assert_eq!(snapshot.items[0].body, "queued");
|
||||
@ -98,12 +106,16 @@ fn inbox_fifo_drains_two_entrants_in_order() {
|
||||
let first = user_item(1, target, "first");
|
||||
let second = user_item(2, target, "second");
|
||||
|
||||
inbox.enqueue_message(target, first.clone()).unwrap();
|
||||
inbox.enqueue_message(target, second.clone()).unwrap();
|
||||
inbox
|
||||
.enqueue_message(key(1, target), first.clone())
|
||||
.unwrap();
|
||||
inbox
|
||||
.enqueue_message(key(1, target), second.clone())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(inbox.dequeue_next(target), Some(first));
|
||||
assert_eq!(inbox.dequeue_next(target), Some(second));
|
||||
assert_eq!(inbox.dequeue_next(target), None);
|
||||
assert_eq!(inbox.dequeue_next(key(1, target)), Some(first));
|
||||
assert_eq!(inbox.dequeue_next(key(1, target)), Some(second));
|
||||
assert_eq!(inbox.dequeue_next(key(1, target)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -113,7 +125,7 @@ fn overflow_normal_message_returns_inbox_full() {
|
||||
make_busy(&inbox, target);
|
||||
|
||||
let err = inbox
|
||||
.enqueue_message(target, user_item(1, target, "overflow"))
|
||||
.enqueue_message(key(1, target), user_item(1, target, "overflow"))
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
@ -132,12 +144,12 @@ fn overflow_completion_is_deferred_not_lost_or_enqueued() {
|
||||
make_busy(&inbox, target);
|
||||
|
||||
let receipt = inbox
|
||||
.enqueue_message(target, completion_item(1, target, 42))
|
||||
.enqueue_message(key(1, target), completion_item(1, target, 42))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(receipt.status, InboxReceiptStatus::Deferred);
|
||||
assert_eq!(receipt.depth, 1);
|
||||
assert!(inbox.snapshot(target).items.is_empty());
|
||||
assert!(inbox.snapshot(key(1, target)).items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -146,13 +158,13 @@ fn snapshot_exposes_queue_depth_and_items() {
|
||||
let target = agent(1);
|
||||
|
||||
inbox
|
||||
.enqueue_message(target, user_item(1, target, "first"))
|
||||
.enqueue_message(key(1, target), user_item(1, target, "first"))
|
||||
.unwrap();
|
||||
inbox
|
||||
.enqueue_message(target, user_item(2, target, "second"))
|
||||
.enqueue_message(key(1, target), user_item(2, target, "second"))
|
||||
.unwrap();
|
||||
|
||||
let snapshot = inbox.snapshot(target);
|
||||
let snapshot = inbox.snapshot(key(1, target));
|
||||
assert_eq!(snapshot.agent_id, target);
|
||||
assert_eq!(snapshot.depth, 2);
|
||||
assert_eq!(
|
||||
@ -170,19 +182,38 @@ fn drain_removes_only_one_item_at_a_time() {
|
||||
let inbox = inbox(10);
|
||||
let target = agent(1);
|
||||
inbox
|
||||
.enqueue_message(target, user_item(1, target, "first"))
|
||||
.enqueue_message(key(1, target), user_item(1, target, "first"))
|
||||
.unwrap();
|
||||
inbox
|
||||
.enqueue_message(target, user_item(2, target, "second"))
|
||||
.enqueue_message(key(1, target), user_item(2, target, "second"))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(inbox.dequeue_next(target).unwrap().body, "first");
|
||||
assert_eq!(inbox.dequeue_next(key(1, target)).unwrap().body, "first");
|
||||
|
||||
let snapshot = inbox.snapshot(target);
|
||||
let snapshot = inbox.snapshot(key(1, target));
|
||||
assert_eq!(snapshot.depth, 1);
|
||||
assert_eq!(snapshot.items[0].body, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_agent_id_in_distinct_projects_has_isolated_inbox_queues() {
|
||||
let inbox = inbox(10);
|
||||
let target = agent(1);
|
||||
let p1 = key(1, target);
|
||||
let p2 = key(2, target);
|
||||
let first = user_item(1, target, "project one");
|
||||
let second = user_item(2, target, "project two");
|
||||
|
||||
inbox.enqueue_message(p1, first.clone()).unwrap();
|
||||
inbox.enqueue_message(p2, second.clone()).unwrap();
|
||||
|
||||
assert_eq!(inbox.snapshot(p1).depth, 1);
|
||||
assert_eq!(inbox.snapshot(p2).depth, 1);
|
||||
assert_eq!(inbox.dequeue_next(p1), Some(first));
|
||||
assert_eq!(inbox.snapshot(p1).depth, 0);
|
||||
assert_eq!(inbox.snapshot(p2).items, vec![second]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ready_bridge_enqueues_background_completion_item() {
|
||||
let inbox = Arc::new(inbox(10));
|
||||
@ -192,13 +223,13 @@ async fn ready_bridge_enqueues_background_completion_item() {
|
||||
|
||||
tx.send(BackgroundTaskReadyToDeliver {
|
||||
task_id: task_id(42),
|
||||
project_id: domain::ProjectId::from_uuid(Uuid::from_u128(7)),
|
||||
project_id: project_id(7),
|
||||
owner_agent_id: target,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let snapshot = inbox.snapshot(target);
|
||||
let snapshot = inbox.snapshot(key(7, target));
|
||||
assert_eq!(snapshot.depth, 1);
|
||||
assert_eq!(snapshot.items[0].kind, InboxItemKind::BackgroundCompletion);
|
||||
assert_eq!(
|
||||
|
||||
@ -1146,7 +1146,8 @@ async fn stop_agent_call_closes_the_live_session() {
|
||||
let (service, sessions) = build_service(contexts);
|
||||
// Pre-bind a live PTY session for the agent so StopAgent has something to close.
|
||||
let session_id = SessionId::from_uuid(Uuid::from_u128(555));
|
||||
sessions.insert(
|
||||
sessions.insert_in_project(
|
||||
project().id,
|
||||
PtyHandle { session_id },
|
||||
TerminalSession::starting(
|
||||
session_id,
|
||||
@ -1156,7 +1157,9 @@ async fn stop_agent_call_closes_the_live_session() {
|
||||
PtySize { rows: 24, cols: 80 },
|
||||
),
|
||||
);
|
||||
assert!(sessions.session_for_agent(&agent_id).is_some());
|
||||
assert!(sessions
|
||||
.session_for_agent_in_project(project().id, &agent_id)
|
||||
.is_some());
|
||||
let server = server(service);
|
||||
|
||||
let raw = tools_call(1, "idea_stop_agent", json!({ "target": "dev" }));
|
||||
@ -1166,7 +1169,9 @@ async fn stop_agent_call_closes_the_live_session() {
|
||||
|
||||
// CloseTerminal ran → the agent no longer has a live session.
|
||||
assert!(
|
||||
sessions.session_for_agent(&agent_id).is_none(),
|
||||
sessions
|
||||
.session_for_agent_in_project(project().id, &agent_id)
|
||||
.is_none(),
|
||||
"stop_agent should have removed the session"
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ use async_trait::async_trait;
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::ids::SkillId;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId, RuntimeAgentKey};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
|
||||
@ -840,12 +840,13 @@ async fn ask_request_surfaces_reply_alongside_detail() {
|
||||
|
||||
let svc = Arc::clone(&service);
|
||||
let proj = project();
|
||||
let runtime_key = RuntimeAgentKey::new(proj.id, agent_id);
|
||||
let ask_req = req.clone();
|
||||
let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await });
|
||||
|
||||
// Wait until the ask has enqueued its ticket (blocked awaiting the reply).
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
while mailbox.pending(&agent_id) == 0 {
|
||||
while mailbox.pending(&runtime_key) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
@ -918,11 +919,12 @@ async fn ask_request_no_reply_persists_failed_rendezvous_task() {
|
||||
|
||||
let svc = Arc::clone(&service);
|
||||
let proj = project();
|
||||
let runtime_key = RuntimeAgentKey::new(proj.id, agent_id);
|
||||
let ask_req = req.clone();
|
||||
let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await });
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
while mailbox.pending(&agent_id) == 0 {
|
||||
while mailbox.pending(&runtime_key) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
@ -951,7 +953,11 @@ async fn ask_request_no_reply_persists_failed_rendezvous_task() {
|
||||
}
|
||||
other => panic!("expected failure result, got {other:?}"),
|
||||
}
|
||||
assert_eq!(mailbox.pending(&agent_id), 0, "turn-lock mailbox is freed");
|
||||
assert_eq!(
|
||||
mailbox.pending(&runtime_key),
|
||||
0,
|
||||
"turn-lock mailbox is freed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Point 2 — a non-`ask` command (here `spawn_agent`, reply `None`) ⇒ the `reply`
|
||||
|
||||
@ -7,7 +7,9 @@ use std::sync::Arc;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{FileSystem, ProfileStore, RemotePath, SecretRef, StoreError};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter,
|
||||
};
|
||||
use infrastructure::{FsProfileStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -173,11 +175,20 @@ async fn profiles_file_is_camelcase_versioned() {
|
||||
/// cloud provider (`opencodeProvider`) — the two mutually exclusive OpenCode
|
||||
/// backends. A profile carrying both is inconsistent.
|
||||
fn opencode_backends() -> (OpenCodeConfig, OpenCodeProviderConfig) {
|
||||
let local =
|
||||
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen3-coder-30b", None, None).unwrap();
|
||||
let cloud =
|
||||
OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-cloud"))
|
||||
.unwrap();
|
||||
let local = OpenCodeConfig::new(
|
||||
"http://localhost:8080/v1",
|
||||
None,
|
||||
"qwen3-coder-30b",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cloud = OpenCodeProviderConfig::new(
|
||||
"anthropic",
|
||||
"claude-sonnet-5",
|
||||
SecretRef::new("secret-cloud"),
|
||||
)
|
||||
.unwrap();
|
||||
(local, cloud)
|
||||
}
|
||||
|
||||
@ -203,15 +214,21 @@ async fn read_doc_repairs_stale_opencode_when_both_sections_present() {
|
||||
|
||||
let fs = LocalFileSystem::new();
|
||||
let doc = serde_json::json!({ "version": 1, "profiles": [corrupted] });
|
||||
fs.write(&tmp.child("profiles.json"), &serde_json::to_vec_pretty(&doc).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
fs.write(
|
||||
&tmp.child("profiles.json"),
|
||||
&serde_json::to_vec_pretty(&doc).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let store = store(&tmp);
|
||||
let listed = store.list().await.expect("read repairs instead of failing");
|
||||
assert_eq!(listed.len(), 1, "corrupted profile preserved as an entry");
|
||||
let repaired = &listed[0];
|
||||
assert!(repaired.opencode.is_none(), "stale local `opencode` dropped on read");
|
||||
assert!(
|
||||
repaired.opencode.is_none(),
|
||||
"stale local `opencode` dropped on read"
|
||||
);
|
||||
assert_eq!(
|
||||
repaired.opencode_provider.as_ref().unwrap().provider_id,
|
||||
"anthropic",
|
||||
|
||||
Reference in New Issue
Block a user