fix(backend): disponibilité session user + hand-off CLI↔TUI sans collision — #169 #170

This commit is contained in:
2026-08-06 20:00:36 +02:00
parent c50b6c5388
commit 292a81ee60
2 changed files with 114 additions and 46 deletions

View File

@ -1924,9 +1924,13 @@ impl LaunchAgent {
}
}
}
// Garde structurée (§17.4) : même sémantique côté registre IA. R0a appliqué de
// façon identique — rebind de la cellule-vue pour une réattache légitime,
// idempotence sans node/conversation, refus d'un second lancement neuf ailleurs.
// Garde structurée (§17.4) : même sémantique côté registre IA, mais seulement
// pour un lancement qui vise lui-même la surface structurée. Un lancement
// humain PTY/TUI explicite doit pouvoir coexister avec une conversation
// inter-agent/headless déjà vivante pour le même agent : les canaux d'entrée
// et de sortie sont distincts, et la garde PTY ci-dessus reste la source de
// vérité de l'occupation TUI utilisateur.
if wants_structured {
if let Some(structured) = &self.structured {
if let Some(existing) =
structured.session_for_agent_in_project(input.project.id, &input.agent_id)
@ -1947,7 +1951,9 @@ impl LaunchAgent {
node_id
}
// Idempotent — garder le node hôte courant, sinon un node neuf.
ReattachDecision::Idempotent => host_node.unwrap_or_else(NodeId::new_random),
ReattachDecision::Idempotent => {
host_node.unwrap_or_else(NodeId::new_random)
}
ReattachDecision::Refuse { node_id } => {
return Err(AppError::AgentAlreadyRunning {
agent_id: input.agent_id,
@ -1971,6 +1977,7 @@ impl LaunchAgent {
});
}
}
}
// 2. Read its context and resolve its profile.
let content = self

View File

@ -50,8 +50,8 @@ use domain::{MemoryIndexEntry, NodeId, PtySize, SessionId, SessionKind, SkillId}
use uuid::Uuid;
use application::{
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput, StructuredSessions,
TerminalSessions,
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput,
StructuredRoutingMode, StructuredSessions, TerminalSessions,
};
// ---------------------------------------------------------------------------
@ -811,6 +811,14 @@ struct LaunchFixture {
/// Wires a `LaunchAgent.with_structured(...)` for a given profile + factory.
fn launch_fixture(profile: AgentProfile, factory: FakeFactory) -> LaunchFixture {
launch_fixture_with_mode(profile, factory, StructuredRoutingMode::RequireStructured)
}
fn launch_fixture_with_mode(
profile: AgentProfile,
factory: FakeFactory,
mode: StructuredRoutingMode,
) -> LaunchFixture {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
let profiles = FakeProfiles::new(vec![profile]);
@ -834,7 +842,8 @@ fn launch_fixture(profile: AgentProfile, factory: FakeFactory) -> LaunchFixture
Arc::new(FakeRecall),
None,
)
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured))
.with_structured_routing_mode(mode);
LaunchFixture {
launch: Arc::new(launch),
agent,
@ -1092,6 +1101,58 @@ async fn structured_relaunch_other_cell_with_conversation_id_rebinds() {
assert_eq!(desc.node_id, target);
}
/// #169/#170 — une session structurée vivante (chat/headless) ne doit pas bloquer
/// un lancement PTY/TUI utilisateur explicite du même agent. Les deux surfaces ont
/// des canaux distincts ; la garde singleton de la TUI reste portée par le registre
/// PTY, pas par `StructuredSessions`.
#[tokio::test]
async fn pty_launch_is_allowed_while_structured_session_is_live() {
let factory = FakeFactory::new(500, Some("engine-conv"));
let f = launch_fixture_with_mode(
structured_profile(pid(9)),
factory,
StructuredRoutingMode::HumanPtyFallback,
);
let chat_node = nid(1);
let mut chat = launch_input(f.agent.id);
chat.node_id = Some(chat_node);
chat.require_structured = true;
f.launch.execute(chat).await.expect("structured launch");
assert_eq!(f.factory.start_count(), 1);
assert_eq!(
f.structured
.session_id_for_agent_in_project(project_id(), &f.agent.id),
Some(sid(500))
);
let tui_node = nid(2);
let mut tui = launch_input(f.agent.id);
tui.node_id = Some(tui_node);
tui.require_structured = false;
let out = f.launch.execute(tui).await.expect("pty launch");
assert_eq!(
f.factory.start_count(),
1,
"the PTY launch must not re-enter the structured session factory"
);
assert_eq!(f.pty.spawn_count(), 1, "the user TUI gets its own PTY");
assert_eq!(out.session.id, sid(777));
assert!(out.structured.is_none(), "output describes the PTY surface");
assert_eq!(
f.sessions
.session_for_agent_in_project(project_id(), &f.agent.id),
Some(sid(777))
);
assert_eq!(
f.structured
.session_id_for_agent_in_project(project_id(), &f.agent.id),
Some(sid(500)),
"the structured conversation remains live and distinct"
);
}
/// Helper: executes a launch that is expected to be refused, returning the error.
async fn second_launch_err(f: &LaunchFixture, input: LaunchAgentInput) -> application::AppError {
f.launch