diff --git a/.cursor/skills/record-agent-issue/SKILL.md b/.cursor/skills/record-agent-issue/SKILL.md index 4edb9f7..88c532d 100644 --- a/.cursor/skills/record-agent-issue/SKILL.md +++ b/.cursor/skills/record-agent-issue/SKILL.md @@ -71,6 +71,22 @@ Future triage moves conclusively fixed, non-reproducible, or superseded records to `docs/issues/resolved/` and updates their resolution history. Do not delete records. +## Shared worktree safety + +Concurrent Cursor agents (or moving the agent root between sibling clones) can +silently change which branch is checked out in a shared worktree. Before any +mutating git command (`cherry-pick`, `commit`, `push`, `checkout` that writes), +assert the expected branch: + +```bash +uv run python -m devops.git.assert_branch --repo \ + --operation "git cherry-pick origin/results" +``` + +Prefer separate worktrees per concurrent agent when possible. When a branch +mismatch error fires, re-check `git status` and the agent UI root before +retrying the git operation. + ## Common gotchas - `run_manifest.json` is per-run provenance, not the cross-cutting issue queue. diff --git a/.cursor/skills/vast-provisioning/SKILL.md b/.cursor/skills/vast-provisioning/SKILL.md index fe9f1ec..c898371 100644 --- a/.cursor/skills/vast-provisioning/SKILL.md +++ b/.cursor/skills/vast-provisioning/SKILL.md @@ -22,7 +22,11 @@ run a command in `tmux`. > no boxes remain (`status`, or check ). > **DO NOT TAKE OVER ANOTHER AGENT'S BOX:** multiple Cursor agents (or git -> worktrees) can run on the same Mac against the same vast.ai account. Each +> worktrees) can run on the same Mac against the same vast.ai account. The same +> sharing also applies to **git branches**: assert the expected branch with +> `uv run python -m devops.git.assert_branch --repo ` before +> `git commit`, `git push`, or result-import commands so a concurrent session +> cannot land writes on the wrong branch. Each > library checkout has its **own** gitignored `devops/vast/state.json`, but > `~/.ssh/config.d/vast.conf` and the vast account are **shared machine-wide**. > An agent whose `state.json` is empty must **not** assume no boxes are running. diff --git a/devops/git/__init__.py b/devops/git/__init__.py new file mode 100644 index 0000000..14c81fb --- /dev/null +++ b/devops/git/__init__.py @@ -0,0 +1,5 @@ +"""Git helpers for local agent orchestration (not used on training boxes).""" + +from devops.git.assert_branch import assert_branch, current_branch, BranchMismatchError + +__all__ = ["assert_branch", "current_branch", "BranchMismatchError"] diff --git a/devops/git/assert_branch.py b/devops/git/assert_branch.py new file mode 100644 index 0000000..d57acd1 --- /dev/null +++ b/devops/git/assert_branch.py @@ -0,0 +1,108 @@ +"""Assert the active Git branch before mutating operations in shared worktrees. + +Concurrent Cursor agent sessions can mutate one worktree's HEAD when they share +a checkout or when the agent root moves between sibling clones. Call +``assert_branch`` (or the CLI) immediately before ``git cherry-pick``, ``git +commit``, ``git push``, and similar write operations. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +class BranchMismatchError(Exception): + """Raised when the active branch does not match the expected branch.""" + + def __init__( + self, + repo: Path, + expected: str, + actual: str, + *, + operation: str | None = None, + ) -> None: + self.repo = repo + self.expected = expected + self.actual = actual + self.operation = operation + op = f" before {operation}" if operation else "" + super().__init__( + f"expected branch {expected!r} in {repo}{op}, but HEAD is {actual!r}" + ) + + +def current_branch(repo: Path) -> str: + """Return the abbreviated name of the active branch in ``repo``.""" + completed = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).strip() + raise RuntimeError(f"git rev-parse failed in {repo}: {detail}") + branch = completed.stdout.strip() + if not branch or branch == "HEAD": + raise RuntimeError( + f"detached HEAD in {repo}; branch assertion requires a named branch" + ) + return branch + + +def assert_branch( + repo: Path, + expected: str, + *, + operation: str | None = None, +) -> str: + """Return the active branch when it matches ``expected``; otherwise raise.""" + actual = current_branch(repo) + if actual != expected: + raise BranchMismatchError(repo, expected, actual, operation=operation) + return actual + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Assert the active Git branch before a mutating git operation.", + ) + parser.add_argument( + "expected_branch", + help="Branch name that must be checked out in the target repository.", + ) + parser.add_argument( + "--repo", + type=Path, + default=Path.cwd(), + help="Repository path (default: current working directory).", + ) + parser.add_argument( + "--operation", + default=None, + help="Optional label for the upcoming git command (included in errors).", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + repo = args.repo.resolve() + try: + assert_branch(repo, args.expected_branch, operation=args.operation) + except BranchMismatchError as exc: + print(str(exc), file=sys.stderr) + return 1 + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/issues/open/2026-07-19-shared-worktree-branch-race.md b/docs/issues/resolved/2026-07-19-shared-worktree-branch-race.md similarity index 83% rename from docs/issues/open/2026-07-19-shared-worktree-branch-race.md rename to docs/issues/resolved/2026-07-19-shared-worktree-branch-race.md index 92ba606..27f9c98 100644 --- a/docs/issues/open/2026-07-19-shared-worktree-branch-race.md +++ b/docs/issues/resolved/2026-07-19-shared-worktree-branch-race.md @@ -1,5 +1,5 @@ --- -status: open +status: resolved severity: high area: agent orchestration / git worktree isolation discovered: 2026-07-19 @@ -55,3 +55,9 @@ the risk. - 2026-07-19 — Recorded from the MESS3 result-import incident. - 2026-07-19 — Reproduced during issue reporting: moving the agent root to the harness clone silently checked out the experiment repository's branch name. +- 2026-08-02 — Fix drafted on `automated/bugfix/2026-08-02-shared-worktree-branch-race` + (not merged). +- 2026-08-09 — Weekly triage re-landed `devops.git.assert_branch` CLI/helper and + agent-skill guidance so mutating git commands can fail fast when HEAD drifted. + Cursor platform behavior is unchanged; separate worktrees per concurrent agent + remains the strongest isolation. Covered by `tests/test_git_assert_branch.py`. diff --git a/tests/test_git_assert_branch.py b/tests/test_git_assert_branch.py new file mode 100644 index 0000000..99f1f24 --- /dev/null +++ b/tests/test_git_assert_branch.py @@ -0,0 +1,76 @@ +import subprocess +from pathlib import Path + +import pytest + +from devops.git.assert_branch import ( + BranchMismatchError, + assert_branch, + current_branch, + main, +) + + +def _init_repo(path: Path) -> None: + subprocess.run(["git", "init", "-b", "main"], cwd=path, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True) + subprocess.run(["git", "config", "user.name", "test"], cwd=path, check=True) + (path / "README").write_text("seed\n") + subprocess.run(["git", "add", "README"], cwd=path, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "seed"], + cwd=path, + check=True, + capture_output=True, + ) + + +def test_current_branch_returns_active_branch(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _init_repo(repo) + assert current_branch(repo) == "main" + + +def test_assert_branch_passes_when_branch_matches(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _init_repo(repo) + assert assert_branch(repo, "main") == "main" + + +def test_assert_branch_raises_on_mismatch(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _init_repo(repo) + subprocess.run( + ["git", "checkout", "-b", "feature"], + cwd=repo, + check=True, + capture_output=True, + ) + with pytest.raises(BranchMismatchError) as excinfo: + assert_branch(repo, "main", operation="git cherry-pick") + assert excinfo.value.expected == "main" + assert excinfo.value.actual == "feature" + assert excinfo.value.operation == "git cherry-pick" + + +def test_cli_returns_nonzero_on_mismatch(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _init_repo(repo) + subprocess.run( + ["git", "checkout", "-b", "feature"], + cwd=repo, + check=True, + capture_output=True, + ) + assert main(["main", "--repo", str(repo)]) == 1 + + +def test_cli_succeeds_when_branch_matches(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _init_repo(repo) + assert main(["main", "--repo", str(repo)]) == 0