feat(orchestrator): backstop no-reply du rendez-vous inter-agents
Remédiation du wedge persistant après échec live du fix Finding A (77e62e5).
Détecte la fin de tour d'un agent sollicité qui n'a pas appelé idea_reply et
débloque l'agent demandeur au lieu de le laisser en attente indéfinie.
Ajoute le suivi de tour côté inspector Claude (claude_turn_watcher) et la
résolution des chemins de session (claude_paths), câblés dans le rendez-vous
idea_ask_agent ⇄ idea_reply.
Build workspace vert, suite complète verte, zéro warning.
Backstop NON prouvé levé en live : merge develop interdit tant que la levée
du wedge n'est pas validée en conditions réelles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1103,6 +1103,9 @@ pub async fn stop_live_agent(
|
||||
) -> Result<StopLiveAgentResponseDto, ErrorDto> {
|
||||
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
|
||||
|
||||
@ -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<dyn domain::ports::TurnWatcher>,
|
||||
/// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du
|
||||
/// [`turn_watcher`](Self::turn_watcher) à l'armement.
|
||||
pub turn_watch_input: Arc<dyn domain::input::InputMediator>,
|
||||
/// Handles des watches de fin-de-tour vivants, par agent. (Re)lancer un agent
|
||||
/// **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; fermer/arrêter
|
||||
/// l'agent le retire. `Mutex` car launch/stop y accèdent concurremment.
|
||||
pub turn_watch_handles: Mutex<HashMap<AgentId, Box<dyn domain::ports::TurnWatchHandle>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@ -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<dyn ProfileStore> = Arc::new(
|
||||
ReferenceBackfillProfileStore::new(Arc::clone(&profile_store) as Arc<dyn ProfileStore>),
|
||||
);
|
||||
let profile_store_port: Arc<dyn ProfileStore> =
|
||||
Arc::clone(&profile_store) as Arc<dyn ProfileStore>;
|
||||
|
||||
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<Arc<dyn domain::ports::SessionInspector>> = vec![Arc::new(
|
||||
ClaudeTranscriptInspector::new(Arc::clone(&fs_port), home_dir),
|
||||
ClaudeTranscriptInspector::new(Arc::clone(&fs_port), home_dir.clone()),
|
||||
)];
|
||||
// Backstop no-reply : observateur de fin de tour transcript (Claude `turn_duration`)
|
||||
// — lit le même `<home>/.claude/projects/<encoded-run-dir>/` que l'inspecteur, via
|
||||
// le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`).
|
||||
let turn_watcher: Arc<dyn domain::ports::TurnWatcher> = Arc::new(
|
||||
infrastructure::ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs_port), home_dir),
|
||||
);
|
||||
let inspect_conversation = Arc::new(InspectConversation::new(
|
||||
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 (`<root>/.ideai/run/<agent>`) and routes each detected turn end
|
||||
/// to [`InputMediator::turn_ended`](domain::input::InputMediator::turn_ended). Called
|
||||
/// at launch; **idempotent by replacement** — a relaunch drops the previous handle
|
||||
/// (stopping its polling task) and arms a fresh one. A non-supported profile is a
|
||||
/// no-op. `conversation_id` is diagnostic only.
|
||||
pub fn arm_turn_watch(
|
||||
&self,
|
||||
project_root: &domain::project::ProjectPath,
|
||||
agent_id: AgentId,
|
||||
profile: &AgentProfile,
|
||||
conversation_id: Option<String>,
|
||||
) {
|
||||
if !self.turn_watcher.supports(profile) {
|
||||
return;
|
||||
}
|
||||
// cwd = the agent's isolated run dir (matches the profile `{agentRunDir}` cwd that
|
||||
// Claude runs in, hence the `<home>/.claude/projects/<encoded-run-dir>/` folder).
|
||||
let run_dir = format!(
|
||||
"{}/.ideai/run/{agent_id}",
|
||||
project_root.as_str().trim_end_matches(['/', '\\'])
|
||||
);
|
||||
let Ok(cwd) = domain::project::ProjectPath::new(run_dir) else {
|
||||
return;
|
||||
};
|
||||
let input = Arc::clone(&self.turn_watch_input);
|
||||
// Callback invoked from the watcher's polling task (no lock held here).
|
||||
let on_turn_end: domain::ports::OnTurnEnd = Arc::new(move |a| input.turn_ended(a));
|
||||
let handle = self
|
||||
.turn_watcher
|
||||
.watch(agent_id, conversation_id, cwd, on_turn_end);
|
||||
if let Ok(mut map) = self.turn_watch_handles.lock() {
|
||||
// Insert replaces (and drops) any prior handle ⇒ its polling task stops.
|
||||
map.insert(agent_id, handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops and removes the end-of-turn watcher of `agent_id` (close / stop). Dropping
|
||||
/// the stored handle stops its polling task. No-op if none is armed.
|
||||
pub fn stop_turn_watch(&self, agent_id: AgentId) {
|
||||
if let Ok(mut map) = self.turn_watch_handles.lock() {
|
||||
map.remove(&agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops and removes the orchestrator watcher for `project_id`, if any.
|
||||
/// Called from `close_project` so a closed project stops consuming requests.
|
||||
/// Symmetrically stops the project's MCP server (its twin) so both entry doors
|
||||
|
||||
@ -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<AgentProfile> {
|
||||
)
|
||||
.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<AgentProfile> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Lazy index `ProfileId -> reference profile`, built once from
|
||||
/// [`reference_profiles`]. Keyed by the deterministic [`reference_id`], so a stored
|
||||
/// profile created from a reference is addressable across runs without a registry.
|
||||
fn reference_index() -> &'static HashMap<ProfileId, AgentProfile> {
|
||||
static INDEX: OnceLock<HashMap<ProfileId, AgentProfile>> = OnceLock::new();
|
||||
INDEX.get_or_init(|| reference_profiles().into_iter().map(|p| (p.id, p)).collect())
|
||||
}
|
||||
|
||||
/// **Merge-on-read overlay** (pure, no I/O): backfills *internal* reference fields
|
||||
/// that a stored profile is missing, without ever persisting anything.
|
||||
///
|
||||
/// Motivation: profiles persisted before a reference field existed (e.g. the measured
|
||||
/// `prompt_ready_pattern`, finding A) are the launch-time source of truth, but the
|
||||
/// catalogue is never re-merged into the store. This overlay lets every reader see the
|
||||
/// up-to-date reference defaults while the on-disk `profiles.json` stays untouched.
|
||||
///
|
||||
/// Invariants:
|
||||
/// - **Match on the deterministic [`reference_id`] only** — a custom profile (random id)
|
||||
/// is returned **unchanged** (its id is absent from [`reference_index`]).
|
||||
/// - **Never clobbers a user value** — a field is filled **iff** it is `None`/absent on
|
||||
/// the stored profile; a present value always wins.
|
||||
/// - **Strict allowlist** — only *internal, non-wizard-editable* reference fields are
|
||||
/// overlaid. Today that is **`prompt_ready_pattern` alone** (an internal measured
|
||||
/// marker, never surfaced in the wizard). User-editable fields are never touched.
|
||||
/// - **Idempotent**: `overlay(overlay(p)) == overlay(p)` (a filled field is then
|
||||
/// present, so the second pass is a no-op).
|
||||
/// - **Pure**: no I/O, safe to call on every `list()`.
|
||||
#[must_use]
|
||||
pub fn overlay_reference_defaults(stored: &AgentProfile) -> AgentProfile {
|
||||
let Some(reference) = reference_index().get(&stored.id) else {
|
||||
return stored.clone();
|
||||
};
|
||||
let mut merged = stored.clone();
|
||||
// Allowlist STRICTE — un champ par ligne, rempli uniquement s'il est absent.
|
||||
if merged.prompt_ready_pattern.is_none() {
|
||||
merged.prompt_ready_pattern = reference.prompt_ready_pattern.clone();
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod overlay_tests {
|
||||
use super::*;
|
||||
|
||||
/// Claude reference profile with `prompt_ready_pattern` cleared — stands in for a
|
||||
/// profile persisted before the field existed (the case this overlay fixes).
|
||||
fn claude_without_pattern() -> AgentProfile {
|
||||
let id = reference_id("claude");
|
||||
let mut p = reference_profiles()
|
||||
.into_iter()
|
||||
.find(|p| p.id == id)
|
||||
.expect("claude reference exists");
|
||||
p.prompt_ready_pattern = None;
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_id_with_missing_field_is_backfilled() {
|
||||
let merged = overlay_reference_defaults(&claude_without_pattern());
|
||||
assert_eq!(
|
||||
merged.prompt_ready_pattern.as_deref(),
|
||||
Some("? for shortcuts"),
|
||||
"a reference profile missing the internal field gets it backfilled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_id_never_clobbers_a_present_user_value() {
|
||||
let mut stored = claude_without_pattern();
|
||||
stored.prompt_ready_pattern = Some("USER OVERRIDE".to_owned());
|
||||
let merged = overlay_reference_defaults(&stored);
|
||||
assert_eq!(
|
||||
merged.prompt_ready_pattern.as_deref(),
|
||||
Some("USER OVERRIDE"),
|
||||
"a present value always wins — never clobbered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_reference_id_is_returned_unchanged() {
|
||||
// A custom profile (random id absent from the reference index) is untouched,
|
||||
// even when it is missing the field.
|
||||
let mut custom = claude_without_pattern();
|
||||
custom.id = ProfileId::from_uuid(uuid::Uuid::from_u128(0xC0FFEE));
|
||||
let merged = overlay_reference_defaults(&custom);
|
||||
assert_eq!(
|
||||
merged.prompt_ready_pattern, None,
|
||||
"a custom profile is never backfilled (id not a reference id)"
|
||||
);
|
||||
assert_eq!(merged, custom, "custom profile returned byte-for-byte unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_is_idempotent() {
|
||||
let once = overlay_reference_defaults(&claude_without_pattern());
|
||||
let twice = overlay_reference_defaults(&once);
|
||||
assert_eq!(once, twice, "overlay(overlay(p)) == overlay(p)");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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"] {
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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<String>,
|
||||
_submit: SubmitConfig,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<String>, 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) :
|
||||
|
||||
@ -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<String>,
|
||||
_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
|
||||
|
||||
@ -1305,6 +1305,46 @@ pub trait SessionInspector: Send + Sync {
|
||||
) -> Result<ConversationDetails, InspectError>;
|
||||
}
|
||||
|
||||
/// Callback fired **once per detected turn end** by a [`TurnWatcher`].
|
||||
///
|
||||
/// Invoked from the watcher's own polling task (never while holding a lock), so the
|
||||
/// composition root can safely route it to
|
||||
/// [`crate::input::InputMediator::turn_ended`] for the given agent.
|
||||
pub type OnTurnEnd = Arc<dyn Fn(AgentId) + Send + Sync>;
|
||||
|
||||
/// Opaque handle to a live [`TurnWatcher::watch`]. **Dropping it stops the watch**
|
||||
/// (the adapter's polling task must observe the drop and cease firing, idempotently).
|
||||
pub trait TurnWatchHandle: Send + Sync {}
|
||||
|
||||
/// Observes an agent's **end-of-turn** signal from its on-disk transcript, replacing
|
||||
/// the dead PTY prompt-ready sniff as the turn-end authority (rendez-vous no-reply
|
||||
/// backstop). Model-specific: an adapter watches the CLI's transcript shape (e.g. the
|
||||
/// Claude `turn_duration` record) and fires [`OnTurnEnd`] each time a turn completes.
|
||||
///
|
||||
/// Pure port (no async, no I/O in the trait): the adapter owns its polling task. The
|
||||
/// composition root arms one watch per **live agent session** (keyed on `cwd`, the
|
||||
/// agent's isolated run dir) and drops the handle on close/exit.
|
||||
pub trait TurnWatcher: Send + Sync {
|
||||
/// Whether this watcher knows how to read turn ends for the given profile
|
||||
/// (mirrors [`SessionInspector::supports`] — e.g. recognising `CLAUDE.md`).
|
||||
fn supports(&self, profile: &AgentProfile) -> bool;
|
||||
|
||||
/// Arms a watch over `agent`'s transcript under `cwd` (its run dir). The adapter
|
||||
/// captures a **baseline** of turn ends already present at arm time and fires
|
||||
/// `on_turn_end(agent)` only on increments **above** that baseline (so a
|
||||
/// pre-existing transcript — relaunch / background wake — never triggers a phantom
|
||||
/// turn end). `conversation_id` is an optional diagnostic label only — it is **not**
|
||||
/// the file key (the transcript stem is the engine session id, unknown at cold
|
||||
/// start). Returns a [`TurnWatchHandle`]; dropping it stops the watch.
|
||||
fn watch(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
conversation_id: Option<String>,
|
||||
cwd: ProjectPath,
|
||||
on_turn_end: OnTurnEnd,
|
||||
) -> Box<dyn TurnWatchHandle>;
|
||||
}
|
||||
|
||||
/// Publish/subscribe domain events. Synchronous, in-process.
|
||||
pub trait EventBus: Send + Sync {
|
||||
/// Publishes an event.
|
||||
|
||||
@ -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<McpCapability>,
|
||||
/// Motif de **retour-de-prompt** (cadrage « conversation par paire » §6, lot C5).
|
||||
///
|
||||
/// Donnée **déclarative** (pas de code par CLI — Open/Closed) : un motif **littéral**
|
||||
/// recherché par sous-chaîne dans le flux de sortie PTY du tour courant. Quand il
|
||||
/// apparaît, IdeA considère l'agent **revenu au prompt** et le marque `Idle`
|
||||
/// (`InputMediator::mark_idle`), ce qui fait avancer sa file. C'est l'un des deux
|
||||
/// signaux du « double signal OR » (l'autre étant un `idea_reply` explicite) ; le
|
||||
/// **premier** des deux qui arrive libère le tour.
|
||||
///
|
||||
/// **Choix tranché : littéral, pas regex.** Une sous-chaîne littérale est
|
||||
/// déterministe, sans dépendance (`regex`), suffisante pour un sigil de prompt
|
||||
/// stable (ex. `"\n> "`), et ne risque pas le faux-positif d'un motif regex mal
|
||||
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
|
||||
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
|
||||
///
|
||||
/// **Rang (chantier readiness/heartbeat, lot 1) : signal de repli n°3.** Depuis
|
||||
/// l'introduction de la fin-de-tour structurée ([`crate::ports::ReplyEvent::Final`]
|
||||
/// ⇒ [`crate::readiness::ReadinessSignal::TurnEnded`], signal n°1) et du signal
|
||||
/// explicite `idea_reply` (n°2), ce sniff littéral est **rétrogradé** au rang de
|
||||
/// repli : il ne sert plus que pour les agents **TUI/PTY sans adapter structuré**.
|
||||
/// Conservé tel quel pour la rétro-compat (jamais supprimé).
|
||||
///
|
||||
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
|
||||
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
|
||||
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
|
||||
/// `Busy` mais la file continue d'accepter » (jamais de faux `Idle`).
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
|
||||
/// un profil sans motif sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_ready_pattern: Option<String>,
|
||||
/// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut,
|
||||
/// 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<String>) -> 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) ------------
|
||||
|
||||
@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<dyn FileSystem>, home_dir: impl Into<String>) -> Self {
|
||||
Self::new(fs, home_dir)
|
||||
}
|
||||
|
||||
/// `<home>/.claude/projects/<encoded-cwd>/<conversation-id>.jsonl`.
|
||||
fn transcript_path(&self, conversation_id: &str, cwd: &ProjectPath) -> RemotePath {
|
||||
let base = self.home_dir.trim_end_matches(['/', '\\']);
|
||||
let encoded = encode_cwd(cwd.as_str());
|
||||
RemotePath::new(format!(
|
||||
"{base}/.claude/projects/{encoded}/{conversation_id}.jsonl"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes a cwd the way Claude Code names its per-project transcript folder.
|
||||
///
|
||||
/// **Assumption (documented, isolated, and unit-tested):** Claude flattens the
|
||||
/// absolute cwd into a single directory segment by replacing each path
|
||||
/// separator (`/` or `\`) — and the leading separator — with `-`. So
|
||||
/// `/home/anthony/Documents/Projects/IdeA` becomes
|
||||
/// `-home-anthony-Documents-Projects-IdeA`. We keep this convention in one
|
||||
/// small, tested function so that if Claude's exact encoding differs in some
|
||||
/// edge case, only this seam needs to change. We do not attempt to encode `.`
|
||||
/// or other characters, as the common case (clean absolute project paths) is
|
||||
/// covered and over-encoding would risk pointing at the wrong folder.
|
||||
fn encode_cwd(cwd: &str) -> String {
|
||||
cwd.chars()
|
||||
.map(|c| if c == '/' || c == '\\' { '-' } else { c })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One transcript line, as far as we care about it. Everything is optional so
|
||||
@ -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<ConversationDetails, InspectError> {
|
||||
let path = self.transcript_path(conversation_id, cwd);
|
||||
let path = claude_transcript_path(&self.home_dir, conversation_id, cwd);
|
||||
match self.fs.read(&path).await {
|
||||
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!(
|
||||
|
||||
102
crates/infrastructure/src/inspector/claude_paths.rs
Normal file
102
crates/infrastructure/src/inspector/claude_paths.rs
Normal file
@ -0,0 +1,102 @@
|
||||
//! Shared Claude Code transcript-path helpers.
|
||||
//!
|
||||
//! Claude Code records each conversation as a JSONL transcript under the user's
|
||||
//! home directory:
|
||||
//!
|
||||
//! ```text
|
||||
//! <home>/.claude/projects/<encoded-cwd>/<engine-session-id>.jsonl
|
||||
//! ```
|
||||
//!
|
||||
//! where `<encoded-cwd>` is the agent's working directory with its path separators
|
||||
//! flattened to `-` (see [`encode_cwd`]). Both the [`ClaudeTranscriptInspector`] (which
|
||||
//! reads one known transcript) and the [`ClaudeTranscriptTurnWatcher`] (which watches the
|
||||
//! whole per-agent project dir) need to derive these paths, so the convention lives in
|
||||
//! **one** small, tested place: if Claude's exact encoding ever differs, only this seam
|
||||
//! changes.
|
||||
//!
|
||||
//! [`ClaudeTranscriptInspector`]: super::ClaudeTranscriptInspector
|
||||
//! [`ClaudeTranscriptTurnWatcher`]: super::ClaudeTranscriptTurnWatcher
|
||||
|
||||
use domain::ports::RemotePath;
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
/// Encodes a cwd the way Claude Code names its per-project transcript folder.
|
||||
///
|
||||
/// **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.
|
||||
pub(crate) fn encode_cwd(cwd: &str) -> String {
|
||||
cwd.chars()
|
||||
.map(|c| if c == '/' || c == '\\' { '-' } else { c })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `<home>/.claude/projects/<encoded-cwd>` — the directory holding **all** of one
|
||||
/// agent's conversation transcripts for `cwd` (its isolated run dir). Trailing path
|
||||
/// separators on `home_dir` are trimmed first.
|
||||
pub(crate) fn claude_project_dir(home_dir: &str, cwd: &ProjectPath) -> RemotePath {
|
||||
let base = home_dir.trim_end_matches(['/', '\\']);
|
||||
let encoded = encode_cwd(cwd.as_str());
|
||||
RemotePath::new(format!("{base}/.claude/projects/{encoded}"))
|
||||
}
|
||||
|
||||
/// `<home>/.claude/projects/<encoded-cwd>/<conversation-id>.jsonl`.
|
||||
pub(crate) fn claude_transcript_path(
|
||||
home_dir: &str,
|
||||
conversation_id: &str,
|
||||
cwd: &ProjectPath,
|
||||
) -> RemotePath {
|
||||
let dir = claude_project_dir(home_dir, cwd);
|
||||
RemotePath::new(format!("{}/{conversation_id}.jsonl", dir.0))
|
||||
}
|
||||
|
||||
/// Recognises a Claude profile by its conventional context file `CLAUDE.md`, mirroring
|
||||
/// the detection in `application::agent::lifecycle`. Shared by the inspector and the
|
||||
/// turn watcher so both agree on what they can read.
|
||||
pub(crate) fn supports_claude(profile: &AgentProfile) -> bool {
|
||||
matches!(
|
||||
&profile.context_injection,
|
||||
ContextInjection::ConventionFile { target }
|
||||
if target
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(target)
|
||||
.eq_ignore_ascii_case("CLAUDE.md")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encode_cwd_flattens_separators_to_dash() {
|
||||
assert_eq!(
|
||||
encode_cwd("/home/anthony/Documents/Projects/IdeA"),
|
||||
"-home-anthony-Documents-Projects-IdeA"
|
||||
);
|
||||
assert_eq!(encode_cwd("/a/b"), "-a-b");
|
||||
// Windows-style separators flatten too.
|
||||
assert_eq!(encode_cwd("C:\\Users\\me"), "C:-Users-me");
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
352
crates/infrastructure/src/inspector/claude_turn_watcher.rs
Normal file
352
crates/infrastructure/src/inspector/claude_turn_watcher.rs
Normal file
@ -0,0 +1,352 @@
|
||||
//! [`ClaudeTranscriptTurnWatcher`] — the [`TurnWatcher`] adapter for the **Claude Code**
|
||||
//! CLI (rendez-vous no-reply backstop).
|
||||
//!
|
||||
//! It replaces the dead PTY prompt-ready sniff as the **end-of-turn** authority. Claude
|
||||
//! records each completed turn as a `turn_duration` entry in its JSONL transcript; this
|
||||
//! adapter **tail-polls** the agent's per-run-dir transcript folder
|
||||
//! (`<home>/.claude/projects/<encoded-run-dir>/`) every [`POLL_INTERVAL`] and fires the
|
||||
//! [`OnTurnEnd`] callback each time the **aggregate** count of `turn_duration` records
|
||||
//! grows above the **baseline** captured at arm time.
|
||||
//!
|
||||
//! ## Why the whole folder, not one file
|
||||
//!
|
||||
//! The transcript file is named by Claude's **engine session id** (the resumable), which
|
||||
//! is unknown at cold start (the file does not exist yet) and is **not** the IdeA pair
|
||||
//! `conversation_id`. Since every IdeA agent runs in its **own isolated run dir**
|
||||
//! (`.ideai/run/<agent>/`), its `<encoded-run-dir>` folder belongs to exactly **one**
|
||||
//! agent: aggregating `turn_duration` across the `.jsonl` files in that folder is a sound
|
||||
//! per-agent turn-end signal that needs neither the engine id nor file mtimes (the
|
||||
//! [`domain::ports::FileSystem`] port exposes neither).
|
||||
//!
|
||||
//! ## Robustness (cadrage pièges)
|
||||
//!
|
||||
//! - **Baseline at arm** ⇒ a pre-existing transcript (relaunch / background wake) never
|
||||
//! fires a phantom turn end (cf. `mcp-e2e-findings-reply-wedge-phantom-busy`).
|
||||
//! - **Cold start**: the folder/file may not exist yet ⇒ treated as count `0`, the watch
|
||||
//! simply waits for it to appear (the first turn is also covered by the MCP-initialize
|
||||
//! cold-start release).
|
||||
//! - **Rotation / truncation**: if the aggregate count ever **drops**, the baseline is
|
||||
//! reset to the new (lower) value — best-effort, never fires spuriously.
|
||||
//! - **Poll, not inotify** ⇒ portable inside the AppImage.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::ids::AgentId;
|
||||
use domain::ports::{FileSystem, OnTurnEnd, RemotePath, TurnWatchHandle, TurnWatcher};
|
||||
use domain::profile::AgentProfile;
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
use super::claude_paths::{claude_project_dir, supports_claude};
|
||||
|
||||
/// Tail-poll interval (cadrage : 250-500 ms). 300 ms balances latency vs. cost.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Substring marking a completed turn in a Claude transcript line.
|
||||
const TURN_END_MARKER: &str = "turn_duration";
|
||||
|
||||
/// [`TurnWatcher`] reading Claude Code transcripts. Composes a [`FileSystem`] port (so it
|
||||
/// stays OS- and Tauri-agnostic and trivially testable) plus the base directory standing
|
||||
/// in for the user's home (`<home>/.claude/projects/`).
|
||||
#[derive(Clone)]
|
||||
pub struct ClaudeTranscriptTurnWatcher {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
home_dir: String,
|
||||
}
|
||||
|
||||
impl ClaudeTranscriptTurnWatcher {
|
||||
/// Builds the watcher from an injected [`FileSystem`] and the user's home directory
|
||||
/// (resolved by the composition root, e.g. from `$HOME`).
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>, home_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
home_dir: home_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle whose [`Drop`] stops the polling task **effectively and idempotently**: it
|
||||
/// flips the shared stop flag (observed at the top *and* bottom of each loop iteration)
|
||||
/// **and** aborts the task (so an in-flight `await` cannot fire after the drop).
|
||||
struct ClaudeTurnWatchHandle {
|
||||
stop: Arc<AtomicBool>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TurnWatchHandle for ClaudeTurnWatchHandle {}
|
||||
|
||||
impl Drop for ClaudeTurnWatchHandle {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnWatcher for ClaudeTranscriptTurnWatcher {
|
||||
fn supports(&self, profile: &AgentProfile) -> bool {
|
||||
supports_claude(profile)
|
||||
}
|
||||
|
||||
fn watch(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
conversation_id: Option<String>,
|
||||
cwd: ProjectPath,
|
||||
on_turn_end: OnTurnEnd,
|
||||
) -> Box<dyn TurnWatchHandle> {
|
||||
let fs = Arc::clone(&self.fs);
|
||||
let dir = claude_project_dir(&self.home_dir, &cwd);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_task = Arc::clone(&stop);
|
||||
application::diag!(
|
||||
"[turn-watcher] arm agent={agent} dir={} conversation={}",
|
||||
dir.0,
|
||||
conversation_id.as_deref().unwrap_or("<none>"),
|
||||
);
|
||||
let task = tokio::spawn(async move {
|
||||
// Baseline = turn ends already present at arm time (never re-fired).
|
||||
let mut last = count_turn_ends(fs.as_ref(), &dir).await;
|
||||
application::diag!("[turn-watcher] baseline agent={agent} count={last}");
|
||||
loop {
|
||||
if stop_task.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
if stop_task.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let current = count_turn_ends(fs.as_ref(), &dir).await;
|
||||
if current < last {
|
||||
// Rotation / truncation : re-baseline (best-effort, no spurious fire).
|
||||
last = current;
|
||||
continue;
|
||||
}
|
||||
if current > last {
|
||||
application::diag!(
|
||||
"[turn-watcher] turn_duration detected agent={agent} count {last}->{current} -> turn_ended"
|
||||
);
|
||||
last = current;
|
||||
// Callback invoked from the polling task, holding no lock (it ends up
|
||||
// in `InputMediator::turn_ended`). One fire per detected increase: a
|
||||
// burst still ends the currently-busy turn; an idle agent is a no-op.
|
||||
on_turn_end(agent);
|
||||
}
|
||||
}
|
||||
});
|
||||
Box::new(ClaudeTurnWatchHandle { stop, task })
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate count of `turn_duration` records across **all** `.jsonl` transcripts in the
|
||||
/// agent's per-run-dir folder. A missing folder/file (cold start) ⇒ `0`. Unreadable
|
||||
/// files are skipped (best-effort, never fatal).
|
||||
async fn count_turn_ends(fs: &dyn FileSystem, dir: &RemotePath) -> usize {
|
||||
let Ok(entries) = fs.list(dir).await else {
|
||||
return 0; // folder not created yet (cold start) or transient error.
|
||||
};
|
||||
let mut total = 0usize;
|
||||
for entry in entries {
|
||||
if entry.is_dir || !entry.name.ends_with(".jsonl") {
|
||||
continue;
|
||||
}
|
||||
let path = RemotePath::new(format!("{}/{}", dir.0, entry.name));
|
||||
let Ok(bytes) = fs.read(&path).await else {
|
||||
continue;
|
||||
};
|
||||
total += count_marker_lines(&bytes);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Counts transcript **lines** containing the [`TURN_END_MARKER`] (one logical turn end
|
||||
/// per `turn_duration` record). Substring match on the raw bytes — cheap and tolerant of
|
||||
/// partial/unparseable lines.
|
||||
fn count_marker_lines(body: &[u8]) -> usize {
|
||||
String::from_utf8_lossy(body)
|
||||
.lines()
|
||||
.filter(|line| line.contains(TURN_END_MARKER))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{DirEntry, FsError};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// In-memory [`FileSystem`] double: a flat map of path → bytes; `list` returns the
|
||||
/// basenames whose parent is the queried dir. Only `read`/`list` are exercised.
|
||||
#[derive(Default)]
|
||||
struct FakeFs {
|
||||
files: Mutex<HashMap<String, Vec<u8>>>,
|
||||
}
|
||||
impl FakeFs {
|
||||
fn set(&self, path: &str, body: &str) {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.to_owned(), body.as_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&path.0)
|
||||
.cloned()
|
||||
.ok_or_else(|| FsError::NotFound(path.0.clone()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.files.lock().unwrap().insert(path.0.clone(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(self.files.lock().unwrap().contains_key(&path.0))
|
||||
}
|
||||
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
let prefix = format!("{}/", path.0);
|
||||
let files = self.files.lock().unwrap();
|
||||
let mut out = Vec::new();
|
||||
for key in files.keys() {
|
||||
if let Some(rest) = key.strip_prefix(&prefix) {
|
||||
if !rest.contains('/') {
|
||||
out.push(DirEntry {
|
||||
name: rest.to_owned(),
|
||||
is_dir: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() && !files.keys().any(|k| k.starts_with(&prefix)) {
|
||||
return Err(FsError::NotFound(path.0.clone()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn line(extra: &str) -> String {
|
||||
format!("{{\"type\":\"result\",\"{extra}\":1}}\n")
|
||||
}
|
||||
|
||||
/// The run dir `/run/a` encodes to `-run-a` ⇒ folder `<home>/.claude/projects/-run-a`.
|
||||
fn transcript_path(name: &str) -> String {
|
||||
format!("/home/me/.claude/projects/-run-a/{name}")
|
||||
}
|
||||
|
||||
fn count_calls() -> (Arc<Mutex<Vec<AgentId>>>, OnTurnEnd) {
|
||||
let log: Arc<Mutex<Vec<AgentId>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let log2 = Arc::clone(&log);
|
||||
let cb: OnTurnEnd = Arc::new(move |a: AgentId| log2.lock().unwrap().push(a));
|
||||
(log, cb)
|
||||
}
|
||||
|
||||
async fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
|
||||
for _ in 0..100 {
|
||||
if cond() {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
cond()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_marker_lines_counts_only_turn_duration_lines() {
|
||||
let body = concat!(
|
||||
"{\"type\":\"user\"}\n",
|
||||
"{\"type\":\"result\",\"turn_duration\":12}\n",
|
||||
"garbage line\n",
|
||||
"{\"type\":\"result\",\"turn_duration\":7}\n",
|
||||
);
|
||||
assert_eq!(count_marker_lines(body.as_bytes()), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fires_on_new_turn_duration_above_baseline() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
// Baseline: one pre-existing turn end ⇒ must NOT fire for it.
|
||||
fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration"));
|
||||
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let (log, cb) = count_calls();
|
||||
let cwd = ProjectPath::new("/run/a").expect("path");
|
||||
let _h = watcher.watch(agent(1), None, cwd, cb);
|
||||
|
||||
// Append a second turn end ⇒ exactly one fire.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
fs.set(
|
||||
&transcript_path("engine-1.jsonl"),
|
||||
&format!("{}{}", line("turn_duration"), line("turn_duration")),
|
||||
);
|
||||
assert!(
|
||||
wait_until(|| !log.lock().unwrap().is_empty()).await,
|
||||
"a new turn_duration above baseline fires turn_ended"
|
||||
);
|
||||
assert_eq!(log.lock().unwrap().as_slice(), &[agent(1)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_existing_transcript_never_fires_phantom() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.set(
|
||||
&transcript_path("engine-1.jsonl"),
|
||||
&format!("{}{}", line("turn_duration"), line("turn_duration")),
|
||||
);
|
||||
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let (log, cb) = count_calls();
|
||||
let cwd = ProjectPath::new("/run/a").expect("path");
|
||||
let _h = watcher.watch(agent(1), None, cwd, cb);
|
||||
|
||||
// No new turn end appended ⇒ no fire, ever (baseline absorbs the existing ones).
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
assert!(log.lock().unwrap().is_empty(), "baseline must absorb pre-existing turn ends");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_start_missing_folder_then_first_turn_fires() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let (log, cb) = count_calls();
|
||||
let cwd = ProjectPath::new("/run/a").expect("path");
|
||||
let _h = watcher.watch(agent(1), None, cwd, cb);
|
||||
|
||||
// Folder/file appear later (cold start), then a first turn end is written.
|
||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||
fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration"));
|
||||
assert!(
|
||||
wait_until(|| !log.lock().unwrap().is_empty()).await,
|
||||
"a first turn end after a cold start fires (baseline was 0)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_handle_stops_polling() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let (log, cb) = count_calls();
|
||||
let cwd = ProjectPath::new("/run/a").expect("path");
|
||||
let h = watcher.watch(agent(1), None, cwd, cb);
|
||||
drop(h); // stop the watch.
|
||||
|
||||
// A turn end written after the drop must not fire.
|
||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||
fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration"));
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
assert!(log.lock().unwrap().is_empty(), "a dropped handle stops firing");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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<u32>) -> Duration {
|
||||
match override_ms {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -148,59 +148,6 @@ impl ProfileStore for FsProfileStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Merge-on-read decorator** over any [`ProfileStore`]: backfills *internal*
|
||||
/// reference defaults that a stored profile is missing, without ever persisting
|
||||
/// anything (finding A — propagate the measured `prompt_ready_pattern` to profiles
|
||||
/// saved before that field existed).
|
||||
///
|
||||
/// Only [`list`](ProfileStore::list) is transformed — each returned profile is run
|
||||
/// through [`application::overlay_reference_defaults`] (pure, allowlisted, never
|
||||
/// clobbers a user value, idempotent). `save`/`delete`/`is_configured`/
|
||||
/// `mark_configured` **delegate verbatim** so persistence and first-run detection are
|
||||
/// byte-for-byte unchanged. Wrap the concrete [`FsProfileStore`] once at the
|
||||
/// composition root, before injecting the store into use cases and the orchestrator,
|
||||
/// so every reader (including direct `.list()` calls) sees the overlaid reads.
|
||||
pub struct ReferenceBackfillProfileStore {
|
||||
inner: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl ReferenceBackfillProfileStore {
|
||||
/// Wraps an inner [`ProfileStore`]; reads are overlaid, writes pass through.
|
||||
#[must_use]
|
||||
pub fn new(inner: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for ReferenceBackfillProfileStore {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|p| application::overlay_reference_defaults(&p))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
self.inner.save(profile).await
|
||||
}
|
||||
|
||||
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
|
||||
self.inner.delete(id).await
|
||||
}
|
||||
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
self.inner.is_configured().await
|
||||
}
|
||||
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
self.inner.mark_configured().await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FsEmbedderProfileStore — declarative embedder profiles (`embedder.json`, LOT C).
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -340,87 +287,3 @@ impl EmbedderProfileStore for FsEmbedderProfileStore {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod backfill_tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// In-memory [`ProfileStore`] fake recording mutating/query calls, so we can prove
|
||||
/// the decorator transforms ONLY `list` and delegates the rest verbatim.
|
||||
#[derive(Default)]
|
||||
struct FakeStore {
|
||||
profiles: Mutex<Vec<AgentProfile>>,
|
||||
saved: Mutex<Vec<AgentProfile>>,
|
||||
deleted: Mutex<Vec<ProfileId>>,
|
||||
is_configured_calls: Mutex<u32>,
|
||||
mark_configured_calls: Mutex<u32>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for FakeStore {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok(self.profiles.lock().unwrap().clone())
|
||||
}
|
||||
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
self.saved.lock().unwrap().push(profile.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
|
||||
self.deleted.lock().unwrap().push(id);
|
||||
Ok(())
|
||||
}
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
*self.is_configured_calls.lock().unwrap() += 1;
|
||||
Ok(true)
|
||||
}
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
*self.mark_configured_calls.lock().unwrap() += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude reference profile with the internal field cleared — the persisted-before
|
||||
/// shape the overlay backfills.
|
||||
fn claude_without_pattern() -> AgentProfile {
|
||||
let id = application::reference_profile_id("claude");
|
||||
let mut p = application::reference_profiles()
|
||||
.into_iter()
|
||||
.find(|p| p.id == id)
|
||||
.expect("claude reference exists");
|
||||
p.prompt_ready_pattern = None;
|
||||
p
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_overlays_reference_defaults() {
|
||||
let fake = Arc::new(FakeStore::default());
|
||||
fake.profiles.lock().unwrap().push(claude_without_pattern());
|
||||
let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc<dyn ProfileStore>);
|
||||
|
||||
let listed = store.list().await.expect("list ok");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(
|
||||
listed[0].prompt_ready_pattern.as_deref(),
|
||||
Some("? for shortcuts"),
|
||||
"a stored Claude profile WITHOUT the pattern is backfilled on read"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_delete_and_config_delegate_verbatim() {
|
||||
let fake = Arc::new(FakeStore::default());
|
||||
let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc<dyn ProfileStore>);
|
||||
|
||||
let p = claude_without_pattern();
|
||||
store.save(&p).await.expect("save ok");
|
||||
store.delete(p.id).await.expect("delete ok");
|
||||
assert!(store.is_configured().await.expect("is_configured ok"));
|
||||
store.mark_configured().await.expect("mark_configured ok");
|
||||
|
||||
// save passes the profile through UNCHANGED (no overlay on the write path).
|
||||
assert_eq!(*fake.saved.lock().unwrap(), vec![p.clone()]);
|
||||
assert_eq!(*fake.deleted.lock().unwrap(), vec![p.id]);
|
||||
assert_eq!(*fake.is_configured_calls.lock().unwrap(), 1);
|
||||
assert_eq!(*fake.mark_configured_calls.lock().unwrap(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user