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(),

View 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)*))
};
}

View File

@ -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::{

View File

@ -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(),

View File

@ -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"

View File

@ -42,12 +42,14 @@ impl FakeContexts {
Self(Arc::new(Mutex::new(AgentManifest {
version: 1,
entries: vec![ManifestEntry::from_agent(agent)],
orchestrator: None,
})))
}
fn empty() -> Self {
Self(Arc::new(Mutex::new(AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
})))
}
}

View File

@ -23,25 +23,25 @@ use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::events::DomainEvent;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::permission::{
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
ProjectionContext, ProjectorKey,
};
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort,
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::permission::{
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
ProjectionContext, ProjectorKey,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
SessionStrategy, StructuredAdapter,
};
use domain::{PermissionSet, ProjectPermissions};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
use domain::{PermissionSet, ProjectPermissions};
use domain::{PtySize, SessionId, SkillId, SkillRef};
use uuid::Uuid;
@ -84,6 +84,7 @@ impl FakeContexts {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
})))
@ -1351,7 +1352,9 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
"first memory line exact format: {doc}"
);
assert!(
doc.contains("- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"),
doc.contains(
"- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"
),
"second memory line exact format: {doc}"
);
// Recalled order preserved; section after the persona.
@ -2611,7 +2614,9 @@ impl PermissionProjector for FakeClaudeProjector {
/// Faithful Codex projector double: emits a co-owned `MergeToml` over the two
/// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived
/// from the posture exactly like the real projector. `eff == None` ⇒ empty.
/// from the posture exactly like the real projector. Workspace-write postures also
/// add the project root as a writable directory (`--add-dir`). `eff == None` ⇒
/// empty.
struct FakeCodexProjector;
impl FakeCodexProjector {
@ -2631,14 +2636,23 @@ impl PermissionProjector for FakeCodexProjector {
fn project(
&self,
eff: Option<&EffectivePermissions>,
_ctx: &ProjectionContext,
ctx: &ProjectionContext,
) -> PermissionProjection {
let Some(eff) = eff else {
return PermissionProjection::empty();
};
let (sandbox, approval) = Self::modes(eff.fallback());
let contents =
format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
let contents = format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
let mut args = vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
"--ask-for-approval".to_owned(),
approval.to_owned(),
];
if sandbox == "workspace-write" {
args.push("--add-dir".to_owned());
args.push(ctx.project_root.to_owned());
}
PermissionProjection {
files: vec![ProjectedFile::MergeToml {
rel_path: ".codex/config.toml".to_owned(),
@ -2646,12 +2660,7 @@ impl PermissionProjector for FakeCodexProjector {
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
contents,
}],
args: vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
"--ask-for-approval".to_owned(),
approval.to_owned(),
],
args,
env: Vec::new(),
}
}
@ -2741,8 +2750,11 @@ fn convention_file_plan() -> Option<ContextInjectionPlan> {
/// would not fire (here the convention file is GEMINI.md): the explicit field wins.
#[tokio::test]
async fn projection_selects_claude_from_explicit_projector_field() {
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap())
.with_projector(ProjectorKey::Claude);
let profile = profile(
pid(9),
ContextInjection::convention_file("GEMINI.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
@ -2752,7 +2764,10 @@ async fn projection_selects_claude_from_explicit_projector_field() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
assert_eq!(seeds.len(), 1, "the Claude projector ran (explicit key)");
@ -2766,8 +2781,14 @@ async fn projection_selects_claude_from_explicit_projector_field() {
/// Claude projector is selected.
#[tokio::test]
async fn projection_falls_back_to_claude_from_convention_file() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap());
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
);
assert!(
profile.projector.is_none(),
"no explicit projector (legacy)"
);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
convention_file_plan(),
@ -2775,7 +2796,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert_eq!(
fs.writes_ending_with(CLAUDE_SEED_REL).len(),
@ -2789,7 +2813,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
#[tokio::test]
async fn projection_falls_back_to_codex_from_structured_adapter() {
let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
assert!(
profile.projector.is_none(),
"no explicit projector (legacy)"
);
let (launch, agent, fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
@ -2799,7 +2826,10 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert_eq!(
fs.writes_ending_with(CODEX_CONFIG_REL).len(),
@ -2821,7 +2851,10 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
/// no projection at all, even with a full registry and a posed policy.
#[tokio::test]
async fn projection_noop_for_unprojectable_profile() {
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap());
let profile = profile(
pid(9),
ContextInjection::convention_file("GEMINI.md").unwrap(),
);
let (launch, agent, fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
@ -2831,7 +2864,10 @@ async fn projection_noop_for_unprojectable_profile() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert!(fs.writes_ending_with(CLAUDE_SEED_REL).is_empty());
assert!(fs.writes_ending_with(CODEX_CONFIG_REL).is_empty());
@ -2849,8 +2885,11 @@ async fn projection_noop_for_unprojectable_profile() {
/// inversion vs. the MCP non-clobbering regime (which skips when the file exists).
#[tokio::test]
async fn claude_replace_seed_is_clobbered_on_relaunch() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
.with_projector(ProjectorKey::Claude);
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, sessions) = launch_with_projection(
profile,
convention_file_plan(),
@ -2864,9 +2903,15 @@ async fn claude_replace_seed_is_clobbered_on_relaunch() {
fs.mark_existing(&seed_path);
// First launch, then simulate the agent exiting so a fresh relaunch is allowed.
launch.execute(launch_input(agent.id)).await.expect("launch 1");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 1");
sessions.remove(&sid(777));
launch.execute(launch_input(agent.id)).await.expect("launch 2");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 2");
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
assert_eq!(
@ -2906,7 +2951,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
);
// First projection.
launch.execute(launch_input(agent.id)).await.expect("launch 1");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 1");
let first = String::from_utf8(
fs.writes_ending_with(CODEX_CONFIG_REL)
.last()
@ -2915,8 +2963,14 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
.clone(),
)
.unwrap();
assert!(first.contains("user_key = \"keep-me\""), "unmanaged key preserved: {first}");
assert!(first.contains("[mcp_servers.idea]"), "unmanaged table preserved: {first}");
assert!(
first.contains("user_key = \"keep-me\""),
"unmanaged key preserved: {first}"
);
assert!(
first.contains("[mcp_servers.idea]"),
"unmanaged table preserved: {first}"
);
assert!(
first.contains("sandbox_mode = \"workspace-write\""),
"managed sandbox_mode upserted: {first}"
@ -2928,7 +2982,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
// Second projection (relaunch): managed keys are replaced in place, not dup'd.
sessions.remove(&sid(777));
launch.execute(launch_input(agent.id)).await.expect("launch 2");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 2");
let second = String::from_utf8(
fs.writes_ending_with(CODEX_CONFIG_REL)
.last()
@ -2947,7 +3004,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
1,
"idempotent: no duplicate approval_policy: {second}"
);
assert!(second.contains("user_key = \"keep-me\""), "unmanaged key still preserved");
assert!(
second.contains("user_key = \"keep-me\""),
"unmanaged key still preserved"
);
}
// ---- (4) args/env fold into the spawned spec --------------------------------
@ -2966,18 +3026,34 @@ async fn codex_projection_folds_args_into_spawn_spec() {
Some(perm_doc(Posture::Ask)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let args = &pty.spawns()[0].args;
// Ask ⇒ workspace-write / on-request, in CLI order.
let pos = args
.windows(2)
.position(|w| w == ["--sandbox".to_owned(), "workspace-write".to_owned()]);
assert!(pos.is_some(), "expected --sandbox workspace-write in {args:?}");
assert!(
pos.is_some(),
"expected --sandbox workspace-write in {args:?}"
);
let pos2 = args
.windows(2)
.position(|w| w == ["--ask-for-approval".to_owned(), "on-request".to_owned()]);
assert!(pos2.is_some(), "expected --ask-for-approval on-request in {args:?}");
assert!(
pos2.is_some(),
"expected --ask-for-approval on-request in {args:?}"
);
let add_dir = args
.windows(2)
.position(|w| w == ["--add-dir".to_owned(), "/home/me/proj".to_owned()]);
assert!(
add_dir.is_some(),
"expected --add-dir project root in {args:?}"
);
}
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
@ -2998,13 +3074,19 @@ async fn codex_sandbox_projected_without_any_mcp_capability() {
Some(perm_doc(Posture::Deny)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
// The sandbox config WAS written despite the absent MCP capability.
let cfg = fs.writes_ending_with(CODEX_CONFIG_REL);
assert_eq!(cfg.len(), 1, "sandbox config projected without MCP");
let toml = String::from_utf8(cfg[0].1.clone()).unwrap();
assert!(toml.contains("sandbox_mode = \"read-only\""), "Deny ⇒ read-only: {toml}");
assert!(
toml.contains("sandbox_mode = \"read-only\""),
"Deny ⇒ read-only: {toml}"
);
// And the args were folded too.
assert!(
pty.spawns()[0]
@ -3022,8 +3104,11 @@ async fn codex_sandbox_projected_without_any_mcp_capability() {
/// even with a posed policy.
#[tokio::test]
async fn no_registry_means_no_projection() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
.with_projector(ProjectorKey::Claude);
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
convention_file_plan(),
@ -3031,7 +3116,10 @@ async fn no_registry_means_no_projection() {
Some(perm_doc(Posture::Deny)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert!(
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
@ -3043,8 +3131,11 @@ async fn no_registry_means_no_projection() {
/// written, even though the registry IS wired.
#[tokio::test]
async fn no_policy_posed_means_empty_projection() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
.with_projector(ProjectorKey::Claude);
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
convention_file_plan(),
@ -3052,7 +3143,10 @@ async fn no_policy_posed_means_empty_projection() {
Some(ProjectPermissions::default()), // project_defaults = None ⇒ resolve_for == None
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert!(
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
@ -3066,8 +3160,11 @@ async fn no_policy_posed_means_empty_projection() {
/// projector and is reflected in the produced Claude settings (`defaultMode=plan`).
#[tokio::test]
async fn resolved_deny_posture_reflected_as_plan_mode() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
.with_projector(ProjectorKey::Claude);
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
convention_file_plan(),
@ -3075,7 +3172,10 @@ async fn resolved_deny_posture_reflected_as_plan_mode() {
Some(perm_doc(Posture::Deny)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let seed = String::from_utf8(fs.writes_ending_with(CLAUDE_SEED_REL)[0].1.clone()).unwrap();
let json: serde_json::Value = serde_json::from_str(&seed).expect("valid settings JSON");

View File

@ -76,6 +76,7 @@ impl FakeContexts {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
saves: 0,
@ -1058,7 +1059,10 @@ async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
"the orphan .claude seed must be removed; removed={:?}",
f.fs.removed()
);
assert!(!f.fs.has_file(&seed_path), "the seed is gone from the FS state");
assert!(
!f.fs.has_file(&seed_path),
"the seed is gone from the FS state"
);
// The relaunch projected the Codex sandbox config + args.
assert!(
@ -1066,7 +1070,10 @@ async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
"the relaunch must project the Codex config"
);
let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap();
assert!(toml.contains("sandbox_mode = \"workspace-write\""), "{toml}");
assert!(
toml.contains("sandbox_mode = \"workspace-write\""),
"{toml}"
);
assert!(
f.pty.last_spawn_args().contains(&"--sandbox".to_owned()),
"sandbox args folded into the relaunch spawn: {:?}",
@ -1102,7 +1109,10 @@ async fn swap_claude_to_claude_does_not_remove_seed() {
f.fs.removed()
);
// It is still present (re-clobbered by the relaunch's projection).
assert!(f.fs.has_file(&seed_path), "the seed survives the same-family swap");
assert!(
f.fs.has_file(&seed_path),
"the seed survives the same-family swap"
);
}
/// (3) **Codex→Claude**: Codex owns no Replace file ⇒ nothing is removed on the
@ -1138,13 +1148,19 @@ async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
f.fs.removed()
);
// The co-owned Codex config is untouched by cleanup…
assert!(f.fs.has_file(&codex_path), "the .codex/config.toml is never deleted");
assert!(
f.fs.has_file(&codex_path),
"the .codex/config.toml is never deleted"
);
assert!(
!f.fs.removed().iter().any(|p| p == &codex_path),
"the .codex/config.toml is not in the removed list"
);
// …and the relaunch projected the new Claude seed.
assert!(f.fs.has_file(&seed_path), "the relaunch writes the Claude seed");
assert!(
f.fs.has_file(&seed_path),
"the relaunch writes the Claude seed"
);
}
/// (4a) **No-op (no registry)**: without a projector registry wired on the swap,
@ -1153,7 +1169,8 @@ async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
async fn swap_without_registry_removes_nothing() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
// The default fixture wires NO projector registry on the swap.
let f = fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await;
let f =
fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await;
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
f.fs.put(&seed_path, b"{\"permissions\":{}}");
@ -1251,7 +1268,8 @@ async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
let run_dir = run_dir_of(&agent.id);
f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}");
let pair = pair_uuid();
f.handoffs.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif"));
f.handoffs
.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif"));
let host = nid(1);
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
@ -1273,7 +1291,10 @@ async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
// …and the pair id was preserved on the persisted leaf (P8d invariant).
let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted");
assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved");
assert!(!running, "engine running flag reset by invalidate_engine_link");
assert!(
!running,
"engine running flag reset by invalidate_engine_link"
);
// …and the handoff (keyed by that pair id) was re-injected into the new engine's
// convention file — proving the relaunch threaded the preserved pair id. (The

View File

@ -258,8 +258,9 @@ async fn stream_without_final_does_not_mark_idle_and_is_io_error() {
#[tokio::test]
async fn send_error_is_propagated_and_no_mark_idle() {
let agent = aid(5);
let session =
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
let session = ScriptedSession::new(Script::Err(AgentSessionError::Decode(
"bad json".to_owned(),
)));
let mediator = RecordingMediator::new();
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
@ -322,8 +323,14 @@ async fn timeout_returns_timeout_no_mark_idle_session_alive() {
};
let mediator = RecordingMediator::new();
let out =
drain_with_readiness(&session, "x", Some(Duration::from_millis(20)), &mediator, agent).await;
let out = drain_with_readiness(
&session,
"x",
Some(Duration::from_millis(20)),
&mediator,
agent,
)
.await;
assert_eq!(out, Err(AgentSessionError::Timeout));
assert_eq!(
mediator.mark_idle_count(agent),

View File

@ -143,6 +143,7 @@ impl FakeContexts {
manifest: Arc::new(Mutex::new(AgentManifest {
version: 1,
entries,
orchestrator: None,
})),
fail: false,
}
@ -152,6 +153,7 @@ impl FakeContexts {
manifest: Arc::new(Mutex::new(AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
})),
fail: true,
}

View File

@ -61,6 +61,7 @@ impl FakeContexts {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
})))

View File

@ -21,7 +21,7 @@ use domain::project::ProjectPath;
use application::{
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput,
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
};
// ---------------------------------------------------------------------------
@ -366,6 +366,11 @@ fn catalogue_has_expected_commands_and_injection() {
target: "AGENTS.md".to_owned()
}
);
assert_eq!(
by_command["codex"].submit_delay_ms,
Some(CODEX_SUBMIT_DELAY_MS),
"Codex TUI needs a conservative text→submit delay for delegated prompts"
);
assert_eq!(
by_command["gemini"].context_injection,
ContextInjection::ConventionFile {

View File

@ -133,10 +133,12 @@ impl AgentResumer for FakeResumer {
conversation_id: Option<String>,
resume_prompt: &str,
) -> Result<(), AppError> {
self.calls
.lock()
.unwrap()
.push((agent_id, node_id, conversation_id, resume_prompt.to_owned()));
self.calls.lock().unwrap().push((
agent_id,
node_id,
conversation_id,
resume_prompt.to_owned(),
));
if self.fail.load(Ordering::SeqCst) {
Err(AppError::Internal("reprise échouée (fake)".to_owned()))
} else {
@ -252,7 +254,10 @@ fn on_rate_limited_without_reset_is_human_fallback_no_arm() {
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), None);
assert!(env.scheduler.armed().is_empty(), "aucun arm sans heure de reset");
assert!(
env.scheduler.armed().is_empty(),
"aucun arm sans heure de reset"
);
assert!(env.scheduler.cancels().is_empty(), "aucun cancel non plus");
assert_eq!(
env.bus.events(),
@ -284,7 +289,11 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
// Deux arms (un par signal), ids distincts.
let issued = env.scheduler.issued();
assert_eq!(issued.len(), 2, "deux arms (rafraîchissement, pas empilement)");
assert_eq!(
issued.len(),
2,
"deux arms (rafraîchissement, pas empilement)"
);
// Le premier id émis a été annulé par le dédoublonnage du 2ᵉ signal.
assert_eq!(
env.scheduler.cancels(),
@ -316,8 +325,12 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
let env = env_at(NOW);
// Arme d'abord (pour prouver que l'entrée est ensuite retirée).
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
let task = ScheduledTask::ResumeAgent {
agent_id: aid(1),
@ -329,7 +342,12 @@ async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
// Le resumer a vu exactement l'appel attendu, avec le prompt constant.
assert_eq!(
env.resumer.calls(),
vec![(aid(1), nid(2), Some("conv-1".to_owned()), RESUME_PROMPT.to_owned())]
vec![(
aid(1),
nid(2),
Some("conv-1".to_owned()),
RESUME_PROMPT.to_owned()
)]
);
// AgentResumed publié.
@ -364,11 +382,13 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
.execute_resume(task)
.await
.expect_err("la reprise doit échouer");
assert!(matches!(err, AppError::Internal(_)), "erreur propagée: {err:?}");
assert!(
matches!(err, AppError::Internal(_)),
"erreur propagée: {err:?}"
);
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumed { .. })),
@ -385,11 +405,18 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
#[test]
fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
let env = env_at(NOW);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
let issued = env.scheduler.issued();
assert!(env.service.cancel_resume(aid(1)), "cancel d'un réveil armé ⇒ true");
assert!(
env.service.cancel_resume(aid(1)),
"cancel d'un réveil armé ⇒ true"
);
// Le bon ScheduleId a été passé au Scheduler.
assert_eq!(env.scheduler.cancels(), vec![issued[0]]);
// AgentResumeCancelled publié.
@ -408,7 +435,10 @@ fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
fn cancel_resume_without_arm_is_false_no_event() {
let env = env_at(NOW);
assert!(!env.service.cancel_resume(aid(1)));
assert!(env.scheduler.cancels().is_empty(), "Scheduler non sollicité");
assert!(
env.scheduler.cancels().is_empty(),
"Scheduler non sollicité"
);
assert!(env.bus.events().is_empty(), "aucun event");
}
@ -417,8 +447,12 @@ fn cancel_resume_without_arm_is_false_no_event() {
#[test]
fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
let env = env_at(NOW);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
// Simule un réveil déjà tiré : cancel renvoie false.
env.scheduler.set_cancel_result(false);
@ -430,8 +464,7 @@ fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
assert_eq!(env.scheduler.cancels().len(), 1);
// AUCUN AgentResumeCancelled (pas d'event trompeur).
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
@ -491,8 +524,7 @@ fn confirm_human_resume_future_arms_and_emits_in_order() {
fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
let env = env_at(NOW);
let past = NOW - 30_000;
env.service
.confirm_human_resume(aid(1), nid(2), None, past);
env.service.confirm_human_resume(aid(1), nid(2), None, past);
let armed = env.scheduler.armed();
assert_eq!(armed.len(), 1);
@ -521,13 +553,21 @@ fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
#[test]
fn confirm_human_resume_after_auto_dedups_single_active_arm() {
let env = env_at(NOW);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
env.service
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000);
let issued = env.scheduler.issued();
assert_eq!(issued.len(), 2, "deux arms (auto puis humain), pas d'empilement");
assert_eq!(
issued.len(),
2,
"deux arms (auto puis humain), pas d'empilement"
);
assert_eq!(
env.scheduler.cancels(),
vec![issued[0]],
@ -536,8 +576,7 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
// Aucun AgentResumeCancelled (dédoublonnage interne, silencieux).
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
@ -545,7 +584,10 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
);
// Unicité de l'armement actif : un seul cancel_resume aboutit.
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
assert!(
env.service.cancel_resume(aid(1)),
"un armement actif unique ⇒ true"
);
assert!(
!env.service.cancel_resume(aid(1)),
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
@ -560,11 +602,19 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
let env = env_at(NOW);
env.service
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 120_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 120_000),
);
let issued = env.scheduler.issued();
assert_eq!(issued.len(), 2, "deux arms (humain puis auto), pas d'empilement");
assert_eq!(
issued.len(),
2,
"deux arms (humain puis auto), pas d'empilement"
);
assert_eq!(
env.scheduler.cancels(),
vec![issued[0]],
@ -572,15 +622,17 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
);
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
"le dédoublonnage croisé n'émet PAS AgentResumeCancelled"
);
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
assert!(
env.service.cancel_resume(aid(1)),
"un armement actif unique ⇒ true"
);
assert!(
!env.service.cancel_resume(aid(1)),
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"

View File

@ -91,6 +91,7 @@ impl FakeContexts {
Self(Arc::new(Mutex::new(AgentManifest {
version: 1,
entries,
orchestrator: None,
})))
}
fn manifest(&self) -> AgentManifest {

View File

@ -73,6 +73,7 @@ impl FakeContexts {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
})));
@ -1111,6 +1112,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
let ctx = PreparedContext {
content: MarkdownDoc::new("# persona"),
relative_path: "agents/backend.md".to_owned(),
project_root: ROOT.to_owned(),
};
let cwd = ProjectPath::new(ROOT).unwrap();
let session = f

View File

@ -72,6 +72,7 @@ impl FakeContexts {
AgentManifest {
version: 1,
entries,
orchestrator: None,
},
HashMap::new(),
))))