diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 41b6b79..ff7d59c 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -1103,6 +1103,9 @@ pub async fn stop_live_agent( ) -> Result { let project = resolve_project(&request.project_id, &state).await?; 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 .stop_live_agent .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 // rotation triggered at thread (re)open below. 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 .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 // **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 diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 890c835..d95584f 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -58,7 +58,7 @@ use infrastructure::{ FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, - OrchestratorWatchHandle, PortablePtyAdapter, ReferenceBackfillProfileStore, RwFileGuard, + OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator, 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 /// que la reprise auto puisse recomposer un `LaunchAgentInput` complet. 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, + /// 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, + /// 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>>, } impl AppState { @@ -718,14 +729,8 @@ impl AppState { Arc::clone(&fs_port), app_data_dir.to_string_lossy().into_owned(), )); - // Merge-on-read overlay (finding A) : envelopper le store concret UNE SEULE FOIS - // ici, avant toute injection, pour que chaque lecteur (use cases + `.list()` directs - // 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 = Arc::new( - ReferenceBackfillProfileStore::new(Arc::clone(&profile_store) as Arc), - ); + let profile_store_port: Arc = + Arc::clone(&profile_store) as Arc; let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_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")) .unwrap_or_default(); let inspectors: Vec> = 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 `/.claude/projects//` que l'inspecteur, via + // le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`). + let turn_watcher: Arc = Arc::new( + infrastructure::ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs_port), home_dir), + ); let inspect_conversation = Arc::new(InspectConversation::new( Arc::clone(&contexts_port), Arc::clone(&profile_store_port), @@ -1435,6 +1446,9 @@ impl AppState { mcp_servers: Mutex::new(HashMap::new()), session_limit_service, resume_contexts, + turn_watcher, + turn_watch_input: Arc::clone(&input_mediator), + turn_watch_handles: Mutex::new(HashMap::new()), move_tab, } } @@ -1519,9 +1533,10 @@ impl AppState { service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid)); } }); - // Borne du rendez-vous `idea_ask_agent` (filet serveur) : réglage projet - // optionnel via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Absent / invalide / `0` ⇒ - // défaut généreux inchangé (24 h), zéro régression sur les tours longs légitimes. + // Borne du rendez-vous `idea_ask_agent` (filet serveur, plancher universel FINI) : + // réglage projet optionnel via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Absent / invalide / + // `0` ⇒ défaut fini (600 s) — jamais (quasi-)infini, sinon une cible silencieuse + // wedge l'appelant. let ask_timeout = infrastructure::resolve_ask_rendezvous_timeout( std::env::var("IDEA_ASK_RENDEZVOUS_TIMEOUT_MS") .ok() @@ -1563,6 +1578,52 @@ impl AppState { 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 (`/.ideai/run/`) 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, + ) { + 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 `/.claude/projects//` 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. /// Called from `close_project` so a closed project stops consuming requests. /// Symmetrically stops the project's MCP server (its twin) so both entry doors diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs index bdd678d..35d9022 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -17,9 +17,6 @@ //! so re-deriving the catalogue yields the same id for "claude" every time, //! making the reference profiles addressable across runs without a registry. -use std::collections::HashMap; -use std::sync::OnceLock; - use domain::ids::ProfileId; use domain::permission::ProjectorKey; use domain::profile::{ @@ -70,14 +67,6 @@ pub fn reference_profiles() -> Vec { ) .expect("claude reference profile is valid") .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_mcp(McpCapability::new( McpConfigStrategy::config_file(".mcp.json") @@ -156,106 +145,6 @@ pub fn selectable_reference_profiles() -> Vec { .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 { - static INDEX: OnceLock> = 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)] mod mcp_tests { use super::*; @@ -268,28 +157,6 @@ mod mcp_tests { .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] fn claude_and_codex_expose_mcp_capability() { for slug in ["claude", "codex"] { diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 84b26d3..0dd82a8 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -23,7 +23,7 @@ pub use structured::{ }; pub use catalogue::{ - overlay_reference_defaults, reference_profile_id, reference_profiles, + reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS, }; pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; diff --git a/crates/application/src/agent/structured.rs b/crates/application/src/agent/structured.rs index b1226f0..37aacd5 100644 --- a/crates/application/src/agent/structured.rs +++ b/crates/application/src/agent/structured.rs @@ -301,11 +301,10 @@ mod tests { AgentBusyState::Idle } fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {} - fn bind_handle_with_prompt( + fn bind_handle_with_submit( &self, _agent: AgentId, _handle: PtyHandle, - _pattern: Option, _submit: SubmitConfig, ) { } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index a65d7b1..62c5c13 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -31,7 +31,7 @@ pub mod window; pub mod workstate; 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, selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput, diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 1474e9c..93baf2a 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -75,12 +75,15 @@ fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig { /// intentionally not yet config-exposed (it may become a per-project setting /// without changing the contract). /// -/// TEMPORAIRE (demande utilisateur 2026-06-13) : la borne de 300 s coupait des tours -/// délégués légitimement très longs (gros refactors + tests). On la relève donc à 24 h -/// (≈ « pas de limite ») le temps de concevoir un vrai signal de vivacité (savoir si -/// l'agent travaille encore) qui remplacera ce timeout brut. NE PAS considérer comme -/// définitif : à reconvertir en borne courte + heartbeat once that liveness signal exists. -const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); +/// **Plancher universel FINI (backstop no-reply).** Le vrai signal de fin-de-tour +/// existe désormais (`turn_duration` du transcript via [`domain::ports::TurnWatcher`] +/// ⇒ [`domain::input::InputMediator::turn_ended`], qui réveille l'appelant en ≈ grâce), +/// mais il n'est émis que par Claude. Ce timeout reste donc le **dernier recours** +/// pour toute cible silencieuse qui n'émet pas `turn_duration` (Codex & co) ou qui +/// 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, /// 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 /// pleins d'attente. /// -/// TEMPORAIRE (cf. [`ASK_AGENT_TIMEOUT`]) : relevé à 48 h (≈ deux tours « sans limite ») -/// en cohérence avec la levée temporaire de la borne de tour, le temps d'un vrai -/// signal de vivacité. -const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(48 * 60 * 60); +/// FINI (cf. [`ASK_AGENT_TIMEOUT`]) : deux tours pleins d'attente ⇒ 2 × 600 s = 1200 s. +/// Comme la borne de tour, ce plafond reste fini (jamais d'attente quasi-infinie en file). +const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(1200); /// 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 @@ -1298,29 +1300,26 @@ impl OrchestratorService { let (handle, cold_launch) = self .ensure_live_pty(project, agent_id, conversation_id, &target) .await?; - // Arm prompt-ready detection (C5) with the target profile's literal marker, so a - // return-to-prompt frees the turn (the other OR signal being `idea_reply`). - let (prompt_pattern, submit, has_mcp) = - self.prompt_and_submit_for_agent(project, agent_id).await; - // Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le - // 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher - // (pattern non vide), soit la connexion du pont MCP de l'agent - // (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun - // des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini). - let gate_cold_start = - cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp); + // Résout la config de soumission du profil cible + s'il déclare un pont MCP. + let (submit, has_mcp) = self.submit_and_mcp_for_agent(project, agent_id).await; + // Gate cold-launch : un agent froid n'est pas encore prêt à recevoir son 1er tour. + // On le diffère s'il existe un signal pour le libérer — la connexion du pont MCP + // de l'agent (`InputMediator::release_cold_start`, déclenchée par l'McpServer sur + // `initialize`). C'est désormais le **seul** signal de readiness (le watcher + // prompt-ready PTY a été supprimé). Sans pont MCP ⇒ pas de gate (livraison + // immédiate, sinon blocage indéfini). + let gate_cold_start = cold_launch && has_mcp; // 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 - // immédiatement ; un `true` diffère la livraison jusqu'au signal. + // cible froide SANS pont MCP livrerait immédiatement ; un `true` diffère la + // livraison jusqu'au signal MCP-initialize. crate::diag!( "[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}", - prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()), + has_mcp={has_mcp} gate_cold_start={gate_cold_start}", ); if gate_cold_start { 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 // (é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 .ensure_live_pty(project, agent_id, conversation_id, target) .await?; - let (prompt_pattern, submit, has_mcp) = - self.prompt_and_submit_for_agent(project, agent_id).await; - // Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le - // 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher - // (pattern non vide), soit la connexion du pont MCP de l'agent - // (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun - // des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini). - let gate_cold_start = - cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp); + let (submit, has_mcp) = self.submit_and_mcp_for_agent(project, agent_id).await; + // Gate cold-launch : un agent froid n'est pas encore prêt. On diffère le 1er tour + // s'il existe un signal pour le libérer — la connexion du pont MCP de l'agent + // (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Seul signal + // de readiness restant (watcher prompt-ready PTY supprimé). Sans pont MCP ⇒ pas + // de gate (livraison immédiate, sinon blocage indéfini). + let gate_cold_start = cold_launch && has_mcp; if gate_cold_start { 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- // 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** - /// its **submit config** (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) in - /// a single profile lookup. Both are carried into `bind_handle_with_prompt`: the - /// pattern arms prompt detection, the submit config is echoed on the next - /// `DelegationReady` so the frontend write-portal knows how to submit. Returns - /// `(None, default)` when the agent, its profile, or the field is absent — the safe - /// fallback (no pattern ⇒ Idle only via explicit signal/timeout; no submit ⇒ the - /// front applies its own `"\r"`/~60 ms default). - async fn prompt_and_submit_for_agent( + /// Resolves the target agent profile's **submit config** + /// (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) **and** whether it + /// declares an **MCP bridge**, in a single profile lookup. The submit config is + /// carried into `bind_handle_with_submit` (echoed on the next `DelegationReady` so + /// the frontend write-portal knows how to submit); the MCP flag drives the + /// cold-launch gate (its `initialize` connection releases a deferred first turn). + /// Returns `(default, false)` when the agent or its profile is absent — the safe + /// fallback (no submit ⇒ the front applies its own `"\r"`/~60 ms default; no gate). + async fn submit_and_mcp_for_agent( &self, project: &Project, agent_id: AgentId, - ) -> (Option, SubmitConfig, bool) { + ) -> (SubmitConfig, bool) { let Some(agent) = self .list_agents .execute(ListAgentsInput { @@ -2333,7 +2330,7 @@ impl OrchestratorService { .ok() .and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id)) else { - return (None, SubmitConfig::default(), false); + return (SubmitConfig::default(), false); }; let Some(profile) = self .profiles @@ -2342,13 +2339,13 @@ impl OrchestratorService { .ok() .and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id)) else { - return (None, SubmitConfig::default(), false); + return (SubmitConfig::default(), false); }; let submit = submit_config_for_profile(&profile); - // 3e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion - // (initialize) servira de signal de readiness de démarrage pour libérer un 1er - // tour différé — d'où le gate cold-launch même sans `prompt_ready_pattern`. - (profile.prompt_ready_pattern, submit, profile.mcp.is_some()) + // 2e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion + // (`initialize`) sert de signal de readiness de démarrage pour libérer un 1er + // tour différé (`release_cold_start`) — c'est ce qui arme le gate cold-launch. + (submit, profile.mcp.is_some()) } /// Résout la [`domain::profile::LivenessStrategy`] du profil de la cible (lot 2) : diff --git a/crates/domain/src/input.rs b/crates/domain/src/input.rs index be82041..6e92cbe 100644 --- a/crates/domain/src/input.rs +++ b/crates/domain/src/input.rs @@ -173,32 +173,22 @@ pub trait InputMediator: Send + Sync { /// own the delivery write). fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {} - /// Like [`InputMediator::bind_handle`], but also arms **prompt-ready detection** - /// (cadrage §6, lot C5): `prompt_ready_pattern` is the agent profile's optional - /// literal marker (`AgentProfile::prompt_ready_pattern`). When `Some`, the mediator - /// watches the bound handle's output stream and calls [`InputMediator::mark_idle`] - /// the first time the marker appears (one of the two OR signals; the other is an - /// explicit `idea_reply`). When `None`, no pattern detection is armed — the agent - /// only returns `Idle` on the explicit signal or the per-turn timeout (fallback - /// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`). - /// - /// It also records the target's [`SubmitConfig`] (ARCHITECTURE §20.3) so the - /// adapter can echo `submit_sequence`/`submit_delay_ms` on the + /// Like [`InputMediator::bind_handle`], but also records the target's + /// [`SubmitConfig`] (ARCHITECTURE §20.3) so the adapter can echo + /// `submit_sequence`/`submit_delay_ms` on the /// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts. - /// The bind is the natural carrier: both the prompt pattern and the submit config - /// are per-agent profile data the orchestrator resolves at the same time, so they - /// travel together (no extra ticket field, no second resolve). + /// The bind is the natural carrier: the submit config is per-agent profile data the + /// orchestrator resolves at bind time, so it travels with the handle (no extra + /// ticket field, no second resolve). /// - /// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and - /// submit config (a mediator that does not observe the output stream). The infra - /// adapter overrides it to arm the watcher and stash the submit config. - fn bind_handle_with_prompt( - &self, - agent: AgentId, - handle: PtyHandle, - _prompt_ready_pattern: Option, - _submit: SubmitConfig, - ) { + /// Turn-end detection is **no longer** armed here: the dead PTY prompt-ready sniff + /// was replaced by the transcript [`crate::ports::TurnWatcher`] (armed at the + /// composition root, once per live session) which calls + /// [`InputMediator::turn_ended`]. + /// + /// Default: delegates to [`InputMediator::bind_handle`], ignoring the submit config. + /// The infra adapter overrides it to stash the submit config. + fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, _submit: SubmitConfig) { 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 - /// premier tour doit être *gatée* sur le prompt-ready (la `DelegationReady` est - /// différée jusqu'à l'apparition du prompt du CLI), pour éviter d'écrire la tâche + /// premier tour doit être *gatée* sur la readiness MCP (la `DelegationReady` est + /// 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). /// - /// À n'appeler **que** lorsqu'un prompt-ready watcher sera effectivement armé (le - /// profil porte un `prompt_ready_pattern` non vide). Sans watcher pour le libérer, + /// À n'appeler **que** lorsqu'un signal de readiness le libérera (le profil porte un + /// 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 /// doit **pas** appeler `mark_starting` et l'`enqueue` livre la tâche immédiatement /// (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 /// a été différé par [`InputMediator::mark_starting`] (démarrage à froid), c'est le /// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à - /// `prompt_ready`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de - /// fin de tour. Idempotent : un second appel (ou après que `prompt_ready` ait déjà - /// drainé) ne trouve rien et est un no-op. En OR avec le prompt-ready : le premier - /// arrivé draine, l'autre est no-op. + /// `turn_ended`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de + /// fin de tour. Idempotent : un second appel ne trouve rien et est un no-op. C'est + /// désormais le **seul** drain du tour différé (le signal MCP-initialize), le + /// watcher prompt-ready PTY ayant été supprimé. /// /// Default: no-op (médiateur qui ne gate pas les démarrages à froid). fn release_cold_start(&self, _agent: AgentId) {} @@ -254,9 +244,24 @@ pub trait InputMediator: Send + Sync { /// is **not** an enqueue and correlates **no** ticket. fn preempt(&self, agent: AgentId); - /// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances. + /// Marks `agent` free (explicit signal) so its FIFO advances. 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 /// 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 diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 087b828..470740d 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1305,6 +1305,46 @@ pub trait SessionInspector: Send + Sync { ) -> Result; } +/// 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; + +/// 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, + cwd: ProjectPath, + on_turn_end: OnTurnEnd, + ) -> Box; +} + /// Publish/subscribe domain events. Synchronous, in-process. pub trait EventBus: Send + Sync { /// Publishes an event. diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index aa0b8ef..c36df0d 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -177,9 +177,9 @@ impl LivenessStrategy { /// 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). /// -/// Donnée **pure** (pas de code par CLI — Open/Closed, §9), calquée sur la -/// philosophie de [`AgentProfile::prompt_ready_pattern`] mais **plus riche** : là où -/// le retour-de-prompt est une simple sous-chaîne littérale, la limite de session a +/// Donnée **pure** (pas de code par CLI — Open/Closed, §9), un motif déclaratif +/// littéral **plus riche** qu'une simple sous-chaîne : là où un sigil de prompt +/// 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 /// 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 @@ -578,37 +578,6 @@ pub struct AgentProfile { /// sérialisation : un profil sans MCP sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp: Option, - /// 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, /// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut, /// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ /// présent mais **non consommé** (place ménagée pour les seuils de stagnation / @@ -804,7 +773,6 @@ impl AgentProfile { session, structured_adapter: None, mcp: None, - prompt_ready_pattern: None, liveness: None, rate_limit_pattern: None, submit_sequence: None, @@ -831,15 +799,6 @@ impl AgentProfile { 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) -> Self { - self.prompt_ready_pattern = Some(pattern.into()); - self - } - /// Builder : fixe la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie /// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// 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] - fn profile_default_has_no_prompt_ready_pattern() { - // Existing profiles (built via `new`) carry no pattern ⇒ no false Idle. - assert!(profile_without_mcp().prompt_ready_pattern.is_none()); - } - - #[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() { + fn legacy_json_with_obsolete_prompt_ready_pattern_is_ignored() { + // Le watcher prompt-ready PTY est supprimé (remplacé par le TurnWatcher + // 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. let legacy = r#"{ "id": "00000000-0000-0000-0000-000000000000", "name": "Dev", @@ -1143,22 +1091,11 @@ mod mcp_tests { "args": [], "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, "detect": null, - "cwdTemplate": "{agentRunDir}" + "cwdTemplate": "{agentRunDir}", + "promptReadyPattern": "? for shortcuts" }"#; let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); - assert!(profile.prompt_ready_pattern.is_none()); - } - - #[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> ")); + assert_eq!(profile.command, "claude"); } // -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------ diff --git a/crates/domain/src/readiness.rs b/crates/domain/src/readiness.rs index 44e64a9..b44bacc 100644 --- a/crates/domain/src/readiness.rs +++ b/crates/domain/src/readiness.rs @@ -15,10 +15,11 @@ //! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`]. //! 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. -//! 3. **Signal n°3 — repli `prompt_ready_pattern`** : sniff littéral du sigil de -//! prompt dans la sortie PTY ([`crate::profile::AgentProfile::prompt_ready_pattern`]). -//! **Rétrogradé** au rang de repli depuis ce lot : il ne sert que pour les agents -//! TUI/PTY sans adapter structuré (rétro-compat, jamais supprimé). +//! 3. **Signal n°3 — fin de tour transcript** : l'agent a écrit une fin de tour dans +//! son transcript on-disk (Claude `turn_duration`), observée par le +//! [`crate::ports::TurnWatcher`] qui appelle [`crate::input::InputMediator::turn_ended`]. +//! 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 //! 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 /// vocabulaire et le rendre explicite. ExplicitReply, - /// Le sigil de prompt PTY (repli n°3) est apparu. Idem : non produit par - /// `classify`, présent pour nommer le signal de repli legacy. + /// Une fin de tour transcript (repli n°3, [`crate::ports::TurnWatcher`]) est + /// 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, /// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place /// réservée au **lot 2** — non produit dans ce lot. diff --git a/crates/infrastructure/src/input/mod.rs b/crates/infrastructure/src/input/mod.rs index fc120dc..d1a247b 100644 --- a/crates/infrastructure/src/input/mod.rs +++ b/crates/infrastructure/src/input/mod.rs @@ -47,23 +47,23 @@ struct BusyTracker { liveness: Mutex>, /// **Démarrage à froid** (fix race cold-launch) : ensemble des agents fraîchement /// lancés à froid pour lesquels la livraison du **premier** tour doit être *gatée* - /// sur le prompt-ready watcher. Un agent y est inscrit par - /// [`BusyTracker::mark_starting`] (uniquement quand un watcher est effectivement - /// armé), puis consommé par l'`enqueue` qui démarre le tour : la `DelegationReady` + /// sur la readiness MCP. Un agent y est inscrit par + /// [`BusyTracker::mark_starting`] (uniquement quand un pont MCP le libérera), puis + /// consommé par l'`enqueue` qui démarre le tour : la `DelegationReady` /// est alors **différée** dans `deferred` au lieu d'être publiée immédiatement (le - /// CLI n'a pas encore affiché son prompt). Vide ⇒ comportement chaud inchangé. + /// CLI n'a pas encore chargé ses outils MCP). Vide ⇒ comportement chaud inchangé. starting: Mutex>, /// **Tour différé** (fix race cold-launch) : payload de la `DelegationReady` retenue - /// pour un démarrage à froid, publiée par [`BusyTracker::prompt_ready`] à l'apparition - /// du prompt (jamais avant). Absent ⇒ aucun tour en attente de gate. + /// pour un démarrage à froid, publiée par [`BusyTracker::release_cold_start`] à la + /// connexion du pont MCP (jamais avant). Absent ⇒ aucun tour en attente de gate. deferred: Mutex>, /// **Latch « déjà libéré »** (fix race cold-start, ordre inverse) : ensemble des - /// agents pour lesquels un signal de readiness (`release_cold_start` / - /// `prompt_ready`) est arrivé **avant** que l'`enqueue` n'ait parqué son tour dans + /// agents pour lesquels le signal de readiness (`release_cold_start`, pont MCP) est + /// arrivé **avant** que l'`enqueue` n'ait parqué son tour dans /// `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` - /// parquerait le tour dans `deferred` — où il resterait à jamais (Claude Code n'a pas - /// de `prompt_ready_pattern`, donc aucun second signal ne viendrait le drainer). Le + /// parquerait le tour dans `deferred` — où il resterait à jamais (le watcher + /// 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 /// livre alors **immédiatement** au lieu de parquer. Consommé (retiré) à la livraison /// ⇒ 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 - /// *gaté* sur le prompt-ready watcher (la `DelegationReady` sera différée à - /// l'apparition du prompt). À n'appeler **que** lorsqu'un watcher est effectivement - /// armé (un `prompt_ready_pattern` non vide est configuré), sinon le premier tour - /// resterait bloqué indéfiniment (aucun signal ne viendrait le libérer). Sans cet - /// appel, l'`enqueue` publie la `DelegationReady` immédiatement (chemin chaud, - /// zéro régression). + /// *gaté* sur la readiness MCP (la `DelegationReady` sera différée jusqu'à la + /// connexion du pont MCP). À n'appeler **que** lorsqu'un pont MCP libérera le tour + /// (`release_cold_start`), sinon le premier tour resterait bloqué indéfiniment + /// (aucun signal ne viendrait le libérer). Sans cet appel, l'`enqueue` publie la + /// `DelegationReady` immédiatement (chemin chaud, zéro régression). fn mark_starting(&self, agent: AgentId) { application::diag!("[input-mediator] mark_starting cold-start gate armed agent={agent}"); self.lock_starting().insert(agent); @@ -352,77 +351,46 @@ 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 - /// moment de le livrer ⇒ on publie la `DelegationReady` retenue et l'agent **reste - /// Busy** (son premier tour court désormais réellement). Le tour ne se terminera - /// que sur un `idea_reply` ou un prochain prompt-ready (qui, lui, fera `mark_idle`). - /// - Sinon : comportement historique ⇒ `mark_idle` (fait avancer la FIFO). - fn prompt_ready(&self, agent: AgentId) { - // On ne retire l'agent de `starting` qu'ici : tant que son premier tour n'a pas - // été livré, un re-arming éventuel doit rester gaté. On capture `was_starting` - // pour distinguer le retour-au-prompt d'un tour chaud (fin de tour ⇒ `mark_idle` + - // 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); - application::diag!( - "[input-mediator] prompt_ready agent={agent} no_reply_payload -> mark_idle + grace" - ); - self.mark_idle(agent); - if let (Some(ticket), Some(mailbox)) = (active, self.mailbox.clone()) { - let grace = self.grace; - application::diag!( - "[input-mediator] prompt_ready arming grace agent={agent} ticket={ticket} grace_ms={}", - grace.as_millis(), - ); - // Thread détaché (jamais de hot loop, jamais de blocage de l'appelant) : - // attend G puis tente la complétion « sans réponse ». `complete_without_reply` - // est head-only/idempotent et préserve le fire-and-forget humain. - std::thread::spawn(move || { - std::thread::sleep(grace); - mailbox.complete_without_reply(agent, ticket); - }); - } - } + /// La cible a terminé son tour 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. La + /// fin de tour ne porte AUCUNE réponse : 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). Sur un agent déjà `Idle` (aucun ticket actif) ⇒ simple + /// `mark_idle` idempotent, pas de grâce. + fn turn_ended(&self, agent: AgentId) { + let active = self.lock().get(&agent).and_then(AgentBusyState::ticket); + application::diag!( + "[input-mediator] turn_ended agent={agent} no_reply_payload -> mark_idle + grace" + ); + self.mark_idle(agent); + if let (Some(ticket), Some(mailbox)) = (active, self.mailbox.clone()) { + let grace = self.grace; + application::diag!( + "[input-mediator] turn_ended arming grace agent={agent} ticket={ticket} grace_ms={}", + grace.as_millis(), + ); + // Thread détaché (jamais de hot loop, jamais de blocage de l'appelant) : + // attend G puis tente la complétion « sans réponse ». `complete_without_reply` + // est head-only/idempotent et préserve le fire-and-forget humain. + std::thread::spawn(move || { + std::thread::sleep(grace); + mailbox.complete_without_reply(agent, ticket); + }); } } /// Libère un premier tour différé sur **readiness de démarrage** (connexion du pont /// MCP de l'agent). Draine le `DeferredDelegation` retenu s'il existe (et retire /// l'agent de `starting`) ; sinon no-op. **Pas de `mark_idle`** (signal de démarrage, - /// pas de fin de tour). Idempotent et OR-safe avec `prompt_ready` (le `remove` ne rend - /// `Some` qu'une fois). + /// pas de fin de tour). Idempotent : c'est le seul drain du tour différé (le `remove` + /// ne rend `Some` qu'une fois). fn release_cold_start(&self, agent: AgentId) { let was_starting = self.lock_starting().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 // 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 - // lieu de parquer dans `deferred` — où le tour resterait à jamais (Claude Code - // n'ayant pas de prompt_ready_pattern, aucun second signal ne le drainerait). + // lieu de parquer dans `deferred` — où le tour resterait à jamais (le watcher + // prompt-ready PTY ayant été supprimé, aucun second signal ne le drainerait). // Hors démarrage à froid (`was_starting == false`), aucun latch : no-op. application::diag!( "[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 /// that starts a turn. Absent ⇒ `None` (no stall detection — legacy behaviour). stall: Mutex>>, - /// 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>>, /// 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. grace: Duration, @@ -561,7 +525,6 @@ impl MediatedInbox { front_owned, submit: Mutex::new(HashMap::new()), stall: Mutex::new(HashMap::new()), - watched: Arc::new(Mutex::new(HashSet::new())), grace: PROMPT_READY_GRACE, } } @@ -753,7 +716,6 @@ impl MediatedInbox { front_owned, submit: Mutex::new(HashMap::new()), stall: Mutex::new(HashMap::new()), - watched: Arc::new(Mutex::new(HashSet::new())), grace: PROMPT_READY_GRACE, } } @@ -801,73 +763,6 @@ impl MediatedInbox { Arc::clone(&self.mailbox) } - fn watched(&self) -> std::sync::MutexGuard<'_, HashSet> { - 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 = 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. @@ -981,10 +876,10 @@ impl InputMediator for MediatedInbox { // dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`). let text = delegation_preamble(&ticket.requester, ticket_id, &ticket.task); // **Fix race cold-launch** : si l'agent est en démarrage à froid (inscrit - // par `mark_starting` quand un watcher est armé), ON DIFFÈRE la - // `DelegationReady` jusqu'au prompt-ready (le CLI n'a pas encore affiché - // son prompt — la livrer maintenant perdrait le premier tour). Le watcher - // la publiera via `prompt_ready`. Agent chaud (pas dans `starting`) ⇒ + // par `mark_starting` quand un pont MCP le libérera), ON DIFFÈRE la + // `DelegationReady` jusqu'à la readiness MCP (le CLI n'a pas encore chargé + // ses outils — la livrer maintenant perdrait le premier tour). + // `release_cold_start` la publiera. Agent chaud (pas dans `starting`) ⇒ // publication immédiate, comportement inchangé. let deferred = DeferredDelegation { ticket: ticket_id, @@ -994,7 +889,7 @@ impl InputMediator for MediatedInbox { }; if self.tracker.lock_starting().contains(&agent) { // 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, // 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 @@ -1029,30 +924,20 @@ impl InputMediator for MediatedInbox { self.handles().insert(agent, handle); } - fn bind_handle_with_prompt( - &self, - agent: AgentId, - handle: PtyHandle, - prompt_ready_pattern: Option, - submit: SubmitConfig, - ) { - // 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). + fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, submit: SubmitConfig) { + // Register the input handle exactly like `bind_handle` and stash the target's + // submit config (echoed on `DelegationReady` at the next turn start, §20.3). + // Turn-end detection is NO LONGER armed here: the dead PTY prompt-ready watcher + // was replaced by the transcript `TurnWatcher` (armed at the composition root, + // once per live session) which calls `turn_ended`. 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, - prompt_ready_pattern.as_deref().unwrap_or(""), describe_submit_sequence(submit.sequence.as_deref()), submit.delay_ms, ); - self.handles().insert(agent, handle.clone()); + self.handles().insert(agent, handle); 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 { @@ -1061,9 +946,9 @@ impl InputMediator for MediatedInbox { fn mark_starting(&self, agent: AgentId) { // Gate du premier tour d'un agent froid : appelé par l'orchestrateur juste après - // un (re)lancement à froid, AVANT le bind/enqueue, et uniquement si un watcher - // sera armé (cf. doc du trait). Consommé par l'`enqueue` qui démarre le tour - // (diffère la `DelegationReady`) puis par `prompt_ready` (la libère). + // un (re)lancement à froid, AVANT le bind/enqueue, et uniquement si un signal de + // readiness le libérera (pont MCP). Consommé par l'`enqueue` qui démarre le tour + // (diffère la `DelegationReady`) puis par `release_cold_start` (la libère). self.tracker.mark_starting(agent); } @@ -1102,12 +987,19 @@ impl InputMediator for MediatedInbox { } fn mark_idle(&self, agent: AgentId) { - // Single authority (also used by the prompt-ready watcher): real Busy→Idle only, - // publishing AgentBusyChanged{busy:false} once. Also clears the liveness entry - // (fin de tour) — émet la reprise si l'agent était `Stalled`. + // Single authority: real Busy→Idle only, publishing AgentBusyChanged{busy:false} + // once. Also clears the liveness entry (fin de tour) — émet la reprise si l'agent + // était `Stalled`. 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) { // Un battement (delta / activité / heartbeat) rafraîchit `last_seen` et ramène // l'agent à `Alive` s'il était `Stalled` (lot 2). @@ -1318,12 +1210,7 @@ mod tests { let h = handle(1); // Bind the target's submit config (resolved from its profile by the service). - inbox.bind_handle_with_prompt( - a, - h, - None, - SubmitConfig::new(Some("\r".to_owned()), Some(42)), - ); + inbox.bind_handle_with_submit(a, h, SubmitConfig::new(Some("\r".to_owned()), Some(42))); // A frontend cell is mounted ⇒ delivery flows through the event (the front // writes). This test asserts the event carries the bound submit config. inbox.set_front_attached(a, true); @@ -1364,7 +1251,7 @@ mod tests { ) .with_events(Arc::clone(&bus) as Arc); 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.enqueue(a, ticket(10, "do X")); @@ -1397,7 +1284,7 @@ mod tests { .with_events(Arc::clone(&bus) as Arc); let a = agent(1); // 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")); @@ -1432,7 +1319,7 @@ mod tests { .with_events(Arc::clone(&bus) as Arc); let a = agent(1); 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)); @@ -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::ports::{ExitStatus, OutputStream, PtyError, PtyHandle as Handle, SpawnSpec}; use domain::terminal::PtySize; - /// A fake [`PtyPort`] whose `subscribe_output` replays a fixed list of chunks then - /// ends (EOF), so the watcher thread sees a deterministic, finite stream. `write` is - /// recorded; `spawn`/`resize`/`kill`/`scrollback` are unused stubs for these tests. + /// A fake [`PtyPort`] that records `write`s (for the headless-delivery tests). + /// `subscribe_output` yields an empty stream; `spawn`/`resize`/`kill`/`scrollback` + /// are unused stubs. struct FakePty { - chunks: Mutex>>>, writes: Mutex>>, } impl FakePty { fn new() -> Self { Self { - chunks: Mutex::new(HashMap::new()), writes: Mutex::new(Vec::new()), } } - /// Seeds the chunks a later `subscribe_output(handle)` will replay. - fn seed(&self, handle: &Handle, chunks: Vec>) { - self.chunks - .lock() - .unwrap() - .insert(handle.session_id.clone(), chunks); - } } #[async_trait::async_trait] impl PtyPort for FakePty { @@ -1612,15 +1490,8 @@ mod tests { fn resize(&self, _handle: &Handle, _size: PtySize) -> Result<(), PtyError> { Ok(()) } - fn subscribe_output(&self, handle: &Handle) -> Result { - let chunks = self - .chunks - .lock() - .unwrap() - .get(&handle.session_id) - .cloned() - .unwrap_or_default(); - Ok(Box::new(chunks.into_iter())) + fn subscribe_output(&self, _handle: &Handle) -> Result { + Ok(Box::new(std::iter::empty())) } fn scrollback(&self, _handle: &Handle) -> Result, PtyError> { Ok(Vec::new()) @@ -1636,8 +1507,8 @@ mod tests { } } - /// Spins until `cond` holds or the deadline passes (the watcher runs on its own - /// thread, so the transition is observed asynchronously). + /// Spins until `cond` holds or the deadline passes (headless writes happen on a + /// detached thread, so the effect is observed asynchronously). fn wait_until(mut cond: impl FnMut() -> bool) -> bool { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); while std::time::Instant::now() < deadline { @@ -1649,50 +1520,35 @@ mod tests { cond() } - fn inbox_with(pty: Arc) -> MediatedInbox { - MediatedInbox::with_pty( - Arc::new(InMemoryMailbox::new()), - Arc::new(FixedClock(1)), - pty as Arc, - ) + // --- backstop no-reply : `turn_ended` (fin de tour transcript) ⇒ réveil après G --- + // + // Le déclencheur n'est plus un watcher PTY mais le `TurnWatcher` transcript ; on + // appelle donc `turn_ended` directement (le watcher est testé à part dans + // `inspector::claude_turn_watcher`). La logique grâce/complétion est inchangée. + + /// Builds a grace-shortened inbox (no PTY needed: `turn_ended` is invoked directly). + fn inbox_grace(grace: Duration) -> MediatedInbox { + MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_grace(grace) } - /// Builds a PTY-backed inbox with a **short grace** so the no-reply path is - /// observable in milliseconds (prod grace is 2 s). - fn inbox_with_grace(pty: Arc, grace: Duration) -> MediatedInbox { - MediatedInbox::with_pty( - Arc::new(InMemoryMailbox::new()), - Arc::new(FixedClock(1)), - pty as Arc, - ) - .with_grace(grace) - } - - // --- fix: prompt-ready sans idea_reply ⇒ réveil de l'appelant après G ---------- - - /// La cible revient à son prompt SANS `idea_reply` : après la fenêtre de grâce, + /// La cible termine son tour SANS `idea_reply` : après la fenêtre de grâce, /// l'appelant en attente est réveillé avec `ReturnedToPromptNoReply` — pas de /// blocage jusqu'au timeout long (le bug corrigé). #[tokio::test] - async fn prompt_ready_without_reply_wakes_caller_after_grace() { - let pty = Arc::new(FakePty::new()); + async fn turn_ended_without_reply_wakes_caller_after_grace() { let a = agent(1); - let h = handle(50); - pty.seed(&h, vec![b"done\n> ".to_vec()]); - let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(40)); + let inbox = inbox_grace(Duration::from_millis(40)); 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. let res = tokio::time::timeout(Duration::from_secs(2), pending) .await .expect("must NOT hang until the long timeout") .expect("a turn outcome, not a closed channel"); - assert_eq!( - res, - domain::mailbox::TurnResolution::ReturnedToPromptNoReply - ); + assert_eq!(res, domain::mailbox::TurnResolution::ReturnedToPromptNoReply); assert_eq!( inbox.mailbox().pending(&a), 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. #[tokio::test] - async fn reply_before_prompt_ready_wins() { - let pty = Arc::new(FakePty::new()); + async fn reply_before_turn_ended_wins() { let a = agent(1); - let h = handle(51); - pty.seed(&h, vec![b"done\n> ".to_vec()]); - let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(40)); + let inbox = inbox_grace(Duration::from_millis(40)); 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.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) .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. #[tokio::test] - async fn reply_within_grace_after_prompt_ready_wins() { - let pty = Arc::new(FakePty::new()); + async fn reply_within_grace_after_turn_ended_wins() { 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. - 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")); - inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); - // Let the watcher fire prompt-ready (arms the grace), then reply within it. - assert!( - wait_until(|| !inbox.busy_state(a).is_busy()), - "prompt-ready marks idle (grace armed)" - ); + inbox.turn_ended(a); + // turn_ended a marqué Idle et armé la grâce ; on répond pendant la grâce. + assert!(!inbox.busy_state(a).is_busy(), "turn_ended marks idle (grace armed)"); inbox.mailbox().resolve(a, "in-time".to_owned()).unwrap(); 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. #[tokio::test] async fn reply_after_grace_is_unmatched() { - let pty = Arc::new(FakePty::new()); let a = agent(1); - let h = handle(53); - pty.seed(&h, vec![b"done\n> ".to_vec()]); - let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(30)); + let inbox = inbox_grace(Duration::from_millis(30)); 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). let res = tokio::time::timeout(Duration::from_secs(2), pending) .await .expect("no hang") .expect("outcome"); - assert_eq!( - res, - domain::mailbox::TurnResolution::ReturnedToPromptNoReply - ); + assert_eq!(res, domain::mailbox::TurnResolution::ReturnedToPromptNoReply); // A late idea_reply now has no head to resolve ⇒ typed NoPendingRequest. assert!( @@ -1788,15 +1629,12 @@ mod tests { /// retirer la tête — `complete_without_reply` skip quand le receiver est fermé. #[test] fn fire_and_forget_head_is_preserved_through_grace() { - let pty = Arc::new(FakePty::new()); let a = agent(1); - let h = handle(54); - pty.seed(&h, vec![b"done\n> ".to_vec()]); - let inbox = inbox_with_grace(Arc::clone(&pty), Duration::from_millis(20)); + let inbox = inbox_grace(Duration::from_millis(20)); // Human submit: the reply handle is dropped immediately (not awaited). 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). std::thread::sleep(Duration::from_millis(120)); 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] - fn prompt_pattern_present_and_output_contains_it_flips_busy_to_idle() { - let pty = Arc::new(FakePty::new()); + fn turn_ended_on_idle_agent_is_noop() { let a = agent(1); - let h = handle(1); - pty.seed(&h, vec![b"working...\n".to_vec(), b"done\n> ".to_vec()]); - let inbox = inbox_with(Arc::clone(&pty)); - - 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"); + let inbox = inbox_grace(Duration::from_millis(20)); + inbox.turn_ended(a); // jamais de tour démarré. + assert_eq!(inbox.busy_state(a), AgentBusyState::Idle); } // ==================================================================== - // 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, - ) - .with_events(Arc::clone(&bus) as Arc); - - // Cellule front montée : ce test observe la livraison via l'événement (chemin - // write-portal), orthogonal au gate cold-launch testé ici. - inbox.set_front_attached(a, true); - // Démarrage à froid : gate armé AVANT bind/enqueue (ordre de l'orchestrateur). - inbox.mark_starting(a); - inbox.enqueue(a, ticket(10, "cold task")); - // L'enqueue a démarré le tour (Busy) MAIS la DelegationReady est différée. - assert!(inbox.busy_state(a).is_busy(), "le tour démarre (Busy)"); - assert!( - bus.delegation_ready().is_empty(), - "cold-launch ⇒ pas de DelegationReady tant que le prompt n'est pas prêt" - ); - - // On arme le watcher (le prompt "\n> " finira par apparaître dans la sortie). - inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); - - // À l'apparition du prompt : la DelegationReady différée est publiée (1 seule), - // et l'agent reste Busy (le premier tour court désormais réellement). - assert!( - wait_until(|| bus.delegation_ready().len() == 1), - "prompt-ready ⇒ la DelegationReady différée est livrée" - ); - let ready = bus.delegation_ready(); - assert_eq!( - ready.len(), - 1, - "exactement une DelegationReady (pas de doublon)" - ); - assert!( - ready[0].1.ends_with("\ncold task"), - "le texte différé porte la tâche brute: {:?}", - ready[0].1 - ); - assert!( - inbox.busy_state(a).is_busy(), - "après livraison du 1er tour froid, l'agent reste Busy (idle viendra d'idea_reply)" - ); - } - /// Readiness de démarrage MCP : un 1er tour différé (`mark_starting` + enqueue) reste /// retenu tant que rien ne le libère ; `release_cold_start` (signal connexion MCP) le - /// draine ⇒ exactement UNE `DelegationReady`. Calque du gate prompt-ready, mais libéré - /// par le signal MCP (pas de watcher armé ici). + /// draine ⇒ exactement UNE `DelegationReady`. C'est le seul drain du tour différé. #[test] fn release_cold_start_delivers_deferred_first_turn() { 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 /// 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 - /// signal ne venait le drainer ⇒ `idea_ask_agent` bloquait jusqu'au timeout, Claude - /// Code n'ayant pas de `prompt_ready_pattern`). Après le fix : exactement UNE + /// signal ne venait le drainer ⇒ `idea_ask_agent` bloquait jusqu'au timeout, le watcher + /// 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). #[test] 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, - ) - .with_events(Arc::clone(&bus) as Arc); - - // 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, /// surtout, PAS de transition Idle (signal de DÉMARRAGE, pas de fin de tour). #[test] @@ -2212,66 +1802,43 @@ mod tests { } /// (c) Cas limite : si on ne marque PAS `starting` (l'orchestrateur n'arme pas le - /// gate quand aucun `prompt_ready_pattern` n'est configuré), l'enqueue livre la - /// `DelegationReady` immédiatement — donc PAS de blocage indéfini du premier tour. + /// gate quand la cible n'a pas de pont MCP), l'enqueue livre la `DelegationReady` + /// immédiatement — donc PAS de blocage indéfini du premier tour. #[test] - fn no_prompt_pattern_no_starting_gate_delivers_immediately() { + fn no_mcp_no_starting_gate_delivers_immediately() { let bus = Arc::new(RecordingBus::default()); - let pty = Arc::new(FakePty::new()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_events(Arc::clone(&bus) as Arc); 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, - ) - .with_events(Arc::clone(&bus) as Arc); - // Cellule front montée ⇒ livraison observable via l'événement. - inbox.set_front_attached(a, true); - // Cold launch SANS pattern ⇒ l'orchestrateur N'APPELLE PAS mark_starting. + // Cold launch SANS pont MCP ⇒ l'orchestrateur N'APPELLE PAS mark_starting. inbox.enqueue(a, ticket(10, "task")); - // Premier tour livré tout de suite : aucun watcher ne viendrait le débloquer. + // Premier tour livré tout de suite : aucun signal de readiness ne viendrait le + // débloquer, donc surtout pas de gate. assert_eq!( bus.delegation_ready().len(), 1, "sans gate ⇒ DelegationReady immédiate (pas de blocage indéfini)" ); - // Bind sans pattern : aucun watcher armé (fallback sûr). - inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default()); - std::thread::sleep(std::time::Duration::from_millis(40)); - // Reste Busy (idle viendra d'idea_reply / timeout), mais le tour A bien été livré. + // Reste Busy (idle viendra d'idea_reply / turn_ended / timeout), tour bien livré. assert!(inbox.busy_state(a).is_busy()); - assert_eq!( - bus.delegation_ready().len(), - 1, - "toujours une seule livraison" - ); } - /// Après livraison du 1er tour froid, un `mark_idle` (idea_reply) puis un nouvel - /// enqueue (agent désormais chaud) repart en livraison immédiate. + /// Après livraison du 1er tour froid (drainé par `release_cold_start`), un `mark_idle` + /// (idea_reply) puis un nouvel enqueue (agent désormais chaud) repart en livraison + /// immédiate. #[test] fn after_cold_first_turn_subsequent_enqueue_is_immediate() { let bus = Arc::new(RecordingBus::default()); - let pty = Arc::new(FakePty::new()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_events(Arc::clone(&bus) as Arc); 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, - ) - .with_events(Arc::clone(&bus) as Arc); - // Cellule front montée ⇒ livraison observable via l'événement. - inbox.set_front_attached(a, true); inbox.mark_starting(a); inbox.enqueue(a, ticket(10, "first")); - inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); - assert!(wait_until(|| bus.delegation_ready().len() == 1)); + // Readiness MCP ⇒ draine le 1er tour différé. + 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. inbox.mark_idle(a); diff --git a/crates/infrastructure/src/inspector/claude.rs b/crates/infrastructure/src/inspector/claude.rs index 23e433f..0cba927 100644 --- a/crates/infrastructure/src/inspector/claude.rs +++ b/crates/infrastructure/src/inspector/claude.rs @@ -25,11 +25,13 @@ use async_trait::async_trait; use serde::Deserialize; 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 super::claude_paths::{claude_transcript_path, supports_claude}; + /// 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. @@ -67,32 +69,6 @@ impl ClaudeTranscriptInspector { pub fn with_base_dir(fs: Arc, home_dir: impl Into) -> Self { Self::new(fs, home_dir) } - - /// `/.claude/projects//.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 @@ -251,17 +227,7 @@ fn truncate_topic(s: &str) -> String { #[async_trait] impl SessionInspector for ClaudeTranscriptInspector { fn supports(&self, profile: &AgentProfile) -> bool { - // Recognise Claude by its conventional context file `CLAUDE.md`, - // 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") - ) + supports_claude(profile) } async fn details( @@ -270,7 +236,7 @@ impl SessionInspector for ClaudeTranscriptInspector { conversation_id: &str, cwd: &ProjectPath, ) -> Result { - 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 { Ok(bytes) => Ok(parse_transcript(&bytes)), Err(FsError::NotFound(_)) => Err(InspectError::NotFound), @@ -283,17 +249,6 @@ impl SessionInspector for ClaudeTranscriptInspector { 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"); - // Windows-style separators flatten too. - assert_eq!(encode_cwd("C:\\Users\\me"), "C:-Users-me"); - } - #[test] fn parse_extracts_last_user_topic_and_token_sum() { let body = concat!( diff --git a/crates/infrastructure/src/inspector/claude_paths.rs b/crates/infrastructure/src/inspector/claude_paths.rs new file mode 100644 index 0000000..0371b12 --- /dev/null +++ b/crates/infrastructure/src/inspector/claude_paths.rs @@ -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 +//! /.claude/projects//.jsonl +//! ``` +//! +//! where `` 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/` becomes +/// `-home-anthony-Documents-Projects-IdeA--ideai-run-` (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 ``) 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() +} + +/// `/.claude/projects/` — 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}")) +} + +/// `/.claude/projects//.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-`. 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" + ); + } +} diff --git a/crates/infrastructure/src/inspector/claude_turn_watcher.rs b/crates/infrastructure/src/inspector/claude_turn_watcher.rs new file mode 100644 index 0000000..5a19fe0 --- /dev/null +++ b/crates/infrastructure/src/inspector/claude_turn_watcher.rs @@ -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 +//! (`/.claude/projects//`) 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//`), its `` 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 (`/.claude/projects/`). +#[derive(Clone)] +pub struct ClaudeTranscriptTurnWatcher { + fs: Arc, + 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, home_dir: impl Into) -> 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, + 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, + cwd: ProjectPath, + on_turn_end: OnTurnEnd, + ) -> Box { + 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(""), + ); + 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>>, + } + 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, 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 { + 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, 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 `/.claude/projects/-run-a`. + fn transcript_path(name: &str) -> String { + format!("/home/me/.claude/projects/-run-a/{name}") + } + + fn count_calls() -> (Arc>>, OnTurnEnd) { + let log: Arc>> = 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, "/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, "/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, "/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, "/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"); + } +} diff --git a/crates/infrastructure/src/inspector/mod.rs b/crates/infrastructure/src/inspector/mod.rs index ec7fa9d..fc23515 100644 --- a/crates/infrastructure/src/inspector/mod.rs +++ b/crates/infrastructure/src/inspector/mod.rs @@ -8,5 +8,8 @@ //! missing or failing inspector must never block a resume. mod claude; +mod claude_paths; +mod claude_turn_watcher; pub use claude::ClaudeTranscriptInspector; +pub use claude_turn_watcher::ClaudeTranscriptTurnWatcher; diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 5da6b91..27ce230 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -48,7 +48,7 @@ pub use fs::LocalFileSystem; pub use git::Git2Repository; pub use id::UuidGenerator; pub use input::{MediatedInbox, MillisClock, SystemMillisClock}; -pub use inspector::ClaudeTranscriptInspector; +pub use inspector::{ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher}; pub use mailbox::InMemoryMailbox; pub use orchestrator::mcp::{ resolve_ask_rendezvous_timeout, McpServer, MemoryTransport, StdioTransport, @@ -77,7 +77,7 @@ pub use store::{ AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, - OnnxModelInfo, ReferenceBackfillProfileStore, StubEmbedder, VectorMemoryRecall, + OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 60c1181..999d38a 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -41,17 +41,22 @@ const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; /// 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 /// `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 -/// never infinite. Every other tool (`idea_reply`, `idea_list_agents`, …) is left -/// untouched — they don't rendezvous. -const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); +/// instead of hanging. +/// +/// **Plancher universel FINI (backstop no-reply).** The real turn-end signal now exists +/// (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 /// configured override in **milliseconds** (project/profile level), mirroring the /// application's [`resolve_turn_timeout`](application) shape: `Some(ms)` ⇒ that bound, -/// `None`/absent ⇒ the generous [`ASK_RENDEZVOUS_TIMEOUT`] default (statu quo, zero -/// regression). `Some(0)` is treated as "no override" so a misconfigured zero can never -/// collapse the safety net to an instant expiry. Pure and unit-testable without wiring. +/// `None`/absent ⇒ the finite [`ASK_RENDEZVOUS_TIMEOUT`] default (600 s). `Some(0)` is +/// treated as "no override" so a misconfigured zero can never collapse the safety net to +/// an instant expiry. Pure and unit-testable without wiring. #[must_use] pub fn resolve_ask_rendezvous_timeout(override_ms: Option) -> Duration { match override_ms { diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 55869b6..3d9204f 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -28,7 +28,7 @@ pub use embedder::{ pub use live_state::FsLiveStateStore; pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall}; pub use permission::FsPermissionStore; -pub use profile::{FsEmbedderProfileStore, FsProfileStore, ReferenceBackfillProfileStore}; +pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use project::FsProjectStore; pub use skill::FsSkillStore; pub use template::FsTemplateStore; diff --git a/crates/infrastructure/src/store/profile.rs b/crates/infrastructure/src/store/profile.rs index 24bb002..5024afd 100644 --- a/crates/infrastructure/src/store/profile.rs +++ b/crates/infrastructure/src/store/profile.rs @@ -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, -} - -impl ReferenceBackfillProfileStore { - /// Wraps an inner [`ProfileStore`]; reads are overlaid, writes pass through. - #[must_use] - pub fn new(inner: Arc) -> Self { - Self { inner } - } -} - -#[async_trait] -impl ProfileStore for ReferenceBackfillProfileStore { - async fn list(&self) -> Result, 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 { - 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). // --------------------------------------------------------------------------- @@ -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>, - saved: Mutex>, - deleted: Mutex>, - is_configured_calls: Mutex, - mark_configured_calls: Mutex, - } - - #[async_trait] - impl ProfileStore for FakeStore { - async fn list(&self) -> Result, 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 { - *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); - - 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); - - 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); - } -}