fix(runtime): stop/restart d'agent visible robuste — kill réel, état stopped, relance froide (#196, QA verte)

Solidifie le cycle stop/restart d'un agent visible en cellule:

- terminal/registry: kill effectif de la TUI native et de la CLI custom, arrêt idempotent du live runtime (PTY + structuré), distinction nette « agent stoppé » vs « pas de session ».

- workstate + lifecycle: projection d'un état runtime explicite « stopped » (AgentRuntimeState: running/stopping/stopped/failedStart), réconcilié avec le live state; traitement des courses « agent not found ».

- relance froide via freshStart: repart de zéro sans réattacher une session morte et recharge la configuration courante de l'agent (y compris changement de modèle).

- contrat projeté côté adapters frontend (ports/domain/normalization/mock) pour la consommation UI future (#197/#198), sans surface UI ici.

Tests QA verts: workstate_actions, orchestrator_service stop_agent, agent_lifecycle (reopen/fresh_start), workstate stopped runtime state, vitest adapter stop_live_agent.
This commit is contained in:
2026-08-12 14:56:02 +02:00
parent 4e93fa2f20
commit bad7b4c2f3
23 changed files with 440 additions and 46 deletions

View File

@ -963,6 +963,7 @@ impl ChangeAgentProfile {
mcp_runtime: None,
allow_structured_alongside_pty: false,
require_structured: false,
fresh_start: false,
})
.await?;
Ok(Some(output.session))
@ -1143,6 +1144,9 @@ pub struct LaunchAgentInput {
///
/// Historical callers leave this `false`, preserving the PTY launch contract.
pub require_structured: bool,
/// Cold restart request: ignore the cell's previous conversation id and create
/// a new provider/CLI conversation for this activation.
pub fresh_start: bool,
}
/// OS/runtime facts injected by the composition root (`app-tauri`) to materialise
@ -1863,6 +1867,11 @@ impl LaunchAgent {
self.structured_routing_mode,
StructuredRoutingMode::RequireStructured
);
let launch_conversation_id = if input.fresh_start {
None
} else {
input.conversation_id.clone()
};
// 1b. Enforce the "one live session per agent" invariant (decision: an
// agent is a singleton that runs in a single cell at a time). This
@ -1908,7 +1917,7 @@ impl LaunchAgent {
self.pty.kill(&handle).await?;
}
} else {
match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref())
match reattach_decision(input.node_id, host_node, launch_conversation_id.as_deref())
{
ReattachDecision::Rebind { node_id } => {
if let Some(session) = self.sessions.rebind_agent_node_in_project(
@ -1971,7 +1980,7 @@ impl LaunchAgent {
reattach_decision(
input.node_id,
host_node,
input.conversation_id.as_deref(),
launch_conversation_id.as_deref(),
)
};
let node_id = match decision {
@ -2061,7 +2070,11 @@ impl LaunchAgent {
// the cell's current `conversation_id`. Any id this launch *assigns* is
// returned in the output so the caller persists it on the leaf.
let (session_plan, assigned_conversation_id) = self
.resolve_session_plan(&profile, input.conversation_id.clone(), &input.project.root)
.resolve_session_plan(
&profile,
launch_conversation_id.clone(),
&input.project.root,
)
.await;
let mut spec =
self.runtime
@ -2076,7 +2089,7 @@ impl LaunchAgent {
&manifest,
&agent,
content.clone(),
input.conversation_id.as_deref(),
launch_conversation_id.as_deref(),
)
.await?;
// Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
@ -2186,7 +2199,7 @@ impl LaunchAgent {
// pure partagée avec `resolve_conversation` (aucun couplage à
// 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(|| {
let pair_conversation_id = launch_conversation_id.clone().unwrap_or_else(|| {
ConversationId::for_project_pair(
input.project.id,
ConversationParty::User,

View File

@ -228,10 +228,10 @@ pub use template::{
SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, UpdateTemplateOutput,
};
pub use terminal::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessionKind,
LiveSessionSnapshot, LiveSessions, OpenTerminal, OpenTerminalInput, OpenTerminalOutput,
ResizeTerminal, ResizeTerminalInput, StructuredSessions, TerminalSessions, WriteToTerminal,
WriteToTerminalInput,
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveRuntimeState,
LiveRuntimeStatus, LiveSessionKind, LiveSessionSnapshot, LiveSessions, OpenTerminal,
OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, StructuredSessions,
TerminalSessions, WriteToTerminal, WriteToTerminalInput,
};
pub use ticket_assistant::{
CloseTicketAssistant, CloseTicketAssistantInput, OpenTicketAssistant, OpenTicketAssistantInput,

View File

@ -1718,6 +1718,7 @@ impl OrchestratorService {
mcp_runtime: None,
allow_structured_alongside_pty: false,
require_structured: false,
fresh_start: false,
})
.await?;
@ -2496,6 +2497,7 @@ impl OrchestratorService {
.and_then(|p| p.runtime_for(project, agent_id)),
allow_structured_alongside_pty: false,
require_structured: false,
fresh_start: false,
})
.await?;
@ -2577,6 +2579,7 @@ impl OrchestratorService {
.and_then(|p| p.runtime_for(project, agent_id)),
allow_structured_alongside_pty: true,
require_structured: true,
fresh_start: false,
})
.await?;

View File

@ -29,8 +29,8 @@ mod registry;
mod usecases;
pub use registry::{
LiveAgentRegistry, LiveSessionKind, LiveSessionSnapshot, LiveSessions, StructuredSessions,
TerminalSessions,
LiveAgentRegistry, LiveRuntimeState, LiveRuntimeStatus, LiveSessionKind, LiveSessionSnapshot,
LiveSessions, StructuredSessions, TerminalSessions,
};
pub use usecases::{
stop_live_agent_runtime_in_project, CloseTerminal, CloseTerminalInput, CloseTerminalOutput,

View File

@ -24,6 +24,19 @@ pub enum LiveSessionKind {
Structured,
}
/// Explicit lifecycle state of an agent runtime in a visible cell.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LiveRuntimeState {
/// The runtime has a live session in one of the registries.
Running,
/// A stop request is currently tearing the runtime down.
Stopping,
/// The runtime was torn down and no live session remains.
Stopped,
/// The last launch attempt failed before a live session was registered.
FailedStart,
}
/// Read-only coordinates of one live agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LiveSessionSnapshot {
@ -39,6 +52,51 @@ pub struct LiveSessionSnapshot {
pub kind: LiveSessionKind,
}
/// Last-known lifecycle projection for one agent runtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LiveRuntimeStatus {
/// The project owning the runtime.
pub project_id: ProjectId,
/// The agent owning the runtime.
pub agent_id: AgentId,
/// Last known layout node hosting the runtime view.
pub node_id: NodeId,
/// Last known session id.
pub session_id: SessionId,
/// Runtime family that owns or owned the session.
pub kind: LiveSessionKind,
/// Current lifecycle state.
pub state: LiveRuntimeState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct LiveAgentKey {
project_id: ProjectId,
agent_id: AgentId,
}
impl LiveAgentKey {
fn new(project_id: ProjectId, agent_id: AgentId) -> Self {
Self {
project_id,
agent_id,
}
}
}
impl From<LiveSessionSnapshot> for LiveRuntimeStatus {
fn from(snapshot: LiveSessionSnapshot) -> Self {
Self {
project_id: snapshot.project_id,
agent_id: snapshot.agent_id,
node_id: snapshot.node_id,
session_id: snapshot.session_id,
kind: snapshot.kind,
state: LiveRuntimeState::Running,
}
}
}
/// A registered, live terminal: its PTY handle plus the domain snapshot.
#[derive(Debug)]
struct Entry {
@ -849,13 +907,18 @@ pub struct LiveSessions {
pub pty: Arc<TerminalSessions>,
/// Registre des sessions IA structurées.
pub structured: Arc<StructuredSessions>,
runtime: Mutex<HashMap<LiveAgentKey, LiveRuntimeStatus>>,
}
impl LiveSessions {
/// Construit l'agrégateur à partir des deux registres partagés.
#[must_use]
pub fn new(pty: Arc<TerminalSessions>, structured: Arc<StructuredSessions>) -> Self {
Self { pty, structured }
Self {
pty,
structured,
runtime: Mutex::new(HashMap::new()),
}
}
/// L'[`SessionId`] de la session vivante d'un agent, PTY **ou** structurée.
@ -899,6 +962,62 @@ impl LiveSessions {
all.extend(self.structured.live_agent_snapshots());
all
}
/// All live runtime snapshots for one `(project, agent)` canonical key.
#[must_use]
pub fn live_agent_snapshots_for(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Vec<LiveSessionSnapshot> {
self.live_agent_snapshots()
.into_iter()
.filter(|snapshot| snapshot.project_id == project_id && &snapshot.agent_id == agent_id)
.collect()
}
/// Current runtime projection for one canonical `(project, agent)` key.
#[must_use]
pub fn runtime_status_for(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<LiveRuntimeStatus> {
self.live_agent_snapshots_for(project_id, agent_id)
.into_iter()
.next()
.map(LiveRuntimeStatus::from)
.or_else(|| {
self.runtime
.lock()
.ok()
.and_then(|m| m.get(&LiveAgentKey::new(project_id, *agent_id)).copied())
})
}
/// Records a non-running runtime state for the last known session coordinates.
pub fn mark_runtime_state(&self, snapshot: LiveSessionSnapshot, state: LiveRuntimeState) {
if let Ok(mut map) = self.runtime.lock() {
map.insert(
LiveAgentKey::new(snapshot.project_id, snapshot.agent_id),
LiveRuntimeStatus {
project_id: snapshot.project_id,
agent_id: snapshot.agent_id,
node_id: snapshot.node_id,
session_id: snapshot.session_id,
kind: snapshot.kind,
state,
},
);
}
}
/// Drops stale stopped/failed state when a caller needs a clean runtime boundary.
pub fn clear_runtime_state(&self, project_id: ProjectId, agent_id: &AgentId) {
if let Ok(mut map) = self.runtime.lock() {
map.remove(&LiveAgentKey::new(project_id, *agent_id));
}
}
}
impl LiveAgentRegistry for LiveSessions {

View File

@ -10,7 +10,7 @@ use domain::{
use crate::error::AppError;
use super::registry::{LiveSessionKind, LiveSessions, TerminalSessions};
use super::registry::{LiveRuntimeState, LiveSessionKind, LiveSessions, TerminalSessions};
/// Input for [`OpenTerminal::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
@ -223,9 +223,10 @@ impl CloseTerminal {
) -> Result<CloseTerminalOutput, AppError> {
let handle = self
.sessions
.remove(&input.session_id)
.handle(&input.session_id)
.ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?;
let status = self.pty.kill(&handle).await?;
self.sessions.remove(&input.session_id);
Ok(CloseTerminalOutput { code: status.code })
}
}
@ -237,8 +238,12 @@ pub struct StoppedAgentRuntime {
pub agent_id: AgentId,
/// Live session id removed from its registry.
pub session_id: SessionId,
/// Last known layout node hosting the runtime view.
pub node_id: NodeId,
/// Runtime registry that owned the session.
pub kind: LiveSessionKind,
/// Lifecycle state reached by this stop operation.
pub state: LiveRuntimeState,
}
/// Stops one agent's live runtime across both registries.
@ -253,30 +258,34 @@ pub async fn stop_live_agent_runtime_in_project(
close: &CloseTerminal,
) -> Result<Vec<StoppedAgentRuntime>, AppError> {
let mut stopped = Vec::new();
let snapshots = live.live_agent_snapshots_for(project.id, &agent_id);
if let Some(session_id) = live
.structured
.session_id_for_agent_in_project(project.id, &agent_id)
{
if let Some(session) = live.structured.remove(&session_id) {
session
.shutdown()
.await
.map_err(|e| AppError::Process(e.to_string()))?;
for snapshot in snapshots {
live.mark_runtime_state(snapshot, LiveRuntimeState::Stopping);
match snapshot.kind {
LiveSessionKind::Structured => {
if let Some(session) = live.structured.remove(&snapshot.session_id) {
session
.shutdown()
.await
.map_err(|e| AppError::Process(e.to_string()))?;
}
}
LiveSessionKind::Pty => {
close
.execute(CloseTerminalInput {
session_id: snapshot.session_id,
})
.await?;
}
}
live.mark_runtime_state(snapshot, LiveRuntimeState::Stopped);
stopped.push(StoppedAgentRuntime {
agent_id,
session_id,
kind: LiveSessionKind::Structured,
});
}
if let Some(session_id) = live.pty.session_for_agent_in_project(project.id, &agent_id) {
close.execute(CloseTerminalInput { session_id }).await?;
stopped.push(StoppedAgentRuntime {
agent_id,
session_id,
kind: LiveSessionKind::Pty,
agent_id: snapshot.agent_id,
session_id: snapshot.session_id,
node_id: snapshot.node_id,
kind: snapshot.kind,
state: LiveRuntimeState::Stopped,
});
}

View File

@ -16,7 +16,8 @@ use domain::{AgentId, NodeId, Project, RuntimeAgentKey, SessionId};
use crate::error::AppError;
use crate::orchestrator::OrchestratorService;
use crate::terminal::{
stop_live_agent_runtime_in_project, CloseTerminal, LiveSessionKind, LiveSessions,
stop_live_agent_runtime_in_project, CloseTerminal, LiveRuntimeState, LiveSessionKind,
LiveSessions,
};
/// Input for [`AttachLiveAgent::execute`].
@ -115,8 +116,12 @@ pub struct StopLiveAgentOutput {
pub agent_id: AgentId,
/// The id of the session that was torn down.
pub session_id: SessionId,
/// Last known layout node hosting the runtime view.
pub node_id: NodeId,
/// Runtime family that owned the session.
pub kind: LiveSessionKind,
/// Runtime lifecycle projection after the stop request.
pub runtime_state: LiveRuntimeState,
}
/// Best-effort stopper for execution resources owned by one live agent turn.
@ -232,7 +237,26 @@ impl StopLiveAgent {
.map(|stopped| StopLiveAgentOutput {
agent_id: stopped.agent_id,
session_id: stopped.session_id,
node_id: stopped.node_id,
kind: stopped.kind,
runtime_state: stopped.state,
})
.or_else(|| {
self.live
.runtime_status_for(project.id, &agent_id)
.filter(|status| {
matches!(
status.state,
LiveRuntimeState::Stopping | LiveRuntimeState::Stopped
)
})
.map(|status| StopLiveAgentOutput {
agent_id: status.agent_id,
session_id: status.session_id,
node_id: status.node_id,
kind: status.kind,
runtime_state: status.state,
})
})
.ok_or_else(|| AppError::NotFound(format!("running session for agent {agent_id}")))
}

View File

@ -32,7 +32,7 @@ use domain::{
};
use crate::error::AppError;
use crate::terminal::{LiveSessionKind, LiveSessionSnapshot, LiveSessions};
use crate::terminal::{LiveRuntimeState, LiveSessionKind, LiveSessionSnapshot, LiveSessions};
use crate::HandoffProvider;
/// Maximum length (in characters) of a derived [`AgentTicketState::task_preview`].
@ -202,6 +202,8 @@ pub struct AgentWorkState {
pub profile_id: ProfileId,
/// Current live session, if the agent is live.
pub live: Option<LiveWorkSession>,
/// Last known runtime lifecycle state for the agent, distinct from `live`.
pub runtime_state: Option<LiveRuntimeState>,
/// Current FIFO/busy state.
pub busy: AgentBusyState,
/// Pending/in-progress delegation tickets, in FIFO order.
@ -490,6 +492,10 @@ impl GetProjectWorkState {
session_id: snapshot.session_id,
kind: snapshot.kind,
});
let runtime_state = self
.live
.runtime_status_for(input.project.id, &agent.id)
.map(|status| status.state);
// 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.
@ -510,6 +516,7 @@ impl GetProjectWorkState {
name: agent.name,
profile_id: agent.profile_id,
live,
runtime_state,
busy,
tickets,
background_tasks,