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