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,51 +1924,58 @@ impl LaunchAgent {
} }
} }
} }
// Garde structurée (§17.4) : même sémantique côté registre IA. R0a appliqué de // Garde structurée (§17.4) : même sémantique côté registre IA, mais seulement
// façon identique — rebind de la cellule-vue pour une réattache légitime, // pour un lancement qui vise lui-même la surface structurée. Un lancement
// idempotence sans node/conversation, refus d'un second lancement neuf ailleurs. // humain PTY/TUI explicite doit pouvoir coexister avec une conversation
if let Some(structured) = &self.structured { // inter-agent/headless déjà vivante pour le même agent : les canaux d'entrée
if let Some(existing) = // et de sortie sont distincts, et la garde PTY ci-dessus reste la source de
structured.session_for_agent_in_project(input.project.id, &input.agent_id) // vérité de l'occupation TUI utilisateur.
{ if wants_structured {
let host_node = if let Some(structured) = &self.structured {
structured.node_for_agent_in_project(input.project.id, &input.agent_id); if let Some(existing) =
let node_id = match reattach_decision( structured.session_for_agent_in_project(input.project.id, &input.agent_id)
input.node_id, {
host_node, let host_node =
input.conversation_id.as_deref(), structured.node_for_agent_in_project(input.project.id, &input.agent_id);
) { let node_id = match reattach_decision(
ReattachDecision::Rebind { node_id } => { input.node_id,
let _ = structured.rebind_agent_node_in_project( host_node,
input.project.id, input.conversation_id.as_deref(),
&input.agent_id, ) {
node_id, ReattachDecision::Rebind { node_id } => {
); let _ = structured.rebind_agent_node_in_project(
node_id input.project.id,
} &input.agent_id,
// Idempotent — garder le node hôte courant, sinon un node neuf. node_id,
ReattachDecision::Idempotent => host_node.unwrap_or_else(NodeId::new_random), );
ReattachDecision::Refuse { node_id } => { node_id
return Err(AppError::AgentAlreadyRunning { }
// Idempotent — garder le node hôte courant, sinon un node neuf.
ReattachDecision::Idempotent => {
host_node.unwrap_or_else(NodeId::new_random)
}
ReattachDecision::Refuse { node_id } => {
return Err(AppError::AgentAlreadyRunning {
agent_id: input.agent_id,
node_id,
});
}
};
return Ok(LaunchAgentOutput {
session: structured_snapshot(&existing, input.agent_id, node_id, size),
assigned_conversation_id: None,
// Rebind de vue : aucune (ré)assignation ⇒ rien de neuf à cacher.
engine_session_id: None,
structured: Some(StructuredSessionDescriptor {
session_id: existing.id(),
agent_id: input.agent_id, agent_id: input.agent_id,
node_id, node_id,
}); conversation_id: existing.conversation_id(),
} }),
}; // Réattache structurée (rebind de vue) : aucun profil re-résolu.
return Ok(LaunchAgentOutput { profile: None,
session: structured_snapshot(&existing, input.agent_id, node_id, size), });
assigned_conversation_id: None, }
// Rebind de vue : aucune (ré)assignation ⇒ rien de neuf à cacher.
engine_session_id: None,
structured: Some(StructuredSessionDescriptor {
session_id: existing.id(),
agent_id: input.agent_id,
node_id,
conversation_id: existing.conversation_id(),
}),
// Réattache structurée (rebind de vue) : aucun profil re-résolu.
profile: None,
});
} }
} }

View File

@ -50,8 +50,8 @@ use domain::{MemoryIndexEntry, NodeId, PtySize, SessionId, SessionKind, SkillId}
use uuid::Uuid; use uuid::Uuid;
use application::{ use application::{
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput, StructuredSessions, ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput,
TerminalSessions, StructuredRoutingMode, StructuredSessions, TerminalSessions,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -811,6 +811,14 @@ struct LaunchFixture {
/// Wires a `LaunchAgent.with_structured(...)` for a given profile + factory. /// Wires a `LaunchAgent.with_structured(...)` for a given profile + factory.
fn launch_fixture(profile: AgentProfile, factory: FakeFactory) -> LaunchFixture { 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 agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
let contexts = FakeContexts::with_agent(&agent, "# ctx body"); let contexts = FakeContexts::with_agent(&agent, "# ctx body");
let profiles = FakeProfiles::new(vec![profile]); let profiles = FakeProfiles::new(vec![profile]);
@ -834,7 +842,8 @@ fn launch_fixture(profile: AgentProfile, factory: FakeFactory) -> LaunchFixture
Arc::new(FakeRecall), Arc::new(FakeRecall),
None, 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 { LaunchFixture {
launch: Arc::new(launch), launch: Arc::new(launch),
agent, agent,
@ -1092,6 +1101,58 @@ async fn structured_relaunch_other_cell_with_conversation_id_rebinds() {
assert_eq!(desc.node_id, target); 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. /// Helper: executes a launch that is expected to be refused, returning the error.
async fn second_launch_err(f: &LaunchFixture, input: LaunchAgentInput) -> application::AppError { async fn second_launch_err(f: &LaunchFixture, input: LaunchAgentInput) -> application::AppError {
f.launch f.launch