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

@ -29,22 +29,21 @@ use application::{
ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories,
ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins,
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates,
LiveAgentRegistry, LiveSessions,
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice,
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile,
SaveProfile, SessionLimitService,
SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents,
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue,
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState,
ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider,
ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog,
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows,
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue,
UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
@ -85,8 +84,7 @@ use infrastructure::{
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore,
FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsTemplateStore,
FsWindowStateStore,
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
@ -696,7 +694,10 @@ impl WakeSessionProvider for AppWakeSessionProvider {
project: &Project,
agent: AgentId,
) -> Result<Arc<dyn AgentSession>, WakeError> {
if let Some(session) = self.structured_sessions.session_for_agent(&agent) {
if let Some(session) = self
.structured_sessions
.session_for_agent_in_project(project.id, &agent)
{
return Ok(session);
}
@ -715,7 +716,7 @@ impl WakeSessionProvider for AppWakeSessionProvider {
.map_err(|err| WakeError::Session(err.to_string()))?;
self.structured_sessions
.session_for_agent(&agent)
.session_for_agent_in_project(project.id, &agent)
.ok_or_else(|| {
WakeError::Session(format!(
"agent {agent} has no structured session after background wake launch"
@ -745,11 +746,11 @@ impl application::ProviderSessionProvider for AppProviderSessionProvider {
/// Contexte minimal de **relance** d'un agent (LS7, ARCHITECTURE §21.5).
///
/// [`AgentResumer::resume`] et [`ScheduledTask::ResumeAgent`] ne portent **pas** le
/// `Project` ni la taille de la cellule, alors que [`LaunchAgentInput`] les exige.
/// La commande `launch_agent` (seul endroit où ces faits sont en main) alimente ce
/// contexte par `agent_id` ; [`AppAgentResumer`] le relit à l'échéance pour
/// recomposer un lancement complet.
/// [`AgentResumer::resume`] ne porte pas le `Project` complet ni la taille de la
/// cellule, alors que [`LaunchAgentInput`] les exige. La commande `launch_agent`
/// (seul endroit où ces faits sont en main) alimente ce contexte par clé runtime
/// `(project_id, agent_id)` ; [`AppAgentResumer`] le relit à l'échéance pour
/// recomposer un lancement complet sans collision entre projets.
#[derive(Clone)]
pub struct ResumeContext {
/// Le projet hôte de l'agent (pour recomposer `LaunchAgentInput`).
@ -760,10 +761,10 @@ pub struct ResumeContext {
pub cols: u16,
}
/// Registre partagé `agent_id → ResumeContext` (composition root ↔ commande
/// Registre partagé `(project_id, agent_id) → ResumeContext` (composition root ↔ commande
/// `launch_agent`). Le **même** `Arc` est injecté dans [`AppAgentResumer`] et conservé
/// sur [`BackendCore`] pour que la commande l'alimente à chaque lancement.
pub type ResumeContexts = Arc<Mutex<HashMap<AgentId, ResumeContext>>>;
pub type ResumeContexts = Arc<Mutex<HashMap<domain::RuntimeAgentKey, ResumeContext>>>;
/// Implémente le port applicatif [`AgentResumer`] (LS7) **par-dessus** le mécanisme de
/// lancement existant ([`LaunchAgent`]).
@ -789,6 +790,7 @@ struct AppAgentResumer {
impl AgentResumer for AppAgentResumer {
async fn resume(
&self,
project_id: ProjectId,
agent_id: AgentId,
node_id: domain::NodeId,
conversation_id: Option<String>,
@ -796,11 +798,10 @@ impl AgentResumer for AppAgentResumer {
) -> Result<(), AppError> {
// Repli propre (jamais de panique) : sans contexte de relance connu, on ne
// reprend pas à l'aveugle. L'erreur remonte ⇒ `AgentResumed` n'est pas publié.
let ctx = self
.contexts
.lock()
.ok()
.and_then(|m| m.get(&agent_id).cloned());
let ctx = self.contexts.lock().ok().and_then(|m| {
m.get(&domain::RuntimeAgentKey::new(project_id, agent_id))
.cloned()
});
let Some(ctx) = ctx else {
return Err(AppError::NotFound(format!(
"resume context for agent {agent_id}"
@ -818,6 +819,7 @@ impl AgentResumer for AppAgentResumer {
requester: agent_id.to_string(),
});
let project_id = ctx.project.id;
self.launch_agent
.execute(LaunchAgentInput {
project: ctx.project,
@ -840,7 +842,9 @@ impl AgentResumer for AppAgentResumer {
"IdeA",
resume_prompt,
);
let _ = self.input_mediator.enqueue(agent_id, ticket);
let _ = self
.input_mediator
.enqueue(domain::RuntimeAgentKey::new(project_id, agent_id), ticket);
Ok(())
}
@ -1200,7 +1204,7 @@ pub struct BackendCore {
/// (structuré, `agent_send`) et niveau 2 (PTY, `launch_agent`) ; sa reprise auto est
/// annulable via la commande `cancel_resume`.
pub session_limit_service: Arc<SessionLimitService>,
/// Registre `agent_id → ResumeContext` (LS7) partagé avec [`AppAgentResumer`] :
/// Registre `(project_id, agent_id) → ResumeContext` (LS7) partagé avec [`AppAgentResumer`] :
/// la commande `launch_agent` y dépose le `Project`/taille du dernier lancement pour
/// que la reprise auto puisse recomposer un `LaunchAgentInput` complet.
pub resume_contexts: ResumeContexts,
@ -1211,10 +1215,11 @@ pub struct BackendCore {
/// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du
/// [`turn_watcher`](Self::turn_watcher) à l'armement.
pub turn_watch_input: Arc<dyn domain::input::InputMediator>,
/// Handles des watches de fin-de-tour vivants, par agent. (Re)lancer un agent
/// **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; fermer/arrêter
/// l'agent le retire. `Mutex` car launch/stop y accèdent concurremment.
pub turn_watch_handles: Mutex<HashMap<AgentId, Box<dyn domain::ports::TurnWatchHandle>>>,
/// Handles des watches de fin-de-tour vivants, par clé runtime. (Re)lancer un agent
/// dans un projet **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ;
/// fermer/arrêter l'agent le retire. `Mutex` car launch/stop y accèdent concurremment.
pub turn_watch_handles:
Mutex<HashMap<domain::RuntimeAgentKey, Box<dyn domain::ports::TurnWatchHandle>>>,
/// Port `FileSystem` partagé, conservé pour bâtir la **sonde d'activité** du rendez-vous
/// `idea_ask_agent` (octets cumulés des transcripts de la cible). Même port que
/// l'inspecteur et le turn-watcher.
@ -2235,7 +2240,10 @@ impl BackendCore {
created_at_ms: clock_for_items.now_millis().max(0) as u64,
correlation_id: Some(ready.task_id.to_string()),
};
match inbox.enqueue_message(ready.owner_agent_id, item) {
match inbox.enqueue_message(
domain::RuntimeAgentKey::new(ready.project_id, ready.owner_agent_id),
item,
) {
Ok(receipt) if receipt.status == InboxReceiptStatus::Deferred => {
application::diag!(
"[background-task] completion deferred: task={} owner={} \
@ -2822,9 +2830,11 @@ impl BackendCore {
// L'id arrive en hex (handshake `requester`) ⇒ on le parse en AgentId ici (la
// composition root est la seule à connaître la frontière infra↔domaine).
let service_for_ready = Arc::clone(&self.orchestrator_service);
let ready_project = project.clone();
let ready_sink: Arc<dyn Fn(&str) + Send + Sync> = Arc::new(move |requester: &str| {
if let Ok(uuid) = Uuid::parse_str(requester) {
service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid));
service_for_ready
.release_agent_cold_start(&ready_project, AgentId::from_uuid(uuid));
}
});
let handle = McpServerHandle::start(
@ -2875,6 +2885,7 @@ impl BackendCore {
/// no-op. `conversation_id` is diagnostic only.
pub fn arm_turn_watch(
&self,
project_id: ProjectId,
project_root: &domain::project::ProjectPath,
agent_id: AgentId,
profile: &AgentProfile,
@ -2894,21 +2905,22 @@ impl BackendCore {
};
let input = Arc::clone(&self.turn_watch_input);
// Callback invoked from the watcher's polling task (no lock held here).
let on_turn_end: domain::ports::OnTurnEnd = Arc::new(move |a| input.turn_ended(a));
let on_turn_end: domain::ports::OnTurnEnd =
Arc::new(move |a| input.turn_ended(domain::RuntimeAgentKey::new(project_id, a)));
let handle = self
.turn_watcher
.watch(agent_id, conversation_id, cwd, on_turn_end);
if let Ok(mut map) = self.turn_watch_handles.lock() {
// Insert replaces (and drops) any prior handle ⇒ its polling task stops.
map.insert(agent_id, handle);
map.insert(domain::RuntimeAgentKey::new(project_id, agent_id), handle);
}
}
/// Stops and removes the end-of-turn watcher of `agent_id` (close / stop). Dropping
/// the stored handle stops its polling task. No-op if none is armed.
pub fn stop_turn_watch(&self, agent_id: AgentId) {
/// Stops and removes the end-of-turn watcher of `agent_id` in `project_id` (close /
/// stop). Dropping the stored handle stops its polling task. No-op if none is armed.
pub fn stop_turn_watch(&self, project_id: ProjectId, agent_id: AgentId) {
if let Ok(mut map) = self.turn_watch_handles.lock() {
map.remove(&agent_id);
map.remove(&domain::RuntimeAgentKey::new(project_id, agent_id));
}
}
@ -6326,7 +6338,7 @@ mod mcp_e2e_loopback_tests {
"ask reply must be returned inline over the real loopback; got {result}"
);
assert_eq!(
mailbox.pending(&agent_id),
mailbox.pending(&domain::RuntimeAgentKey::new(proj.id, agent_id)),
0,
"structured ask must drain its accounting ticket"
);
@ -6385,7 +6397,7 @@ mod mcp_e2e_loopback_tests {
"ask reply must be returned inline over the real loopback (Codex target); got {result}"
);
assert_eq!(
mailbox.pending(&agent_id),
mailbox.pending(&domain::RuntimeAgentKey::new(proj.id, agent_id)),
0,
"structured ask must drain its accounting ticket"
);