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

@ -10,7 +10,7 @@
use std::sync::Arc;
use domain::input::InputMediator;
use domain::{AgentId, NodeId, Project, SessionId};
use domain::{AgentId, NodeId, Project, RuntimeAgentKey, SessionId};
use crate::error::AppError;
use crate::orchestrator::OrchestratorService;
@ -65,11 +65,11 @@ impl AttachLiveAgent {
/// [`AppError::NotFound`] when the agent has no live session in either registry.
pub fn execute(&self, input: AttachLiveAgentInput) -> Result<AttachLiveAgentOutput, AppError> {
// PTY first, then structured (one-live-session-per-agent ⇒ at most one match).
if let Some(session) = self
.live
.pty
.rebind_agent_node(&input.agent_id, input.node_id)
{
if let Some(session) = self.live.pty.rebind_agent_node_in_project(
input.project.id,
&input.agent_id,
input.node_id,
) {
return Ok(AttachLiveAgentOutput {
agent_id: input.agent_id,
node_id: session.node_id,
@ -77,11 +77,11 @@ impl AttachLiveAgent {
kind: LiveSessionKind::Pty,
});
}
if let Some(session) = self
.live
.structured
.rebind_agent_node(&input.agent_id, input.node_id)
{
if let Some(session) = self.live.structured.rebind_agent_node_in_project(
input.project.id,
&input.agent_id,
input.node_id,
) {
return Ok(AttachLiveAgentOutput {
agent_id: input.agent_id,
node_id: input.node_id,
@ -168,28 +168,37 @@ impl StopLiveAgent {
&self,
input: StopLiveAgentInput,
) -> Result<StopLiveAgentOutput, AppError> {
self.stop_dependencies(input.agent_id).await;
self.stop_dependencies(&input.project, input.agent_id).await;
if let Some(mediator) = &self.input {
mediator.preempt(input.agent_id);
mediator.preempt(RuntimeAgentKey::new(input.project.id, input.agent_id));
}
self.stop_one(input.agent_id).await
self.stop_one_in_project(&input.project, input.agent_id)
.await
}
async fn stop_dependencies(&self, agent_id: AgentId) {
async fn stop_dependencies(&self, project: &Project, agent_id: AgentId) {
let Some(waits) = &self.waits else {
return;
};
for dep in waits.active_wait_dependencies(agent_id) {
if let Some(mediator) = &self.input {
mediator.preempt(dep);
mediator.preempt(RuntimeAgentKey::new(project.id, dep));
}
let _ = self.stop_one(dep).await;
let _ = self.stop_one_in_project(project, dep).await;
}
}
async fn stop_one(&self, agent_id: AgentId) -> Result<StopLiveAgentOutput, AppError> {
async fn stop_one_in_project(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<StopLiveAgentOutput, AppError> {
// PTY first: delegate to the existing close primitive (removes + kills).
if let Some(session_id) = self.live.pty.session_for_agent(&agent_id) {
if let Some(session_id) = self
.live
.pty
.session_for_agent_in_project(project.id, &agent_id)
{
self.close
.execute(CloseTerminalInput { session_id })
.await?;
@ -201,7 +210,11 @@ impl StopLiveAgent {
}
// Structured: remove from the registry first (so the uniqueness guard no
// longer sees a live session), then shut the session down out of the lock.
if let Some(session_id) = self.live.structured.session_id_for_agent(&agent_id) {
if let Some(session_id) = self
.live
.structured
.session_id_for_agent_in_project(project.id, &agent_id)
{
if let Some(session) = self.live.structured.remove(&session_id) {
session
.shutdown()

View File

@ -461,7 +461,12 @@ impl GetProjectWorkState {
input: GetProjectWorkStateInput,
) -> Result<ProjectWorkState, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let live_by_agent = live_by_agent(self.live.live_agent_snapshots());
let live_by_agent = live_by_agent(
self.live
.live_agent_snapshots()
.into_iter()
.filter(|snapshot| snapshot.project_id == input.project.id),
);
let undelivered_completions = match &self.background_tasks {
Some(store) => Some(store.list_undelivered_completions().await),
None => None,
@ -482,11 +487,12 @@ impl GetProjectWorkState {
// Tickets are crossed with the busy state: only an agent absent from
// the manifest is dropped (this loop only visits manifest entries), so
// the manifest boundary is naturally preserved.
let busy = self.input.busy_state(agent.id);
let runtime_key = domain::RuntimeAgentKey::new(input.project.id, agent.id);
let busy = self.input.busy_state(runtime_key);
let busy_ticket = busy.ticket();
let tickets = self
.queue
.queue_for(agent.id)
.queue_for(runtime_key)
.into_iter()
.map(|snapshot| ticket_state(snapshot, busy_ticket))
.collect();
@ -810,7 +816,9 @@ fn preview(text: &str, max_chars: usize) -> String {
}
}
fn live_by_agent(snapshots: Vec<LiveSessionSnapshot>) -> HashMap<AgentId, LiveSessionSnapshot> {
fn live_by_agent(
snapshots: impl IntoIterator<Item = LiveSessionSnapshot>,
) -> HashMap<AgentId, LiveSessionSnapshot> {
let mut out = HashMap::new();
for snapshot in snapshots {
out.entry(snapshot.agent_id).or_insert(snapshot);

View File

@ -77,10 +77,11 @@ impl ReconcileLiveState {
///
/// # Errors
/// [`AppError::Store`] sur défaillance de chargement ou d'upsert du store.
pub async fn execute(&self, _input: ReconcileLiveStateInput) -> Result<(), AppError> {
pub async fn execute(&self, input: ReconcileLiveStateInput) -> Result<(), AppError> {
let state = self.store.load().await?;
let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
let reconciled = state.reconcile_orphans(|a| self.registry.is_agent_live(a), now_ms);
let reconciled =
state.reconcile_orphans(|a| self.registry.is_agent_live(input.project_id, a), now_ms);
for entry in reconciled {
self.store.upsert(entry).await?;
}
@ -125,7 +126,7 @@ mod tests {
live: HashSet<AgentId>,
}
impl LiveAgentRegistry for FakeRegistry {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
fn is_agent_live(&self, _project_id: domain::ProjectId, agent_id: &AgentId) -> bool {
self.live.contains(agent_id)
}
fn is_node_live(&self, _node_id: &NodeId) -> bool {