From 98e9ce8237b11f4b8eaa8f9d58f6f1e489a7f0aa Mon Sep 17 00:00:00 2001 From: lolismek Date: Fri, 10 Apr 2026 03:31:42 -0400 Subject: [PATCH] fix(swarm): fix deploy key, metadata, and branch collisions for private tasks When running `hive swarm up` on a private task, all agents clone the same repo in branch mode. Three bugs caused agents to interfere with each other: 1. Deploy key filename derived from ssh_url was identical for all agents, causing each agent to overwrite the previous one's key file. 2. .hive/ metadata was written before `git checkout`, so checkout could overwrite it with stale data. Also .hive/ was not gitignored. 3. If the remote branch didn't exist yet, checkout failed silently and the agent stayed on main. Fixes: include agent_id in key filename, move metadata writes after checkout, add .hive/ to .gitignore, and fall back to `checkout -b` when the branch doesn't exist on the remote. --- src/hive/cli/cmd_swarm.py | 45 ++++++-- tests/cli/test_cmd_swarm.py | 206 ++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 10 deletions(-) diff --git a/src/hive/cli/cmd_swarm.py b/src/hive/cli/cmd_swarm.py index fb6557f..3bce1f3 100644 --- a/src/hive/cli/cmd_swarm.py +++ b/src/hive/cli/cmd_swarm.py @@ -72,6 +72,18 @@ def _register_agents(count: int, prefix: str | None) -> list[dict]: return agents +def _ensure_gitignore_entry(gitignore_path: Path, entry: str): + if gitignore_path.exists(): + content = gitignore_path.read_text() + if entry in content.splitlines(): + return + if not content.endswith("\n"): + content += "\n" + else: + content = "" + gitignore_path.write_text(content + entry + "\n") + + def _clone_one(task_id: str, agent: dict, base_dir: Path) -> dict: token = agent["token"] agent_id = agent["id"] @@ -84,7 +96,7 @@ def _clone_one(task_id: str, agent: dict, base_dir: Path) -> dict: key_dir = Path.home() / ".hive" / "keys" key_dir.mkdir(parents=True, exist_ok=True) fork_name = ssh_url.split("/")[-1].replace(".git", "") - key_path = key_dir / fork_name + key_path = key_dir / f"{fork_name}--{agent_id}" if private_key: key_path.write_text(private_key) key_path.chmod(0o600) @@ -103,33 +115,46 @@ def _clone_one(task_id: str, agent: dict, base_dir: Path) -> dict: subprocess.run(["git", "-C", str(work_dir), "config", "core.sshCommand", ssh_cmd], capture_output=True, text=True) - # Write .hive metadata - hive_dir = work_dir / ".hive" - hive_dir.mkdir(exist_ok=True) - (hive_dir / "task").write_text(task_id) - (hive_dir / "agent").write_text(agent_id) - if mode == "branch": - # Branch mode: checkout initial branch, no upstream remote + # Branch mode: checkout initial branch default_branch = resp.get("default_branch", "") if default_branch: - subprocess.run(["git", "-C", str(work_dir), "checkout", default_branch], - capture_output=True, text=True) + checkout = subprocess.run( + ["git", "-C", str(work_dir), "checkout", default_branch], + capture_output=True, text=True) + if checkout.returncode != 0: + subprocess.run( + ["git", "-C", str(work_dir), "checkout", "-b", default_branch], + capture_output=True, text=True) + + # Write .hive metadata AFTER checkout so it can't be overwritten + hive_dir = work_dir / ".hive" + hive_dir.mkdir(exist_ok=True) + (hive_dir / "task").write_text(task_id) + (hive_dir / "agent").write_text(agent_id) (hive_dir / "fork.json").write_text(json.dumps({ "mode": "branch", "branch_prefix": resp.get("branch_prefix", ""), "key_path": str(key_path), }, indent=2)) + _ensure_gitignore_entry(work_dir / ".gitignore", ".hive/") else: # Fork mode: add upstream remote upstream_url = resp.get("upstream_url", "") if upstream_url: subprocess.run(["git", "-C", str(work_dir), "remote", "add", "upstream", upstream_url], capture_output=True, text=True) + + # Write .hive metadata + hive_dir = work_dir / ".hive" + hive_dir.mkdir(exist_ok=True) + (hive_dir / "task").write_text(task_id) + (hive_dir / "agent").write_text(agent_id) (hive_dir / "fork.json").write_text(json.dumps({ "mode": "fork", "fork_url": resp.get("fork_url", ""), "key_path": str(key_path), }, indent=2)) + _ensure_gitignore_entry(work_dir / ".gitignore", ".hive/") return {"agent_id": agent_id, "work_dir": str(work_dir), "key_path": str(key_path)} diff --git a/tests/cli/test_cmd_swarm.py b/tests/cli/test_cmd_swarm.py index 92bd48a..7b7da6a 100644 --- a/tests/cli/test_cmd_swarm.py +++ b/tests/cli/test_cmd_swarm.py @@ -1,6 +1,11 @@ +import json +import subprocess +from pathlib import Path + import click.testing from hive.cli.hive import hive +from hive.cli.cmd_swarm import _clone_one class TestSwarmHelp: @@ -40,3 +45,204 @@ def test_unknown_task(self, tmp_path, monkeypatch): runner = click.testing.CliRunner() result = runner.invoke(hive, ["swarm", "down", "no-such-task"]) assert result.exit_code != 0 + + +def _make_branch_response(agent_id: str): + return { + "mode": "branch", + "ssh_url": "git@github.com:owner/task--foo.git", + "private_key": f"PRIVATE_KEY_FOR_{agent_id}", + "default_branch": f"hive/{agent_id}/initial", + "branch_prefix": f"hive/{agent_id}/", + } + + +def _mock_subprocess(monkeypatch, *, checkout_fails=False): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + if cmd[0] == "git" and cmd[1] == "clone": + Path(cmd[3]).mkdir(parents=True, exist_ok=True) + (Path(cmd[3]) / ".git").mkdir(exist_ok=True) + return subprocess.CompletedProcess(cmd, 0, "", "") + if cmd[0] == "git" and "checkout" in cmd and checkout_fails and "-b" not in cmd: + return subprocess.CompletedProcess(cmd, 1, "", "error: pathspec did not match") + return subprocess.CompletedProcess(cmd, 0, "", "") + + monkeypatch.setattr("subprocess.run", fake_run) + return calls + + +class TestCloneOneBugs: + + def test_bug1_deploy_key_collision(self, tmp_path, monkeypatch): + """Two agents with same ssh_url must get separate key files.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + + responses = iter([_make_branch_response("agent-1"), + _make_branch_response("agent-2")]) + monkeypatch.setattr("hive.cli.cmd_swarm._api", + lambda *a, **kw: next(responses)) + _mock_subprocess(monkeypatch) + + base = tmp_path / "work" + base.mkdir() + _clone_one("test-task", {"id": "agent-1", "token": "t1"}, base) + _clone_one("test-task", {"id": "agent-2", "token": "t2"}, base) + + key_dir = fake_home / ".hive" / "keys" + key_files = sorted(p.name for p in key_dir.iterdir()) + assert len(key_files) == 2, f"Expected 2 key files, got {key_files}" + keys = {p.name: p.read_text() for p in key_dir.iterdir()} + assert len(set(keys.values())) == 2, "Keys have identical content" + + def test_bug2_metadata_survives_checkout(self, tmp_path, monkeypatch): + """.hive/agent must contain correct agent_id after checkout.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setattr("hive.cli.cmd_swarm._api", + lambda *a, **kw: _make_branch_response("agent-1")) + + def fake_run(cmd, **kwargs): + if cmd[0] == "git" and cmd[1] == "clone": + Path(cmd[3]).mkdir(parents=True, exist_ok=True) + (Path(cmd[3]) / ".git").mkdir(exist_ok=True) + return subprocess.CompletedProcess(cmd, 0, "", "") + if cmd[0] == "git" and "checkout" in cmd and "-b" not in cmd: + work_dir = Path(cmd[cmd.index("-C") + 1]) + hive_dir = work_dir / ".hive" + if hive_dir.exists(): + (hive_dir / "agent").write_text("stale-agent") + return subprocess.CompletedProcess(cmd, 0, "", "") + return subprocess.CompletedProcess(cmd, 0, "", "") + + monkeypatch.setattr("subprocess.run", fake_run) + + base = tmp_path / "work" + base.mkdir() + result = _clone_one("test-task", {"id": "agent-1", "token": "t1"}, base) + + agent_file = Path(result["work_dir"]) / ".hive" / "agent" + assert agent_file.read_text() == "agent-1" + + def test_bug2_gitignore_added(self, tmp_path, monkeypatch): + """.hive/ should be in workspace .gitignore.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setattr("hive.cli.cmd_swarm._api", + lambda *a, **kw: _make_branch_response("agent-1")) + _mock_subprocess(monkeypatch) + + base = tmp_path / "work" + base.mkdir() + result = _clone_one("test-task", {"id": "agent-1", "token": "t1"}, base) + + gitignore = Path(result["work_dir"]) / ".gitignore" + assert gitignore.exists(), ".gitignore not created" + assert ".hive/" in gitignore.read_text() + + def test_bug3_checkout_fallback_on_missing_branch(self, tmp_path, monkeypatch): + """If branch doesn't exist, fall back to checkout -b.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setattr("hive.cli.cmd_swarm._api", + lambda *a, **kw: _make_branch_response("agent-1")) + calls = _mock_subprocess(monkeypatch, checkout_fails=True) + + base = tmp_path / "work" + base.mkdir() + _clone_one("test-task", {"id": "agent-1", "token": "t1"}, base) + + checkout_cmds = [c for c in calls if c[0] == "git" and "checkout" in c] + assert len(checkout_cmds) >= 2, f"Expected checkout fallback, got {checkout_cmds}" + assert "-b" in checkout_cmds[1] + + +def _init_bare_repo(path): + """Create a bare git repo with one commit (simulates a GitHub remote).""" + subprocess.run(["git", "init", "--bare", str(path)], capture_output=True, check=True) + # Clone it, add a commit, push back + tmp_clone = path.parent / "tmp-clone" + subprocess.run(["git", "clone", str(path), str(tmp_clone)], capture_output=True, check=True) + (tmp_clone / "README.md").write_text("hello") + subprocess.run(["git", "-C", str(tmp_clone), "add", "."], capture_output=True, check=True) + subprocess.run(["git", "-C", str(tmp_clone), "commit", "-m", "init"], + capture_output=True, check=True, + env={**__import__("os").environ, + "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t"}) + subprocess.run(["git", "-C", str(tmp_clone), "push"], capture_output=True, check=True) + import shutil + shutil.rmtree(tmp_clone) + + +class TestCloneOneIntegration: + """End-to-end test using real git repos (no subprocess mocking).""" + + def test_two_agents_private_task(self, tmp_path, monkeypatch): + """Simulate two agents cloning the same private-task repo in branch mode.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + + # Create a bare repo to act as the "remote" + remote = tmp_path / "remote.git" + _init_bare_repo(remote) + + call_count = 0 + agents = ["agent-alpha", "agent-beta"] + + def fake_api(*args, **kwargs): + nonlocal call_count + aid = agents[call_count] + call_count += 1 + return { + "mode": "branch", + "ssh_url": str(remote), + "private_key": f"KEY_{aid}", + "default_branch": f"hive/{aid}/initial", + "branch_prefix": f"hive/{aid}/", + } + + monkeypatch.setattr("hive.cli.cmd_swarm._api", fake_api) + + base = tmp_path / "workdirs" + base.mkdir() + r1 = _clone_one("my-task", {"id": "agent-alpha", "token": "t1"}, base) + r2 = _clone_one("my-task", {"id": "agent-beta", "token": "t2"}, base) + + # Bug 1: each agent has its own deploy key + key_dir = fake_home / ".hive" / "keys" + key_files = sorted(p.name for p in key_dir.iterdir()) + assert len(key_files) == 2 + assert "agent-alpha" in key_files[0] + assert "agent-beta" in key_files[1] + + # Bug 2: .hive/agent is correct in each workspace + assert (Path(r1["work_dir"]) / ".hive" / "agent").read_text() == "agent-alpha" + assert (Path(r2["work_dir"]) / ".hive" / "agent").read_text() == "agent-beta" + + # Bug 2: .gitignore contains .hive/ + assert ".hive/" in (Path(r1["work_dir"]) / ".gitignore").read_text() + assert ".hive/" in (Path(r2["work_dir"]) / ".gitignore").read_text() + + # Bug 2: fork.json has correct per-agent branch_prefix + f1 = json.loads((Path(r1["work_dir"]) / ".hive" / "fork.json").read_text()) + f2 = json.loads((Path(r2["work_dir"]) / ".hive" / "fork.json").read_text()) + assert f1["branch_prefix"] == "hive/agent-alpha/" + assert f2["branch_prefix"] == "hive/agent-beta/" + + # Bug 3: each agent is on its own branch (created via -b fallback) + def current_branch(work_dir): + r = subprocess.run(["git", "-C", work_dir, "branch", "--show-current"], + capture_output=True, text=True) + return r.stdout.strip() + + assert current_branch(r1["work_dir"]) == "hive/agent-alpha/initial" + assert current_branch(r2["work_dir"]) == "hive/agent-beta/initial"