feat(agent): routage LaunchAgent structuré vs PTY + réconciliation A/B (D3) — §17

- LaunchAgent route sur profile.structured_adapter : Some ⇒ AgentSession via
  factory + enregistrement StructuredSessions (aucun pty.spawn) ; None ⇒ PTY
  inchangé. Dépendances structurées injectées par builders additifs
  with_structured (signatures publiques inchangées ⇒ A/B legacy verts).
- LaunchAgentOutput étendu d'un champ optionnel structured (non cassant).
- A (ChangeAgentProfile) : kill polymorphe — session structurée ⇒ shutdown(),
  PTY ⇒ kill ; détection sur les deux registres.
- B : resume_supported vrai pour profil structuré ; resolve_session_plan ⇒
  Resume{conversation_id} (Claude --resume / Codex exec resume).
- Codex : build_spawn_line porte --sandbox workspace-write --ask-for-approval
  never (autonomie d'écriture ; à terme piloté par les permissions).

Tests : structured_launch 8 + codex flags 2 ; application/infrastructure 0 échec.

Reste D4 : ChatBridge (Channel) + commandes agent_send/reattach + StructuredSessions
dans AppState + DTO cellKind/ReplyChunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 18:48:25 +02:00
parent f104682477
commit 56913b9053
6 changed files with 1524 additions and 52 deletions

View File

@ -14,20 +14,20 @@
use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError,
IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, ProjectStore, PtyPort,
RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore,
ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project,
ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
ProjectPath, PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
};
use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc};
use crate::project::project_context_path;
use crate::terminal::TerminalSessions;
use crate::terminal::{StructuredSessions, TerminalSessions};
/// Directory (relative to `.ideai/`) under which agent contexts are written.
const AGENTS_SUBDIR: &str = "agents";
@ -321,6 +321,11 @@ pub struct ChangeAgentProfile {
pty: Arc<dyn PtyPort>,
launch: Arc<LaunchAgent>,
events: Arc<dyn EventBus>,
/// Registre des sessions **structurées** (§17.5), pour un « kill » polymorphe
/// (§17.4) : si l'agent a une session structurée vivante, on la `shutdown()` au
/// lieu de tuer un PTY. Injecté au câblage via [`Self::with_structured`] ; `None`
/// ⇒ seul le registre PTY est consulté (mode legacy / tests existants).
structured: Option<Arc<StructuredSessions>>,
}
impl ChangeAgentProfile {
@ -346,9 +351,20 @@ impl ChangeAgentProfile {
pty,
launch,
events,
structured: None,
}
}
/// Branche le registre des sessions **structurées** (§17.4) pour un kill
/// polymorphe au hot-swap. Builder additif : signature de [`Self::new`]
/// **inchangée** (les tests A existants restent verts), le câblage fait
/// `ChangeAgentProfile::new(...).with_structured(registry)`.
#[must_use]
pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
self.structured = Some(structured);
self
}
/// Executes the hot-swap, following the 7-step algorithm of §15.1.
///
/// # Errors
@ -476,18 +492,14 @@ impl ChangeAgentProfile {
&self,
input: &ChangeAgentProfileInput,
) -> Result<Option<TerminalSession>, AppError> {
let Some(session_id) = self.sessions.session_for_agent(&input.agent_id) else {
// Résolution **polymorphe** de la session vivante sur les deux registres
// (§17.4) : structuré d'abord, puis PTY. Un agent ne vit que dans un seul des
// deux à la fois (invariant « 1 session/agent »).
let killed = self.kill_live_session(&input.agent_id).await?;
let Some(node_id) = killed else {
// Aucune session vivante (ni structurée, ni PTY) ⇒ rien à relancer.
return Ok(None);
};
// The hosting cell of the live session — the relaunch reopens here.
let node_id = self.sessions.node_for_agent(&input.agent_id);
// Kill the PTY: remove from the registry first (so the relaunch's
// one-live-session-per-agent guard sees no live session), then kill the
// process.
if let Some(handle) = self.sessions.remove(&session_id) {
self.pty.kill(&handle).await?;
}
let output = self
.launch
@ -504,6 +516,44 @@ impl ChangeAgentProfile {
.await?;
Ok(Some(output.session))
}
/// Tue la session vivante de `agent_id` de façon **polymorphe** (§17.4) :
/// `shutdown()` si elle est structurée, kill PTY sinon. Retire la session de son
/// registre **avant** l'arrêt, pour que la garde d'unicité du relance (sur les
/// deux registres) ne voie plus de session vivante.
///
/// Retourne `Some(node_id_hôte)` quand une session a été tuée (le node où
/// relancer), `None` si l'agent n'avait aucune session vivante. La cellule hôte
/// peut elle-même être absente (session de fond) ⇒ `Some(None)` est replié sur un
/// node neuf côté relance via `LaunchAgentInput.node_id = None`.
async fn kill_live_session(
&self,
agent_id: &AgentId,
) -> Result<Option<Option<NodeId>>, AppError> {
// 1. Session structurée vivante ? ⇒ shutdown polymorphe.
if let Some(structured) = &self.structured {
if let Some(session_id) = structured.session_id_for_agent(agent_id) {
let node_id = structured.node_for_agent(agent_id);
if let Some(session) = structured.remove(&session_id) {
session
.shutdown()
.await
.map_err(|e| AppError::Process(e.to_string()))?;
}
return Ok(Some(node_id));
}
}
// 2. Sinon, session PTY vivante ? ⇒ kill PTY (chemin historique).
let Some(session_id) = self.sessions.session_for_agent(agent_id) else {
return Ok(None);
};
let node_id = self.sessions.node_for_agent(agent_id);
if let Some(handle) = self.sessions.remove(&session_id) {
self.pty.kill(&handle).await?;
}
Ok(Some(node_id))
}
}
// ---------------------------------------------------------------------------
@ -589,10 +639,32 @@ pub struct LaunchAgentInput {
pub conversation_id: Option<String>,
}
/// Descripteur d'une session **structurée** (IA, cellule chat) démarrée par
/// [`LaunchAgent`] (ARCHITECTURE §17.4). Renvoyé en plus du snapshot
/// [`TerminalSession`] quand le profil porte un `structured_adapter` : il identifie
/// la session structurée vivante (registre [`StructuredSessions`]) sans faire fuiter
/// l'`Arc<dyn AgentSession>` à travers la frontière de sortie du use case.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructuredSessionDescriptor {
/// L'id de session IdeA de la session structurée (clé du registre).
pub session_id: SessionId,
/// L'agent IA pilotant cette session.
pub agent_id: AgentId,
/// La cellule (feuille de layout) qui héberge la vue chat.
pub node_id: NodeId,
/// L'id de conversation du moteur, s'il a déjà été attribué.
pub conversation_id: Option<String>,
}
/// Output of [`LaunchAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchAgentOutput {
/// The created agent terminal session.
///
/// **Toujours présent**, y compris pour un agent IA structuré (cellule chat) :
/// dans ce cas c'est un snapshot `kind = Agent` portant l'id de la session
/// structurée (cf. [`Self::structured`]), conformément à §17.4. Garde la sortie
/// **non cassante** pour A/B et le câblage existant.
pub session: TerminalSession,
/// The conversation id **assigned** by this launch, when the profile supports
/// session assignment and the cell had none yet. The caller persists it on the
@ -601,6 +673,13 @@ pub struct LaunchAgentOutput {
/// (resume of an existing id, degraded mode, or a profile without a session
/// block) — the caller has nothing to persist.
pub assigned_conversation_id: Option<String>,
/// Descripteur de la session **structurée**, présent **uniquement** quand ce
/// lancement a routé vers un agent IA structuré (`profile.structured_adapter =
/// Some(_)`, ARCHITECTURE §17.4). `None` pour le chemin PTY/terminal brut
/// historique. Champ **additionnel et optionnel** : la sortie reste compatible
/// avec les use cases A/B et le câblage qui ne lisent que `session` /
/// `assigned_conversation_id`.
pub structured: Option<StructuredSessionDescriptor>,
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
@ -630,6 +709,15 @@ pub struct LaunchAgent {
/// reads the project memory. `None` keeps the launcher independent of it (legacy
/// wiring / tests). A failure here never affects the launch.
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
/// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage
/// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4
/// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy /
/// tests existants, qui restent verts sans changement de signature).
session_factory: Option<Arc<dyn AgentSessionFactory>>,
/// Registre des sessions structurées vivantes (§17.5), jumeau de
/// [`TerminalSessions`]. Peuplé quand un agent IA structuré est lancé. `None` en
/// même temps que [`Self::session_factory`].
structured: Option<Arc<StructuredSessions>>,
}
impl LaunchAgent {
@ -661,9 +749,30 @@ impl LaunchAgent {
ids,
recall,
embedder_suggestion,
session_factory: None,
structured: None,
}
}
/// Branche le **routage structuré (§17.4)** sur ce launcher : fournit la fabrique
/// [`AgentSessionFactory`] et le registre [`StructuredSessions`] injectés au
/// composition root. Sans cet appel (cas legacy / tests existants), `execute`
/// route **tout** vers le PTY — signature de [`Self::new`] **inchangée**, donc
/// aucun appelant existant ne casse.
///
/// Builder (consomme `self`, retourne `Self`) pour rester additif : le câblage
/// fait `LaunchAgent::new(...).with_structured(factory, registry)`.
#[must_use]
pub fn with_structured(
mut self,
session_factory: Arc<dyn AgentSessionFactory>,
structured: Arc<StructuredSessions>,
) -> Self {
self.session_factory = Some(session_factory);
self.structured = Some(structured);
self
}
/// Resolves the Markdown bodies of an agent's assigned skills, in the
/// **manifest order** (deterministic). A skill that no longer exists in its
/// store (deleted out from under the assignment) is silently skipped — a
@ -760,12 +869,18 @@ impl LaunchAgent {
// - without a requested node → idempotent background/no-op launch:
// return the existing session without respawning.
// The resume path (agent dead ⇒ no live session) is unaffected.
//
// Invariant « 1 session vivante/agent » **généralisé aux deux registres**
// (§17.4) : un agent est vivant s'il a une session PTY *ou* structurée.
// On consulte d'abord le registre PTY (chemin historique), puis le
// registre structuré (le cas échéant).
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
if let Some(node_id) = input.node_id {
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
structured: None,
});
}
}
@ -775,6 +890,31 @@ impl LaunchAgent {
return Ok(LaunchAgentOutput {
session,
assigned_conversation_id: None,
structured: None,
});
}
}
// Garde structurée (§17.4) : même sémantique côté registre IA. Rebind de la
// cellule-vue si un node est demandé, sinon idempotence (pas de redémarrage).
if let Some(structured) = &self.structured {
if let Some(existing) = structured.session_for_agent(&input.agent_id) {
// Cellule-vue : le node demandé, sinon le node hôte courant, sinon un
// node neuf (rebind sans redémarrer le process, « la cellule est une
// vue »).
let node_id = input.node_id.or_else(|| structured.node_for_agent(&input.agent_id));
if let Some(node_id) = node_id {
let _ = structured.rebind_agent_node(&input.agent_id, node_id);
}
let node_id = node_id.unwrap_or_else(NodeId::new_random);
return Ok(LaunchAgentOutput {
session: structured_snapshot(&existing, input.agent_id, node_id, size),
assigned_conversation_id: None,
structured: Some(StructuredSessionDescriptor {
session_id: existing.id(),
agent_id: input.agent_id,
node_id,
conversation_id: existing.conversation_id(),
}),
});
}
}
@ -861,6 +1001,34 @@ impl LaunchAgent {
)
.await?;
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
// run dir (étape 5) ; la CLI structurée le lira à chaque tour
// (incarnation « un run par tour »). Si le profil porte un
// `structured_adapter` ET que la fabrique structurée est câblée, on
// démarre une `AgentSession` via le port — **pas** de `pty.spawn` — et on
// l'enregistre dans `StructuredSessions`. Sinon : chemin PTY inchangé.
if let (Some(factory), Some(structured), true) = (
self.session_factory.as_ref(),
self.structured.as_ref(),
profile.structured_adapter.is_some(),
) {
return self
.launch_structured(
factory.as_ref(),
structured,
&agent,
&profile,
&prepared,
&run_dir,
&session_plan,
assigned_conversation_id,
input.node_id,
size,
)
.await;
}
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
let handle = self.pty.spawn(spec.clone(), size).await?;
let session_id = handle.session_id;
@ -889,6 +1057,67 @@ impl LaunchAgent {
Ok(LaunchAgentOutput {
session,
assigned_conversation_id,
structured: None,
})
}
/// Démarre un agent IA **structuré** (§17.4) : crée une [`AgentSession`] via la
/// fabrique, l'enregistre dans [`StructuredSessions`] avec son `agent_id`/`node_id`
/// et publie [`DomainEvent::AgentLaunched`]. **Aucun `pty.spawn`** — la cellule est
/// de type chat.
///
/// L'`assigned_conversation_id` calculé en amont (`resolve_session_plan`) est
/// préservé, mais l'id réellement attribué par le moteur (s'il diffère ou s'il
/// apparaît seulement après le démarrage) prime quand il est disponible : c'est
/// `session.conversation_id()` qui fait foi pour la persistance sur la cellule.
#[allow(clippy::too_many_arguments)]
async fn launch_structured(
&self,
factory: &dyn AgentSessionFactory,
structured: &Arc<StructuredSessions>,
agent: &Agent,
profile: &AgentProfile,
prepared: &PreparedContext,
run_dir: &ProjectPath,
session_plan: &SessionPlan,
assigned_conversation_id: Option<String>,
node_id: Option<NodeId>,
size: PtySize,
) -> Result<LaunchAgentOutput, AppError> {
let session = factory
.start(profile, prepared, run_dir, session_plan)
.await
.map_err(|e| AppError::Process(e.to_string()))?;
let session_id = session.id();
let node_id = node_id.unwrap_or_else(NodeId::new_random);
// Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en
// amont sur les deux registres).
structured.insert(Arc::clone(&session), agent.id, node_id);
// L'id de conversation à persister : celui que le moteur expose s'il en a un,
// sinon l'id pré-attribué par le plan de session (§15).
let engine_conversation_id = session.conversation_id();
let conversation_id = engine_conversation_id.clone().or(assigned_conversation_id);
let snapshot = structured_snapshot(&session, agent.id, node_id, size);
self.events.publish(DomainEvent::AgentLaunched {
agent_id: agent.id,
session_id,
});
Ok(LaunchAgentOutput {
session: snapshot,
// Le caller persiste cet id sur la cellule (pivot de reprise §15.2).
assigned_conversation_id: conversation_id.clone(),
structured: Some(StructuredSessionDescriptor {
session_id,
agent_id: agent.id,
node_id,
conversation_id: engine_conversation_id,
}),
})
}
@ -912,8 +1141,17 @@ impl LaunchAgent {
profile: &AgentProfile,
cell_conversation_id: Option<String>,
) -> (SessionPlan, Option<String>) {
// No session strategy at all: behave exactly as before.
// No session strategy at all: behave exactly as before — **sauf** pour un
// profil **structuré** (§17), dont la reprise ne dépend pas d'un bloc
// `session` mais de l'adapter (Claude `--resume` / Codex `exec resume`). Pour
// un tel profil, une conversation présente sur la cellule ⇒ `Resume`, l'id
// étant capté/attribué par le moteur au premier tour (rien à pré-attribuer).
let Some(session) = &profile.session else {
if profile.structured_adapter.is_some() {
if let Some(conversation_id) = cell_conversation_id {
return (SessionPlan::Resume { conversation_id }, None);
}
}
return (SessionPlan::None, None);
};
@ -1053,6 +1291,34 @@ pub(crate) fn agent_run_dir(
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
}
/// Construit le snapshot [`TerminalSession`] qui représente une session
/// **structurée** (cellule chat) dans la sortie de [`LaunchAgent`] (§17.4).
///
/// Le modèle de sortie reste le même que pour le PTY (non cassant pour A/B) : un
/// snapshot `kind = Agent` portant l'id de la session structurée. Le `cwd` n'a pas
/// de sens process ici (la cellule chat n'a pas de PTY) ; on réutilise le run dir
/// au câblage si besoin, mais le snapshot ne porte qu'un placeholder cohérent.
fn structured_snapshot(
session: &Arc<dyn domain::ports::AgentSession>,
agent_id: AgentId,
node_id: NodeId,
size: PtySize,
) -> TerminalSession {
// La cellule chat n'a pas de cwd PTY ; un chemin racine neutre suffit au snapshot
// (le run dir réel reste connu de l'adapter). `ProjectPath::new("/")` ne peut pas
// échouer (chemin absolu trivial).
let cwd = ProjectPath::new("/").expect("/ est un ProjectPath absolu valide");
let mut snapshot = TerminalSession::starting(
session.id(),
node_id,
cwd,
SessionKind::Agent { agent_id },
size,
);
snapshot.status = SessionStatus::Running;
snapshot
}
/// Builds the Claude Code permission seed (`.claude/settings.local.json`) written
/// into an agent's run dir: full project autonomy (`bypassPermissions` + broad
/// Read/Edit/Write/Bash) with the project root granted as an additional working

View File

@ -161,13 +161,21 @@ impl ListResumableAgents {
};
let name = entry.name.clone();
// `resume_supported` : le profil de l'agent porte-t-il une
// SessionStrategy exploitable (resume_flag présent) ?
// `resume_supported` : le profil de l'agent sait-il reprendre une
// conversation ? Vrai si :
// - il porte une `SessionStrategy` (TUI/PTY avec `resume_flag`,
// sémantique §15), **ou**
// - il est **structuré** (`structured_adapter`, §17) : l'adapter
// Claude (`--resume`) / Codex (`exec resume`) passe le flag de
// reprise du moteur via `SessionPlan::Resume`, donc la reprise est
// intrinsèquement supportée.
let resume_supported = entry
.to_agent()
.ok()
.and_then(|agent| profiles.iter().find(|p| p.id == agent.profile_id))
.is_some_and(|profile| profile.session.is_some());
.is_some_and(|profile| {
profile.session.is_some() || profile.structured_adapter.is_some()
});
resumable.push(ResumableAgent {
agent_id,

File diff suppressed because it is too large Load Diff