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

@ -52,13 +52,12 @@ use crate::dto::{
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto,
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
SetActiveLayoutRequestDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
@ -1547,10 +1546,9 @@ pub async fn read_conversation_page(
/// `Arc` registries already held by [`AppState`]; the aggregation logic itself
/// is not duplicated.
///
/// `project_id` is accepted for API symmetry and future per-project scoping; the
/// session registry is process-wide today, so the full live set is returned (a
/// project's agent ids are disjoint from other projects' by construction, so the
/// frontend can filter by the agents it knows).
/// `project_id` scopes the process-wide runtime registry before serialization so two
/// open projects carrying the same persisted `AgentId` cannot see each other's live
/// session.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed project id).
@ -1561,13 +1559,16 @@ pub fn list_live_agents(
) -> Result<LiveAgentListDto, ErrorDto> {
// Validate the id shape for a consistent contract, even though the registry
// is not project-scoped yet.
let _ = parse_project_id(&project_id)?;
let project_id = parse_project_id(&project_id)?;
let live = LiveSessions::new(
std::sync::Arc::clone(&state.terminal_sessions),
std::sync::Arc::clone(&state.structured_sessions),
);
Ok(LiveAgentListDto::from_snapshots(
live.live_agent_snapshots(),
live.live_agent_snapshots()
.into_iter()
.filter(|snapshot| snapshot.project_id == project_id)
.collect(),
))
}
@ -1620,9 +1621,9 @@ pub async fn stop_live_agent(
.active_wait_dependencies(agent_id);
// Backstop no-reply : arrêter l'observateur de fin de tour de l'agent (le handle est
// droppé ⇒ polling stoppé) avant de démonter sa session.
state.stop_turn_watch(agent_id);
state.stop_turn_watch(project.id, agent_id);
for dependency in dependencies {
state.stop_turn_watch(dependency);
state.stop_turn_watch(project.id, dependency);
}
state
.stop_live_agent
@ -1745,6 +1746,7 @@ pub async fn launch_agent(
state: State<'_, AppState>,
) -> Result<TerminalSessionDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let project_id = project.id;
let agent_id = parse_agent_id(&request.agent_id)?;
// The hosting cell drives the singleton-invariant guard. Parse it when the
// frontend supplies one; absent ⇒ `None` (a fresh node is minted, and an
@ -1804,7 +1806,7 @@ pub async fn launch_agent(
if let Ok(mut contexts) = state.resume_contexts.lock() {
contexts.insert(
agent_id,
domain::RuntimeAgentKey::new(project_id, agent_id),
crate::state::ResumeContext {
project: resume_project,
rows: request.rows,
@ -1819,6 +1821,7 @@ pub async fn launch_agent(
// posé au 1er lancement persiste. cwd = run dir isolé de l'agent.
if let Some(profile) = output.profile.as_ref() {
state.arm_turn_watch(
project_id,
&watch_root,
agent_id,
profile,
@ -1900,6 +1903,7 @@ pub async fn launch_agent(
.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
{
service.on_rate_limited(
project_id,
agent_id,
host_node_id,
conversation_id.clone(),
@ -2024,7 +2028,7 @@ pub async fn agent_send(
// (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on
// continue à drainer comme pour un battement.
if let domain::ports::ReplyEvent::RateLimited { resets_at_ms } = &event {
if let Some((agent_id, node_id, conversation_id)) = &meta {
if let Some((project_id, agent_id, node_id, conversation_id)) = &meta {
let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) {
(Some(conversation_id), resets_at_ms) => {
(Some(conversation_id.clone()), *resets_at_ms)
@ -2034,7 +2038,13 @@ pub async fn agent_send(
// une reprise non reprenable ; surfacer le fallback humain.
(None, Some(_)) => (None, None),
};
service.on_rate_limited(*agent_id, *node_id, conversation_id, resets_at_ms);
service.on_rate_limited(
*project_id,
*agent_id,
*node_id,
conversation_id,
resets_at_ms,
);
}
}
// Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire
@ -2110,28 +2120,47 @@ pub async fn set_resume_at(
) -> Result<(), ErrorDto> {
let id = parse_agent_id(&agent_id)?;
// Résolution agent→cellule : la registry des sessions vivantes est la source de
// vérité. On regarde d'abord le structuré (qui porte aussi le `conversation_id`),
// puis le terminal (PTY).
let node_id = state
.structured_sessions
.node_for_agent(&id)
.or_else(|| state.terminal_sessions.node_for_agent(&id))
.ok_or_else(|| {
ErrorDto::from(AppError::NotFound(format!(
// Résolution agent→(projet, cellule) : la registry des sessions vivantes est la
// source de vérité. Sans `project_id` dans le DTO historique, on exige un match
// unique pour éviter d'armer la reprise d'un homonyme dans le mauvais projet.
let live = LiveSessions::new(
std::sync::Arc::clone(&state.terminal_sessions),
std::sync::Arc::clone(&state.structured_sessions),
);
let matches = live
.live_agent_snapshots()
.into_iter()
.filter(|snapshot| snapshot.agent_id == id)
.collect::<Vec<_>>();
let snapshot = match matches.as_slice() {
[snapshot] => snapshot,
[] => {
return Err(ErrorDto::from(AppError::NotFound(format!(
"aucune cellule vivante pour l'agent {id}"
)))
})?;
))));
}
_ => {
return Err(ErrorDto::from(AppError::Invalid(format!(
"plusieurs projets vivants portent l'agent {id}"
))));
}
};
let project_id = snapshot.project_id;
let node_id = snapshot.node_id;
// `conversation_id` best-effort : seule une session structurée vivante l'expose.
let conversation_id = state
.structured_sessions
.session_for_agent(&id)
.session_for_agent_in_project(project_id, &id)
.and_then(|s| s.conversation_id());
state
.session_limit_service
.confirm_human_resume(id, node_id, conversation_id, resets_at_ms);
state.session_limit_service.confirm_human_resume(
project_id,
id,
node_id,
conversation_id,
resets_at_ms,
);
Ok(())
}
@ -2210,9 +2239,39 @@ pub async fn set_front_attached(
"[delivery] set_front_attached command: agent={agent_id} attached={}",
request.attached
);
state
.orchestrator_service
.set_agent_front_attached(agent_id, request.attached);
let mut matches = Vec::new();
for project in state
.project_store
.list_projects()
.await
.map_err(|err| ErrorDto::from(application::AppError::Store(err.to_string())))?
{
if state
.list_agents
.execute(application::ListAgentsInput {
project: project.clone(),
})
.await
.map(|out| out.agents.iter().any(|agent| agent.id == agent_id))
.unwrap_or(false)
{
matches.push(project);
}
}
match matches.as_slice() {
[project] => {
state
.orchestrator_service
.set_agent_front_attached(project, agent_id, request.attached)
}
[] => application::diag!(
"[delivery] set_front_attached ignored: agent={agent_id} not found in known projects"
),
_ => application::diag!(
"[delivery] set_front_attached ignored: ambiguous agent={agent_id} across {} projects",
matches.len()
),
}
Ok(())
}