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

@ -1193,6 +1193,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
mcp_runtime: None,
allow_structured_alongside_pty: false,
require_structured: false,
fresh_start: false,
}
}
@ -2946,6 +2947,33 @@ async fn launch_reopen_with_existing_conversation_id_resumes_without_new_id() {
);
}
#[tokio::test]
async fn launch_fresh_start_ignores_existing_conversation_id_and_mints_new_one() {
let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile(
profile_with_session(pid(9), Some("--session-id"), "--resume"),
Some(ContextInjectionPlan::Stdin),
);
let mut input = launch_input(agent.id);
input.conversation_id = Some("conv-dead-session".to_owned());
input.fresh_start = true;
let out = launch.execute(input).await.expect("fresh restart launch");
let expected = Uuid::from_u128(1).to_string();
assert_eq!(
out.assigned_conversation_id.as_deref(),
Some(expected.as_str()),
"fresh restart must mint a new conversation id"
);
assert_eq!(
*session.lock().unwrap(),
Some(SessionPlan::Assign {
conversation_id: expected
}),
"fresh restart must not reuse the dead conversation id"
);
}
#[tokio::test]
async fn launch_profile_without_session_block_passes_none() {
// Non-regression: a profile with no session block behaves exactly as before.

View File

@ -772,6 +772,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
mcp_runtime: None,
allow_structured_alongside_pty: false,
require_structured: false,
fresh_start: false,
}
}
@ -1462,6 +1463,28 @@ async fn structured_profile_with_cell_conversation_resolves_to_resume() {
);
}
/// Une relance froide ignore explicitement le `conversation_id` encore porté par
/// la cellule : le runtime structuré doit recevoir un plan neuf, pas une reprise.
#[tokio::test]
async fn structured_profile_fresh_start_ignores_cell_conversation() {
let factory = FakeFactory::new(500, Some("engine-fresh"));
let f = launch_fixture(structured_profile(pid(9)), factory);
let mut input = launch_input(f.agent.id);
input.conversation_id = Some("conv-existing".to_owned());
input.fresh_start = true;
f.launch.execute(input).await.expect("launch starts fresh");
assert_eq!(
*f.session_probe.lock().unwrap(),
Some(SessionPlan::None),
"fresh start must not pass the old cell conversation to the runtime"
);
let starts = f.factory.starts();
assert_eq!(starts.len(), 1);
assert_eq!(starts[0].1, SessionPlan::None);
}
/// Une cellule structurée **neuve** (pas de conversation) sur un profil sans bloc
/// `session` ⇒ `SessionPlan::None` (rien à reprendre ; l'id moteur sera capté au 1er
/// tour).

View File

@ -10,8 +10,8 @@ use async_trait::async_trait;
use application::{
AppExitWorkGuardDetail, ConversationLogProvider, ConversationPreviewStatus,
GetAppExitWorkGuardState, GetAppExitWorkGuardStateInput, GetProjectWorkState,
GetProjectWorkStateInput, HandoffProvider, LiveSessionKind, LiveSessions, StructuredSessions,
TerminalSessions, TicketWorkSource, TicketWorkStatus,
GetProjectWorkStateInput, HandoffProvider, LiveRuntimeState, LiveSessionKind, LiveSessions,
StructuredSessions, TerminalSessions, TicketWorkSource, TicketWorkStatus,
};
use domain::mailbox::{
AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket, TurnResolution,
@ -702,6 +702,37 @@ async fn workstate_attaches_live_structured_session_to_manifest_agent() {
assert_eq!(live.kind, LiveSessionKind::Structured);
}
#[tokio::test]
async fn workstate_exposes_stopped_runtime_state_without_live_session() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let snapshot = application::LiveSessionSnapshot {
project_id: f.project.id,
agent_id: a.id,
node_id: nid(300),
session_id: sid(3),
kind: LiveSessionKind::Pty,
};
let live = LiveSessions::new(Arc::clone(&f.pty), Arc::clone(&f.structured));
live.mark_runtime_state(snapshot, LiveRuntimeState::Stopped);
let usecase = GetProjectWorkState::new(
Arc::new(FakeContexts {
manifest: manifest(std::slice::from_ref(&a)),
}),
Arc::new(live),
Arc::clone(&f.input) as Arc<dyn InputMediator>,
Arc::clone(&f.queue) as Arc<dyn AgentQueueSnapshot>,
);
let out = usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents[0].live, None);
assert_eq!(out.agents[0].runtime_state, Some(LiveRuntimeState::Stopped));
}
#[tokio::test]
async fn app_exit_guard_is_false_without_busy_agent_or_active_background_task() {
let a = agent(10, "alpha");

View File

@ -11,8 +11,8 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use application::{
AttachLiveAgent, AttachLiveAgentInput, CloseTerminal, LiveSessionKind, LiveSessions,
StopAgentExecutionCascade, StopLiveAgent, StopLiveAgentInput, StructuredSessions,
AttachLiveAgent, AttachLiveAgentInput, CloseTerminal, LiveRuntimeState, LiveSessionKind,
LiveSessions, StopAgentExecutionCascade, StopLiveAgent, StopLiveAgentInput, StructuredSessions,
TerminalSessions,
};
use domain::ports::{
@ -329,7 +329,9 @@ async fn stop_pty_kills_and_removes_session() {
assert_eq!(out.agent_id, a);
assert_eq!(out.session_id, sid(1));
assert_eq!(out.node_id, nid(100));
assert_eq!(out.kind, LiveSessionKind::Pty);
assert_eq!(out.runtime_state, LiveRuntimeState::Stopped);
// Delegated to the close primitive: process killed and registry emptied.
assert_eq!(f.pty_port.kills(), vec![sid(1)]);
assert!(f.pty.is_empty(), "live session removed from the registry");
@ -353,6 +355,7 @@ async fn stop_structured_shuts_down_and_removes_session() {
assert_eq!(out.session_id, sid(2));
assert_eq!(out.kind, LiveSessionKind::Structured);
assert_eq!(out.runtime_state, LiveRuntimeState::Stopped);
assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called");
assert!(f.structured.is_empty(), "live session removed");
assert_eq!(
@ -388,6 +391,34 @@ async fn stop_structured_runs_execution_cascade_best_effort() {
assert!(f.structured.is_empty(), "live session removed");
}
#[tokio::test]
async fn stop_pty_is_idempotent_after_success() {
let f = fixture();
let a = aid(10);
insert_pty(&f.pty, sid(1), a, nid(100));
let first = f
.stop
.execute(StopLiveAgentInput {
project: project(),
agent_id: a,
})
.await
.unwrap();
let second = f
.stop
.execute(StopLiveAgentInput {
project: project(),
agent_id: a,
})
.await
.unwrap();
assert_eq!(first, second);
assert_eq!(second.runtime_state, LiveRuntimeState::Stopped);
assert_eq!(f.pty_port.kills(), vec![sid(1)], "kill is not repeated");
}
#[tokio::test]
async fn stop_unknown_agent_is_not_found() {
let f = fixture();