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