This commit is contained in:
@ -1924,51 +1924,58 @@ 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.
|
||||
if let Some(structured) = &self.structured {
|
||||
if let Some(existing) =
|
||||
structured.session_for_agent_in_project(input.project.id, &input.agent_id)
|
||||
{
|
||||
let host_node =
|
||||
structured.node_for_agent_in_project(input.project.id, &input.agent_id);
|
||||
let node_id = match reattach_decision(
|
||||
input.node_id,
|
||||
host_node,
|
||||
input.conversation_id.as_deref(),
|
||||
) {
|
||||
ReattachDecision::Rebind { node_id } => {
|
||||
let _ = structured.rebind_agent_node_in_project(
|
||||
input.project.id,
|
||||
&input.agent_id,
|
||||
node_id,
|
||||
);
|
||||
node_id
|
||||
}
|
||||
// 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 {
|
||||
// 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)
|
||||
{
|
||||
let host_node =
|
||||
structured.node_for_agent_in_project(input.project.id, &input.agent_id);
|
||||
let node_id = match reattach_decision(
|
||||
input.node_id,
|
||||
host_node,
|
||||
input.conversation_id.as_deref(),
|
||||
) {
|
||||
ReattachDecision::Rebind { node_id } => {
|
||||
let _ = structured.rebind_agent_node_in_project(
|
||||
input.project.id,
|
||||
&input.agent_id,
|
||||
node_id,
|
||||
);
|
||||
node_id
|
||||
}
|
||||
// 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,
|
||||
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,
|
||||
node_id,
|
||||
conversation_id: existing.conversation_id(),
|
||||
}),
|
||||
// Réattache structurée (rebind de vue) : aucun profil re-résolu.
|
||||
profile: None,
|
||||
});
|
||||
conversation_id: existing.conversation_id(),
|
||||
}),
|
||||
// Réattache structurée (rebind de vue) : aucun profil re-résolu.
|
||||
profile: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user