Build repository transaction core
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02b6f-7db1-7222-918b-e813a6bda38d
This commit is contained in:
parent
e4b73a8a78
commit
eaae4357eb
3 changed files with 600 additions and 0 deletions
358
rein_aharness/repository_transaction.py
Normal file
358
rein_aharness/repository_transaction.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
"""Process-safe transaction boundary for a local Git checkout.
|
||||
|
||||
This module is intentionally not wired into the claim loop yet. It provides
|
||||
the repository-local half of HARNESS-WP-0003-T02 while the Activity Core lease
|
||||
boundary is still under owner review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import TracebackType
|
||||
from typing import Any, TextIO
|
||||
|
||||
|
||||
class RepositoryTransactionError(RuntimeError):
|
||||
"""Base error for repository transaction refusal or setup failure."""
|
||||
|
||||
|
||||
class GitRepositoryError(RepositoryTransactionError):
|
||||
"""The target cannot provide the required Git repository state."""
|
||||
|
||||
|
||||
class RepositoryBusyError(RepositoryTransactionError):
|
||||
"""Another process owns the canonical repository lock."""
|
||||
|
||||
def __init__(self, repo_id: str, owner: dict[str, Any] | None = None) -> None:
|
||||
self.repo_id = repo_id
|
||||
self.owner = owner or {}
|
||||
owner_tx = self.owner.get("transaction_id")
|
||||
suffix = f" owner_transaction={owner_tx}" if owner_tx else ""
|
||||
super().__init__(f"repository is already locked: repo_id={repo_id}{suffix}")
|
||||
|
||||
|
||||
class DirtyRepositoryError(RepositoryTransactionError):
|
||||
"""An unattended transaction was refused because its baseline is dirty."""
|
||||
|
||||
def __init__(self, baseline: "RepositoryBaseline") -> None:
|
||||
self.baseline = baseline
|
||||
super().__init__(
|
||||
"repository baseline is dirty: "
|
||||
f"repo_id={baseline.repo_id} entries={baseline.dirty_entries}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepositoryBaseline:
|
||||
"""Exact local facts captured while the canonical repository lock is held."""
|
||||
|
||||
repo_root: Path
|
||||
git_common_dir: Path
|
||||
repo_id: str
|
||||
head: str
|
||||
branch: str | None
|
||||
clean: bool
|
||||
dirty_entries: int
|
||||
status_digest: str
|
||||
index_diff_digest: str
|
||||
worktree_diff_digest: str
|
||||
upstream_ref: str | None
|
||||
upstream_oid: str | None
|
||||
remote_refs: tuple[tuple[str, str], ...]
|
||||
|
||||
@property
|
||||
def detached(self) -> bool:
|
||||
return self.branch is None
|
||||
|
||||
def evidence(self) -> dict[str, Any]:
|
||||
"""Return bounded, path-safe baseline evidence for run results."""
|
||||
remote_payload = "\n".join(f"{name} {oid}" for name, oid in self.remote_refs)
|
||||
return {
|
||||
"repo_id": self.repo_id,
|
||||
"repo_name": self.repo_root.name,
|
||||
"head": self.head,
|
||||
"branch": self.branch,
|
||||
"detached": self.detached,
|
||||
"clean": self.clean,
|
||||
"dirty_entries": self.dirty_entries,
|
||||
"status_digest": self.status_digest,
|
||||
"index_diff_digest": self.index_diff_digest,
|
||||
"worktree_diff_digest": self.worktree_diff_digest,
|
||||
"upstream_ref": self.upstream_ref,
|
||||
"upstream_oid": self.upstream_oid,
|
||||
"remote_ref_count": len(self.remote_refs),
|
||||
"remote_refs_digest": _digest(remote_payload),
|
||||
}
|
||||
|
||||
|
||||
class RepositoryTransaction:
|
||||
"""Hold one canonical Git repository lock and capture its baseline.
|
||||
|
||||
Lock files live outside the checkout and are keyed by the resolved Git
|
||||
common directory. This makes aliases and linked worktrees for one local
|
||||
repository contend on the same lock while unrelated repositories proceed
|
||||
independently.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo: Path,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
state_dir: Path | None = None,
|
||||
require_clean: bool = True,
|
||||
) -> None:
|
||||
self.repo = Path(repo).expanduser().resolve()
|
||||
self.correlation_id = _bounded_text(correlation_id, 200)
|
||||
self.state_dir = Path(state_dir).expanduser().resolve() if state_dir else _state_dir()
|
||||
self.require_clean = require_clean
|
||||
self.transaction_id = uuid.uuid4().hex
|
||||
self.baseline: RepositoryBaseline | None = None
|
||||
self.lock_path: Path | None = None
|
||||
self._lock_file: TextIO | None = None
|
||||
|
||||
@property
|
||||
def locked(self) -> bool:
|
||||
return self._lock_file is not None
|
||||
|
||||
def __enter__(self) -> "RepositoryTransaction":
|
||||
if self.locked:
|
||||
raise RepositoryTransactionError("repository transaction is already entered")
|
||||
|
||||
repo_root, common_dir, repo_id = _canonical_repository(self.repo)
|
||||
locks_dir = self.state_dir / "repository-locks"
|
||||
locks_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.lock_path = locks_dir / f"{repo_id}.lock"
|
||||
lock_file = _open_private_lock(self.lock_path)
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
owner = _read_lock_metadata(lock_file)
|
||||
lock_file.close()
|
||||
raise RepositoryBusyError(repo_id, owner) from exc
|
||||
|
||||
self._lock_file = lock_file
|
||||
self._write_metadata(
|
||||
{
|
||||
"transaction_id": self.transaction_id,
|
||||
"repo_id": repo_id,
|
||||
"repo_name": repo_root.name,
|
||||
"pid": os.getpid(),
|
||||
"correlation_id": self.correlation_id,
|
||||
"acquired_at": _now(),
|
||||
"state": "capturing-baseline",
|
||||
}
|
||||
)
|
||||
try:
|
||||
baseline = _capture_baseline(repo_root, common_dir, repo_id)
|
||||
self.baseline = baseline
|
||||
self._write_metadata(
|
||||
{
|
||||
"transaction_id": self.transaction_id,
|
||||
"repo_id": repo_id,
|
||||
"repo_name": repo_root.name,
|
||||
"pid": os.getpid(),
|
||||
"correlation_id": self.correlation_id,
|
||||
"acquired_at": _now(),
|
||||
"state": "active",
|
||||
"baseline": baseline.evidence(),
|
||||
}
|
||||
)
|
||||
if self.require_clean and not baseline.clean:
|
||||
raise DirtyRepositoryError(baseline)
|
||||
return self
|
||||
except BaseException:
|
||||
self.release()
|
||||
raise
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> None:
|
||||
self.release()
|
||||
|
||||
def release(self) -> None:
|
||||
"""Release the OS lock without deleting checkout or lock-state data."""
|
||||
lock_file = self._lock_file
|
||||
if lock_file is None:
|
||||
return
|
||||
try:
|
||||
self._write_metadata(
|
||||
{
|
||||
"transaction_id": self.transaction_id,
|
||||
"repo_id": self.baseline.repo_id if self.baseline else None,
|
||||
"repo_name": self.baseline.repo_root.name if self.baseline else self.repo.name,
|
||||
"pid": os.getpid(),
|
||||
"correlation_id": self.correlation_id,
|
||||
"released_at": _now(),
|
||||
"state": "released",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
lock_file.close()
|
||||
self._lock_file = None
|
||||
|
||||
def evidence(self) -> dict[str, Any]:
|
||||
"""Return the bounded transaction and baseline result envelope."""
|
||||
if self.baseline is None:
|
||||
raise RepositoryTransactionError("repository baseline is not captured")
|
||||
return {
|
||||
"transaction_id": self.transaction_id,
|
||||
"correlation_id": self.correlation_id,
|
||||
"baseline": self.baseline.evidence(),
|
||||
}
|
||||
|
||||
def _write_metadata(self, value: dict[str, Any]) -> None:
|
||||
if self._lock_file is None:
|
||||
return
|
||||
self._lock_file.seek(0)
|
||||
self._lock_file.truncate()
|
||||
json.dump(value, self._lock_file, sort_keys=True, separators=(",", ":"))
|
||||
self._lock_file.write("\n")
|
||||
self._lock_file.flush()
|
||||
os.fsync(self._lock_file.fileno())
|
||||
|
||||
|
||||
def _state_dir() -> Path:
|
||||
explicit = os.environ.get("REIN_AHARNESS_STATE_DIR", "").strip()
|
||||
if explicit:
|
||||
return Path(explicit).expanduser().resolve()
|
||||
xdg = os.environ.get("XDG_STATE_HOME", "").strip()
|
||||
if xdg:
|
||||
return (Path(xdg).expanduser() / "rein-aharness").resolve()
|
||||
return (Path.home() / ".local" / "state" / "rein-aharness").resolve()
|
||||
|
||||
|
||||
def _canonical_repository(repo: Path) -> tuple[Path, Path, str]:
|
||||
root_result = _run_git(repo, "rev-parse", "--show-toplevel")
|
||||
if root_result.returncode != 0:
|
||||
raise GitRepositoryError(f"not a Git working tree: {repo}")
|
||||
repo_root = Path(root_result.stdout.strip()).resolve()
|
||||
|
||||
common_result = _run_git(repo_root, "rev-parse", "--git-common-dir")
|
||||
if common_result.returncode != 0 or not common_result.stdout.strip():
|
||||
raise GitRepositoryError(f"cannot resolve Git common directory: {repo_root}")
|
||||
raw_common = Path(common_result.stdout.strip())
|
||||
common_dir = (repo_root / raw_common).resolve() if not raw_common.is_absolute() else raw_common.resolve()
|
||||
repo_id = hashlib.sha256(str(common_dir).encode("utf-8")).hexdigest()[:32]
|
||||
return repo_root, common_dir, repo_id
|
||||
|
||||
|
||||
def _capture_baseline(repo_root: Path, common_dir: Path, repo_id: str) -> RepositoryBaseline:
|
||||
head = _git(repo_root, "rev-parse", "--verify", "HEAD")
|
||||
branch_result = _run_git(repo_root, "symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
if branch_result.returncode not in (0, 1):
|
||||
raise GitRepositoryError(
|
||||
f"cannot resolve branch state: {branch_result.stderr.strip()[:200]}"
|
||||
)
|
||||
branch = branch_result.stdout.strip() or None
|
||||
|
||||
status = _git(repo_root, "status", "--porcelain=v2", "--untracked-files=all")
|
||||
status_lines = tuple(line for line in status.splitlines() if line)
|
||||
index_diff = _git(repo_root, "diff", "--cached", "--binary", "--no-ext-diff")
|
||||
worktree_diff = _git(repo_root, "diff", "--binary", "--no-ext-diff")
|
||||
|
||||
remote_lines = _git(
|
||||
repo_root,
|
||||
"for-each-ref",
|
||||
"--format=%(refname) %(objectname)",
|
||||
"refs/remotes",
|
||||
)
|
||||
remote_refs: list[tuple[str, str]] = []
|
||||
for line in remote_lines.splitlines():
|
||||
name, separator, oid = line.partition(" ")
|
||||
if separator and name and oid:
|
||||
remote_refs.append((name, oid))
|
||||
|
||||
upstream_result = _run_git(
|
||||
repo_root,
|
||||
"rev-parse",
|
||||
"--abbrev-ref",
|
||||
"--symbolic-full-name",
|
||||
"@{upstream}",
|
||||
)
|
||||
upstream_ref = upstream_result.stdout.strip() if upstream_result.returncode == 0 else None
|
||||
upstream_oid = _git(repo_root, "rev-parse", upstream_ref) if upstream_ref else None
|
||||
|
||||
return RepositoryBaseline(
|
||||
repo_root=repo_root,
|
||||
git_common_dir=common_dir,
|
||||
repo_id=repo_id,
|
||||
head=head,
|
||||
branch=branch,
|
||||
clean=not status_lines,
|
||||
dirty_entries=len(status_lines),
|
||||
status_digest=_digest(status),
|
||||
index_diff_digest=_digest(index_diff),
|
||||
worktree_diff_digest=_digest(worktree_diff),
|
||||
upstream_ref=upstream_ref,
|
||||
upstream_oid=upstream_oid,
|
||||
remote_refs=tuple(remote_refs),
|
||||
)
|
||||
|
||||
|
||||
def _run_git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise GitRepositoryError(f"git {' '.join(args)} failed: {exc}") from exc
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
result = _run_git(repo, *args)
|
||||
if result.returncode != 0:
|
||||
reason = (result.stderr or result.stdout or "unknown Git error").strip()[:300]
|
||||
raise GitRepositoryError(f"git {' '.join(args)} failed: {reason}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _read_lock_metadata(lock_file: TextIO) -> dict[str, Any] | None:
|
||||
try:
|
||||
lock_file.seek(0)
|
||||
raw = lock_file.read(4096)
|
||||
value = json.loads(raw) if raw.strip() else None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _open_private_lock(path: Path) -> TextIO:
|
||||
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
return os.fdopen(fd, "r+", encoding="utf-8")
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def _digest(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
|
||||
|
||||
|
||||
def _bounded_text(value: str, limit: int) -> str:
|
||||
cleaned = " ".join(str(value).split())
|
||||
return cleaned[:limit]
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
225
tests/test_repository_transaction.py
Normal file
225
tests/test_repository_transaction.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from rein_aharness.repository_transaction import (
|
||||
DirtyRepositoryError,
|
||||
GitRepositoryError,
|
||||
RepositoryBusyError,
|
||||
RepositoryTransaction,
|
||||
)
|
||||
|
||||
|
||||
def _make_repo(path: Path, name: str = "repo") -> Path:
|
||||
repo = path / name
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
(repo / "README.md").write_text("baseline\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"user.email=test@example.invalid",
|
||||
"-c",
|
||||
"user.name=test",
|
||||
"commit",
|
||||
"-qm",
|
||||
"baseline",
|
||||
],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
)
|
||||
return repo
|
||||
|
||||
|
||||
def test_transaction_captures_clean_branch_and_remote_refs(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
subprocess.run(
|
||||
["git", "update-ref", "refs/remotes/origin/main", "HEAD"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
)
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
with RepositoryTransaction(repo, correlation_id="run-1", state_dir=state_dir) as tx:
|
||||
assert tx.locked
|
||||
assert tx.baseline is not None
|
||||
evidence = tx.baseline.evidence()
|
||||
assert evidence["clean"] is True
|
||||
assert evidence["head"]
|
||||
assert evidence["branch"] in {"main", "master"}
|
||||
assert evidence["detached"] is False
|
||||
assert evidence["remote_ref_count"] == 1
|
||||
assert len(evidence["repo_id"]) == 32
|
||||
assert tx.evidence() == {
|
||||
"transaction_id": tx.transaction_id,
|
||||
"correlation_id": "run-1",
|
||||
"baseline": evidence,
|
||||
}
|
||||
assert tx.lock_path is not None
|
||||
assert not tx.lock_path.is_relative_to(repo)
|
||||
assert tx.lock_path.stat().st_mode & 0o777 == 0o600
|
||||
metadata = json.loads(tx.lock_path.read_text(encoding="utf-8"))
|
||||
assert metadata["transaction_id"] == tx.transaction_id
|
||||
assert metadata["correlation_id"] == "run-1"
|
||||
|
||||
assert tx.locked is False
|
||||
|
||||
|
||||
def test_dirty_baseline_is_refused_without_changing_user_files(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
changed = repo / "README.md"
|
||||
changed.write_text("operator change\n", encoding="utf-8")
|
||||
before = _status(repo)
|
||||
|
||||
with pytest.raises(DirtyRepositoryError) as excinfo:
|
||||
with RepositoryTransaction(repo, state_dir=tmp_path / "state"):
|
||||
pytest.fail("dirty transaction must not enter")
|
||||
|
||||
assert excinfo.value.baseline.clean is False
|
||||
assert excinfo.value.baseline.dirty_entries == 1
|
||||
assert changed.read_text(encoding="utf-8") == "operator change\n"
|
||||
assert _status(repo) == before
|
||||
|
||||
# Refusal released the lock; inspection can explicitly opt into dirty state.
|
||||
with RepositoryTransaction(
|
||||
repo,
|
||||
state_dir=tmp_path / "state",
|
||||
require_clean=False,
|
||||
) as tx:
|
||||
assert tx.baseline is not None
|
||||
assert tx.baseline.clean is False
|
||||
|
||||
|
||||
def test_detached_head_is_recorded_without_refusal(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
subprocess.run(["git", "checkout", "--detach", "-q"], cwd=repo, check=True)
|
||||
|
||||
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
||||
assert tx.baseline is not None
|
||||
assert tx.baseline.detached is True
|
||||
assert tx.baseline.branch is None
|
||||
|
||||
|
||||
def test_staged_and_untracked_state_are_captured_as_dirty(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
(repo / "README.md").write_text("staged change\n", encoding="utf-8")
|
||||
(repo / "UNTRACKED.txt").write_text("operator file\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=repo, check=True)
|
||||
|
||||
with RepositoryTransaction(
|
||||
repo,
|
||||
state_dir=tmp_path / "state",
|
||||
require_clean=False,
|
||||
) as tx:
|
||||
assert tx.baseline is not None
|
||||
assert tx.baseline.clean is False
|
||||
assert tx.baseline.dirty_entries == 2
|
||||
assert tx.baseline.index_diff_digest != hashlib.sha256(b"").hexdigest()
|
||||
assert tx.baseline.status_digest
|
||||
|
||||
|
||||
def test_resolved_path_alias_contends_on_the_same_lock(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
alias = tmp_path / "repo-alias"
|
||||
alias.symlink_to(repo, target_is_directory=True)
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
with RepositoryTransaction(repo, state_dir=state_dir):
|
||||
with pytest.raises(RepositoryBusyError):
|
||||
with RepositoryTransaction(alias, state_dir=state_dir):
|
||||
pytest.fail("path alias must use the canonical repository lock")
|
||||
|
||||
|
||||
def test_same_repo_is_locked_across_processes(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
state_dir = tmp_path / "state"
|
||||
child = """
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from rein_aharness.repository_transaction import RepositoryBusyError, RepositoryTransaction
|
||||
try:
|
||||
with RepositoryTransaction(Path(sys.argv[1]), state_dir=Path(sys.argv[2])):
|
||||
pass
|
||||
except RepositoryBusyError:
|
||||
raise SystemExit(23)
|
||||
raise SystemExit(0)
|
||||
"""
|
||||
|
||||
with RepositoryTransaction(repo, state_dir=state_dir):
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", child, str(repo), str(state_dir)],
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
env=os.environ.copy(),
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 23
|
||||
|
||||
|
||||
def test_distinct_repositories_use_distinct_locks(tmp_path: Path) -> None:
|
||||
first = _make_repo(tmp_path, "first")
|
||||
second = _make_repo(tmp_path, "second")
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
with RepositoryTransaction(first, state_dir=state_dir) as first_tx:
|
||||
with RepositoryTransaction(second, state_dir=state_dir) as second_tx:
|
||||
assert first_tx.baseline is not None
|
||||
assert second_tx.baseline is not None
|
||||
assert first_tx.baseline.repo_id != second_tx.baseline.repo_id
|
||||
assert first_tx.lock_path != second_tx.lock_path
|
||||
|
||||
|
||||
def test_exception_releases_lock_for_next_transaction(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
with pytest.raises(RuntimeError, match="adapter failed"):
|
||||
with RepositoryTransaction(repo, state_dir=state_dir):
|
||||
raise RuntimeError("adapter failed")
|
||||
|
||||
with RepositoryTransaction(repo, state_dir=state_dir) as tx:
|
||||
assert tx.locked
|
||||
|
||||
|
||||
def test_non_repository_is_refused_before_lock_creation(tmp_path: Path) -> None:
|
||||
state_dir = tmp_path / "state"
|
||||
target = tmp_path / "not-a-repo"
|
||||
target.mkdir()
|
||||
|
||||
with pytest.raises(GitRepositoryError, match="not a Git working tree"):
|
||||
with RepositoryTransaction(target, state_dir=state_dir):
|
||||
pytest.fail("non-repository transaction must not enter")
|
||||
|
||||
assert not state_dir.exists()
|
||||
|
||||
|
||||
def test_busy_error_exposes_bounded_owner_metadata(tmp_path: Path) -> None:
|
||||
repo = _make_repo(tmp_path)
|
||||
state_dir = tmp_path / "state"
|
||||
long_ref = "run " + ("x" * 500)
|
||||
|
||||
with RepositoryTransaction(repo, correlation_id=long_ref, state_dir=state_dir) as owner:
|
||||
with pytest.raises(RepositoryBusyError) as excinfo:
|
||||
with RepositoryTransaction(repo, state_dir=state_dir):
|
||||
pytest.fail("second transaction must not enter")
|
||||
|
||||
assert excinfo.value.owner["transaction_id"] == owner.transaction_id
|
||||
assert len(excinfo.value.owner["correlation_id"]) == 200
|
||||
|
||||
|
||||
def _status(repo: Path) -> str:
|
||||
return subprocess.run(
|
||||
["git", "status", "--porcelain=v2", "--untracked-files=all"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
|
|
@ -146,6 +146,23 @@ signal cleanup, and close failure; a refused run changes neither repository nor
|
|||
remote refs; and the result carries a bounded transaction identifier and
|
||||
baseline evidence.
|
||||
|
||||
### Preparation — 2026-08-23
|
||||
|
||||
Added the production-inert core transaction primitive in
|
||||
`rein_aharness/repository_transaction.py`. It keys a private external `flock`
|
||||
by the resolved Git common directory, captures branch/detached `HEAD`, clean or
|
||||
dirty index/worktree digests, upstream and remote-tracking refs, and exposes a
|
||||
bounded transaction/baseline evidence envelope. Ten focused tests cover
|
||||
cross-process same-repo contention, path aliases, distinct repos, dirty/staged/
|
||||
untracked baselines, detached `HEAD`, private lock metadata, exception cleanup,
|
||||
and non-repository refusal.
|
||||
|
||||
The primitive is intentionally not wired into `runner.py`, legacy approaches,
|
||||
or the claim loop while ADR-002 still awaits Activity Core, sand-boxer, and
|
||||
llm-connect acknowledgements. Lease-loss cancellation, moved-`HEAD` acceptance,
|
||||
timeout/signal integration, and result-close reconciliation remain outstanding;
|
||||
T02 therefore remains `wait`.
|
||||
|
||||
## Verify accepted commits and reconcile metrics/reporting
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue