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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user