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

@ -724,7 +724,9 @@ impl ChangeAgentProfile {
// Résolution **polymorphe** de la session vivante sur les deux registres
// (§17.4) : structuré d'abord, puis PTY. Un agent ne vit que dans un seul des
// deux à la fois (invariant « 1 session/agent »).
let killed = self.kill_live_session(&input.agent_id).await?;
let killed = self
.kill_live_session(&input.project, &input.agent_id)
.await?;
let Some(node_id) = killed else {
// Aucune session vivante (ni structurée, ni PTY) ⇒ rien à relancer.
return Ok(None);
@ -776,12 +778,15 @@ impl ChangeAgentProfile {
/// node neuf côté relance via `LaunchAgentInput.node_id = None`.
async fn kill_live_session(
&self,
project: &Project,
agent_id: &AgentId,
) -> Result<Option<Option<NodeId>>, AppError> {
// 1. Session structurée vivante ? ⇒ shutdown polymorphe.
if let Some(structured) = &self.structured {
if let Some(session_id) = structured.session_id_for_agent(agent_id) {
let node_id = structured.node_for_agent(agent_id);
if let Some(session_id) =
structured.session_id_for_agent_in_project(project.id, agent_id)
{
let node_id = structured.node_for_agent_in_project(project.id, agent_id);
if let Some(session) = structured.remove(&session_id) {
session
.shutdown()
@ -793,10 +798,15 @@ impl ChangeAgentProfile {
}
// 2. Sinon, session PTY vivante ? ⇒ kill PTY (chemin historique).
let Some(session_id) = self.sessions.session_for_agent(agent_id) else {
let Some(session_id) = self
.sessions
.session_for_agent_in_project(project.id, agent_id)
else {
return Ok(None);
};
let node_id = self.sessions.node_for_agent(agent_id);
let node_id = self
.sessions
.node_for_agent_in_project(project.id, agent_id);
if let Some(handle) = self.sessions.remove(&session_id) {
self.pty.kill(&handle).await?;
}
@ -1506,9 +1516,13 @@ impl LaunchAgent {
// (rend la session existante, pas de respawn) ;
// - **second lancement neuf** : on vise un **autre** node, sans signal de
// réattache ⇒ refus [`AppError::AgentAlreadyRunning`] (node hôte rapporté).
let existing_pty = self.sessions.session_for_agent(&input.agent_id);
let existing_pty = self
.sessions
.session_for_agent_in_project(input.project.id, &input.agent_id);
if let Some(existing_id) = existing_pty {
let host_node = self.sessions.node_for_agent(&input.agent_id);
let host_node = self
.sessions
.node_for_agent_in_project(input.project.id, &input.agent_id);
if input.allow_structured_alongside_pty && input.node_id.is_none() {
crate::diag!(
"[launch] existing PTY kept while structured launch proceeds: agent={} \
@ -1519,9 +1533,11 @@ impl LaunchAgent {
match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref())
{
ReattachDecision::Rebind { node_id } => {
if let Some(session) =
self.sessions.rebind_agent_node(&input.agent_id, node_id)
{
if let Some(session) = self.sessions.rebind_agent_node_in_project(
input.project.id,
&input.agent_id,
node_id,
) {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
@ -1558,15 +1574,22 @@ impl LaunchAgent {
// façon identique — rebind de la cellule-vue pour une réattache légitime,
// idempotence sans node/conversation, refus d'un second lancement neuf ailleurs.
if let Some(structured) = &self.structured {
if let Some(existing) = structured.session_for_agent(&input.agent_id) {
let host_node = structured.node_for_agent(&input.agent_id);
if let Some(existing) =
structured.session_for_agent_in_project(input.project.id, &input.agent_id)
{
let host_node =
structured.node_for_agent_in_project(input.project.id, &input.agent_id);
let node_id = match reattach_decision(
input.node_id,
host_node,
input.conversation_id.as_deref(),
) {
ReattachDecision::Rebind { node_id } => {
let _ = structured.rebind_agent_node(&input.agent_id, node_id);
let _ = structured.rebind_agent_node_in_project(
input.project.id,
&input.agent_id,
node_id,
);
node_id
}
// Idempotent — garder le node hôte courant, sinon un node neuf.
@ -1772,7 +1795,8 @@ impl LaunchAgent {
// l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| {
ConversationId::for_pair(
ConversationId::for_project_pair(
input.project.id,
ConversationParty::User,
ConversationParty::agent(agent.id),
)
@ -1788,6 +1812,7 @@ impl LaunchAgent {
&run_dir,
&session_plan,
pair_conversation_id,
input.project.id,
&input.project.root,
input.node_id,
size,
@ -1824,7 +1849,8 @@ impl LaunchAgent {
size,
);
session.status = SessionStatus::Running;
self.sessions.insert(handle, session.clone());
self.sessions
.insert_in_project(input.project.id, handle, session.clone());
self.events.publish(DomainEvent::AgentLaunched {
agent_id: agent.id,
@ -1863,6 +1889,7 @@ impl LaunchAgent {
run_dir: &ProjectPath,
session_plan: &SessionPlan,
pair_conversation_id: String,
project_id: domain::ProjectId,
root: &ProjectPath,
node_id: Option<NodeId>,
size: PtySize,
@ -1889,7 +1916,7 @@ impl LaunchAgent {
// Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en
// amont sur les deux registres).
structured.insert(Arc::clone(&session), agent.id, node_id);
structured.insert_in_project(project_id, Arc::clone(&session), agent.id, node_id);
// ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ──
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur