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>
155 lines
6.8 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|