From 33edbad713abab84a12bcda76803e8a213db70a3 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 6 Jun 2026 12:18:14 +0200 Subject: [PATCH] feat(agent): isolate agent cwd in .ideai/run/ to kill convention-file collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECTURE §14.1: an agent's PTY cwd is now its own `/.ideai/run//` directory, never the project root, so N agents of the same profile no longer collide on a single conventional file (CLAUDE.md/AGENTS.md/...). - profile: cwd_template is now "{agentRunDir}" (built-in catalogue + docs). - runtime: resolve_cwd substitutes {agentRunDir} (legacy {projectRoot} kept). - LaunchAgent: computes + creates the run dir via FileSystem::create_dir_all, passes it as the cwd base to the pure prepare_invocation. Contract chosen: pass run_dir as the `cwd` argument (no PreparedContext change) — keeps prepare_invocation pure, I/O stays in the use case. - convention file is generated by IdeA inside the run dir via a pure compose_convention_file(project_root, agent_md): absolute project-root header + agent persona (extensible for skills, §14.2). - .gitignore: ignore .ideai/run/. - run-dir cleanup left as a TODO (FileSystem port exposes no delete). Tests: anti-collision (2 agents -> 2 distinct cwd, 2 distinct convention files, none at root), run-dir creation order, composed convention file; pure unit tests for agent_run_dir + compose_convention_file; runtime {agentRunDir} substitution. cargo test --workspace + clippy -D warnings green. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 5 + crates/application/src/agent/catalogue.rs | 8 +- crates/application/src/agent/lifecycle.rs | 107 ++++++++++++++++-- crates/application/tests/agent_lifecycle.rs | 111 +++++++++++++++++-- crates/domain/src/profile.rs | 5 +- crates/infrastructure/src/runtime/mod.rs | 19 +++- crates/infrastructure/tests/agent_runtime.rs | 18 ++- 7 files changed, 242 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 697f4cf..7ae9d76 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,11 @@ frontend/coverage/ # Personal, machine-local overrides (shared settings.json, if any, stays tracked). .claude/settings.local.json +# ─── IdeA project data ────────────────────────────────────────────────────── +# Ephemeral per-agent run directories (isolated PTY cwd + generated convention +# files), created at activation — not versioned (ARCHITECTURE §9.1 / §14.1). +.ideai/run/ + # ─── Editors / OS ─────────────────────────────────────────────────────────── .idea/ .vscode/ diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs index 21b876b..f43dad0 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -53,7 +53,7 @@ pub fn reference_profiles() -> Vec { ContextInjection::convention_file("CLAUDE.md") .expect("CLAUDE.md is a valid convention target"), Some("claude --version".to_owned()), - "{projectRoot}", + "{agentRunDir}", ) .expect("claude reference profile is valid"), AgentProfile::new( @@ -64,7 +64,7 @@ pub fn reference_profiles() -> Vec { ContextInjection::convention_file("AGENTS.md") .expect("AGENTS.md is a valid convention target"), Some("codex --version".to_owned()), - "{projectRoot}", + "{agentRunDir}", ) .expect("codex reference profile is valid"), AgentProfile::new( @@ -75,7 +75,7 @@ pub fn reference_profiles() -> Vec { ContextInjection::convention_file("GEMINI.md") .expect("GEMINI.md is a valid convention target"), Some("gemini --version".to_owned()), - "{projectRoot}", + "{agentRunDir}", ) .expect("gemini reference profile is valid"), AgentProfile::new( @@ -86,7 +86,7 @@ pub fn reference_profiles() -> Vec { ContextInjection::flag("--message-file {path}") .expect("aider flag template is non-empty"), Some("aider --version".to_owned()), - "{projectRoot}", + "{agentRunDir}", ) .expect("aider reference profile is valid"), ] diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index bc7a537..84b6d0a 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -421,24 +421,38 @@ impl LaunchAgent { AppError::NotFound(format!("profile {} for agent", agent.profile_id)) })?; - // 3. Prepare the invocation (pure): command + args + injection plan + cwd. + // 3. Compute and create the agent's isolated run directory + // `/.ideai/run//` (ARCHITECTURE §14.1). The PTY cwd is + // *never* the project root: each agent gets its own directory so that N + // instances of the same profile never collide on a single conventional + // file (CLAUDE.md, …). This is the only I/O in the cwd resolution; the + // runtime's `prepare_invocation` stays pure. + let run_dir = agent_run_dir(&input.project.root, &agent.id) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.fs + .create_dir_all(&RemotePath::new(run_dir.as_str().to_owned())) + .await?; + + // 4. Prepare the invocation (pure): command + args + injection plan + cwd. + // The run dir is passed as the cwd base; the profile's `{agentRunDir}` + // placeholder resolves against it. let prepared = PreparedContext { content: content.clone(), relative_path: agent.context_path.clone(), }; let mut spec = self .runtime - .prepare_invocation(&profile, &prepared, &input.project.root)?; + .prepare_invocation(&profile, &prepared, &run_dir)?; - // 4. Apply the injection plan side effects *before* spawning. + // 5. Apply the injection plan side effects *before* spawning. self.apply_injection(&input.project, &agent.context_path, &content, &mut spec) .await?; - // 5. Spawn the PTY at the resolved cwd; adopt its session id everywhere. + // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere. let handle = self.pty.spawn(spec.clone(), size).await?; let session_id = handle.session_id; - // 6. For the Stdin strategy, pipe the context once the PTY is live. + // 7. For the Stdin strategy, pipe the context once the PTY is live. if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) { self.pty.write(&handle, content.as_str().as_bytes())?; } @@ -477,12 +491,16 @@ impl LaunchAgent { ) -> Result<(), AppError> { match spec.context_plan.clone() { Some(ContextInjectionPlan::File { target }) => { - // conventionFile spike (ARCHITECTURE §13.6): copy the context to the - // conventional file (e.g. CLAUDE.md), overwriting any existing one. - // A copy (not a symlink) is the portable choice — Windows symlinks - // need privileges and SFTP/WSL symlink semantics differ. + // conventionFile (ARCHITECTURE §14.1): IdeA *generates* the + // conventional file (e.g. CLAUDE.md) inside the agent's isolated + // run directory — `spec.cwd` is that run dir, never the project + // root, so there is zero collision between agents. The document is + // composed: an absolute project-root header (so the agent knows + // where to operate, since its cwd is *not* the root) followed by + // the agent's persona `.md`. + let document = compose_convention_file(project.root.as_str(), content.as_str()); let path = RemotePath::new(join(&spec.cwd, &target)); - self.fs.write(&path, content.as_str().as_bytes()).await?; + self.fs.write(&path, document.as_bytes()).await?; } Some(ContextInjectionPlan::Env { var }) => { // Hand the CLI the absolute path of the agent's `.md` (which lives at @@ -505,6 +523,40 @@ fn join(base: &ProjectPath, rel: &str) -> String { format!("{b}/{rel}") } +/// Computes an agent's isolated run directory `/.ideai/run//` +/// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project +/// root — guaranteeing that two distinct agents on the same project root get two +/// distinct cwd (the anti-collision contract). +/// +/// # Errors +/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is +/// not a valid [`ProjectPath`] (should not happen for an absolute project root). +fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result { + ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}"))) +} + +/// Composes the convention file IdeA writes into an agent's run directory: an +/// absolute project-root header (the agent's cwd is the run dir, *not* the root, +/// so it must be told where to work) followed by the agent's persona `.md`. +/// +/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation, and +/// deliberately structured so future blocks (assigned skills, shared project +/// context — ARCHITECTURE §14.2) can be appended without touching the launcher. +#[must_use] +pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str) -> String { + let mut out = String::new(); + out.push_str("# Project root\n\n"); + out.push_str(project_root); + out.push_str("\n\nTous tes travaux portent sur ce project root (chemin absolu ci-dessus). "); + out.push_str( + "Ton répertoire courant est un dossier d'exécution isolé (`.ideai/run//`) ; \ + opère sur le project root, pas sur ce dossier.\n\n", + ); + out.push_str("---\n\n"); + out.push_str(agent_md); + out +} + /// Derives a unique, filesystem-safe `md_path` (`agents/.md`) for a new /// agent, disambiguating against the manifest's existing paths with a numeric /// suffix when needed. Shared with the template-driven agent creation (L7). @@ -536,3 +588,38 @@ fn slugify(name: &str) -> String { } out.trim_matches('-').to_owned() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() { + let root = ProjectPath::new("/home/me/proj").unwrap(); + let a = AgentId::from_uuid(uuid::Uuid::from_u128(1)); + let b = AgentId::from_uuid(uuid::Uuid::from_u128(2)); + + let dir_a = agent_run_dir(&root, &a).unwrap(); + let dir_b = agent_run_dir(&root, &b).unwrap(); + + assert_eq!(dir_a.as_str(), format!("/home/me/proj/.ideai/run/{a}")); + assert_ne!(dir_a, dir_b, "distinct agents → distinct run dirs"); + // Never the project root. + assert_ne!(dir_a.as_str(), "/home/me/proj"); + } + + #[test] + fn compose_convention_file_carries_root_then_persona() { + let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things."); + + // Absolute project root present. + assert!(doc.contains("/abs/project/root")); + // Persona present. + assert!(doc.contains("# Persona")); + assert!(doc.contains("Do things.")); + // Root header precedes the persona body (ordering of the composition). + let root_at = doc.find("/abs/project/root").unwrap(); + let persona_at = doc.find("# Persona").unwrap(); + assert!(root_at < persona_at, "root header must precede the persona"); + } +} diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 0eda9cb..4c87a04 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -221,6 +221,7 @@ impl AgentRuntime for FakeRuntime { struct FakeFs { trace: Trace, writes: WriteLog, + created_dirs: Arc>>, } impl FakeFs { @@ -228,11 +229,15 @@ impl FakeFs { Self { trace, writes: Arc::new(Mutex::new(Vec::new())), + created_dirs: Arc::new(Mutex::new(Vec::new())), } } fn writes(&self) -> Vec<(String, Vec)> { self.writes.lock().unwrap().clone() } + fn created_dirs(&self) -> Vec { + self.created_dirs.lock().unwrap().clone() + } } #[async_trait] @@ -251,7 +256,11 @@ impl FileSystem for FakeFs { async fn exists(&self, _path: &RemotePath) -> Result { Ok(false) } - async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { + async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { + self.created_dirs + .lock() + .unwrap() + .push(path.as_str().to_owned()); Ok(()) } async fn list(&self, _path: &RemotePath) -> Result, FsError> { @@ -386,7 +395,7 @@ fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile { Vec::new(), injection, Some("claude --version".to_owned()), - "{projectRoot}", + "{agentRunDir}", ) .unwrap() } @@ -600,17 +609,32 @@ async fn launch_orders_prepare_then_injection_then_spawn() { "prepare → injection → spawn" ); - // The conventionFile was written to /CLAUDE.md with the context body. + // The conventionFile was written inside the agent's isolated run directory + // (`.ideai/run//CLAUDE.md`) — NOT at the project root. Its content + // is the *composed* document: an absolute project-root header followed by the + // agent persona `.md`. + let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); let writes = fs.writes(); assert_eq!(writes.len(), 1); - assert_eq!(writes[0].0, "/home/me/proj/CLAUDE.md"); - assert_eq!(writes[0].1, b"# ctx body"); + assert_eq!(writes[0].0, format!("{run_dir}/CLAUDE.md")); + let written = String::from_utf8(writes[0].1.clone()).unwrap(); + assert!( + written.contains("/home/me/proj"), + "convention file must carry the absolute project root, got: {written}" + ); + assert!( + written.contains("# ctx body"), + "convention file must carry the agent persona, got: {written}" + ); - // Spawn happened at the resolved cwd with the profile command. + // The run directory was created (via the FileSystem port) before spawn. + assert_eq!(fs.created_dirs(), vec![run_dir.clone()]); + + // Spawn happened at the isolated run dir with the profile command. let spawns = pty.spawns(); assert_eq!(spawns.len(), 1); assert_eq!(spawns[0].command, "claude"); - assert_eq!(spawns[0].cwd.as_str(), "/home/me/proj"); + assert_eq!(spawns[0].cwd.as_str(), run_dir); // The session adopts the PTY id, is Running, and is registered as an agent. assert_eq!(out.session.id, sid(777)); @@ -630,6 +654,79 @@ async fn launch_orders_prepare_then_injection_then_spawn() { ); } +/// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same* +/// profile on the *same* project root must launch into two **distinct** cwd — +/// each its own `.ideai/run//` — and each writes its convention file +/// inside its own run dir, never colliding at the project root. +#[tokio::test] +async fn two_agents_same_root_get_distinct_run_dirs_no_collision() { + let injection = ContextInjection::convention_file("CLAUDE.md").unwrap(); + let plan = Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }); + + // Two agents, same profile (pid(9)), same project root. + let agent_a = scratch_agent(aid(1), "Alpha", "agents/alpha.md", pid(9)); + let agent_b = scratch_agent(aid(2), "Bravo", "agents/bravo.md", pid(9)); + + let contexts = FakeContexts::with_agent(&agent_a, "# alpha"); + { + let mut inner = contexts.0.lock().unwrap(); + inner + .manifest + .entries + .push(ManifestEntry::from_agent(&agent_b)); + inner + .contents + .insert(agent_b.context_path.clone(), "# bravo".to_owned()); + } + let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]); + + let tr = trace(); + let fs = FakeFs::new(Arc::clone(&tr)); + let pty = FakePty::new(Arc::clone(&tr), sid(777)); + let sessions = Arc::new(TerminalSessions::new()); + let launch = LaunchAgent::new( + Arc::new(contexts), + Arc::new(profiles), + Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)), + Arc::new(fs.clone()), + Arc::new(pty.clone()), + Arc::clone(&sessions), + Arc::new(SpyBus::default()), + ); + + launch.execute(launch_input(agent_a.id)).await.unwrap(); + launch.execute(launch_input(agent_b.id)).await.unwrap(); + + let dir_a = format!("/home/me/proj/.ideai/run/{}", agent_a.id); + let dir_b = format!("/home/me/proj/.ideai/run/{}", agent_b.id); + assert_ne!(dir_a, dir_b, "the two agents must map to different run dirs"); + + // Two distinct run dirs were created. + assert_eq!(fs.created_dirs(), vec![dir_a.clone(), dir_b.clone()]); + + // Two spawns at two distinct cwd — the core anti-collision guarantee. + let spawns = pty.spawns(); + assert_eq!(spawns.len(), 2); + assert_eq!(spawns[0].cwd.as_str(), dir_a); + assert_eq!(spawns[1].cwd.as_str(), dir_b); + assert_ne!(spawns[0].cwd, spawns[1].cwd); + + // Two convention files, each inside its own run dir (no shared root file). + let writes = fs.writes(); + assert_eq!(writes.len(), 2); + assert_eq!(writes[0].0, format!("{dir_a}/CLAUDE.md")); + assert_eq!(writes[1].0, format!("{dir_b}/CLAUDE.md")); + assert_ne!(writes[0].0, writes[1].0); + // Neither writes to the project root. + assert!(writes.iter().all(|(p, _)| p != "/home/me/proj/CLAUDE.md")); + + // Each convention file carries its own persona. + assert!(String::from_utf8(writes[0].1.clone()).unwrap().contains("# alpha")); + assert!(String::from_utf8(writes[1].1.clone()).unwrap().contains("# bravo")); +} + #[tokio::test] async fn launch_stdin_strategy_pipes_context_after_spawn() { let (launch, agent, fs, pty, _bus, _sessions, tr) = diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 3481021..f69408a 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -96,7 +96,10 @@ pub struct AgentProfile { pub context_injection: ContextInjection, /// Optional detection command (e.g. `claude --version`). pub detect: Option, - /// Working-directory template (e.g. `"{projectRoot}"`). + /// Working-directory template. Always `"{agentRunDir}"` (ARCHITECTURE §14.1): + /// an agent's PTY cwd is its isolated `.ideai/run//` directory, + /// **never** the project root, so that N agents of the same profile never + /// collide on a single conventional file (`CLAUDE.md`, …) at the root. pub cwd_template: String, } diff --git a/crates/infrastructure/src/runtime/mod.rs b/crates/infrastructure/src/runtime/mod.rs index c30a6d4..1b62869 100644 --- a/crates/infrastructure/src/runtime/mod.rs +++ b/crates/infrastructure/src/runtime/mod.rs @@ -80,16 +80,23 @@ impl CliAgentRuntime { }) } - /// Resolves the profile's `cwd_template` against the project root. + /// Resolves the profile's `cwd_template` against the supplied `base` cwd. /// - /// The only recognised placeholder is `{projectRoot}` (CONTEXT §9). An empty - /// template defaults to the project root itself. - fn resolve_cwd(profile: &AgentProfile, root: &ProjectPath) -> Result { + /// The base is the agent's **run directory** (`.ideai/run//`), + /// already computed (and created) by `LaunchAgent` and passed as the `cwd` + /// argument of [`prepare_invocation`](AgentRuntime::prepare_invocation). The + /// recognised placeholder is `{agentRunDir}` (ARCHITECTURE §14.1); the legacy + /// `{projectRoot}` is still substituted with the same base for backwards + /// compatibility (the caller always passes the run dir now). An empty template + /// defaults to the base itself. + fn resolve_cwd(profile: &AgentProfile, base: &ProjectPath) -> Result { let template = profile.cwd_template.trim(); if template.is_empty() { - return Ok(root.clone()); + return Ok(base.clone()); } - let resolved = template.replace("{projectRoot}", root.as_str()); + let resolved = template + .replace("{agentRunDir}", base.as_str()) + .replace("{projectRoot}", base.as_str()); ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string())) } diff --git a/crates/infrastructure/tests/agent_runtime.rs b/crates/infrastructure/tests/agent_runtime.rs index 2ec975c..5517e9e 100644 --- a/crates/infrastructure/tests/agent_runtime.rs +++ b/crates/infrastructure/tests/agent_runtime.rs @@ -217,15 +217,27 @@ fn prepare_substitutes_project_root_in_cwd_template() { } #[test] -fn prepare_empty_cwd_template_defaults_to_root() { +fn prepare_empty_cwd_template_defaults_to_base() { let rt = pure_runtime(); let p = profile(ContextInjection::stdin(), ""); - let root = ProjectPath::new("/home/me/proj").unwrap(); + let base = ProjectPath::new("/home/me/proj").unwrap(); - let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap(); + let spec = rt.prepare_invocation(&p, &ctx(), &base).unwrap(); assert_eq!(spec.cwd.as_str(), "/home/me/proj"); } +#[test] +fn prepare_substitutes_agent_run_dir_in_cwd_template() { + // The canonical template (ARCHITECTURE §14.1): `{agentRunDir}` resolves to the + // base cwd the launcher passes — the agent's isolated run directory. + let rt = pure_runtime(); + let p = profile(ContextInjection::convention_file("CLAUDE.md").unwrap(), "{agentRunDir}"); + let run_dir = ProjectPath::new("/home/me/proj/.ideai/run/agent-1").unwrap(); + + let spec = rt.prepare_invocation(&p, &ctx(), &run_dir).unwrap(); + assert_eq!(spec.cwd.as_str(), "/home/me/proj/.ideai/run/agent-1"); +} + // --------------------------------------------------------------------------- // detection_spec (pure) // ---------------------------------------------------------------------------