Files
IdeA/crates/infrastructure/src/inspector/claude_paths.rs
Blomios 6050a6da5f feat(orchestrator): watchdog d'inactivité réarmable + plafond du rendez-vous inter-agents
Redessine la borne de fin de tour du rendez-vous `idea_ask_agent` ⇄ `idea_reply` :
au lieu d'un timeout plat (qui coupait à 600 s un seul long tour de la cible, cf.
T7), la borne devient une **fenêtre d'inactivité réarmée** à chaque progrès observé
de la cible, sous un **plafond absolu** (défaut 4 h, réglable via
`IDEA_ASK_RENDEZVOUS_CEILING_MS`).

- Sonde d'activité (`transcript_activity_token`, inspector) : jeton monotone =
  octets cumulés des `.jsonl` de la cible. Croît même pendant un seul long tour
  sans `turn_duration` ⇒ détecte « vivant et au travail » mi-tour. Best-effort,
  sans effet de bord ; folder absent/illisible ⇒ « pas de progrès ».
- Watchdog (`run_inactivity_watchdog`, nouveau module `orchestrator/rendezvous`) :
  fenêtre réarmable + plafond, fallback timeout plat si aucune sonde (zéro régression).
- Issue typée distincte `TargetCeilingActive` (code `RENDEZVOUS_CEILING_ACTIVE`) :
  une cible **active** stoppée au plafond n'est jamais confondue avec un
  `TargetReturnedNoReply` (silence) ; le message guide « ne pas retenter à l'aveugle ».
- Câblage composition-root (`state.rs`) : sonde résolue nom→AgentId→run-dir transcript,
  branchée sur le service et sur l'McpServer (`AskActivityProbe`, `with_ask_ceiling`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:32:19 +02:00

155 lines
6.8 KiB
Rust

//! 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::{FileSystem, RemotePath};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::ProjectPath;
/// Encodes a cwd the way Claude Code names its per-project transcript folder.
///
/// **Verified against real `~/.claude/projects/` folders:** Claude flattens the absolute
/// cwd into a single directory segment by replacing **every character that is not an ASCII
/// alphanumeric** (`[a-zA-Z0-9]`) with `-` — this is Claude Code's `replace(/[^a-zA-Z0-9]/g,
/// '-')`. Crucially that means **`.` is encoded too**, not just path separators: a run dir
/// like `/home/anthony/Documents/Projects/IdeA/.ideai/run/<uuid>` becomes
/// `-home-anthony-Documents-Projects-IdeA--ideai-run-<uuid>` (the `/.` collapses to a
/// **double** dash). The earlier version only replaced `/` and `\`, leaving the `.`, so it
/// computed `-IdeA-.ideai-run-…` — a folder that never exists on disk; the turn-watcher
/// then saw an empty dir (baseline 0, conversation `<none>`) and never fired `turn_ended`,
/// wedging the no-reply backstop. We keep this convention in one small, tested seam so a
/// future Claude encoding change touches only here.
pub(crate) fn encode_cwd(cwd: &str) -> String {
cwd.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}
/// `<home>/.claude/projects/<encoded-cwd>` — the directory holding **all** of one
/// agent's conversation transcripts for `cwd` (its isolated run dir). Trailing path
/// separators on `home_dir` are trimmed first.
pub(crate) fn claude_project_dir(home_dir: &str, cwd: &ProjectPath) -> RemotePath {
let base = home_dir.trim_end_matches(['/', '\\']);
let encoded = encode_cwd(cwd.as_str());
RemotePath::new(format!("{base}/.claude/projects/{encoded}"))
}
/// `<home>/.claude/projects/<encoded-cwd>/<conversation-id>.jsonl`.
pub(crate) fn claude_transcript_path(
home_dir: &str,
conversation_id: &str,
cwd: &ProjectPath,
) -> RemotePath {
let dir = claude_project_dir(home_dir, cwd);
RemotePath::new(format!("{}/{conversation_id}.jsonl", dir.0))
}
/// Recognises a Claude profile by its conventional context file `CLAUDE.md`, mirroring
/// the detection in `application::agent::lifecycle`. Shared by the inspector and the
/// turn watcher so both agree on what they can read.
pub(crate) fn supports_claude(profile: &AgentProfile) -> bool {
matches!(
&profile.context_injection,
ContextInjection::ConventionFile { target }
if target
.rsplit(['/', '\\'])
.next()
.unwrap_or(target)
.eq_ignore_ascii_case("CLAUDE.md")
)
}
/// Cumulative byte size of **all** `.jsonl` transcripts in a Claude agent's per-run-dir
/// folder (`<home>/.claude/projects/<encoded-cwd>/`) — a monotonic **activity token** for
/// the `idea_ask_agent` rendezvous inactivity watchdog (server-side safety net).
///
/// Why bytes, not the `turn_duration` count: a target working in **one long turn** never
/// emits a `turn_duration` record until that turn ends, yet its transcript file keeps
/// **growing** as the model streams output. Summing transcript bytes therefore detects
/// "alive and working" even mid-turn — exactly the wedge the flat timeout mis-handled.
/// Best-effort and side-effect-free: a missing folder (cold start) or any unreadable file
/// contributes `0`; `None` only when the folder cannot be listed at all (treated by the
/// watchdog as "no observable progress"). Lives beside the path encoder so the transcript
/// layout knowledge stays in one tested seam.
pub async fn transcript_activity_token(
fs: &dyn FileSystem,
home_dir: &str,
cwd: &ProjectPath,
) -> Option<u64> {
let dir = claude_project_dir(home_dir, cwd);
let entries = fs.list(&dir).await.ok()?;
let mut total: u64 = 0;
for entry in entries {
if entry.is_dir || !entry.name.ends_with(".jsonl") {
continue;
}
let path = RemotePath::new(format!("{}/{}", dir.0, entry.name));
if let Ok(bytes) = fs.read(&path).await {
total = total.saturating_add(bytes.len() as u64);
}
}
Some(total)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_cwd_flattens_separators_to_dash() {
assert_eq!(
encode_cwd("/home/anthony/Documents/Projects/IdeA"),
"-home-anthony-Documents-Projects-IdeA"
);
assert_eq!(encode_cwd("/a/b"), "-a-b");
// Every non-alphanumeric flattens to `-`, including `:` and `\` on Windows paths.
assert_eq!(encode_cwd("C:\\Users\\me"), "C--Users-me");
}
/// Non-regression for the no-reply backstop wedge: an IdeA agent's isolated run dir
/// contains a `.` (`/.ideai/`), which Claude encodes as `-` just like `/`. The `/.`
/// must therefore collapse to a **double** dash, matching the real on-disk folder
/// `-home-anthony-Documents-Projects-IdeA--ideai-run-<uuid>`. The earlier `/`-only
/// encoding produced `-IdeA-.ideai-run-…` (nonexistent) and broke the turn watcher.
#[test]
fn encode_cwd_encodes_dot_in_run_dir_to_double_dash() {
let uuid = "73c853d1-c0fd-463b-ad17-1d24fefa371f";
let cwd = format!("/home/anthony/Documents/Projects/IdeA/.ideai/run/{uuid}");
assert_eq!(
encode_cwd(&cwd),
format!("-home-anthony-Documents-Projects-IdeA--ideai-run-{uuid}")
);
// The hyphens already present inside the UUID survive (they are non-alphanumeric,
// re-encoded to the same `-`), so the segment matches byte-for-byte.
assert!(encode_cwd(&cwd).ends_with(uuid));
}
#[test]
fn project_dir_and_transcript_path_compose() {
let cwd = ProjectPath::new("/a/b").expect("valid path");
assert_eq!(
claude_project_dir("/home/me/", &cwd).0,
"/home/me/.claude/projects/-a-b"
);
assert_eq!(
claude_transcript_path("/home/me", "conv-1", &cwd).0,
"/home/me/.claude/projects/-a-b/conv-1.jsonl"
);
}
}