diff --git a/src/agent_comms/commander/config.py b/src/agent_comms/commander/config.py index c837f375d7..6700c80308 100644 --- a/src/agent_comms/commander/config.py +++ b/src/agent_comms/commander/config.py @@ -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.""" diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 33e6ee33de..468e4163d7 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -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) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 8b6b85eb00..7359efa6c4 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -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, @@ -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 @@ -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" ) diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 59b73b4ae7..1f9b289cdb 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -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 @@ -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: diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index b6cfe20dbb..1ac3e6205a 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -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): @@ -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( @@ -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))) diff --git a/src/agent_comms/repository_name.py b/src/agent_comms/repository_name.py new file mode 100644 index 0000000000..327e7da59a --- /dev/null +++ b/src/agent_comms/repository_name.py @@ -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"} diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 896e0be57f..9129555fb9 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -7,14 +7,14 @@ 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 @@ -22,27 +22,34 @@ 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: @@ -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 {{ ' @@ -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(): @@ -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.") @@ -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) @@ -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: @@ -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 @@ -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. @@ -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( @@ -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: @@ -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, @@ -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, diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index 7c997d1459..886d5b1d3a 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -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, ...]] = [()] diff --git a/tests/test_commander_problem_orders.py b/tests/test_commander_problem_orders.py index 2bc690a82d..cba4207c3d 100644 --- a/tests/test_commander_problem_orders.py +++ b/tests/test_commander_problem_orders.py @@ -31,6 +31,7 @@ from agent_comms.goals import CountCheckResult, OrderCheck, ProblemGoalOrder from agent_comms.progress_claims import WorkingClaim from agent_comms.recorded_time import RecordedEventTime, UnrecordedEventTime +from agent_comms.repository_name import RepositoryName from agent_comms.threads import Thread from agent_comms.tool_caller import ThreadCaller from agent_comms.tools import (CommsCorrectWordingTool, CommsSendTool, CommsFireTool, CommsOrderCheckTool, CommsOrderDoneTool, CommsOrderTool, @@ -56,7 +57,7 @@ def create(commander, problem, command='printf 1', expected=1, deadline=1000, ow CommsTodoCreateTool(owner=owner, order=CreateProblemGoalAction( problem=problem, order=order(comms.root, 'order-' + problem, command, expected), request='request', deadline=RecordedEventTime(deadline), commander='lead', - repo='OpenHCSDev/agent-comms', text='Fix ' + problem, + repo=RepositoryName.from_wire('OpenHCSDev/agent-comms'), text='Fix ' + problem, )).apply(comms, caller) return comms.goals.todos.get(problem) @@ -694,7 +695,7 @@ def test_a_deletion_order_must_check_the_imports_of_every_repo_that_imports_the_ (toad / 'src').mkdir(parents=True) (toad / 'src' / 'app.py').write_text('from agent_comms.acp_ingress import Ingress\n') script = script.model_copy(update={'repos': ( - Repo(checkout=toad, github='OpenHCSDev/toad'),)}) + Repo(checkout=toad, github=RepositoryName.from_wire('OpenHCSDev/toad')),)}) (repo / 'acp_ingress.py').write_text('class Ingress: ...\n') edit = 'Delete src/agent_comms/acp_ingress.py.' with pytest.raises(SystemExit, match=rf"\['{toad}'\] import"): @@ -1231,7 +1232,7 @@ def test_dropped_problem_links_through_the_unordered_lifecycle(commander): problem=pid, order=replace(order(comms.root, 'order-' + pid), at=RecordedEventTime(at), first_call_deadline=RecordedEventTime(at + 120), finding=finding), request='request', deadline=RecordedEventTime(1000), commander='lead', - repo='OpenHCSDev/agent-comms', text='Fix ' + pid)).apply(comms, caller) + repo=RepositoryName.from_wire('OpenHCSDev/agent-comms'), text='Fix ' + pid)).apply(comms, caller) old_message = order_message(commander, 'P1', 'The earlier counter order') new_message = order_message(commander, 'P2', 'The newer counter order') lead = comms.registry.require('lead').incarnation @@ -1486,7 +1487,7 @@ def test_close_quotes_the_works_line(commander): run = ApplicationOrderRun('The board shows the problem closed after the commander reopens the session') CommsTodoCreateTool(owner='worker', order=CreateProblemGoalAction( problem='P1', order=replace(order(comms.root, 'order-P1'), run=run), request='request', - deadline=RecordedEventTime(1000), commander='lead', repo='OpenHCSDev/agent-comms', text='Fix P1', + deadline=RecordedEventTime(1000), commander='lead', repo=RepositoryName.from_wire('OpenHCSDev/agent-comms'), text='Fix P1', )).apply(comms, caller) CommsOrderDoneTool(problem='P1', order_id=comms.goals.todos.get('P1').order.id, delivery=OperationDelivery(), claims=working(comms)).apply(comms, caller) artifact = comms.root / 'installed-evidence' @@ -1517,7 +1518,7 @@ def test_delivery_that_renames_the_decision_is_refused(commander, tmp_path): CommsTodoCreateTool(owner='worker', order=CreateProblemGoalAction( problem='P520', order=replace(order(comms.root, 'order-P520'), deletes=deletes), request='request', deadline=RecordedEventTime(1000), commander='lead', - repo='OpenHCSDev/agent-comms', text='Delete the is_set decision', + repo=RepositoryName.from_wire('OpenHCSDev/agent-comms'), text='Delete the is_set decision', )).apply(comms, caller) pending = comms.goals.todos.get('P520') assert 'Deletes: is_set absence decision at' in pending.order.instructions(pending) diff --git a/tests/test_repository_name.py b/tests/test_repository_name.py new file mode 100644 index 0000000000..7485b4805d --- /dev/null +++ b/tests/test_repository_name.py @@ -0,0 +1,53 @@ +"""Repository identities cross the wire and SQL once, without case-sensitive aliases.""" +from dataclasses import replace + +import pytest + +from agent_comms.commander.config import Repo +from agent_comms.field_codec import FieldCodec +from agent_comms.repository_name import RepositoryName +from agent_comms.todos import PullRequests, UnorderedProblemOrder +from test_commander_problem_orders import create + + +def test_repository_identity_hash_and_existing_wire_form(): + first = RepositoryName("OpenHCSDev", "Agent-Comms") + second = RepositoryName.from_wire("OPENHCSDEV/agent-comms") + assert first == second + assert len({first, second}) == 1 + assert FieldCodec.encode(first) == "openhcsdev/agent-comms" + assert FieldCodec.decode(RepositoryName, FieldCodec.encode(first)) == first + + +@pytest.mark.parametrize("owner", ["ß" * 100, "İ"]) +def test_repository_rejects_names_whose_canonical_wire_cannot_be_decoded(owner): + with pytest.raises(ValueError): + RepositoryName(owner, "repo") + + +def test_identity_uses_owned_components_not_rendered_text(monkeypatch): + first = RepositoryName("org", "first") + second = RepositoryName("org", "second") + monkeypatch.setattr(RepositoryName, "to_wire", lambda self: "display") + assert first != second + assert first == RepositoryName("ORG", "FIRST") + + +def test_repository_config_json_and_saved_todo_have_the_same_identity(commander): + comms, _ = commander + todo = create(commander, "P1") + repo = Repo.model_validate_json('{"checkout":"/unused", "github":"OPENHCSDEV/AGENT-COMMS"}') + assert repo.holds(todo) + assert Repo.model_validate_json(repo.model_dump_json()) == repo + assert comms.goals.todos.list(repo.github) == (todo,) + restored = comms.goals.todos.get(todo.id) + assert restored.repo == repo.github + assert FieldCodec.decode(type(todo), FieldCodec.encode(todo)) == restored + assert not Repo(checkout=repo.checkout, github=RepositoryName("another", "repo")).holds(todo) + + +def test_order_family_owns_which_kinds_a_merge_closes(commander): + comms, _ = commander + todo = create(commander, "P1") + finding = replace(todo, order=UnorderedProblemOrder()) + assert finding.order.closed_by_merge(finding, (), comms.registry.storage_rule, PullRequests((finding,))) == () diff --git a/tests/test_todos.py b/tests/test_todos.py index 13c9d61363..8c3adba9b5 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -15,6 +15,7 @@ from agent_comms.field_codec import FieldCodec from agent_comms.goals import OrderCheck from agent_comms.recorded_time import RecordedEventTime +from agent_comms.repository_name import RepositoryName from agent_comms.registration import Registration from agent_comms.threads import Thread from agent_comms.todos import ( @@ -57,25 +58,25 @@ def test_create_is_idempotent_and_goal_reference_is_historical(tmp_path: Path) - lead = thread("lead", tmp_path, 1.0) goal = GoalRef("lead", 1.0, "goal-1") original = store.create( - "todo-1", "OpenHCSDev/agent-comms", "Fix the sidebar", creator=lead, goal=goal, + "todo-1", RepositoryName.from_wire("OpenHCSDev/agent-comms"), "Fix the sidebar", creator=lead, goal=goal, owner=lead, generation="initial", deadline=RecordedEventTime(1000.0), order=order(tmp_path), ) - assert original.repo == "openhcsdev/agent-comms" + assert original.repo == RepositoryName.from_wire("OPENHCSDEV/agent-comms") assert original.revision == 1 assert original.assignment == Assignment("lead", 1.0, "lead", 1.0, "initial") assert ( store.create( - "todo-1", "openhcsdev/agent-comms", "Fix the sidebar", creator=lead, goal=goal, + "todo-1", RepositoryName.from_wire("openhcsdev/agent-comms"), "Fix the sidebar", creator=lead, goal=goal, owner=lead, generation="initial", deadline=RecordedEventTime(1000.0), order=order(tmp_path), ) == original ) with pytest.raises(TodoConflict): store.create( - "todo-1", "openhcsdev/agent-comms", "A different task", creator=lead, goal=goal, + "todo-1", RepositoryName.from_wire("openhcsdev/agent-comms"), "A different task", creator=lead, goal=goal, owner=lead, generation="initial", deadline=RecordedEventTime(1000.0), order=order(tmp_path), ) - assert TodoStore(tmp_path).list("openhcsdev/agent-comms") == (original,) + assert TodoStore(tmp_path).list(RepositoryName.from_wire("openhcsdev/agent-comms")) == (original,) newer = thread("lead", tmp_path, 2.0) assert store.get(original.id).goal == goal with pytest.raises(TodoConflict): @@ -86,7 +87,7 @@ def test_create_is_idempotent_and_goal_reference_is_historical(tmp_path: Path) - def test_commander_reads_decode_only_open_todos(tmp_path, monkeypatch): store = TodoStore(tmp_path) lead = thread("lead", tmp_path, 1.0) - first = store.create("P1", "org/repo", "Open order", creator=lead, owner=lead, + first = store.create("P1", RepositoryName.from_wire("org/repo"), "Open order", creator=lead, owner=lead, generation="one", goal=GoalRef("lead", 1.0, "request"), deadline=RecordedEventTime(1000), order=order(tmp_path)) second = replace(first, id="P2", state=BlockedTodoState) @@ -99,7 +100,8 @@ def test_commander_reads_decode_only_open_todos(tmp_path, monkeypatch): assignment=None, order=delivered).insert(db) with closing(CoordinationStore.writer(store.path)) as db: indexes = tuple(name for name, in db.execute( - "SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'")) + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='todo' " + "AND name NOT LIKE 'sqlite_%'")) for name in indexes: db.execute(f'DROP INDEX "{name}"') db.commit() @@ -143,7 +145,7 @@ def test_two_processes_assign_one_todo_before_any_worker_is_started(tmp_path: Pa store = TodoStore(tmp_path) lead = thread("lead", tmp_path, 1.0) store.create( - "todo-1", "OpenHCSDev/agent-comms", "One implementation", creator=lead, goal=GoalRef("lead", 1.0, "goal-1"), + "todo-1", RepositoryName.from_wire("OpenHCSDev/agent-comms"), "One implementation", creator=lead, goal=GoalRef("lead", 1.0, "goal-1"), owner=lead, generation="initial", deadline=RecordedEventTime(1000.0), order=order(tmp_path), ) with Manager() as manager, ProcessPoolExecutor(max_workers=2) as pool: @@ -159,22 +161,22 @@ def test_two_processes_assign_one_todo_before_any_worker_is_started(tmp_path: Pa persisted = TodoStore(tmp_path).get("todo-1") assert persisted.assignment.owner == observed[0][1] assert persisted.revision == 2 - assert len(store.list("OpenHCSDev/agent-comms")) == 1 + assert len(store.list(RepositoryName.from_wire("OpenHCSDev/agent-comms"))) == 1 def test_validation_rejects_ambiguous_identity_and_bad_state(tmp_path: Path) -> None: store = TodoStore(tmp_path) lead = thread("lead", tmp_path, 1.0) for index, repo in enumerate(("org/repo/branch", "../repo", "org/", "org/repo name")): - with pytest.raises(TodoError): + with pytest.raises(ValueError): store.create( - f"invalid-{index}", repo, "task", creator=lead, goal=GoalRef("lead", 1.0, "goal-1"), + f"invalid-{index}", RepositoryName.from_wire(repo), "task", creator=lead, goal=GoalRef("lead", 1.0, "goal-1"), owner=lead, generation="initial", deadline=RecordedEventTime(1000.0), order=order(tmp_path), ) with pytest.raises(TodoError): GoalRef("lead", float("nan"), "goal-1") store.create( - "todo-1", "org/repo", "task", creator=lead, goal=GoalRef("lead", 1.0, "goal-1"), + "todo-1", RepositoryName.from_wire("org/repo"), "task", creator=lead, goal=GoalRef("lead", 1.0, "goal-1"), owner=lead, generation="initial", deadline=RecordedEventTime(1000.0), order=order(tmp_path), ) with pytest.raises(ValueError):