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:
@ -23,15 +23,17 @@ use application::{
|
||||
LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
|
||||
RecallMemory, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveMemoryLinks,
|
||||
SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, SuggestedThisSession,
|
||||
SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
|
||||
SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
|
||||
UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore,
|
||||
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, MemoryRecall, MemoryStore,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore,
|
||||
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||
MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore,
|
||||
TemplateStore,
|
||||
};
|
||||
use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
|
||||
|
||||
@ -40,7 +42,8 @@ use infrastructure::{
|
||||
EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore,
|
||||
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
|
||||
Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, SystemClock, TokioBroadcastEventBus,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock,
|
||||
TokioBroadcastEventBus,
|
||||
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
@ -291,6 +294,14 @@ impl AppState {
|
||||
let pty_port = Arc::clone(&pty) as Arc<dyn PtyPort>;
|
||||
let terminal_sessions = Arc::new(TerminalSessions::new());
|
||||
|
||||
// --- Sessions structurées (IA / cellules chat, §17) ---
|
||||
// Registre jumeau de TerminalSessions + fabrique infra routée par
|
||||
// `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
|
||||
// ChangeAgentProfile (shutdown polymorphe au hot-swap).
|
||||
let structured_sessions = Arc::new(StructuredSessions::new());
|
||||
let session_factory =
|
||||
Arc::new(StructuredSessionFactory::new()) as Arc<dyn AgentSessionFactory>;
|
||||
|
||||
let open_terminal = Arc::new(OpenTerminal::new(
|
||||
Arc::clone(&pty_port),
|
||||
Arc::clone(&terminal_sessions),
|
||||
@ -507,33 +518,45 @@ impl AppState {
|
||||
));
|
||||
// LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
|
||||
// use cases — indispensable for the PtyBridge to work correctly.
|
||||
let launch_agent = Arc::new(LaunchAgent::new(
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&runtime_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&pty_port),
|
||||
Arc::clone(&skill_store_port),
|
||||
Arc::clone(&terminal_sessions),
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&memory_recall_port),
|
||||
Some(Arc::clone(&check_embedder_suggestion)),
|
||||
));
|
||||
let launch_agent = Arc::new(
|
||||
LaunchAgent::new(
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&runtime_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&pty_port),
|
||||
Arc::clone(&skill_store_port),
|
||||
Arc::clone(&terminal_sessions),
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&memory_recall_port),
|
||||
Some(Arc::clone(&check_embedder_suggestion)),
|
||||
)
|
||||
// Routage structuré §17.4 : un profil avec `structured_adapter` lance une
|
||||
// AgentSession (cellule chat) au lieu d'un PTY ; sinon chemin PTY inchangé.
|
||||
.with_structured(
|
||||
Arc::clone(&session_factory),
|
||||
Arc::clone(&structured_sessions),
|
||||
),
|
||||
);
|
||||
|
||||
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
|
||||
// profile/project/fs stores, the live-session registry and PTY port, and
|
||||
// *composes* the launcher above for the in-place relaunch (no duplication).
|
||||
let change_agent_profile = Arc::new(ChangeAgentProfile::new(
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&terminal_sessions),
|
||||
Arc::clone(&pty_port),
|
||||
Arc::clone(&launch_agent),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
// Voit aussi le registre structuré pour un « kill » polymorphe (§17.4).
|
||||
let change_agent_profile = Arc::new(
|
||||
ChangeAgentProfile::new(
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&terminal_sessions),
|
||||
Arc::clone(&pty_port),
|
||||
Arc::clone(&launch_agent),
|
||||
Arc::clone(&events_port),
|
||||
)
|
||||
.with_structured(Arc::clone(&structured_sessions)),
|
||||
);
|
||||
|
||||
// Read-only inventory of resumable agent cells (§15.2). Reuses the shared
|
||||
// project/fs/context/profile stores already injected above — no new port.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
1077
crates/application/tests/structured_launch_d3.rs
Normal file
1077
crates/application/tests/structured_launch_d3.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -142,13 +142,18 @@ impl CodexExecSession {
|
||||
/// Compose la ligne de commande d'un tour.
|
||||
///
|
||||
/// Format RÉEL vérifié 2026-06-09 :
|
||||
/// - Conversation neuve : `codex exec --json --skip-git-repo-check <prompt>`.
|
||||
/// - Conversation neuve : `codex exec --json --skip-git-repo-check
|
||||
/// --sandbox workspace-write --ask-for-approval never <prompt>`.
|
||||
/// - Reprise (id connu) : `codex exec resume <thread_id> --json
|
||||
/// --skip-git-repo-check <prompt>`.
|
||||
/// --skip-git-repo-check --sandbox workspace-write --ask-for-approval never
|
||||
/// <prompt>`.
|
||||
///
|
||||
/// NOTE intégration (D3, hors parsing) : pour qu'un agent puisse **écrire**, le
|
||||
/// lancement devra ajouter `--sandbox workspace-write` + une politique
|
||||
/// d'approbation non bloquante. Ce n'est pas le rôle de cette composition.
|
||||
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
|
||||
/// écrire dans son workspace et `--ask-for-approval never` évite tout blocage sur
|
||||
/// une demande d'approbation interactive (un tour structuré non-interactif ne peut
|
||||
/// répondre à un prompt). Défaut raisonnable, aligné sur l'autonomie projet
|
||||
/// (CLAUDE.md §12) ; à terme **piloté par les permissions de l'agent**
|
||||
/// (`.ideai/permissions.json` + sandbox OS) — non implémenté ici.
|
||||
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
||||
let mut args = vec!["exec".to_owned()];
|
||||
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
|
||||
@ -157,6 +162,10 @@ impl CodexExecSession {
|
||||
}
|
||||
args.push("--json".to_owned());
|
||||
args.push("--skip-git-repo-check".to_owned());
|
||||
args.push("--sandbox".to_owned());
|
||||
args.push("workspace-write".to_owned());
|
||||
args.push("--ask-for-approval".to_owned());
|
||||
args.push("never".to_owned());
|
||||
args.push(prompt.to_owned());
|
||||
SpawnLine {
|
||||
command: self.command.clone(),
|
||||
|
||||
@ -1127,4 +1127,93 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// DURCISSEMENT QA (lot D3, §17.9 D3) — autonomie d'écriture Codex :
|
||||
// la commande générée porte `--sandbox workspace-write` ET
|
||||
// `--ask-for-approval never`, conversation NEUVE comme en REPRISE.
|
||||
// Prouvé via le sidecar argv du fake enregistreur (jamais le vrai codex).
|
||||
// =====================================================================
|
||||
|
||||
/// Conversation NEUVE : `codex exec --json --skip-git-repo-check
|
||||
/// --sandbox workspace-write --ask-for-approval never <prompt>`. On prouve la
|
||||
/// présence des deux flags d'autonomie ET leur appariement valeur (sandbox=
|
||||
/// workspace-write, ask-for-approval=never), sans sous-commande `resume`.
|
||||
#[tokio::test]
|
||||
async fn codex_new_conversation_command_carries_autonomy_flags() {
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||
]);
|
||||
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
||||
let _ = session.send("salut").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
|
||||
// Conversation neuve : pas de sous-commande resume.
|
||||
assert!(
|
||||
!args.contains(&"resume"),
|
||||
"1er tour NEUF ne doit PAS porter resume, vu: {args:?}"
|
||||
);
|
||||
// Flags d'autonomie (D3), avec leur valeur appariée.
|
||||
assert!(args.contains(&"--sandbox"), "vu: {args:?}");
|
||||
assert!(args.contains(&"workspace-write"), "vu: {args:?}");
|
||||
assert!(args.contains(&"--ask-for-approval"), "vu: {args:?}");
|
||||
assert!(args.contains(&"never"), "vu: {args:?}");
|
||||
// `--sandbox` est immédiatement suivi de `workspace-write`.
|
||||
let sb = args.iter().position(|a| *a == "--sandbox").unwrap();
|
||||
assert_eq!(
|
||||
args.get(sb + 1),
|
||||
Some(&"workspace-write"),
|
||||
"--sandbox doit être suivi de workspace-write, vu: {args:?}"
|
||||
);
|
||||
// `--ask-for-approval` est immédiatement suivi de `never`.
|
||||
let af = args.iter().position(|a| *a == "--ask-for-approval").unwrap();
|
||||
assert_eq!(
|
||||
args.get(af + 1),
|
||||
Some(&"never"),
|
||||
"--ask-for-approval doit être suivi de never, vu: {args:?}"
|
||||
);
|
||||
// La base + le prompt restent présents.
|
||||
assert!(args.contains(&"--json") && args.contains(&"--skip-git-repo-check"));
|
||||
assert!(args.contains(&"salut"), "vu: {args:?}");
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
/// REPRISE (seed d'id) : `codex exec resume <id> --json --skip-git-repo-check
|
||||
/// --sandbox workspace-write --ask-for-approval never <prompt>`. Les flags
|
||||
/// d'autonomie sont AUSSI présents lors d'une reprise.
|
||||
#[tokio::test]
|
||||
async fn codex_resume_command_carries_autonomy_flags() {
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||
]);
|
||||
let session = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
cmd.clone(),
|
||||
"/",
|
||||
Some("cx-id".to_owned()),
|
||||
);
|
||||
let _ = session.send("vas-y").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
|
||||
// Reprise : sous-commande resume + id.
|
||||
assert!(args.contains(&"resume"), "vu: {args:?}");
|
||||
assert!(args.contains(&"cx-id"), "vu: {args:?}");
|
||||
// Flags d'autonomie présents même en reprise.
|
||||
let sb = args
|
||||
.iter()
|
||||
.position(|a| *a == "--sandbox")
|
||||
.expect("--sandbox présent en reprise");
|
||||
assert_eq!(args.get(sb + 1), Some(&"workspace-write"), "vu: {args:?}");
|
||||
let af = args
|
||||
.iter()
|
||||
.position(|a| *a == "--ask-for-approval")
|
||||
.expect("--ask-for-approval présent en reprise");
|
||||
assert_eq!(args.get(af + 1), Some(&"never"), "vu: {args:?}");
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user