diff --git a/src/agent_comms/declared_family.py b/src/agent_comms/declared_family.py index 578a12b508..2c19507ebb 100644 --- a/src/agent_comms/declared_family.py +++ b/src/agent_comms/declared_family.py @@ -38,11 +38,9 @@ def __new__( **class_options: Any, ) -> FamilyMeta: roots = { - ancestor + base._family_root for base in bases - if isinstance(base, FamilyMeta) - for ancestor in base.__mro__ - if ancestor is base._family_root + if isinstance(base, FamilyMeta) and base is not DeclaredFamily } if len(roots) > 1: raise TypeError("A declaration cannot belong to two families.") @@ -87,7 +85,11 @@ def __new__( cls: type[DeclaredFamily] = super().__new__( mcs, name, bases, namespace, registry_config=config, **class_options ) - cls._family_root = cls if is_root else root + if is_root: + cls._family_root = cls + else: + for family_root in roots: + cls._family_root = family_root return cast(FamilyMeta, cls) @staticmethod @@ -130,7 +132,7 @@ class DeclaredFamily(ABC, metaclass=FamilyMeta): declared_name: ClassVar[str] family_discriminator: ClassVar[str] = "kind" - _family_root: ClassVar[type[DeclaredFamily] | None] = None + _family_root: ClassVar[type[DeclaredFamily]] _family_affix: ClassVar[str] __registry__: ClassVar[dict[str, type[DeclaredFamily]]] @@ -234,9 +236,14 @@ def members_with(cls, capability: type) -> tuple[type[Self], ...]: if issubclass(member, cls) and issubclass(member, capability) ) + @classmethod + def family_root(cls) -> type[DeclaredFamily]: + """The nominal root recorded when this declaration was constructed.""" + return cls._family_root + @classmethod def declared_position(cls) -> int: - root = cast(type[DeclaredFamily], cls._family_root) + root = cls.family_root() return root.members_with(root).index(cls) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 02ee6e697a..a1cb264220 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -13,7 +13,6 @@ import os import re import sqlite3 -import sys import types from abc import ABC, abstractmethod from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence @@ -1961,7 +1960,11 @@ def update( class ProducedTable(ProducedRow, TypedTable, declared_name=None): - """A named table whose producing family owns one source commitment.""" + """A named table whose producing family owns one source commitment. + + The first install after this change rebuilds bus projections once because the digest + format changed. + """ @classmethod def producer_digest(cls) -> str: @@ -1974,13 +1977,23 @@ def producer_digest(cls) -> str: @cache def _producer_digest(cls) -> str: producers = cls.producer_classes() - modules = { - cls.__module__, - *(row.__module__ for row in cls.members_with(cls)), - *(owner.__module__ for producer in producers - for owner in producer.__mro__ if owner is not object), + owners: set[type[DeclaredFamily]] = { + owner + for row in (cls, *cls.members_with(cls)) + for owner in row.__mro__ + if issubclass(owner, cls) } - source = "\0".join(getsource(sys.modules[name]) for name in sorted(modules)) + for producer in producers: + root = producer.family_root() + owners.update( + owner + for owner in producer.__mro__ + if issubclass(owner, cls) or issubclass(owner, root) + ) + source = "\0".join( + f"{owner.__module__}.{owner.__qualname__}\0{getsource(owner)}" + for owner in sorted(owners, key=lambda owner: (owner.__module__, owner.__qualname__)) + ) return hashlib.sha256(source.encode()).hexdigest() diff --git a/stack/pi-native.sha256 b/stack/pi-native.sha256 index b13826a331..3e2db0c631 100644 --- a/stack/pi-native.sha256 +++ b/stack/pi-native.sha256 @@ -57,4 +57,4 @@ d64e8c05c7932d377034820b1db0df45612afe64885c0765f8f348a12e1bc955 dist/core/skil 0391543cf703a93a0163b37b3ccebe53e0f12bff6f6162b86d56fb017fe49011 dist/core/system-prompt.js # agent-comms-native-stock-v1 @earendil-works/pi-coding-agent@0.85.1 # agent-comms-native-tree-v1 17edf8badd73bafccf592b651b5a9c0f71b2b05b89db470d1042d3ccb544a0b9 -# agent-comms-native-inputs-v1 a420ec4c8414f1e9b5355eeaf303c1c1f798d47c876d5612bf2512daf445294b +# agent-comms-native-inputs-v1 e57296bd11e2b731df3c744633772054a8568de2a96dc2012623aa83a54c8cda diff --git a/stack/test-native-global-extensions.py b/stack/test-native-global-extensions.py index e06984b4a4..a0cd541fdc 100755 --- a/stack/test-native-global-extensions.py +++ b/stack/test-native-global-extensions.py @@ -18,6 +18,7 @@ import time import tempfile +from agent_comms.comms import wire from agent_comms.field_codec import FieldCodec from agent_comms.storage_rules import StorageRoots from agent_comms.native_package import NativeImportsManifest, VerifiedNativePackage @@ -62,10 +63,9 @@ def main(fixture: Path): } environment.update(RestartEnvironment(native_config=agent, agent_directory=agent).encode_native()) from agent_comms.child_process import ProcessIdentity - from agent_comms.comms import Comms from agent_comms.threads import Thread - comms = Comms(root / "wire") + comms = wire(root / "wire") thread = Thread("native-startup-fixture", frozenset(), str(project), storage_roots=StorageRoots(root / "scratch"), process_identity=ProcessIdentity.capture(os.getpid())) diff --git a/tests/test_bus.py b/tests/test_bus.py index 5b6b00644e..7a71031c81 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -1,8 +1,12 @@ import os +import shutil import signal import sqlite3 +import subprocess +import sys from contextlib import closing, contextmanager from dataclasses import replace +from pathlib import Path import pytest @@ -48,6 +52,115 @@ from bus_record_fixture import publication_processes, record_from +@pytest.fixture +def projection_source(tmp_path: Path) -> Path: + checkout = tmp_path / "producer-source" + source = checkout / "src" / "agent_comms" + original = Path(__file__).resolve().parents[1] / "src" / "agent_comms" + shutil.copytree(original, source, ignore=shutil.ignore_patterns("__pycache__", "skills")) + (source / "skills").symlink_to(original / "skills", target_is_directory=True) + subprocess.run(("git", "init", "-q", str(checkout)), check=True, capture_output=True) + subprocess.run( + ( + "git", + "-C", + str(checkout), + "-c", + "user.name=Digest fixture", + "-c", + "user.email=digest@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=/dev/null", + "commit", + "--allow-empty", + "-qm", + "Source fixture", + ), + check=True, + capture_output=True, + ) + return source + + +def projection_source_digest(source: Path) -> str: + return subprocess.run( + ( + sys.executable, + "-I", + "-c", + "import sys; sys.path.insert(0, sys.argv[1]); " + "from agent_comms.messages import BusProjectionTables; " + "print(BusProjectionTables._producer_digest())", + str(source.parent), + ), + check=True, + capture_output=True, + text=True, + timeout=60, + ).stdout.strip() + + +@pytest.mark.parametrize( + ("module", "old", "new"), + ( + ( + "typed_table.py", + "Durable files compare their stored DDL text", + "An unrelated infrastructure docstring: durable files compare their stored DDL text", + ), + ( + "field_codec.py", + "class TimestampText(", + "def unrelated_projection_probe():\n return 17\n\n\nclass TimestampText(", + ), + ), +) +def test_projection_digest_ignores_infrastructure_source( + projection_source: Path, + module: str, + old: str, + new: str, +) -> None: + before = projection_source_digest(projection_source) + path = projection_source / module + original = path.read_text() + assert original.count(old) == 1 + path.write_text(original.replace(old, new)) + assert projection_source_digest(projection_source) == before + + +@pytest.mark.parametrize( + ("module", "old", "new"), + ( + ( + "bus_publication.py", + "yield SentSource(self.audience.sender_lookup, self.seq)", + "yield SentSource(self.audience.sender_lookup, self.seq + 1)", + ), + ("bus_activity_index.py", '=max("{name}",excluded.', '=min("{name}",excluded.'), + ( + "messages.py", + "for record in BusCapability.history(db):", + "for record in reversed(tuple(BusCapability.history(db))):", + ), + ), +) +def test_projection_digest_tracks_producing_source( + projection_source: Path, + module: str, + old: str, + new: str, +) -> None: + before = projection_source_digest(projection_source) + path = projection_source / module + original = path.read_text() + assert original.count(old) == 1 + path.write_text(original.replace(old, new)) + assert projection_source_digest(projection_source) != before + + def test_bus_attachment_and_failed_write_do_not_install_a_schema(tmp_path): bus = Bus(Bus.path_in(tmp_path / "not-installed")) assert not bus.path.parent.exists()