feat(permissions): voie projection CLI (LP0→LP3) + checkpoint Codex/input

Jalon vert regroupant deux chantiers entrelacés dans le working tree,
indissociables au niveau fichier mais tous deux verts (cargo test
--workspace + tests frontend permissions au vert).

Permissions — voie « projection CLI » (advisory), complète :
- LP0 domaine pur : modèle PermissionSet/EffectivePermissions, resolve
  deny-wins + postures Allow<Ask<Deny (crates/domain/src/permission.rs).
- LP1 store : FsPermissionStore (.ideai/permissions.json).
- LP2 use cases : Get/Update project, Update agent override, Resolve.
- LP3 projecteurs Claude/Codex (settings.local.json / config.toml),
  câblage launch-path + PermissionProjectorRegistry, nettoyage des
  fichiers Replace orphelins au swap de profil (LP3-4), composition root
  + commandes Tauri, UI PermissionsPanel (projet + override agent).
- ports.rs : PermissionStore + FileSystem::remove_file (cleanup au swap).

Reste ouvert (hors scope, marqué dans le code) : LP4 enforcement OS
airtight (Landlock fichiers) + résumé de permissions injecté.

Inclut aussi le chantier Codex/input/sessions structurées en cours
(McpConfigStrategy, StructuredAdapter, gestion d'input) partageant les
mêmes fichiers (lifecycle.rs, commands.rs, dto.rs, state.rs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:39:18 +02:00
parent 46492506e1
commit 27597eb64e
41 changed files with 6513 additions and 269 deletions

View File

@ -44,9 +44,57 @@ struct BusyTracker {
/// stagnation issu du profil, et état de vivacité courant pour n'émettre
/// `AgentLivenessChanged` qu'**une fois par transition** (pas de spam).
liveness: Mutex<HashMap<AgentId, LivenessState>>,
/// **Démarrage à froid** (fix race cold-launch) : ensemble des agents fraîchement
/// lancés à froid pour lesquels la livraison du **premier** tour doit être *gatée*
/// sur le prompt-ready watcher. Un agent y est inscrit par
/// [`BusyTracker::mark_starting`] (uniquement quand un watcher est effectivement
/// armé), puis consommé par l'`enqueue` qui démarre le tour : la `DelegationReady`
/// est alors **différée** dans `deferred` au lieu d'être publiée immédiatement (le
/// CLI n'a pas encore affiché son prompt). Vide ⇒ comportement chaud inchangé.
starting: Mutex<HashSet<AgentId>>,
/// **Tour différé** (fix race cold-launch) : payload de la `DelegationReady` retenue
/// pour un démarrage à froid, publiée par [`BusyTracker::prompt_ready`] à l'apparition
/// du prompt (jamais avant). Absent ⇒ aucun tour en attente de gate.
deferred: Mutex<HashMap<AgentId, DeferredDelegation>>,
events: Option<Arc<dyn EventBus>>,
/// Sink de livraison headless (cf. [`HeadlessSink`]). Câblé par
/// [`MediatedInbox::with_pty`]/[`MediatedInbox::with_events`] quand un PTY est
/// présent ; `None` ⇒ toute livraison passe par l'événement (médiateur sans PTY,
/// utilisé par les tests événementiels — comportement historique).
headless_sink: Option<HeadlessSink>,
}
/// Payload d'une [`DomainEvent::DelegationReady`] **différée** le temps qu'un agent
/// lancé à froid atteigne son prompt (fix race cold-launch). Reconstruite à l'identique
/// quand le prompt-ready watcher déclenche, de sorte que le premier tour soit livré au
/// bon moment (et un seul `DelegationReady`, comme pour un agent chaud).
#[derive(Debug, Clone)]
struct DeferredDelegation {
ticket: TicketId,
text: String,
submit_sequence: Option<String>,
submit_delay_ms: Option<u32>,
}
/// Sink de livraison « headless » optionnel branché sur le [`BusyTracker`].
///
/// Appelé pour **chaque** tour à livrer (chemin chaud immédiat comme drains à froid).
/// Reçoit l'agent + le payload de délégation et décide :
/// - `None` ⇒ il a **pris en charge** la livraison (écriture directe dans le PTY d'un
/// agent headless qui n'a pas de cellule frontend) ;
/// - `Some(d)` ⇒ il **rend la main** : le tracker publie alors le `DelegationReady`
/// normal (cellule frontend présente ⇒ c'est le write-portal qui écrira, ou pas de
/// PTY/handle disponible ⇒ repli sur l'événement, comportement historique).
type HeadlessSink =
Arc<dyn Fn(AgentId, DeferredDelegation) -> Option<DeferredDelegation> + Send + Sync>;
/// Délai (ms) entre l'écriture du texte de la tâche et celle de la séquence de
/// soumission lors d'une livraison **headless** (le médiateur écrit lui-même le PTY).
/// Aligné sur le défaut du write-portal frontend (`DEFAULT_SUBMIT_DELAY_MS`) : sépare
/// la soumission du collage pour esquiver la paste-detection des CLI. Utilisé seulement
/// quand le profil de la cible ne fournit pas de `submit_delay_ms`.
const DEFAULT_SUBMIT_DELAY_MS: u32 = 60;
/// État de vivacité d'un agent maintenu par le [`BusyTracker`] (lot 2).
///
/// Pur (aucune I/O) : un timestamp `last_seen_ms` rafraîchi à chaque battement, le
@ -68,10 +116,36 @@ impl BusyTracker {
Self {
busy: Mutex::new(HashMap::new()),
liveness: Mutex::new(HashMap::new()),
starting: Mutex::new(HashSet::new()),
deferred: Mutex::new(HashMap::new()),
events,
headless_sink: None,
}
}
fn lock_starting(&self) -> std::sync::MutexGuard<'_, HashSet<AgentId>> {
self.starting
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn lock_deferred(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, DeferredDelegation>> {
self.deferred
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Marque un agent **en démarrage à froid** : son tout premier tour devra être
/// *gaté* sur le prompt-ready watcher (la `DelegationReady` sera différée à
/// l'apparition du prompt). À n'appeler **que** lorsqu'un watcher est effectivement
/// armé (un `prompt_ready_pattern` non vide est configuré), sinon le premier tour
/// resterait bloqué indéfiniment (aucun signal ne viendrait le libérer). Sans cet
/// appel, l'`enqueue` publie la `DelegationReady` immédiatement (chemin chaud,
/// zéro régression).
fn mark_starting(&self, agent: AgentId) {
self.lock_starting().insert(agent);
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, AgentBusyState>> {
self.busy
.lock()
@ -192,6 +266,62 @@ impl BusyTracker {
}
}
/// Publie une [`DomainEvent::DelegationReady`] depuis un payload différé (si un bus
/// est câblé). Utilisée pour livrer le **premier** tour d'un agent froid au moment
/// où son prompt apparaît.
fn publish_deferred(&self, agent: AgentId, d: DeferredDelegation) {
// Point de livraison unique (tours chauds immédiats ET drains à froid). Si un
// sink headless est câblé, il a la priorité : pour un agent sans cellule
// frontend il écrit lui-même la tâche dans le PTY et renvoie `None` (pris en
// charge) ; sinon il renvoie `Some(d)` et on retombe sur l'événement.
let d = match &self.headless_sink {
Some(sink) => match sink(agent, d) {
Some(d) => d,
None => return,
},
None => d,
};
if let Some(events) = &self.events {
events.publish(DomainEvent::DelegationReady {
agent_id: agent,
ticket: d.ticket,
text: d.text,
submit_sequence: d.submit_sequence,
submit_delay_ms: d.submit_delay_ms,
});
}
}
/// Signal **prompt-ready** émis par le watcher à l'apparition du marqueur.
///
/// - Si un tour de démarrage à froid est **différé** pour cet agent : c'est le
/// moment de le livrer ⇒ on publie la `DelegationReady` retenue et l'agent **reste
/// Busy** (son premier tour court désormais réellement). Le tour ne se terminera
/// que sur un `idea_reply` ou un prochain prompt-ready (qui, lui, fera `mark_idle`).
/// - Sinon : comportement historique ⇒ `mark_idle` (fait avancer la FIFO).
fn prompt_ready(&self, agent: AgentId) {
// On ne retire l'agent de `starting` qu'ici : tant que son premier tour n'a pas
// été livré, un re-arming éventuel doit rester gaté.
self.lock_starting().remove(&agent);
let deferred = self.lock_deferred().remove(&agent);
match deferred {
Some(d) => self.publish_deferred(agent, d),
None => self.mark_idle(agent),
}
}
/// Libère un premier tour différé sur **readiness de démarrage** (connexion du pont
/// MCP de l'agent). Draine le `DeferredDelegation` retenu s'il existe (et retire
/// l'agent de `starting`) ; sinon no-op. **Pas de `mark_idle`** (signal de démarrage,
/// pas de fin de tour). Idempotent et OR-safe avec `prompt_ready` (le `remove` ne rend
/// `Some` qu'une fois).
fn release_cold_start(&self, agent: AgentId) {
self.lock_starting().remove(&agent);
if let Some(d) = self.lock_deferred().remove(&agent) {
self.publish_deferred(agent, d);
}
}
/// Marks `agent` `Idle`, publishing `AgentBusyChanged{busy:false}` only on a real
/// `Busy→Idle` transition. Idempotent: a `mark_idle` on an already-idle agent is a
/// no-op and emits nothing.
@ -256,7 +386,15 @@ pub struct MediatedInbox {
/// observe an agent's output stream for prompt-ready detection (lot C5).
pty: Option<Arc<dyn PtyPort>>,
/// Per-agent live input handle (one stream per agent), fed by `bind_handle`.
handles: Mutex<HashMap<AgentId, PtyHandle>>,
/// `Arc` so the headless delivery sink (wired into the [`BusyTracker`]) can read it
/// to resolve an agent's PTY handle when it must write the turn itself.
handles: Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
/// Agents qui ont une **cellule terminal frontend montée** (write-portal actif),
/// tenu à jour par [`InputMediator::set_front_attached`]. Quand un agent y figure,
/// la livraison passe par l'événement `DelegationReady` (le front écrit) ; sinon
/// (agent headless / délégué en arrière-plan) le médiateur écrit lui-même le tour
/// dans le PTY. `Arc` car le sink headless du tracker le consulte.
front_owned: Arc<Mutex<HashSet<AgentId>>>,
/// Per-agent submit config (target profile's `submit_sequence`/`submit_delay_ms`),
/// stashed at bind time (§20.3) and echoed on the `DelegationReady` event when a
/// turn starts. Absent ⇒ both `None` (the front applies its defaults).
@ -276,24 +414,103 @@ impl MediatedInbox {
/// turn delivery (the orchestrator writes the turn itself).
#[must_use]
pub fn new(mailbox: Arc<InMemoryMailbox>, clock: Arc<dyn MillisClock>) -> Self {
let handles = Arc::new(Mutex::new(HashMap::new()));
let front_owned = Arc::new(Mutex::new(HashSet::new()));
Self {
mailbox,
tracker: Arc::new(BusyTracker::new(None)),
tracker: Self::build_tracker(None, None, &handles, &front_owned),
clock,
pty: None,
handles: Mutex::new(HashMap::new()),
handles,
front_owned,
submit: Mutex::new(HashMap::new()),
stall: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Construit le [`BusyTracker`] avec, quand un PTY est présent, le **sink headless**
/// branché (cf. [`HeadlessSink`]). Sans PTY ⇒ aucun sink (livraison par événement).
fn build_tracker(
events: Option<Arc<dyn EventBus>>,
pty: Option<&Arc<dyn PtyPort>>,
handles: &Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
front_owned: &Arc<Mutex<HashSet<AgentId>>>,
) -> Arc<BusyTracker> {
let mut tracker = BusyTracker::new(events);
if let Some(pty) = pty {
tracker.headless_sink = Some(Self::make_headless_sink(
Arc::clone(pty),
Arc::clone(handles),
Arc::clone(front_owned),
));
}
Arc::new(tracker)
}
/// Fabrique le sink de livraison headless : pour un agent **sans cellule frontend**
/// (absent de `front_owned`), écrit la tâche dans son PTY (texte, puis — après le
/// délai anti-paste-detection — la séquence de soumission) et renvoie `None` (pris
/// en charge). Pour un agent avec cellule, ou sans handle PTY connu, renvoie
/// `Some(d)` ⇒ le tracker publie l'événement `DelegationReady` comme avant.
fn make_headless_sink(
pty: Arc<dyn PtyPort>,
handles: Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
front_owned: Arc<Mutex<HashSet<AgentId>>>,
) -> HeadlessSink {
Arc::new(move |agent: AgentId, d: DeferredDelegation| {
// Cellule frontend montée ⇒ c'est le write-portal qui écrit (et qui sait
// composer avec une saisie humaine en cours). On rend la main.
if front_owned
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(&agent)
{
return Some(d);
}
// Agent headless : récupérer son handle PTY. Absent ⇒ repli sur l'événement
// (best-effort, ne devrait pas arriver pour un agent qui livre un tour).
let handle = handles
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&agent)
.cloned();
let Some(handle) = handle else {
return Some(d);
};
let pty = Arc::clone(&pty);
let text = d.text;
let submit = d.submit_sequence.unwrap_or_else(|| "\r".to_owned());
let delay = u64::from(d.submit_delay_ms.unwrap_or(DEFAULT_SUBMIT_DELAY_MS));
// Écriture sur un thread détaché : ne JAMAIS bloquer l'appelant (thread du
// watcher prompt-ready, tâche tokio du serveur MCP, ou le fil de l'enqueue).
// Texte d'abord, puis la séquence de soumission après le délai — comme le
// write-portal frontend — pour esquiver la détection de coller des CLI.
std::thread::spawn(move || {
let _ = pty.write(&handle, text.as_bytes());
if delay > 0 {
std::thread::sleep(std::time::Duration::from_millis(delay));
}
let _ = pty.write(&handle, submit.as_bytes());
});
None
})
}
/// Wires an [`EventBus`] so busy/idle transitions publish
/// [`DomainEvent::AgentBusyChanged`] at their source (cadrage C4 §4.2). Builder
/// additive: callers that do not wire a bus stay silent.
#[must_use]
pub fn with_events(mut self, events: Arc<dyn EventBus>) -> Self {
self.tracker = Arc::new(BusyTracker::new(Some(events)));
// Reconstruit le tracker avec le bus ET — si un PTY est déjà câblé (cas
// `with_pty().with_events()` du composition root) — le sink headless, sinon
// l'ordre des builders perdrait la livraison headless.
self.tracker = Self::build_tracker(
Some(events),
self.pty.as_ref(),
&self.handles,
&self.front_owned,
);
self
}
@ -306,12 +523,16 @@ impl MediatedInbox {
clock: Arc<dyn MillisClock>,
pty: Arc<dyn PtyPort>,
) -> Self {
let handles = Arc::new(Mutex::new(HashMap::new()));
let front_owned = Arc::new(Mutex::new(HashSet::new()));
let pty_opt = Some(pty);
Self {
mailbox,
tracker: Arc::new(BusyTracker::new(None)),
tracker: Self::build_tracker(None, pty_opt.as_ref(), &handles, &front_owned),
clock,
pty: Some(pty),
handles: Mutex::new(HashMap::new()),
pty: pty_opt,
handles,
front_owned,
submit: Mutex::new(HashMap::new()),
stall: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
@ -409,8 +630,10 @@ impl MediatedInbox {
for chunk in stream {
window.extend_from_slice(&chunk);
if window.windows(needle.len()).any(|w| w == needle.as_slice()) {
// Prompt-ready: first OR signal wins ⇒ Idle (advances the FIFO).
tracker.mark_idle(agent);
// Prompt-ready: premier signal OR gagnant. Si un premier tour froid
// est différé ⇒ on le **livre** ici (et l'agent reste Busy) ; sinon
// ⇒ `mark_idle` (fait avancer la FIFO). Voir [`BusyTracker::prompt_ready`].
tracker.prompt_ready(agent);
break;
}
if window.len() > keep {
@ -476,21 +699,32 @@ impl InputMediator for MediatedInbox {
busy: true,
});
let submit = self.submit().get(&agent).cloned().unwrap_or_default();
events.publish(DomainEvent::DelegationReady {
agent_id: agent,
// Préfixe de délégation : c'est le **signal** qui dit à la cible
// « ceci est une tâche IdeA, réponds via `idea_reply` (jamais en
// texte) en renvoyant ce `ticket` ». Sans lui la cible traite la
// ligne comme une invite humaine et répond dans le terminal, et
// l'agent demandeur reste bloqué jusqu'au timeout. La tâche brute
// reste dans le `Ticket` (mailbox/historique) ; seul le texte livré
// au PTY porte le préfixe. Format aligné sur l'instruction injectée
// dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`).
let text = delegation_preamble(&ticket.requester, ticket_id, &ticket.task);
// **Fix race cold-launch** : si l'agent est en démarrage à froid (inscrit
// par `mark_starting` quand un watcher est armé), ON DIFFÈRE la
// `DelegationReady` jusqu'au prompt-ready (le CLI n'a pas encore affiché
// son prompt — la livrer maintenant perdrait le premier tour). Le watcher
// la publiera via `prompt_ready`. Agent chaud (pas dans `starting`) ⇒
// publication immédiate, comportement inchangé.
let deferred = DeferredDelegation {
ticket: ticket_id,
// Préfixe de délégation : c'est le **signal** qui dit à la cible
// « ceci est une tâche IdeA, réponds via `idea_reply` (jamais en
// texte) en renvoyant ce `ticket` ». Sans lui la cible traite la
// ligne comme une invite humaine et répond dans le terminal, et
// l'agent demandeur reste bloqué jusqu'au timeout. La tâche brute
// reste dans le `Ticket` (mailbox/historique) ; seul le texte livré
// au PTY porte le préfixe. Format aligné sur l'instruction injectée
// dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`).
text: delegation_preamble(&ticket.requester, ticket_id, &ticket.task),
text,
submit_sequence: submit.sequence,
submit_delay_ms: submit.delay_ms,
});
};
if self.tracker.lock_starting().contains(&agent) {
self.tracker.lock_deferred().insert(agent, deferred);
} else {
self.tracker.publish_deferred(agent, deferred);
}
}
}
self.mailbox.enqueue(agent, ticket)
@ -523,6 +757,30 @@ impl InputMediator for MediatedInbox {
self.pty.is_some() && self.handles().get(&agent).is_some()
}
fn mark_starting(&self, agent: AgentId) {
// Gate du premier tour d'un agent froid : appelé par l'orchestrateur juste après
// un (re)lancement à froid, AVANT le bind/enqueue, et uniquement si un watcher
// sera armé (cf. doc du trait). Consommé par l'`enqueue` qui démarre le tour
// (diffère la `DelegationReady`) puis par `prompt_ready` (la libère).
self.tracker.mark_starting(agent);
}
fn release_cold_start(&self, agent: AgentId) {
self.tracker.release_cold_start(agent);
}
fn set_front_attached(&self, agent: AgentId, attached: bool) {
let mut front = self
.front_owned
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if attached {
front.insert(agent);
} else {
front.remove(&agent);
}
}
fn preempt(&self, agent: AgentId) {
// Interrompre: signals the running turn to stop. It is NOT an enqueue and
// correlates **no** ticket (we never pop/resolve a pending caller — preempt
@ -761,6 +1019,9 @@ mod tests {
None,
SubmitConfig::new(Some("\r".to_owned()), Some(42)),
);
// A frontend cell is mounted ⇒ delivery flows through the event (the front
// writes). This test asserts the event carries the bound submit config.
inbox.set_front_attached(a, true);
inbox.enqueue(a, ticket(10, "task"));
let ready = bus.delegation_ready();
@ -786,24 +1047,71 @@ mod tests {
}
#[test]
fn enqueue_does_not_write_into_the_pty() {
// The whole point of §20: the backend stops writing the turn into the PTY.
fn front_attached_agent_is_delivered_via_event_not_backend_write() {
// §20 nominal: a mounted frontend cell owns the physical write. The backend
// publishes DelegationReady and writes NOTHING into the PTY itself.
let pty = Arc::new(FakePty::new());
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
Arc::clone(&pty) as Arc<dyn PtyPort>,
);
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
let h = handle(1);
inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default());
inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default());
inbox.set_front_attached(a, true);
inbox.enqueue(a, ticket(10, "do X"));
inbox.enqueue(a, ticket(11, "do Y")); // even a second enqueue must not write
assert_eq!(
bus.delegation_ready().len(),
1,
"front-attached ⇒ exactly one DelegationReady event for the write-portal"
);
// The headless path is not taken ⇒ no thread is spawned ⇒ no PTY write, ever.
assert!(
pty.writes.lock().unwrap().is_empty(),
"enqueue must perform NO pty.write (the `\\n` band-aid is gone)"
"front-attached ⇒ the backend must NOT write the PTY (the front does)"
);
}
#[test]
fn headless_agent_without_front_cell_is_written_by_the_backend() {
// Cold/background delegation target: no frontend cell is mounted, so NOBODY
// consumes DelegationReady. The mediator must therefore write the turn into the
// PTY itself (text + submit), else the agent never receives its task — the
// root cause of "a delegated agent never replies".
let pty = Arc::new(FakePty::new());
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
Arc::clone(&pty) as Arc<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
// No set_front_attached ⇒ headless.
inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default());
inbox.enqueue(a, ticket(10, "do X"));
assert!(
wait_until(|| pty.writes.lock().unwrap().len() >= 2),
"headless delivery writes the task text + submit sequence into the PTY"
);
let writes = pty.writes.lock().unwrap().clone();
assert!(
String::from_utf8_lossy(&writes[0]).contains("do X"),
"first write carries the task text"
);
assert_eq!(
writes[1], b"\r",
"second write is the default submit sequence"
);
assert!(
bus.delegation_ready().is_empty(),
"headless delivery takes over ⇒ no DelegationReady event is published"
);
}
@ -1148,12 +1456,252 @@ mod tests {
assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy");
}
// ====================================================================
// Fix race cold-launch — `mark_starting` gate le PREMIER tour sur le prompt-ready
// ====================================================================
/// (a) Cold-launch : un agent marqué `mark_starting` ne livre PAS sa
/// `DelegationReady` à l'enqueue ; elle est différée jusqu'à l'apparition du prompt
/// dans la sortie (le watcher la publie alors), au bon moment. L'agent reste Busy
/// pendant tout le gate.
#[test]
fn cold_launch_defers_first_turn_until_prompt_ready() {
let bus = Arc::new(RecordingBus::default());
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(20);
// Sortie de boot : le prompt "\n> " n'apparaît que dans le second chunk.
pty.seed(
&h,
vec![b"booting CLI...\n".to_vec(), b"ready\n> ".to_vec()],
);
let inbox = MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
Arc::clone(&pty) as Arc<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
// Cellule front montée : ce test observe la livraison via l'événement (chemin
// write-portal), orthogonal au gate cold-launch testé ici.
inbox.set_front_attached(a, true);
// Démarrage à froid : gate armé AVANT bind/enqueue (ordre de l'orchestrateur).
inbox.mark_starting(a);
inbox.enqueue(a, ticket(10, "cold task"));
// L'enqueue a démarré le tour (Busy) MAIS la DelegationReady est différée.
assert!(inbox.busy_state(a).is_busy(), "le tour démarre (Busy)");
assert!(
bus.delegation_ready().is_empty(),
"cold-launch ⇒ pas de DelegationReady tant que le prompt n'est pas prêt"
);
// On arme le watcher (le prompt "\n> " finira par apparaître dans la sortie).
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
// À l'apparition du prompt : la DelegationReady différée est publiée (1 seule),
// et l'agent reste Busy (le premier tour court désormais réellement).
assert!(
wait_until(|| bus.delegation_ready().len() == 1),
"prompt-ready ⇒ la DelegationReady différée est livrée"
);
let ready = bus.delegation_ready();
assert_eq!(
ready.len(),
1,
"exactement une DelegationReady (pas de doublon)"
);
assert!(
ready[0].1.ends_with("\ncold task"),
"le texte différé porte la tâche brute: {:?}",
ready[0].1
);
assert!(
inbox.busy_state(a).is_busy(),
"après livraison du 1er tour froid, l'agent reste Busy (idle viendra d'idea_reply)"
);
}
/// Readiness de démarrage MCP : un 1er tour différé (`mark_starting` + enqueue) reste
/// retenu tant que rien ne le libère ; `release_cold_start` (signal connexion MCP) le
/// draine ⇒ exactement UNE `DelegationReady`. Calque du gate prompt-ready, mais libéré
/// par le signal MCP (pas de watcher armé ici).
#[test]
fn release_cold_start_delivers_deferred_first_turn() {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
// Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur).
inbox.mark_starting(a);
inbox.enqueue(a, ticket(10, "cold task"));
assert!(inbox.busy_state(a).is_busy(), "le tour démarre (Busy)");
assert!(
bus.delegation_ready().is_empty(),
"cold-launch ⇒ pas de DelegationReady tant que rien ne libère"
);
// Connexion du pont MCP ⇒ libère le 1er tour différé.
inbox.release_cold_start(a);
let ready = bus.delegation_ready();
assert_eq!(
ready.len(),
1,
"release_cold_start ⇒ exactement une DelegationReady"
);
assert!(
ready[0].1.ends_with("\ncold task"),
"le texte différé porte la tâche brute: {:?}",
ready[0].1
);
}
/// `release_cold_start` sans tour différé ⇒ no-op : aucune `DelegationReady` et,
/// surtout, PAS de transition Idle (signal de DÉMARRAGE, pas de fin de tour).
#[test]
fn release_cold_start_is_noop_without_deferred() {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
// Démarre un tour normal (Busy), SANS gate cold-launch (pas de mark_starting).
inbox.enqueue(a, ticket(10, "task"));
let busy_before = bus.busy_events();
inbox.release_cold_start(a);
assert!(
bus.delegation_ready().len() == 1,
"pas de DelegationReady supplémentaire (la seule est celle de l'enqueue)"
);
assert_eq!(
bus.busy_events(),
busy_before,
"release_cold_start ne marque PAS Idle (aucun AgentBusyChanged{{busy:false}})"
);
}
/// `release_cold_start` est idempotent : un second appel ne re-draine rien ⇒ une seule
/// `DelegationReady` au total.
#[test]
fn release_cold_start_is_idempotent() {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
inbox.mark_starting(a);
inbox.enqueue(a, ticket(10, "cold task"));
inbox.release_cold_start(a);
inbox.release_cold_start(a);
assert_eq!(
bus.delegation_ready().len(),
1,
"deux release_cold_start ⇒ une seule DelegationReady"
);
}
/// (b) Agent chaud (non `mark_starting`) : la `DelegationReady` est publiée
/// IMMÉDIATEMENT à l'enqueue — non-régression du chemin existant.
#[test]
fn warm_agent_delivers_first_turn_immediately() {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
// Pas de mark_starting ⇒ agent chaud.
inbox.enqueue(a, ticket(10, "warm task"));
let ready = bus.delegation_ready();
assert_eq!(
ready.len(),
1,
"agent chaud ⇒ DelegationReady immédiate (zéro régression)"
);
assert!(ready[0].1.ends_with("\nwarm task"));
}
/// (c) Cas limite : si on ne marque PAS `starting` (l'orchestrateur n'arme pas le
/// gate quand aucun `prompt_ready_pattern` n'est configuré), l'enqueue livre la
/// `DelegationReady` immédiatement — donc PAS de blocage indéfini du premier tour.
#[test]
fn no_prompt_pattern_no_starting_gate_delivers_immediately() {
let bus = Arc::new(RecordingBus::default());
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(21);
pty.seed(&h, vec![b"output without any prompt marker\n".to_vec()]);
let inbox = MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
Arc::clone(&pty) as Arc<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
// Cellule front montée ⇒ livraison observable via l'événement.
inbox.set_front_attached(a, true);
// Cold launch SANS pattern ⇒ l'orchestrateur N'APPELLE PAS mark_starting.
inbox.enqueue(a, ticket(10, "task"));
// Premier tour livré tout de suite : aucun watcher ne viendrait le débloquer.
assert_eq!(
bus.delegation_ready().len(),
1,
"sans gate ⇒ DelegationReady immédiate (pas de blocage indéfini)"
);
// Bind sans pattern : aucun watcher armé (fallback sûr).
inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default());
std::thread::sleep(std::time::Duration::from_millis(40));
// Reste Busy (idle viendra d'idea_reply / timeout), mais le tour A bien été livré.
assert!(inbox.busy_state(a).is_busy());
assert_eq!(
bus.delegation_ready().len(),
1,
"toujours une seule livraison"
);
}
/// Après livraison du 1er tour froid, un `mark_idle` (idea_reply) puis un nouvel
/// enqueue (agent désormais chaud) repart en livraison immédiate.
#[test]
fn after_cold_first_turn_subsequent_enqueue_is_immediate() {
let bus = Arc::new(RecordingBus::default());
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(22);
pty.seed(&h, vec![b"ready\n> ".to_vec()]);
let inbox = MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
Arc::clone(&pty) as Arc<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
// Cellule front montée ⇒ livraison observable via l'événement.
inbox.set_front_attached(a, true);
inbox.mark_starting(a);
inbox.enqueue(a, ticket(10, "first"));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
assert!(wait_until(|| bus.delegation_ready().len() == 1));
// Fin du 1er tour (idea_reply) puis nouvel enqueue ⇒ livraison immédiate.
inbox.mark_idle(a);
inbox.enqueue(a, ticket(11, "second"));
let ready = bus.delegation_ready();
assert_eq!(
ready.len(),
2,
"second tour livré immédiatement (plus de gate)"
);
assert!(ready[1].1.ends_with("\nsecond"));
}
// ====================================================================
// Lot 2 — détection de stagnation (last_seen / sweep_stalled / liveness)
// ====================================================================
use std::sync::atomic::{AtomicU64, Ordering};
use domain::input::AgentLiveness;
use std::sync::atomic::{AtomicU64, Ordering};
/// Horloge millis **mutable** (atomique) pour piloter le temps dans les tests de
/// stagnation sans horloge réelle.
@ -1211,7 +1759,11 @@ mod tests {
// Dans la fenêtre : pas de transition.
clock.set(1_000 + 30_000);
inbox.sweep_stalled();
assert_eq!(bus.liveness_events(), vec![], "à la limite exacte, pas encore stalled");
assert_eq!(
bus.liveness_events(),
vec![],
"à la limite exacte, pas encore stalled"
);
// Au-delà du seuil : exactement une transition Stalled.
clock.set(1_000 + 30_001);