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:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
74
crates/application/src/diag.rs
Normal file
74
crates/application/src/diag.rs
Normal file
@ -0,0 +1,74 @@
|
||||
//! Lightweight, dependency-free diagnostics sink for the orchestrator rendezvous.
|
||||
//!
|
||||
//! The orchestrator already emits best-effort traces through `eprintln!` (see
|
||||
//! [`crate::orchestrator`]). When IdeA is launched by **clicking the AppImage**,
|
||||
//! that stderr is discarded — so an inter-agent block (an `ask` that never gets
|
||||
//! its `idea_reply`) leaves no retrievable trace. This module mirrors those
|
||||
//! traces to a **persistent log file** the user can hand back for diagnosis,
|
||||
//! while keeping the exact same "zero new dependency, never breaks the
|
||||
//! rendezvous" discipline: every write is best-effort and infallible.
|
||||
//!
|
||||
//! - [`set_log_path`] is called once by the composition root (`app-tauri`) with
|
||||
//! `<app-data>/logs/idea.log`. Until then (and in tests) writes go to stderr
|
||||
//! only.
|
||||
//! - [`diag`] (and the [`diag!`] macro) timestamps a line with epoch-millis — the
|
||||
//! same clock as the conversation logs' `atMs` — and appends it to the file,
|
||||
//! always also echoing to stderr so a terminal launch and the test suite still
|
||||
//! see it.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// The resolved log file path, set once at startup. `None` ⇒ stderr-only.
|
||||
static LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Serialises concurrent appends so interleaved rendezvous beacons stay on their
|
||||
/// own lines (the orchestrator handles many connections in parallel).
|
||||
static WRITE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Points the diagnostics sink at `path`, creating its parent directory.
|
||||
///
|
||||
/// Idempotent and infallible: a second call (or a failure to create the
|
||||
/// directory) is ignored — diagnostics must never break startup. Called by the
|
||||
/// composition root once the app-data directory is known.
|
||||
pub fn set_log_path(path: PathBuf) {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = LOG_PATH.set(path);
|
||||
}
|
||||
|
||||
/// Current epoch-millis (same clock as the conversation logs' `atMs`).
|
||||
fn now_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Appends one timestamped diagnostic line. Best-effort and infallible: a missing
|
||||
/// path or an I/O error is swallowed (the line still reaches stderr). Never call
|
||||
/// in a hot loop — these are lifecycle beacons, not a metrics stream.
|
||||
pub fn diag(msg: impl std::fmt::Display) {
|
||||
let line = format!("{} {msg}", now_ms());
|
||||
// Always echo to stderr: keeps a terminal launch and the test suite working,
|
||||
// and preserves the pre-existing `eprintln!` behaviour for these beacons.
|
||||
eprintln!("{line}");
|
||||
if let Some(path) = LOG_PATH.get() {
|
||||
let _guard = WRITE_LOCK.lock();
|
||||
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(f, "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `diag!("...", ...)` — formats then forwards to [`diag`]. Mirrors `eprintln!`.
|
||||
#[macro_export]
|
||||
macro_rules! diag {
|
||||
($($arg:tt)*) => {
|
||||
$crate::diag::diag(format!($($arg)*))
|
||||
};
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
pub mod agent;
|
||||
pub mod conversation;
|
||||
pub mod diag;
|
||||
pub mod embedder;
|
||||
pub mod error;
|
||||
pub mod git;
|
||||
@ -31,19 +32,19 @@ pub mod window;
|
||||
pub use agent::{
|
||||
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
|
||||
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
|
||||
ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput,
|
||||
InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents,
|
||||
ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry,
|
||||
ProfileAvailability,
|
||||
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
||||
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput,
|
||||
ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, RESUME_PROMPT,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
RESUME_PROMPT,
|
||||
};
|
||||
pub use conversation::RecordTurn;
|
||||
pub use embedder::{
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
|
||||
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
||||
@ -135,9 +135,10 @@ impl ReadContext {
|
||||
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
|
||||
///
|
||||
/// For an **agent** context: a direct write under an exclusive write-lease. For the
|
||||
/// **global** project context by a non-orchestrator: the guard returns
|
||||
/// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal*
|
||||
/// (a file under `.ideai/proposals/`) — never an overwrite of the live context.
|
||||
/// **global** project context by a non-orchestrator: the use case asks the domain
|
||||
/// policy whether the requester may write directly; otherwise it materialises a
|
||||
/// proposal (a file under `.ideai/proposals/`) — never an overwrite of the live
|
||||
/// context.
|
||||
pub struct ProposeContext {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
@ -214,24 +215,23 @@ impl ProposeContext {
|
||||
Ok(ProposeOutcome::Written)
|
||||
}
|
||||
None => {
|
||||
// Global project context: single-writer. Try to acquire the write
|
||||
// lease; Forbidden ⇒ materialise a proposal instead of overwriting.
|
||||
match self
|
||||
.guard
|
||||
.acquire_write(requester, GuardedResource::ProjectContext)
|
||||
.await
|
||||
{
|
||||
Ok(_lease) => {
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
Ok(ProposeOutcome::Written)
|
||||
}
|
||||
Err(GuardError::Forbidden) => {
|
||||
let path = self.file_proposal(&project, requester, &content).await?;
|
||||
Ok(ProposeOutcome::Proposed { path })
|
||||
}
|
||||
Err(other) => Err(map_guard_err(other)),
|
||||
// Global project context: single-writer. Authorization stays in the
|
||||
// domain policy; the guard only serialises the eventual direct write.
|
||||
let manifest = self.contexts.load_manifest(&project).await?;
|
||||
let designation = manifest.orchestrator_designation();
|
||||
let resource = GuardedResource::ProjectContext;
|
||||
if !may_write_directly(requester, &resource, &designation) {
|
||||
let path = self.file_proposal(&project, requester, &content).await?;
|
||||
return Ok(ProposeOutcome::Proposed { path });
|
||||
}
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_write(requester, resource)
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
Ok(ProposeOutcome::Written)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -392,7 +392,7 @@ mod tests {
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{may_write_directly, ReadLease, WriteLease};
|
||||
use domain::fileguard::{ReadLease, WriteLease};
|
||||
use domain::ports::{FsError, MemoryError, StoreError};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{ProfileId, ProjectId, RemoteRef};
|
||||
@ -443,12 +443,9 @@ mod tests {
|
||||
}
|
||||
async fn acquire_write(
|
||||
&self,
|
||||
who: ConversationParty,
|
||||
_who: ConversationParty,
|
||||
res: GuardedResource,
|
||||
) -> Result<WriteLease, GuardError> {
|
||||
if !may_write_directly(who, &res) {
|
||||
return Err(GuardError::Forbidden);
|
||||
}
|
||||
let lock = self.lock_for(&res);
|
||||
Ok(WriteLease::new(Box::new(lock.write_owned().await)))
|
||||
}
|
||||
@ -612,6 +609,7 @@ mod tests {
|
||||
Arc::new(FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
orchestrator: None,
|
||||
entries: vec![ManifestEntry {
|
||||
agent_id: agent,
|
||||
name: name.to_owned(),
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
@ -26,7 +26,8 @@ use domain::mailbox::{Ticket, TicketId};
|
||||
use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{
|
||||
AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project,
|
||||
AgentId, AgentProfile, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId,
|
||||
Project,
|
||||
};
|
||||
|
||||
use crate::conversation::RecordTurn;
|
||||
@ -34,7 +35,7 @@ use crate::conversation::RecordTurn;
|
||||
use crate::agent::{
|
||||
drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
||||
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
|
||||
UpdateAgentContextInput,
|
||||
UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::orchestrator::{
|
||||
@ -51,6 +52,19 @@ const DEFAULT_ROWS: u16 = 24;
|
||||
/// See [`DEFAULT_ROWS`].
|
||||
const DEFAULT_COLS: u16 = 80;
|
||||
|
||||
/// Submit defaults for delegated prompts, after applying profile-specific
|
||||
/// compatibility fallbacks for existing saved profiles.
|
||||
fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
|
||||
let delay_ms = profile.submit_delay_ms.or_else(|| {
|
||||
matches!(
|
||||
profile.structured_adapter,
|
||||
Some(domain::profile::StructuredAdapter::Codex)
|
||||
)
|
||||
.then_some(CODEX_SUBMIT_DELAY_MS)
|
||||
});
|
||||
SubmitConfig::new(profile.submit_sequence.clone(), delay_ms)
|
||||
}
|
||||
|
||||
/// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`).
|
||||
///
|
||||
/// A target agent's turn can be long (reasoning + tool use), so the cap is
|
||||
@ -152,6 +166,15 @@ impl Drop for BusyTurnGuard {
|
||||
// soit le chemin (erreur, timeout, drop).
|
||||
self.mailbox.cancel_head(self.agent, self.ticket);
|
||||
self.input.mark_idle(self.agent);
|
||||
// Rendezvous beacon (diagnostics) : le garde a libéré une cible restée Busy
|
||||
// (chemin erreur / timeout / futur ask abandonné). Si ce beacon apparaît
|
||||
// sans « ask resolved », la cible n'a jamais répondu — c'est le scénario de
|
||||
// blocage à diagnostiquer.
|
||||
crate::diag!(
|
||||
"[rendezvous] busy-guard freed target agent {} (ticket {})",
|
||||
self.agent,
|
||||
self.ticket,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -939,16 +962,25 @@ impl OrchestratorService {
|
||||
// le médiateur AVANT l'enqueue (consommé au start_turn).
|
||||
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
|
||||
let pending = input.enqueue(agent_id, ticket);
|
||||
// Rendezvous beacon (diagnostics) : l'ask est désormais en attente du
|
||||
// `idea_reply` (ou prompt-ready) de la cible. Si la cible termine son tour en
|
||||
// texte SANS appeler `idea_reply`, ce beacon « ask started » n'aura pas de
|
||||
// « ask resolved » correspondant avant l'expiration du `turn_timeout` — la
|
||||
// signature exacte du blocage Main→cible.
|
||||
let started = Instant::now();
|
||||
crate::diag!(
|
||||
"[rendezvous] ask started: requester={} -> target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} cold_launch={cold_launch} gate_cold_start={gate_cold_start} \
|
||||
turn_timeout_ms={}",
|
||||
requester.map_or_else(|| "user".to_owned(), |a| a.to_string()),
|
||||
turn_timeout.as_millis(),
|
||||
);
|
||||
// Garde RAII de fin de tour, armé JUSTE après l'enqueue (la cible est maintenant
|
||||
// `Busy`). Quel que soit le chemin de sortie — erreur, timeout, ou **futur
|
||||
// abandonné (drop)** — son `Drop` ramène la cible `Idle` et retire le ticket
|
||||
// fantôme de la FIFO. C'est le fix de la cause racine (cf. [`BusyTurnGuard`]).
|
||||
let busy_guard = BusyTurnGuard::new(
|
||||
Arc::clone(input),
|
||||
Arc::clone(mailbox),
|
||||
agent_id,
|
||||
ticket_id,
|
||||
);
|
||||
let busy_guard =
|
||||
BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id);
|
||||
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
|
||||
// turn into the bound handle). The service no longer writes the PTY directly —
|
||||
// no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1).
|
||||
@ -973,14 +1005,35 @@ impl OrchestratorService {
|
||||
// qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket
|
||||
// déjà résolu, ni libérer un busy state qui ne nous appartient plus.
|
||||
busy_guard.disarm();
|
||||
crate::diag!(
|
||||
"[rendezvous] ask resolved: target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} after_ms={} reply_len={}",
|
||||
started.elapsed().as_millis(),
|
||||
result.len(),
|
||||
);
|
||||
Ok(self.reply_outcome(agent_id, &target, result))
|
||||
}
|
||||
// Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au
|
||||
// Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent).
|
||||
Ok(Err(_cancelled)) => Err(AppError::Process(format!(
|
||||
"agent {target} : canal de réponse fermé avant un résultat"
|
||||
))),
|
||||
Err(_elapsed) => Err(AppError::from(domain::ports::AgentSessionError::Timeout)),
|
||||
Ok(Err(_cancelled)) => {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask channel-closed: target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} after_ms={}",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Err(AppError::Process(format!(
|
||||
"agent {target} : canal de réponse fermé avant un résultat"
|
||||
)))
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
crate::diag!(
|
||||
"[rendezvous] ask TIMEOUT: target={target} (agent {agent_id}) \
|
||||
ticket={ticket_id} after_ms={} (la cible n'a jamais appelé idea_reply \
|
||||
ni atteint son prompt-ready dans le turn_timeout)",
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Err(AppError::from(domain::ports::AgentSessionError::Timeout))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1053,12 +1106,8 @@ impl OrchestratorService {
|
||||
// toutes les sorties — `return Err` des bras du select, OU **futur abandonné
|
||||
// (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est
|
||||
// le fix de la cause racine du blocage `Busy` à vie.
|
||||
let busy_guard = BusyTurnGuard::new(
|
||||
Arc::clone(input),
|
||||
Arc::clone(mailbox),
|
||||
agent_id,
|
||||
ticket_id,
|
||||
);
|
||||
let busy_guard =
|
||||
BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id);
|
||||
|
||||
// Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`), borné par le
|
||||
// **même** garde-fou que le chemin PTY (profil ou défaut). On attend la
|
||||
@ -1317,11 +1366,24 @@ impl OrchestratorService {
|
||||
})?;
|
||||
// Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ;
|
||||
// sinon repli sur la tête de file de l'émetteur (compat agents mono-fil).
|
||||
match ticket {
|
||||
let correlation = match ticket {
|
||||
Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result),
|
||||
None => mailbox.resolve(from, result),
|
||||
};
|
||||
// Rendezvous beacon (diagnostics) : un `idea_reply` est arrivé. Tracer s'il a
|
||||
// corrélé à un ask en vol — un échec ici (« no matching ask ») signe une
|
||||
// délégation déjà expirée/abandonnée ou un ticket erroné côté cible.
|
||||
match &correlation {
|
||||
Ok(()) => crate::diag!(
|
||||
"[rendezvous] idea_reply correlated: from agent {from} ticket={}",
|
||||
ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()),
|
||||
),
|
||||
Err(e) => crate::diag!(
|
||||
"[rendezvous] idea_reply UNMATCHED: from agent {from} ticket={} err={e}",
|
||||
ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()),
|
||||
),
|
||||
}
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
correlation.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
// Explicit «end-of-turn» signal (cadrage §6, lot C5): an `idea_reply` means the
|
||||
// emitting agent `from` finished its delegated task ⇒ mark it Idle so its FIFO
|
||||
// advances to the next queued ticket. This is the deterministic OR signal that
|
||||
@ -1773,7 +1835,7 @@ impl OrchestratorService {
|
||||
else {
|
||||
return (None, SubmitConfig::default(), false);
|
||||
};
|
||||
let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms);
|
||||
let submit = submit_config_for_profile(&profile);
|
||||
// 3e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion
|
||||
// (initialize) servira de signal de readiness de démarrage pour libérer un 1er
|
||||
// tour différé — d'où le gate cold-launch même sans `prompt_ready_pattern`.
|
||||
@ -1901,7 +1963,7 @@ fn normalise(s: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||
use domain::ProfileId;
|
||||
|
||||
// (c) — timeouts pilotés par profil (lot 2) : `turn_timeout_ms` prime sur le défaut.
|
||||
@ -1949,6 +2011,28 @@ mod tests {
|
||||
assert_eq!(p.id.to_string(), p.id.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_submit_config_gets_conservative_delay_when_profile_omits_it() {
|
||||
let p = profile(2, "OpenAI Codex CLI", "codex")
|
||||
.with_structured_adapter(StructuredAdapter::Codex);
|
||||
|
||||
let submit = submit_config_for_profile(&p);
|
||||
|
||||
assert_eq!(submit.sequence, None);
|
||||
assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_profile_submit_delay_is_preserved() {
|
||||
let p = profile(3, "OpenAI Codex CLI", "codex")
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_submit_delay_ms(900);
|
||||
|
||||
let submit = submit_config_for_profile(&p);
|
||||
|
||||
assert_eq!(submit.delay_ms, Some(900));
|
||||
}
|
||||
|
||||
// --- BusyTurnGuard (RAII de fin de tour) -------------------------------
|
||||
//
|
||||
// Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur
|
||||
@ -2026,7 +2110,11 @@ mod tests {
|
||||
tid(7),
|
||||
);
|
||||
} // Drop ici.
|
||||
assert_eq!(*med.idled.lock().unwrap(), vec![aid(1)], "mark_idle au Drop");
|
||||
assert_eq!(
|
||||
*med.idled.lock().unwrap(),
|
||||
vec![aid(1)],
|
||||
"mark_idle au Drop"
|
||||
);
|
||||
assert_eq!(
|
||||
*mb.cancelled.lock().unwrap(),
|
||||
vec![(aid(1), tid(7))],
|
||||
@ -2047,7 +2135,10 @@ mod tests {
|
||||
tid(7),
|
||||
);
|
||||
g.disarm();
|
||||
assert!(med.idled.lock().unwrap().is_empty(), "pas de mark_idle après disarm");
|
||||
assert!(
|
||||
med.idled.lock().unwrap().is_empty(),
|
||||
"pas de mark_idle après disarm"
|
||||
);
|
||||
assert!(
|
||||
mb.cancelled.lock().unwrap().is_empty(),
|
||||
"pas de cancel_head après disarm"
|
||||
|
||||
Reference in New Issue
Block a user