Skip to content
Closed
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 @@ -85,7 +85,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
2 changes: 1 addition & 1 deletion src/agent_comms/commander/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,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
6 changes: 4 additions & 2 deletions src/agent_comms/commander/standing.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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 @@ -211,7 +212,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 @@ -262,6 +263,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 @@ -563,7 +564,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) -> bool:
"""Only a successful ancestry proof in this installed checkout permits closure."""
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"}
101 changes: 57 additions & 44 deletions src/agent_comms/todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,49 @@

from __future__ import annotations

import asyncio
import calendar
import json
import re
import subprocess
import sqlite3
import subprocess
import sys
import time
import asyncio
from abc import abstractmethod
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from dataclasses import dataclass, field, fields, replace
from functools import cache, cached_property
from importlib.resources import files
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, Final, Self
from typing import TYPE_CHECKING, ClassVar, Self
from uuid import uuid4

from . import skills
from .change_report import FileLine
from .channel_targets import CommandTarget
from .child_process import BoundedRun, CheckPrinted, ChildResult, ExitedOutcome, NoIntegerPrinted
from .command import Command
from .declared_family import DeclaredFamily
from .errors import ToolRefusedError
from .field_codec import (
FieldCodec, GitCommitText, MalformedRecord, NullMember, RecordMember, SubclassUnion, WireValue,
FieldCodec,
GitCommitText,
MalformedRecord,
NullMember,
RecordMember,
SubclassUnion,
WireValue,
)
from .channel_targets import CommandTarget
from .goals import CheckedCommit, CountCheckResult, OrderCheck, ProblemGoalOrder
from .plan_phases import OrderPhase, UnplannedPhase
from .recorded_time import EventTime, RecordedEventTime, UnrecordedEventTime
from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation
from .threads import Thread
from .typed_table import Column, Index, JsonStorage, TypedStore, TypedTable
from .repository_name import RepositoryName
from .standing_rules import UndatedStandingRule
from .storage_rules import StorageRule
from .plan_phases import OrderPhase, UnplannedPhase
from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation
from .threads import Thread
from .typed_table import Column, Index, JsonStorage, TextStorage, TypedStore, TypedTable
from .work_branches import WorkBranchError, WorkBranches

if TYPE_CHECKING:
Expand Down Expand Up @@ -371,28 +378,32 @@ class PullRequests:
"""Pull requests queried by immutable order identity, cached within one board read."""

def __init__(self, todos: Sequence[Todo] = ()) -> None:
self._read: dict[tuple[str, str, str], tuple[PullRequest, ...]] = {}
self._orders: dict[str, dict[str, CheckedProblemOrder]] = {}
self._read: dict[tuple[RepositoryName, str, str], tuple[PullRequest, ...]] = {}
self._orders: dict[RepositoryName, dict[str, CheckedProblemOrder]] = {}
for todo in todos:
if todo.holder:
order = todo.order.checked()
self._orders.setdefault(todo.repo, {})[order.id] = order
for order in todo.order.pull_queries():
self._orders.setdefault(todo.repo, {})[order.id] = order

def listed(self, repo: str, state: str, order: ProblemOrder) -> tuple[PullRequest, ...]:
def listed(
self, repo: RepositoryName, state: str, order: ProblemOrder,
) -> tuple[PullRequest, ...]:
checked = order.checked()
key = (repo, state, checked.id)
if key not in self._read:
self._read[key] = self.read(repo, state, checked)
return tuple(request for request in self._read[key] if checked.id in request.orders)

def read(self, repo: str, state: str, order: ProblemOrder) -> tuple[PullRequest, ...]:
def read(
self, repo: RepositoryName, state: str, order: ProblemOrder,
) -> tuple[PullRequest, ...]:
checked = order.checked()
orders = self._orders.setdefault(repo, {})
orders[checked.id] = checked
aliases = {f"o{index}": candidate for index, candidate in enumerate(orders.values())}
selections = []
for alias, candidate in aliases.items():
search = json.dumps(f'repo:{repo} is:pr is:{state} in:body '
search = json.dumps(f'repo:{repo.to_wire()} is:pr is:{state} in:body '
f'"{candidate.pull_request_reference}" sort:updated-desc')
selections.append(
f'{alias}: search(query: {search}, type: ISSUE, first: 100) {{ nodes {{ '
Expand All @@ -406,7 +417,8 @@ def read(self, repo: str, state: str, order: ProblemOrder) -> tuple[PullRequest,
f'. + {{kind: "{MergedListedPullRequest.declared_name}"}} end))'),
capture_output=True, text=True, timeout=60)
if listed.returncode:
raise WorkBranchError(listed.stderr.strip() or f"GitHub order lookup in {repo} failed.")
raise WorkBranchError(
listed.stderr.strip() or f"GitHub order lookup in {repo.to_wire()} failed.")
rows = FieldCodec.decode(dict[str, tuple[ListedPullRequest, ...]],
FieldCodec.parse(listed.stdout))
for alias, candidate in aliases.items():
Expand Down Expand Up @@ -1341,6 +1353,13 @@ def for_phase(self, phase: type[OrderPhase]) -> ProblemOrder:
def accepted(self) -> bool:
return False

def pull_queries(self) -> tuple[CheckedProblemOrder, ...]:
return ()

def closed_by_merge(self, todo: Todo, repos: Sequence[Repo], storage: StorageRule,
pulls: PullRequests) -> tuple[Todo, ...]:
return ()

def require_transfer(self, todo: Todo) -> None:
raise ValueError("This problem cannot receive another order.")

Expand Down Expand Up @@ -1474,6 +1493,16 @@ def copy_as[Kind: ProblemOrder](self, kind: type[Kind], **evidence) -> Kind:
return kind(**{item.name: getattr(self, item.name) for item in fields(kind)
if item.name not in evidence}, **evidence)

def pull_queries(self) -> tuple[CheckedProblemOrder, ...]:
return (self,)

def closed_by_merge(self, todo: Todo, repos: Sequence[Repo], storage: StorageRule,
pulls: PullRequests) -> tuple[Todo, ...]:
return tuple(DoneTodoState.closed(todo, self.merged(merged.sha))
for repo in repos
for merged in MergedProgress.found(ProgressSource.of(todo, storage, pulls))
if repo.contains(merged.sha))

def merged(self, sha: str) -> ProblemOrder:
return self.copy_as(MergedProblemOrder, sha=sha)

Expand All @@ -1492,7 +1521,7 @@ def pull_request_body(self, body: str) -> str:
def open_pull_request(self, todo: Todo, checkout: Path, title: str, body: str) -> str:
"""The order creates its PR with the immutable identity every progress reader uses."""
created = subprocess.run(
("gh", "pr", "create", "-R", todo.repo, "--title", title, "--body-file", "-"),
("gh", "pr", "create", "-R", todo.repo.to_wire(), "--title", title, "--body-file", "-"),
input=self.pull_request_body(body), cwd=checkout,
capture_output=True, text=True, timeout=60)
if created.returncode:
Expand Down Expand Up @@ -2347,7 +2376,7 @@ class Todo(TypedTable):
indexes = (Index(("creator", "creator_created", "state")), Index(("state",)))

id: str = field(metadata={"sql": Column(primary_key=True)})
repo: str
repo: RepositoryName = field(metadata={"sql": Column(storage=TextStorage)})
text: str
creator: str
creator_created: float
Expand Down Expand Up @@ -2523,17 +2552,6 @@ def _participant(thread: Thread) -> ThreadIncarnation:
return thread.incarnation


def _repo(value: str) -> str:
_text(value, "Repo ID", 200)
pieces = value.split("/")
if len(pieces) != 2 or any(
piece in {"", ".", ".."} or not all(char.isalnum() or char in "-_." for char in piece)
for piece in pieces
):
raise TodoError("Repo ID must have the exact owner/repo form.")
return value.lower()


@dataclass(frozen=True, slots=True)
class TodoStore(TypedStore):
"""One SQLite row owns both todo state and its exclusive assignment.
Expand Down Expand Up @@ -2642,8 +2660,8 @@ def get(self, todo_id: str) -> Todo:
_text(todo_id, "Todo ID", 128)
return self._one(self._select("id=?", (todo_id,)), todo_id)

def list(self, repo: str) -> tuple[Todo, ...]:
return self._select("repo=?", (_repo(repo),))
def list(self, repo: RepositoryName) -> tuple[Todo, ...]:
return self._select("repo=?", (repo.to_wire(),))

def for_goal(self, owner: ThreadIncarnation, goal_id: str) -> tuple[Todo, ...]:
return self._select(
Expand All @@ -2662,15 +2680,11 @@ def stalled(self, storage: StorageRule) -> tuple[Todo, ...]:
def reconcile_merges(self, commander: ThreadIncarnation, repos: Sequence[Repo],
storage: StorageRule) -> None:
"""Read before locking; close only orders whose observed revision still holds."""
checked = tuple((todo, order) for todo in self.held_for_commander(commander)
if isinstance(order := todo.order, CheckedProblemOrder))
pulls = PullRequests(tuple(todo for todo, _ in checked))
completed = tuple(DoneTodoState.closed(todo, order.merged(merged.sha))
for todo, order in checked
for repo in repos if repo.github.lower() == todo.repo
for merged in MergedProgress.found(
ProgressSource.of(todo, storage, pulls))
if repo.contains(merged.sha))
held = self.held_for_commander(commander)
pulls = PullRequests(held)
completed = tuple(closed for todo in held
for closed in todo.order.closed_by_merge(
todo, tuple(repo for repo in repos if repo.holds(todo)), storage, pulls))
if not completed:
return
with self.writing() as db:
Expand Down Expand Up @@ -2735,7 +2749,7 @@ def _open(self, where: str, parameters: tuple,
def create(
self,
todo_id: str,
repo: str,
repo: RepositoryName,
text: str,
*,
creator: Thread,
Expand All @@ -2747,14 +2761,13 @@ def create(
) -> Todo:
_text(todo_id, "Todo ID", 128)
_text(text, "Todo text", 2000)
repo_id = _repo(repo)
identity, assignee = _participant(creator), _thread(owner)
deadline.require_deadline()
goal.require_reference()
order.require_writable()
proposed = Todo(
id=todo_id,
repo=repo_id,
repo=repo,
text=text,
creator=identity.name,
creator_created=identity.created_at,
Expand Down
4 changes: 2 additions & 2 deletions src/agent_comms/work_branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,8 @@ def _run(self, *command: str, input: str = "") -> str:

@property
def repository(self) -> tuple[str, str]:
owner, name = Repo.of(self.checkout).github.split("/")
return owner, name
repository = Repo.of(self.checkout).github
return repository.owner, repository.name

def _pages(self, query: str, answer: type[ForgeQuery], **variables: str):
pages: list[tuple[str, ...]] = [()]
Expand Down
Loading
Loading