merge: isolate agent cwd in .ideai/run/<id> (convention-file collision fix, §14.1)

This commit is contained in:
2026-06-06 12:26:02 +02:00
7 changed files with 242 additions and 31 deletions

View File

@ -221,6 +221,7 @@ impl AgentRuntime for FakeRuntime {
struct FakeFs {
trace: Trace,
writes: WriteLog<String>,
created_dirs: Arc<Mutex<Vec<String>>>,
}
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<u8>)> {
self.writes.lock().unwrap().clone()
}
fn created_dirs(&self) -> Vec<String> {
self.created_dirs.lock().unwrap().clone()
}
}
#[async_trait]
@ -251,7 +256,11 @@ impl FileSystem for FakeFs {
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
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<Vec<DirEntry>, FsError> {
@ -389,7 +398,7 @@ fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile {
Vec::new(),
injection,
Some("claude --version".to_owned()),
"{projectRoot}",
"{agentRunDir}",
)
.unwrap()
}
@ -603,17 +612,32 @@ async fn launch_orders_prepare_then_injection_then_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();
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));
@ -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]
async fn launch_stdin_strategy_pipes_context_after_spawn() {
let (launch, agent, fs, pty, _bus, _sessions, tr) =