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,