merge: isolate agent cwd in .ideai/run/<id> (convention-file collision fix, §14.1)
This commit is contained in:
@ -53,7 +53,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
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<AgentProfile> {
|
||||
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<AgentProfile> {
|
||||
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<AgentProfile> {
|
||||
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"),
|
||||
]
|
||||
|
||||
@ -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
|
||||
// `<root>/.ideai/run/<agent-id>/` (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 `<root>/.ideai/run/<agent-id>/`
|
||||
/// (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, domain::error::DomainError> {
|
||||
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/<agent>/`) ; \
|
||||
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/<slug>.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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user