merge: isolate agent cwd in .ideai/run/<id> (convention-file collision fix, §14.1)
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@ -25,6 +25,11 @@ frontend/coverage/
|
|||||||
# Personal, machine-local overrides (shared settings.json, if any, stays tracked).
|
# Personal, machine-local overrides (shared settings.json, if any, stays tracked).
|
||||||
.claude/settings.local.json
|
.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 ───────────────────────────────────────────────────────────
|
# ─── Editors / OS ───────────────────────────────────────────────────────────
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@ -53,7 +53,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
ContextInjection::convention_file("CLAUDE.md")
|
ContextInjection::convention_file("CLAUDE.md")
|
||||||
.expect("CLAUDE.md is a valid convention target"),
|
.expect("CLAUDE.md is a valid convention target"),
|
||||||
Some("claude --version".to_owned()),
|
Some("claude --version".to_owned()),
|
||||||
"{projectRoot}",
|
"{agentRunDir}",
|
||||||
)
|
)
|
||||||
.expect("claude reference profile is valid"),
|
.expect("claude reference profile is valid"),
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
@ -64,7 +64,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
ContextInjection::convention_file("AGENTS.md")
|
ContextInjection::convention_file("AGENTS.md")
|
||||||
.expect("AGENTS.md is a valid convention target"),
|
.expect("AGENTS.md is a valid convention target"),
|
||||||
Some("codex --version".to_owned()),
|
Some("codex --version".to_owned()),
|
||||||
"{projectRoot}",
|
"{agentRunDir}",
|
||||||
)
|
)
|
||||||
.expect("codex reference profile is valid"),
|
.expect("codex reference profile is valid"),
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
@ -75,7 +75,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
ContextInjection::convention_file("GEMINI.md")
|
ContextInjection::convention_file("GEMINI.md")
|
||||||
.expect("GEMINI.md is a valid convention target"),
|
.expect("GEMINI.md is a valid convention target"),
|
||||||
Some("gemini --version".to_owned()),
|
Some("gemini --version".to_owned()),
|
||||||
"{projectRoot}",
|
"{agentRunDir}",
|
||||||
)
|
)
|
||||||
.expect("gemini reference profile is valid"),
|
.expect("gemini reference profile is valid"),
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
@ -86,7 +86,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
ContextInjection::flag("--message-file {path}")
|
ContextInjection::flag("--message-file {path}")
|
||||||
.expect("aider flag template is non-empty"),
|
.expect("aider flag template is non-empty"),
|
||||||
Some("aider --version".to_owned()),
|
Some("aider --version".to_owned()),
|
||||||
"{projectRoot}",
|
"{agentRunDir}",
|
||||||
)
|
)
|
||||||
.expect("aider reference profile is valid"),
|
.expect("aider reference profile is valid"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -421,24 +421,38 @@ impl LaunchAgent {
|
|||||||
AppError::NotFound(format!("profile {} for agent", agent.profile_id))
|
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 {
|
let prepared = PreparedContext {
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
relative_path: agent.context_path.clone(),
|
relative_path: agent.context_path.clone(),
|
||||||
};
|
};
|
||||||
let mut spec = self
|
let mut spec = self
|
||||||
.runtime
|
.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)
|
self.apply_injection(&input.project, &agent.context_path, &content, &mut spec)
|
||||||
.await?;
|
.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 handle = self.pty.spawn(spec.clone(), size).await?;
|
||||||
let session_id = handle.session_id;
|
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)) {
|
if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) {
|
||||||
self.pty.write(&handle, content.as_str().as_bytes())?;
|
self.pty.write(&handle, content.as_str().as_bytes())?;
|
||||||
}
|
}
|
||||||
@ -477,12 +491,16 @@ impl LaunchAgent {
|
|||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
match spec.context_plan.clone() {
|
match spec.context_plan.clone() {
|
||||||
Some(ContextInjectionPlan::File { target }) => {
|
Some(ContextInjectionPlan::File { target }) => {
|
||||||
// conventionFile spike (ARCHITECTURE §13.6): copy the context to the
|
// conventionFile (ARCHITECTURE §14.1): IdeA *generates* the
|
||||||
// conventional file (e.g. CLAUDE.md), overwriting any existing one.
|
// conventional file (e.g. CLAUDE.md) inside the agent's isolated
|
||||||
// A copy (not a symlink) is the portable choice — Windows symlinks
|
// run directory — `spec.cwd` is that run dir, never the project
|
||||||
// need privileges and SFTP/WSL symlink semantics differ.
|
// 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));
|
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 }) => {
|
Some(ContextInjectionPlan::Env { var }) => {
|
||||||
// Hand the CLI the absolute path of the agent's `.md` (which lives at
|
// 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}")
|
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
|
/// Derives a unique, filesystem-safe `md_path` (`agents/<slug>.md`) for a new
|
||||||
/// agent, disambiguating against the manifest's existing paths with a numeric
|
/// agent, disambiguating against the manifest's existing paths with a numeric
|
||||||
/// suffix when needed. Shared with the template-driven agent creation (L7).
|
/// 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()
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -221,6 +221,7 @@ impl AgentRuntime for FakeRuntime {
|
|||||||
struct FakeFs {
|
struct FakeFs {
|
||||||
trace: Trace,
|
trace: Trace,
|
||||||
writes: WriteLog<String>,
|
writes: WriteLog<String>,
|
||||||
|
created_dirs: Arc<Mutex<Vec<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FakeFs {
|
impl FakeFs {
|
||||||
@ -228,11 +229,15 @@ impl FakeFs {
|
|||||||
Self {
|
Self {
|
||||||
trace,
|
trace,
|
||||||
writes: Arc::new(Mutex::new(Vec::new())),
|
writes: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
created_dirs: Arc::new(Mutex::new(Vec::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn writes(&self) -> Vec<(String, Vec<u8>)> {
|
fn writes(&self) -> Vec<(String, Vec<u8>)> {
|
||||||
self.writes.lock().unwrap().clone()
|
self.writes.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
fn created_dirs(&self) -> Vec<String> {
|
||||||
|
self.created_dirs.lock().unwrap().clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -251,7 +256,11 @@ impl FileSystem for FakeFs {
|
|||||||
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
||||||
Ok(false)
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||||
@ -389,7 +398,7 @@ fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile {
|
|||||||
Vec::new(),
|
Vec::new(),
|
||||||
injection,
|
injection,
|
||||||
Some("claude --version".to_owned()),
|
Some("claude --version".to_owned()),
|
||||||
"{projectRoot}",
|
"{agentRunDir}",
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
@ -603,17 +612,32 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
|
|||||||
"prepare → injection → spawn"
|
"prepare → injection → spawn"
|
||||||
);
|
);
|
||||||
|
|
||||||
// The conventionFile was written to <cwd>/CLAUDE.md with the context body.
|
// The conventionFile was written inside the agent's isolated run directory
|
||||||
|
// (`.ideai/run/<agent-id>/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();
|
let writes = fs.writes();
|
||||||
assert_eq!(writes.len(), 1);
|
assert_eq!(writes.len(), 1);
|
||||||
assert_eq!(writes[0].0, "/home/me/proj/CLAUDE.md");
|
assert_eq!(writes[0].0, format!("{run_dir}/CLAUDE.md"));
|
||||||
assert_eq!(writes[0].1, b"# ctx body");
|
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();
|
let spawns = pty.spawns();
|
||||||
assert_eq!(spawns.len(), 1);
|
assert_eq!(spawns.len(), 1);
|
||||||
assert_eq!(spawns[0].command, "claude");
|
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.
|
// The session adopts the PTY id, is Running, and is registered as an agent.
|
||||||
assert_eq!(out.session.id, sid(777));
|
assert_eq!(out.session.id, sid(777));
|
||||||
@ -633,6 +657,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/<agent-id>/` — 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]
|
#[tokio::test]
|
||||||
async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
||||||
let (launch, agent, fs, pty, _bus, _sessions, tr) =
|
let (launch, agent, fs, pty, _bus, _sessions, tr) =
|
||||||
|
|||||||
@ -96,7 +96,10 @@ pub struct AgentProfile {
|
|||||||
pub context_injection: ContextInjection,
|
pub context_injection: ContextInjection,
|
||||||
/// Optional detection command (e.g. `claude --version`).
|
/// Optional detection command (e.g. `claude --version`).
|
||||||
pub detect: Option<String>,
|
pub detect: Option<String>,
|
||||||
/// Working-directory template (e.g. `"{projectRoot}"`).
|
/// Working-directory template. Always `"{agentRunDir}"` (ARCHITECTURE §14.1):
|
||||||
|
/// an agent's PTY cwd is its isolated `.ideai/run/<agent-id>/` 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,
|
pub cwd_template: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
/// The base is the agent's **run directory** (`.ideai/run/<agent-id>/`),
|
||||||
/// template defaults to the project root itself.
|
/// already computed (and created) by `LaunchAgent` and passed as the `cwd`
|
||||||
fn resolve_cwd(profile: &AgentProfile, root: &ProjectPath) -> Result<ProjectPath, RuntimeError> {
|
/// 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<ProjectPath, RuntimeError> {
|
||||||
let template = profile.cwd_template.trim();
|
let template = profile.cwd_template.trim();
|
||||||
if template.is_empty() {
|
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()))
|
ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -217,15 +217,27 @@ fn prepare_substitutes_project_root_in_cwd_template() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prepare_empty_cwd_template_defaults_to_root() {
|
fn prepare_empty_cwd_template_defaults_to_base() {
|
||||||
let rt = pure_runtime();
|
let rt = pure_runtime();
|
||||||
let p = profile(ContextInjection::stdin(), "");
|
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");
|
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)
|
// detection_spec (pure)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user