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>
323 lines
15 KiB
Rust
323 lines
15 KiB
Rust
//! Mediated agent input (cadrage « entrée médiée par IdeA », lot C1/C2).
|
|
//!
|
|
//! Every input to an agent — **human** keystrokes *and* **inter-agent** delegations
|
|
//! — converges on **one FIFO per agent**, with `enqueue` (Envoyer) and `preempt`
|
|
//! (Interrompre) kept distinct. This module owns the pure value objects
|
|
//! ([`InputSource`], [`AgentBusyState`]) and the [`InputMediator`] port.
|
|
//!
|
|
//! The mediator **absorbs** [`crate::mailbox::AgentMailbox`]: the existing mailbox
|
|
//! (FIFO + one-shot reply) is reused as the *correlation engine* inside the infra
|
|
//! adapter (`MediatedInbox`), rather than spawning a second concurrent queue. The
|
|
//! delegation mailbox is thus just **one input source among two**.
|
|
//!
|
|
//! Pure (no I/O): the FIFO, locks and busy bookkeeping live in the infra adapter.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::ids::AgentId;
|
|
use crate::mailbox::{PendingReply, Ticket, TicketId};
|
|
use crate::ports::PtyHandle;
|
|
|
|
/// Where a queued input came from.
|
|
///
|
|
/// Replaces the free-form `requester: String` as the **source of truth** (the
|
|
/// string remains a derived display label). Lets IdeA propagate the requester's
|
|
/// identity and feed the wait-for graph (cycle detection).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "kind")]
|
|
pub enum InputSource {
|
|
/// The human operator (a `SubmitHumanInput` use case).
|
|
Human,
|
|
/// Another agent delegating via `idea_ask_agent`.
|
|
#[serde(rename_all = "camelCase")]
|
|
Agent {
|
|
/// The delegating agent.
|
|
agent_id: AgentId,
|
|
},
|
|
}
|
|
|
|
impl InputSource {
|
|
/// Convenience constructor for an agent source.
|
|
#[must_use]
|
|
pub const fn agent(agent_id: AgentId) -> Self {
|
|
Self::Agent { agent_id }
|
|
}
|
|
|
|
/// Returns the [`AgentId`] when this source is an agent.
|
|
#[must_use]
|
|
pub const fn as_agent(&self) -> Option<AgentId> {
|
|
match self {
|
|
Self::Agent { agent_id } => Some(*agent_id),
|
|
Self::Human => None,
|
|
}
|
|
}
|
|
|
|
/// Whether the source is the human operator.
|
|
#[must_use]
|
|
pub const fn is_human(&self) -> bool {
|
|
matches!(self, Self::Human)
|
|
}
|
|
}
|
|
|
|
/// Whether an agent is currently processing a task.
|
|
///
|
|
/// Derived state, published to the front (`AgentBusyChanged`). An agent goes `Busy`
|
|
/// on the enqueue that **starts a turn**, and returns `Idle` on prompt-ready OR an
|
|
/// explicit signal (cf. cadrage §6). In doubt it stays `Busy`, but the FIFO keeps
|
|
/// accepting enqueues (forward, never reject the sender).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "state")]
|
|
pub enum AgentBusyState {
|
|
/// No turn in flight; the next enqueued ticket can start.
|
|
Idle,
|
|
/// A turn is in flight.
|
|
#[serde(rename_all = "camelCase")]
|
|
Busy {
|
|
/// The ticket whose turn is running.
|
|
ticket: TicketId,
|
|
/// When the turn started (epoch millis), for UI age display.
|
|
since_ms: u64,
|
|
},
|
|
}
|
|
|
|
/// Whether an agent currently shows **signs of life** during a turn (lot 2,
|
|
/// chantier readiness/heartbeat — détection « Stalled »).
|
|
///
|
|
/// Orthogonal to [`AgentBusyState`]: an agent can be `Busy` **and** `Alive` (it is
|
|
/// working, producing deltas/heartbeats) or `Busy` **and** `Stalled` (no proof of
|
|
/// liveness for longer than its profile's `stall_after_ms`). Only meaningful while
|
|
/// `Busy`; an `Idle` agent is considered `Alive` (its turn ended cleanly).
|
|
///
|
|
/// Published to the front (`AgentLivenessChanged`) **once per transition** so the UI
|
|
/// can badge a frozen agent without event spam. Derived from the per-agent
|
|
/// `last_seen_ms` heartbeat, never from parsing the model output.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "liveness")]
|
|
pub enum AgentLiveness {
|
|
/// The agent is producing proof of liveness (deltas / tool activity /
|
|
/// heartbeats) within its `stall_after_ms` window — or is idle.
|
|
Alive,
|
|
/// No proof of liveness for longer than the profile's `stall_after_ms`: the
|
|
/// agent is presumed frozen. Advisory only — the FIFO and the turn keep running
|
|
/// (a late heartbeat flips it back to [`Self::Alive`]).
|
|
Stalled,
|
|
}
|
|
|
|
impl AgentBusyState {
|
|
/// Whether a turn is currently in flight.
|
|
#[must_use]
|
|
pub const fn is_busy(&self) -> bool {
|
|
matches!(self, Self::Busy { .. })
|
|
}
|
|
|
|
/// The in-flight ticket, if any.
|
|
#[must_use]
|
|
pub const fn ticket(&self) -> Option<TicketId> {
|
|
match self {
|
|
Self::Busy { ticket, .. } => Some(*ticket),
|
|
Self::Idle => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Per-agent submit configuration (ARCHITECTURE §20.3), resolved from the target
|
|
/// agent's profile and carried to the [`InputMediator`] at bind time so it can be
|
|
/// echoed on a [`crate::events::DomainEvent::DelegationReady`].
|
|
///
|
|
/// Both fields stay `Option`: the **default** (`"\r"`, ~60 ms) is applied at the
|
|
/// point of use (the frontend write-portal), never hard-coded in the domain — the
|
|
/// domain only transports the declared values.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct SubmitConfig {
|
|
/// The profile's `submit_sequence` (e.g. `"\r"`). `None` ⇒ front default.
|
|
pub sequence: Option<String>,
|
|
/// The profile's `submit_delay_ms`. `None` ⇒ front default (~60 ms).
|
|
pub delay_ms: Option<u32>,
|
|
}
|
|
|
|
impl SubmitConfig {
|
|
/// Builds a submit config from the two optional profile fields.
|
|
#[must_use]
|
|
pub const fn new(sequence: Option<String>, delay_ms: Option<u32>) -> Self {
|
|
Self { sequence, delay_ms }
|
|
}
|
|
}
|
|
|
|
/// The single convergence point of **all** of an agent's input (one FIFO/agent),
|
|
/// with `enqueue` (Envoyer) and `preempt` (Interrompre) kept **distinct**, plus the
|
|
/// busy state.
|
|
///
|
|
/// Object-safe (held as `Arc<dyn InputMediator>`). The infra adapter `MediatedInbox`
|
|
/// composes the existing [`crate::mailbox::AgentMailbox`] (correlation by ticket) +
|
|
/// turn locks + busy bookkeeping — it does **not** create a second queue.
|
|
pub trait InputMediator: Send + Sync {
|
|
/// Envoyer = enqueue: appends `ticket` to `agent`'s FIFO and returns the
|
|
/// [`PendingReply`] to await (the mailbox reply type, reused).
|
|
///
|
|
/// **Delivery** (ARCHITECTURE §20.2/§20.3): the mediator **no longer writes the
|
|
/// turn into the PTY** (the `\n` band-aid is gone — it never submitted in raw mode
|
|
/// and produced a "double chat"). Instead, on the enqueue that **starts a turn**
|
|
/// (Idle→Busy), the adapter publishes a [`crate::events::DomainEvent::DelegationReady`]
|
|
/// carrying the task text + ticket + the target's [`SubmitConfig`]; the frontend
|
|
/// cell (sole owner of the terminal) runs the write-portal handshake and writes the
|
|
/// text + submit sequence through the single PTY writer. The mediator stays the
|
|
/// authority of the FIFO/busy state and correlation only.
|
|
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
|
|
|
|
/// Registers (or refreshes) the live input [`PtyHandle`] of `agent` so a later
|
|
/// [`InputMediator::enqueue`] delivers the turn through it (cadrage C3 §5.2).
|
|
///
|
|
/// Keyed by **agent** (one live input stream per agent — «1 agent = 1 employee»);
|
|
/// the orchestrator calls this once it has resolved/launched the agent's live
|
|
/// session for the target conversation. Default: no-op (a mediator that does not
|
|
/// own the delivery write).
|
|
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
|
|
|
|
/// Like [`InputMediator::bind_handle`], but also arms **prompt-ready detection**
|
|
/// (cadrage §6, lot C5): `prompt_ready_pattern` is the agent profile's optional
|
|
/// literal marker (`AgentProfile::prompt_ready_pattern`). When `Some`, the mediator
|
|
/// watches the bound handle's output stream and calls [`InputMediator::mark_idle`]
|
|
/// the first time the marker appears (one of the two OR signals; the other is an
|
|
/// explicit `idea_reply`). When `None`, no pattern detection is armed — the agent
|
|
/// only returns `Idle` on the explicit signal or the per-turn timeout (fallback
|
|
/// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`).
|
|
///
|
|
/// It also records the target's [`SubmitConfig`] (ARCHITECTURE §20.3) so the
|
|
/// adapter can echo `submit_sequence`/`submit_delay_ms` on the
|
|
/// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts.
|
|
/// The bind is the natural carrier: both the prompt pattern and the submit config
|
|
/// are per-agent profile data the orchestrator resolves at the same time, so they
|
|
/// travel together (no extra ticket field, no second resolve).
|
|
///
|
|
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and
|
|
/// submit config (a mediator that does not observe the output stream). The infra
|
|
/// adapter overrides it to arm the watcher and stash the submit config.
|
|
fn bind_handle_with_prompt(
|
|
&self,
|
|
agent: AgentId,
|
|
handle: PtyHandle,
|
|
_prompt_ready_pattern: Option<String>,
|
|
_submit: SubmitConfig,
|
|
) {
|
|
self.bind_handle(agent, handle);
|
|
}
|
|
|
|
/// **Deprecated since ARCHITECTURE §20**: the mediator never writes the turn into
|
|
/// the PTY anymore — the **frontend** always delivers (via the write-portal). Kept
|
|
/// for source compatibility; it now always returns `false`. Callers should stop
|
|
/// branching on it (the orchestrator no longer falls back to its own PTY write).
|
|
#[must_use]
|
|
fn delivers_turn(&self, _agent: AgentId) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Déclare qu'`agent` vient d'être **lancé à froid** : la livraison de son tout
|
|
/// premier tour doit être *gatée* sur le prompt-ready (la `DelegationReady` est
|
|
/// différée jusqu'à l'apparition du prompt du CLI), pour éviter d'écrire la tâche
|
|
/// avant que le CLI ait fini de booter (premier tour perdu sinon).
|
|
///
|
|
/// À n'appeler **que** lorsqu'un prompt-ready watcher sera effectivement armé (le
|
|
/// profil porte un `prompt_ready_pattern` non vide). Sans watcher pour le libérer,
|
|
/// gater le premier tour le bloquerait indéfiniment ; dans ce cas l'orchestrateur ne
|
|
/// doit **pas** appeler `mark_starting` et l'`enqueue` livre la tâche immédiatement
|
|
/// (chemin chaud, fallback sûr, zéro régression).
|
|
///
|
|
/// Default: no-op (a mediator that does not gate cold starts).
|
|
fn mark_starting(&self, _agent: AgentId) {}
|
|
|
|
/// Signal de **readiness de démarrage** : le pont MCP de `agent` vient de se
|
|
/// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour
|
|
/// a été différé par [`InputMediator::mark_starting`] (démarrage à froid), c'est le
|
|
/// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à
|
|
/// `prompt_ready`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de
|
|
/// fin de tour. Idempotent : un second appel (ou après que `prompt_ready` ait déjà
|
|
/// drainé) ne trouve rien et est un no-op. En OR avec le prompt-ready : le premier
|
|
/// arrivé draine, l'autre est no-op.
|
|
///
|
|
/// Default: no-op (médiateur qui ne gate pas les démarrages à froid).
|
|
fn release_cold_start(&self, _agent: AgentId) {}
|
|
|
|
/// Déclare si une **cellule terminal du frontend** est montée pour `agent`
|
|
/// (`true` au montage du write-portal, `false` au démontage). C'est le frontend
|
|
/// qui possède l'écriture physique du PTY *quand une cellule existe* (cas d'un
|
|
/// agent épinglé dans le layout) ; un agent **délégué en arrière-plan n'a aucune
|
|
/// cellule**, donc personne ne consomme `DelegationReady` côté UI. Le médiateur
|
|
/// utilise cet état pour décider, à la livraison d'un tour, entre **publier
|
|
/// l'événement** (cellule présente ⇒ le front écrit) et **écrire lui-même** la
|
|
/// tâche dans le PTY (agent headless ⇒ sinon le tour est perdu).
|
|
///
|
|
/// Default: no-op (médiateur sans notion de cellule front — tout passe par
|
|
/// l'événement, comportement historique).
|
|
fn set_front_attached(&self, _agent: AgentId, _attached: bool) {}
|
|
|
|
/// Interrompre = preempt: signals the running turn to stop (Échap/stop). This
|
|
/// is **not** an enqueue and correlates **no** ticket.
|
|
fn preempt(&self, agent: AgentId);
|
|
|
|
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
|
|
fn mark_idle(&self, agent: AgentId);
|
|
|
|
/// Records a **proof of liveness** (« battement ») for `agent` — called on every
|
|
/// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`])
|
|
/// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall
|
|
/// detector ([`crate::events::DomainEvent::AgentLivenessChanged`], lot 2) knows the
|
|
/// agent is still working; a battement arriving after a `Stalled` transition flips it
|
|
/// back to [`AgentLiveness::Alive`].
|
|
///
|
|
/// Default: no-op (a mediator that does not track liveness). The infra adapter
|
|
/// `MediatedInbox` overrides it to refresh `last_seen` and publish an
|
|
/// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement.
|
|
fn mark_alive(&self, _agent: AgentId) {}
|
|
|
|
/// Declares the agent's **stall threshold** (its profile's
|
|
/// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows
|
|
/// how long without a battement means "frozen" (lot 2). The orchestrator resolves it
|
|
/// from the target's profile and calls this **before** the enqueue that starts a
|
|
/// turn; the adapter stashes it and arms a fresh liveness window when the turn
|
|
/// begins. `None` ⇒ no stall detection for this agent (legacy / no `liveness` block —
|
|
/// zero regression).
|
|
///
|
|
/// Default: no-op (a mediator that does not track liveness).
|
|
fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option<u32>) {}
|
|
|
|
/// The current [`AgentBusyState`] of `agent`.
|
|
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn agent(n: u128) -> AgentId {
|
|
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
|
}
|
|
|
|
fn ticket(n: u128) -> TicketId {
|
|
TicketId::from_uuid(uuid::Uuid::from_u128(n))
|
|
}
|
|
|
|
#[test]
|
|
fn input_source_human_vs_agent() {
|
|
assert!(InputSource::Human.is_human());
|
|
assert_eq!(InputSource::Human.as_agent(), None);
|
|
let s = InputSource::agent(agent(1));
|
|
assert!(!s.is_human());
|
|
assert_eq!(s.as_agent(), Some(agent(1)));
|
|
}
|
|
|
|
#[test]
|
|
fn busy_state_transitions_and_accessors() {
|
|
let idle = AgentBusyState::Idle;
|
|
assert!(!idle.is_busy());
|
|
assert_eq!(idle.ticket(), None);
|
|
|
|
let busy = AgentBusyState::Busy {
|
|
ticket: ticket(7),
|
|
since_ms: 1000,
|
|
};
|
|
assert!(busy.is_busy());
|
|
assert_eq!(busy.ticket(), Some(ticket(7)));
|
|
assert_ne!(idle, busy);
|
|
}
|
|
}
|