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