feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic

Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).

Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 08:56:17 +02:00
parent 40982d44da
commit 287681c198
57 changed files with 1758 additions and 420 deletions

View File

@ -24,6 +24,11 @@ use domain::profile::{
StructuredAdapter,
};
/// Codex's interactive TUI is sensitive to receiving a large pasted block and the
/// submit key too close together. Keep the default conservative so delegated
/// prompts are actually submitted instead of remaining in the input editor.
pub const CODEX_SUBMIT_DELAY_MS: u32 = 350;
/// A fixed UUID namespace used to derive stable ids for reference profiles.
/// (Random-looking but constant; only its stability matters.)
const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b");
@ -82,6 +87,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
.expect("codex reference profile is valid")
.with_structured_adapter(StructuredAdapter::Codex)
.with_projector(ProjectorKey::Codex)
.with_submit_delay_ms(CODEX_SUBMIT_DELAY_MS)
.with_mcp(McpCapability::new(
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour

View File

@ -21,14 +21,14 @@ use domain::ports::{
StoreError,
};
use domain::profile::{McpConfigStrategy, StructuredAdapter};
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry,
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId,
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId, Project,
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
};
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc};
@ -1446,6 +1446,7 @@ impl LaunchAgent {
let prepared = PreparedContext {
content: content.clone(),
relative_path: agent.context_path.clone(),
project_root: input.project.root.as_str().to_owned(),
};
// 4a. Resolve the session intention (T4). The conversation id is a property
// of the *cell*, not the PTY: the caller (which owns the layout) passes

View File

@ -22,7 +22,9 @@ pub use structured::{
drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome,
};
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
pub use catalogue::{
reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS,
};
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
pub use lifecycle::{
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,

View File

@ -35,8 +35,7 @@ use crate::error::AppError;
/// `--resume` (via [`domain::ports::SessionPlan::Resume`]) porte déjà tout
/// l'historique : ce prompt n'a qu'à **réamorcer** le tour, pas reconstruire le
/// contexte. Volontairement neutre et model-agnostique.
pub const RESUME_PROMPT: &str =
"La limite de session est levée. Reprends là où tu t'étais arrêté.";
pub const RESUME_PROMPT: &str = "La limite de session est levée. Reprends là où tu t'étais arrêté.";
/// Port applicatif de **reprise d'un agent** (frontière implémentée au composition
/// root, LS7). Calqué sur les autres traits-passerelles de l'application
@ -161,7 +160,13 @@ impl SessionLimitService {
conversation_id,
} = plan_resume(now, &limit, conversation_id)
{
self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, Some(resets_at_ms));
self.arm_scheduled(
agent_id,
fire_at_ms,
node_id,
conversation_id,
Some(resets_at_ms),
);
}
}
@ -199,9 +204,14 @@ impl SessionLimitService {
conversation_id,
},
);
self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id);
self.events
.publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms });
self.armed
.lock()
.expect("session-limit mutex sain")
.insert(agent_id, id);
self.events.publish(DomainEvent::AgentResumeScheduled {
agent_id,
fire_at_ms,
});
}
/// **(b) Exécution de la reprise.** Consomme une [`ScheduledTask::ResumeAgent`]
@ -255,7 +265,10 @@ impl SessionLimitService {
};
if self.scheduler.cancel(id) {
self.armed.lock().expect("session-limit mutex sain").remove(&agent_id);
self.armed
.lock()
.expect("session-limit mutex sain")
.remove(&agent_id);
self.events
.publish(DomainEvent::AgentResumeCancelled { agent_id });
true

View File

@ -14,8 +14,8 @@
use std::time::Duration;
use domain::input::InputMediator;
use domain::ids::AgentId;
use domain::input::InputMediator;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
@ -178,16 +178,15 @@ async fn drain_bounded_events(
on_signal: impl FnMut(ReadinessSignal),
) -> Result<TurnOutcome, AgentSessionError> {
match timeout {
Some(dur) => match tokio::time::timeout(
dur,
drain_to_final(session, prompt, on_event, on_signal),
)
.await
{
Ok(result) => result,
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
Err(_elapsed) => Err(AgentSessionError::Timeout),
},
Some(dur) => {
match tokio::time::timeout(dur, drain_to_final(session, prompt, on_event, on_signal))
.await
{
Ok(result) => result,
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
Err(_elapsed) => Err(AgentSessionError::Timeout),
}
}
None => drain_to_final(session, prompt, on_event, on_signal).await,
}
}
@ -317,7 +316,9 @@ mod tests {
let session = FakeSession {
events: vec![
ReplyEvent::TextDelta { text: "a".into() },
ReplyEvent::ToolActivity { label: "lit".into() },
ReplyEvent::ToolActivity {
label: "lit".into(),
},
ReplyEvent::Heartbeat,
ReplyEvent::Final {
content: "fini".into(),