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
2 changes: 1 addition & 1 deletion src/agent_comms/commander/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def packaged_rules_files(cls) -> tuple[Path, ...]:
def repo_directory(self, name: str) -> Path:
"""A configured repository name takes precedence over a directory spelling."""
return next((repo.checkout for repo in self.repos
if repo.github.rsplit('/', 1)[-1] == name), Path(name)).resolve()
if repo.github.named(name)), Path(name)).resolve()

def write(self, home: Path) -> Path:
"""Record the config and start each state file empty, so every tool reads a real file."""
Expand Down
25 changes: 15 additions & 10 deletions src/agent_comms/commander/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ def read_check(self, line: str) -> OrderCheck:
return replace(check, directory=str(directory))

def held_orders(self) -> tuple[Todo, ...]:
return self.comms.goals.todos.held_for_commander(self.incarnation)
return tuple(todo for todo in self.todos() if todo.holder)

def held_by(self, unit: str) -> tuple[Todo, ...]:
return self.comms.goals.todos.held_by(self.incarnation, unit)
Expand Down Expand Up @@ -677,9 +677,9 @@ def deadline_lines(self, pending: list[Todo], read: dict[str, OrderProgress],
continue
answer = self.reply(t.holder, ordered)
due = f'due {t.deadline.clock()}, order {t.order.id}'
late.append(f'{t.holder} ({due}, {work.line}{answer})')
alarm = f'OVERDUE: {", ".join(late)} -> push or merge unfinished work now'
return [alarm] * bool(late)
late.append(f'OVERDUE: {t.id} {t.holder} ({due}, {work.line}{answer}) '
'-> push or merge unfinished work now')
return late

def install_lines(self, held: tuple[Todo, ...], read: dict[str, OrderProgress], now: float) -> list[str]:
"""One INSTALL line naming every held order that waits on the fleet's install."""
Expand Down Expand Up @@ -996,18 +996,23 @@ def run(self, ctx: Commander) -> None:

@dataclass(frozen=True, kw_only=True)
class PullRequestVerb(OrderVerb, declared_name="pr"):
help = "open the problem's PR with its required immutable order identity"
help = ("open the problem's PR from this owned source checkout "
"with its immutable order identity")
pid: str = option("pid")
title: str = option("--title")
body_file: str = option("--body-file")

def run(self, ctx: Commander) -> None:
todo = ctx.comms.goals.todos.get(self.pid)
checkout = Repo.of(Path.cwd())
if checkout.github.lower() != todo.repo.lower():
sys.exit(f'Refused: current checkout belongs to {checkout.github}, not {todo.repo}.')
print(todo.order.open_pull_request(
todo, checkout.checkout, self.title, Path(self.body_file).read_text()))
source = Repo.of(Path.cwd())
holder = todo.held().owner_identity
if not ctx.comms.registry.storage_rule.owns_checkout(holder, source.checkout):
raise ValueError(f"Run order pr from a source checkout owned by {holder.name}.")
if not source.holds(todo):
sys.exit(f'Refused: current checkout belongs to {source.github.to_wire()}, '
f'not {todo.repo.to_wire()}.')
print(todo.order.checked().open_pull_request(
todo, source.checkout, self.title, Path(self.body_file).read_text()))


@dataclass(frozen=True, kw_only=True)
Expand Down
6 changes: 4 additions & 2 deletions src/agent_comms/commander/standing.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ..goal_actions import OrderGoalAction
from ..goals import Goal
from ..repo import Repo
from ..repository_name import RepositoryName
from ..standing_rules import (
DatedStandingRule,
ReasonedStandingRule,
Expand Down Expand Up @@ -214,7 +215,7 @@ class MergeGrowth(DatedStandingRule):

limit: ClassVar[int] = 40
window: ClassVar[str] = "12 hours ago"
repo: str
repo: RepositoryName
sha: str
subject: str
merged: float
Expand Down Expand Up @@ -279,6 +280,7 @@ def began(self) -> float:
def line(self) -> str:
(a, d), (ta, td) = self.src, self.tests
return (
f"GROWTH {self.repo} {self.sha[:9]} src +{a}/-{d} (net {a - d:+}) tests +{ta}/-{td}: "
f"GROWTH {self.repo.to_wire()} {self.sha[:9]} src +{a}/-{d} (net {a - d:+}) "
f"tests +{ta}/-{td}: "
f"{self.subject[:90]} -> justify or send back the owner with a deletion order"
)
3 changes: 2 additions & 1 deletion src/agent_comms/goal_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
)
from .progress_claims import ProgressClaim
from .recorded_time import EventTime
from .repository_name import RepositoryName
from .thread_identity import ThreadIncarnation
from .threads import Thread
from .todos import GoalRef, PendingProblemOrder, SendBackVerdict, Todo, TodoOperation
Expand Down Expand Up @@ -564,7 +565,7 @@ def reserve(self, ctx: GoalActionContext, generation: str, goal: GoalRef) -> Tod

@dataclass(frozen=True, kw_only=True)
class CreateProblemGoalAction(ProblemOrderGoalAction):
repo: str
repo: RepositoryName
text: str

def bound_to(self, actor: ThreadIncarnation) -> CreateProblemGoalAction:
Expand Down
21 changes: 18 additions & 3 deletions src/agent_comms/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@

import subprocess
from pathlib import Path
from typing import TYPE_CHECKING, Annotated

from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, InstanceOf, PlainSerializer, PlainValidator

from .repository_name import RepositoryName

if TYPE_CHECKING:
from .todos import Todo


class Repo(BaseModel):
Expand All @@ -14,9 +20,18 @@ class Repo(BaseModel):
model_config = ConfigDict(frozen=True)

checkout: Path
github: str
github: InstanceOf[
Annotated[
RepositoryName,
PlainValidator(RepositoryName.from_wire, json_schema_input_type=str),
PlainSerializer(RepositoryName.to_wire),
]
]
source: str = "src"

def holds(self, todo: Todo) -> bool:
return self.github == todo.repo

def contains(self, sha: str, revision: str = "HEAD") -> bool:
"""Prove ancestry in the requested fetched revision without moving the checkout."""
result = subprocess.run(
Expand All @@ -43,4 +58,4 @@ def of(cls, checkout: Path) -> Repo:
"""Its GitHub name comes from the checkout's own origin remote."""
url = cls.git(checkout, "remote", "get-url", "origin").strip()
github = url.removesuffix(".git").replace(":", "/").split("/")[-2:]
return cls(checkout=checkout.resolve(), github="/".join(github))
return cls(checkout=checkout.resolve(), github=RepositoryName.from_wire("/".join(github)))
49 changes: 49 additions & 0 deletions src/agent_comms/repository_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""A GitHub repository identity, with its existing owner/name wire form."""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import ClassVar

from .field_codec import MalformedRecord, WireValue


@dataclass(frozen=True, slots=True, eq=False)
class RepositoryName(WireValue):
owner: str
name: str
_part: ClassVar[re.Pattern[str]] = re.compile(r"(?!\.{1,2}$)[\w.-]+\Z")

def __post_init__(self) -> None:
if len(self.to_wire()) > 200 or not all(self._part.fullmatch(part) for part in self.key):
raise ValueError("Repo ID must have the exact owner/repo form.")

@property
def key(self) -> tuple[str, str]:
return self.owner.casefold(), self.name.casefold()

def __eq__(self, other: object) -> bool:
if not isinstance(other, RepositoryName):
return NotImplemented
return self.key == other.key

def __hash__(self) -> int:
return hash(self.key)

def named(self, name: str) -> bool:
return self.name.casefold() == name.casefold()

def to_wire(self) -> str:
return "/".join(self.key)

@classmethod
def from_wire(cls, data: object) -> RepositoryName:
if not isinstance(data, str):
raise MalformedRecord("Repo ID must be an owner/repo string.")
owner, _, name = data.partition("/")
return cls(owner, name)

@classmethod
def schema(cls) -> dict[str, object]:
return {"type": "string", "description": "GitHub repository owner/name"}
29 changes: 18 additions & 11 deletions src/agent_comms/storage_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

if TYPE_CHECKING:
from .comms import Comms
from .thread_identity import ThreadIncarnation
from .threads import Thread

StoragePath = Annotated[Path, PathText]
Expand Down Expand Up @@ -93,21 +94,27 @@ def initial_roots(self, name):
return StorageRoots(self.scratch_base / name)

def order_checkout(self, name: str, problem: str) -> Path:
"""The checkout thread ``name`` holds for one ordered problem."""
"""Allocation path for a new problem checkout; an existing source may be reused."""
return self.checkout_base.resolve() / f'{name}--{problem}'

def owns_checkout(self, thread: Thread, path: Path) -> bool:
problem = path.name.removeprefix(f'{thread.name}--')
return (re.fullmatch(r'P[1-9][0-9]*', problem) is not None
and path == self.order_checkout(thread.name, problem))
def owns_checkout(self, owner: ThreadIncarnation, path: Path) -> bool:
problem = path.name.removeprefix(f'{owner.name}--')
return (path == self.checkout_base.resolve() / owner.name
or (bool(re.fullmatch(r'P[1-9][0-9]*', problem))
and path == self.order_checkout(owner.name, problem)))

def checkout_paths(self, owner: ThreadIncarnation) -> tuple[Path, ...]:
"""Candidate locations; Git worktree metadata supplies their branches."""
ordered = self.order_checkout(owner.name, 'P*')
return tuple(path for path in (self.checkout_base.resolve() / owner.name,
*sorted(ordered.parent.glob(ordered.name)))
if path.is_dir() and self.owns_checkout(owner, path))

def checkouts_of(self, thread: Thread) -> tuple[Path, ...]:
"""Read each repository now; directories and names do not register ownership."""
home = self.checkout_base.expanduser().resolve() / thread.name
ordered = self.order_checkout(thread.name, 'P*')
candidates = (home, Path(thread.worktree).expanduser().resolve(),
*(path for path in ordered.parent.glob(ordered.name)
if path.is_dir() and self.owns_checkout(thread, path)))
*self.checkout_paths(thread.incarnation))
checkouts = {home}
listed = set()
for repository in candidates:
Expand All @@ -121,7 +128,7 @@ def checkouts_of(self, thread: Thread) -> tuple[Path, ...]:
for field in output.split('\0') if field.startswith('worktree '))
listed.update(entries)
checkouts.update(path for path in entries
if path.is_dir() and self.owns_checkout(thread, path))
if path.is_dir() and self.owns_checkout(thread.incarnation, path))
return (home, *sorted(checkouts - {home}))

def deny(self, reason):
Expand Down Expand Up @@ -199,7 +206,7 @@ def require_path(self, thread: Thread, path):
base = self.checkout_base.resolve()
if path.is_relative_to(base) and path != base:
checkout = base / path.relative_to(base).parts[0]
if not (checkout.name == thread.name or self.owns_checkout(thread, checkout)):
if not self.owns_checkout(thread.incarnation, checkout):
self.deny(f'{checkout} is not a checkout of {thread.name}')

def render(self, roots):
Expand Down Expand Up @@ -359,7 +366,7 @@ class CheckoutStorageEffect(AllocateStorageEffect):
def check(self, projection: StorageProjection):
rule, thread = projection.rule, projection.thread
rule.require_path(thread, self.path)
if not rule.owns_checkout(thread, self.path):
if not rule.owns_checkout(thread.incarnation, self.path):
destination = rule.order_checkout(thread.name, "P<number>")
rule.deny(f'checkout destination must be {destination}')
super().check(projection)
Expand Down
Loading
Loading