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:
@ -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.
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user