fix(runtime): isolate agent state by project (#101)
This commit is contained in:
@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user