fix(agent): rendre explicite le routage structuré vs PTY dans LaunchAgent (#33)

Un profil structuré rencontrant une factory de session non câblée retombait
silencieusement sur pty.spawn (fallthrough du `if let`), masquant une erreur de
configuration au lieu de la signaler.

Remplace ce fallthrough par une intention explicite :
- nouvel enum StructuredRoutingMode { HumanPtyFallback, RequireStructured } sur
  LaunchAgent, posé via le builder with_structured_routing_mode ;
- le `if let` devient un `match` explicite (agent/lifecycle.rs) ; en mode
  RequireStructured, un profil structuré sans factory câblée retourne
  AppError::Process("structured profile requires structured session factory")
  au lieu de tomber sur pty.spawn.

Composition root (app-tauri/src/state.rs) : launcher humain = HumanPtyFallback,
launcher orchestrateur = RequireStructured, wake background rebranché sur
orchestrator_launch_agent.

Tests : agent_lifecycle.rs (4 branches de routage) + non-régression
agent_wake/structured_launch_d3. Crate application verte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:08:23 +02:00
parent e24bb5f1f4
commit 67101ba607
5 changed files with 268 additions and 56 deletions

View File

@ -998,6 +998,15 @@ pub struct LaunchAgentOutput {
pub profile: Option<AgentProfile>,
}
/// Explicit routing intent for profiles that declare a structured adapter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StructuredRoutingMode {
/// Human-facing cells intentionally keep the native CLI/TUI in a PTY.
HumanPtyFallback,
/// Headless/orchestrated launchers must route structured profiles via AgentSession.
RequireStructured,
}
/// Registry mapping each [`ProjectorKey`] to its concrete
/// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the
/// optional builder [`LaunchAgent::with_permission_projectors`]; the concrete
@ -1141,6 +1150,8 @@ pub struct LaunchAgent {
/// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime
/// when `OpenCodeConfig.localModelServerId` is set.
local_model_server: Option<Arc<EnsureLocalModelServer>>,
/// Explicit intent for structured profiles when structured ports are absent.
structured_routing_mode: StructuredRoutingMode,
}
impl LaunchAgent {
@ -1180,9 +1191,17 @@ impl LaunchAgent {
projectors: None,
live_state_lean: None,
local_model_server: None,
structured_routing_mode: StructuredRoutingMode::HumanPtyFallback,
}
}
/// Sets the explicit routing intent for profiles carrying a structured adapter.
#[must_use]
pub const fn with_structured_routing_mode(mut self, mode: StructuredRoutingMode) -> Self {
self.structured_routing_mode = mode;
self
}
/// Injects the local model-server ensure use case used by OpenCode profiles.
#[must_use]
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
@ -1712,51 +1731,60 @@ impl LaunchAgent {
);
// 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) = (
// L'intention est explicite sur le launcher : les cellules humaines peuvent
// garder une CLI/TUI native en PTY, les launchers headless/orchestrés doivent
// échouer fort si le routage structuré est mal câblé.
match (
profile.structured_adapter.is_some(),
self.session_factory.as_ref(),
self.structured.as_ref(),
profile.structured_adapter.is_some(),
self.structured_routing_mode,
) {
// ── Clé **logique** de la cellule = id de paire IdeA (ARCHITECTURE §19.7,
// lot P8a) ──
// - cellule porteuse d'un `conversation_id` (resume, ou lancement délégué
// qui a déjà injecté l'id de paire A↔B) ⇒ c'est **déjà** l'id de paire,
// on le conserve tel quel ;
// - cellule structurée **neuve** (lancement direct utilisateur, aucun
// requester) ⇒ on **dérive** `pair(User, agent)` via la fonction domaine
// pure partagée avec `resolve_conversation` (aucun couplage à
// l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| {
ConversationId::for_pair(
ConversationParty::User,
ConversationParty::agent(agent.id),
)
.to_string()
});
return self
.launch_structured(
factory.as_ref(),
structured,
&agent,
&profile,
&prepared,
&run_dir,
&session_plan,
pair_conversation_id,
&input.project.root,
input.node_id,
size,
&spec.env,
spec.sandbox.as_ref(),
)
.await;
(false, _, _, _) => {}
(true, Some(factory), Some(structured), _) => {
// ── Clé **logique** de la cellule = id de paire IdeA (ARCHITECTURE §19.7,
// lot P8a) ──
// - cellule porteuse d'un `conversation_id` (resume, ou lancement délégué
// qui a déjà injecté l'id de paire A↔B) ⇒ c'est **déjà** l'id de paire,
// on le conserve tel quel ;
// - cellule structurée **neuve** (lancement direct utilisateur, aucun
// requester) ⇒ on **dérive** `pair(User, agent)` via la fonction domaine
// pure partagée avec `resolve_conversation` (aucun couplage à
// l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| {
ConversationId::for_pair(
ConversationParty::User,
ConversationParty::agent(agent.id),
)
.to_string()
});
return self
.launch_structured(
factory.as_ref(),
structured,
&agent,
&profile,
&prepared,
&run_dir,
&session_plan,
pair_conversation_id,
&input.project.root,
input.node_id,
size,
&spec.env,
spec.sandbox.as_ref(),
)
.await;
}
(true, _, _, StructuredRoutingMode::HumanPtyFallback) => {
// Fallback humain intentionnel : cellule interactive native en PTY.
}
(true, _, _, StructuredRoutingMode::RequireStructured) => {
return Err(AppError::Process(
"structured profile requires structured session factory".to_owned(),
));
}
}
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.

View File

@ -34,8 +34,8 @@ pub use lifecycle::{
InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX,
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContext,
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX,
};
pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,

View File

@ -52,8 +52,9 @@ pub use agent::{
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext,
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use background::{
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,