merge(orchestrator): intègre le backstop no-reply + cause racine encode_cwd (rendez-vous inter-agents)

Intègre la branche feature/rendezvous-no-reply-backstop : durcissement du
rendez-vous idea_ask_agent ⇄ idea_reply, backstop no-reply, et le fix de cause
racine `encode_cwd` (encodage exact de Claude Code, `.` -> `-`) qui rétablit le
turn-watcher et lève le wedge.

Validé EN LIVE (wedge prouvé levé, demandeur libéré via grâce 2s) + suite verte
(cargo test --workspace 1616 passed / 0 failed, clippy 0 erreur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 19:51:05 +02:00
20 changed files with 887 additions and 1093 deletions

View File

@ -1103,6 +1103,9 @@ pub async fn stop_live_agent(
) -> Result<StopLiveAgentResponseDto, ErrorDto> { ) -> Result<StopLiveAgentResponseDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?; let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?; let agent_id = parse_agent_id(&request.agent_id)?;
// Backstop no-reply : arrêter l'observateur de fin de tour de l'agent (le handle est
// droppé ⇒ polling stoppé) avant de démonter sa session.
state.stop_turn_watch(agent_id);
state state
.stop_live_agent .stop_live_agent
.execute(StopLiveAgentInput { project, agent_id }) .execute(StopLiveAgentInput { project, agent_id })
@ -1260,6 +1263,9 @@ pub async fn launch_agent(
// Lot LS6 — project root captured before `project` moves, for the off-hot-path log // Lot LS6 — project root captured before `project` moves, for the off-hot-path log
// rotation triggered at thread (re)open below. // rotation triggered at thread (re)open below.
let rotation_root = project.root.clone(); let rotation_root = project.root.clone();
// Backstop no-reply — project root captured before `project` moves, to arm the
// end-of-turn transcript watcher for the launched agent (cf. `AppState::arm_turn_watch`).
let watch_root = project.root.clone();
let output = state let output = state
.launch_agent .launch_agent
@ -1288,6 +1294,19 @@ pub async fn launch_agent(
); );
} }
// Backstop no-reply : armer l'observateur de fin de tour transcript pour la cible si
// son profil est supporté (Claude). Idempotent par remplacement à chaque (re)lancement
// effectif (profil résolu présent) ; no-op sur reattach (profil `None`) — le handle
// posé au 1er lancement persiste. cwd = run dir isolé de l'agent.
if let Some(profile) = output.profile.as_ref() {
state.arm_turn_watch(
&watch_root,
agent_id,
profile,
output.assigned_conversation_id.clone(),
);
}
// Lot LS6 — rotation **best-effort** du log, déclenchée à la (re)ouverture du fil et // Lot LS6 — rotation **best-effort** du log, déclenchée à la (re)ouverture du fil et
// **hors chemin chaud** : détachée (`tokio::spawn`) pour n'ajouter aucune latence au // **hors chemin chaud** : détachée (`tokio::spawn`) pour n'ajouter aucune latence au
// launch, erreurs **avalées** (la rotation ne doit jamais casser une reprise). Un // launch, erreurs **avalées** (la rotation ne doit jamais casser une reprise). Un

View File

@ -58,7 +58,7 @@ use infrastructure::{
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, ReferenceBackfillProfileStore, RwFileGuard, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
StructuredSessionFactory, StructuredSessionFactory,
SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator, SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
@ -564,6 +564,17 @@ pub struct AppState {
/// la commande `launch_agent` y dépose le `Project`/taille du dernier lancement pour /// la commande `launch_agent` y dépose le `Project`/taille du dernier lancement pour
/// que la reprise auto puisse recomposer un `LaunchAgentInput` complet. /// que la reprise auto puisse recomposer un `LaunchAgentInput` complet.
pub resume_contexts: ResumeContexts, pub resume_contexts: ResumeContexts,
/// Observateur de **fin de tour** (backstop no-reply) : lit le transcript on-disk de
/// l'agent (Claude `turn_duration`) et appelle `InputMediator::turn_ended`. Remplace
/// le watcher prompt-ready PTY mort. Armé par agent supporté au lancement.
pub turn_watcher: Arc<dyn domain::ports::TurnWatcher>,
/// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du
/// [`turn_watcher`](Self::turn_watcher) à l'armement.
pub turn_watch_input: Arc<dyn domain::input::InputMediator>,
/// Handles des watches de fin-de-tour vivants, par agent. (Re)lancer un agent
/// **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; fermer/arrêter
/// l'agent le retire. `Mutex` car launch/stop y accèdent concurremment.
pub turn_watch_handles: Mutex<HashMap<AgentId, Box<dyn domain::ports::TurnWatchHandle>>>,
} }
impl AppState { impl AppState {
@ -718,14 +729,8 @@ impl AppState {
Arc::clone(&fs_port), Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(), app_data_dir.to_string_lossy().into_owned(),
)); ));
// Merge-on-read overlay (finding A) : envelopper le store concret UNE SEULE FOIS let profile_store_port: Arc<dyn ProfileStore> =
// ici, avant toute injection, pour que chaque lecteur (use cases + `.list()` directs Arc::clone(&profile_store) as Arc<dyn ProfileStore>;
// de l'orchestrateur) voie les défauts de référence backfillés (`prompt_ready_pattern`
// mesuré). `save`/`delete`/`is_configured` délèguent verbatim ⇒ persistance et
// détection first-run inchangées.
let profile_store_port: Arc<dyn ProfileStore> = Arc::new(
ReferenceBackfillProfileStore::new(Arc::clone(&profile_store) as Arc<dyn ProfileStore>),
);
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port))); let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port))); let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port)));
@ -975,8 +980,14 @@ impl AppState {
.or_else(|_| std::env::var("USERPROFILE")) .or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default(); .unwrap_or_default();
let inspectors: Vec<Arc<dyn domain::ports::SessionInspector>> = vec![Arc::new( let inspectors: Vec<Arc<dyn domain::ports::SessionInspector>> = vec![Arc::new(
ClaudeTranscriptInspector::new(Arc::clone(&fs_port), home_dir), ClaudeTranscriptInspector::new(Arc::clone(&fs_port), home_dir.clone()),
)]; )];
// Backstop no-reply : observateur de fin de tour transcript (Claude `turn_duration`)
// — lit le même `<home>/.claude/projects/<encoded-run-dir>/` que l'inspecteur, via
// le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`).
let turn_watcher: Arc<dyn domain::ports::TurnWatcher> = Arc::new(
infrastructure::ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs_port), home_dir),
);
let inspect_conversation = Arc::new(InspectConversation::new( let inspect_conversation = Arc::new(InspectConversation::new(
Arc::clone(&contexts_port), Arc::clone(&contexts_port),
Arc::clone(&profile_store_port), Arc::clone(&profile_store_port),
@ -1435,6 +1446,9 @@ impl AppState {
mcp_servers: Mutex::new(HashMap::new()), mcp_servers: Mutex::new(HashMap::new()),
session_limit_service, session_limit_service,
resume_contexts, resume_contexts,
turn_watcher,
turn_watch_input: Arc::clone(&input_mediator),
turn_watch_handles: Mutex::new(HashMap::new()),
move_tab, move_tab,
} }
} }
@ -1519,9 +1533,10 @@ impl AppState {
service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid)); service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid));
} }
}); });
// Borne du rendez-vous `idea_ask_agent` (filet serveur) : réglage projet // Borne du rendez-vous `idea_ask_agent` (filet serveur, plancher universel FINI) :
// optionnel via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Absent / invalide / `0` ⇒ // réglage projet optionnel via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Absent / invalide /
// défaut généreux inchangé (24 h), zéro régression sur les tours longs légitimes. // `0` ⇒ défaut fini (600 s) — jamais (quasi-)infini, sinon une cible silencieuse
// wedge l'appelant.
let ask_timeout = infrastructure::resolve_ask_rendezvous_timeout( let ask_timeout = infrastructure::resolve_ask_rendezvous_timeout(
std::env::var("IDEA_ASK_RENDEZVOUS_TIMEOUT_MS") std::env::var("IDEA_ASK_RENDEZVOUS_TIMEOUT_MS")
.ok() .ok()
@ -1563,6 +1578,52 @@ impl AppState {
self.migrate_claude_run_dirs(project).await; self.migrate_claude_run_dirs(project).await;
} }
/// Arms the **end-of-turn watcher** (no-reply backstop) for `agent_id` if its
/// `profile` is supported (Claude). The watcher tails the agent's isolated run-dir
/// transcript folder (`<root>/.ideai/run/<agent>`) and routes each detected turn end
/// to [`InputMediator::turn_ended`](domain::input::InputMediator::turn_ended). Called
/// at launch; **idempotent by replacement** — a relaunch drops the previous handle
/// (stopping its polling task) and arms a fresh one. A non-supported profile is a
/// no-op. `conversation_id` is diagnostic only.
pub fn arm_turn_watch(
&self,
project_root: &domain::project::ProjectPath,
agent_id: AgentId,
profile: &AgentProfile,
conversation_id: Option<String>,
) {
if !self.turn_watcher.supports(profile) {
return;
}
// cwd = the agent's isolated run dir (matches the profile `{agentRunDir}` cwd that
// Claude runs in, hence the `<home>/.claude/projects/<encoded-run-dir>/` folder).
let run_dir = format!(
"{}/.ideai/run/{agent_id}",
project_root.as_str().trim_end_matches(['/', '\\'])
);
let Ok(cwd) = domain::project::ProjectPath::new(run_dir) else {
return;
};
let input = Arc::clone(&self.turn_watch_input);
// Callback invoked from the watcher's polling task (no lock held here).
let on_turn_end: domain::ports::OnTurnEnd = Arc::new(move |a| input.turn_ended(a));
let handle = self
.turn_watcher
.watch(agent_id, conversation_id, cwd, on_turn_end);
if let Ok(mut map) = self.turn_watch_handles.lock() {
// Insert replaces (and drops) any prior handle ⇒ its polling task stops.
map.insert(agent_id, handle);
}
}
/// Stops and removes the end-of-turn watcher of `agent_id` (close / stop). Dropping
/// the stored handle stops its polling task. No-op if none is armed.
pub fn stop_turn_watch(&self, agent_id: AgentId) {
if let Ok(mut map) = self.turn_watch_handles.lock() {
map.remove(&agent_id);
}
}
/// Stops and removes the orchestrator watcher for `project_id`, if any. /// Stops and removes the orchestrator watcher for `project_id`, if any.
/// Called from `close_project` so a closed project stops consuming requests. /// Called from `close_project` so a closed project stops consuming requests.
/// Symmetrically stops the project's MCP server (its twin) so both entry doors /// Symmetrically stops the project's MCP server (its twin) so both entry doors

View File

@ -17,9 +17,6 @@
//! so re-deriving the catalogue yields the same id for "claude" every time, //! so re-deriving the catalogue yields the same id for "claude" every time,
//! making the reference profiles addressable across runs without a registry. //! making the reference profiles addressable across runs without a registry.
use std::collections::HashMap;
use std::sync::OnceLock;
use domain::ids::ProfileId; use domain::ids::ProfileId;
use domain::permission::ProjectorKey; use domain::permission::ProjectorKey;
use domain::profile::{ use domain::profile::{
@ -70,14 +67,6 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
) )
.expect("claude reference profile is valid") .expect("claude reference profile is valid")
.with_structured_adapter(StructuredAdapter::Claude) .with_structured_adapter(StructuredAdapter::Claude)
// Marqueur de **retour au prompt idle** (signal de repli n°3, agents PTY/TUI ;
// arme le watcher dans `MediatedInbox::bind_handle_with_prompt`). Mesuré en réel
// (capture PTY live, finding A) : le footer idle de Claude Code rend la sous-chaîne
// contiguë `? for shortcuts` à chaque retour au prompt (et au démarrage idle).
// Discriminant : pendant un tour, le footer devient `esc to interrupt` (+ spinner),
// donc cette chaîne n'apparaît JAMAIS en milieu de tour ⇒ pas de faux positif. La
// recherche est littérale sur octets bruts : l'ASCII suffit (pas d'ANSI à embarquer).
.with_prompt_ready_pattern("? for shortcuts")
.with_projector(ProjectorKey::Claude) .with_projector(ProjectorKey::Claude)
.with_mcp(McpCapability::new( .with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json") McpConfigStrategy::config_file(".mcp.json")
@ -156,106 +145,6 @@ pub fn selectable_reference_profiles() -> Vec<AgentProfile> {
.collect() .collect()
} }
/// Lazy index `ProfileId -> reference profile`, built once from
/// [`reference_profiles`]. Keyed by the deterministic [`reference_id`], so a stored
/// profile created from a reference is addressable across runs without a registry.
fn reference_index() -> &'static HashMap<ProfileId, AgentProfile> {
static INDEX: OnceLock<HashMap<ProfileId, AgentProfile>> = OnceLock::new();
INDEX.get_or_init(|| reference_profiles().into_iter().map(|p| (p.id, p)).collect())
}
/// **Merge-on-read overlay** (pure, no I/O): backfills *internal* reference fields
/// that a stored profile is missing, without ever persisting anything.
///
/// Motivation: profiles persisted before a reference field existed (e.g. the measured
/// `prompt_ready_pattern`, finding A) are the launch-time source of truth, but the
/// catalogue is never re-merged into the store. This overlay lets every reader see the
/// up-to-date reference defaults while the on-disk `profiles.json` stays untouched.
///
/// Invariants:
/// - **Match on the deterministic [`reference_id`] only** — a custom profile (random id)
/// is returned **unchanged** (its id is absent from [`reference_index`]).
/// - **Never clobbers a user value** — a field is filled **iff** it is `None`/absent on
/// the stored profile; a present value always wins.
/// - **Strict allowlist** — only *internal, non-wizard-editable* reference fields are
/// overlaid. Today that is **`prompt_ready_pattern` alone** (an internal measured
/// marker, never surfaced in the wizard). User-editable fields are never touched.
/// - **Idempotent**: `overlay(overlay(p)) == overlay(p)` (a filled field is then
/// present, so the second pass is a no-op).
/// - **Pure**: no I/O, safe to call on every `list()`.
#[must_use]
pub fn overlay_reference_defaults(stored: &AgentProfile) -> AgentProfile {
let Some(reference) = reference_index().get(&stored.id) else {
return stored.clone();
};
let mut merged = stored.clone();
// Allowlist STRICTE — un champ par ligne, rempli uniquement s'il est absent.
if merged.prompt_ready_pattern.is_none() {
merged.prompt_ready_pattern = reference.prompt_ready_pattern.clone();
}
merged
}
#[cfg(test)]
mod overlay_tests {
use super::*;
/// Claude reference profile with `prompt_ready_pattern` cleared — stands in for a
/// profile persisted before the field existed (the case this overlay fixes).
fn claude_without_pattern() -> AgentProfile {
let id = reference_id("claude");
let mut p = reference_profiles()
.into_iter()
.find(|p| p.id == id)
.expect("claude reference exists");
p.prompt_ready_pattern = None;
p
}
#[test]
fn reference_id_with_missing_field_is_backfilled() {
let merged = overlay_reference_defaults(&claude_without_pattern());
assert_eq!(
merged.prompt_ready_pattern.as_deref(),
Some("? for shortcuts"),
"a reference profile missing the internal field gets it backfilled"
);
}
#[test]
fn reference_id_never_clobbers_a_present_user_value() {
let mut stored = claude_without_pattern();
stored.prompt_ready_pattern = Some("USER OVERRIDE".to_owned());
let merged = overlay_reference_defaults(&stored);
assert_eq!(
merged.prompt_ready_pattern.as_deref(),
Some("USER OVERRIDE"),
"a present value always wins — never clobbered"
);
}
#[test]
fn non_reference_id_is_returned_unchanged() {
// A custom profile (random id absent from the reference index) is untouched,
// even when it is missing the field.
let mut custom = claude_without_pattern();
custom.id = ProfileId::from_uuid(uuid::Uuid::from_u128(0xC0FFEE));
let merged = overlay_reference_defaults(&custom);
assert_eq!(
merged.prompt_ready_pattern, None,
"a custom profile is never backfilled (id not a reference id)"
);
assert_eq!(merged, custom, "custom profile returned byte-for-byte unchanged");
}
#[test]
fn overlay_is_idempotent() {
let once = overlay_reference_defaults(&claude_without_pattern());
let twice = overlay_reference_defaults(&once);
assert_eq!(once, twice, "overlay(overlay(p)) == overlay(p)");
}
}
#[cfg(test)] #[cfg(test)]
mod mcp_tests { mod mcp_tests {
use super::*; use super::*;
@ -268,28 +157,6 @@ mod mcp_tests {
.unwrap_or_else(|| panic!("reference profile `{slug}` exists")) .unwrap_or_else(|| panic!("reference profile `{slug}` exists"))
} }
#[test]
fn claude_seed_carries_the_measured_prompt_ready_pattern() {
// Marqueur de retour-au-prompt idle mesuré en réel (capture PTY live, finding A).
// Recherche littérale d'octets côté watcher ⇒ l'ASCII exact suffit. Non vide ⇒ le
// watcher s'arme (`MediatedInbox::bind_handle_with_prompt`).
assert_eq!(
profile("claude").prompt_ready_pattern.as_deref(),
Some("? for shortcuts"),
"Claude seed must carry the measured idle-prompt marker"
);
}
#[test]
fn codex_seed_leaves_prompt_ready_pattern_empty_for_now() {
// Capture Codex non concluante (sandbox) + Codex non utilisé en pratique : pattern
// laissé VIDE (le backstop serveur Lot 3 couvre l'absence de détection). Follow-up.
assert!(
profile("codex").prompt_ready_pattern.is_none(),
"Codex prompt_ready_pattern stays empty until a clean live capture"
);
}
#[test] #[test]
fn claude_and_codex_expose_mcp_capability() { fn claude_and_codex_expose_mcp_capability() {
for slug in ["claude", "codex"] { for slug in ["claude", "codex"] {

View File

@ -23,7 +23,7 @@ pub use structured::{
}; };
pub use catalogue::{ pub use catalogue::{
overlay_reference_defaults, reference_profile_id, reference_profiles, reference_profile_id, reference_profiles,
selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS,
}; };
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};

View File

@ -301,11 +301,10 @@ mod tests {
AgentBusyState::Idle AgentBusyState::Idle
} }
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {} fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
fn bind_handle_with_prompt( fn bind_handle_with_submit(
&self, &self,
_agent: AgentId, _agent: AgentId,
_handle: PtyHandle, _handle: PtyHandle,
_pattern: Option<String>,
_submit: SubmitConfig, _submit: SubmitConfig,
) { ) {
} }

View File

@ -31,7 +31,7 @@ pub mod window;
pub mod workstate; pub mod workstate;
pub use agent::{ pub use agent::{
drain_with_readiness, drain_with_readiness_outcome, overlay_reference_defaults, drain_with_readiness, drain_with_readiness_outcome,
reference_profile_id, reference_profiles, reference_profile_id, reference_profiles,
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile, selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,

View File

@ -75,12 +75,15 @@ fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
/// intentionally not yet config-exposed (it may become a per-project setting /// intentionally not yet config-exposed (it may become a per-project setting
/// without changing the contract). /// without changing the contract).
/// ///
/// TEMPORAIRE (demande utilisateur 2026-06-13) : la borne de 300 s coupait des tours /// **Plancher universel FINI (backstop no-reply).** Le vrai signal de fin-de-tour
/// délégués légitimement très longs (gros refactors + tests). On la relève donc à 24 h /// existe désormais (`turn_duration` du transcript via [`domain::ports::TurnWatcher`]
/// (≈ « pas de limite ») le temps de concevoir un vrai signal de vivacité (savoir si /// ⇒ [`domain::input::InputMediator::turn_ended`], qui réveille l'appelant en ≈ grâce),
/// l'agent travaille encore) qui remplacera ce timeout brut. NE PAS considérer comme /// mais il n'est émis que par Claude. Ce timeout reste donc le **dernier recours**
/// définitif : à reconvertir en borne courte + heartbeat once that liveness signal exists. /// pour toute cible silencieuse qui n'émet pas `turn_duration` (Codex & co) ou qui
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); /// hang : il ne doit JAMAIS être (quasi-)infini sous peine de wedger l'appelant. 600 s
/// par défaut, **surchargeable par profil** via `turn_timeout_ms`
/// ([`resolve_turn_timeout`]).
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(600);
/// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0, /// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0,
/// cadrage v5 §4). /// cadrage v5 §4).
@ -96,10 +99,9 @@ const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
/// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours /// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours
/// pleins d'attente. /// pleins d'attente.
/// ///
/// TEMPORAIRE (cf. [`ASK_AGENT_TIMEOUT`]) : relevé à 48 h (≈ deux tours « sans limite ») /// FINI (cf. [`ASK_AGENT_TIMEOUT`]) : deux tours pleins d'attente ⇒ 2 × 600 s = 1200 s.
/// en cohérence avec la levée temporaire de la borne de tour, le temps d'un vrai /// Comme la borne de tour, ce plafond reste fini (jamais d'attente quasi-infinie en file).
/// signal de vivacité. const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(1200);
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(48 * 60 * 60);
/// Borne de tour effective (lot 2, timeouts pilotés par profil) : le /// Borne de tour effective (lot 2, timeouts pilotés par profil) : le
/// `turn_timeout_ms` du profil de la cible **quand il existe**, sinon le défaut /// `turn_timeout_ms` du profil de la cible **quand il existe**, sinon le défaut
@ -1298,29 +1300,26 @@ impl OrchestratorService {
let (handle, cold_launch) = self let (handle, cold_launch) = self
.ensure_live_pty(project, agent_id, conversation_id, &target) .ensure_live_pty(project, agent_id, conversation_id, &target)
.await?; .await?;
// Arm prompt-ready detection (C5) with the target profile's literal marker, so a // Résout la config de soumission du profil cible + s'il déclare un pont MCP.
// return-to-prompt frees the turn (the other OR signal being `idea_reply`). let (submit, has_mcp) = self.submit_and_mcp_for_agent(project, agent_id).await;
let (prompt_pattern, submit, has_mcp) = // Gate cold-launch : un agent froid n'est pas encore prêt à recevoir son 1er tour.
self.prompt_and_submit_for_agent(project, agent_id).await; // On le diffère s'il existe un signal pour le libérer — la connexion du pont MCP
// Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le // de l'agent (`InputMediator::release_cold_start`, déclenchée par l'McpServer sur
// 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher // `initialize`). C'est désormais le **seul** signal de readiness (le watcher
// (pattern non vide), soit la connexion du pont MCP de l'agent // prompt-ready PTY a été supprimé). Sans pont MCP ⇒ pas de gate (livraison
// (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun // immédiate, sinon blocage indéfini).
// des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini). let gate_cold_start = cold_launch && has_mcp;
let gate_cold_start =
cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp);
// Diagnostics : décision de gate du premier tour. `gate_cold_start=false` sur une // Diagnostics : décision de gate du premier tour. `gate_cold_start=false` sur une
// cible froide SANS signal de libération (ni prompt-ready ni MCP) livrerait // cible froide SANS pont MCP livrerait immédiatement ; un `true` diffère la
// immédiatement ; un `true` diffère la livraison jusqu'au signal. // livraison jusqu'au signal MCP-initialize.
crate::diag!( crate::diag!(
"[rendezvous] gate decision: target={target} (agent {agent_id}) cold_launch={cold_launch} \ "[rendezvous] gate decision: target={target} (agent {agent_id}) cold_launch={cold_launch} \
has_prompt_pattern={} has_mcp={has_mcp} gate_cold_start={gate_cold_start}", has_mcp={has_mcp} gate_cold_start={gate_cold_start}",
prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()),
); );
if gate_cold_start { if gate_cold_start {
input.mark_starting(agent_id); input.mark_starting(agent_id);
} }
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern, submit); input.bind_handle_with_submit(agent_id, handle.clone(), submit);
// 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur // 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur
// (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket // (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket
@ -1694,19 +1693,17 @@ impl OrchestratorService {
let (handle, cold_launch) = self let (handle, cold_launch) = self
.ensure_live_pty(project, agent_id, conversation_id, target) .ensure_live_pty(project, agent_id, conversation_id, target)
.await?; .await?;
let (prompt_pattern, submit, has_mcp) = let (submit, has_mcp) = self.submit_and_mcp_for_agent(project, agent_id).await;
self.prompt_and_submit_for_agent(project, agent_id).await; // Gate cold-launch : un agent froid n'est pas encore prêt. On diffère le 1er tour
// Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le // s'il existe un signal pour le libérer — la connexion du pont MCP de l'agent
// 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher // (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Seul signal
// (pattern non vide), soit la connexion du pont MCP de l'agent // de readiness restant (watcher prompt-ready PTY supprimé). Sans pont MCP ⇒ pas
// (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun // de gate (livraison immédiate, sinon blocage indéfini).
// des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini). let gate_cold_start = cold_launch && has_mcp;
let gate_cold_start =
cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp);
if gate_cold_start { if gate_cold_start {
input.mark_starting(agent_id); input.mark_starting(agent_id);
} }
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern, submit); input.bind_handle_with_submit(agent_id, handle, submit);
// Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and- // Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
// forget: we drop the PendingReply (the human reads the terminal). The // forget: we drop the PendingReply (the human reads the terminal). The
@ -2311,19 +2308,19 @@ impl OrchestratorService {
))) )))
} }
/// Resolves the target agent profile's **prompt-ready pattern** (§6, lot C5) **and** /// Resolves the target agent profile's **submit config**
/// its **submit config** (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) in /// (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) **and** whether it
/// a single profile lookup. Both are carried into `bind_handle_with_prompt`: the /// declares an **MCP bridge**, in a single profile lookup. The submit config is
/// pattern arms prompt detection, the submit config is echoed on the next /// carried into `bind_handle_with_submit` (echoed on the next `DelegationReady` so
/// `DelegationReady` so the frontend write-portal knows how to submit. Returns /// the frontend write-portal knows how to submit); the MCP flag drives the
/// `(None, default)` when the agent, its profile, or the field is absent — the safe /// cold-launch gate (its `initialize` connection releases a deferred first turn).
/// fallback (no pattern ⇒ Idle only via explicit signal/timeout; no submit the /// Returns `(default, false)` when the agent or its profile is absent the safe
/// front applies its own `"\r"`/~60 ms default). /// fallback (no submit ⇒ the front applies its own `"\r"`/~60 ms default; no gate).
async fn prompt_and_submit_for_agent( async fn submit_and_mcp_for_agent(
&self, &self,
project: &Project, project: &Project,
agent_id: AgentId, agent_id: AgentId,
) -> (Option<String>, SubmitConfig, bool) { ) -> (SubmitConfig, bool) {
let Some(agent) = self let Some(agent) = self
.list_agents .list_agents
.execute(ListAgentsInput { .execute(ListAgentsInput {
@ -2333,7 +2330,7 @@ impl OrchestratorService {
.ok() .ok()
.and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id)) .and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
else { else {
return (None, SubmitConfig::default(), false); return (SubmitConfig::default(), false);
}; };
let Some(profile) = self let Some(profile) = self
.profiles .profiles
@ -2342,13 +2339,13 @@ impl OrchestratorService {
.ok() .ok()
.and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id)) .and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
else { else {
return (None, SubmitConfig::default(), false); return (SubmitConfig::default(), false);
}; };
let submit = submit_config_for_profile(&profile); let submit = submit_config_for_profile(&profile);
// 3e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion // 2e é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 // (`initialize`) sert 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`. // tour différé (`release_cold_start`) — c'est ce qui arme le gate cold-launch.
(profile.prompt_ready_pattern, submit, profile.mcp.is_some()) (submit, profile.mcp.is_some())
} }
/// Résout la [`domain::profile::LivenessStrategy`] du profil de la cible (lot 2) : /// Résout la [`domain::profile::LivenessStrategy`] du profil de la cible (lot 2) :

View File

@ -173,32 +173,22 @@ pub trait InputMediator: Send + Sync {
/// own the delivery write). /// own the delivery write).
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {} fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
/// Like [`InputMediator::bind_handle`], but also arms **prompt-ready detection** /// Like [`InputMediator::bind_handle`], but also records the target's
/// (cadrage §6, lot C5): `prompt_ready_pattern` is the agent profile's optional /// [`SubmitConfig`] (ARCHITECTURE §20.3) so the adapter can echo
/// literal marker (`AgentProfile::prompt_ready_pattern`). When `Some`, the mediator /// `submit_sequence`/`submit_delay_ms` on the
/// 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. /// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts.
/// The bind is the natural carrier: both the prompt pattern and the submit config /// The bind is the natural carrier: the submit config is per-agent profile data the
/// are per-agent profile data the orchestrator resolves at the same time, so they /// orchestrator resolves at bind time, so it travels with the handle (no extra
/// travel together (no extra ticket field, no second resolve). /// ticket field, no second resolve).
/// ///
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and /// Turn-end detection is **no longer** armed here: the dead PTY prompt-ready sniff
/// submit config (a mediator that does not observe the output stream). The infra /// was replaced by the transcript [`crate::ports::TurnWatcher`] (armed at the
/// adapter overrides it to arm the watcher and stash the submit config. /// composition root, once per live session) which calls
fn bind_handle_with_prompt( /// [`InputMediator::turn_ended`].
&self, ///
agent: AgentId, /// Default: delegates to [`InputMediator::bind_handle`], ignoring the submit config.
handle: PtyHandle, /// The infra adapter overrides it to stash the submit config.
_prompt_ready_pattern: Option<String>, fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, _submit: SubmitConfig) {
_submit: SubmitConfig,
) {
self.bind_handle(agent, handle); self.bind_handle(agent, handle);
} }
@ -212,12 +202,12 @@ pub trait InputMediator: Send + Sync {
} }
/// Déclare qu'`agent` vient d'être **lancé à froid** : la livraison de son tout /// 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 /// premier tour doit être *gatée* sur la readiness MCP (la `DelegationReady` est
/// différée jusqu'à l'apparition du prompt du CLI), pour éviter d'écrire la tâche /// différée jusqu'à la connexion du pont MCP du CLI), pour éviter d'écrire la tâche
/// avant que le CLI ait fini de booter (premier tour perdu sinon). /// avant que le CLI ait fini de booter (premier tour perdu sinon).
/// ///
/// À n'appeler **que** lorsqu'un prompt-ready watcher sera effectivement armé (le /// À n'appeler **que** lorsqu'un signal de readiness le libérera (le profil porte un
/// profil porte un `prompt_ready_pattern` non vide). Sans watcher pour le libérer, /// pont MCP ⇒ [`InputMediator::release_cold_start`]). Sans signal pour le libérer,
/// gater le premier tour le bloquerait indéfiniment ; dans ce cas l'orchestrateur ne /// 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 /// doit **pas** appeler `mark_starting` et l'`enqueue` livre la tâche immédiatement
/// (chemin chaud, fallback sûr, zéro régression). /// (chemin chaud, fallback sûr, zéro régression).
@ -229,10 +219,10 @@ pub trait InputMediator: Send + Sync {
/// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour /// 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 /// a été différé par [`InputMediator::mark_starting`] (démarrage à froid), c'est le
/// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à /// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à
/// `prompt_ready`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de /// `turn_ended`, 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à /// fin de tour. Idempotent : un second appel ne trouve rien et est un no-op. C'est
/// drainé) ne trouve rien et est un no-op. En OR avec le prompt-ready : le premier /// désormais le **seul** drain du tour différé (le signal MCP-initialize), le
/// arrivé draine, l'autre est no-op. /// watcher prompt-ready PTY ayant été supprimé.
/// ///
/// Default: no-op (médiateur qui ne gate pas les démarrages à froid). /// Default: no-op (médiateur qui ne gate pas les démarrages à froid).
fn release_cold_start(&self, _agent: AgentId) {} fn release_cold_start(&self, _agent: AgentId) {}
@ -254,9 +244,24 @@ pub trait InputMediator: Send + Sync {
/// is **not** an enqueue and correlates **no** ticket. /// is **not** an enqueue and correlates **no** ticket.
fn preempt(&self, agent: AgentId); fn preempt(&self, agent: AgentId);
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances. /// Marks `agent` free (explicit signal) so its FIFO advances.
fn mark_idle(&self, agent: AgentId); fn mark_idle(&self, agent: AgentId);
/// **End-of-turn** signal: the agent's transcript just recorded a completed turn
/// (the [`crate::ports::TurnWatcher`] fired), and **no** `idea_reply` carried a
/// result. This is the no-reply backstop trigger: the adapter captures the active
/// ticket, marks the agent `Idle` (advancing the FIFO), then arms a short **grace**
/// window — if no `idea_reply` correlates the ticket by then, it completes the turn
/// "without reply" (waking a parked caller with a typed error instead of leaving it
/// blocked until the long timeout). A late `idea_reply` within the grace wins.
///
/// Replaces the former prompt-ready watcher branch verbatim; only the **trigger**
/// changed (transcript `turn_duration` instead of a PTY prompt sigil). Default:
/// [`InputMediator::mark_idle`] (a mediator with no mailbox/grace just advances).
fn turn_ended(&self, agent: AgentId) {
self.mark_idle(agent);
}
/// Records a **proof of liveness** (« battement ») for `agent` — called on every /// Records a **proof of liveness** (« battement ») for `agent` — called on every
/// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`]) /// 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 /// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall

View File

@ -1305,6 +1305,46 @@ pub trait SessionInspector: Send + Sync {
) -> Result<ConversationDetails, InspectError>; ) -> Result<ConversationDetails, InspectError>;
} }
/// Callback fired **once per detected turn end** by a [`TurnWatcher`].
///
/// Invoked from the watcher's own polling task (never while holding a lock), so the
/// composition root can safely route it to
/// [`crate::input::InputMediator::turn_ended`] for the given agent.
pub type OnTurnEnd = Arc<dyn Fn(AgentId) + Send + Sync>;
/// Opaque handle to a live [`TurnWatcher::watch`]. **Dropping it stops the watch**
/// (the adapter's polling task must observe the drop and cease firing, idempotently).
pub trait TurnWatchHandle: Send + Sync {}
/// Observes an agent's **end-of-turn** signal from its on-disk transcript, replacing
/// the dead PTY prompt-ready sniff as the turn-end authority (rendez-vous no-reply
/// backstop). Model-specific: an adapter watches the CLI's transcript shape (e.g. the
/// Claude `turn_duration` record) and fires [`OnTurnEnd`] each time a turn completes.
///
/// Pure port (no async, no I/O in the trait): the adapter owns its polling task. The
/// composition root arms one watch per **live agent session** (keyed on `cwd`, the
/// agent's isolated run dir) and drops the handle on close/exit.
pub trait TurnWatcher: Send + Sync {
/// Whether this watcher knows how to read turn ends for the given profile
/// (mirrors [`SessionInspector::supports`] — e.g. recognising `CLAUDE.md`).
fn supports(&self, profile: &AgentProfile) -> bool;
/// Arms a watch over `agent`'s transcript under `cwd` (its run dir). The adapter
/// captures a **baseline** of turn ends already present at arm time and fires
/// `on_turn_end(agent)` only on increments **above** that baseline (so a
/// pre-existing transcript — relaunch / background wake — never triggers a phantom
/// turn end). `conversation_id` is an optional diagnostic label only — it is **not**
/// the file key (the transcript stem is the engine session id, unknown at cold
/// start). Returns a [`TurnWatchHandle`]; dropping it stops the watch.
fn watch(
&self,
agent: AgentId,
conversation_id: Option<String>,
cwd: ProjectPath,
on_turn_end: OnTurnEnd,
) -> Box<dyn TurnWatchHandle>;
}
/// Publish/subscribe domain events. Synchronous, in-process. /// Publish/subscribe domain events. Synchronous, in-process.
pub trait EventBus: Send + Sync { pub trait EventBus: Send + Sync {
/// Publishes an event. /// Publishes an event.

View File

@ -177,9 +177,9 @@ impl LivenessStrategy {
/// Motif déclaratif de détection d'une **limite de session/débit** pour un agent /// Motif déclaratif de détection d'une **limite de session/débit** pour un agent
/// **PTY/TUI sans adapter structuré** (ARCHITECTURE §21, niveau 2 de détection). /// **PTY/TUI sans adapter structuré** (ARCHITECTURE §21, niveau 2 de détection).
/// ///
/// Donnée **pure** (pas de code par CLI — Open/Closed, §9), calquée sur la /// Donnée **pure** (pas de code par CLI — Open/Closed, §9), un motif déclaratif
/// philosophie de [`AgentProfile::prompt_ready_pattern`] mais **plus riche** : là où /// littéral **plus riche** qu'une simple sous-chaîne : là où un sigil de prompt
/// le retour-de-prompt est une simple sous-chaîne littérale, la limite de session a /// serait une simple sous-chaîne littérale, la limite de session a
/// besoin d'**extraire une heure de reset** dans la sortie. Le domaine **ne stocke /// besoin d'**extraire une heure de reset** dans la sortie. Le domaine **ne stocke
/// que la donnée** (chaînes) ; le **moteur regex et le parsing d'heure vivent en /// que la donnée** (chaînes) ; le **moteur regex et le parsing d'heure vivent en
/// infrastructure** (composant `RateLimitParser`, dépendance `regex` ajoutée au seul /// infrastructure** (composant `RateLimitParser`, dépendance `regex` ajoutée au seul
@ -578,37 +578,6 @@ pub struct AgentProfile {
/// sérialisation : un profil sans MCP sérialise exactement comme avant. /// sérialisation : un profil sans MCP sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpCapability>, pub mcp: Option<McpCapability>,
/// Motif de **retour-de-prompt** (cadrage « conversation par paire » §6, lot C5).
///
/// Donnée **déclarative** (pas de code par CLI — Open/Closed) : un motif **littéral**
/// recherché par sous-chaîne dans le flux de sortie PTY du tour courant. Quand il
/// apparaît, IdeA considère l'agent **revenu au prompt** et le marque `Idle`
/// (`InputMediator::mark_idle`), ce qui fait avancer sa file. C'est l'un des deux
/// signaux du « double signal OR » (l'autre étant un `idea_reply` explicite) ; le
/// **premier** des deux qui arrive libère le tour.
///
/// **Choix tranché : littéral, pas regex.** Une sous-chaîne littérale est
/// déterministe, sans dépendance (`regex`), suffisante pour un sigil de prompt
/// stable (ex. `"\n> "`), et ne risque pas le faux-positif d'un motif regex mal
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
///
/// **Rang (chantier readiness/heartbeat, lot 1) : signal de repli n°3.** Depuis
/// l'introduction de la fin-de-tour structurée ([`crate::ports::ReplyEvent::Final`]
/// ⇒ [`crate::readiness::ReadinessSignal::TurnEnded`], signal n°1) et du signal
/// explicite `idea_reply` (n°2), ce sniff littéral est **rétrogradé** au rang de
/// repli : il ne sert plus que pour les agents **TUI/PTY sans adapter structuré**.
/// Conservé tel quel pour la rétro-compat (jamais supprimé).
///
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
/// `Busy` mais la file continue d'accepter » (jamais de faux `Idle`).
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
/// un profil sans motif sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_ready_pattern: Option<String>,
/// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut, /// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut,
/// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ /// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ
/// présent mais **non consommé** (place ménagée pour les seuils de stagnation / /// présent mais **non consommé** (place ménagée pour les seuils de stagnation /
@ -804,7 +773,6 @@ impl AgentProfile {
session, session,
structured_adapter: None, structured_adapter: None,
mcp: None, mcp: None,
prompt_ready_pattern: None,
liveness: None, liveness: None,
rate_limit_pattern: None, rate_limit_pattern: None,
submit_sequence: None, submit_sequence: None,
@ -831,15 +799,6 @@ impl AgentProfile {
self self
} }
/// Builder : fixe le motif de **retour-de-prompt** (§6, lot C5) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans détection de prompt ne l'appellent simplement pas.
#[must_use]
pub fn with_prompt_ready_pattern(mut self, pattern: impl Into<String>) -> Self {
self.prompt_ready_pattern = Some(pattern.into());
self
}
/// Builder : fixe la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie /// Builder : fixe la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie
/// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans réglage de vivacité ne l'appellent simplement pas. /// profils sans réglage de vivacité ne l'appellent simplement pas.
@ -1117,25 +1076,14 @@ mod mcp_tests {
); );
} }
// -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) -------------- // -- Backstop no-reply : le champ `promptReadyPattern` a été retiré ----------
#[test] #[test]
fn profile_default_has_no_prompt_ready_pattern() { fn legacy_json_with_obsolete_prompt_ready_pattern_is_ignored() {
// Existing profiles (built via `new`) carry no pattern ⇒ no false Idle. // Le watcher prompt-ready PTY est supprimé (remplacé par le TurnWatcher
assert!(profile_without_mcp().prompt_ready_pattern.is_none()); // transcript). Un `profiles.json` antérieur portant encore la clé doit
} // continuer à se désérialiser (pas de `deny_unknown_fields`) : la clé obsolète
// est simplement ignorée — zéro régression de lecture des profils existants.
#[test]
fn profile_without_prompt_pattern_omits_key_in_json() {
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
assert!(
!json.contains("promptReadyPattern"),
"a profile without a prompt pattern must NOT serialise the key (zero regression); got: {json}"
);
}
#[test]
fn legacy_json_without_prompt_pattern_deserialises_to_none() {
let legacy = r#"{ let legacy = r#"{
"id": "00000000-0000-0000-0000-000000000000", "id": "00000000-0000-0000-0000-000000000000",
"name": "Dev", "name": "Dev",
@ -1143,22 +1091,11 @@ mod mcp_tests {
"args": [], "args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": null, "detect": null,
"cwdTemplate": "{agentRunDir}" "cwdTemplate": "{agentRunDir}",
"promptReadyPattern": "? for shortcuts"
}"#; }"#;
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
assert!(profile.prompt_ready_pattern.is_none()); assert_eq!(profile.command, "claude");
}
#[test]
fn with_prompt_ready_pattern_sets_and_round_trips() {
let profile = profile_without_mcp().with_prompt_ready_pattern("\n> ");
assert_eq!(profile.prompt_ready_pattern.as_deref(), Some("\n> "));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("promptReadyPattern"), "key present: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> "));
} }
// -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------ // -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------

View File

@ -15,10 +15,11 @@
//! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`]. //! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`].
//! 2. **Signal n°2 — `idea_reply` explicite** : l'agent appelle l'outil MCP //! 2. **Signal n°2 — `idea_reply` explicite** : l'agent appelle l'outil MCP
//! [`crate::ports`]/délégation. Premier arrivé gagne avec le n°1. //! [`crate::ports`]/délégation. Premier arrivé gagne avec le n°1.
//! 3. **Signal n°3 — repli `prompt_ready_pattern`** : sniff littéral du sigil de //! 3. **Signal n°3 — fin de tour transcript** : l'agent a écrit une fin de tour dans
//! prompt dans la sortie PTY ([`crate::profile::AgentProfile::prompt_ready_pattern`]). //! son transcript on-disk (Claude `turn_duration`), observée par le
//! **Rétrogradé** au rang de repli depuis ce lot : il ne sert que pour les agents //! [`crate::ports::TurnWatcher`] qui appelle [`crate::input::InputMediator::turn_ended`].
//! TUI/PTY sans adapter structuré (rétro-compat, jamais supprimé). //! Backstop no-reply : remplace l'ancien sniff littéral du sigil de prompt PTY
//! (`prompt_ready_pattern`), supprimé car inopérant par tour.
//! //!
//! Les variantes [`ReadinessSignal::Stalled`]/[`ReadinessSignal::TimedOut`] sont la //! Les variantes [`ReadinessSignal::Stalled`]/[`ReadinessSignal::TimedOut`] sont la
//! place réservée au **lot 2** (détection de stagnation, remplacement des timeouts) : //! place réservée au **lot 2** (détection de stagnation, remplacement des timeouts) :
@ -40,8 +41,9 @@ pub enum ReadinessSignal {
/// est porté par le chemin de délégation, présent ici pour compléter le /// est porté par le chemin de délégation, présent ici pour compléter le
/// vocabulaire et le rendre explicite. /// vocabulaire et le rendre explicite.
ExplicitReply, ExplicitReply,
/// Le sigil de prompt PTY (repli n°3) est apparu. Idem : non produit par /// Une fin de tour transcript (repli n°3, [`crate::ports::TurnWatcher`]) est
/// `classify`, présent pour nommer le signal de repli legacy. /// apparue. Non produit par `classify` (qui ne voit que des [`ReplyEvent`]) :
/// présent pour nommer le signal de fin-de-tour observé hors flux structuré.
PromptReady, PromptReady,
/// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place /// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place
/// réservée au **lot 2** — non produit dans ce lot. /// réservée au **lot 2** — non produit dans ce lot.

View File

@ -47,23 +47,23 @@ struct BusyTracker {
liveness: Mutex<HashMap<AgentId, LivenessState>>, liveness: Mutex<HashMap<AgentId, LivenessState>>,
/// **Démarrage à froid** (fix race cold-launch) : ensemble des agents fraîchement /// **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* /// 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 /// sur la readiness MCP. Un agent y est inscrit par
/// [`BusyTracker::mark_starting`] (uniquement quand un watcher est effectivement /// [`BusyTracker::mark_starting`] (uniquement quand un pont MCP le libérera), puis
/// armé), puis consommé par l'`enqueue` qui démarre le tour : la `DelegationReady` /// 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 /// 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é. /// CLI n'a pas encore chargé ses outils MCP). Vide ⇒ comportement chaud inchangé.
starting: Mutex<HashSet<AgentId>>, starting: Mutex<HashSet<AgentId>>,
/// **Tour différé** (fix race cold-launch) : payload de la `DelegationReady` retenue /// **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 /// pour un démarrage à froid, publiée par [`BusyTracker::release_cold_start`] à la
/// du prompt (jamais avant). Absent ⇒ aucun tour en attente de gate. /// connexion du pont MCP (jamais avant). Absent ⇒ aucun tour en attente de gate.
deferred: Mutex<HashMap<AgentId, DeferredDelegation>>, deferred: Mutex<HashMap<AgentId, DeferredDelegation>>,
/// **Latch « déjà libéré »** (fix race cold-start, ordre inverse) : ensemble des /// **Latch « déjà libéré »** (fix race cold-start, ordre inverse) : ensemble des
/// agents pour lesquels un signal de readiness (`release_cold_start` / /// agents pour lesquels le signal de readiness (`release_cold_start`, pont MCP) est
/// `prompt_ready`) est arrivé **avant** que l'`enqueue` n'ait parqué son tour dans /// arrivé **avant** que l'`enqueue` n'ait parqué son tour dans
/// `deferred`. Sans ce latch, ce signal trouverait `deferred` vide, ne ferait rien /// `deferred`. Sans ce latch, ce signal trouverait `deferred` vide, ne ferait rien
/// d'utile (l'agent encore `starting` ⇒ pas de `mark_idle`), puis l'`enqueue` /// d'utile (l'agent encore `starting` ⇒ pas de `mark_idle`), puis l'`enqueue`
/// parquerait le tour dans `deferred` — où il resterait à jamais (Claude Code n'a pas /// parquerait le tour dans `deferred` — où il resterait à jamais (le watcher
/// de `prompt_ready_pattern`, donc aucun second signal ne viendrait le drainer). Le /// prompt-ready PTY ayant été supprimé, aucun second signal ne viendrait le drainer). Le
/// latch enregistre « cet agent en démarrage est déjà prêt » : l'`enqueue` qui suit /// latch enregistre « cet agent en démarrage est déjà prêt » : l'`enqueue` qui suit
/// livre alors **immédiatement** au lieu de parquer. Consommé (retiré) à la livraison /// livre alors **immédiatement** au lieu de parquer. Consommé (retiré) à la livraison
/// ⇒ exactement-une-fois, quel que soit l'ordre. Vide ⇒ comportement inchangé. /// ⇒ exactement-une-fois, quel que soit l'ordre. Vide ⇒ comportement inchangé.
@ -177,12 +177,11 @@ impl BusyTracker {
} }
/// Marque un agent **en démarrage à froid** : son tout premier tour devra être /// 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 à /// *gaté* sur la readiness MCP (la `DelegationReady` sera différée jusqu'à la
/// l'apparition du prompt). À n'appeler **que** lorsqu'un watcher est effectivement /// connexion du pont MCP). À n'appeler **que** lorsqu'un pont MCP libérera le tour
/// armé (un `prompt_ready_pattern` non vide est configuré), sinon le premier tour /// (`release_cold_start`), sinon le premier tour resterait bloqué indéfiniment
/// resterait bloqué indéfiniment (aucun signal ne viendrait le libérer). Sans cet /// (aucun signal ne viendrait le libérer). Sans cet appel, l'`enqueue` publie la
/// appel, l'`enqueue` publie la `DelegationReady` immédiatement (chemin chaud, /// `DelegationReady` immédiatement (chemin chaud, zéro régression).
/// zéro régression).
fn mark_starting(&self, agent: AgentId) { fn mark_starting(&self, agent: AgentId) {
application::diag!("[input-mediator] mark_starting cold-start gate armed agent={agent}"); application::diag!("[input-mediator] mark_starting cold-start gate armed agent={agent}");
self.lock_starting().insert(agent); self.lock_starting().insert(agent);
@ -352,58 +351,29 @@ impl BusyTracker {
} }
} }
/// Signal **prompt-ready** émis par le watcher à l'apparition du marqueur. /// Signal de **fin de tour** émis par le [`domain::ports::TurnWatcher`] à l'apparition
/// d'une fin de tour dans le transcript (Claude `turn_duration`). Remplace **verbatim**
/// l'ancienne branche `None` du watcher prompt-ready PTY supprimé : seul le déclencheur
/// change (transcript au lieu d'un sigil PTY).
/// ///
/// - Si un tour de démarrage à froid est **différé** pour cet agent : c'est le /// La cible a terminé son tour sans `idea_reply`. On capture le ticket actif AVANT
/// moment de le livrer ⇒ on publie la `DelegationReady` retenue et l'agent **reste /// `mark_idle` (qui efface l'état Busy), puis on libère le busy state / la FIFO. La
/// Busy** (son premier tour court désormais réellement). Le tour ne se terminera /// fin de tour ne porte AUCUNE réponse : pour ne pas laisser l'appelant bloqué jusqu'au
/// que sur un `idea_reply` ou un prochain prompt-ready (qui, lui, fera `mark_idle`). /// timeout long, on **arme une fenêtre de grâce** G — si aucun `idea_reply` n'a corrélé
/// - Sinon : comportement historique ⇒ `mark_idle` (fait avancer la FIFO). /// le ticket d'ici là, on complète le tour « sans réponse » (réveille l'appelant avec
fn prompt_ready(&self, agent: AgentId) { /// une erreur typée). Un `idea_reply` arrivé pendant G gagne (la complétion devient un
// On ne retire l'agent de `starting` qu'ici : tant que son premier tour n'a pas /// no-op, tête déjà retirée). Sur un agent déjà `Idle` (aucun ticket actif) ⇒ simple
// été livré, un re-arming éventuel doit rester gaté. On capture `was_starting` /// `mark_idle` idempotent, pas de grâce.
// pour distinguer le retour-au-prompt d'un tour chaud (fin de tour ⇒ `mark_idle` + fn turn_ended(&self, agent: AgentId) {
// grâce) du prompt-ready arrivé AVANT l'enqueue différé d'un démarrage à froid.
let was_starting = self.lock_starting().remove(&agent);
let deferred = self.lock_deferred().remove(&agent);
match deferred {
Some(d) => {
application::diag!(
"[input-mediator] prompt_ready released deferred delegation agent={agent} ticket={}",
d.ticket
);
self.publish_deferred(agent, d);
}
// **Ordre inverse de la race** : l'agent est encore en démarrage à froid mais
// aucun tour n'est parqué ⇒ le prompt-ready est arrivé AVANT l'enqueue. On pose
// le latch « déjà libéré » et SURTOUT pas de `mark_idle`/grâce : le tour n'a pas
// encore couru, il n'y a aucun appelant à réveiller. L'`enqueue` qui suit verra
// le latch et livrera immédiatement (exactement-une-fois).
None if was_starting => {
application::diag!(
"[input-mediator] prompt_ready before deferred enqueue (cold-start) -> released latch agent={agent}"
);
self.lock_released().insert(agent);
}
None => {
// Pas de tour différé : la cible est revenue à son prompt sans
// `idea_reply`. On capture le ticket actif AVANT `mark_idle` (qui efface
// l'état Busy), puis on libère le busy state / la FIFO comme avant. Le
// prompt-ready ne porte AUCUNE réponse (`no_reply_payload`) : pour ne pas
// laisser l'appelant bloqué jusqu'au timeout long, on **arme une fenêtre
// de grâce** G — si aucun `idea_reply` n'a corrélé le ticket d'ici là, on
// complète le tour « sans réponse » (réveille l'appelant avec une erreur
// typée). Un `idea_reply` arrivé pendant G gagne (la complétion devient
// un no-op, tête déjà retirée).
let active = self.lock().get(&agent).and_then(AgentBusyState::ticket); let active = self.lock().get(&agent).and_then(AgentBusyState::ticket);
application::diag!( application::diag!(
"[input-mediator] prompt_ready agent={agent} no_reply_payload -> mark_idle + grace" "[input-mediator] turn_ended agent={agent} no_reply_payload -> mark_idle + grace"
); );
self.mark_idle(agent); self.mark_idle(agent);
if let (Some(ticket), Some(mailbox)) = (active, self.mailbox.clone()) { if let (Some(ticket), Some(mailbox)) = (active, self.mailbox.clone()) {
let grace = self.grace; let grace = self.grace;
application::diag!( application::diag!(
"[input-mediator] prompt_ready arming grace agent={agent} ticket={ticket} grace_ms={}", "[input-mediator] turn_ended arming grace agent={agent} ticket={ticket} grace_ms={}",
grace.as_millis(), grace.as_millis(),
); );
// Thread détaché (jamais de hot loop, jamais de blocage de l'appelant) : // Thread détaché (jamais de hot loop, jamais de blocage de l'appelant) :
@ -415,14 +385,12 @@ impl BusyTracker {
}); });
} }
} }
}
}
/// Libère un premier tour différé sur **readiness de démarrage** (connexion du pont /// 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 /// 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, /// 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 /// pas de fin de tour). Idempotent : c'est le seul drain du tour différé (le `remove`
/// `Some` qu'une fois). /// ne rend `Some` qu'une fois).
fn release_cold_start(&self, agent: AgentId) { fn release_cold_start(&self, agent: AgentId) {
let was_starting = self.lock_starting().remove(&agent); let was_starting = self.lock_starting().remove(&agent);
if let Some(d) = self.lock_deferred().remove(&agent) { if let Some(d) = self.lock_deferred().remove(&agent) {
@ -436,8 +404,8 @@ impl BusyTracker {
// **Ordre inverse de la race** : la readiness MCP est arrivée AVANT que // **Ordre inverse de la race** : la readiness MCP est arrivée AVANT que
// l'`enqueue` ait parqué son tour. On pose le latch « déjà libéré » : le // l'`enqueue` ait parqué son tour. On pose le latch « déjà libéré » : le
// prochain `enqueue` (agent encore vu comme démarrant) livrera immédiatement au // prochain `enqueue` (agent encore vu comme démarrant) livrera immédiatement au
// lieu de parquer dans `deferred` — où le tour resterait à jamais (Claude Code // lieu de parquer dans `deferred` — où le tour resterait à jamais (le watcher
// n'ayant pas de prompt_ready_pattern, aucun second signal ne le drainerait). // prompt-ready PTY ayant été supprimé, aucun second signal ne le drainerait).
// Hors démarrage à froid (`was_starting == false`), aucun latch : no-op. // Hors démarrage à froid (`was_starting == false`), aucun latch : no-op.
application::diag!( application::diag!(
"[input-mediator] release_cold_start before deferred enqueue (cold-start) -> released latch agent={agent}" "[input-mediator] release_cold_start before deferred enqueue (cold-start) -> released latch agent={agent}"
@ -528,10 +496,6 @@ pub struct MediatedInbox {
/// `set_stall_threshold` and consumed to arm a fresh liveness window on the enqueue /// `set_stall_threshold` and consumed to arm a fresh liveness window on the enqueue
/// that starts a turn. Absent ⇒ `None` (no stall detection — legacy behaviour). /// that starts a turn. Absent ⇒ `None` (no stall detection — legacy behaviour).
stall: Mutex<HashMap<AgentId, Option<u32>>>, stall: Mutex<HashMap<AgentId, Option<u32>>>,
/// Agents whose prompt-ready watcher thread is already armed, so re-binding the same
/// handle does not spawn a duplicate watcher (lot C5). Shared (`Arc`) because each
/// watcher thread un-arms its own entry on exit.
watched: Arc<Mutex<HashSet<AgentId>>>,
/// Fenêtre de grâce (G) propagée au [`BusyTracker`] à chaque (re)construction. /// Fenêtre de grâce (G) propagée au [`BusyTracker`] à chaque (re)construction.
/// Prod = [`PROMPT_READY_GRACE`] (2 s) ; surchargée par [`Self::with_grace`] en test. /// Prod = [`PROMPT_READY_GRACE`] (2 s) ; surchargée par [`Self::with_grace`] en test.
grace: Duration, grace: Duration,
@ -561,7 +525,6 @@ impl MediatedInbox {
front_owned, front_owned,
submit: Mutex::new(HashMap::new()), submit: Mutex::new(HashMap::new()),
stall: Mutex::new(HashMap::new()), stall: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
grace: PROMPT_READY_GRACE, grace: PROMPT_READY_GRACE,
} }
} }
@ -753,7 +716,6 @@ impl MediatedInbox {
front_owned, front_owned,
submit: Mutex::new(HashMap::new()), submit: Mutex::new(HashMap::new()),
stall: Mutex::new(HashMap::new()), stall: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
grace: PROMPT_READY_GRACE, grace: PROMPT_READY_GRACE,
} }
} }
@ -801,73 +763,6 @@ impl MediatedInbox {
Arc::clone(&self.mailbox) Arc::clone(&self.mailbox)
} }
fn watched(&self) -> std::sync::MutexGuard<'_, HashSet<AgentId>> {
self.watched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Arms the **prompt-ready watcher** for `agent` (lot C5).
///
/// Requires a wired [`PtyPort`] **and** a non-empty literal `pattern`. Spawns a
/// detached thread that consumes the handle's output stream and calls
/// [`BusyTracker::mark_idle`] the **first** time `pattern` appears as a substring of
/// the cumulative output, then exits (one-shot per arming). A sliding buffer keeps
/// the last `pattern.len()-1` bytes so a marker split across two chunks still
/// matches. No watcher is armed when the port is absent or the pattern is empty
/// (no detection ⇒ Idle only via explicit signal/timeout — the safe fallback).
///
/// Re-arming the same agent is a no-op while a watcher is already live (tracked in
/// `watched`), so re-binding a handle never spawns duplicate watchers.
fn arm_prompt_watcher(&self, agent: AgentId, handle: &PtyHandle, pattern: String) {
if pattern.is_empty() {
return;
}
let Some(pty) = self.pty.clone() else {
return;
};
// Already watching this agent ⇒ keep the live watcher (avoid duplicates).
if !self.watched().insert(agent) {
return;
}
let stream = match pty.subscribe_output(handle) {
Ok(s) => s,
Err(_) => {
// Could not subscribe (unknown handle): un-arm so a later bind retries.
self.watched().remove(&agent);
return;
}
};
let tracker = Arc::clone(&self.tracker);
let watched = Arc::clone(&self.watched);
let needle = pattern.into_bytes();
std::thread::spawn(move || {
// Cumulative tail kept small: just enough to catch a marker split across two
// chunks (keep the last needle.len()-1 bytes between reads).
let keep = needle.len().saturating_sub(1);
let mut window: Vec<u8> = Vec::with_capacity(keep + 1);
for chunk in stream {
window.extend_from_slice(&chunk);
if window.windows(needle.len()).any(|w| w == needle.as_slice()) {
// 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 {
let drop_to = window.len() - keep;
window.drain(..drop_to);
}
}
// Watcher done (matched or stream closed at EOF): un-arm so a future bind
// (e.g. after a relaunch) can re-arm a fresh watcher.
watched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&agent);
});
}
} }
/// Composes the line delivered into the target's terminal for a delegated turn. /// Composes the line delivered into the target's terminal for a delegated turn.
@ -981,10 +876,10 @@ impl InputMediator for MediatedInbox {
// dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`). // dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`).
let text = delegation_preamble(&ticket.requester, ticket_id, &ticket.task); let text = delegation_preamble(&ticket.requester, ticket_id, &ticket.task);
// **Fix race cold-launch** : si l'agent est en démarrage à froid (inscrit // **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 // par `mark_starting` quand un pont MCP le libérera), ON DIFFÈRE la
// `DelegationReady` jusqu'au prompt-ready (le CLI n'a pas encore affiché // `DelegationReady` jusqu'à la readiness MCP (le CLI n'a pas encore chargé
// son prompt — la livrer maintenant perdrait le premier tour). Le watcher // ses outils — la livrer maintenant perdrait le premier tour).
// la publiera via `prompt_ready`. Agent chaud (pas dans `starting`) ⇒ // `release_cold_start` la publiera. Agent chaud (pas dans `starting`) ⇒
// publication immédiate, comportement inchangé. // publication immédiate, comportement inchangé.
let deferred = DeferredDelegation { let deferred = DeferredDelegation {
ticket: ticket_id, ticket: ticket_id,
@ -994,7 +889,7 @@ impl InputMediator for MediatedInbox {
}; };
if self.tracker.lock_starting().contains(&agent) { if self.tracker.lock_starting().contains(&agent) {
// Agent en démarrage à froid. Si un signal de readiness est DÉJÀ arrivé // Agent en démarrage à froid. Si un signal de readiness est DÉJÀ arrivé
// (latch « déjà libéré » posé par `release_cold_start`/`prompt_ready` // (latch « déjà libéré » posé par `release_cold_start`
// AVANT cet enqueue — l'ordre inverse de la race), on consomme le latch, // AVANT cet enqueue — l'ordre inverse de la race), on consomme le latch,
// on retire l'agent du gate et on livre TOUT DE SUITE : sinon ce tour // on retire l'agent du gate et on livre TOUT DE SUITE : sinon ce tour
// serait parqué dans `deferred` sans plus aucun signal pour le drainer // serait parqué dans `deferred` sans plus aucun signal pour le drainer
@ -1029,30 +924,20 @@ impl InputMediator for MediatedInbox {
self.handles().insert(agent, handle); self.handles().insert(agent, handle);
} }
fn bind_handle_with_prompt( fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, submit: SubmitConfig) {
&self, // Register the input handle exactly like `bind_handle` and stash the target's
agent: AgentId, // submit config (echoed on `DelegationReady` at the next turn start, §20.3).
handle: PtyHandle, // Turn-end detection is NO LONGER armed here: the dead PTY prompt-ready watcher
prompt_ready_pattern: Option<String>, // was replaced by the transcript `TurnWatcher` (armed at the composition root,
submit: SubmitConfig, // once per live session) which calls `turn_ended`.
) {
// Register the input handle exactly like `bind_handle`, stash the target's
// submit config (echoed on `DelegationReady` at the next turn start, §20.3),
// then arm the prompt-ready watcher when the profile declares a literal marker
// (C5). A `None`/empty pattern arms nothing: Idle then comes only from the
// explicit signal or the per-turn timeout (safe fallback, never a false Idle).
eprintln!( eprintln!(
"[input-mediator] bind handle with prompt agent={agent} handle={} prompt_ready={} submit_sequence={} submit_delay_ms={:?}", "[input-mediator] bind handle with submit agent={agent} handle={} submit_sequence={} submit_delay_ms={:?}",
handle.session_id, handle.session_id,
prompt_ready_pattern.as_deref().unwrap_or("<none>"),
describe_submit_sequence(submit.sequence.as_deref()), describe_submit_sequence(submit.sequence.as_deref()),
submit.delay_ms, submit.delay_ms,
); );
self.handles().insert(agent, handle.clone()); self.handles().insert(agent, handle);
self.submit().insert(agent, submit); self.submit().insert(agent, submit);
if let Some(pattern) = prompt_ready_pattern {
self.arm_prompt_watcher(agent, &handle, pattern);
}
} }
fn delivers_turn(&self, agent: AgentId) -> bool { fn delivers_turn(&self, agent: AgentId) -> bool {
@ -1061,9 +946,9 @@ impl InputMediator for MediatedInbox {
fn mark_starting(&self, agent: AgentId) { fn mark_starting(&self, agent: AgentId) {
// Gate du premier tour d'un agent froid : appelé par l'orchestrateur juste après // 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 // un (re)lancement à froid, AVANT le bind/enqueue, et uniquement si un signal de
// sera armé (cf. doc du trait). Consommé par l'`enqueue` qui démarre le tour // readiness le libérera (pont MCP). Consommé par l'`enqueue` qui démarre le tour
// (diffère la `DelegationReady`) puis par `prompt_ready` (la libère). // (diffère la `DelegationReady`) puis par `release_cold_start` (la libère).
self.tracker.mark_starting(agent); self.tracker.mark_starting(agent);
} }
@ -1102,12 +987,19 @@ impl InputMediator for MediatedInbox {
} }
fn mark_idle(&self, agent: AgentId) { fn mark_idle(&self, agent: AgentId) {
// Single authority (also used by the prompt-ready watcher): real Busy→Idle only, // Single authority: real Busy→Idle only, publishing AgentBusyChanged{busy:false}
// publishing AgentBusyChanged{busy:false} once. Also clears the liveness entry // once. Also clears the liveness entry (fin de tour) — émet la reprise si l'agent
// (fin de tour) — émet la reprise si l'agent était `Stalled`. // était `Stalled`.
self.tracker.mark_idle(agent); self.tracker.mark_idle(agent);
} }
fn turn_ended(&self, agent: AgentId) {
// Déclenché par le `TurnWatcher` transcript (Claude `turn_duration`) : fin de tour
// sans `idea_reply` ⇒ `mark_idle` + fenêtre de grâce pour réveiller l'appelant
// parqué (cf. [`BusyTracker::turn_ended`]). Backstop no-reply.
self.tracker.turn_ended(agent);
}
fn mark_alive(&self, agent: AgentId) { fn mark_alive(&self, agent: AgentId) {
// Un battement (delta / activité / heartbeat) rafraîchit `last_seen` et ramène // Un battement (delta / activité / heartbeat) rafraîchit `last_seen` et ramène
// l'agent à `Alive` s'il était `Stalled` (lot 2). // l'agent à `Alive` s'il était `Stalled` (lot 2).
@ -1318,12 +1210,7 @@ mod tests {
let h = handle(1); let h = handle(1);
// Bind the target's submit config (resolved from its profile by the service). // Bind the target's submit config (resolved from its profile by the service).
inbox.bind_handle_with_prompt( inbox.bind_handle_with_submit(a, h, SubmitConfig::new(Some("\r".to_owned()), Some(42)));
a,
h,
None,
SubmitConfig::new(Some("\r".to_owned()), Some(42)),
);
// A frontend cell is mounted ⇒ delivery flows through the event (the front // A frontend cell is mounted ⇒ delivery flows through the event (the front
// writes). This test asserts the event carries the bound submit config. // writes). This test asserts the event carries the bound submit config.
inbox.set_front_attached(a, true); inbox.set_front_attached(a, true);
@ -1364,7 +1251,7 @@ mod tests {
) )
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = agent(1);
inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default()); inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
inbox.set_front_attached(a, true); inbox.set_front_attached(a, true);
inbox.enqueue(a, ticket(10, "do X")); inbox.enqueue(a, ticket(10, "do X"));
@ -1397,7 +1284,7 @@ mod tests {
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = agent(1);
// No set_front_attached ⇒ headless. // No set_front_attached ⇒ headless.
inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default()); inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
inbox.enqueue(a, ticket(10, "do X")); inbox.enqueue(a, ticket(10, "do X"));
@ -1432,7 +1319,7 @@ mod tests {
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = agent(1);
let task = format!("début {} fin", "x".repeat(1200)); let task = format!("début {} fin", "x".repeat(1200));
inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default()); inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
inbox.enqueue(a, ticket(10, &task)); inbox.enqueue(a, ticket(10, &task));
@ -1569,34 +1456,25 @@ mod tests {
} }
// ==================================================================== // ====================================================================
// Lot C5 — prompt-ready detection on the PTY output stream // Headless delivery — the backend writes the PTY when no front cell is mounted
// ==================================================================== // ====================================================================
use domain::ids::SessionId; use domain::ids::SessionId;
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle as Handle, SpawnSpec}; use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle as Handle, SpawnSpec};
use domain::terminal::PtySize; use domain::terminal::PtySize;
/// A fake [`PtyPort`] whose `subscribe_output` replays a fixed list of chunks then /// A fake [`PtyPort`] that records `write`s (for the headless-delivery tests).
/// ends (EOF), so the watcher thread sees a deterministic, finite stream. `write` is /// `subscribe_output` yields an empty stream; `spawn`/`resize`/`kill`/`scrollback`
/// recorded; `spawn`/`resize`/`kill`/`scrollback` are unused stubs for these tests. /// are unused stubs.
struct FakePty { struct FakePty {
chunks: Mutex<HashMap<SessionId, Vec<Vec<u8>>>>,
writes: Mutex<Vec<Vec<u8>>>, writes: Mutex<Vec<Vec<u8>>>,
} }
impl FakePty { impl FakePty {
fn new() -> Self { fn new() -> Self {
Self { Self {
chunks: Mutex::new(HashMap::new()),
writes: Mutex::new(Vec::new()), writes: Mutex::new(Vec::new()),
} }
} }
/// Seeds the chunks a later `subscribe_output(handle)` will replay.
fn seed(&self, handle: &Handle, chunks: Vec<Vec<u8>>) {
self.chunks
.lock()
.unwrap()
.insert(handle.session_id.clone(), chunks);
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl PtyPort for FakePty { impl PtyPort for FakePty {
@ -1612,15 +1490,8 @@ mod tests {
fn resize(&self, _handle: &Handle, _size: PtySize) -> Result<(), PtyError> { fn resize(&self, _handle: &Handle, _size: PtySize) -> Result<(), PtyError> {
Ok(()) Ok(())
} }
fn subscribe_output(&self, handle: &Handle) -> Result<OutputStream, PtyError> { fn subscribe_output(&self, _handle: &Handle) -> Result<OutputStream, PtyError> {
let chunks = self Ok(Box::new(std::iter::empty()))
.chunks
.lock()
.unwrap()
.get(&handle.session_id)
.cloned()
.unwrap_or_default();
Ok(Box::new(chunks.into_iter()))
} }
fn scrollback(&self, _handle: &Handle) -> Result<Vec<u8>, PtyError> { fn scrollback(&self, _handle: &Handle) -> Result<Vec<u8>, PtyError> {
Ok(Vec::new()) Ok(Vec::new())
@ -1636,8 +1507,8 @@ mod tests {
} }
} }
/// Spins until `cond` holds or the deadline passes (the watcher runs on its own /// Spins until `cond` holds or the deadline passes (headless writes happen on a
/// thread, so the transition is observed asynchronously). /// detached thread, so the effect is observed asynchronously).
fn wait_until(mut cond: impl FnMut() -> bool) -> bool { fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < deadline { while std::time::Instant::now() < deadline {
@ -1649,50 +1520,35 @@ mod tests {
cond() cond()
} }
fn inbox_with(pty: Arc<FakePty>) -> MediatedInbox { // --- backstop no-reply : `turn_ended` (fin de tour transcript) ⇒ réveil après G ---
MediatedInbox::with_pty( //
Arc::new(InMemoryMailbox::new()), // Le déclencheur n'est plus un watcher PTY mais le `TurnWatcher` transcript ; on
Arc::new(FixedClock(1)), // appelle donc `turn_ended` directement (le watcher est testé à part dans
pty as Arc<dyn PtyPort>, // `inspector::claude_turn_watcher`). La logique grâce/complétion est inchangée.
)
}
/// Builds a PTY-backed inbox with a **short grace** so the no-reply path is /// Builds a grace-shortened inbox (no PTY needed: `turn_ended` is invoked directly).
/// observable in milliseconds (prod grace is 2 s). fn inbox_grace(grace: Duration) -> MediatedInbox {
fn inbox_with_grace(pty: Arc<FakePty>, grace: Duration) -> MediatedInbox { MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
pty as Arc<dyn PtyPort>,
)
.with_grace(grace) .with_grace(grace)
} }
// --- fix: prompt-ready sans idea_reply ⇒ réveil de l'appelant après G ---------- /// La cible termine son tour SANS `idea_reply` : après la fenêtre de grâce,
/// La cible revient à son prompt SANS `idea_reply` : après la fenêtre de grâce,
/// l'appelant en attente est réveillé avec `ReturnedToPromptNoReply` — pas de /// l'appelant en attente est réveillé avec `ReturnedToPromptNoReply` — pas de
/// blocage jusqu'au timeout long (le bug corrigé). /// blocage jusqu'au timeout long (le bug corrigé).
#[tokio::test] #[tokio::test]
async fn prompt_ready_without_reply_wakes_caller_after_grace() { async fn turn_ended_without_reply_wakes_caller_after_grace() {
let pty = Arc::new(FakePty::new());
let a = agent(1); let a = agent(1);
let h = handle(50); let inbox = inbox_grace(Duration::from_millis(40));
pty.seed(&h, vec![b"done\n> ".to_vec()]);
let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(40));
let pending = inbox.enqueue(a, ticket(10, "task")); let pending = inbox.enqueue(a, ticket(10, "task"));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); inbox.turn_ended(a);
// Bounded await: the grace (40 ms) must resolve the caller well under this. // Bounded await: the grace (40 ms) must resolve the caller well under this.
let res = tokio::time::timeout(Duration::from_secs(2), pending) let res = tokio::time::timeout(Duration::from_secs(2), pending)
.await .await
.expect("must NOT hang until the long timeout") .expect("must NOT hang until the long timeout")
.expect("a turn outcome, not a closed channel"); .expect("a turn outcome, not a closed channel");
assert_eq!( assert_eq!(res, domain::mailbox::TurnResolution::ReturnedToPromptNoReply);
res,
domain::mailbox::TurnResolution::ReturnedToPromptNoReply
);
assert_eq!( assert_eq!(
inbox.mailbox().pending(&a), inbox.mailbox().pending(&a),
0, 0,
@ -1700,20 +1556,17 @@ mod tests {
); );
} }
/// `idea_reply` arrivé AVANT le prompt-ready gagne : l'appelant reçoit le `Replied` /// `idea_reply` arrivé AVANT la fin de tour gagne : l'appelant reçoit le `Replied`
/// et la complétion de grâce (tête déjà retirée) est un no-op. /// et la complétion de grâce (tête déjà retirée) est un no-op.
#[tokio::test] #[tokio::test]
async fn reply_before_prompt_ready_wins() { async fn reply_before_turn_ended_wins() {
let pty = Arc::new(FakePty::new());
let a = agent(1); let a = agent(1);
let h = handle(51); let inbox = inbox_grace(Duration::from_millis(40));
pty.seed(&h, vec![b"done\n> ".to_vec()]);
let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(40));
let pending = inbox.enqueue(a, ticket(10, "task")); let pending = inbox.enqueue(a, ticket(10, "task"));
// idea_reply resolves the head BEFORE the watcher fires prompt-ready. // idea_reply resolves the head BEFORE the turn-end signal.
inbox.mailbox().resolve(a, "answer".to_owned()).unwrap(); inbox.mailbox().resolve(a, "answer".to_owned()).unwrap();
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); inbox.turn_ended(a);
let res = tokio::time::timeout(Duration::from_secs(2), pending) let res = tokio::time::timeout(Duration::from_secs(2), pending)
.await .await
@ -1725,24 +1578,18 @@ mod tests {
); );
} }
/// `idea_reply` arrivé APRÈS le prompt-ready mais DANS la fenêtre de grâce gagne : /// `idea_reply` arrivé APRÈS la fin de tour mais DANS la fenêtre de grâce gagne :
/// avec une grâce longue, on résout via la mailbox avant son expiration. /// avec une grâce longue, on résout via la mailbox avant son expiration.
#[tokio::test] #[tokio::test]
async fn reply_within_grace_after_prompt_ready_wins() { async fn reply_within_grace_after_turn_ended_wins() {
let pty = Arc::new(FakePty::new());
let a = agent(1); let a = agent(1);
let h = handle(52);
pty.seed(&h, vec![b"done\n> ".to_vec()]);
// Long grace ⇒ the reply lands well within it. // Long grace ⇒ the reply lands well within it.
let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_secs(30)); let inbox = inbox_grace(Duration::from_secs(30));
let pending = inbox.enqueue(a, ticket(10, "task")); let pending = inbox.enqueue(a, ticket(10, "task"));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); inbox.turn_ended(a);
// Let the watcher fire prompt-ready (arms the grace), then reply within it. // turn_ended a marqué Idle et armé la grâce ; on répond pendant la grâce.
assert!( assert!(!inbox.busy_state(a).is_busy(), "turn_ended marks idle (grace armed)");
wait_until(|| !inbox.busy_state(a).is_busy()),
"prompt-ready marks idle (grace armed)"
);
inbox.mailbox().resolve(a, "in-time".to_owned()).unwrap(); inbox.mailbox().resolve(a, "in-time".to_owned()).unwrap();
let res = tokio::time::timeout(Duration::from_secs(2), pending) let res = tokio::time::timeout(Duration::from_secs(2), pending)
@ -1759,23 +1606,17 @@ mod tests {
/// été retirée par la complétion) — typé, idempotent, jamais un panic. /// été retirée par la complétion) — typé, idempotent, jamais un panic.
#[tokio::test] #[tokio::test]
async fn reply_after_grace_is_unmatched() { async fn reply_after_grace_is_unmatched() {
let pty = Arc::new(FakePty::new());
let a = agent(1); let a = agent(1);
let h = handle(53); let inbox = inbox_grace(Duration::from_millis(30));
pty.seed(&h, vec![b"done\n> ".to_vec()]);
let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(30));
let pending = inbox.enqueue(a, ticket(10, "task")); let pending = inbox.enqueue(a, ticket(10, "task"));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); inbox.turn_ended(a);
// The caller is woken by the grace completion (head retired). // The caller is woken by the grace completion (head retired).
let res = tokio::time::timeout(Duration::from_secs(2), pending) let res = tokio::time::timeout(Duration::from_secs(2), pending)
.await .await
.expect("no hang") .expect("no hang")
.expect("outcome"); .expect("outcome");
assert_eq!( assert_eq!(res, domain::mailbox::TurnResolution::ReturnedToPromptNoReply);
res,
domain::mailbox::TurnResolution::ReturnedToPromptNoReply
);
// A late idea_reply now has no head to resolve ⇒ typed NoPendingRequest. // A late idea_reply now has no head to resolve ⇒ typed NoPendingRequest.
assert!( assert!(
@ -1788,15 +1629,12 @@ mod tests {
/// retirer la tête — `complete_without_reply` skip quand le receiver est fermé. /// retirer la tête — `complete_without_reply` skip quand le receiver est fermé.
#[test] #[test]
fn fire_and_forget_head_is_preserved_through_grace() { fn fire_and_forget_head_is_preserved_through_grace() {
let pty = Arc::new(FakePty::new());
let a = agent(1); let a = agent(1);
let h = handle(54); let inbox = inbox_grace(Duration::from_millis(20));
pty.seed(&h, vec![b"done\n> ".to_vec()]);
let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(20));
// Human submit: the reply handle is dropped immediately (not awaited). // Human submit: the reply handle is dropped immediately (not awaited).
drop(inbox.enqueue(a, ticket(10, "human submit"))); drop(inbox.enqueue(a, ticket(10, "human submit")));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); inbox.turn_ended(a);
// Give the grace thread time to run; the head must survive (skip-closed). // Give the grace thread time to run; the head must survive (skip-closed).
std::thread::sleep(Duration::from_millis(120)); std::thread::sleep(Duration::from_millis(120));
assert_eq!( assert_eq!(
@ -1806,228 +1644,23 @@ mod tests {
); );
} }
/// `turn_ended` sur un agent déjà `Idle` (aucun ticket actif) ⇒ simple `mark_idle`
/// idempotent, aucune grâce armée, aucun panic.
#[test] #[test]
fn prompt_pattern_present_and_output_contains_it_flips_busy_to_idle() { fn turn_ended_on_idle_agent_is_noop() {
let pty = Arc::new(FakePty::new());
let a = agent(1); let a = agent(1);
let h = handle(1); let inbox = inbox_grace(Duration::from_millis(20));
pty.seed(&h, vec![b"working...\n".to_vec(), b"done\n> ".to_vec()]); inbox.turn_ended(a); // jamais de tour démarré.
let inbox = inbox_with(Arc::clone(&pty)); assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
inbox.enqueue(a, ticket(10, "task"));
assert!(inbox.busy_state(a).is_busy(), "enqueue starts a turn");
// Arm prompt detection with the literal marker "\n> " (a stable prompt sigil).
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
assert!(
wait_until(|| !inbox.busy_state(a).is_busy()),
"prompt marker in output ⇒ Busy→Idle"
);
}
#[test]
fn prompt_marker_split_across_two_chunks_still_matches() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(2);
// The marker "READY" straddles two chunks: "REA" | "DY done".
pty.seed(&h, vec![b"out REA".to_vec(), b"DY done".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some("READY".to_owned()), SubmitConfig::default());
assert!(
wait_until(|| !inbox.busy_state(a).is_busy()),
"a marker split across chunks must still flip to Idle"
);
}
#[test]
fn no_pattern_in_profile_never_marks_idle_even_with_output() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(3);
pty.seed(&h, vec![b"lots of output\n> $ done\n".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
// No pattern: bind without arming a watcher (the safe fallback).
inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default());
// Give any (erroneously spawned) watcher a chance to fire — it must not.
std::thread::sleep(std::time::Duration::from_millis(80));
assert!(
inbox.busy_state(a).is_busy(),
"no pattern ⇒ never a false Idle; the agent stays Busy"
);
// The FIFO still accepts a second enqueue (forward, never reject).
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(
inbox.mailbox().pending(&a),
2,
"enqueue accepted while Busy"
);
}
#[test]
fn pattern_absent_from_output_keeps_agent_busy_but_queue_accepts() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(4);
// Output never contains the marker ⇒ watcher runs to EOF without matching.
pty.seed(&h, vec![b"still thinking, no prompt here\n".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
// Even after the stream ends, no match ⇒ stays Busy (timeout is the ultimate
// guard, exercised at the orchestrator layer).
std::thread::sleep(std::time::Duration::from_millis(80));
assert!(
inbox.busy_state(a).is_busy(),
"marker absent from output ⇒ stays Busy (no false Idle)"
);
inbox.enqueue(a, ticket(11, "queued"));
assert_eq!(
inbox.mailbox().pending(&a),
2,
"in doubt → keep accepting (forward, never reject)"
);
}
#[test]
fn empty_pattern_arms_nothing() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(5);
pty.seed(&h, vec![b"anything\n> ".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some(String::new()), SubmitConfig::default());
std::thread::sleep(std::time::Duration::from_millis(60));
assert!(
inbox.busy_state(a).is_busy(),
"an empty pattern must arm no watcher (treated as no detection)"
);
}
#[test]
fn prompt_match_advances_fifo_to_next_ticket() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(6);
pty.seed(&h, vec![b"done\n> ".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
// Two tickets queued; the turn is on the first.
inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(
inbox.busy_state(a).ticket(),
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(
10
)))
);
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
// Prompt-ready ⇒ Idle ⇒ the next enqueue can start a fresh turn.
assert!(wait_until(|| !inbox.busy_state(a).is_busy()));
inbox.enqueue(a, ticket(12, "third"));
assert_eq!(
inbox.busy_state(a).ticket(),
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(
12
))),
"after prompt-ready Idle, a new enqueue starts the next turn"
);
}
#[test]
fn rebinding_same_agent_does_not_spawn_duplicate_watcher() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(7);
// A stream that never matches and never ends quickly: empty ⇒ immediate EOF.
pty.seed(&h, vec![b"noise".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
// Arm twice in a row; the second must be a no-op while the first is live (no
// panic, no double-subscribe). After EOF the agent is un-armed and stays Busy.
inbox.bind_handle_with_prompt(
a,
h.clone(),
Some("ZZZ".to_owned()),
SubmitConfig::default(),
);
inbox.bind_handle_with_prompt(a, h, Some("ZZZ".to_owned()), SubmitConfig::default());
std::thread::sleep(std::time::Duration::from_millis(60));
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 // Fix race cold-launch — `mark_starting` gate le PREMIER tour sur la readiness MCP
// ==================================================================== // ====================================================================
/// (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 /// 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 /// 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é /// draine ⇒ exactement UNE `DelegationReady`. C'est le seul drain du tour différé.
/// par le signal MCP (pas de watcher armé ici).
#[test] #[test]
fn release_cold_start_delivers_deferred_first_turn() { fn release_cold_start_delivers_deferred_first_turn() {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
@ -2063,8 +1696,8 @@ mod tests {
/// (signal de connexion du pont MCP) arrive AVANT que l'`enqueue` n'ait parqué son /// (signal de connexion du pont MCP) arrive AVANT que l'`enqueue` n'ait parqué son
/// tour différé. Avant le fix, le signal trouvait `deferred` vide (no-op), puis /// tour différé. Avant le fix, le signal trouvait `deferred` vide (no-op), puis
/// l'`enqueue` parquait le tour dans `deferred` — où il restait à JAMAIS (aucun second /// l'`enqueue` parquait le tour dans `deferred` — où il restait à JAMAIS (aucun second
/// signal ne venait le drainer ⇒ `idea_ask_agent` bloquait jusqu'au timeout, Claude /// signal ne venait le drainer ⇒ `idea_ask_agent` bloquait jusqu'au timeout, le watcher
/// Code n'ayant pas de `prompt_ready_pattern`). Après le fix : exactement UNE /// prompt-ready PTY ayant été supprimé). Après le fix : exactement UNE
/// `DelegationReady` est livrée (le latch « déjà libéré » garantit la livraison). /// `DelegationReady` est livrée (le latch « déjà libéré » garantit la livraison).
#[test] #[test]
fn release_cold_start_before_deferred_enqueue_still_delivers_once() { fn release_cold_start_before_deferred_enqueue_still_delivers_once() {
@ -2102,49 +1735,6 @@ mod tests {
); );
} }
/// Variante prompt-ready de la race ordre INVERSE : le watcher signale `prompt_ready`
/// AVANT l'enqueue ⇒ pas de `mark_idle` prématuré, et le tour est livré une seule fois
/// par l'enqueue qui suit (via le latch « déjà libéré »).
#[test]
fn prompt_ready_before_deferred_enqueue_still_delivers_once() {
let pty = Arc::new(FakePty::new());
let bus = Arc::new(RecordingBus::default());
let a = agent(1);
let h = handle(23);
// Le prompt "\n> " est présent d'emblée ⇒ le watcher déclenche tôt.
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);
// Le watcher s'arme et déclenche `prompt_ready` AVANT l'enqueue : il consomme son
// stream (prompt présent d'emblée), pose le latch « déjà libéré » et n'émet
// surtout PAS de `mark_idle` (le tour n'a pas encore couru).
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
// Laisse le watcher (thread détaché) consommer son flux fini et poser le latch.
// Le latch n'est pas observable publiquement et `prompt_ready` n'est déclenchable
// que par le watcher ⇒ on attend brièvement (stream in-memory ⇒ déclenche très
// vite ; la marge couvre l'ordonnancement du thread).
std::thread::sleep(std::time::Duration::from_millis(100));
assert!(
bus.delegation_ready().is_empty(),
"prompt-ready avant enqueue ⇒ rien encore livré"
);
inbox.enqueue(a, ticket(10, "cold task"));
assert_eq!(
bus.delegation_ready().len(),
1,
"prompt-ready AVANT enqueue ⇒ exactement une DelegationReady"
);
}
/// `release_cold_start` sans tour différé ⇒ no-op : aucune `DelegationReady` et, /// `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). /// surtout, PAS de transition Idle (signal de DÉMARRAGE, pas de fin de tour).
#[test] #[test]
@ -2212,66 +1802,43 @@ mod tests {
} }
/// (c) Cas limite : si on ne marque PAS `starting` (l'orchestrateur n'arme pas le /// (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 /// gate quand la cible n'a pas de pont MCP), l'enqueue livre la `DelegationReady`
/// `DelegationReady` immédiatement — donc PAS de blocage indéfini du premier tour. /// immédiatement — donc PAS de blocage indéfini du premier tour.
#[test] #[test]
fn no_prompt_pattern_no_starting_gate_delivers_immediately() { fn no_mcp_no_starting_gate_delivers_immediately() {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let pty = Arc::new(FakePty::new()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
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>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
// Cellule front montée ⇒ livraison observable via l'événement. // Cold launch SANS pont MCP ⇒ l'orchestrateur N'APPELLE PAS mark_starting.
inbox.set_front_attached(a, true);
// Cold launch SANS pattern ⇒ l'orchestrateur N'APPELLE PAS mark_starting.
inbox.enqueue(a, ticket(10, "task")); inbox.enqueue(a, ticket(10, "task"));
// Premier tour livré tout de suite : aucun watcher ne viendrait le débloquer. // Premier tour livré tout de suite : aucun signal de readiness ne viendrait le
// débloquer, donc surtout pas de gate.
assert_eq!( assert_eq!(
bus.delegation_ready().len(), bus.delegation_ready().len(),
1, 1,
"sans gate ⇒ DelegationReady immédiate (pas de blocage indéfini)" "sans gate ⇒ DelegationReady immédiate (pas de blocage indéfini)"
); );
// Bind sans pattern : aucun watcher armé (fallback sûr). // Reste Busy (idle viendra d'idea_reply / turn_ended / timeout), tour bien livré.
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!(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 /// Après livraison du 1er tour froid (drainé par `release_cold_start`), un `mark_idle`
/// enqueue (agent désormais chaud) repart en livraison immédiate. /// (idea_reply) puis un nouvel enqueue (agent désormais chaud) repart en livraison
/// immédiate.
#[test] #[test]
fn after_cold_first_turn_subsequent_enqueue_is_immediate() { fn after_cold_first_turn_subsequent_enqueue_is_immediate() {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let pty = Arc::new(FakePty::new()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
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>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
// Cellule front montée ⇒ livraison observable via l'événement.
inbox.set_front_attached(a, true);
inbox.mark_starting(a); inbox.mark_starting(a);
inbox.enqueue(a, ticket(10, "first")); inbox.enqueue(a, ticket(10, "first"));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); // Readiness MCP ⇒ draine le 1er tour différé.
assert!(wait_until(|| bus.delegation_ready().len() == 1)); inbox.release_cold_start(a);
assert_eq!(bus.delegation_ready().len(), 1, "1er tour froid livré");
// Fin du 1er tour (idea_reply) puis nouvel enqueue ⇒ livraison immédiate. // Fin du 1er tour (idea_reply) puis nouvel enqueue ⇒ livraison immédiate.
inbox.mark_idle(a); inbox.mark_idle(a);

View File

@ -25,11 +25,13 @@ use async_trait::async_trait;
use serde::Deserialize; use serde::Deserialize;
use domain::ports::{ use domain::ports::{
ConversationDetails, FileSystem, FsError, InspectError, RemotePath, SessionInspector, ConversationDetails, FileSystem, FsError, InspectError, SessionInspector,
}; };
use domain::profile::{AgentProfile, ContextInjection}; use domain::profile::AgentProfile;
use domain::project::ProjectPath; use domain::project::ProjectPath;
use super::claude_paths::{claude_transcript_path, supports_claude};
/// Max byte length of the extracted `last_topic` before it is truncated. /// Max byte length of the extracted `last_topic` before it is truncated.
/// ///
/// Kept small: the topic only labels a resume popup, it is not the message. /// Kept small: the topic only labels a resume popup, it is not the message.
@ -67,32 +69,6 @@ impl ClaudeTranscriptInspector {
pub fn with_base_dir(fs: Arc<dyn FileSystem>, home_dir: impl Into<String>) -> Self { pub fn with_base_dir(fs: Arc<dyn FileSystem>, home_dir: impl Into<String>) -> Self {
Self::new(fs, home_dir) Self::new(fs, home_dir)
} }
/// `<home>/.claude/projects/<encoded-cwd>/<conversation-id>.jsonl`.
fn transcript_path(&self, conversation_id: &str, cwd: &ProjectPath) -> RemotePath {
let base = self.home_dir.trim_end_matches(['/', '\\']);
let encoded = encode_cwd(cwd.as_str());
RemotePath::new(format!(
"{base}/.claude/projects/{encoded}/{conversation_id}.jsonl"
))
}
}
/// Encodes a cwd the way Claude Code names its per-project transcript folder.
///
/// **Assumption (documented, isolated, and unit-tested):** Claude flattens the
/// absolute cwd into a single directory segment by replacing each path
/// separator (`/` or `\`) — and the leading separator — with `-`. So
/// `/home/anthony/Documents/Projects/IdeA` becomes
/// `-home-anthony-Documents-Projects-IdeA`. We keep this convention in one
/// small, tested function so that if Claude's exact encoding differs in some
/// edge case, only this seam needs to change. We do not attempt to encode `.`
/// or other characters, as the common case (clean absolute project paths) is
/// covered and over-encoding would risk pointing at the wrong folder.
fn encode_cwd(cwd: &str) -> String {
cwd.chars()
.map(|c| if c == '/' || c == '\\' { '-' } else { c })
.collect()
} }
/// One transcript line, as far as we care about it. Everything is optional so /// One transcript line, as far as we care about it. Everything is optional so
@ -251,17 +227,7 @@ fn truncate_topic(s: &str) -> String {
#[async_trait] #[async_trait]
impl SessionInspector for ClaudeTranscriptInspector { impl SessionInspector for ClaudeTranscriptInspector {
fn supports(&self, profile: &AgentProfile) -> bool { fn supports(&self, profile: &AgentProfile) -> bool {
// Recognise Claude by its conventional context file `CLAUDE.md`, supports_claude(profile)
// mirroring the detection in `application::agent::lifecycle`.
matches!(
&profile.context_injection,
ContextInjection::ConventionFile { target }
if target
.rsplit(['/', '\\'])
.next()
.unwrap_or(target)
.eq_ignore_ascii_case("CLAUDE.md")
)
} }
async fn details( async fn details(
@ -270,7 +236,7 @@ impl SessionInspector for ClaudeTranscriptInspector {
conversation_id: &str, conversation_id: &str,
cwd: &ProjectPath, cwd: &ProjectPath,
) -> Result<ConversationDetails, InspectError> { ) -> Result<ConversationDetails, InspectError> {
let path = self.transcript_path(conversation_id, cwd); let path = claude_transcript_path(&self.home_dir, conversation_id, cwd);
match self.fs.read(&path).await { match self.fs.read(&path).await {
Ok(bytes) => Ok(parse_transcript(&bytes)), Ok(bytes) => Ok(parse_transcript(&bytes)),
Err(FsError::NotFound(_)) => Err(InspectError::NotFound), Err(FsError::NotFound(_)) => Err(InspectError::NotFound),
@ -283,17 +249,6 @@ impl SessionInspector for ClaudeTranscriptInspector {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn encode_cwd_flattens_separators_to_dash() {
assert_eq!(
encode_cwd("/home/anthony/Documents/Projects/IdeA"),
"-home-anthony-Documents-Projects-IdeA"
);
assert_eq!(encode_cwd("/a/b"), "-a-b");
// Windows-style separators flatten too.
assert_eq!(encode_cwd("C:\\Users\\me"), "C:-Users-me");
}
#[test] #[test]
fn parse_extracts_last_user_topic_and_token_sum() { fn parse_extracts_last_user_topic_and_token_sum() {
let body = concat!( let body = concat!(

View File

@ -0,0 +1,122 @@
//! Shared Claude Code transcript-path helpers.
//!
//! Claude Code records each conversation as a JSONL transcript under the user's
//! home directory:
//!
//! ```text
//! <home>/.claude/projects/<encoded-cwd>/<engine-session-id>.jsonl
//! ```
//!
//! where `<encoded-cwd>` is the agent's working directory with its path separators
//! flattened to `-` (see [`encode_cwd`]). Both the [`ClaudeTranscriptInspector`] (which
//! reads one known transcript) and the [`ClaudeTranscriptTurnWatcher`] (which watches the
//! whole per-agent project dir) need to derive these paths, so the convention lives in
//! **one** small, tested place: if Claude's exact encoding ever differs, only this seam
//! changes.
//!
//! [`ClaudeTranscriptInspector`]: super::ClaudeTranscriptInspector
//! [`ClaudeTranscriptTurnWatcher`]: super::ClaudeTranscriptTurnWatcher
use domain::ports::RemotePath;
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::ProjectPath;
/// Encodes a cwd the way Claude Code names its per-project transcript folder.
///
/// **Verified against real `~/.claude/projects/` folders:** Claude flattens the absolute
/// cwd into a single directory segment by replacing **every character that is not an ASCII
/// alphanumeric** (`[a-zA-Z0-9]`) with `-` — this is Claude Code's `replace(/[^a-zA-Z0-9]/g,
/// '-')`. Crucially that means **`.` is encoded too**, not just path separators: a run dir
/// like `/home/anthony/Documents/Projects/IdeA/.ideai/run/<uuid>` becomes
/// `-home-anthony-Documents-Projects-IdeA--ideai-run-<uuid>` (the `/.` collapses to a
/// **double** dash). The earlier version only replaced `/` and `\`, leaving the `.`, so it
/// computed `-IdeA-.ideai-run-…` — a folder that never exists on disk; the turn-watcher
/// then saw an empty dir (baseline 0, conversation `<none>`) and never fired `turn_ended`,
/// wedging the no-reply backstop. We keep this convention in one small, tested seam so a
/// future Claude encoding change touches only here.
pub(crate) fn encode_cwd(cwd: &str) -> String {
cwd.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}
/// `<home>/.claude/projects/<encoded-cwd>` — the directory holding **all** of one
/// agent's conversation transcripts for `cwd` (its isolated run dir). Trailing path
/// separators on `home_dir` are trimmed first.
pub(crate) fn claude_project_dir(home_dir: &str, cwd: &ProjectPath) -> RemotePath {
let base = home_dir.trim_end_matches(['/', '\\']);
let encoded = encode_cwd(cwd.as_str());
RemotePath::new(format!("{base}/.claude/projects/{encoded}"))
}
/// `<home>/.claude/projects/<encoded-cwd>/<conversation-id>.jsonl`.
pub(crate) fn claude_transcript_path(
home_dir: &str,
conversation_id: &str,
cwd: &ProjectPath,
) -> RemotePath {
let dir = claude_project_dir(home_dir, cwd);
RemotePath::new(format!("{}/{conversation_id}.jsonl", dir.0))
}
/// Recognises a Claude profile by its conventional context file `CLAUDE.md`, mirroring
/// the detection in `application::agent::lifecycle`. Shared by the inspector and the
/// turn watcher so both agree on what they can read.
pub(crate) fn supports_claude(profile: &AgentProfile) -> bool {
matches!(
&profile.context_injection,
ContextInjection::ConventionFile { target }
if target
.rsplit(['/', '\\'])
.next()
.unwrap_or(target)
.eq_ignore_ascii_case("CLAUDE.md")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_cwd_flattens_separators_to_dash() {
assert_eq!(
encode_cwd("/home/anthony/Documents/Projects/IdeA"),
"-home-anthony-Documents-Projects-IdeA"
);
assert_eq!(encode_cwd("/a/b"), "-a-b");
// Every non-alphanumeric flattens to `-`, including `:` and `\` on Windows paths.
assert_eq!(encode_cwd("C:\\Users\\me"), "C--Users-me");
}
/// Non-regression for the no-reply backstop wedge: an IdeA agent's isolated run dir
/// contains a `.` (`/.ideai/`), which Claude encodes as `-` just like `/`. The `/.`
/// must therefore collapse to a **double** dash, matching the real on-disk folder
/// `-home-anthony-Documents-Projects-IdeA--ideai-run-<uuid>`. The earlier `/`-only
/// encoding produced `-IdeA-.ideai-run-…` (nonexistent) and broke the turn watcher.
#[test]
fn encode_cwd_encodes_dot_in_run_dir_to_double_dash() {
let uuid = "73c853d1-c0fd-463b-ad17-1d24fefa371f";
let cwd = format!("/home/anthony/Documents/Projects/IdeA/.ideai/run/{uuid}");
assert_eq!(
encode_cwd(&cwd),
format!("-home-anthony-Documents-Projects-IdeA--ideai-run-{uuid}")
);
// The hyphens already present inside the UUID survive (they are non-alphanumeric,
// re-encoded to the same `-`), so the segment matches byte-for-byte.
assert!(encode_cwd(&cwd).ends_with(uuid));
}
#[test]
fn project_dir_and_transcript_path_compose() {
let cwd = ProjectPath::new("/a/b").expect("valid path");
assert_eq!(
claude_project_dir("/home/me/", &cwd).0,
"/home/me/.claude/projects/-a-b"
);
assert_eq!(
claude_transcript_path("/home/me", "conv-1", &cwd).0,
"/home/me/.claude/projects/-a-b/conv-1.jsonl"
);
}
}

View File

@ -0,0 +1,352 @@
//! [`ClaudeTranscriptTurnWatcher`] — the [`TurnWatcher`] adapter for the **Claude Code**
//! CLI (rendez-vous no-reply backstop).
//!
//! It replaces the dead PTY prompt-ready sniff as the **end-of-turn** authority. Claude
//! records each completed turn as a `turn_duration` entry in its JSONL transcript; this
//! adapter **tail-polls** the agent's per-run-dir transcript folder
//! (`<home>/.claude/projects/<encoded-run-dir>/`) every [`POLL_INTERVAL`] and fires the
//! [`OnTurnEnd`] callback each time the **aggregate** count of `turn_duration` records
//! grows above the **baseline** captured at arm time.
//!
//! ## Why the whole folder, not one file
//!
//! The transcript file is named by Claude's **engine session id** (the resumable), which
//! is unknown at cold start (the file does not exist yet) and is **not** the IdeA pair
//! `conversation_id`. Since every IdeA agent runs in its **own isolated run dir**
//! (`.ideai/run/<agent>/`), its `<encoded-run-dir>` folder belongs to exactly **one**
//! agent: aggregating `turn_duration` across the `.jsonl` files in that folder is a sound
//! per-agent turn-end signal that needs neither the engine id nor file mtimes (the
//! [`domain::ports::FileSystem`] port exposes neither).
//!
//! ## Robustness (cadrage pièges)
//!
//! - **Baseline at arm** ⇒ a pre-existing transcript (relaunch / background wake) never
//! fires a phantom turn end (cf. `mcp-e2e-findings-reply-wedge-phantom-busy`).
//! - **Cold start**: the folder/file may not exist yet ⇒ treated as count `0`, the watch
//! simply waits for it to appear (the first turn is also covered by the MCP-initialize
//! cold-start release).
//! - **Rotation / truncation**: if the aggregate count ever **drops**, the baseline is
//! reset to the new (lower) value — best-effort, never fires spuriously.
//! - **Poll, not inotify** ⇒ portable inside the AppImage.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use domain::ids::AgentId;
use domain::ports::{FileSystem, OnTurnEnd, RemotePath, TurnWatchHandle, TurnWatcher};
use domain::profile::AgentProfile;
use domain::project::ProjectPath;
use super::claude_paths::{claude_project_dir, supports_claude};
/// Tail-poll interval (cadrage : 250-500 ms). 300 ms balances latency vs. cost.
const POLL_INTERVAL: Duration = Duration::from_millis(300);
/// Substring marking a completed turn in a Claude transcript line.
const TURN_END_MARKER: &str = "turn_duration";
/// [`TurnWatcher`] reading Claude Code transcripts. Composes a [`FileSystem`] port (so it
/// stays OS- and Tauri-agnostic and trivially testable) plus the base directory standing
/// in for the user's home (`<home>/.claude/projects/`).
#[derive(Clone)]
pub struct ClaudeTranscriptTurnWatcher {
fs: Arc<dyn FileSystem>,
home_dir: String,
}
impl ClaudeTranscriptTurnWatcher {
/// Builds the watcher from an injected [`FileSystem`] and the user's home directory
/// (resolved by the composition root, e.g. from `$HOME`).
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, home_dir: impl Into<String>) -> Self {
Self {
fs,
home_dir: home_dir.into(),
}
}
}
/// Handle whose [`Drop`] stops the polling task **effectively and idempotently**: it
/// flips the shared stop flag (observed at the top *and* bottom of each loop iteration)
/// **and** aborts the task (so an in-flight `await` cannot fire after the drop).
struct ClaudeTurnWatchHandle {
stop: Arc<AtomicBool>,
task: tokio::task::JoinHandle<()>,
}
impl TurnWatchHandle for ClaudeTurnWatchHandle {}
impl Drop for ClaudeTurnWatchHandle {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
self.task.abort();
}
}
impl TurnWatcher for ClaudeTranscriptTurnWatcher {
fn supports(&self, profile: &AgentProfile) -> bool {
supports_claude(profile)
}
fn watch(
&self,
agent: AgentId,
conversation_id: Option<String>,
cwd: ProjectPath,
on_turn_end: OnTurnEnd,
) -> Box<dyn TurnWatchHandle> {
let fs = Arc::clone(&self.fs);
let dir = claude_project_dir(&self.home_dir, &cwd);
let stop = Arc::new(AtomicBool::new(false));
let stop_task = Arc::clone(&stop);
application::diag!(
"[turn-watcher] arm agent={agent} dir={} conversation={}",
dir.0,
conversation_id.as_deref().unwrap_or("<none>"),
);
let task = tokio::spawn(async move {
// Baseline = turn ends already present at arm time (never re-fired).
let mut last = count_turn_ends(fs.as_ref(), &dir).await;
application::diag!("[turn-watcher] baseline agent={agent} count={last}");
loop {
if stop_task.load(Ordering::SeqCst) {
break;
}
tokio::time::sleep(POLL_INTERVAL).await;
if stop_task.load(Ordering::SeqCst) {
break;
}
let current = count_turn_ends(fs.as_ref(), &dir).await;
if current < last {
// Rotation / truncation : re-baseline (best-effort, no spurious fire).
last = current;
continue;
}
if current > last {
application::diag!(
"[turn-watcher] turn_duration detected agent={agent} count {last}->{current} -> turn_ended"
);
last = current;
// Callback invoked from the polling task, holding no lock (it ends up
// in `InputMediator::turn_ended`). One fire per detected increase: a
// burst still ends the currently-busy turn; an idle agent is a no-op.
on_turn_end(agent);
}
}
});
Box::new(ClaudeTurnWatchHandle { stop, task })
}
}
/// Aggregate count of `turn_duration` records across **all** `.jsonl` transcripts in the
/// agent's per-run-dir folder. A missing folder/file (cold start) ⇒ `0`. Unreadable
/// files are skipped (best-effort, never fatal).
async fn count_turn_ends(fs: &dyn FileSystem, dir: &RemotePath) -> usize {
let Ok(entries) = fs.list(dir).await else {
return 0; // folder not created yet (cold start) or transient error.
};
let mut total = 0usize;
for entry in entries {
if entry.is_dir || !entry.name.ends_with(".jsonl") {
continue;
}
let path = RemotePath::new(format!("{}/{}", dir.0, entry.name));
let Ok(bytes) = fs.read(&path).await else {
continue;
};
total += count_marker_lines(&bytes);
}
total
}
/// Counts transcript **lines** containing the [`TURN_END_MARKER`] (one logical turn end
/// per `turn_duration` record). Substring match on the raw bytes — cheap and tolerant of
/// partial/unparseable lines.
fn count_marker_lines(body: &[u8]) -> usize {
String::from_utf8_lossy(body)
.lines()
.filter(|line| line.contains(TURN_END_MARKER))
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use domain::ports::{DirEntry, FsError};
use std::collections::HashMap;
use std::sync::Mutex;
/// In-memory [`FileSystem`] double: a flat map of path → bytes; `list` returns the
/// basenames whose parent is the queried dir. Only `read`/`list` are exercised.
#[derive(Default)]
struct FakeFs {
files: Mutex<HashMap<String, Vec<u8>>>,
}
impl FakeFs {
fn set(&self, path: &str, body: &str) {
self.files
.lock()
.unwrap()
.insert(path.to_owned(), body.as_bytes().to_vec());
}
}
#[async_trait]
impl FileSystem for FakeFs {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
self.files
.lock()
.unwrap()
.get(&path.0)
.cloned()
.ok_or_else(|| FsError::NotFound(path.0.clone()))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
self.files.lock().unwrap().insert(path.0.clone(), data.to_vec());
Ok(())
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
Ok(self.files.lock().unwrap().contains_key(&path.0))
}
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
Ok(())
}
async fn list(&self, path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
let prefix = format!("{}/", path.0);
let files = self.files.lock().unwrap();
let mut out = Vec::new();
for key in files.keys() {
if let Some(rest) = key.strip_prefix(&prefix) {
if !rest.contains('/') {
out.push(DirEntry {
name: rest.to_owned(),
is_dir: false,
});
}
}
}
if out.is_empty() && !files.keys().any(|k| k.starts_with(&prefix)) {
return Err(FsError::NotFound(path.0.clone()));
}
Ok(out)
}
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
Ok(())
}
}
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn line(extra: &str) -> String {
format!("{{\"type\":\"result\",\"{extra}\":1}}\n")
}
/// The run dir `/run/a` encodes to `-run-a` ⇒ folder `<home>/.claude/projects/-run-a`.
fn transcript_path(name: &str) -> String {
format!("/home/me/.claude/projects/-run-a/{name}")
}
fn count_calls() -> (Arc<Mutex<Vec<AgentId>>>, OnTurnEnd) {
let log: Arc<Mutex<Vec<AgentId>>> = Arc::new(Mutex::new(Vec::new()));
let log2 = Arc::clone(&log);
let cb: OnTurnEnd = Arc::new(move |a: AgentId| log2.lock().unwrap().push(a));
(log, cb)
}
async fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
for _ in 0..100 {
if cond() {
return true;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
cond()
}
#[test]
fn count_marker_lines_counts_only_turn_duration_lines() {
let body = concat!(
"{\"type\":\"user\"}\n",
"{\"type\":\"result\",\"turn_duration\":12}\n",
"garbage line\n",
"{\"type\":\"result\",\"turn_duration\":7}\n",
);
assert_eq!(count_marker_lines(body.as_bytes()), 2);
}
#[tokio::test]
async fn fires_on_new_turn_duration_above_baseline() {
let fs = Arc::new(FakeFs::default());
// Baseline: one pre-existing turn end ⇒ must NOT fire for it.
fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration"));
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let (log, cb) = count_calls();
let cwd = ProjectPath::new("/run/a").expect("path");
let _h = watcher.watch(agent(1), None, cwd, cb);
// Append a second turn end ⇒ exactly one fire.
tokio::time::sleep(Duration::from_millis(50)).await;
fs.set(
&transcript_path("engine-1.jsonl"),
&format!("{}{}", line("turn_duration"), line("turn_duration")),
);
assert!(
wait_until(|| !log.lock().unwrap().is_empty()).await,
"a new turn_duration above baseline fires turn_ended"
);
assert_eq!(log.lock().unwrap().as_slice(), &[agent(1)]);
}
#[tokio::test]
async fn pre_existing_transcript_never_fires_phantom() {
let fs = Arc::new(FakeFs::default());
fs.set(
&transcript_path("engine-1.jsonl"),
&format!("{}{}", line("turn_duration"), line("turn_duration")),
);
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let (log, cb) = count_calls();
let cwd = ProjectPath::new("/run/a").expect("path");
let _h = watcher.watch(agent(1), None, cwd, cb);
// No new turn end appended ⇒ no fire, ever (baseline absorbs the existing ones).
tokio::time::sleep(Duration::from_millis(120)).await;
assert!(log.lock().unwrap().is_empty(), "baseline must absorb pre-existing turn ends");
}
#[tokio::test]
async fn cold_start_missing_folder_then_first_turn_fires() {
let fs = Arc::new(FakeFs::default());
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let (log, cb) = count_calls();
let cwd = ProjectPath::new("/run/a").expect("path");
let _h = watcher.watch(agent(1), None, cwd, cb);
// Folder/file appear later (cold start), then a first turn end is written.
tokio::time::sleep(Duration::from_millis(40)).await;
fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration"));
assert!(
wait_until(|| !log.lock().unwrap().is_empty()).await,
"a first turn end after a cold start fires (baseline was 0)"
);
}
#[tokio::test]
async fn dropping_handle_stops_polling() {
let fs = Arc::new(FakeFs::default());
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
let (log, cb) = count_calls();
let cwd = ProjectPath::new("/run/a").expect("path");
let h = watcher.watch(agent(1), None, cwd, cb);
drop(h); // stop the watch.
// A turn end written after the drop must not fire.
tokio::time::sleep(Duration::from_millis(40)).await;
fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration"));
tokio::time::sleep(Duration::from_millis(120)).await;
assert!(log.lock().unwrap().is_empty(), "a dropped handle stops firing");
}
}

View File

@ -8,5 +8,8 @@
//! missing or failing inspector must never block a resume. //! missing or failing inspector must never block a resume.
mod claude; mod claude;
mod claude_paths;
mod claude_turn_watcher;
pub use claude::ClaudeTranscriptInspector; pub use claude::ClaudeTranscriptInspector;
pub use claude_turn_watcher::ClaudeTranscriptTurnWatcher;

View File

@ -48,7 +48,7 @@ pub use fs::LocalFileSystem;
pub use git::Git2Repository; pub use git::Git2Repository;
pub use id::UuidGenerator; pub use id::UuidGenerator;
pub use input::{MediatedInbox, MillisClock, SystemMillisClock}; pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
pub use inspector::ClaudeTranscriptInspector; pub use inspector::{ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher};
pub use mailbox::InMemoryMailbox; pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{ pub use orchestrator::mcp::{
resolve_ask_rendezvous_timeout, McpServer, MemoryTransport, StdioTransport, resolve_ask_rendezvous_timeout, McpServer, MemoryTransport, StdioTransport,
@ -77,7 +77,7 @@ pub use store::{
AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore,
FsLiveStateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsLiveStateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
OnnxModelInfo, ReferenceBackfillProfileStore, StubEmbedder, VectorMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
}; };

View File

@ -41,17 +41,22 @@ const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// layer). The application layer already bounds the rendezvous itself, but this /// layer). The application layer already bounds the rendezvous itself, but this
/// adapter adds its own **finite** outer bound so a wedged target can never park a /// adapter adds its own **finite** outer bound so a wedged target can never park a
/// `tools/call` task forever: on expiry the call returns a clean JSON-RPC error /// `tools/call` task forever: on expiry the call returns a clean JSON-RPC error
/// instead of hanging. Generous on purpose (delegated turns can be very long), but /// instead of hanging.
/// never infinite. Every other tool (`idea_reply`, `idea_list_agents`, …) is left ///
/// untouched — they don't rendezvous. /// **Plancher universel FINI (backstop no-reply).** The real turn-end signal now exists
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); /// (the transcript `turn_duration` watcher), but only Claude emits it; this outer bound
/// stays the last resort for any silent target that never emits it (Codex & co) or that
/// wedges. It must therefore NEVER be (quasi-)infinite. 600 s by default, overridable per
/// project via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Every other tool (`idea_reply`,
/// `idea_list_agents`, …) is left untouched — they don't rendezvous.
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(600);
/// Resolves the effective `idea_ask_agent` rendezvous bound from an optional /// Resolves the effective `idea_ask_agent` rendezvous bound from an optional
/// configured override in **milliseconds** (project/profile level), mirroring the /// configured override in **milliseconds** (project/profile level), mirroring the
/// application's [`resolve_turn_timeout`](application) shape: `Some(ms)` ⇒ that bound, /// application's [`resolve_turn_timeout`](application) shape: `Some(ms)` ⇒ that bound,
/// `None`/absent ⇒ the generous [`ASK_RENDEZVOUS_TIMEOUT`] default (statu quo, zero /// `None`/absent ⇒ the finite [`ASK_RENDEZVOUS_TIMEOUT`] default (600 s). `Some(0)` is
/// regression). `Some(0)` is treated as "no override" so a misconfigured zero can never /// treated as "no override" so a misconfigured zero can never collapse the safety net to
/// collapse the safety net to an instant expiry. Pure and unit-testable without wiring. /// an instant expiry. Pure and unit-testable without wiring.
#[must_use] #[must_use]
pub fn resolve_ask_rendezvous_timeout(override_ms: Option<u32>) -> Duration { pub fn resolve_ask_rendezvous_timeout(override_ms: Option<u32>) -> Duration {
match override_ms { match override_ms {

View File

@ -28,7 +28,7 @@ pub use embedder::{
pub use live_state::FsLiveStateStore; pub use live_state::FsLiveStateStore;
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall}; pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
pub use permission::FsPermissionStore; pub use permission::FsPermissionStore;
pub use profile::{FsEmbedderProfileStore, FsProfileStore, ReferenceBackfillProfileStore}; pub use profile::{FsEmbedderProfileStore, FsProfileStore};
pub use project::FsProjectStore; pub use project::FsProjectStore;
pub use skill::FsSkillStore; pub use skill::FsSkillStore;
pub use template::FsTemplateStore; pub use template::FsTemplateStore;

View File

@ -148,59 +148,6 @@ impl ProfileStore for FsProfileStore {
} }
} }
/// **Merge-on-read decorator** over any [`ProfileStore`]: backfills *internal*
/// reference defaults that a stored profile is missing, without ever persisting
/// anything (finding A — propagate the measured `prompt_ready_pattern` to profiles
/// saved before that field existed).
///
/// Only [`list`](ProfileStore::list) is transformed — each returned profile is run
/// through [`application::overlay_reference_defaults`] (pure, allowlisted, never
/// clobbers a user value, idempotent). `save`/`delete`/`is_configured`/
/// `mark_configured` **delegate verbatim** so persistence and first-run detection are
/// byte-for-byte unchanged. Wrap the concrete [`FsProfileStore`] once at the
/// composition root, before injecting the store into use cases and the orchestrator,
/// so every reader (including direct `.list()` calls) sees the overlaid reads.
pub struct ReferenceBackfillProfileStore {
inner: Arc<dyn ProfileStore>,
}
impl ReferenceBackfillProfileStore {
/// Wraps an inner [`ProfileStore`]; reads are overlaid, writes pass through.
#[must_use]
pub fn new(inner: Arc<dyn ProfileStore>) -> Self {
Self { inner }
}
}
#[async_trait]
impl ProfileStore for ReferenceBackfillProfileStore {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
Ok(self
.inner
.list()
.await?
.into_iter()
.map(|p| application::overlay_reference_defaults(&p))
.collect())
}
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
self.inner.save(profile).await
}
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
self.inner.delete(id).await
}
async fn is_configured(&self) -> Result<bool, StoreError> {
self.inner.is_configured().await
}
async fn mark_configured(&self) -> Result<(), StoreError> {
self.inner.mark_configured().await
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// FsEmbedderProfileStore — declarative embedder profiles (`embedder.json`, LOT C). // FsEmbedderProfileStore — declarative embedder profiles (`embedder.json`, LOT C).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -340,87 +287,3 @@ impl EmbedderProfileStore for FsEmbedderProfileStore {
} }
} }
#[cfg(test)]
mod backfill_tests {
use super::*;
use std::sync::Mutex;
/// In-memory [`ProfileStore`] fake recording mutating/query calls, so we can prove
/// the decorator transforms ONLY `list` and delegates the rest verbatim.
#[derive(Default)]
struct FakeStore {
profiles: Mutex<Vec<AgentProfile>>,
saved: Mutex<Vec<AgentProfile>>,
deleted: Mutex<Vec<ProfileId>>,
is_configured_calls: Mutex<u32>,
mark_configured_calls: Mutex<u32>,
}
#[async_trait]
impl ProfileStore for FakeStore {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
Ok(self.profiles.lock().unwrap().clone())
}
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
self.saved.lock().unwrap().push(profile.clone());
Ok(())
}
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
self.deleted.lock().unwrap().push(id);
Ok(())
}
async fn is_configured(&self) -> Result<bool, StoreError> {
*self.is_configured_calls.lock().unwrap() += 1;
Ok(true)
}
async fn mark_configured(&self) -> Result<(), StoreError> {
*self.mark_configured_calls.lock().unwrap() += 1;
Ok(())
}
}
/// Claude reference profile with the internal field cleared — the persisted-before
/// shape the overlay backfills.
fn claude_without_pattern() -> AgentProfile {
let id = application::reference_profile_id("claude");
let mut p = application::reference_profiles()
.into_iter()
.find(|p| p.id == id)
.expect("claude reference exists");
p.prompt_ready_pattern = None;
p
}
#[tokio::test]
async fn list_overlays_reference_defaults() {
let fake = Arc::new(FakeStore::default());
fake.profiles.lock().unwrap().push(claude_without_pattern());
let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc<dyn ProfileStore>);
let listed = store.list().await.expect("list ok");
assert_eq!(listed.len(), 1);
assert_eq!(
listed[0].prompt_ready_pattern.as_deref(),
Some("? for shortcuts"),
"a stored Claude profile WITHOUT the pattern is backfilled on read"
);
}
#[tokio::test]
async fn save_delete_and_config_delegate_verbatim() {
let fake = Arc::new(FakeStore::default());
let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc<dyn ProfileStore>);
let p = claude_without_pattern();
store.save(&p).await.expect("save ok");
store.delete(p.id).await.expect("delete ok");
assert!(store.is_configured().await.expect("is_configured ok"));
store.mark_configured().await.expect("mark_configured ok");
// save passes the profile through UNCHANGED (no overlay on the write path).
assert_eq!(*fake.saved.lock().unwrap(), vec![p.clone()]);
assert_eq!(*fake.deleted.lock().unwrap(), vec![p.id]);
assert_eq!(*fake.is_configured_calls.lock().unwrap(), 1);
assert_eq!(*fake.mark_configured_calls.lock().unwrap(), 1);
}
}