fix(runtime): isolate agent state by project (#101)

This commit is contained in:
2026-07-25 22:14:02 +02:00
parent 6a87c4635f
commit 6e98fd89f7
52 changed files with 1894 additions and 805 deletions

View File

@ -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!(

View File

@ -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"
);
}

View File

@ -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`

View File

@ -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",