Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/agent_comms/bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,8 +460,11 @@ def table_schema(cls) -> type[BusSchema]:

@contextmanager
def observing(self) -> Iterator[sqlite3.Connection]:
"""Borrow one existing read-only snapshot, including for an archived bus."""
with self._attached(lambda: SQLiteFile.observing(self.path)) as db:
"""Borrow one read-only snapshot, including for an archived bus; a bus before its
first write reads as its declared empty tables."""
with self.committed_or_empty(
self.table_schema(), lambda: SQLiteFile.observing(self.path)
) as db:
yield db

def backup(self, destination: Path) -> None:
Expand Down
13 changes: 6 additions & 7 deletions src/agent_comms/change_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@
SubclassUnion,
WireValue,
)
from .goals import CheckedCommit, GitCommandFailed
from .repo import GitCommandError, Repo

if TYPE_CHECKING:
from .repo import Repo
from .todos import PullRequestLink


Expand All @@ -55,17 +54,17 @@ def fetch_refspec(self) -> str: ...

def lines(self, repository: Path, file: str) -> tuple[str, ...]:
if repository not in self._commits:
CheckedCommit.git(str(repository), 'fetch', 'origin', self.fetch_refspec)
self._commits[repository] = CheckedCommit.git(
str(repository), 'rev-parse', '--verify', f'{self.reference}^{{commit}}'
Repo.git(repository, 'fetch', 'origin', self.fetch_refspec)
self._commits[repository] = Repo.git(
repository, 'rev-parse', '--verify', f'{self.reference}^{{commit}}'
).strip()
commit = self._commits[repository]
root = repository.absolute()
path = (root / file).relative_to(root).as_posix()
try:
text = CheckedCommit.git(str(repository), 'show', f'{commit}:{path}')
text = Repo.git(repository, 'show', f'{commit}:{path}')
return tuple(text.splitlines())
except GitCommandFailed as error:
except GitCommandError as error:
if error.source_path_absent(commit, path):
raise MissingSourceLine(
f'Source file {file} does not exist at {commit}.'
Expand Down
43 changes: 6 additions & 37 deletions src/agent_comms/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from agent_comms.lifecycle_capability import Active

import os
import re
import shlex
import signal
Expand All @@ -29,7 +28,7 @@
from agent_comms.goal_turn_permit import GoalTurnPermit, NoLaunchPermit
from .turn_goal_permission import ContinuationGoalPermission, GoalPermission, InactiveGoalPermission
from .registration_inheritance import RegistrationInheritance
from .repo import ScratchCheckout
from .repo import GitCommandError, Repo, ScratchCheckout
from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation
from .text_digest import TextDigest
from .ui_model.goal import GoalDisplay
Expand Down Expand Up @@ -383,7 +382,7 @@ def recorded_among(self, stored: tuple[CheckedCommit, ...]) -> tuple[ChildResult

def at(self, revision: str, repository: str, checkout_base: Path) -> ChildResult:
"""Read the result on ``revision``, or explicitly run the check in a detached checkout."""
sha = CheckedCommit.git(repository, "rev-parse", f"{revision}^{{commit}}").strip()
sha = Repo.git(repository, "rev-parse", f"{revision}^{{commit}}").strip()
for recorded in self.recorded(sha, repository):
return recorded
try:
Expand All @@ -409,28 +408,6 @@ def run_at(self, revision: str, repository: str, checkout_base: Path) -> ChildRe
return ChildResult(ChildOutcome.from_returncode(run.returncode), run.stdout)


class GitCommandFailed(RuntimeError):
"""A Git operation failure, not malformed order data to aggregate and continue past."""

def __init__(self, repository: str, run: subprocess.CompletedProcess[str]):
self.run = run
detail = run.stderr.strip() or f"git failed with exit {run.returncode}"
super().__init__(f"Git in {repository}: {detail}")

def source_path_absent(self, sha: str, file: str) -> bool:
"""Only Git's missing-path result, never a failed fetch or corrupt repository."""
return self.run.returncode == 128 and self.run.stderr in (
f"fatal: path '{file}' does not exist in '{sha}'\n",
f"fatal: path '{file}' exists on disk, but not in '{sha}'\n",
)

def no_check_ran_on(self, sha: str) -> bool:
"""Git's absent-note reply, not a corrupt repository or an invalid revision."""
return self.run.returncode == 1 and self.run.stderr in (
"", f"error: no note found for object {sha}.\n",
)


@dataclass(frozen=True)
class CheckedCommit:
"""What an order's check printed at a commit. A commit's result never changes, so the git
Expand All @@ -448,28 +425,20 @@ class CheckedCommit:
def result(self) -> ChildResult:
return ChildResult(self.outcome, self.stdout.encode())

@staticmethod
def git(repository: str, *args: str) -> str:
run = subprocess.run(("git", "-C", repository, *args), capture_output=True, text=True,
timeout=60, env=os.environ | {"LC_ALL": "C"})
if run.returncode:
raise GitCommandFailed(repository, run)
return run.stdout

@classmethod
def stored(cls, repository: str, sha: str) -> tuple[CheckedCommit, ...]:
"""Every check result the commit's note records; a commit no check ran on has none."""
try:
cls.git(repository, *cls.NOTES, "list", sha)
except GitCommandFailed as failure:
Repo.git(repository, *cls.NOTES, "list", sha)
except GitCommandError as failure:
if failure.no_check_ran_on(sha):
return () # No check ran on this commit.
raise
return tuple(FieldCodec.decode(cls, FieldCodec.parse(line)) for line in
cls.git(repository, *cls.NOTES, "show", sha).splitlines() if line)
Repo.git(repository, *cls.NOTES, "show", sha).splitlines() if line)

def store(self, repository: str, sha: str) -> None:
self.git(repository, *self.NOTES, "append", "-m",
Repo.git(repository, *self.NOTES, "append", "-m",
FieldCodec.compact(FieldCodec.encode(self)), sha)


Expand Down
41 changes: 32 additions & 9 deletions src/agent_comms/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
import shutil
import subprocess
from abc import ABC, abstractmethod
Expand Down Expand Up @@ -31,6 +32,28 @@ def founding(self, commander: str) -> tuple[Repo, ...]: ...
def without_commander(self) -> None: ...


class GitCommandError(RuntimeError):
"""A Git operation failure, not malformed order data to aggregate and continue past."""

def __init__(self, repository: str, run: subprocess.CompletedProcess[str]):
self.run = run
detail = run.stderr.strip() or f"git failed with exit {run.returncode}"
super().__init__(f"Git in {repository}: {detail}")

def source_path_absent(self, sha: str, file: str) -> bool:
"""Only Git's missing-path result, never a failed fetch or corrupt repository."""
return self.run.returncode == 128 and self.run.stderr in (
f"fatal: path '{file}' does not exist in '{sha}'\n",
f"fatal: path '{file}' exists on disk, but not in '{sha}'\n",
)

def no_check_ran_on(self, sha: str) -> bool:
"""Git's absent-note reply, not a corrupt repository or an invalid revision."""
return self.run.returncode == 1 and self.run.stderr in (
"", f"error: no note found for object {sha}.\n",
)


class Repo(BaseModel, WireValue):
"""A repository the fleet works on: its check checkout, its GitHub name and its source root."""

Expand Down Expand Up @@ -95,10 +118,11 @@ def contains(self, sha: str, revision: str = "HEAD") -> bool:
result = subprocess.run(
("git", "-C", str(self.checkout), "merge-base", "--is-ancestor", sha, revision),
capture_output=True,
text=True,
timeout=60,
)
if result.returncode not in (0, 1):
result.check_returncode()
raise GitCommandError(str(self.checkout), result)
return result.returncode == 0

def has_commit(self, sha: str) -> bool:
Expand All @@ -112,14 +136,13 @@ def has_commit(self, sha: str) -> bool:
)

@staticmethod
def git(checkout: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(checkout), *args],
capture_output=True,
text=True,
check=True,
timeout=30,
).stdout
def git(checkout: Path | str, *args: str) -> str:
"""The one Git runner: C-locale output, and every failure a GitCommandError."""
run = subprocess.run(("git", "-C", str(checkout), *args), capture_output=True, text=True,
timeout=60, env=os.environ | {"LC_ALL": "C"})
if run.returncode:
raise GitCommandError(str(checkout), run)
return run.stdout

@classmethod
def of(cls, checkout: Path) -> Repo:
Expand Down
5 changes: 3 additions & 2 deletions src/agent_comms/storage_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,9 +349,10 @@ def scan_owner(cls) -> type[StorageViolation]:
@classmethod
def scan_observed(cls, comms: Comms, commander: str,
observed: Sequence[UnitState]) -> tuple[Self, ...]:
# Storage reads the filesystem, not the git source an unreadable card failed on:
# a unit whose declared home is gone is that card and a missing home.
held = StandingRule.holders(comms, commander)
return cls.for_units(comms, (unit for state in observed
for unit in state.readable_units() if unit in held))
return cls.for_units(comms, (state.unit for state in observed if state.unit in held))

@classmethod
def for_units(cls, comms: Comms, units: Iterable[ThreadIncarnation]) -> tuple[Self, ...]:
Expand Down
9 changes: 5 additions & 4 deletions src/agent_comms/todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@
WireValue,
boundary_decoder,
)
from agent_comms.goals import CheckedCommit, GitCommandFailed, OrderCheck, ProblemGoalOrder
from agent_comms.goals import CheckedCommit, OrderCheck, ProblemGoalOrder
from agent_comms.repo import GitCommandError
from agent_comms.head_review import AcceptedHeadReview
from agent_comms.lifecycle_capability import LifecycleCapability, Pending
from agent_comms.message_reference import MessageReference
Expand Down Expand Up @@ -863,7 +864,7 @@ def default_refs(self) -> tuple[str, ...]:
return (self.default_reference,) if self.default_reference in self.git_refs else ()

def git(self, *args: str) -> str:
return CheckedCommit.git(str(self.path), *args)
return Repo.git(self.path, *args)

def require_read(self) -> None:
self.git("rev-parse", "--git-dir")
Expand Down Expand Up @@ -1026,7 +1027,7 @@ def of(cls, todo: Todo, registry: Registration, pulls: ProgressRead) -> Self:
pulls.checkout(path) if (path / ".git").exists() else MissingCheckout(path))
try:
references = checkout.references(storage, owner)
except (OSError, GitCommandFailed, WorkBranchError, subprocess.TimeoutExpired) as failure:
except (OSError, GitCommandError, WorkBranchError, subprocess.TimeoutExpired) as failure:
checkout, references = UnreadCheckout(path, str(failure)), ()
return cls(todo, checkout, pulls, references,
tuple(pulls.checkout(path) for path in owned if (path / ".git").exists()))
Expand Down Expand Up @@ -1095,7 +1096,7 @@ def from_source(cls, source: ProgressSource) -> OrderProgress:
try:
return next((progress for member in members
for progress in member.observations(source)), NoCommitProgress())
except (OSError, GitCommandFailed, WorkBranchError, subprocess.TimeoutExpired) as unread:
except (OSError, GitCommandError, WorkBranchError, subprocess.TimeoutExpired) as unread:
return UnreadProgress(str(unread))

@property
Expand Down
33 changes: 23 additions & 10 deletions src/agent_comms/typed_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -2169,6 +2169,26 @@ def retire(*error):
lifetime.push(retire)
yield connection

@contextmanager
def committed_or_empty(
self,
schema: type[TableSchema],
opening: Callable[[], AbstractContextManager[sqlite3.Connection]],
) -> Iterator[sqlite3.Connection]:
"""Read the committed file, or its declared empty schema before its first write.

An absent file is established absence: a reader sees every declared table empty
and creates no storage."""
if self.path.exists():
with self._attached(opening) as connection:
yield connection
return
with closing(self.writer(":memory:", isolation_level=None)) as connection:
schema.create_schema(connection)
self._configure_reader(connection)
self.begin_snapshot(connection)
yield connection

@contextmanager
def _open_writer(self, *, check_same_thread: bool = True) -> Iterator[sqlite3.Connection]:
"""Open one private writer and apply the file's declared WAL policy."""
Expand Down Expand Up @@ -2261,16 +2281,9 @@ def _open_writer(self, *, check_same_thread: bool = True) -> Iterator[sqlite3.Co
@contextmanager
def snapshot(self) -> Iterator[sqlite3.Connection]:
"""Read a committed store, or its declared empty schema before its first write."""
with ExitStack() as lifetime:
if self.path.exists():
connection = lifetime.enter_context(self._attached(
lambda: self.observing(self.path, StoreBusy())))
else:
connection = lifetime.enter_context(closing(
self.writer(":memory:", isolation_level=None)))
self.create_schema(connection)
self._configure_reader(connection)
self.begin_snapshot(connection)
with self.committed_or_empty(
type(self), lambda: self.observing(self.path, StoreBusy())
) as connection:
self.require_schema(connection)
yield connection

Expand Down
4 changes: 2 additions & 2 deletions src/agent_comms/ui_model/unit_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
from agent_comms.commander.questions import UnansweredUnitAsks
from agent_comms.field_codec import MalformedRecord
from agent_comms.goal_presentation import GoalSnapshot
from agent_comms.goals import GitCommandFailed
from agent_comms.messages import Message
from agent_comms.native_failures import NativePiUnavailable
from agent_comms.repo import GitCommandError
from agent_comms.standing_rules import StandingRule, UndatedStandingRule
from agent_comms.thread_identity import ThreadIncarnation
from agent_comms.thread_status import ThreadStatus
Expand Down Expand Up @@ -141,7 +141,7 @@ def read(cls, source: UnitObservation) -> UnitState:
return next(state for member in cls.worst_first() for state in member.derive(source))
except (
OSError,
GitCommandFailed,
GitCommandError,
WorkBranchError,
TimeoutExpired,
NativePiUnavailable,
Expand Down
14 changes: 14 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ def publish_initial(comms, sender, target, body, type=InfoKind, *, role=Conversa
destination=CommandTarget.named(target), origin=AgentOrigin(),
)

def _without_caller_repository() -> None:
"""A caller's git selects no repository for any test git call.

``git bisect run`` from a linked worktree exports GIT_DIR, and a fixture's
``git -C tmp init`` then re-initialised the caller's checkout as bare instead of
creating its own. Removed at import, before session start's own git call and before
xdist workers inherit the environment."""
for name in tuple(name for name in os.environ if name.startswith("GIT_")):
del os.environ[name]


_without_caller_repository()


def pytest_sessionstart(session):
"""The controller populates this checkout's pinned submodules before any worker runs,
as every tree of ours is (Checkout). Collection loads declarations only."""
Expand Down
5 changes: 2 additions & 3 deletions tests/test_installed_merge.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"""Merge observation closes the real stored order, without a completion request."""
from agent_comms.repository_name import RepositoryName
import subprocess

import pytest

from agent_comms.repo import Repo
from agent_comms.repo import GitCommandError, Repo
from test_order_progress import git


def test_failed_git_is_not_a_negative_ancestry_result(tmp_path):
with pytest.raises(subprocess.CalledProcessError):
with pytest.raises(GitCommandError):
Repo(checkout=tmp_path / 'missing', github=RepositoryName.from_wire('test/repo')).contains('a' * 40)


Expand Down
4 changes: 2 additions & 2 deletions tests/test_source_revision.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from agent_comms.change_report import FileLine, MainRevision, MissingSourceLine, PullRequestHeadRevision
from agent_comms.field_codec import MalformedRecord
from agent_comms.goal_actions import OrderGoalAction
from agent_comms.goals import GitCommandFailed
from agent_comms.repo import GitCommandError
from agent_comms.owner_requests import OwnerRequestId
from agent_comms.recorded_time import RecordedEventTime
from agent_comms.thread_identity import RecordedThreadIncarnation
Expand Down Expand Up @@ -49,7 +49,7 @@ def test_pull_request_revision_accepts_branch_only_source_without_checkout(tmp_p
assert source.git(source.checkout, 'rev-parse', 'HEAD').strip() == source.main_commit
assert source.git(source.checkout, 'rev-parse', 'refs/agent-comms/cited/7').strip() == source.branch_commit
# An absent remote PR fails its fetch; it is never silently checked at main.
with pytest.raises(GitCommandFailed, match='remote ref'):
with pytest.raises(GitCommandError, match='remote ref'):
with MalformedRecord.refusing('order.owner'):
location.require_exists(source.checkout,
PullRequestHeadRevision(replace(source.link, number=404)))
Expand Down
3 changes: 2 additions & 1 deletion tools/proofs/cn_progress_read_only.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from agent_comms.comms import wire
from agent_comms.field_codec import FieldCodec
from agent_comms.goals import CheckedCommit, CountCheckResult, OrderCheck
from agent_comms.repo import Repo
from agent_comms.todos import CorrectCheckTodo, DistanceReadings, PushedProgress, Todo
from agent_comms.tools import CommsChangeProblemTool, CommsTodoListTool, TodoList

Expand Down Expand Up @@ -62,7 +63,7 @@ def verify() -> None:
try:
assert not board(todo).readings
assert not MARKER.exists()
sha = CheckedCommit.git(str(checkout), "rev-parse", "HEAD").strip()
sha = Repo.git(checkout, "rev-parse", "HEAD").strip()
assert not check.recorded(sha, str(checkout))
check.at(sha, str(checkout), comms.registry.storage_rule.checkout_base)
assert MARKER.read_bytes() == MARKER_CONTENT
Expand Down
Loading