From eeee8ec6f99cb5f9a43ad0a3648ce7eb09db3aaf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:52:06 -0400 Subject: [PATCH 01/24] Project captured display identities through registry rows --- src/agent_comms/display_references.py | 6 ++--- src/agent_comms/registry_document.py | 36 ++++++++++++++++++++------ src/agent_comms/registry_provenance.py | 17 +++++++++--- tests/test_sidebar_observation.py | 19 ++++++++++++++ 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/agent_comms/display_references.py b/src/agent_comms/display_references.py index 43ea9875b7..b0be1c8e42 100644 --- a/src/agent_comms/display_references.py +++ b/src/agent_comms/display_references.py @@ -158,8 +158,8 @@ class DisplayReferences(RootedReferences): @staticmethod def recorded(root, source) -> DisplayReferences: registry = source.provenance - targets = {name: HistoricalDisplayReference(str(root), thread.incarnation, source.key) - for name, thread in registry.named_members()} + targets = {name: HistoricalDisplayReference(str(root), incarnation, source.key) + for name, incarnation in registry.named_incarnations()} return DisplayReferences(str(root), targets) def for_source(self, source) -> DisplayReferences: @@ -209,7 +209,7 @@ class CurrentDisplayReferences(SnapshotRestored, DisplayReferences): @classmethod def from_registry(cls, root: str, registry: RegistrySnapshot, channels: tuple[str, ...] = ()) -> Self: - return cls(root, {name: thread.incarnation for name, thread in registry.named_members()}, channels) + return cls(root, dict(registry.named_incarnations()), channels) def __post_init__(self) -> None: """The target and lookup views of the one stored member map.""" diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index f948421b7c..99f43754eb 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, field, replace from functools import cached_property from types import MappingProxyType -from typing import TYPE_CHECKING, Literal, Annotated, Self +from typing import TYPE_CHECKING, Literal, Annotated, Self, cast from pydantic import JsonValue @@ -55,6 +55,7 @@ OwnerIdentity, RecordedThreadIncarnation, ThreadIncarnation, + ThreadIncarnationRecord, ThreadAuthority, TurnIdentity, ) @@ -102,6 +103,13 @@ class RegistryPresence[StatusMap: Mapping[str, ThreadStatus]](RegistryNames[Thre def children(self, name: str) -> tuple[Thread, ...]: return self.threads.children(self.canonical_name(name)) + def incarnation(self, name: str) -> ThreadIncarnation: + """This captured row owns identity without acquiring its full declaration.""" + canonical = self.canonical_name(name) + if canonical not in self.threads: + raise UnregisteredThreadError(f"Thread {canonical!r} is not registered.") + return self.threads.incarnation(canonical) + def active_candidates(self) -> Iterator[Thread]: return (self.threads[name] for name, status in self.statuses.items() if status.may_be_active) @@ -344,6 +352,21 @@ def children(self, parent: str) -> tuple[Thread, ...]: else self._raw[name].get(key) == expected) ) + def creation_time(self, name: str) -> CreationTime: + """Read the declared birth only; absence must not invoke a generated default.""" + try: + if name in self._decoded: + return self._decoded[name].created_at + plan = FieldCodec.field_plan(Thread, "created_at") + return cast(CreationTime, plan.value.decode_bound(plan.raw(self._raw[name]), {})) + except ValueError as error: + raise InvalidRegistryDocument(error) from error + + def incarnation(self, name: str) -> ThreadIncarnation: + if name in self._decoded: + return self._decoded[name].incarnation + return ThreadIncarnationRecord(name, self.creation_time(name).to_wire()).incarnation() + def __getitem__(self, name: str) -> Thread: if name not in self._decoded: self._decoded[name] = self.decode_row(self._raw[name]) @@ -390,9 +413,11 @@ def schema(cls) -> dict[str, JsonValue]: def checked_rows(cls, raw: object) -> dict[str, dict[str, JsonValue]]: if not isinstance(raw, dict): raise MalformedRecord("registry threads must be an object") + name_key = FieldCodec.field_plan(Thread, "name").key for name, row in raw.items(): - if not isinstance(name, str) or not isinstance(row, dict) or row.get("name") != name: + if not isinstance(name, str) or not isinstance(row, dict) or row.get(name_key) != name: raise MalformedRecord("thread name differs from its declaration") + Thread.require_name(name) return dict(raw) def require_distinct_births(self) -> None: @@ -400,12 +425,7 @@ def require_distinct_births(self) -> None: a stored row's birth is read alone, without decoding the rest of its thread.""" births: set[float] = set() for name in self: - try: - birth = (self._decoded[name].created_at if name in self._decoded - else CreationTime.from_wire(self._raw[name].get("created_at"))) - except ValueError as error: - raise InvalidRegistryDocument(error) from error - births.add(birth.distinct_from(births)) + births.add(self.creation_time(name).distinct_from(births)) @classmethod def from_wire(cls, raw: object) -> Self: diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index b64031fed8..3c07c335a5 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -125,11 +125,22 @@ def claim_name(self, wanted: str, owner: str | None = None) -> str: name, suffix = f"{base}-{suffix}", suffix + 1 return name + def declared_names(self) -> Iterator[str]: + """The current namespace, including each declaration's retained aliases.""" + yield from self.threads + yield from self.aliases + def named_members(self) -> Iterator[tuple[str, Member]]: """Original declarations under their canonical and retained alias names.""" - yield from self.threads.items() - for alias in self.aliases: - yield alias, self.require(alias) + return ((name, self.require(name)) for name in self.declared_names()) + + def incarnation(self, name: str) -> ThreadIncarnation: + """Recorded namespaces already hold their original nominal members.""" + return self.require(name).incarnation + + def named_incarnations(self) -> Iterator[tuple[str, ThreadIncarnation]]: + """Project identity through the namespace owner, never a consumer's roster.""" + return ((name, self.incarnation(name)) for name in self.declared_names()) def names_for(self, name: str) -> frozenset[str]: """Current and retained names of one declared member.""" diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index 27656fea0b..cea9781f99 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -127,6 +127,25 @@ def test_current_reference_membership_and_lookup_share_the_retained_registry(com restored.targets["unowned"] = reference +@pytest.mark.parametrize("decoded", (False, True)) +def test_current_reference_acquires_only_captured_incarnations(comms, declare_reference, decoded): + original = declare_reference() + comms.registry.rename(original.name, "renamed") + captured = comms.registry.snapshot() + snapshot = replace(captured, threads=ThreadRows.from_wire(captured.threads.to_wire())) + if decoded: + snapshot.require("renamed") + acquired_before = dict(snapshot.threads._decoded) + + references = CurrentDisplayReferences.from_registry(str(comms.root), snapshot, ("#comms",)) + + assert snapshot.threads._decoded == acquired_before + assert references.members[original.name].name == "renamed" + assert references.members["renamed"].wire_birth == original.incarnation.wire_birth + assert references.find(f"@{original.name}")[0].reference.name == "renamed" + assert references.find("#comms")[0].reference.name == "#comms" + + def test_current_reference_source_projection_keeps_historical_identity(comms, declare_reference): original = declare_reference() references = comms.views.viewer_snapshot(Checkout(comms.root.parent)).references From d1fdcfc881f7d795c7af6a28e50ff9f59423474f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:55:21 -0400 Subject: [PATCH 02/24] Acquire relationship projections through captured rows and input capabilities --- src/agent_comms/attempt_recovery.py | 2 +- src/agent_comms/bus.py | 16 ++++- src/agent_comms/field_codec.py | 37 ++++++++-- src/agent_comms/native_claim_epoch.py | 2 +- src/agent_comms/owner_lifecycle.py | 13 ++-- src/agent_comms/registry_document.py | 93 +++++++++++++++++--------- src/agent_comms/registry_provenance.py | 12 ++-- src/agent_comms/relationships.py | 86 +++++++++++++++--------- src/agent_comms/thread_creation.py | 6 +- src/agent_comms/thread_identity.py | 30 ++++++++- src/agent_comms/threads.py | 48 ++++++------- tests/test_bus.py | 21 +++++- tests/test_owner_release_store.py | 2 +- tests/test_registration_components.py | 11 ++- tests/test_relationships.py | 69 ++++++++++++++++++- tests/test_sidebar_observation.py | 42 ++++++++++-- 16 files changed, 362 insertions(+), 128 deletions(-) diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index d21945b693..94951dfc78 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -191,7 +191,7 @@ def require_session_exit(self, session_files: tuple[Path, ...]) -> None: if not self.issued_for(self._store): raise RecoveryBlocked("native exit observation requires acquired owner loss") session_dir = ( - self._store.session.path.parent / "native-sessions" / self.release.incarnation().lookup + self._store.session.path.parent / "native-sessions" / self.release.incarnation.lookup ) proc = Path("/proc") if not proc.is_dir(): diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index a2232377f5..09a571c10c 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -239,9 +239,19 @@ def keyed_messages(self, publication_key: str) -> tuple[BusRecord, ...]: def page(self, before: int, limit: int) -> tuple[BusRecord, ...]: with self.observing() as db: - return tuple(row.kind.read_from(db, row.seq) for row in BusRow.select( - db, where="{seq} < ?", parameters=(before,), - order_by=("seq",), descending=True, limit=limit)) + return tuple(row.kind.read_from(db, row.seq) for row in self._page_rows(db, before, limit)) + + def input_page(self, before: int, limit: int) -> tuple[MessageInput, ...]: + """The same global sequence window, without attachment/report/answer acquisition.""" + with self.observing() as db: + return tuple(MessageInput.read_from(db, row.seq) for row in self._page_rows(db, before, limit)) + + @staticmethod + def _page_rows(db: sqlite3.Connection, before: int, limit: int) -> list[BusRow]: + return BusRow.select( + db, where="{seq} < ?", parameters=(before,), + order_by=("seq",), descending=True, limit=limit, + ) def message_history(self) -> Iterator[Message]: """Public bulk export, paged within one committed SQLite snapshot.""" diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index db0eb0ab3f..c33ef57729 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -2397,10 +2397,10 @@ def _tag(value: object) -> dict[str, str]: return RecordTag.of(value) @classmethod - def encode_fields(cls, value: object) -> dict[str, object]: + def encode_fields(cls, value: object) -> dict[str, JsonValue]: """Encode declared fields, except ``wire_exclude`` ones, including inside a custom wire representation.""" - return _member_decoder(type(value)).encode(value) + return cast(dict[str, JsonValue], _member_decoder(type(value)).encode(value)) @classmethod def decode_bound(cls, target: TypeForm[T], data: object, **context: object) -> T: @@ -2410,15 +2410,31 @@ def decode_bound(cls, target: TypeForm[T], data: object, **context: object) -> T def decode_record( cls, target: type[T], data: object, *, field_types: Mapping[str, type[object]] = types.MappingProxyType({}), + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), ) -> T: - """Decode declared fields, with nominal narrowing supplied by their contextual owner.""" + """Continue a boundary's record decode, retaining fields it already decoded.""" decoder = _RecordDecoder(target) for name, narrowed in field_types.items(): annotation, _ = cls.representation(cls.field_plan(target, name).annotation) if not issubclass(narrowed, annotation): raise TypeError(f"{target.__name__}.{name} cannot widen its declared type") return cast(T, decoder.decode_selected( - data, {}, field_types=field_types)) + data, {}, field_types=field_types, decoded_fields=decoded_fields)) + + @classmethod + def project_record( + cls, target: type[T], data: object, *, source: type, + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), + ) -> T: + """Acquire a declared record projection without decoding unrelated source fields.""" + raw = _RecordDecoder(source).checked(data) + projected: dict[str, object] = {} + for entry in cls._record_plan(target).bindings: + original = cls.field_plan(source, entry.name) + captured = original.captured(raw) + if entry.name in captured: + projected[entry.key] = captured[entry.name] + return cls.decode_record(target, projected, decoded_fields=decoded_fields) @classmethod def argument_fields(cls, target: type) -> tuple[FieldPlan, ...]: @@ -3487,23 +3503,30 @@ def decode_selected( self, data: object, selections: Mapping[tuple[type, str], object], *, context: Mapping[str, object] = {}, field_types: Mapping[str, type[object]] = types.MappingProxyType({}), + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), ) -> T: - return self.decode_entries(data, self.plan.bindings, selections, context, field_types) + return self.decode_entries( + data, self.plan.bindings, selections, context, field_types, decoded_fields, + ) def decode_entries( self, data: object, entries: tuple[FieldPlan, ...], selections: Mapping[tuple[type, str], object], context: Mapping[str, object], field_types: Mapping[str, type[object]], + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), ) -> T: if not isinstance(data, dict): raise MalformedRecord("Expected a record object; fix: supply a JSON object.") - problems = self.missing_fields(data) + supplied = {key for entry in entries if entry.name in decoded_fields for key in entry.wire_keys} + problems = {key: issue for key, issue in self.missing_fields(data).items() if key not in supplied} unknown = list(self.unknown_fields(data)) target = self.target - values = {} + values = dict(decoded_fields) for entry in entries: key = (target, entry.name) try: + if entry.name in decoded_fields: + continue if entry.name in field_types: narrowed = compile_decoder(field_types[entry.name]) values.update(entry.decoded_using( diff --git a/src/agent_comms/native_claim_epoch.py b/src/agent_comms/native_claim_epoch.py index 3de1d2f085..4eaee74310 100644 --- a/src/agent_comms/native_claim_epoch.py +++ b/src/agent_comms/native_claim_epoch.py @@ -105,7 +105,7 @@ def reservation_violation(self) -> bool: def require_release(self, acknowledgement, snapshot, current) -> None: sent = ClaimedIdentity(current.incarnation, self.value) - fence = ClaimedIdentity(acknowledgement.incarnation(), acknowledgement.before) + fence = ClaimedIdentity(acknowledgement.incarnation, acknowledgement.before) if not fence.includes(sent): raise RelationViolationError("Native release predates the sending claim_scope") if snapshot.claim_generation(current.name) == acknowledgement.after: diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index 67cdec8852..f9a6e4a4d3 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -209,7 +209,7 @@ def require_process(self) -> ProcessIdentity: return self.process_identity.require(f"Thread {self.name!r} has no released process") def matches(self, expected: Thread) -> bool: - return self.incarnation() == expected.incarnation and self.process_identity == expected.process_identity + return self.incarnation == expected.incarnation and self.process_identity == expected.process_identity class OwnerReleaseStoreRows(StoreRows): @@ -259,14 +259,14 @@ def require_retired(self, snapshot: RegistrySnapshot, thread: Thread, raise RelationViolationError("Retired session acquired another process") def require_loss(self, snapshot: RegistrySnapshot, release: OwnerReleaseEvidence) -> None: - if self.owner.incarnation != release.incarnation(): + if self.owner.incarnation != release.incarnation: raise RelationViolationError("Retired session belongs to another incarnation") if snapshot.owner_identity(release.name).generation <= self.owner.generation: raise RelationViolationError("Retired session still holds its original owner lease") def matches_current(self, snapshot: RegistrySnapshot, release: OwnerReleaseEvidence) -> bool: self.require_loss(snapshot, release) - return release.incarnation().current(snapshot) + return release.incarnation.current(snapshot) @dataclass(frozen=True) @@ -277,13 +277,14 @@ class OwnerReleaseEvidence(OwnerReleaseStoreRows, ReleasedOwner, OwnerRelease, T completion: Annotated[OwnerCompletion, Column()] = ProcessExitedOwnerCompletion() def __post_init__(self) -> None: + super().__post_init__() if self.before <= 0 or self.after <= self.before: raise ValueError("Owner release requires an advanced claim_scope and a closed turn") def current(self, snapshot: RegistrySnapshot, expected: Thread, claim_scope: int) -> bool: if self.before != claim_scope or not self.matches(expected): return False - if not self.incarnation().current(snapshot) or not self.completion.matches_current(snapshot, self): + if not self.incarnation.current(snapshot) or not self.completion.matches_current(snapshot, self): return False return self.after == snapshot.claim_generation(self.name) and snapshot.status(self.name).stopped @@ -299,13 +300,13 @@ def require_owner_loss(self, snapshot, owner_identity): Input generation belongs to the recorded input, not to owner-loss proof. Neither this release nor missing input proves bytes were unwritten. """ - if not self.incarnation().current(snapshot): + if not self.incarnation.current(snapshot): raise RelationViolationError("Released native owner incarnation changed") current = snapshot.require(self.name) owner_identity.require_recorded_owner(current) self.completion.require_loss(snapshot, self) actual = snapshot.claimed_identity(current.name) - released = ClaimedIdentity(self.incarnation(), self.after) + released = ClaimedIdentity(self.incarnation, self.after) if not actual.includes(released): raise RelationViolationError("Native release claim_scope regressed") return self diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 99f43754eb..45b03003a6 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, field, replace from functools import cached_property from types import MappingProxyType -from typing import TYPE_CHECKING, Literal, Annotated, Self, cast +from typing import TYPE_CHECKING, Literal, Annotated, Self from pydantic import JsonValue @@ -26,6 +26,7 @@ SlicePolicy, WireOmitDefault, WireRequired, + WireTransient, WireValue, ) from .registration_change import ( @@ -55,7 +56,6 @@ OwnerIdentity, RecordedThreadIncarnation, ThreadIncarnation, - ThreadIncarnationRecord, ThreadAuthority, TurnIdentity, ) @@ -67,7 +67,7 @@ ThreadStatus, ) from .thread_text import ForkedParent, NoParent -from .threads import Thread, ThreadWorkState +from .threads import GoalOwner, Thread, ThreadWorkState from .turn_lease import ( FinishedTurn, FinishedTurnFence, NoFinishedTurn, StoredTurnRecord, TurnCompletion, TurnLeaseFence, TurnRecord, @@ -105,10 +105,7 @@ def children(self, name: str) -> tuple[Thread, ...]: def incarnation(self, name: str) -> ThreadIncarnation: """This captured row owns identity without acquiring its full declaration.""" - canonical = self.canonical_name(name) - if canonical not in self.threads: - raise UnregisteredThreadError(f"Thread {canonical!r} is not registered.") - return self.threads.incarnation(canonical) + return self.threads.incarnation(self.require_canonical(name)) def active_candidates(self) -> Iterator[Thread]: return (self.threads[name] for name, status in self.statuses.items() @@ -314,12 +311,46 @@ def __init__(self, error: Exception) -> None: super().__init__(f"Invalid registry document: {error}") +@dataclass(frozen=True) +class ThreadRow(GoalOwner): + """One captured declaration: determining facts are nominal, only the rest stays raw.""" + + remainder: Annotated[Mapping[str, JsonValue], WireTransient()] = field( + default_factory=dict, kw_only=True, + ) + + @classmethod + def capture(cls, raw: dict[str, JsonValue]) -> ThreadRow: + keys = { + key for entry in FieldCodec.field_plans(cls) if entry.records + for key in FieldCodec.field_plan(Thread, entry.name).wire_keys + } + return FieldCodec.project_record( + cls, raw, source=Thread, + decoded_fields={"remainder": MappingProxyType({ + key: value for key, value in raw.items() if key not in keys + })}, + ) + + def to_wire(self) -> dict[str, JsonValue]: + return {**self.remainder, **FieldCodec.encode_fields(self)} + + def __reduce__(self): + # The row owns its immutable remainder; restoration re-enters the same boundary. + return type(self).capture, (self.to_wire(),) + + def decoded(self) -> Thread: + values = {entry.name: entry.current(self) + for entry in FieldCodec.field_plans(type(self)) if entry.records} + return FieldCodec.decode_record(Thread, dict(self.remainder), decoded_fields=values) + + class ThreadRows(ExternalJsonShape, WireValue, MutableMapping[str, Thread]): """A revision's thread rows, decoded and bound only when read.""" def __init__(self) -> None: # checked_rows proved each stored row an object, so the type keeps that proof. - self._raw: dict[str, dict[str, JsonValue]] = {} + self._raw: dict[str, ThreadRow] = {} self._decoded: dict[str, Thread] = {} self._binding: Callable[[Thread], Thread] = self.unbound @@ -349,32 +380,28 @@ def children(self, parent: str) -> tuple[Thread, ...]: return tuple( self[name] for name in self if (self._decoded[name].parent.named(parent) if name in self._decoded - else self._raw[name].get(key) == expected) + else self._raw[name].remainder.get(key) == expected) ) def creation_time(self, name: str) -> CreationTime: - """Read the declared birth only; absence must not invoke a generated default.""" - try: - if name in self._decoded: - return self._decoded[name].created_at - plan = FieldCodec.field_plan(Thread, "created_at") - return cast(CreationTime, plan.value.decode_bound(plan.raw(self._raw[name]), {})) - except ValueError as error: - raise InvalidRegistryDocument(error) from error + return ChainMap(self._decoded, self._raw)[name].created_at def incarnation(self, name: str) -> ThreadIncarnation: - if name in self._decoded: - return self._decoded[name].incarnation - return ThreadIncarnationRecord(name, self.creation_time(name).to_wire()).incarnation() + return ChainMap(self._decoded, self._raw)[name].incarnation + + def active_goal_owners(self) -> Iterator[Thread]: + """Select through the shared goal/role declarations before full thread acquisition.""" + return (self[name] for name, row in ChainMap(self._decoded, self._raw).items() + for _ in row.active_goals()) def __getitem__(self, name: str) -> Thread: if name not in self._decoded: self._decoded[name] = self.decode_row(self._raw[name]) return self._decoded[name] - def decode_row(self, row: object) -> Thread: + def decode_row(self, row: ThreadRow) -> Thread: try: - thread = FieldCodec.decode(Thread, row) + thread = row.decoded() except ValueError as error: raise InvalidRegistryDocument(error) from error return self._binding(thread) @@ -401,8 +428,8 @@ def __delitem__(self, name: str) -> None: self._decoded.pop(name, None) def to_wire(self) -> dict[str, JsonValue]: - return {**self._raw, **{name: FieldCodec.encode(thread) - for name, thread in self._decoded.items()}} + return {**{name: row.to_wire() for name, row in self._raw.items()}, + **{name: FieldCodec.encode(thread) for name, thread in self._decoded.items()}} @classmethod def schema(cls) -> dict[str, JsonValue]: @@ -410,15 +437,18 @@ def schema(cls) -> dict[str, JsonValue]: return {"type": "object", "additionalProperties": FieldCodec.record_schema(Thread)} @classmethod - def checked_rows(cls, raw: object) -> dict[str, dict[str, JsonValue]]: + def checked_rows(cls, raw: object) -> dict[str, ThreadRow]: if not isinstance(raw, dict): raise MalformedRecord("registry threads must be an object") - name_key = FieldCodec.field_plan(Thread, "name").key + result: dict[str, ThreadRow] = {} for name, row in raw.items(): - if not isinstance(name, str) or not isinstance(row, dict) or row.get(name_key) != name: + if not isinstance(name, str) or not isinstance(row, dict): raise MalformedRecord("thread name differs from its declaration") - Thread.require_name(name) - return dict(raw) + captured = ThreadRow.capture(row) + if captured.name != name: + raise MalformedRecord("thread name differs from its declaration") + result[name] = captured + return result def require_distinct_births(self) -> None: """Rows decode late, so a read of one thread never sees two sharing a creation lookup; @@ -454,7 +484,10 @@ def freeze(self) -> ThreadRows: return result def reuse(self, rows: Mapping[str, object], kept: frozenset[str], earlier: ThreadRows) -> None: - self._raw = self.checked_rows(rows) + retained = {name: earlier._raw[name] for name in kept & earlier._raw.keys()} + changed = self.checked_rows({name: row for name, row in rows.items() if name not in retained}) + captured = ChainMap(retained, changed) + self._raw = {name: captured[name] for name in rows} self._decoded = {name: earlier._decoded[name] for name in kept if name in earlier._decoded} self._binding = earlier._binding diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index 3c07c335a5..4f4da2529e 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -169,12 +169,14 @@ def participant(self, name: str) -> Member | DeletedThread: return DeletedThread(canonical) raise UnregisteredThreadError(f"Thread {canonical!r} was never registered.") - def require(self, name: str) -> Member: + def require_canonical(self, name: str) -> str: canonical = self.canonical_name(name) - try: - return self.threads[canonical] - except KeyError as error: - raise UnregisteredThreadError(f"Thread {canonical!r} is not registered.") from error + if canonical not in self.threads: + raise UnregisteredThreadError(f"Thread {canonical!r} is not registered.") + return canonical + + def require(self, name: str) -> Member: + return self.threads[self.require_canonical(name)] @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 97dda8bae1..c543d7a2a8 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -27,7 +27,7 @@ from .goals import Goal, GoalMentionBinding from .locked_store import DocumentChange from .maintenance_barrier import CarriedDocument -from .messages import Message +from .messages import MessageInput from .peer_relevance import ( ChannelmateRelevance, CollaboratorRelevance, @@ -280,7 +280,34 @@ def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: @classmethod def build(cls, context: RelationshipContext) -> RelationshipGroup: - return cls({entry.target: entry for entry in cls.supply(context)}) + return cls.from_entries({entry.target: entry for entry in cls.supply(context)}, context) + + @classmethod + def from_entries(cls, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> RelationshipGroup: + return cls(entries) + + @classmethod + def capture(cls, context: RelationshipContext, views: HistoryViews) -> tuple[RelationshipGroup, ...]: + """Select declared entries first; acquire their views once on this identity cut.""" + selected = tuple(group.build(context) for group in cls.members_with(cls)) + names = dict.fromkeys(entry.target for group in selected for entry in group.entries.values()) + context.people.update((view.thread.name, view) for view in presentation.ThreadView.roster( + context.registry, views.agents, views.goals, RosterVisibility(archived=True), names=names, + )) + return tuple(group.presented(context) for group in selected) + + @staticmethod + def present_entry(entry: RelationshipEntry, context: RelationshipContext) -> RelationshipEntry: + return replace(entry, person=context.people.get(entry.target)) + + def ordered_entries(self, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> dict[str, RelationshipEntry]: + return entries + + def presented(self, context: RelationshipContext) -> RelationshipGroup: + entries = {name: self.present_entry(entry, context) for name, entry in self.entries.items()} + return replace(self, entries=self.ordered_entries(entries, context)) def selectable(self) -> tuple[SortableRelationshipGroup, ...]: return () @@ -302,7 +329,7 @@ def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: @staticmethod @abstractmethod - def targets(context: RelationshipContext, message: Message) -> tuple[str, ...]: ... + def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: ... class InboundRelationshipGroup(RecentRelationshipGroup): @@ -310,7 +337,7 @@ class InboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent inbound" @staticmethod - def targets(context: RelationshipContext, message: Message) -> tuple[str, ...]: + def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: sender = context.registry.canonical_name(message.sender) if sender == context.thread.name or not context.delivery.delivers(message.sender, message.target): return () @@ -322,7 +349,7 @@ class OutboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent outbound" @staticmethod - def targets(context: RelationshipContext, message: Message) -> tuple[str, ...]: + def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: if context.registry.canonical_name(message.sender) != context.thread.name: return () return (CommandTarget.named(message.target).canonical_name(context.registry.canonical_name),) @@ -349,19 +376,22 @@ def selected_order(cls, context: RelationshipContext) -> type[ThreadSort]: return context.orders.get(cls, cls.default_order) @classmethod - def build(cls, context: RelationshipContext) -> SortableRelationshipGroup: - order = cls.selected_order(context) + def from_entries(cls, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> SortableRelationshipGroup: + return cls(entries, cls.selected_order(context)) + def ordered_entries(self, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> dict[str, RelationshipEntry]: def key(item: RelationshipEntry) -> tuple[float, float, str]: person = item.person - return order.key( + return self.order.key( item.target, person.thread.created_at if person else 0, person.activity.timestamp if person else 0, context.sent.get(item.target, 0) if person else 0, ) - return cls({entry.target: entry for entry in sorted(cls.supply(context), key=key)}, order) + return {entry.target: entry for entry in sorted(entries.values(), key=key)} def selectable(self) -> tuple[SortableRelationshipGroup, ...]: return (self,) @@ -384,9 +414,7 @@ class ChildrenRelationshipGroup(SortableRelationshipGroup): def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: return tuple( context.entry(child.name) - for child in context.registry.threads.values() - for parent in child.parent.names() - if context.registry.canonical_name(parent) == context.thread.name + for child in context.registry.children(context.thread.name) ) @@ -402,25 +430,31 @@ def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: identity = relation.counterpart(context.thread) if identity is None: continue - person = context.people.get(identity.name) - available = person is not None and person.thread.incarnation == identity - entries[identity] = relation.entry(identity, person, available, entries.get(identity)) + entries[identity] = relation.entry( + identity, None, identity.current(context.registry), entries.get(identity), + ) return tuple(entries.values()) + @staticmethod + def present_entry(entry: RelationshipEntry, context: RelationshipContext) -> RelationshipEntry: + person = context.people.get(entry.target) + available = entry.available and person is not None + return replace(entry, person=person if available else None, available=available) + @dataclass(frozen=True, slots=True) class RelationshipContext: registry: RegistrySnapshot thread: Thread people: dict[str, presentation.ThreadView] - messages: tuple[Message, ...] + messages: tuple[MessageInput, ...] delivery: DeliveryScope edges: tuple[Collaboration, ...] goal_contacts: tuple[GoalDerivedContact, ...] orders: dict[type[SortableRelationshipGroup], type[ThreadSort]] sent: dict[str, float] - def entry(self, name: str, *, message: Message | None = None, + def entry(self, name: str, *, message: MessageInput | None = None, detail: str = "") -> RelationshipEntry: target = CommandTarget.named(name) name = target.canonical_name(self.registry.canonical_name) @@ -712,11 +746,7 @@ def _goal_contacts( active goals in ONE registry snapshot; never changes an explicit contact or delivery.""" contacts: list[GoalDerivedContact] = [] diagnostics: list[GoalMentionDiagnostic] = [] - for owner in ( - executor - for thread in registry.threads.values() - for executor in thread.role.executor(thread) - ): + for owner in registry.threads.active_goal_owners(): for goal in owner.goal.active(): ThreadRelationships._project_goal_contacts(goal, owner, registry, contacts, diagnostics) return (tuple(row for row in contacts if row.incident(thread)), @@ -846,10 +876,10 @@ def set_order(self, owner: str, group: type[SortableRelationshipGroup], ) return order - def _recent_messages(self) -> tuple[tuple[Message, ...], bool]: + def _recent_messages(self) -> tuple[tuple[MessageInput, ...], bool]: """One bounded sequence page, on a single committed read snapshot.""" with self.bus.bus.reading() as source: - messages = source.page(source.revision() + 1, self.RECENT_MESSAGES + 1) + messages = source.input_page(source.revision() + 1, self.RECENT_MESSAGES + 1) return tuple(reversed(messages[:self.RECENT_MESSAGES])), len(messages) > self.RECENT_MESSAGES def snapshot(self, owner: str) -> ThreadCommsSnapshot: @@ -860,14 +890,10 @@ def snapshot(self, owner: str) -> ThreadCommsSnapshot: edges = state.collaborations goal_contacts, goal_diagnostics = self._goal_contacts(registry, thread) delivery = DeliveryScope.capture(thread.name, registry, self.views.channels.catalog.read()) - people = { - view.thread.name: view - for view in self.views.thread_views(RosterVisibility(archived=True)) - } messages, limited = self._recent_messages() context = RelationshipContext( - registry, thread, people, messages, delivery, edges, goal_contacts, + registry, thread, {}, messages, delivery, edges, goal_contacts, {row.group: row.order for row in state.orders if row.incarnation == thread.incarnation}, {}, ) @@ -879,7 +905,7 @@ def snapshot(self, owner: str) -> ThreadCommsSnapshot: return ThreadCommsSnapshot( thread.name, str(self.root.resolve()), - tuple(group.build(context) for group in RelationshipGroup.members_with(RelationshipGroup)), + RelationshipGroup.capture(context, self.views), limited, len(messages), unresolved_goal_mentions=goal_diagnostics, diff --git a/src/agent_comms/thread_creation.py b/src/agent_comms/thread_creation.py index 693f37487d..d66fe6a19e 100644 --- a/src/agent_comms/thread_creation.py +++ b/src/agent_comms/thread_creation.py @@ -6,11 +6,12 @@ import time from abc import ABC, abstractmethod from collections.abc import Set +from typing import Annotated, TypeAlias from metaclass_registry.mro_dispatch import handles from .errors import RelationViolationError -from .field_codec import FieldCodec, FieldRepresentation, WireValue +from .field_codec import FieldCodec, FieldRepresentation, WireRequired, WireValue class CreationTime(float, WireValue, ABC): @@ -67,3 +68,6 @@ def encode(cls, value: CreationTime) -> float: @classmethod def schema(cls) -> dict[str, object]: return CreationTime.schema() + + +RequiredCreationTime: TypeAlias = Annotated[CreationTime, CreationSeconds, WireRequired()] diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 880aef1716..0e24bfdf0a 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -7,6 +7,7 @@ from __future__ import annotations from .class_fact import ClassFact +from .thread_creation import CreationSeconds, CreationTime, RequiredCreationTime import hashlib import math from abc import ABC, abstractmethod @@ -190,7 +191,7 @@ def schema(cls) -> dict: @classmethod def from_wire(cls, data: dict) -> ThreadIncarnation: - return FieldCodec.decode(ThreadIncarnationRecord, data).incarnation() + return FieldCodec.decode(ThreadIncarnationRecord, data).incarnation @classmethod def deleted(cls, name: str) -> DeletedThreadIncarnation: @@ -403,14 +404,25 @@ def recorded_as(cls, name: str) -> CoreWatcherIncarnation: return cls.watcher() -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True) class ThreadIncarnationRecord: """The single exact external name/birth record; members own its meaning.""" name: str created_at: float + @staticmethod + def require_name(name: str) -> None: + allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + if not name or not set(name) <= allowed: + raise ValueError(f"Thread name {name!r} must be alphanumeric with hyphens/underscores.") + + def __post_init__(self) -> None: + self.require_name(self.name) + + @cached_property def incarnation(self) -> ThreadIncarnation: + """One birth derivation, shared by full declarations and captured narrow rows.""" try: member = BirthlessIncarnation.member_selected(self.created_at) except UnknownFamilyMember: @@ -419,9 +431,21 @@ def incarnation(self) -> ThreadIncarnation: @classmethod def decode(cls, name: str, birth: float) -> ThreadIncarnation: - return FieldCodec.decode(cls, {"name": name, "created_at": birth}).incarnation() + return FieldCodec.decode(cls, {"name": name, "created_at": birth}).incarnation + + +@dataclass(frozen=True, kw_only=True) +class ThreadBirthRecord(ThreadIncarnationRecord): + """A registered declaration retains its recorded/generated creation-clock capability.""" + created_at: RequiredCreationTime + + def __post_init__(self) -> None: + object.__setattr__(self, "created_at", FieldCodec.decode( + Annotated[CreationTime, CreationSeconds], self.created_at, + )) + super().__post_init__() @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 2d96466a8c..12b03f5f9e 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -4,6 +4,7 @@ import os from abc import ABC, abstractmethod +from collections.abc import Iterator from dataclasses import dataclass, field, fields, replace from functools import cached_property from pathlib import Path @@ -29,6 +30,7 @@ WireRequired, ) from .goals import ( + Goal, GoalDeclaration, NoGoal, ) @@ -53,14 +55,14 @@ from .session_file import NoSessionFile, SessionFile from .store_files import AtomicFile from .task_sources import TaskScope -from .thread_creation import CreationSeconds, CreationTime, GeneratedCreationTime +from .thread_creation import GeneratedCreationTime, RequiredCreationTime from .thread_execution import NativeThreadExecution, ThreadExecution from .thread_identity import ( AgentThreadRole, ObservedTurn, OfferedTools, ThreadIncarnation, - ThreadIncarnationRecord, + ThreadBirthRecord, ThreadRole, TurnFence, TurnIdentity, @@ -192,13 +194,11 @@ def configured(self, thread: Thread) -> Thread: @dataclass(frozen=True, kw_only=True) -class ThreadProvenance: +class ThreadProvenance(ThreadBirthRecord): """Recorded thread identity, without permission to own a process or start a turn.""" - name: str = field(kw_only=False) tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) worktree: Checkout = field(kw_only=False) - created_at: Annotated[CreationTime, CreationSeconds, WireRequired()] session_file: Annotated[SessionFile, RegistrationPolicy(InheritApplied)] = field( default_factory=NoSessionFile ) @@ -223,16 +223,8 @@ def writable_runtime(self) -> RuntimeLocation: def display_title(self) -> str: return self.title.otherwise(self.name) - @staticmethod - def require_name(name: str) -> None: - allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") - if not name or not set(name) <= allowed: - raise ValueError(f"Thread name {name!r} must be alphanumeric with hyphens/underscores.") - def __post_init__(self) -> None: - object.__setattr__(self, "created_at", FieldCodec.decode( - Annotated[CreationTime, CreationSeconds], self.created_at)) - self.require_name(self.incarnation.name) + super().__post_init__() for tag in self.tags: Tag.validate(tag) @@ -247,11 +239,6 @@ def capture(thread: ThreadProvenance) -> ThreadProvenance: } ) - @cached_property - def incarnation(self) -> ThreadIncarnation: - """The declaration's birth member, created once and shared by its readers.""" - return ThreadIncarnationRecord(self.name, self.created_at.to_wire()).incarnation() - class ThreadCatalog(DurableFile, PatternFile): """The tool catalog a native launch reads from its thread's session directory.""" @@ -260,8 +247,21 @@ class ThreadCatalog(DurableFile, PatternFile): filename: ClassVar[str] = "native-sessions/{lookup}/comms-catalog.json" +@dataclass(frozen=True, kw_only=True) +class GoalOwner(ThreadBirthRecord): + """The declared goal and executor membership, shared by full and captured rows.""" + + goal: Annotated[GoalDeclaration, RegistrationPolicy(GoalDeclaration)] = ( + field(default_factory=NoGoal) + ) + role: Annotated[type[ThreadRole], RegistrationPolicy(InheritPrevious)] = AgentThreadRole + + def active_goals(self) -> Iterator[Goal]: + return (goal for owner in self.role.executor(self) for goal in owner.goal.active()) + + @dataclass(frozen=True, slots=True) -class Thread(ThreadProvenance, CatalogSelection): +class Thread(ThreadProvenance, GoalOwner, CatalogSelection): """Declares one agent thread's identity and provenance.""" parent: ThreadParent = field(default_factory=NoParent) @@ -281,10 +281,7 @@ class Thread(ThreadProvenance, CatalogSelection): service_tier: Annotated[ServiceTier, RegistrationPolicy(InheritPrevious)] = ( field(default=ServiceTier.PRIORITY, kw_only=True) ) - goal: Annotated[GoalDeclaration, RegistrationPolicy(GoalDeclaration)] = ( - field(default_factory=NoGoal) - ) - created_at: Annotated[CreationTime, CreationSeconds, WireRequired()] = field( + created_at: RequiredCreationTime = field( default_factory=GeneratedCreationTime.now, kw_only=True ) storage_roots: Annotated[ @@ -305,9 +302,6 @@ class Thread(ThreadProvenance, CatalogSelection): project_resume_due: Annotated[ bool, RegistrationPolicy(InheritPrevious), WireOmitDefault() ] = False - role: Annotated[type[ThreadRole], RegistrationPolicy(InheritPrevious)] = ( - AgentThreadRole - ) execution: Annotated[type[ThreadExecution], WireOmitDefault()] = field( default=NativeThreadExecution, kw_only=True ) diff --git a/tests/test_bus.py b/tests/test_bus.py index 8ef0bf11fb..683a82cee3 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -41,6 +41,7 @@ InfoKind, MembershipTransition, Message, + MessageInput, MessageKind, MessageRoleWire, MessageRoutes, @@ -54,7 +55,7 @@ RelationViolationError, UnclaimedError, ) -from agent_comms.field_codec import GitCommitText, MessageId, MessageIdText, TimestampText +from agent_comms.field_codec import FieldCodec, GitCommitText, MessageId, MessageIdText, TimestampText from agent_comms.message_reference import MessageReference from agent_comms.read_basis import RepliesDisplayScope from agent_comms.response_acknowledgement import ( @@ -381,6 +382,24 @@ def test_real_message_record_round_trip(bus, declaration, role): assert bus.lookup(stored.seq) == stored == record assert Bus(bus.path).lookup(stored.seq) == stored assert bus.page(stored.seq + 1, 10) == (stored,) + expected_input = FieldCodec.construct(MessageInput, { + entry.name: entry.current(stored) for entry in FieldCodec.field_plans(MessageInput) + }) + with bus.reading() as snapshot: + with snapshot.observing() as db: + allowed = {table.declared_name for table in MessageInput.tables()} | {BusRow.declared_name} + + def input_reads_only(action, table, column, database, trigger): + return (sqlite3.SQLITE_DENY if action == sqlite3.SQLITE_READ and table not in allowed + else sqlite3.SQLITE_OK) + + db.set_authorizer(input_reads_only) + try: + assert snapshot.input_page(stored.seq + 1, 10) == (expected_input,) + with pytest.raises(sqlite3.DatabaseError): + snapshot.page(stored.seq + 1, 10) + finally: + db.set_authorizer(None) assert stored.message is stored assert stored.publication_snapshot == message.publication_snapshot assert type(stored.publication_snapshot) is Message diff --git a/tests/test_owner_release_store.py b/tests/test_owner_release_store.py index 3f0ebada86..8115c37640 100644 --- a/tests/test_owner_release_store.py +++ b/tests/test_owner_release_store.py @@ -26,7 +26,7 @@ def test_release_survives_metadata_changes_and_reads_only_its_row(tmp_path): OwnerReleaseEvidence.update(db, where='name=?', parameters=('other',), after=0) record = releases.read_many(('worker',))['worker'] assert record.current(comms.registry.snapshot(), originals[0], generation) - assert record.incarnation() == originals[0].incarnation + assert record.incarnation == originals[0].incarnation assert record.process_identity == originals[0].process_identity releases.remove_threads((originals[0],)) assert isinstance(releases.read_many(('worker',))['worker'], NoRelease) diff --git a/tests/test_registration_components.py b/tests/test_registration_components.py index 507d11b937..fd3019238d 100644 --- a/tests/test_registration_components.py +++ b/tests/test_registration_components.py @@ -158,11 +158,16 @@ def test_lifecycle_document_survives_fresh_process_restart(tmp_path): def test_thread_rows_compare_unread_rows_as_stored_and_decode_only_what_changed(tmp_path): - from agent_comms.registry_document import ThreadRows + from agent_comms.field_codec import FieldCodec + from agent_comms.registry_document import InvalidRegistryDocument, ThreadRows from agent_comms.thread_execution import ExternalThreadExecution - # A row that cannot decode proves an unread, unchanged row is never decoded to compare. - stored = {'unread': {'name': 'unread', 'not': 'a thread'}} + # A real declaration with malformed non-determining data stays lazy until a full read. + row = FieldCodec.encode_fields(Thread('unread', frozenset(), Checkout(tmp_path))) + row[FieldCodec.field_plan(Thread, 'model').key] = 123 + stored = {'unread': row} + with pytest.raises(InvalidRegistryDocument): + ThreadRows.from_wire(stored)['unread'] assert ThreadRows.from_wire(stored) == ThreadRows.from_wire(stored) written = ThreadRows.from_wire(stored) written['unread'] = Thread( diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 05b967883b..032c2d840f 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -7,14 +7,19 @@ import pytest -from agent_comms.registry_document import RosterVisibility +from agent_comms.registry_document import InvalidRegistryDocument, RosterVisibility, ThreadRows from agent_comms.relationships import ( ChildrenRelationshipGroup, CollaboratingRelationshipGroup, InboundRelationshipGroup, - OutboundRelationshipGroup, ParentRelationshipGroup, RelationshipEdit, + OutboundRelationshipGroup, ParentRelationshipGroup, RelationshipContext, RelationshipEdit, + RelationshipGroup, SortableRelationshipGroup, ) from agent_comms.comms import wire from conftest import installed_comms -from agent_comms.display_order import LastActivityThreadSort +from agent_comms.display_order import LastActivityThreadSort, ThreadSort +from agent_comms.field_codec import FieldCodec +from agent_comms.bus import PublicRecord +from agent_comms.messages import InfoKind, Message, NoticeRole +from agent_comms.routing import DeliveryScope from agent_comms.thread_text import ForkedParent, NoParent from agent_comms.tools import invoke_tool from agent_comms.threads import Thread @@ -120,6 +125,64 @@ def test_alias_and_persisted_thread_sort(tmp_path): assert collaboration_rows(comms, "reviewer")[0].target == "renamed" +def test_recent_relationships_keep_the_global_cut_and_exact_preview(tmp_path): + comms = setup_wire(tmp_path) + bus = comms.bus.bus + bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "owner", "evicted relevant input", InfoKind, seq=seq), + )) + for _ in range(comms.relationships.RECENT_MESSAGES): + bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "peer", "unrelated global row", InfoKind, seq=seq), + )) + snapshot = comms.relationships.snapshot("owner") + assert snapshot.history_limited and snapshot.history_messages == comms.relationships.RECENT_MESSAGES + assert not snapshot.groups_by_type[InboundRelationshipGroup].entries + + body = "preview " * 50 + latest = bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "owner", body, InfoKind, seq=seq), + )) + bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "owner", "not a conversation", InfoKind, seq=seq, role=NoticeRole()), + )) + snapshot = comms.relationships.snapshot("owner") + entry = snapshot.groups_by_type[InboundRelationshipGroup].entries["peer"] + assert entry.sequence == latest.seq and entry.timestamp == latest.timestamp + assert entry.detail == f"peer → owner\n{body[:240]}" + assert snapshot.history_limited and snapshot.history_messages == comms.relationships.RECENT_MESSAGES + + +@pytest.mark.parametrize("order", ThreadSort.members_with(ThreadSort)) +def test_group_selection_hydrates_only_selected_threads_on_its_captured_cut(tmp_path, order): + comms = setup_wire(tmp_path) + comms.registry.declare(Thread("unselected", frozenset(), Checkout(tmp_path))) + comms.relationships.edit("owner", RelationshipEdit.member_named("add"), "peer", "Real contact") + captured = comms.registry.snapshot() + raw = captured.threads.to_wire() + raw["unselected"][FieldCodec.field_plan(Thread, "model").key] = 123 + registry = replace(captured, threads=ThreadRows.from_wire(raw)) + thread = registry.require("owner") + contacts, _ = comms.relationships._goal_contacts(registry, thread) + context = RelationshipContext( + registry, thread, {}, (), + DeliveryScope.capture(thread.name, registry, comms.views.channels.catalog.read()), + comms.relationships.store.read().resolved(registry).collaborations, contacts, + {group: order for group in SortableRelationshipGroup.members_with(SortableRelationshipGroup)}, + order.sent_timestamps(comms.views, registry=registry), + ) + groups = {type(group): group for group in RelationshipGroup.capture(context, comms.views)} + for group_type, target in ((ParentRelationshipGroup, "origin"), + (ChildrenRelationshipGroup, "child"), + (CollaboratingRelationshipGroup, "peer")): + entry = groups[group_type].entries[target] + assert entry.available and entry.person.thread is registry.require(target) + assert set(context.people) == {"origin", "child", "peer"} + assert all(selected.order is order for group in groups.values() for selected in group.selectable()) + with pytest.raises(InvalidRegistryDocument): + registry.require("unselected") + + def test_concurrent_declaring_agents_do_not_lose_updates(tmp_path): comms = setup_wire(tmp_path) names = [f"worker-{index}" for index in range(8)] diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index cea9781f99..3b26d1667c 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -7,10 +7,10 @@ import pytest -from agent_comms.registry_document import ThreadRows +from agent_comms.registry_document import InvalidRegistryDocument, ThreadRows from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import MalformedRecord +from agent_comms.field_codec import FieldCodec, MalformedRecord from agent_comms.display_references import CurrentDisplayReferences, HistoricalDisplayReference from agent_comms.historical_views import HistorySource from agent_comms.registry_document import HistoricalRootPurpose @@ -130,22 +130,52 @@ def test_current_reference_membership_and_lookup_share_the_retained_registry(com @pytest.mark.parametrize("decoded", (False, True)) def test_current_reference_acquires_only_captured_incarnations(comms, declare_reference, decoded): original = declare_reference() + unread = declare_reference("unread") comms.registry.rename(original.name, "renamed") captured = comms.registry.snapshot() - snapshot = replace(captured, threads=ThreadRows.from_wire(captured.threads.to_wire())) + raw = captured.threads.to_wire() + raw[unread.name][FieldCodec.field_plan(Thread, "model").key] = 123 + snapshot = replace(captured, threads=ThreadRows.from_wire(raw)) if decoded: snapshot.require("renamed") - acquired_before = dict(snapshot.threads._decoded) references = CurrentDisplayReferences.from_registry(str(comms.root), snapshot, ("#comms",)) - - assert snapshot.threads._decoded == acquired_before + for _ in range(100): + repeated = CurrentDisplayReferences.from_registry(str(comms.root), snapshot, ("#comms",)) + assert repeated.members["renamed"] is references.members["renamed"] + assert repeated.members["unread"] is references.members["unread"] + with pytest.raises(InvalidRegistryDocument): + snapshot.require(unread.name) assert references.members[original.name].name == "renamed" assert references.members["renamed"].wire_birth == original.incarnation.wire_birth assert references.find(f"@{original.name}")[0].reference.name == "renamed" assert references.find("#comms")[0].reference.name == "#comms" +@pytest.mark.parametrize("entry,value", ( + (FieldCodec.field_plan(Thread, "name"), "invalid name"), + (FieldCodec.field_plan(Thread, "created_at"), []), + (FieldCodec.field_plan(Thread, "goal"), []), + (FieldCodec.field_plan(Thread, "role"), []), +)) +def test_captured_reference_rows_refuse_malformed_determining_fields(declare_reference, entry, value): + original = declare_reference() + raw = FieldCodec.encode_fields(original) + raw[entry.key] = value + with pytest.raises(MalformedRecord): + ThreadRows.from_wire({original.name: raw}) + + +def test_captured_reference_rows_refuse_unknown_or_absent_required_fields(declare_reference): + original = declare_reference() + raw = FieldCodec.encode_fields(original) + missing_birth = dict(raw) + del missing_birth[FieldCodec.field_plan(Thread, "created_at").key] + for malformed in ({**raw, "undeclared_field": "refuse"}, missing_birth): + with pytest.raises(MalformedRecord): + ThreadRows.from_wire({original.name: malformed}) + + def test_current_reference_source_projection_keeps_historical_identity(comms, declare_reference): original = declare_reference() references = comms.views.viewer_snapshot(Checkout(comms.root.parent)).references From b8c141898d0187e6f99566307d6a1cfc7983064b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 15:27:55 -0400 Subject: [PATCH 03/24] Keep birth projection and retained rows on their wire owners --- src/agent_comms/field_codec.py | 27 +++++++++++-------- src/agent_comms/registry_document.py | 39 ++++++++++++---------------- src/agent_comms/thread_identity.py | 14 ++++++++-- 3 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index c33ef57729..4859d4567e 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -649,7 +649,7 @@ def contribute(self, fields): def wire_keys(self) -> tuple[str, ...]: return (self.key,) - def encoded(self, value, *, key=None): + def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: return {key or self.key: value} def schema_fields(self): @@ -780,7 +780,7 @@ def contribute(self, fields: list[FieldPlan]) -> tuple[str, ...]: def wire_keys(self) -> tuple[str, ...]: return () - def encoded(self, value: object, *, key: str | None = None) -> dict[str, object]: + def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: return {} def decoded(self, data: object) -> dict[str, object]: @@ -841,7 +841,9 @@ def contribute(self, fields): def wire_keys(self) -> tuple[str, ...]: return tuple(entry.key for entry in self.record.fields) - def encoded(self, value, *, key=None): + def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: + if not isinstance(value, dict): + raise MalformedRecord("An inline record must encode a JSON object.") return value def captured(self, data: object) -> dict[str, object]: @@ -2400,7 +2402,7 @@ def _tag(value: object) -> dict[str, str]: def encode_fields(cls, value: object) -> dict[str, JsonValue]: """Encode declared fields, except ``wire_exclude`` ones, including inside a custom wire representation.""" - return cast(dict[str, JsonValue], _member_decoder(type(value)).encode(value)) + return _member_decoder(type(value)).encode(value) @classmethod def decode_bound(cls, target: TypeForm[T], data: object, **context: object) -> T: @@ -2575,7 +2577,7 @@ def _project_structure(cls, value: object, view: str, annotation: object) -> Jso @classmethod def _project_record(cls, value: object, view: str) -> JsonValue: """A record's view: its tag, its view's fields and the properties the view owns.""" - result = cls._tag(value) + result: dict[str, JsonValue] = {**cls._tag(value)} result.update(cls._record_plan(type(value)).encoded( value, view, lambda entry, current: cls.project(current, view, entry.annotation) )) @@ -2606,8 +2608,11 @@ def required_wire_keys(self) -> frozenset[str]: def record_fields(self) -> tuple[tuple[Field[object], str], ...]: return tuple((plan.declared, plan.key) for plan in self.bindings) - def encoded(self, value, view, encode_value) -> dict[str, object]: - result: dict[str, object] = {} + def encoded( + self, value: object, view: str, + encode_value: Callable[[FieldPlan, object], JsonValue], + ) -> dict[str, JsonValue]: + result: dict[str, JsonValue] = {} for entry in self.bindings: current = entry.current(value) if not entry.excluded_in(view) and not entry.omitted( @@ -3444,11 +3449,11 @@ def fits_wire(self, data: object) -> TypeGuard[dict[str, object]]: plan = self.plan return plan.required_wire_keys <= data.keys() <= plan.accepted_keys - def encode(self, value: object) -> dict[str, object]: + def encode(self, value: object) -> dict[str, JsonValue]: if not isinstance(value, self.target): raise MalformedRecord(f"Expected {self.target.__name__} record") result = self.plan.encoded( - value, "wire", lambda entry, current: entry.value.encode(current) + value, "wire", lambda entry, current: FieldCodec.encode(current, entry.annotation) ) result.update( (member.wire_name, FieldCodec.encode(getattr(value, name))) @@ -3614,14 +3619,14 @@ def decode(self, data: object) -> BaseModel: with MalformedRecord.refusing(f"Invalid {self.target.__name__}"): return self.target.model_validate(data) - def encode(self, value: object) -> object: + def encode(self, value: object) -> dict[str, JsonValue]: if not isinstance(value, self.target): raise TypeError(f"Expected {self.target.__name__}, received {type(value).__name__}") return value.model_dump(mode="json", by_alias=True, exclude_none=True) @lru_cache(maxsize=None) -def _member_decoder(target: object) -> ValueDecoder[object]: +def _member_decoder(target: object) -> _RecordDecoder[object] | _PydanticDecoder: """A concrete record, enum or scalar, after any family tag is removed.""" if isinstance(target, type) and is_dataclass(target): return _RecordDecoder(target) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 45b03003a6..37d4999f69 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -19,6 +19,7 @@ from .declared_family import DeclaredFamily from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import ( + DeclaredWireType, ExternalJsonShape, FieldCodec, MalformedRecord, @@ -312,37 +313,30 @@ def __init__(self, error: Exception) -> None: @dataclass(frozen=True) -class ThreadRow(GoalOwner): - """One captured declaration: determining facts are nominal, only the rest stays raw.""" +class ThreadRow(GoalOwner, DeclaredWireType): + """A captured Thread wire value and its already-decoded determining fields.""" - remainder: Annotated[Mapping[str, JsonValue], WireTransient()] = field( - default_factory=dict, kw_only=True, - ) + wire_type = Thread + raw: Annotated[Mapping[str, JsonValue], WireTransient()] = field(kw_only=True) @classmethod - def capture(cls, raw: dict[str, JsonValue]) -> ThreadRow: - keys = { - key for entry in FieldCodec.field_plans(cls) if entry.records - for key in FieldCodec.field_plan(Thread, entry.name).wire_keys - } + def from_wire(cls, raw: object) -> Self: + wire = FieldCodec.decode(dict[str, JsonValue], raw) return FieldCodec.project_record( - cls, raw, source=Thread, - decoded_fields={"remainder": MappingProxyType({ - key: value for key, value in raw.items() if key not in keys - })}, + cls, wire, source=Thread, + decoded_fields={"raw": MappingProxyType(wire)}, ) def to_wire(self) -> dict[str, JsonValue]: - return {**self.remainder, **FieldCodec.encode_fields(self)} + return dict(self.raw) def __reduce__(self): - # The row owns its immutable remainder; restoration re-enters the same boundary. - return type(self).capture, (self.to_wire(),) + return type(self).from_wire, (self.to_wire(),) def decoded(self) -> Thread: values = {entry.name: entry.current(self) for entry in FieldCodec.field_plans(type(self)) if entry.records} - return FieldCodec.decode_record(Thread, dict(self.remainder), decoded_fields=values) + return FieldCodec.decode_record(Thread, dict(self.raw), decoded_fields=values) class ThreadRows(ExternalJsonShape, WireValue, MutableMapping[str, Thread]): @@ -380,7 +374,7 @@ def children(self, parent: str) -> tuple[Thread, ...]: return tuple( self[name] for name in self if (self._decoded[name].parent.named(parent) if name in self._decoded - else self._raw[name].remainder.get(key) == expected) + else self._raw[name].raw.get(key) == expected) ) def creation_time(self, name: str) -> CreationTime: @@ -428,8 +422,9 @@ def __delitem__(self, name: str) -> None: self._decoded.pop(name, None) def to_wire(self) -> dict[str, JsonValue]: - return {**{name: row.to_wire() for name, row in self._raw.items()}, - **{name: FieldCodec.encode(thread) for name, thread in self._decoded.items()}} + # A read memo is not an edit: __setitem__ removes raw only for actual writes. + return {name: FieldCodec.encode(row) + for name, row in ChainMap(self._raw, self._decoded).items()} @classmethod def schema(cls) -> dict[str, JsonValue]: @@ -444,7 +439,7 @@ def checked_rows(cls, raw: object) -> dict[str, ThreadRow]: for name, row in raw.items(): if not isinstance(name, str) or not isinstance(row, dict): raise MalformedRecord("thread name differs from its declaration") - captured = ThreadRow.capture(row) + captured = ThreadRow.from_wire(row) if captured.name != name: raise MalformedRecord("thread name differs from its declaration") result[name] = captured diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 0e24bfdf0a..77308cc1a6 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -420,13 +420,19 @@ def require_name(name: str) -> None: def __post_init__(self) -> None: self.require_name(self.name) + @property + def birth(self) -> float: + """The external identity clock; float records already carry its wire value.""" + return self.created_at + @cached_property def incarnation(self) -> ThreadIncarnation: """One birth derivation, shared by full declarations and captured narrow rows.""" + birth = self.birth try: - member = BirthlessIncarnation.member_selected(self.created_at) + member = BirthlessIncarnation.member_selected(birth) except UnknownFamilyMember: - return RecordedThreadIncarnation(self.name, self.created_at) + return RecordedThreadIncarnation(self.name, birth) return member.recorded_as(self.name) @classmethod @@ -441,6 +447,10 @@ class ThreadBirthRecord(ThreadIncarnationRecord): created_at: RequiredCreationTime + @property + def birth(self) -> float: + return self.created_at.to_wire() + def __post_init__(self) -> None: object.__setattr__(self, "created_at", FieldCodec.decode( Annotated[CreationTime, CreationSeconds], self.created_at, From b6ee56792b064990945c0ae0c20b2da03612b408 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 15:54:22 -0400 Subject: [PATCH 04/24] Retain group source inputs before acquiring complete relationship views --- src/agent_comms/presentation.py | 8 +- src/agent_comms/relationships.py | 250 ++++++++++++++++---------- tests/test_registration_components.py | 24 +++ tests/test_relationships.py | 93 ++++++++-- 4 files changed, 258 insertions(+), 117 deletions(-) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 90613f2fca..d531d2b477 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -36,6 +36,7 @@ JsonShapeFamily, JsonShapeMember, NullMember, + PresentMember, PresenceExclude, RecordMember, SubclassUnion, @@ -720,7 +721,12 @@ def activity_detail(self) -> str: @dataclass(frozen=True, slots=True) -class ThreadView(ThreadRecord): +class NoThreadView(NullMember): + """A named navigation target has no captured thread presentation.""" + + +@dataclass(frozen=True, slots=True) +class ThreadView(ThreadRecord, PresentMember): activity: Annotated[ObservedActivity, PresenceExclude()] runtime: Annotated[RuntimeInfo, PresenceExclude()] last_seen: float diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index c543d7a2a8..25f8827f05 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -9,17 +9,18 @@ import time from abc import ABC, abstractmethod -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field, fields, replace from functools import cmp_to_key from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING, Annotated, ClassVar, Literal from agent_comms.field_codec import GoalId from agent_comms import field_codec from . import presentation -from .channel_targets import CommandTarget +from .channel_targets import CommandTarget, NamedTarget from .command import Command from .declared_family import DeclaredFamily from .display_order import CreatedThreadSort, LastActivityThreadSort, ThreadSort @@ -100,10 +101,11 @@ def incident(self, thread: Thread) -> bool: def entry_goal_contacts(self) -> tuple[GoalDerivedContact, ...]: return () - def entry(self, identity: ThreadIncarnation, person: presentation.ThreadView | None, + def entry(self, identity: ThreadIncarnation, + person: presentation.ThreadView | presentation.NoThreadView, available: bool, current: RelationshipEntry | None) -> RelationshipEntry: return RelationshipEntry( - identity.name, "thread", person if available else None, + identity.name, "thread", person if available else presentation.NoThreadView(), detail=f"{current.detail}\n{self.note}" if current and current.detail else self.note, available=available, sources=(*current.sources, self.source) if current else (self.source,), @@ -242,7 +244,9 @@ class GoalMentionDiagnostic: class RelationshipEntry: target: str kind: str - person: presentation.ThreadView | None = None + person: presentation.ThreadView | presentation.NoThreadView = field( + default_factory=presentation.NoThreadView, + ) sequence: int = 0 timestamp: float = 0 detail: str = "" @@ -258,10 +262,11 @@ def revalidates(self, earlier: RelationshipEntry) -> bool: """A fresh navigation target must retain its identity and incarnation.""" if not self.available or self.identity != earlier.identity: return False - if earlier.person is None: - return True - return (self.person is not None - and self.person.thread.incarnation == earlier.person.thread.incarnation) + return all( + any(person.thread.incarnation == previous.thread.incarnation + for person in self.person.present()) + for previous in earlier.person.present() + ) @dataclass(frozen=True, slots=True) @@ -276,11 +281,16 @@ class RelationshipGroup(DeclaredFamily, affix="RelationshipGroup"): @classmethod @abstractmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: ... + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: ... @classmethod - def build(cls, context: RelationshipContext) -> RelationshipGroup: - return cls.from_entries({entry.target: entry for entry in cls.supply(context)}, context) + def build(cls, context: RelationshipContext, + sources: tuple[RelationshipEntrySource, ...]) -> RelationshipGroup: + entries: dict[str, RelationshipEntry] = {} + for source in sources: + entry = source.entry(context) + entries[entry.target] = entry + return cls.from_entries(entries, context) @classmethod def from_entries(cls, entries: dict[str, RelationshipEntry], @@ -288,26 +298,18 @@ def from_entries(cls, entries: dict[str, RelationshipEntry], return cls(entries) @classmethod - def capture(cls, context: RelationshipContext, views: HistoryViews) -> tuple[RelationshipGroup, ...]: - """Select declared entries first; acquire their views once on this identity cut.""" - selected = tuple(group.build(context) for group in cls.members_with(cls)) - names = dict.fromkeys(entry.target for group in selected for entry in group.entries.values()) - context.people.update((view.thread.name, view) for view in presentation.ThreadView.roster( - context.registry, views.agents, views.goals, RosterVisibility(archived=True), names=names, - )) - return tuple(group.presented(context) for group in selected) - - @staticmethod - def present_entry(entry: RelationshipEntry, context: RelationshipContext) -> RelationshipEntry: - return replace(entry, person=context.people.get(entry.target)) - - def ordered_entries(self, entries: dict[str, RelationshipEntry], - context: RelationshipContext) -> dict[str, RelationshipEntry]: - return entries - - def presented(self, context: RelationshipContext) -> RelationshipGroup: - entries = {name: self.present_entry(entry, context) for name, entry in self.entries.items()} - return replace(self, entries=self.ordered_entries(entries, context)) + def capture(cls, inputs: RelationshipInputs, views: HistoryViews) -> tuple[RelationshipGroup, ...]: + """Retain original inputs once, then construct complete results on one roster cut.""" + selected = tuple((group, group.sources(inputs)) for group in cls.members_with(cls)) + names = dict.fromkeys(source.name for _, sources in selected for source in sources) + people = MappingProxyType({view.thread.name: view for view in presentation.ThreadView.roster( + inputs.registry, views.agents, views.goals, RosterVisibility(archived=True), names=names, + )}) + context = RelationshipContext( + **{item.name: getattr(inputs, item.name) for item in fields(RelationshipInputs)}, + people=people, + ) + return tuple(group.build(context, sources) for group, sources in selected) def selectable(self) -> tuple[SortableRelationshipGroup, ...]: return () @@ -315,21 +317,18 @@ def selectable(self) -> tuple[SortableRelationshipGroup, ...]: class RecentRelationshipGroup(RelationshipGroup): @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: - entries = {} - for message in reversed(context.messages): + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + selected: dict[str, RecentEntrySource] = {} + for message in reversed(inputs.messages): if not message.role.counts_as_conversation(): continue - for target in cls.targets(context, message): - entries.setdefault(target, context.entry( - target, message=message, - detail=f"{message.sender} → {message.target}\n{message.body[:240]}", - )) - return tuple(entries.values()) + for target in cls.targets(inputs, message): + selected.setdefault(target.name, RecentEntrySource(target, message)) + return tuple(selected.values()) @staticmethod @abstractmethod - def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: ... + def targets(inputs: RelationshipInputs, message: MessageInput) -> tuple[NamedTarget, ...]: ... class InboundRelationshipGroup(RecentRelationshipGroup): @@ -337,11 +336,12 @@ class InboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent inbound" @staticmethod - def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: - sender = context.registry.canonical_name(message.sender) - if sender == context.thread.name or not context.delivery.delivers(message.sender, message.target): + def targets(inputs: RelationshipInputs, message: MessageInput) -> tuple[NamedTarget, ...]: + sender = inputs.registry.canonical_name(message.sender) + if sender == inputs.thread.name or not inputs.delivery.delivers(message.sender, message.target): return () - return CommandTarget.named(message.target).inbound_relations(sender) + return tuple(RelationshipEntrySource.canonical_target(name, inputs.registry) + for name in CommandTarget.named(message.target).inbound_relations(sender)) class OutboundRelationshipGroup(RecentRelationshipGroup): @@ -349,10 +349,10 @@ class OutboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent outbound" @staticmethod - def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: - if context.registry.canonical_name(message.sender) != context.thread.name: + def targets(inputs: RelationshipInputs, message: MessageInput) -> tuple[NamedTarget, ...]: + if inputs.registry.canonical_name(message.sender) != inputs.thread.name: return () - return (CommandTarget.named(message.target).canonical_name(context.registry.canonical_name),) + return (RelationshipEntrySource.canonical_target(message.target, inputs.registry),) class ParentRelationshipGroup(RelationshipGroup): @@ -360,8 +360,9 @@ class ParentRelationshipGroup(RelationshipGroup): empty_text = "Not forked" @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: - return tuple(context.entry(name) for name in context.thread.parent.names()) + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + return tuple(NamedEntrySource(RelationshipEntrySource.canonical_target(name, inputs.registry)) + for name in inputs.thread.parent.names()) @dataclass(frozen=True, slots=True) @@ -372,26 +373,24 @@ class SortableRelationshipGroup(RelationshipGroup): default_order: ClassVar[type[ThreadSort]] @classmethod - def selected_order(cls, context: RelationshipContext) -> type[ThreadSort]: - return context.orders.get(cls, cls.default_order) + def selected_order(cls, orders: Mapping[type[SortableRelationshipGroup], type[ThreadSort]]) -> type[ThreadSort]: + return orders.get(cls, cls.default_order) @classmethod def from_entries(cls, entries: dict[str, RelationshipEntry], context: RelationshipContext) -> SortableRelationshipGroup: - return cls(entries, cls.selected_order(context)) + order = cls.selected_order(context.orders) - def ordered_entries(self, entries: dict[str, RelationshipEntry], - context: RelationshipContext) -> dict[str, RelationshipEntry]: def key(item: RelationshipEntry) -> tuple[float, float, str]: - person = item.person - return self.order.key( + people = item.person.present() + return order.key( item.target, - person.thread.created_at if person else 0, - person.activity.timestamp if person else 0, - context.sent.get(item.target, 0) if person else 0, + sum(person.thread.birth for person in people), + sum(person.activity.timestamp for person in people), + sum(context.sent.get(item.target, 0) for _ in people), ) - return {entry.target: entry for entry in sorted(entries.values(), key=key)} + return cls({entry.target: entry for entry in sorted(entries.values(), key=key)}, order) def selectable(self) -> tuple[SortableRelationshipGroup, ...]: return (self,) @@ -411,10 +410,10 @@ class ChildrenRelationshipGroup(SortableRelationshipGroup): default_order = CreatedThreadSort @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: return tuple( - context.entry(child.name) - for child in context.registry.children(context.thread.name) + NamedEntrySource(RelationshipEntrySource.canonical_target(child.name, inputs.registry)) + for child in inputs.registry.children(inputs.thread.name) ) @@ -424,46 +423,102 @@ class CollaboratingRelationshipGroup(SortableRelationshipGroup): default_order = LastActivityThreadSort @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: - entries: dict[ThreadIncarnation, RelationshipEntry] = {} - for relation in (*context.edges, *context.goal_contacts): - identity = relation.counterpart(context.thread) - if identity is None: - continue - entries[identity] = relation.entry( - identity, None, identity.current(context.registry), entries.get(identity), - ) - return tuple(entries.values()) - - @staticmethod - def present_entry(entry: RelationshipEntry, context: RelationshipContext) -> RelationshipEntry: - person = context.people.get(entry.target) - available = entry.available and person is not None - return replace(entry, person=person if available else None, available=available) + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + selected: dict[ThreadIncarnation, list[MutualThreadRelation]] = {} + for relation in (*inputs.edges, *inputs.goal_contacts): + identity = relation.counterpart(inputs.thread) + if identity is not None: + selected.setdefault(identity, []).append(relation) + return tuple(MutualEntrySource(identity, tuple(relations)) + for identity, relations in selected.items()) @dataclass(frozen=True, slots=True) -class RelationshipContext: +class RelationshipInputs: + """One captured selection basis, before acquiring any person presentation.""" + registry: RegistrySnapshot thread: Thread - people: dict[str, presentation.ThreadView] messages: tuple[MessageInput, ...] delivery: DeliveryScope edges: tuple[Collaboration, ...] goal_contacts: tuple[GoalDerivedContact, ...] - orders: dict[type[SortableRelationshipGroup], type[ThreadSort]] - sent: dict[str, float] + orders: Mapping[type[SortableRelationshipGroup], type[ThreadSort]] + sent: Mapping[str, float] + + +@dataclass(frozen=True, slots=True) +class RelationshipContext(RelationshipInputs): + people: Mapping[str, presentation.ThreadView] - def entry(self, name: str, *, message: MessageInput | None = None, + def entry(self, target: NamedTarget, *, message: MessageInput | None = None, detail: str = "") -> RelationshipEntry: - target = CommandTarget.named(name) - name = target.canonical_name(self.registry.canonical_name) return RelationshipEntry( - name, target.kind, self.people.get(name), + target.name, target.kind, self.people.get(target.name, presentation.NoThreadView()), message.seq if message else 0, message.timestamp if message else 0, detail, ) +class RelationshipEntrySource(ABC): + """An original group input, never an unfinished public entry or a wire record.""" + + __slots__ = () + + @property + @abstractmethod + def name(self) -> str: ... + + @abstractmethod + def entry(self, context: RelationshipContext) -> RelationshipEntry: ... + + @staticmethod + def canonical_target(name: str, registry: RegistrySnapshot) -> NamedTarget: + target = CommandTarget.named(name) + return replace(target, name=target.selected_name(registry)) + + +@dataclass(frozen=True, slots=True) +class NamedEntrySource(RelationshipEntrySource): + target: NamedTarget + + @property + def name(self) -> str: + return self.target.name + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + return context.entry(self.target) + + +@dataclass(frozen=True, slots=True) +class RecentEntrySource(NamedEntrySource): + message: MessageInput + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + return context.entry( + self.target, message=self.message, + detail=f"{self.message.sender} → {self.message.target}\n{self.message.body[:240]}", + ) + + +@dataclass(frozen=True, slots=True) +class MutualEntrySource(RelationshipEntrySource): + identity: ThreadIncarnation + relations: tuple[MutualThreadRelation, ...] + + @property + def name(self) -> str: + return self.identity.name + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + person = context.people.get(self.name, presentation.NoThreadView()) + available = any(view.thread.incarnation == self.identity for view in person.present()) + first, *remaining = self.relations + entry = first.entry(self.identity, person, available, None) + for relation in remaining: + entry = relation.entry(self.identity, person, available, entry) + return entry + + @dataclass(frozen=True, slots=True) class ThreadCommsSnapshot: owner: str @@ -892,20 +947,21 @@ def snapshot(self, owner: str) -> ThreadCommsSnapshot: delivery = DeliveryScope.capture(thread.name, registry, self.views.channels.catalog.read()) messages, limited = self._recent_messages() - context = RelationshipContext( - registry, thread, {}, messages, delivery, edges, goal_contacts, - {row.group: row.order for row in state.orders if row.incarnation == thread.incarnation}, - {}, - ) + orders = {row.group: row.order for row in state.orders if row.incarnation == thread.incarnation} + sent: dict[str, float] = {} for order in { - group.selected_order(context) + group.selected_order(orders) for group in SortableRelationshipGroup.members_with(SortableRelationshipGroup) }: - context.sent.update(order.sent_timestamps(self.views, registry=registry)) + sent.update(order.sent_timestamps(self.views, registry=registry)) + inputs = RelationshipInputs( + registry, thread, messages, delivery, edges, goal_contacts, + MappingProxyType(orders), MappingProxyType(sent), + ) return ThreadCommsSnapshot( thread.name, str(self.root.resolve()), - RelationshipGroup.capture(context, self.views), + RelationshipGroup.capture(inputs, self.views), limited, len(messages), unresolved_goal_mentions=goal_diagnostics, diff --git a/tests/test_registration_components.py b/tests/test_registration_components.py index fd3019238d..ae11d24799 100644 --- a/tests/test_registration_components.py +++ b/tests/test_registration_components.py @@ -176,3 +176,27 @@ def test_thread_rows_compare_unread_rows_as_stored_and_decode_only_what_changed( other['unread'] = written['unread'] assert written == other assert written != ThreadRows.from_wire({}) + + +def test_birth_projection_agrees_for_full_thread_and_stored_float_records(tmp_path): + from agent_comms.thread_identity import ThreadIncarnationRecord + + thread = Thread("owner", frozenset(), Checkout(tmp_path)) + stored = ThreadIncarnationRecord(thread.name, thread.created_at.to_wire()) + assert thread.birth == stored.birth + assert thread.incarnation == stored.incarnation + assert FieldCodec.decode(type(stored), FieldCodec.encode(stored)) == stored + + +def test_full_thread_read_keeps_original_raw_wire_until_an_actual_assignment(tmp_path): + from agent_comms.registry_document import ThreadRows + + thread = Thread("owner", frozenset({"kept"}), Checkout(tmp_path)) + rows = ThreadRows.from_wire({thread.name: FieldCodec.encode_fields(thread)}) + tags_key = FieldCodec.field_plan(Thread, "tags").key + kept_tags = rows.to_wire()[thread.name][tags_key] + loaded = rows[thread.name] + assert loaded == thread + assert rows.to_wire()[thread.name][tags_key] is kept_tags + rows[thread.name] = replace(loaded, tags=frozenset({"assigned"})) + assert rows.to_wire()[thread.name][tags_key] == ["assigned"] diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 032c2d840f..22da3146d9 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -4,16 +4,19 @@ import json from concurrent.futures import ProcessPoolExecutor from dataclasses import replace +from pathlib import Path +from types import MappingProxyType import pytest from agent_comms.registry_document import InvalidRegistryDocument, RosterVisibility, ThreadRows from agent_comms.relationships import ( ChildrenRelationshipGroup, CollaboratingRelationshipGroup, InboundRelationshipGroup, - OutboundRelationshipGroup, ParentRelationshipGroup, RelationshipContext, RelationshipEdit, - RelationshipGroup, SortableRelationshipGroup, + NamedEntrySource, OutboundRelationshipGroup, ParentRelationshipGroup, + RelationshipContext, RelationshipEdit, RelationshipEntry, RelationshipEntrySource, + RelationshipGroup, RelationshipInputs, SortableRelationshipGroup, ) -from agent_comms.comms import wire +from agent_comms.comms import Comms, wire from conftest import installed_comms from agent_comms.display_order import LastActivityThreadSort, ThreadSort from agent_comms.field_codec import FieldCodec @@ -42,7 +45,7 @@ def change_shared_peer(root, instruction): wire(root).relationships.edit(owner, RelationshipEdit.member_named(action), other, "Race") -def setup_wire(tmp_path): +def setup_wire(tmp_path: Path) -> Comms: comms = installed_comms(tmp_path / "wire") for name, parent in (("origin", NoParent()), ("owner", ForkedParent("origin")), ("peer", NoParent()), ("child", ForkedParent("owner"))): @@ -154,7 +157,9 @@ def test_recent_relationships_keep_the_global_cut_and_exact_preview(tmp_path): @pytest.mark.parametrize("order", ThreadSort.members_with(ThreadSort)) -def test_group_selection_hydrates_only_selected_threads_on_its_captured_cut(tmp_path, order): +def test_group_selection_hydrates_only_selected_threads_on_its_captured_cut( + tmp_path: Path, order: type[ThreadSort], +): comms = setup_wire(tmp_path) comms.registry.declare(Thread("unselected", frozenset(), Checkout(tmp_path))) comms.relationships.edit("owner", RelationshipEdit.member_named("add"), "peer", "Real contact") @@ -164,20 +169,25 @@ def test_group_selection_hydrates_only_selected_threads_on_its_captured_cut(tmp_ registry = replace(captured, threads=ThreadRows.from_wire(raw)) thread = registry.require("owner") contacts, _ = comms.relationships._goal_contacts(registry, thread) - context = RelationshipContext( - registry, thread, {}, (), + orders = {group: order for group in SortableRelationshipGroup.members_with(SortableRelationshipGroup)} + sent = order.sent_timestamps(comms.views, registry=registry) + inputs = RelationshipInputs( + registry, thread, (), DeliveryScope.capture(thread.name, registry, comms.views.channels.catalog.read()), comms.relationships.store.read().resolved(registry).collaborations, contacts, - {group: order for group in SortableRelationshipGroup.members_with(SortableRelationshipGroup)}, - order.sent_timestamps(comms.views, registry=registry), + MappingProxyType(orders), MappingProxyType(sent), ) - groups = {type(group): group for group in RelationshipGroup.capture(context, comms.views)} + original_orders, original_sent = dict(orders), dict(sent) + groups = {type(group): group for group in RelationshipGroup.capture(inputs, comms.views)} for group_type, target in ((ParentRelationshipGroup, "origin"), (ChildrenRelationshipGroup, "child"), (CollaboratingRelationshipGroup, "peer")): entry = groups[group_type].entries[target] - assert entry.available and entry.person.thread is registry.require(target) - assert set(context.people) == {"origin", "child", "peer"} + assert entry.available and entry.person.require_present().thread is registry.require(target) + selected = {person.thread.name for group in groups.values() + for entry in group.entries.values() for person in entry.person.present()} + assert selected == {"origin", "child", "peer"} + assert inputs.orders == original_orders and inputs.sent == original_sent assert all(selected.order is order for group in groups.values() for selected in group.selectable()) with pytest.raises(InvalidRegistryDocument): registry.require("unselected") @@ -244,7 +254,9 @@ def test_deleted_peer_survives_unrelated_edit_and_explicit_remove(tmp_path): missing = collaboration_rows(comms)[0] assert missing.target == "peer", "Deleted identity must remain copyable" - assert missing.available is False and missing.person is None + assert missing.available is False and missing.person.present() == () + snapshot = comms.relationships.snapshot("owner") + assert FieldCodec.decode(type(snapshot), FieldCodec.encode(snapshot)) == snapshot assert missing.detail == original.note assert comms.relationships.collaborations("owner") == (original,) @@ -289,7 +301,7 @@ def test_reused_peer_name_does_not_rebind_or_overwrite_historical_work(tmp_path) ) row = collaboration_rows(comms)[0] - assert row.target == "peer" and row.person is None and not row.available + assert row.target == "peer" and row.person.present() == () and not row.available for action in ("add", "update"): with pytest.raises(ValueError): comms.relationships.edit( @@ -305,7 +317,7 @@ def test_reused_peer_name_does_not_rebind_or_overwrite_historical_work(tmp_path) ) assert new.peer_created != original.peer_created row = collaboration_rows(comms)[0] - assert row.available and row.person.thread.created_at == new.peer_created + assert row.available and row.person.require_present().thread.birth == new.peer_created assert collaboration_rows(comms, "peer")[0].target == "owner" @@ -363,7 +375,7 @@ def test_retained_alias_contact_without_projected_person_is_unavailable(tmp_path visible = collaboration_rows(comms)[0] assert ( visible.available - and visible.person.thread.incarnation == comms.registry.require("renamed-peer").incarnation + and visible.person.require_present().thread.incarnation == comms.registry.require("renamed-peer").incarnation ) assert visible.target == "renamed-peer" assert visible.sources == ("explicit", "goal_mention") @@ -380,7 +392,7 @@ def test_retained_alias_contact_without_projected_person_is_unavailable(tmp_path unavailable = collaboration_rows(comms)[0] assert unavailable.target == "renamed-peer" assert unavailable.sources == ("explicit", "goal_mention") - assert not unavailable.available and unavailable.person is None + assert not unavailable.available and unavailable.person.present() == () assert "Retained explicit contact" in unavailable.detail @@ -411,8 +423,9 @@ class SortedInboundRelationshipGroup(SortableRelationshipGroup, InboundRelations assert selected.current_entry( SortedInboundRelationshipGroup, replace(earlier, kind="channel") ) is None - reused = replace(earlier.person, thread=replace( - earlier.person.thread, created_at=earlier.person.thread.created_at + 1 + person = earlier.person.require_present() + reused = replace(person, thread=replace( + person.thread, created_at=person.thread.created_at + 1 )) assert selected.current_entry( SortedInboundRelationshipGroup, replace(earlier, person=reused) @@ -429,3 +442,45 @@ class SortedInboundRelationshipGroup(SortableRelationshipGroup, InboundRelations assert FieldCodec.decode(type(reopened), FieldCodec.encode(reopened)) == reopened finally: RelationshipGroup.__registry__.pop(SortedInboundRelationshipGroup.declared_name) + + +def test_new_group_retains_its_one_original_source_through_complete_presentation(tmp_path: Path): + comms = setup_wire(tmp_path) + selections: list[RelationshipInputs] = [] + originals: list[NamedEntrySource] = [] + built: list[NamedEntrySource] = [] + + class CapturedEntrySource(NamedEntrySource): + __slots__ = () + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + built.append(self) + assert context.registry is selections[0].registry + assert context.people[self.name].thread is context.registry.require(self.name) + with pytest.raises(TypeError): + context.people["unexpected"] = context.people[self.name] + return super().entry(context) + + class CapturedRelationshipGroup(RelationshipGroup): + title = "One original source" + + @classmethod + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + selections.append(inputs) + assert len(selections) == 1 + source = CapturedEntrySource( + CapturedEntrySource.canonical_target("peer", inputs.registry), + ) + originals.append(source) + return (source,) + + try: + snapshot = comms.relationships.snapshot("owner") + group = snapshot.groups_by_type[CapturedRelationshipGroup] + assert len(selections) == len(originals) == len(built) == 1 + assert built[0] is originals[0] + entry = group.entries["peer"] + assert entry.person.require_present().thread is selections[0].registry.require("peer") + assert entry.available and entry.target == originals[0].name + finally: + RelationshipGroup.__registry__.pop(CapturedRelationshipGroup.declared_name) From 6a8a18d19c2448cd83bc4ba44154f3dd68092606 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 16:40:00 -0400 Subject: [PATCH 05/24] Require the already supplied person at relationship entry construction --- src/agent_comms/relationships.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 25f8827f05..1951ee10c1 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -244,9 +244,7 @@ class GoalMentionDiagnostic: class RelationshipEntry: target: str kind: str - person: presentation.ThreadView | presentation.NoThreadView = field( - default_factory=presentation.NoThreadView, - ) + person: presentation.ThreadView | presentation.NoThreadView sequence: int = 0 timestamp: float = 0 detail: str = "" From d9b4c5ed112b965223a34759f9639dd7554157b4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:16:41 -0400 Subject: [PATCH 06/24] Acquire selected lookup threads through the captured row owner --- src/agent_comms/registry_document.py | 31 +++++++++- tests/test_recipient_activity_index.py | 39 +++++++++++- tests/test_sidebar_observation.py | 82 +++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 37d4999f69..5d7e7b9711 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -25,6 +25,7 @@ MalformedRecord, FieldPlan, SlicePolicy, + ThreadLookup, WireOmitDefault, WireRequired, WireTransient, @@ -495,6 +496,31 @@ def __delitem__(self, name: str) -> None: raise TypeError("snapshot thread rows are read-only") +@dataclass(frozen=True, slots=True, eq=False) +class ThreadLookupIndex(Mapping[ThreadLookup, Thread]): + """A captured cut's lookup keys; complete values still belong to its thread rows.""" + + rows: ThreadRows + _names: Mapping[ThreadLookup, str] = field(init=False, repr=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "_names", MappingProxyType({ + self.rows.incarnation(name).require_recorded().lookup: name for name in self.rows + })) + + def __getitem__(self, lookup: ThreadLookup) -> Thread: + return self.rows[self._names[lookup]] + + def __contains__(self, lookup: object) -> bool: + return lookup in self._names + + def __iter__(self) -> Iterator[ThreadLookup]: + return iter(self._names) + + def __len__(self) -> int: + return len(self._names) + + class ThreadRowsSlice(OwnRow[ThreadRows]): @classmethod def copy(cls, value: ThreadRows) -> ThreadRows: @@ -1123,9 +1149,8 @@ def __post_init__(self) -> None: self.threads.require_distinct_births() @cached_property - def by_lookup(self) -> Mapping[str, Thread]: - return MappingProxyType({thread.incarnation.require_recorded().lookup: thread - for thread in self.threads.values()}) + def by_lookup(self) -> Mapping[ThreadLookup, Thread]: + return ThreadLookupIndex(self.threads) def owner_generation(self, name: str) -> int: return self.owner_generations[self.canonical_name(name)] diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 6d35c16edb..73c6bee280 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -11,13 +11,16 @@ from agent_comms.activity import Activity, ActivityState, ActivityStore from agent_comms.audience_manifest import FrozenRecipient from agent_comms.comms import wire -from agent_comms.errors import RelationViolationError +from agent_comms.errors import RelationViolationError, UnregisteredThreadError +from agent_comms.field_codec import ThreadLookup from agent_comms.notification_assignment import NotificationAssignment from agent_comms.presentation import AllOutcomes, MessageNotification, OwnerOutcomes from agent_comms.messages import InfoKind, Message from agent_comms.read_ledger import ReadDocument, ReadLedger from agent_comms.read_basis import ConversationCursor, ReadCursor -from agent_comms.registry_provenance import RegistryProvenance +from agent_comms.registry_provenance import ( + RegisteredRecipient, RegistryProvenance, RetiredRecipient, +) from agent_comms.registry_document import RegistrySnapshot from agent_comms.thread_identity import ( UnrecordedThreadIncarnation, @@ -65,6 +68,38 @@ def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_own assert not hasattr(unrecorded.threads["second"].incarnation, "lookup") +def test_captured_lookup_keeps_original_recipient_identity_and_retirement(tmp_path): + comms = canonical_goal_wire(tmp_path / "bus") + for name in ("sender", "peer", "other"): + comms.registry.declare(Thread(name, frozenset(), Checkout(tmp_path))) + sent = comms.messaging.send("sender", "peer", "retained recipient").message + original = comms.bus.bus.delivery(sent.seq) + captured = comms.registry.snapshot() + peer = captured.require("peer") + recipient = FrozenRecipient(peer.incarnation.lookup, peer.name) + assert recipient in original.recipients + assert captured.recipient(recipient, original) == RegisteredRecipient(peer) + with pytest.raises(RelationViolationError): + captured.recipient(FrozenRecipient(recipient.recipient_lookup, "other"), original) + foreign = FrozenRecipient(ThreadLookup.mint(), peer.name) + with pytest.raises(UnregisteredThreadError): + captured.recipient(foreign, original) + + comms.registry.rename(peer.name, "renamed") + renamed = comms.registry.snapshot() + assert renamed.recipient(recipient, original) == RegisteredRecipient(renamed.require("renamed")) + comms.threads.delete("renamed") + deleted = comms.registry.snapshot() + assert deleted.recipient(recipient, original) == RetiredRecipient(recipient.recipient_lookup) + with pytest.raises(UnregisteredThreadError): + deleted.recipient(foreign, original) + recreated = comms.registry.declare(Thread("renamed", frozenset(), Checkout(tmp_path))) + current = comms.registry.snapshot() + assert recreated.incarnation.lookup != recipient.recipient_lookup + assert current.recipient(recipient, original) == RetiredRecipient(recipient.recipient_lookup) + assert captured.recipient(recipient, original) == RegisteredRecipient(peer) + + def test_activity_store_selects_only_requested_threads(tmp_path): store = ActivityStore(tmp_path) for index in range(10): diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index 3b26d1667c..a0009168d0 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -4,13 +4,15 @@ import pickle from collections.abc import Mapping from dataclasses import fields, replace +from pathlib import Path import pytest from agent_comms.registry_document import InvalidRegistryDocument, ThreadRows -from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.errors import RelationViolationError, UnregisteredThreadError +from agent_comms.field_codec import FieldCodec, MalformedRecord, ThreadLookup +from agent_comms.session_file import BusSessionLocation from agent_comms.display_references import CurrentDisplayReferences, HistoricalDisplayReference from agent_comms.historical_views import HistorySource from agent_comms.registry_document import HistoricalRootPurpose @@ -58,10 +60,20 @@ def test_registry_snapshot_constructor_and_pickle_own_immutable_maps(comms, decl supplied = dict(captured.threads) snapshot = replace(captured, threads=ThreadRows.from_threads(supplied)) supplied.clear() + assert snapshot.by_lookup # Derived cached views must not enter the pickle. for restored in (snapshot, pickle.loads(pickle.dumps(snapshot, protocol=5))): assert restored.purpose == captured.purpose assert restored.threads[original.name] == original - assert restored.by_lookup[original.incarnation.require_recorded().lookup] == original + lookup = original.incarnation.require_recorded().lookup + assert restored.by_lookup[lookup] == original + assert dict(restored.by_lookup) == {lookup: original} + assert list(restored.by_lookup.items()) == [(lookup, original)] + assert list(restored.by_lookup.values()) == [original] + assert restored.by_lookup == {lookup: original} + with pytest.raises(TypeError): + restored.by_lookup[lookup] = original + with pytest.raises(TypeError): + del restored.by_lookup[lookup] for declaration in fields(restored): value = getattr(restored, declaration.name) if isinstance(value, Mapping): @@ -92,7 +104,12 @@ def test_snapshot_constructor_retains_namespace_validation_and_derives_birth_ind def test_current_reference_resolves_original_birth_after_registry_rename(comms, declare_reference): original = declare_reference(status=RunningThreadStatus()) + captured = comms.registry.snapshot() + before = captured.by_lookup + lookup = original.incarnation.lookup comms.registry.rename(original.name, "renamed") + assert comms.registry.snapshot().by_lookup[lookup].name == "renamed" + assert before[lookup].name == original.name snapshot = comms.views.viewer_snapshot(Checkout(comms.root.parent)) references = pickle.loads(pickle.dumps(snapshot, protocol=5)).references current = references.thread(original.incarnation) @@ -104,9 +121,18 @@ def test_current_reference_resolves_original_birth_after_registry_rename(comms, def test_recreated_thread_reference_resolves_new_birth_not_same_name(comms, declare_reference): original = declare_reference() + captured = comms.registry.snapshot().by_lookup before = comms.views.viewer_snapshot(Checkout(comms.root.parent)).references comms.threads.delete(original.name) + deleted = comms.registry.snapshot().by_lookup recreated = declare_reference(original.name) + current = comms.registry.snapshot().by_lookup + assert original.incarnation.lookup not in deleted + assert original.incarnation.lookup not in current + assert current[recreated.incarnation.lookup] == recreated + assert captured[original.incarnation.lookup] == original + with pytest.raises(KeyError): + current[original.incarnation.lookup] snapshot = comms.views.viewer_snapshot(Checkout(comms.root.parent)) references = pickle.loads(pickle.dumps(snapshot, protocol=5)).references assert recreated.incarnation != original.incarnation @@ -127,6 +153,56 @@ def test_current_reference_membership_and_lookup_share_the_retained_registry(com restored.targets["unowned"] = reference +@pytest.mark.parametrize("decoded", (False, True)) +def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( + comms, declare_reference, decoded, +): + originals = tuple(declare_reference(f"lookup-{number}") for number in range(12)) + selected = replace( + originals[0], session_file=BusSessionLocation(Path("lookup-selected.jsonl")), + ) + invalid = originals[-1] + captured = comms.registry.snapshot() + raw = captured.threads.to_wire() + raw[selected.name] = FieldCodec.encode_fields(selected) + raw[invalid.name][FieldCodec.field_plan(Thread, "model").key] = 123 + rows = ThreadRows.from_wire(raw) + rows.bind(comms.registry.store._bind_thread) + if decoded: + rows[selected.name] = selected # A genuine write removes this row from _raw. + snapshot = replace(captured, threads=rows) + decoded_before = frozenset(snapshot.threads._decoded) + expected = {thread.name: thread.incarnation.lookup for thread in originals} + + lookup = snapshot.by_lookup + assert lookup + assert len(lookup) == len(originals) + assert tuple(lookup) == tuple(expected[name] for name in snapshot.threads) + assert set(lookup.keys()) == set(expected.values()) + assert all(key in lookup and key in lookup.keys() for key in expected.values()) + missing = ThreadLookup.mint() + assert missing not in lookup and missing not in lookup.keys() + assert lookup.get(missing) is None + with pytest.raises(KeyError) as caught: + lookup[missing] + assert caught.value.args == (missing,) + assert frozenset(snapshot.threads._decoded) == decoded_before + + acquired = lookup[selected.incarnation.lookup] + assert acquired is snapshot.require(selected.name) + assert acquired.session_file == selected.session_file + assert acquired.session_file.bound_to(comms.root) is acquired.session_file + assert lookup.get(selected.incarnation.lookup) is acquired + assert lookup[selected.incarnation.lookup] is acquired + assert frozenset(snapshot.threads._decoded) == decoded_before | {selected.name} + with pytest.raises(InvalidRegistryDocument): + lookup[invalid.incarnation.lookup] + with pytest.raises(InvalidRegistryDocument): + dict(lookup) + with pytest.raises(UnregisteredThreadError): + comms.registry.require_lookup(missing) + + @pytest.mark.parametrize("decoded", (False, True)) def test_current_reference_acquires_only_captured_incarnations(comms, declare_reference, decoded): original = declare_reference() From ae91096e44dc8e76f6c431665ba977ac33091608 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:56:11 -0400 Subject: [PATCH 07/24] Repair nominal acquisition interfaces and real fixture preconditions --- src/agent_comms/field_codec.py | 8 +++--- src/agent_comms/registry_document.py | 17 ++++++----- src/agent_comms/relationships.py | 39 +++++++++++++------------- src/agent_comms/thread_identity.py | 5 ---- tests/test_mutual_relation_identity.py | 6 ++-- tests/test_recipient_activity_index.py | 1 + tests/test_registration_components.py | 28 ++++++++++++++---- tests/test_relationships.py | 2 +- tests/test_sidebar_observation.py | 2 +- 9 files changed, 61 insertions(+), 47 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 4859d4567e..3cb7be0359 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -649,8 +649,8 @@ def contribute(self, fields): def wire_keys(self) -> tuple[str, ...]: return (self.key,) - def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: - return {key or self.key: value} + def encoded(self, value: JsonValue, *, key: str) -> dict[str, JsonValue]: + return {key: value} def schema_fields(self): annotation, declared = self.annotation, self.declared @@ -780,7 +780,7 @@ def contribute(self, fields: list[FieldPlan]) -> tuple[str, ...]: def wire_keys(self) -> tuple[str, ...]: return () - def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: + def encoded(self, value: JsonValue, *, key: str) -> dict[str, JsonValue]: return {} def decoded(self, data: object) -> dict[str, object]: @@ -841,7 +841,7 @@ def contribute(self, fields): def wire_keys(self) -> tuple[str, ...]: return tuple(entry.key for entry in self.record.fields) - def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: + def encoded(self, value: JsonValue, *, key: str) -> dict[str, JsonValue]: if not isinstance(value, dict): raise MalformedRecord("An inline record must encode a JSON object.") return value diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 5d7e7b9711..d4e83e01dd 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -331,7 +331,7 @@ def from_wire(cls, raw: object) -> Self: def to_wire(self) -> dict[str, JsonValue]: return dict(self.raw) - def __reduce__(self): + def __reduce__(self) -> tuple[Callable[[object], Self], tuple[dict[str, JsonValue]]]: return type(self).from_wire, (self.to_wire(),) def decoded(self) -> Thread: @@ -378,16 +378,19 @@ def children(self, parent: str) -> tuple[Thread, ...]: else self._raw[name].raw.get(key) == expected) ) + def determining(self, name: str) -> GoalOwner: + """The current nominal determining row, without acquiring a complete declaration.""" + return self._decoded[name] if name in self._decoded else self._raw[name] + def creation_time(self, name: str) -> CreationTime: - return ChainMap(self._decoded, self._raw)[name].created_at + return self.determining(name).created_at def incarnation(self, name: str) -> ThreadIncarnation: - return ChainMap(self._decoded, self._raw)[name].incarnation + return self.determining(name).incarnation def active_goal_owners(self) -> Iterator[Thread]: """Select through the shared goal/role declarations before full thread acquisition.""" - return (self[name] for name, row in ChainMap(self._decoded, self._raw).items() - for _ in row.active_goals()) + return (self[name] for name in self for _ in self.determining(name).active_goals()) def __getitem__(self, name: str) -> Thread: if name not in self._decoded: @@ -424,8 +427,8 @@ def __delitem__(self, name: str) -> None: def to_wire(self) -> dict[str, JsonValue]: # A read memo is not an edit: __setitem__ removes raw only for actual writes. - return {name: FieldCodec.encode(row) - for name, row in ChainMap(self._raw, self._decoded).items()} + return {name: FieldCodec.encode(self._raw[name] if name in self._raw else self._decoded[name]) + for name in self} @classmethod def schema(cls) -> dict[str, JsonValue]: diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 1951ee10c1..1baa850dd6 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -9,7 +9,7 @@ import time from abc import ABC, abstractmethod -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass, field, fields, replace from functools import cmp_to_key from pathlib import Path @@ -113,12 +113,11 @@ def entry(self, identity: ThreadIncarnation, if current else self.entry_goal_contacts(), ) - def counterpart(self, thread: Thread) -> ThreadIncarnation | None: + def counterparts(self, thread: Thread) -> Iterator[ThreadIncarnation]: if self.owner_incarnation == thread.incarnation: - return self.peer_incarnation - if self.peer_incarnation == thread.incarnation: - return self.owner_incarnation - return None + yield self.peer_incarnation + elif self.peer_incarnation == thread.incarnation: + yield self.owner_incarnation @dataclass(frozen=True, slots=True) @@ -424,8 +423,7 @@ class CollaboratingRelationshipGroup(SortableRelationshipGroup): def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: selected: dict[ThreadIncarnation, list[MutualThreadRelation]] = {} for relation in (*inputs.edges, *inputs.goal_contacts): - identity = relation.counterpart(inputs.thread) - if identity is not None: + for identity in relation.counterparts(inputs.thread): selected.setdefault(identity, []).append(relation) return tuple(MutualEntrySource(identity, tuple(relations)) for identity, relations in selected.items()) @@ -449,11 +447,11 @@ class RelationshipInputs: class RelationshipContext(RelationshipInputs): people: Mapping[str, presentation.ThreadView] - def entry(self, target: NamedTarget, *, message: MessageInput | None = None, + def entry(self, target: NamedTarget, *, sequence: int = 0, timestamp: float = 0, detail: str = "") -> RelationshipEntry: return RelationshipEntry( target.name, target.kind, self.people.get(target.name, presentation.NoThreadView()), - message.seq if message else 0, message.timestamp if message else 0, detail, + sequence, timestamp, detail, ) @@ -493,7 +491,7 @@ class RecentEntrySource(NamedEntrySource): def entry(self, context: RelationshipContext) -> RelationshipEntry: return context.entry( - self.target, message=self.message, + self.target, sequence=self.message.seq, timestamp=self.message.timestamp, detail=f"{self.message.sender} → {self.message.target}\n{self.message.body[:240]}", ) @@ -644,7 +642,7 @@ def active(self) -> Collaboration | None: edge for edge in self.pairs if self.second is not None - and edge.counterpart(self.first) == self.second.incarnation + and self.second.incarnation in edge.counterparts(self.first) ), None, ) @@ -840,10 +838,10 @@ def peer_relations(self, owner: str) -> tuple[PeerRelation, ...]: granted[peer.incarnation].append(ChannelmateRelevance) relation: MutualThreadRelation for relation in (*edges, *contacts): - identity = relation.counterpart(thread) - if identity in granted: - granted[identity].append(relation.relevance) - records.setdefault(identity, []).append(relation.record(identity)) + for identity in relation.counterparts(thread): + if identity in granted: + granted[identity].append(relation.relevance) + records.setdefault(identity, []).append(relation.record(identity)) return tuple(sorted( ( PeerRelation( @@ -890,10 +888,11 @@ def contacts(self, owner: str) -> Contacts: contacts, diagnostics = self._goal_contacts(registry, thread) visible: dict[ThreadIncarnation, RelationshipEntry] = {} for relation in (*explicit, *contacts): - identity = relation.counterpart(thread) - visible[identity] = relation.entry( - identity, None, identity.current(registry), visible.get(identity), - ) + for identity in relation.counterparts(thread): + visible[identity] = relation.entry( + identity, presentation.NoThreadView(), identity.current(registry), + visible.get(identity), + ) return Contacts( explicit, tuple(sorted(visible.values(), key=lambda row: (row.target, not row.available))), diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 77308cc1a6..c4fddcc183 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -435,11 +435,6 @@ def incarnation(self) -> ThreadIncarnation: return RecordedThreadIncarnation(self.name, birth) return member.recorded_as(self.name) - @classmethod - def decode(cls, name: str, birth: float) -> ThreadIncarnation: - return FieldCodec.decode(cls, {"name": name, "created_at": birth}).incarnation - - @dataclass(frozen=True, kw_only=True) class ThreadBirthRecord(ThreadIncarnationRecord): diff --git a/tests/test_mutual_relation_identity.py b/tests/test_mutual_relation_identity.py index b367ef03a4..1da49cf084 100644 --- a/tests/test_mutual_relation_identity.py +++ b/tests/test_mutual_relation_identity.py @@ -29,8 +29,8 @@ def peer_incarnation(self): relation = ReviewRelation() assert relation.incident(first) and relation.incident(second) - assert relation.counterpart(first) == second.incarnation - assert relation.counterpart(second) == first.incarnation + assert tuple(relation.counterparts(first)) == (second.incarnation,) + assert tuple(relation.counterparts(second)) == (first.incarnation,) assert not relation.incident(replacement) - assert relation.counterpart(replacement) is None + assert tuple(relation.counterparts(replacement)) == () assert relation.record(second.incarnation).peer == "second" diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 73c6bee280..9d44cf70e5 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -88,6 +88,7 @@ def test_captured_lookup_keeps_original_recipient_identity_and_retirement(tmp_pa comms.registry.rename(peer.name, "renamed") renamed = comms.registry.snapshot() assert renamed.recipient(recipient, original) == RegisteredRecipient(renamed.require("renamed")) + comms.registry.unregister("renamed") comms.threads.delete("renamed") deleted = comms.registry.snapshot() assert deleted.recipient(recipient, original) == RetiredRecipient(recipient.recipient_lookup) diff --git a/tests/test_registration_components.py b/tests/test_registration_components.py index ae11d24799..7787fa0ac1 100644 --- a/tests/test_registration_components.py +++ b/tests/test_registration_components.py @@ -3,6 +3,7 @@ from agent_comms.repo import Checkout import json import os +import pickle import subprocess import sys from dataclasses import dataclass, field, replace @@ -157,21 +158,21 @@ def test_lifecycle_document_survives_fresh_process_restart(tmp_path): assert restored.snapshot().owner_generations["renamed"] > 0 -def test_thread_rows_compare_unread_rows_as_stored_and_decode_only_what_changed(tmp_path): +def test_thread_rows_compare_unread_rows_as_stored_and_decode_only_what_changed(comms, tmp_path): from agent_comms.field_codec import FieldCodec from agent_comms.registry_document import InvalidRegistryDocument, ThreadRows from agent_comms.thread_execution import ExternalThreadExecution # A real declaration with malformed non-determining data stays lazy until a full read. - row = FieldCodec.encode_fields(Thread('unread', frozenset(), Checkout(tmp_path))) + thread = comms.registry.declare(Thread('unread', frozenset(), Checkout(tmp_path))) + row = FieldCodec.encode_fields(thread) row[FieldCodec.field_plan(Thread, 'model').key] = 123 stored = {'unread': row} with pytest.raises(InvalidRegistryDocument): ThreadRows.from_wire(stored)['unread'] assert ThreadRows.from_wire(stored) == ThreadRows.from_wire(stored) written = ThreadRows.from_wire(stored) - written['unread'] = Thread( - 'unread', frozenset(), Checkout(tmp_path), execution=ExternalThreadExecution) + written['unread'] = replace(thread, execution=ExternalThreadExecution) other = ThreadRows.from_wire({}) other['unread'] = written['unread'] assert written == other @@ -188,10 +189,10 @@ def test_birth_projection_agrees_for_full_thread_and_stored_float_records(tmp_pa assert FieldCodec.decode(type(stored), FieldCodec.encode(stored)) == stored -def test_full_thread_read_keeps_original_raw_wire_until_an_actual_assignment(tmp_path): +def test_full_thread_read_keeps_original_raw_wire_until_an_actual_assignment(comms, tmp_path): from agent_comms.registry_document import ThreadRows - thread = Thread("owner", frozenset({"kept"}), Checkout(tmp_path)) + thread = comms.registry.declare(Thread("owner", frozenset({"kept"}), Checkout(tmp_path))) rows = ThreadRows.from_wire({thread.name: FieldCodec.encode_fields(thread)}) tags_key = FieldCodec.field_plan(Thread, "tags").key kept_tags = rows.to_wire()[thread.name][tags_key] @@ -200,3 +201,18 @@ def test_full_thread_read_keeps_original_raw_wire_until_an_actual_assignment(tmp assert rows.to_wire()[thread.name][tags_key] is kept_tags rows[thread.name] = replace(loaded, tags=frozenset({"assigned"})) assert rows.to_wire()[thread.name][tags_key] == ["assigned"] + + +def test_captured_thread_row_uses_its_wire_decode_and_pickle_protocol(comms, tmp_path): + from agent_comms.registry_document import ThreadRow + + thread = comms.registry.declare(Thread("captured", frozenset({"kept"}), Checkout(tmp_path))) + row = ThreadRow.from_wire(FieldCodec.encode_fields(thread)) + decoded = FieldCodec.decode(ThreadRow, FieldCodec.encode(row)) + restored = pickle.loads(pickle.dumps(row, protocol=5)) + for captured in (decoded, restored): + assert type(captured) is ThreadRow + assert captured == row + assert captured.to_wire() == row.to_wire() + assert captured.incarnation == thread.incarnation + assert captured.decoded() == thread diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 22da3146d9..3324caf6cf 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -136,7 +136,7 @@ def test_recent_relationships_keep_the_global_cut_and_exact_preview(tmp_path): )) for _ in range(comms.relationships.RECENT_MESSAGES): bus.append(lambda seq, db: PublicRecord.from_message( - Message("peer", "peer", "unrelated global row", InfoKind, seq=seq), + Message("peer", "origin", "unrelated global row", InfoKind, seq=seq), )) snapshot = comms.relationships.snapshot("owner") assert snapshot.history_limited and snapshot.history_messages == comms.relationships.RECENT_MESSAGES diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index a0009168d0..2be4020a64 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -205,7 +205,7 @@ def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( @pytest.mark.parametrize("decoded", (False, True)) def test_current_reference_acquires_only_captured_incarnations(comms, declare_reference, decoded): - original = declare_reference() + original = declare_reference(status=RunningThreadStatus()) unread = declare_reference("unread") comms.registry.rename(original.name, "renamed") captured = comms.registry.snapshot() From 9b105df4dd82d1134de29093dd270c09cc6c04fa Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:28:32 -0400 Subject: [PATCH 08/24] Serialize retained thread rows through their existing raw owner --- src/agent_comms/registry_document.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index d4e83e01dd..5f6c7cf692 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -427,8 +427,8 @@ def __delitem__(self, name: str) -> None: def to_wire(self) -> dict[str, JsonValue]: # A read memo is not an edit: __setitem__ removes raw only for actual writes. - return {name: FieldCodec.encode(self._raw[name] if name in self._raw else self._decoded[name]) - for name in self} + return {name: (self._raw[name].to_wire() if name in self._raw + else FieldCodec.encode(self._decoded[name])) for name in self} @classmethod def schema(cls) -> dict[str, JsonValue]: From 0524dd6bb9b9ea0c60d6a58d22905429b48192fa Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:43:06 -0400 Subject: [PATCH 09/24] Let ThreadIncarnation strictly resolve its original current peer --- src/agent_comms/channel_targets.py | 5 +---- src/agent_comms/thread_identity.py | 12 +++++++++++- tests/test_named_target_contracts.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index fdea37d61a..320dbd2596 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -483,10 +483,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "name", self.incarnation.name) def selected_name(self, snapshot: RegistryProvenance) -> str: - if not self.incarnation.current(snapshot): - raise UnregisteredThreadError( - f"Original DM peer {self.name!r} is no longer registered.") - return super().selected_name(snapshot) + return self.incarnation.require_current(snapshot).name def require_publication(self, message: Message, snapshot: RegistryProvenance, require_channel: Callable[[str], Channel]) -> None: diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index c4fddcc183..9ad9f876d3 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -25,7 +25,7 @@ UnknownFamilyMember, declared_words, ) -from .errors import RelationViolationError +from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import ( FieldCodec, TurnId, @@ -209,6 +209,16 @@ def require_recorded(self) -> RecordedThreadIncarnation: def renamed(self, name: str) -> ThreadIncarnation: """Retain this member's birth state under an original alias.""" + def require_current(self, snapshot: RegistryProvenance) -> ThreadIncarnation: + """Require this original birth and return its current canonical incarnation.""" + original = self.require_recorded() + current = snapshot.incarnation(original.name) + if current != original: + raise UnregisteredThreadError( + f"Thread {original.name!r} no longer has its recorded birth." + ) + return current + def resolved(self, snapshot: RegistryProvenance) -> ThreadIncarnation: """Follow retained rename aliases only for this exact historical owner.""" if not self.current(snapshot): diff --git a/tests/test_named_target_contracts.py b/tests/test_named_target_contracts.py index 58ad93e6cd..b3599cd9f5 100644 --- a/tests/test_named_target_contracts.py +++ b/tests/test_named_target_contracts.py @@ -11,6 +11,7 @@ from agent_comms.message_page import ConversationMessagePageRequest from agent_comms.read_basis import NamedViewer from agent_comms.thread_execution import ExternalThreadExecution +from agent_comms.thread_identity import BirthlessIncarnation from bus_record_fixture import context_comms from agent_comms.threads import Thread @@ -64,8 +65,17 @@ def test_retained_dm_target_follows_rename_through_send_page_and_revision(tmp_pa comms = context_comms(tmp_path) worktree = Checkout(tmp_path) target = IncarnationThreadTarget(comms.registry.require("Alice").incarnation) + original_cut = comms.registry.snapshot() + assert target.incarnation.require_current(original_cut).name == "Alice" original = comms.messaging.send_user_message(target, "before rename", worktree=worktree) comms.registry.rename("Alice", "renamed") + renamed_cut = comms.registry.snapshot() + current = target.incarnation.require_current(renamed_cut) + assert current == target.incarnation + assert current.name == "renamed" + assert target.selected_name(renamed_cut) == current.name + assert target.incarnation.resolved(renamed_cut).name == current.name + assert target.incarnation.require_current(original_cut).name == "Alice" sent = comms.messaging.send_user_message(target, "after rename", worktree=worktree) assert sent.target == "renamed" delivery = comms.bus.bus.delivery(sent.seq) @@ -92,6 +102,13 @@ def test_retained_dm_refuses_a_removed_birth_at_every_selection_boundary(tmp_pat comms.registry.remove("Alice") if recreate: comms.registry.declare(Thread("Alice", frozenset({"team"}), worktree)) + snapshot = comms.registry.snapshot() + assert not target.incarnation.current(snapshot) + assert target.incarnation.resolved(snapshot) is target.incarnation + with pytest.raises(UnregisteredThreadError): + target.incarnation.require_current(snapshot) + with pytest.raises(UnregisteredThreadError): + target.selected_name(snapshot) before = comms.bus.bus.revision() with pytest.raises(HumanSendRefusedError): comms.messaging.send_user_message(target, "must not reach successor", worktree=worktree) @@ -104,6 +121,17 @@ def test_retained_dm_refuses_a_removed_birth_at_every_selection_boundary(tmp_pat comms.views.history_page(target, viewer=NamedViewer(viewer.name)) +@pytest.mark.parametrize("member", BirthlessIncarnation.members_with(BirthlessIncarnation)) +def test_birthless_identity_cannot_strictly_select_a_current_peer(named_target_journey, member): + incarnation = member.recorded_as("Alice") + snapshot = named_target_journey.registry.snapshot() + assert incarnation.resolved(snapshot) is incarnation + with pytest.raises(ValueError): + incarnation.require_current(snapshot) + with pytest.raises(ValueError): + IncarnationThreadTarget(incarnation) + + def test_dm_page_refuses_replacement_between_registry_capture_and_bus_read(tmp_path, monkeypatch): comms = context_comms(tmp_path) worktree = Checkout(tmp_path) From 544dc198f550ea0a71a695ea7a1aeb11a9a5147e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:54:45 -0400 Subject: [PATCH 10/24] Defer captured goal fields and carry lookup metadata across revisions --- src/agent_comms/presentation.py | 8 +- src/agent_comms/registry_document.py | 137 ++++++++++++++++++--------- src/agent_comms/relationships.py | 8 +- src/agent_comms/thread_identity.py | 16 ++-- tests/test_sidebar_observation.py | 55 ++++++++++- 5 files changed, 161 insertions(+), 63 deletions(-) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index d531d2b477..95969433db 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -27,7 +27,7 @@ from .coordination_database import CoordinationStore from .coordination_errors import DMPaintedBoundChanged, StaleRevision from .declared_family import DeclaredFamily -from .display_order import ChannelSort, NameChannelSort +from .display_order import ChannelSort, NameChannelSort, ThreadSort from .display_references import CurrentDisplayReferences from .external_transcript import ExternalTranscript from .field_codec import ( @@ -724,6 +724,9 @@ def activity_detail(self) -> str: class NoThreadView(NullMember): """A named navigation target has no captured thread presentation.""" + def sort_key(self, order: type[ThreadSort], target: str, sent: float) -> tuple[float, float, str]: + return order.key(target, 0, 0, 0) + @dataclass(frozen=True, slots=True) class ThreadView(ThreadRecord, PresentMember): @@ -750,6 +753,9 @@ def roster( snapshot.seen_at(thread.name), snapshot.owner_binding(thread.name), ) for thread in threads) + def sort_key(self, order: type[ThreadSort], target: str, sent: float) -> tuple[float, float, str]: + return order.key(target, self.thread.birth, self.activity.timestamp, sent) + @property def presentation(self) -> ThreadPresentation: """One declaration-owned interpretation for every thread view.""" diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 5f6c7cf692..94eab9665f 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -57,6 +57,7 @@ OfferedTools, OwnerIdentity, RecordedThreadIncarnation, + ThreadBirthRecord, ThreadIncarnation, ThreadAuthority, TurnIdentity, @@ -314,18 +315,19 @@ def __init__(self, error: Exception) -> None: @dataclass(frozen=True) -class ThreadRow(GoalOwner, DeclaredWireType): - """A captured Thread wire value and its already-decoded determining fields.""" +class ThreadRow(ThreadBirthRecord, DeclaredWireType): + """A captured birth and original wire row; goal fields are acquired only on demand.""" wire_type = Thread raw: Annotated[Mapping[str, JsonValue], WireTransient()] = field(kw_only=True) @classmethod def from_wire(cls, raw: object) -> Self: - wire = FieldCodec.decode(dict[str, JsonValue], raw) + if not isinstance(raw, dict): + raise MalformedRecord("thread row must be an object") return FieldCodec.project_record( - cls, wire, source=Thread, - decoded_fields={"raw": MappingProxyType(wire)}, + cls, raw, source=Thread, + decoded_fields={"raw": MappingProxyType(raw)}, ) def to_wire(self) -> dict[str, JsonValue]: @@ -334,9 +336,18 @@ def to_wire(self) -> dict[str, JsonValue]: def __reduce__(self) -> tuple[Callable[[object], Self], tuple[dict[str, JsonValue]]]: return type(self).from_wire, (self.to_wire(),) + @cached_property + def goal_owner(self) -> GoalOwner: + captured = {entry.name: entry.current(self) + for entry in FieldCodec.field_plans(ThreadBirthRecord) if entry.records} + return FieldCodec.project_record( + GoalOwner, dict(self.raw), source=Thread, decoded_fields=captured, + ) + def decoded(self) -> Thread: - values = {entry.name: entry.current(self) - for entry in FieldCodec.field_plans(type(self)) if entry.records} + owner = self.goal_owner + values = {entry.name: entry.current(owner) + for entry in FieldCodec.field_plans(GoalOwner) if entry.records} return FieldCodec.decode_record(Thread, dict(self.raw), decoded_fields=values) @@ -345,8 +356,11 @@ class ThreadRows(ExternalJsonShape, WireValue, MutableMapping[str, Thread]): def __init__(self) -> None: # checked_rows proved each stored row an object, so the type keeps that proof. - self._raw: dict[str, ThreadRow] = {} + self._raw: dict[str, dict[str, JsonValue]] = {} self._decoded: dict[str, Thread] = {} + # Same-cut copies share read memos; an actual assignment detaches them. + self._captured: dict[str, ThreadRow] = {} + self._lookup_names: dict[ThreadLookup, str] = {} self._binding: Callable[[Thread], Thread] = self.unbound @staticmethod @@ -375,18 +389,33 @@ def children(self, parent: str) -> tuple[Thread, ...]: return tuple( self[name] for name in self if (self._decoded[name].parent.named(parent) if name in self._decoded - else self._raw[name].raw.get(key) == expected) + else self._raw[name].get(key) == expected) ) + def captured(self, name: str) -> ThreadRow: + if name not in self._captured: + self._captured[name] = ThreadRow.from_wire(self._raw[name]) + return self._captured[name] + def determining(self, name: str) -> GoalOwner: - """The current nominal determining row, without acquiring a complete declaration.""" - return self._decoded[name] if name in self._decoded else self._raw[name] + """Only a goal/role question acquires the declared goal-owner projection.""" + return self._decoded[name] if name in self._decoded else self.captured(name).goal_owner + + def birth_record(self, name: str) -> ThreadBirthRecord: + return self._decoded[name] if name in self._decoded else self.captured(name) def creation_time(self, name: str) -> CreationTime: - return self.determining(name).created_at + return self.birth_record(name).created_at def incarnation(self, name: str) -> ThreadIncarnation: - return self.determining(name).incarnation + return self.birth_record(name).incarnation + + def lookup_names(self) -> Mapping[ThreadLookup, str]: + """Prime once for this cut, including its document and detached snapshot copies.""" + if not self._lookup_names: + captured = {self.incarnation(name).require_recorded().lookup: name for name in self} + self._lookup_names.update(captured) + return MappingProxyType(self._lookup_names) def active_goal_owners(self) -> Iterator[Thread]: """Select through the shared goal/role declarations before full thread acquisition.""" @@ -394,12 +423,12 @@ def active_goal_owners(self) -> Iterator[Thread]: def __getitem__(self, name: str) -> Thread: if name not in self._decoded: - self._decoded[name] = self.decode_row(self._raw[name]) + self._decoded[name] = self.decode_row(name) return self._decoded[name] - def decode_row(self, row: ThreadRow) -> Thread: + def decode_row(self, name: str) -> Thread: try: - thread = row.decoded() + thread = self.captured(name).decoded() except ValueError as error: raise InvalidRegistryDocument(error) from error return self._binding(thread) @@ -416,18 +445,27 @@ def __contains__(self, name: object) -> bool: def __setitem__(self, name: str, thread: Thread) -> None: if thread.name != name: raise MalformedRecord("thread name differs from its declaration") + if not self._lookup_names or self._lookup_names.get( + thread.incarnation.require_recorded().lookup + ) != name: + self._lookup_names = {} + self._captured = self._captured.copy() + self._captured.pop(name, None) self._decoded[name] = self._binding(thread) self._raw.pop(name, None) def __delitem__(self, name: str) -> None: if name not in self: raise KeyError(name) + self._lookup_names = {} + self._captured = self._captured.copy() + self._captured.pop(name, None) self._raw.pop(name, None) self._decoded.pop(name, None) def to_wire(self) -> dict[str, JsonValue]: # A read memo is not an edit: __setitem__ removes raw only for actual writes. - return {name: (self._raw[name].to_wire() if name in self._raw + return {name: (dict(self._raw[name]) if name in self._raw else FieldCodec.encode(self._decoded[name])) for name in self} @classmethod @@ -436,18 +474,14 @@ def schema(cls) -> dict[str, JsonValue]: return {"type": "object", "additionalProperties": FieldCodec.record_schema(Thread)} @classmethod - def checked_rows(cls, raw: object) -> dict[str, ThreadRow]: + def checked_rows(cls, raw: object) -> dict[str, dict[str, JsonValue]]: if not isinstance(raw, dict): raise MalformedRecord("registry threads must be an object") - result: dict[str, ThreadRow] = {} + name_key = FieldCodec.field_plan(Thread, "name").key for name, row in raw.items(): - if not isinstance(name, str) or not isinstance(row, dict): - raise MalformedRecord("thread name differs from its declaration") - captured = ThreadRow.from_wire(row) - if captured.name != name: + if not isinstance(name, str) or not isinstance(row, dict) or row.get(name_key) != name: raise MalformedRecord("thread name differs from its declaration") - result[name] = captured - return result + return dict(raw) def require_distinct_births(self) -> None: """Rows decode late, so a read of one thread never sees two sharing a creation lookup; @@ -468,18 +502,14 @@ def from_threads(cls, threads: Mapping[str, Thread]) -> Self: result.update(threads) return result - def copy(self) -> ThreadRows: - result = ThreadRows() - result._raw = self._raw.copy() - result._decoded = self._decoded.copy() - result._binding = self._binding - return result - - def freeze(self) -> ThreadRows: - result = FrozenThreadRows() - result._raw = self._raw.copy() - result._decoded = self._decoded.copy() - result._binding = self._binding + @classmethod + def copied(cls, source: ThreadRows) -> Self: + result = cls() + result._raw = source._raw.copy() + result._decoded = source._decoded.copy() + result._captured = source._captured + result._lookup_names = source._lookup_names + result._binding = source._binding return result def reuse(self, rows: Mapping[str, object], kept: frozenset[str], earlier: ThreadRows) -> None: @@ -488,7 +518,25 @@ def reuse(self, rows: Mapping[str, object], kept: frozenset[str], earlier: Threa captured = ChainMap(retained, changed) self._raw = {name: captured[name] for name in rows} self._decoded = {name: earlier._decoded[name] for name in kept if name in earlier._decoded} + self._captured = {name: earlier._captured[name] for name in kept & earlier._captured.keys()} self._binding = earlier._binding + if earlier._lookup_names: + removed = (earlier._raw.keys() | earlier._decoded.keys()) - rows.keys() + retired = {earlier.incarnation(name).require_recorded().lookup for name in removed} + added: dict[ThreadLookup, str] = {} + for name in rows.keys() - kept: + lookup = self.incarnation(name).require_recorded().lookup + if earlier._lookup_names.get(lookup) != name: + added[lookup] = name + if name in earlier: + retired.add(earlier.incarnation(name).require_recorded().lookup) + if retired or added: + self._lookup_names = earlier._lookup_names.copy() + for lookup in retired: + self._lookup_names.pop(lookup) + self._lookup_names.update(added) + else: + self._lookup_names = earlier._lookup_names class FrozenThreadRows(ThreadRows): @@ -504,12 +552,7 @@ class ThreadLookupIndex(Mapping[ThreadLookup, Thread]): """A captured cut's lookup keys; complete values still belong to its thread rows.""" rows: ThreadRows - _names: Mapping[ThreadLookup, str] = field(init=False, repr=False) - - def __post_init__(self) -> None: - object.__setattr__(self, "_names", MappingProxyType({ - self.rows.incarnation(name).require_recorded().lookup: name for name in self.rows - })) + _names: Mapping[ThreadLookup, str] = field(repr=False) def __getitem__(self, lookup: ThreadLookup) -> Thread: return self.rows[self._names[lookup]] @@ -518,7 +561,7 @@ def __contains__(self, lookup: object) -> bool: return lookup in self._names def __iter__(self) -> Iterator[ThreadLookup]: - return iter(self._names) + return (self.rows.incarnation(name).require_recorded().lookup for name in self.rows) def __len__(self) -> int: return len(self._names) @@ -527,11 +570,11 @@ def __len__(self) -> int: class ThreadRowsSlice(OwnRow[ThreadRows]): @classmethod def copy(cls, value: ThreadRows) -> ThreadRows: - return value.copy() + return ThreadRows.copied(value) @classmethod def freeze(cls, value: ThreadRows) -> ThreadRows: - return value.freeze() + return FrozenThreadRows.copied(value) @classmethod def reuse(cls, rows: Mapping[str, object], kept: frozenset[str], @@ -1153,7 +1196,7 @@ def __post_init__(self) -> None: @cached_property def by_lookup(self) -> Mapping[ThreadLookup, Thread]: - return ThreadLookupIndex(self.threads) + return ThreadLookupIndex(self.threads, self.threads.lookup_names()) def owner_generation(self, name: str) -> int: return self.owner_generations[self.canonical_name(name)] diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 1baa850dd6..3c8277aa07 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -379,13 +379,7 @@ def from_entries(cls, entries: dict[str, RelationshipEntry], order = cls.selected_order(context.orders) def key(item: RelationshipEntry) -> tuple[float, float, str]: - people = item.person.present() - return order.key( - item.target, - sum(person.thread.birth for person in people), - sum(person.activity.timestamp for person in people), - sum(context.sent.get(item.target, 0) for _ in people), - ) + return item.person.sort_key(order, item.target, context.sent.get(item.target, 0)) return cls({entry.target: entry for entry in sorted(entries.values(), key=key)}, order) diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 9ad9f876d3..488efd9251 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -213,7 +213,7 @@ def require_current(self, snapshot: RegistryProvenance) -> ThreadIncarnation: """Require this original birth and return its current canonical incarnation.""" original = self.require_recorded() current = snapshot.incarnation(original.name) - if current != original: + if not original.current(snapshot): raise UnregisteredThreadError( f"Thread {original.name!r} no longer has its recorded birth." ) @@ -223,11 +223,13 @@ def resolved(self, snapshot: RegistryProvenance) -> ThreadIncarnation: """Follow retained rename aliases only for this exact historical owner.""" if not self.current(snapshot): return self - return snapshot.threads[snapshot.canonical_name(self.name)].incarnation + return snapshot.incarnation(self.name) def current(self, snapshot: RegistryProvenance) -> bool: - thread = snapshot.threads.get(snapshot.canonical_name(self.name)) - return thread is not None and thread.incarnation == self + try: + return snapshot.incarnation(self.name) == self + except UnregisteredThreadError: + return False def recorded_or( self, make: Callable[[RecordedThreadIncarnation], T], otherwise: Callable[[], T] @@ -256,8 +258,10 @@ def matches_recorded_name(self, name: str, snapshot: RegistryProvenance) -> bool The registry owns aliases and birth. A stored name does not supply or manufacture either fact, including after a name is reused. """ - thread = snapshot.threads.get(snapshot.canonical_name(name)) - return thread is not None and self.resolved(snapshot) == thread.incarnation + try: + return self.resolved(snapshot) == snapshot.incarnation(name) + except UnregisteredThreadError: + return False def recorded_names(self, snapshot: RegistryProvenance) -> tuple[ThreadIncarnation, ...]: """Original names retained for this exact current incarnation.""" diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index 2be4020a64..4e01f08b35 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -17,6 +17,7 @@ from agent_comms.historical_views import HistorySource from agent_comms.registry_document import HistoricalRootPurpose from agent_comms.thread_status import RunningThreadStatus, StoppedThreadStatus +from agent_comms.thread_text import GivenThreadText from agent_comms.ui_model.sidebar import SidebarModel from agent_comms.ui_model.unread import ExactUnread, UnobservedUnread from agent_comms.threads import Thread @@ -228,6 +229,56 @@ def test_current_reference_acquires_only_captured_incarnations(comms, declare_re assert references.find("#comms")[0].reference.name == "#comms" +@pytest.mark.parametrize("entry", ( + FieldCodec.field_plan(Thread, "goal"), FieldCodec.field_plan(Thread, "role"), +)) +def test_cold_shape_and_birth_capture_do_not_acquire_unrequested_goal_fields( + comms, declare_reference, entry, +): + original = declare_reference() + captured = comms.registry.snapshot() + raw = captured.threads.to_wire() + raw[original.name][entry.key] = [] + rows = ThreadRows.from_wire(raw) + assert not rows._captured + assert not rows._decoded + snapshot = replace(captured, threads=rows) + assert snapshot.incarnation(original.name) == original.incarnation + assert original.incarnation.lookup in snapshot.by_lookup + assert not snapshot.threads._decoded + with pytest.raises(MalformedRecord): + snapshot.threads.determining(original.name) + with pytest.raises(InvalidRegistryDocument): + snapshot.by_lookup[original.incarnation.lookup] + + +def test_lookup_metadata_is_carried_from_snapshot_through_data_only_revision( + comms, declare_reference, +): + original = declare_reference(status=RunningThreadStatus()) + declare_reference("unrelated") + before = comms.registry.snapshot() + lookup = original.incarnation.lookup + original_index = before.by_lookup + metadata = before.threads._lookup_names + changed = comms.registry.update_thread( + original.name, lambda thread: replace(thread, task=GivenThreadText("data-only edit")), + ) + after = comms.registry.snapshot() + assert after.by_lookup[lookup] == changed + assert after.threads._lookup_names is metadata + assert original_index[lookup] == original + assert tuple(after.by_lookup) == tuple(after.incarnation(name).require_recorded().lookup + for name in after.threads) + comms.registry.rename(original.name, "renamed") + renamed = comms.registry.snapshot() + assert renamed.by_lookup[lookup].name == "renamed" + assert original_index[lookup].name == original.name + assert after.by_lookup[lookup].name == original.name + assert tuple(renamed.by_lookup) == tuple(renamed.incarnation(name).require_recorded().lookup + for name in renamed.threads) + + @pytest.mark.parametrize("entry,value", ( (FieldCodec.field_plan(Thread, "name"), "invalid name"), (FieldCodec.field_plan(Thread, "created_at"), []), @@ -239,7 +290,7 @@ def test_captured_reference_rows_refuse_malformed_determining_fields(declare_ref raw = FieldCodec.encode_fields(original) raw[entry.key] = value with pytest.raises(MalformedRecord): - ThreadRows.from_wire({original.name: raw}) + ThreadRows.from_wire({original.name: raw}).determining(original.name) def test_captured_reference_rows_refuse_unknown_or_absent_required_fields(declare_reference): @@ -249,7 +300,7 @@ def test_captured_reference_rows_refuse_unknown_or_absent_required_fields(declar del missing_birth[FieldCodec.field_plan(Thread, "created_at").key] for malformed in ({**raw, "undeclared_field": "refuse"}, missing_birth): with pytest.raises(MalformedRecord): - ThreadRows.from_wire({original.name: malformed}) + ThreadRows.from_wire({original.name: malformed}).determining(original.name) def test_current_reference_source_projection_keeps_historical_identity(comms, declare_reference): From e66d9b763033b487cd34066e7f6bf8df9074294d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 23:08:58 -0400 Subject: [PATCH 11/24] Join channel activity to captured tag declarations --- src/agent_comms/bus.py | 33 ++++--- src/agent_comms/check_root.py | 4 +- src/agent_comms/messages.py | 41 ++++++-- src/agent_comms/read_basis.py | 127 +++++++++++++++++++------ src/agent_comms/registry_document.py | 40 ++++++-- src/agent_comms/registry_provenance.py | 45 ++++++++- src/agent_comms/threads.py | 20 ++-- tests/test_bus_activity.py | 10 +- 8 files changed, 244 insertions(+), 76 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index 09a571c10c..9bfc4c6a30 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -130,13 +130,18 @@ def counts(self) -> dict[str, int]: def advanced(self, source: BusReader, selection: DisplayUnreadSelection) -> DisplayUnread: queries = selection.queries() through = source.revision() - if through == self.through and tuple(self.counted) == queries: - return self earlier = {query.counting: query for query in self.counted} - kept = {query: query for query in queries if query in self.counted} - moved = {query: (earlier[query.counting], parts) for query in queries - if query not in kept and query.counting in earlier - and (parts := query.moved(earlier[query.counting]))} + reusable = {query: earlier[query.counting] for query in queries + if query.counting in earlier + and query.same_membership(earlier[query.counting])} + kept = {query: previous for query, previous in reusable.items() + if query == previous} + if through == self.through and len(kept) == len(queries): + if tuple(self.counted) == queries: + return self + return DisplayUnread(through, {query: self.counted[kept[query]] for query in queries}) + moved = {query: (previous, parts) for query, previous in reusable.items() + if query not in kept and (parts := query.moved(previous))} progressed = (*kept, *(previous for previous, _ in moved.values())) appended = source.count_unread( progressed if through != self.through else (), through=through, after=self.through) @@ -180,10 +185,10 @@ def target_activity(self, predicate: str, parameters: tuple[object, ...]) -> Cha with self.observing() as db: return BusActivityIndex.aggregate(db, predicate, parameters) - def participant_activity(self, names: frozenset[str]) -> ChannelActivity: - marks = ','.join('?' for _ in names) + def participant_activity(self, predicate: str, + parameters: tuple[object, ...]) -> ChannelActivity: with self.observing() as db: - return BusDisplayIndex.aggregate(db, f'participant IN ({marks})', tuple(names)) + return BusDisplayIndex.aggregate(db, predicate, parameters) def claim_projection(self) -> ClaimProjection: with self.observing() as db: @@ -284,22 +289,22 @@ def scoped_page( upper: int | None = None, descending: bool = False, limit: int = 100, kinds: tuple[type[MessageKind], ...] = (), ) -> tuple[Message, ...]: - """A captured display scope narrows the addressed table before decoding.""" + """A captured display scope reads public message capabilities, not delivery payloads.""" with self.observing() as db: upper = BusRow.last_seq(db) + 1 if upper is None else upper order = "DESC" if descending else "ASC" members = "" if kinds: members = MessageInput.render(' AND {type} IN (') + ','.join('?' for _ in kinds) + ')' - rows = db.execute( + return tuple(Message.selected( + db, f'SELECT seq FROM "{Addressed.table.declared_name}" ' f'JOIN "{MessageInput.table.declared_name}" USING(seq) ' f'WHERE ({predicate}) AND seq > ? AND seq < ?{members} ' f'ORDER BY seq {order} LIMIT ?', (*parameters, lower, upper, *(kind.declared_name for kind in kinds), limit), - ) - headers = (BusRow.require(db, seq=FieldCodec.decode(int, row[0])) for row in rows) - return tuple(header.kind.read_from(db, header.seq) for header in headers) + descending=descending, + )) def latest_conversations( self, predicate: str, parameters: tuple[object, ...], through: int, diff --git a/src/agent_comms/check_root.py b/src/agent_comms/check_root.py index 7070dc907a..39b9751480 100644 --- a/src/agent_comms/check_root.py +++ b/src/agent_comms/check_root.py @@ -24,7 +24,7 @@ from .launch_runtime import ThreadLaunch from .native_entries import ImportedSessionWriter, NativeEntry from .native_session_files import CheckSession -from .read_basis import MemberActivityDisplayScope +from .read_basis import NamedActivityDisplayScope from .registry_document import RosterVisibility from .session_file import RegistrySessionLocation from .thread_storage import StorageRoots @@ -94,7 +94,7 @@ def _populate(self, copied: Comms) -> None: targets = frozenset( target for name in names for target in catalog.targets_for(snapshot.require(name).tags) ) - scope = MemberActivityDisplayScope("#check", targets, participant_names=selected | aliases) + scope = NamedActivityDisplayScope("#check", targets, names=selected | aliases) with self.source.bus.bus.reading() as source: messages = scope.history(source, limit=self.max_records) copied.registry.store.prepare_native_root() diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 74de34bb2d..634cf931f7 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -427,14 +427,39 @@ def rows(self) -> tuple[CapabilityRow, ...]: @classmethod def read_from(cls, db: sqlite3.Connection, seq: int) -> Self: - """Reconstruct this declared value from its own capability tables.""" - rows = [] - for table in cls.tables(): - stored = table.select(db, seq=seq) - if not stored: - raise RelationViolationError(f"Bus row {seq} lacks {table.declared_name}") - rows.append(stored[0]) - return cls.assembled(rows) + """Reconstruct one declared value through the shared joined acquisition.""" + with closing(cls.selected( + db, f'SELECT seq FROM "{BusRow.declared_name}" WHERE seq=?', (seq,), + )) as values: + for value in values: + return value + raise RelationViolationError(f"Bus row {seq} lacks {cls.__name__}") + + @classmethod + def selected( + cls, db: sqlite3.Connection, positions: str, parameters: tuple[object, ...], + *, descending: bool = False, + ) -> Iterator[Self]: + """One cursor reads only the capability tables this value inherits.""" + tables = cls.tables() + offsets = dict(zip(tables, accumulate( + (len(BusRow.columns()), *(len(table.columns()) for table in tables))))) + header = BusRow.qualified("seq") + order = "DESC" if descending else "ASC" + with closing(db.execute( + f"WITH selected AS ({positions}) " + f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in (BusRow, *tables))} " + f'FROM "{BusRow.declared_name}" JOIN selected USING(seq) ' + + " ".join(f'LEFT JOIN "{table.declared_name}" ON {table.qualified("seq")} = {header}' + for table in tables) + + f" ORDER BY {header} {order}", parameters, + )) as cursor: + for stored in cursor: + original = BusRow.decode_at(stored, 0) + for table in tables: + if stored[offsets[table]] is None: + raise RelationViolationError(f"Bus row {original.seq} lacks {table.declared_name}") + yield cls.assembled(table.decode_at(stored, offsets[table]) for table in tables) @classmethod def assembled(cls, rows: Iterable[CapabilityRow]) -> Self: diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 275375d3a8..f9cd68b286 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -28,7 +28,7 @@ from .coordination_errors import DMPaintedBoundChanged, DMScopeChanged, HistoricalSourceChanged from .declared_family import DeclaredFamily from .errors import RelationViolationError, UnregisteredThreadError -from .field_codec import FieldCodec, NullMember, PathText, SingleFieldValue, SubclassUnion +from .field_codec import FieldCodec, NullMember, PathText, SingleFieldValue, SubclassUnion, WireTransient from .mentions import ThreadMention from .message_reference import MessageReference from .private_path import FileObservation, MissingFile @@ -311,6 +311,7 @@ def flush(self) -> None: from .registration import Registration from .registry_document import RegistrySnapshot, UserThreadLookup from .registry_provenance import RegistryProvenance + from .threads import ThreadMembership from .threads import Thread @@ -952,6 +953,13 @@ def counted(self, cursor: ReadCursor, snapshot: RegistryProvenance return ((key, value) for key, value in cursor.conversations.items() if key.in_targets(targets, snapshot)) + @property + def memberships(self) -> Mapping[str, ThreadMembership]: + return {} + + def same_membership(self, earlier: MessageDisplayScope) -> bool: + return True + @abstractmethod def includes(self, message: Message) -> bool: """Apply the captured predicate to a validated authoritative message.""" @@ -1123,10 +1131,15 @@ def capture_all(self, reads: ReadLedger, viewer: str, snapshot: RegistrySnapshot @dataclass(frozen=True, slots=True) -class MemberActivityDisplayScope(AllTargetsDisplayScope): - """Member activity may match a sender, DM peer or mention on any target.""" +class ParticipantActivityDisplayScope(AllTargetsDisplayScope, declared_name=None): + """Sender, DM peer and mention activity share one relational predicate.""" - participant_names: frozenset[str] = frozenset() + @property + @abstractmethod + def participant_query(self) -> tuple[str, tuple[object, ...]]: ... + + @abstractmethod + def includes_participant(self, name: str) -> bool: ... def capture_deliveries(self, reads: ReadLedger, viewer: str, snapshot: RegistrySnapshot, source: BusSnapshot, *, document: ReadDocument, @@ -1135,40 +1148,76 @@ def capture_deliveries(self, reads: ReadLedger, viewer: str, snapshot: RegistryS rows = source.selected_sequences(*self.intersection(audience), through) return reads.capture(viewer, rows, snapshot, source, document=document) - @classmethod - def capture(cls, channel: TagChannel, snapshot: RegistryProvenance) -> MemberActivityDisplayScope: - members = frozenset(name for name, thread in snapshot.threads.items() - if channel.tags <= thread.tags) - names = members | frozenset(alias for alias, owner in snapshot.aliases.items() - if owner in members) - return cls(channel.name, channel.history_targets, participant_names=names) - @property def condition(self) -> tuple[str, tuple]: - targets, names = tuple(self.targets), tuple(self.participant_names) - target_marks, name_marks = ",".join("?" for _ in targets), ",".join("?" for _ in names) + targets = tuple(self.targets) + members, parameters = self.participant_query + target_marks = ",".join("?" for _ in targets) mention_key = FieldCodec.field_plan(ThreadMention, "thread").key - return (f"{{target}} IN ({target_marks}) OR {{sender}} IN ({name_marks}) OR " - f"({{target}} NOT LIKE '#%' AND {{target}} IN ({name_marks})) OR " + return (f"{{target}} IN ({target_marks}) OR {{sender}} IN ({members}) OR " + f"({{target}} NOT LIKE '#%' AND {{target}} IN ({members})) OR " f"EXISTS (SELECT 1 FROM json_each({{mentions}}) " - f"WHERE json_extract(value,?) IN ({name_marks}))", - (*targets, *names, *names, f"$.{mention_key}", *names)) + f"WHERE json_extract(value,?) IN ({members}))", + (*targets, *parameters, *parameters, f"$.{mention_key}", *parameters)) def includes(self, message: Message) -> bool: return self._includes(message.sender, message.target, (mention.thread for mention in message.mentions)) def activity(self, source: BusReader): - + members, parameters = self.participant_query return ChannelActivity.combine(( ChannelDisplayScope(self.channel, self.targets).activity(source), - source.participant_activity(self.participant_names), + source.participant_activity(f"participant IN ({members})", parameters), )) def _includes(self, sender: str, target: str, mentions: Iterable[str]) -> bool: - return (target in self.targets or sender in self.participant_names - or not self.participant_names.isdisjoint(CommandTarget.named(target).direct_names) - or any(name in self.participant_names for name in mentions)) + return (target in self.targets or self.includes_participant(sender) + or any(self.includes_participant(name) + for name in CommandTarget.named(target).direct_names) + or any(self.includes_participant(name) for name in mentions)) + + +@dataclass(frozen=True, slots=True) +class NamedActivityDisplayScope(ParticipantActivityDisplayScope): + """An explicitly selected set, used by root checks rather than channel tags.""" + + names: frozenset[str] = frozenset() + + @property + def participant_query(self) -> tuple[str, tuple[object, ...]]: + return "SELECT value FROM json_each(?)", (json.dumps(sorted(self.names)),) + + def includes_participant(self, name: str) -> bool: + return name in self.names + + +@dataclass(frozen=True, slots=True) +class MemberActivityDisplayScope(ParticipantActivityDisplayScope): + """A channel declaration joined to the original immutable namespace's tags.""" + + tags: frozenset[str] = frozenset() + registry: Annotated[RegistryProvenance, WireTransient()] = field( + kw_only=True, compare=False, repr=False, + ) + + @classmethod + def capture(cls, channel: TagChannel, snapshot: RegistryProvenance) -> MemberActivityDisplayScope: + return cls(channel.name, channel.history_targets, tags=channel.tags, registry=snapshot) + + @property + def memberships(self) -> Mapping[str, ThreadMembership]: + return self.registry.memberships + + def same_membership(self, earlier: MessageDisplayScope) -> bool: + return self.registry.same_tag_membership(earlier.memberships, self.tags) + + @property + def participant_query(self) -> tuple[str, tuple[object, ...]]: + return self.registry.tagged_names(self.tags) + + def includes_participant(self, name: str) -> bool: + return name in self.memberships and self.tags <= self.memberships[name].tags @dataclass(frozen=True, slots=True) @@ -1179,18 +1228,36 @@ class UnreadQuery: moved for some conversations differs from its earlier count only in those conversations' rows above the lower of their two high waters.""" - channel: str + scope: ChannelDisplayScope cursors: tuple[CursorRow, ...] lower_bound: int - predicate: str - parameters: tuple viewer_names: str + target_groups: tuple[tuple[str, ...], ...] = () + + @property + def channel(self) -> str: + return self.scope.channel + + @property + def predicate(self) -> str: + predicate, _ = self.scope.index_predicate + for targets in self.target_groups: + predicate = f"({predicate}) AND target IN ({', '.join('?' * len(targets))})" + return predicate + + @property + def parameters(self) -> tuple: + _, parameters = self.scope.index_predicate + return (*parameters, *(target for targets in self.target_groups for target in targets)) + + def same_membership(self, earlier: UnreadQuery) -> bool: + return self.scope.same_membership(earlier.scope) @property def counting(self) -> UnreadQuery: """The same rows and rule with no read cursor: what stays fixed while reading. The lower bound is the cursor's own floor over the scope's targets, so it moves with it.""" - return replace(self, cursors=(), lower_bound=0) + return replace(self, cursors=(), lower_bound=0, target_groups=()) def moved(self, earlier: UnreadQuery) -> tuple[UnreadQuery, ...]: """The earlier and current counts over only the conversations whose cursor moved; @@ -1210,8 +1277,7 @@ def within(self, targets: tuple[str, ...], floor: int) -> UnreadQuery: """This query over only these targets' rows above floor; every row at or below a moved conversation's lower high water is read under both cursors.""" return replace(self, lower_bound=max(self.lower_bound, floor), - predicate=f"({self.predicate}) AND target IN ({', '.join('?' * len(targets))})", - parameters=(*self.parameters, *targets)) + target_groups=(*self.target_groups, targets)) @dataclass(frozen=True, slots=True) @@ -1228,10 +1294,9 @@ def queries(self) -> tuple[UnreadQuery, ...]: names = json.dumps(self.viewer_names) return tuple( UnreadQuery( - scope.channel, + scope, ReadCursor.rows(self.registry, {"viewer": self.seen.counted(scope, self.registry)}), self.seen.lower_bound(scope, self.registry), - *scope.index_predicate, names, ) for scope in self.scopes diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 94eab9665f..fee9393217 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -70,7 +70,7 @@ ThreadStatus, ) from .thread_text import ForkedParent, NoParent -from .threads import GoalOwner, Thread, ThreadWorkState +from .threads import GoalOwner, Thread, ThreadMembership, ThreadWorkState from .turn_lease import ( FinishedTurn, FinishedTurnFence, NoFinishedTurn, StoredTurnRecord, TurnCompletion, TurnLeaseFence, TurnRecord, @@ -110,6 +110,9 @@ def incarnation(self, name: str) -> ThreadIncarnation: """This captured row owns identity without acquiring its full declaration.""" return self.threads.incarnation(self.require_canonical(name)) + def membership(self, name: str) -> ThreadMembership: + return self.threads.membership(self.require_canonical(name)) + def active_candidates(self) -> Iterator[Thread]: return (self.threads[name] for name, status in self.statuses.items() if status.may_be_active) @@ -336,6 +339,14 @@ def to_wire(self) -> dict[str, JsonValue]: def __reduce__(self) -> tuple[Callable[[object], Self], tuple[dict[str, JsonValue]]]: return type(self).from_wire, (self.to_wire(),) + @cached_property + def membership(self) -> ThreadMembership: + captured = {entry.name: entry.current(self) + for entry in FieldCodec.field_plans(ThreadBirthRecord) if entry.records} + return FieldCodec.project_record( + ThreadMembership, dict(self.raw), source=Thread, decoded_fields=captured, + ) + @cached_property def goal_owner(self) -> GoalOwner: captured = {entry.name: entry.current(self) @@ -393,14 +404,18 @@ def children(self, parent: str) -> tuple[Thread, ...]: ) def captured(self, name: str) -> ThreadRow: + raw = self._raw[name] if name not in self._captured: - self._captured[name] = ThreadRow.from_wire(self._raw[name]) + self._captured[name] = ThreadRow.from_wire(raw) return self._captured[name] def determining(self, name: str) -> GoalOwner: """Only a goal/role question acquires the declared goal-owner projection.""" return self._decoded[name] if name in self._decoded else self.captured(name).goal_owner + def membership(self, name: str) -> ThreadMembership: + return self._decoded[name] if name in self._decoded else self.captured(name).membership + def birth_record(self, name: str) -> ThreadBirthRecord: return self._decoded[name] if name in self._decoded else self.captured(name) @@ -445,11 +460,16 @@ def __contains__(self, name: object) -> bool: def __setitem__(self, name: str, thread: Thread) -> None: if thread.name != name: raise MalformedRecord("thread name differs from its declaration") - if not self._lookup_names or self._lookup_names.get( - thread.incarnation.require_recorded().lookup - ) != name: + if self._lookup_names: + lookup = thread.incarnation.require_recorded().lookup + if self._lookup_names.get(lookup) != name: + names = self._lookup_names.copy() + if name in self: + names.pop(self.incarnation(name).require_recorded().lookup) + names[lookup] = name + self._lookup_names = names + else: self._lookup_names = {} - self._captured = self._captured.copy() self._captured.pop(name, None) self._decoded[name] = self._binding(thread) self._raw.pop(name, None) @@ -457,8 +477,12 @@ def __setitem__(self, name: str, thread: Thread) -> None: def __delitem__(self, name: str) -> None: if name not in self: raise KeyError(name) - self._lookup_names = {} - self._captured = self._captured.copy() + if self._lookup_names: + names = self._lookup_names.copy() + names.pop(self.incarnation(name).require_recorded().lookup) + self._lookup_names = names + else: + self._lookup_names = {} self._captured.pop(name, None) self._raw.pop(name, None) self._decoded.pop(name, None) diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index 4f4da2529e..2095edcf74 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, replace from functools import cached_property, partial from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING, Generic, TypeVar from agent_comms.field_codec import FieldCodec, ThreadLookup @@ -18,7 +19,7 @@ from .messages import PublicSender from .routing import DeliveryScope from .thread_identity import ThreadIncarnation -from .threads import Thread, ThreadProvenance +from .threads import Thread, ThreadMembership, ThreadProvenance if TYPE_CHECKING: from .audience_manifest import FrozenRecipient @@ -178,6 +179,9 @@ def require_canonical(self, name: str) -> str: def require(self, name: str) -> Member: return self.threads[self.require_canonical(name)] + def membership(self, name: str) -> ThreadMembership: + return self.require(name) + @dataclass(frozen=True, slots=True) class SenderBindings: @@ -203,6 +207,45 @@ def require_alias_names(self) -> None: if any(target not in names or alias in names for alias, target in self.aliases.items()): raise RelationViolationError("Provenance alias has no exclusive retained owner") + @cached_property + def memberships(self) -> Mapping[str, ThreadMembership]: + """One immutable tag/identity relation from this namespace, including retained aliases.""" + canonical = {name: self.membership(name) for name in self.threads} + return MappingProxyType({ + **canonical, + **{alias: canonical[owner] for alias, owner in self.aliases.items()}, + }) + + @cached_property + def membership_json(self) -> str: + return FieldCodec.compact(FieldCodec.encode( + dict(self.memberships), dict[str, ThreadMembership], + )) + + def tagged_names(self, tags: frozenset[str]) -> tuple[str, tuple[object, ...]]: + """A relational tag predicate, not an expanded channel-member name list.""" + key = FieldCodec.field_plan(ThreadMembership, "tags").key + return ( + "SELECT member.key FROM json_each(?) AS member " + "WHERE NOT EXISTS (SELECT 1 FROM json_each(?) AS wanted " + "WHERE NOT EXISTS (SELECT 1 FROM json_each(member.value, ?) AS owned " + "WHERE owned.value = wanted.value))", + (self.membership_json, json.dumps(sorted(tags)), f"$.{key}"), + ) + + def same_tag_membership(self, earlier: Mapping[str, ThreadMembership], + tags: frozenset[str]) -> bool: + """Only a matching name or its actual birth changing invalidates that channel.""" + current = self.memberships + for name in current.keys() | earlier.keys(): + before = name in earlier and tags <= earlier[name].tags + after = name in current and tags <= current[name].tags + if before != after: + return False + if before and earlier[name].incarnation != current[name].incarnation: + return False + return True + def bound_to(self, root: Path) -> RegistryProvenance: """Recorded transcripts resolve under the root this namespace was captured from.""" threads = {name: replace(thread, session_file=thread.session_file.bound_to(root)) diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 12b03f5f9e..6ae2fea1fb 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -194,10 +194,21 @@ def configured(self, thread: Thread) -> Thread: @dataclass(frozen=True, kw_only=True) -class ThreadProvenance(ThreadBirthRecord): - """Recorded thread identity, without permission to own a process or start a turn.""" +class ThreadMembership(ThreadBirthRecord): + """The original thread identity and its declared channel tags.""" tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) + + def __post_init__(self) -> None: + super().__post_init__() + for tag in self.tags: + Tag.validate(tag) + + +@dataclass(frozen=True, kw_only=True) +class ThreadProvenance(ThreadMembership): + """Recorded thread identity, without permission to own a process or start a turn.""" + worktree: Checkout = field(kw_only=False) session_file: Annotated[SessionFile, RegistrationPolicy(InheritApplied)] = field( default_factory=NoSessionFile @@ -223,11 +234,6 @@ def writable_runtime(self) -> RuntimeLocation: def display_title(self) -> str: return self.title.otherwise(self.name) - def __post_init__(self) -> None: - super().__post_init__() - for tag in self.tags: - Tag.validate(tag) - @staticmethod def capture(thread: ThreadProvenance) -> ThreadProvenance: """Freeze only determining provenance from the original typed declaration.""" diff --git a/tests/test_bus_activity.py b/tests/test_bus_activity.py index d8687297cd..410e218431 100644 --- a/tests/test_bus_activity.py +++ b/tests/test_bus_activity.py @@ -8,7 +8,7 @@ from agent_comms.mentions import ThreadMention from agent_comms.messages import InfoKind, Message, NoticeRole from agent_comms.read_basis import ( - AllChannelDisplayScope, ChannelDisplayScope, MemberActivityDisplayScope, + AllChannelDisplayScope, ChannelDisplayScope, NamedActivityDisplayScope, ) from agent_comms.thread_identity import UserThreadRole @@ -32,12 +32,12 @@ def test_activity_family_tracks_original_roles_targets_and_member_scope(bus): scopes = ( (ChannelDisplayScope("#team", frozenset({"#team"})), ChannelActivity(30.0, 0.0)), (AllChannelDisplayScope("#any"), ChannelActivity(30.0, 20.0)), - (MemberActivityDisplayScope("#member", participant_names=frozenset({"mentioned"})), + (NamedActivityDisplayScope("#member", names=frozenset({"mentioned"})), ChannelActivity(20.0, 20.0)), - (MemberActivityDisplayScope("#member", participant_names=frozenset({"worker"})), + (NamedActivityDisplayScope("#member", names=frozenset({"worker"})), ChannelActivity(20.0, 20.0)), - (MemberActivityDisplayScope("#member", frozenset({"#team"}), - participant_names=frozenset({"human"})), + (NamedActivityDisplayScope("#member", frozenset({"#team"}), + names=frozenset({"human"})), ChannelActivity(30.0, 20.0)), ) with bus.reading() as captured, captured.observing() as db: From d66befedac1aa9f1e7446039554f401a483e1a8e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 23:28:52 -0400 Subject: [PATCH 12/24] Delete the stale ThreadRows.lookup_names left by the #3632 merge Two definitions in one class: the later uncached copy overrode the cached immutable map at line 432 and called incarnation as a method. One owner remains; the unused ThreadIncarnationRecord import goes with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/agent_comms/registry_document.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index b1e5df5750..f3e040df1e 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -61,7 +61,6 @@ RecordedThreadIncarnation, ThreadBirthRecord, ThreadIncarnation, - ThreadIncarnationRecord, ThreadAuthority, TurnIdentity, ) @@ -511,17 +510,6 @@ def checked_rows(cls, raw: object) -> dict[str, dict[str, JsonValue]]: raise MalformedRecord("thread name differs from its declaration") return dict(raw) - def lookup_names(self) -> dict[ThreadLookup, str]: - """Each row's creation lookup, read from its birth alone like the distinctness check: - resolving one owner never decodes the other threads.""" - return { - (self._decoded[name].incarnation if name in self._decoded - else ThreadIncarnationRecord(name, CreationTime.from_wire( - self._raw[name].get("created_at")).to_wire()).incarnation() - ).require_recorded().lookup: name - for name in self - } - def require_distinct_births(self) -> None: """Rows decode late, so a read of one thread never sees two sharing a creation lookup; a stored row's birth is read alone, without decoding the rest of its thread.""" From b5d760a8f8363231dbe0532ca658fcd6de2cf253 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 00:19:19 -0400 Subject: [PATCH 13/24] Keep channel membership and capability refusal on their declarations --- src/agent_comms/check_root.py | 2 +- src/agent_comms/messages.py | 19 +++-- src/agent_comms/read_basis.py | 9 +-- src/agent_comms/registry_document.py | 3 +- src/agent_comms/registry_provenance.py | 3 +- src/agent_comms/thread_membership.py | 23 ++++++ src/agent_comms/threads.py | 16 +---- tests/test_bus.py | 12 +++- tests/test_bus_activity.py | 6 +- tests/test_display_unread.py | 99 +++++++++++++++++++++++++- tests/test_named_target_contracts.py | 3 +- tests/test_owner_release_store.py | 11 ++- tests/test_sidebar_observation.py | 51 +++++++++++++ 13 files changed, 220 insertions(+), 37 deletions(-) create mode 100644 src/agent_comms/thread_membership.py diff --git a/src/agent_comms/check_root.py b/src/agent_comms/check_root.py index 39b9751480..ffccd6e239 100644 --- a/src/agent_comms/check_root.py +++ b/src/agent_comms/check_root.py @@ -94,7 +94,7 @@ def _populate(self, copied: Comms) -> None: targets = frozenset( target for name in names for target in catalog.targets_for(snapshot.require(name).tags) ) - scope = NamedActivityDisplayScope("#check", targets, names=selected | aliases) + scope = NamedActivityDisplayScope("#check", targets, participants=selected | aliases) with self.source.bus.bus.reading() as source: messages = scope.history(source, limit=self.max_records) copied.registry.store.prepare_native_root() diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 634cf931f7..684cbf1c11 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -12,7 +12,7 @@ import sqlite3 import time from abc import ABC, abstractmethod -from collections.abc import Iterable, Iterator, Mapping, Sequence +from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence from contextlib import closing from dataclasses import Field, dataclass, field, fields, make_dataclass, replace from functools import cache @@ -45,6 +45,7 @@ FieldCodec, InlineRecord, MessageId, + MalformedRecord, NullMember, PublicationExclude, ThreadLookup, @@ -439,16 +440,17 @@ def read_from(cls, db: sqlite3.Connection, seq: int) -> Self: def selected( cls, db: sqlite3.Connection, positions: str, parameters: tuple[object, ...], *, descending: bool = False, - ) -> Iterator[Self]: + ) -> Generator[Self]: """One cursor reads only the capability tables this value inherits.""" tables = cls.tables() + joined: tuple[type[BusTables], ...] = (BusRow, *tables) offsets = dict(zip(tables, accumulate( (len(BusRow.columns()), *(len(table.columns()) for table in tables))))) header = BusRow.qualified("seq") order = "DESC" if descending else "ASC" with closing(db.execute( f"WITH selected AS ({positions}) " - f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in (BusRow, *tables))} " + f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in joined)} " f'FROM "{BusRow.declared_name}" JOIN selected USING(seq) ' + " ".join(f'LEFT JOIN "{table.declared_name}" ON {table.qualified("seq")} = {header}' for table in tables) @@ -456,10 +458,13 @@ def selected( )) as cursor: for stored in cursor: original = BusRow.decode_at(stored, 0) - for table in tables: - if stored[offsets[table]] is None: - raise RelationViolationError(f"Bus row {original.seq} lacks {table.declared_name}") - yield cls.assembled(table.decode_at(stored, offsets[table]) for table in tables) + try: + value = cls.assembled(table.decode_at(stored, offsets[table]) for table in tables) + except MalformedRecord as error: + raise RelationViolationError( + f"Bus row {original.seq} has malformed required {cls.__name__} capability: {error}" + ) from error + yield value @classmethod def assembled(cls, rows: Iterable[CapabilityRow]) -> Self: diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index f9cd68b286..2e6dcd7e54 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -6,6 +6,7 @@ """ from __future__ import annotations + from agent_comms.repo import Checkout from .bus_tables import AwaitingAnswerSource @@ -35,6 +36,7 @@ from .projection_source import ProducedRow, ProjectionSource from .recorded_bus import RecordedBus, RecordedBusIdentity from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation +from .thread_membership import ThreadMembership class DisplayViewer(SubclassUnion, affix="Viewer"): @@ -311,7 +313,6 @@ def flush(self) -> None: from .registration import Registration from .registry_document import RegistrySnapshot, UserThreadLookup from .registry_provenance import RegistryProvenance - from .threads import ThreadMembership from .threads import Thread @@ -1182,14 +1183,14 @@ def _includes(self, sender: str, target: str, mentions: Iterable[str]) -> bool: class NamedActivityDisplayScope(ParticipantActivityDisplayScope): """An explicitly selected set, used by root checks rather than channel tags.""" - names: frozenset[str] = frozenset() + participants: frozenset[str] = frozenset() @property def participant_query(self) -> tuple[str, tuple[object, ...]]: - return "SELECT value FROM json_each(?)", (json.dumps(sorted(self.names)),) + return "SELECT value FROM json_each(?)", (json.dumps(sorted(self.participants)),) def includes_participant(self, name: str) -> bool: - return name in self.names + return name in self.participants @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index f3e040df1e..d369fd0c39 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -72,7 +72,8 @@ ThreadStatus, ) from .thread_text import ForkedParent, NoParent -from .threads import GoalOwner, Thread, ThreadMembership, ThreadWorkState +from .thread_membership import ThreadMembership +from .threads import GoalOwner, Thread, ThreadWorkState from .turn_lease import ( FinishedTurn, FinishedTurnFence, NoFinishedTurn, StoredTurnRecord, TurnCompletion, TurnLeaseFence, TurnRecord, diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index 2095edcf74..40d6497be1 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -19,7 +19,8 @@ from .messages import PublicSender from .routing import DeliveryScope from .thread_identity import ThreadIncarnation -from .threads import Thread, ThreadMembership, ThreadProvenance +from .thread_membership import ThreadMembership +from .threads import Thread, ThreadProvenance if TYPE_CHECKING: from .audience_manifest import FrozenRecipient diff --git a/src/agent_comms/thread_membership.py b/src/agent_comms/thread_membership.py new file mode 100644 index 0000000000..7ecdd0d4e0 --- /dev/null +++ b/src/agent_comms/thread_membership.py @@ -0,0 +1,23 @@ +"""The captured thread identity and its declared channel membership.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Annotated + +from .channel_targets import Tag +from .field_codec import RegistrationPolicy +from .registration_inheritance import InheritEmpty +from .thread_identity import ThreadBirthRecord + + +@dataclass(frozen=True, kw_only=True) +class ThreadMembership(ThreadBirthRecord): + """The original thread identity and its declared channel tags.""" + + tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) + + def __post_init__(self) -> None: + super().__post_init__() + for tag in self.tags: + Tag.validate(tag) diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 05ed87cab9..7d60e59b90 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Annotated, ClassVar from .agent_backend import AgentBackend, NewThreadBackend, RuntimeLocation -from .channel_targets import DeclaredTag, Tag +from .channel_targets import DeclaredTag from .child_process import NoProcess, ProcessIdentity, ProcessState from .cli_result import ThreadToolCatalogResult from .context_segments.wake_values import WakeWorkContext @@ -48,7 +48,6 @@ ) from .registration_inheritance import ( InheritApplied, - InheritEmpty, InheritPrevious, ) from .repo import Checkout @@ -68,6 +67,7 @@ TurnIdentity, UnobservedTools, ) +from .thread_membership import ThreadMembership from .thread_model import ( CatalogSelection, DeclaredModel, @@ -193,18 +193,6 @@ def configured(self, thread: Thread) -> Thread: return self.service_tier.configured(self.model.configured(thought)) -@dataclass(frozen=True, kw_only=True) -class ThreadMembership(ThreadBirthRecord): - """The original thread identity and its declared channel tags.""" - - tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) - - def __post_init__(self) -> None: - super().__post_init__() - for tag in self.tags: - Tag.validate(tag) - - @dataclass(frozen=True, kw_only=True) class ThreadProvenance(ThreadMembership): """Recorded thread identity, without permission to own a process or start a turn.""" diff --git a/tests/test_bus.py b/tests/test_bus.py index c33c1b7855..7dca38c925 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -55,7 +55,7 @@ RelationViolationError, UnclaimedError, ) -from agent_comms.field_codec import FieldCodec, GitCommitText, MessageId, MessageIdText, TimestampText +from agent_comms.field_codec import FieldCodec, GitCommitText, MalformedRecord, MessageId, MessageIdText, TimestampText from agent_comms.message_reference import MessageReference from agent_comms.read_basis import RepliesDisplayScope from agent_comms.response_acknowledgement import ( @@ -733,6 +733,16 @@ def build(seq, db): assert all(table.count(db) == 0 for table in TypedTable.members_with(BusTables)) +def test_required_capability_corruption_is_not_hidden_by_the_join(bus): + with bus.writing() as db: + # A retained header without its required capability rows is corruption, + # not an empty page or an absent optional field. + BusRow(1, PublicRecord, 1.0).insert(db) + with pytest.raises(RelationViolationError, match="malformed required Message capability") as refused: + Message.read_from(db, 1) + assert isinstance(refused.value.__cause__, MalformedRecord) + + def test_bulk_history_keeps_one_snapshot_across_pages(bus): assert tuple(bus.message_history()) == () originals = tuple(Message.from_message(bus.append(lambda seq, db: PublicRecord.from_message( diff --git a/tests/test_bus_activity.py b/tests/test_bus_activity.py index 410e218431..8b27186110 100644 --- a/tests/test_bus_activity.py +++ b/tests/test_bus_activity.py @@ -32,12 +32,12 @@ def test_activity_family_tracks_original_roles_targets_and_member_scope(bus): scopes = ( (ChannelDisplayScope("#team", frozenset({"#team"})), ChannelActivity(30.0, 0.0)), (AllChannelDisplayScope("#any"), ChannelActivity(30.0, 20.0)), - (NamedActivityDisplayScope("#member", names=frozenset({"mentioned"})), + (NamedActivityDisplayScope("#member", participants=frozenset({"mentioned"})), ChannelActivity(20.0, 20.0)), - (NamedActivityDisplayScope("#member", names=frozenset({"worker"})), + (NamedActivityDisplayScope("#member", participants=frozenset({"worker"})), ChannelActivity(20.0, 20.0)), (NamedActivityDisplayScope("#member", frozenset({"#team"}), - names=frozenset({"human"})), + participants=frozenset({"human"})), ChannelActivity(30.0, 20.0)), ) with bus.reading() as captured, captured.observing() as db: diff --git a/tests/test_display_unread.py b/tests/test_display_unread.py index 1d47bb85dd..e20ffec9d8 100644 --- a/tests/test_display_unread.py +++ b/tests/test_display_unread.py @@ -1,8 +1,10 @@ from dataclasses import replace +import pytest + from agent_comms.audience_manifest import FrozenRecipient from agent_comms.bus import Bus, DisplayUnread -from agent_comms.field_codec import ThreadLookup +from agent_comms.field_codec import FieldCodec, MalformedRecord, ThreadLookup from agent_comms.messages import InfoKind, Message, PublicRecord from agent_comms.notification_progress import UnrecordedProgress from agent_comms.presentation import MessageNotification @@ -11,9 +13,15 @@ ChannelDisplayScope, DisplayedConversation, DisplayUnreadSelection, + MemberActivityDisplayScope, ReadCursor, ) +from agent_comms.registry_document import ThreadRows from agent_comms.registry_provenance import RegistryProvenance +from agent_comms.repo import Checkout +from agent_comms.thread_status import RunningThreadStatus +from agent_comms.thread_text import GivenThreadText +from agent_comms.threads import Thread from agent_comms.thread_presentation import ( ThreadPresentation, WorkingThreadPresentationStatus, @@ -120,6 +128,95 @@ class RevisedRegistry(RegistryProvenance): assert fourth == source.display_unread(reread, through=source.revision()) +def test_tagged_unread_reuses_unchanged_membership_and_recounts_actual_tag_changes(comms, bus): + member = comms.registry.declare( + Thread("member", frozenset({"team"}), Checkout(comms.root.parent)), + status=RunningThreadStatus(), + ) + other = comms.registry.declare(Thread("other", frozenset(), Checkout(comms.root.parent))) + channel = comms.bus.require_channel("#team") + for sender, target in ((member.name, other.name), (other.name, member.name)): + bus.append(lambda seq, db, sender=sender, target=target: PublicRecord.from_message( + Message(sender, target, "member activity", InfoKind, seq=seq))) + + def selection(snapshot): + return DisplayUnreadSelection( + ReadCursor(), (MemberActivityDisplayScope.capture(channel, snapshot),), + ("viewer",), snapshot, + ) + + original = selection(comms.registry.snapshot()) + with bus.reading() as source: + first = DisplayUnread().advanced(source, original) + assert first.counts == {"#team": 2} + comms.registry.update_thread( + other.name, lambda thread: replace(thread, task=GivenThreadText("unrelated edit")), + ) + revised = selection(comms.registry.snapshot()) + assert revised.queries() == original.queries() + assert revised.queries()[0].same_membership(original.queries()[0]) + statements = [] + with bus.reading() as source, source.observing() as db: + changes = db.total_changes + db.set_trace_callback(statements.append) + assert first.advanced(source, revised).counts == first.counts + assert not any(query.startswith("WITH read_cursors") for query in statements) + assert db.total_changes == changes + bus.append(lambda seq, db: PublicRecord.from_message( + Message(other.name, member.name, "appended activity", InfoKind, seq=seq))) + statements.clear() + with bus.reading() as source, source.observing() as db: + db.set_trace_callback(statements.append) + appended = first.advanced(source, revised) + counted = [query for query in statements if query.startswith("WITH read_cursors")] + assert counted and all(f"seq > {first.through}" in query for query in counted) + assert appended.counts == {"#team": 3} + assert appended == source.display_unread(revised, through=source.revision()) + comms.registry.update_thread(member.name, lambda thread: replace(thread, tags=frozenset())) + retagged = selection(comms.registry.snapshot()) + assert not retagged.queries()[0].same_membership(revised.queries()[0]) + with bus.reading() as source: + recounted = appended.advanced(source, retagged) + assert recounted.counts == {"#team": 0} + assert recounted == source.display_unread(retagged, through=source.revision()) + + +def test_tag_membership_keeps_alias_births_and_defers_unselected_full_fields(comms): + member = comms.registry.declare( + Thread("member", frozenset({"team"}), Checkout(comms.root.parent)), + status=RunningThreadStatus(), + ) + unrelated = comms.registry.declare(Thread("other", frozenset(), Checkout(comms.root.parent))) + channel = comms.bus.require_channel("#team") + before = comms.registry.snapshot() + original = MemberActivityDisplayScope.capture(channel, before) + comms.registry.rename(member.name, "renamed") + renamed = comms.registry.snapshot() + raw = renamed.threads.to_wire() + raw[unrelated.name][FieldCodec.field_plan(Thread, "model").key] = [] + narrow = replace(renamed, threads=ThreadRows.from_wire(raw)) + scope = MemberActivityDisplayScope.capture(channel, narrow) + assert scope.includes_participant(member.name) + assert scope.includes_participant("renamed") + assert scope.memberships[member.name].incarnation == member.incarnation + assert scope.memberships["renamed"].incarnation == member.incarnation + assert not scope.includes_participant(unrelated.name) + assert not narrow.threads._decoded + assert original.includes_participant(member.name) + comms.registry.unregister("renamed") + comms.threads.delete("renamed") + replacement = comms.registry.declare( + Thread(member.name, frozenset({"team"}), Checkout(comms.root.parent)), + ) + recreated = MemberActivityDisplayScope.capture(channel, comms.registry.snapshot()) + assert replacement.incarnation != member.incarnation + assert not recreated.same_membership(original) + raw["renamed"][FieldCodec.field_plan(Thread, "tags").key] = 7 + malformed = replace(renamed, threads=ThreadRows.from_wire(raw)) + with pytest.raises(MalformedRecord): + MemberActivityDisplayScope.capture(channel, malformed).memberships + + def test_status_row_equality_ignores_an_older_notification_outside_shown_lines(): lookup = ThreadLookup.mint() older = MessageNotification( diff --git a/tests/test_named_target_contracts.py b/tests/test_named_target_contracts.py index b3599cd9f5..004e87c76c 100644 --- a/tests/test_named_target_contracts.py +++ b/tests/test_named_target_contracts.py @@ -9,6 +9,7 @@ from agent_comms.coordination_errors import DMScopeChanged from agent_comms.historical_views import ConversationObservation, DMDisplayHistory from agent_comms.message_page import ConversationMessagePageRequest +from agent_comms.messages import Message from agent_comms.read_basis import NamedViewer from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.thread_identity import BirthlessIncarnation @@ -82,7 +83,7 @@ def test_retained_dm_target_follows_rename_through_send_page_and_revision(tmp_pa assert [(row.canonical_thread, row.recipient_lookup) for row in delivery.recipients] == [ ("renamed", target.incarnation.require_recorded().lookup)] page = comms.views.dm_display_page(target, worktree=worktree) - assert page.messages == (original, sent) + assert page.messages == (Message.from_message(original), Message.from_message(sent)) assert page.selection.basis.peer_identity == target.incarnation generic_page = comms.views.history_page(target, viewer=NamedViewer(sent.sender)) assert generic_page.messages == page.messages diff --git a/tests/test_owner_release_store.py b/tests/test_owner_release_store.py index 8115c37640..46577bf310 100644 --- a/tests/test_owner_release_store.py +++ b/tests/test_owner_release_store.py @@ -13,14 +13,19 @@ def test_release_survives_metadata_changes_and_reads_only_its_row(tmp_path): comms = wire(tmp_path) originals = [comms.registry.declare(Thread(name, frozenset(), Checkout(tmp_path))) for name in ('worker', 'other')] - generation = comms.registry.snapshot().claim_generation('worker') + before = comms.registry.snapshot() + selected = tuple((thread, before.claim_generation(thread.name)) for thread in originals) + generation = before.claim_generation('worker') + # This store contract needs a stopped cut, not physical process retirement. + # NoProcess stop correctly produces no release; record the explicit fixture here. for thread in originals: - comms.owners.stop(thread.name) + comms.registry.unregister(thread.name) snapshot = comms.registry.snapshot() + releases = comms.owners.releases + releases.record(snapshot, selected) current = snapshot.threads['worker'] comms.registry.register(replace(current, title=GivenThreadText('changed title'), task=GivenThreadText('changed task')), status=snapshot.statuses['worker']) - releases = comms.owners.releases # An invalid unrelated row must not even be decoded by this lookup. with releases.writing() as db: OwnerReleaseEvidence.update(db, where='name=?', parameters=('other',), after=0) diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index b312d066ad..ddf2d04edd 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -205,6 +205,57 @@ def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( comms.registry.require_lookup(missing) +@pytest.mark.parametrize("change", ("add", "delete", "rebirth")) +@pytest.mark.parametrize("new_first", (False, True)) +def test_lookup_membership_stays_with_empty_and_unprimed_cuts( + comms, declare_reference, change, new_first, +): + original = declare_reference() + template = comms.registry.snapshot() + rows = ThreadRows.from_wire( + {} if change == "add" else {original.name: FieldCodec.encode_fields(original)} + ) + before = replace(template, threads=rows) + # Even an obtained empty view must remain empty after the shared rows change. + empty_view = before.lookup_names if change == "add" else None + if change == "delete": + del rows[original.name] + expected_after = () + else: + replacement = original + if change == "rebirth": + comms.threads.delete(original.name) + replacement = declare_reference(original.name) + assert replacement.incarnation != original.incarnation + rows[replacement.name] = replacement + expected_after = (replacement,) + after = replace(template, threads=rows) + expected_before = () if change == "add" else (original,) + cuts = ((before, expected_before), (after, expected_after)) + candidates = {original.incarnation.lookup, ThreadLookup.mint()} + candidates.update(thread.incarnation.lookup for thread in expected_after) + for snapshot, expected in reversed(cuts) if new_first else cuts: + lookup = snapshot.lookup_names + names = {thread.incarnation.lookup: thread.name for thread in expected} + assert tuple(lookup) == tuple(names) + assert tuple(lookup.keys()) == tuple(names) + assert len(lookup) == len(snapshot.threads) == len(names) + assert dict(lookup) == names + for key in candidates: + assert (key in lookup) == (key in names) + assert lookup.get(key) == names.get(key) + if key in names: + assert lookup[key] == names[key] + assert snapshot.born(key) is snapshot.threads[names[key]] + else: + with pytest.raises(KeyError): + lookup[key] + with pytest.raises(UnregisteredThreadError): + snapshot.born(key) + if empty_view is not None: + assert not empty_view and not tuple(empty_view) + + @pytest.mark.parametrize("decoded", (False, True)) def test_current_reference_acquires_only_captured_incarnations(comms, declare_reference, decoded): original = declare_reference(status=RunningThreadStatus()) From e1ee783f1a2354563ea6cb8806d49c877c37b439 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 01:34:09 -0400 Subject: [PATCH 14/24] Preserve scoped copy records and mutate actual producer bodies --- src/agent_comms/check_root.py | 8 ++++---- src/agent_comms/read_basis.py | 21 ++++++++++++--------- tests/test_bus.py | 23 +++++++++++++++++------ 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/agent_comms/check_root.py b/src/agent_comms/check_root.py index ffccd6e239..a9ce91a426 100644 --- a/src/agent_comms/check_root.py +++ b/src/agent_comms/check_root.py @@ -96,9 +96,9 @@ def _populate(self, copied: Comms) -> None: ) scope = NamedActivityDisplayScope("#check", targets, participants=selected | aliases) with self.source.bus.bus.reading() as source: - messages = scope.history(source, limit=self.max_records) + history = scope.history(source, limit=self.max_records) copied.registry.store.prepare_native_root() - Bus.install(Bus.path_in(copied.root), history=messages) + Bus.install(Bus.path_in(copied.root), history=history) copied.channels.catalog.replace(catalog) declarations = {} for name in names: @@ -131,7 +131,7 @@ def _populate(self, copied: Comms) -> None: edit.document.advance_claim(name) edit.document.owners.advance(name) edit.commit() - if messages: + if history: # Imported history is displayable, not fresh input for this root. # Position acceptance itself; human read markers own a different fact. privacy = copied.registry.store.privacy() @@ -141,7 +141,7 @@ def _populate(self, copied: Comms) -> None: AcceptanceFrontier( wire_root_id=privacy.namespace, recipient_lookup=thread.incarnation.require_recorded().lookup, - through_seq=messages[-1].seq, + through_seq=history[-1].seq, ).advance(db) def _session(self, original: Thread, root: Path, slot: str, project: Path) -> str: diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index a354039146..8c43421d48 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -307,7 +307,7 @@ def flush(self) -> None: from .history_views import HistoryViews from .message_bus import MessageBus from .message_page import MessagePage, PageBounds - from .messages import Message, MessageKind + from .messages import BusRecord, Message, MessageKind from .presentation import BusPresentation from .read_ledger import ReadDocument, ReadLedger from .registration import Registration @@ -877,20 +877,23 @@ def records( return source.scoped_page(predicate, parameters, lower=lower, upper=upper, descending=descending, limit=limit, kinds=kinds) - def history(self, source: BusReader, *, limit: int) -> tuple[Message, ...]: - """A bounded recent selection closed over its answers, without crossing this scope. + def history(self, source: BusReader, *, limit: int) -> tuple[BusRecord, ...]: + """Full copy records selected by this scope, closed over their disclosed answers. - A copied message keeps only the answers this scope discloses: a reference to a message - outside it would name that message, and its original stays on the source bus.""" - selected: dict[int, Message] = {} + Display pages stay lightweight. A copy acquires each original record on the held + reader so its declared history_record retains deliveries and removes claims. + References outside this scope stay on the source bus. + """ + selected: dict[int, BusRecord] = {} with source.reading() as reader: for message in self.records(reader, descending=True, limit=limit): - added: dict[int, Message] = {} + added: dict[int, BusRecord] = {} pending = [message] while pending and len(selected) + len(added) <= limit: - answer = pending.pop() - if answer.seq in selected or answer.seq in added: + candidate = pending.pop() + if candidate.seq in selected or candidate.seq in added: continue + answer = reader.lookup(candidate.seq) disclosed = tuple(original for original in reader.messages_for_references(answer.answers) if self.includes(original)) diff --git a/tests/test_bus.py b/tests/test_bus.py index 0ce9acced7..1491acc19a 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -124,8 +124,8 @@ def edit_inside( module = ast.parse(path.read_text()) declaration, = (node for node in module.body if isinstance(node, ast.ClassDef) and node.name == owner.__name__) - statement, = (node for node in declaration.body - if member in ProducedTable.bound_names(ast.unparse(node))) + statement = next(node for node in reversed(declaration.body) + if member in ProducedTable.bound_names(ast.unparse(node))) change(statement) path.write_text(ast.unparse(ast.fix_missing_locations(module)) + "\n") @@ -135,6 +135,11 @@ def return_no_rows(statement: ast.stmt) -> None: statement.body = [ast.Return(value=ast.Tuple(elts=[], ctx=ast.Load()))] +def return_inherited_routes(statement: ast.stmt) -> None: + assert isinstance(statement, ast.FunctionDef) + statement.body = ast.parse("return super().route_rows(message)").body + + def widen_spelling(statement: ast.stmt) -> None: assert isinstance(statement, ast.Assign) statement.value = ast.BinOp(left=statement.value, op=ast.Add(), right=ast.Constant(1)) @@ -247,12 +252,15 @@ def test_projection_digest_tracks_actual_delivery_production(projection_source: assert after[CommanderUpdatePosition.declared_name] == before[CommanderUpdatePosition.declared_name] -@pytest.mark.parametrize("owner", (MessageRoutes, ClaimNoticeKind)) +@pytest.mark.parametrize("owner, change", ( + (MessageRoutes, return_no_rows), + (ClaimNoticeKind, return_inherited_routes), +)) def test_projection_digest_tracks_inherited_routing( - projection_source: Path, owner: type, + projection_source: Path, owner: type, change: Callable[[ast.stmt], None], ) -> None: before = json.loads(projection_source_digest(projection_source)) - edit_inside(projection_source, owner, "route_rows", return_no_rows) + edit_inside(projection_source, owner, "route_rows", change) after = json.loads(projection_source_digest(projection_source)) assert after[BusRouteCounts.declared_name] != before[BusRouteCounts.declared_name] assert after[AnsweredSource.declared_name] == before[AnsweredSource.declared_name] @@ -451,8 +459,11 @@ def test_record_family_rebuild_and_history_install_preserve_scope(bus, declarati assert bus.lookup_message(record.message_id) == record assert bus.page(record.seq + 1, 10) == (record, original) scope = RepliesDisplayScope(frozenset({"author"}), frozenset({"worker"})) + history = scope.history(bus, limit=2) + assert history == (original, record) + assert tuple(type(item) for item in history) == (type(original), declaration) copied = Bus.install( - Bus.path_in(tmp_path / "selected-history"), history=scope.history(bus, limit=2), + Bus.path_in(tmp_path / "selected-history"), history=history, ) kept = copied.lookup(record.seq) assert kept == record.history_record From 1f3a4c3f13491c3c5d54321b408c7a18830e970c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 03:18:35 -0400 Subject: [PATCH 15/24] Hold one display selection and bus cut across history pages --- src/agent_comms/bus.py | 4 ++- src/agent_comms/historical_views.py | 55 ++++++++++++++++------------- src/agent_comms/history_views.py | 19 +++++++++- src/agent_comms/locked_store.py | 17 +++++---- src/agent_comms/presentation.py | 49 +++++++++++++++++++++++-- src/agent_comms/read_basis.py | 24 +++++++++---- src/agent_comms/store_files.py | 9 ++--- src/agent_comms/threads.py | 4 +-- 8 files changed, 134 insertions(+), 47 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index 5dd4d4a3ac..4063763c86 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -508,7 +508,9 @@ def path(self) -> Path: @contextmanager def observing(self) -> Iterator[sqlite3.Connection]: - with SQLiteFile.opened_reader(self.path) as db: + # A captured display cut moves between the preparation owner's workers. + # Its presentation serializes page use and retirement; it remains read-only. + with SQLiteFile.opened_reader(self.path, check_same_thread=False) as db: yield db diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index 19e2e8a795..5d775ac178 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -76,12 +76,23 @@ def viewer(self) -> DisplayViewer: def live_page(self, presentation: BusPresentation, bounds: PageBounds) -> MessagePage: """Bind the query to the bus owner's snapshot and read that exact source.""" - with presentation.bus.snapshot() as (basis, source): + with presentation.snapshot(self.viewer) as (basis, source): request = self.page_request(self.capture(basis.registry), bounds) return request.read_opened(source) + @contextmanager + def reading(self, presentation: BusPresentation) -> Iterator[Callable[[PageBounds], MessagePage]]: + """Borrow one cut for the first tail, subsequent pages and scroll-back. + + The caller retains this lifetime only while its conversation revision is + unchanged, and retires it off the UI thread when that source is replaced. + """ + with presentation.reading(self.viewer) as captured: + yield partial(self.page, captured) + def page(self, presentation: BusPresentation, bounds: PageBounds = NEWEST_PAGE) -> MessagePage: - return presentation.bus.history.integrated_page(self, presentation, bounds) + with presentation.reading(self.viewer) as captured: + return captured.bus.history.integrated_page(self, captured, bounds) @abstractmethod def capture(self, snapshot: RegistryProvenance) -> MessageDisplayScope: @@ -238,19 +249,16 @@ def viewer(self) -> NamedViewer: def live_page(self, presentation: BusPresentation, bounds: PageBounds) -> MessagePage: bus = presentation.bus - snapshot = presentation.registry.snapshot() - peer = self.second.selected_name(snapshot) - missing = peer not in snapshot.threads - if missing and bus.history.threads(peer): - return MessagePage((), False, False, NoDisplaySelection()) - return DMDisplayBasis.fetch_page( - bus, - presentation.registry, - bus.bus.path.parent, - snapshot.require(self.first), - self.second, - worktree=self.worktree, bounds=bounds, - ) + with presentation.snapshot(self.viewer) as (basis, source): + snapshot = basis.registry + peer = self.second.selected_name(snapshot) + missing = peer not in snapshot.threads + if missing and bus.history.threads(peer): + return MessagePage((), False, False, NoDisplaySelection()) + return DMDisplayBasis.fetch_captured( + bus, presentation.registry, bus.bus.path.parent, + self.second, basis, source, worktree=self.worktree, bounds=bounds, + ) @dataclass(frozen=True, slots=True) @@ -536,15 +544,14 @@ def page(self, view: HistoryView, presentation: BusPresentation, bounds: PageBou snapshot = source.provenance current = replace(bounds, traversal=traversal.for_source(index == start)) request = view.page_request(view.capture(snapshot), current) - current_snapshot = presentation.registry.snapshot() - document = presentation.bus.reads.read() - opened = Bus(Bus.path_in(Path(source.root))) - raw = request.read_opened(opened) - projected = replace(raw, messages=tuple( - HistoricalMessage.project(message, source, index, snapshot) - for message in raw.messages)) - page = replace(projected, selection=view.viewer.historical_selection( - projected, presentation, source, opened, document, current_snapshot)) + with presentation.snapshot(view.viewer) as (basis, _): + opened = Bus(Bus.path_in(Path(source.root))) + raw = request.read_opened(opened) + projected = replace(raw, messages=tuple( + HistoricalMessage.project(message, source, index, snapshot) + for message in raw.messages)) + page = replace(projected, selection=view.viewer.historical_selection( + projected, presentation, source, opened, basis.document, basis.registry)) if page.messages: return replace( page, diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index e5c1c1571e..2ccc0d0db9 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -8,7 +8,8 @@ import logging import time from abc import abstractmethod -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence +from contextlib import contextmanager from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, Annotated @@ -250,6 +251,22 @@ def dm_display_page( viewer = self.messaging.user_identity(worktree) return DMDisplayHistory(viewer.name, peer, worktree).page(self.presentation, bounds) + @contextmanager + def dm_display_reading( + self, peer: ThreadTarget, *, worktree: Checkout, + ) -> Iterator[Callable[[PageBounds], MessagePage]]: + viewer = self.messaging.user_identity(worktree) + with DMDisplayHistory(viewer.name, peer, worktree).reading(self.presentation) as pages: + yield pages + + @contextmanager + def channel_display_reading( + self, target: str, *, viewer: DisplayViewer = AnonymousViewer(), + ) -> Iterator[Callable[[PageBounds], MessagePage]]: + channel = self.bus.require_channel(target) + with ChannelDisplayHistory(channel, viewer).reading(self.presentation) as pages: + yield pages + def display_viewer(self, worktree: Checkout) -> NamedViewer: """Select the human reader; page capture resolves it in its own snapshot.""" return NamedViewer(self.messaging.user_identity(worktree).name) diff --git a/src/agent_comms/locked_store.py b/src/agent_comms/locked_store.py index 2713c674f1..55dd843182 100644 --- a/src/agent_comms/locked_store.py +++ b/src/agent_comms/locked_store.py @@ -785,10 +785,11 @@ def _configure_reader(cls, db: sqlite3.Connection) -> sqlite3.Connection: @classmethod def _open_reader( - cls, path: Path, contention: StoreContention + cls, path: Path, contention: StoreContention, *, check_same_thread: bool = True, ) -> sqlite3.Connection: return cls._configure_reader( - contention.reader(path.resolve().as_uri() + "?mode=ro") + contention.reader(path.resolve().as_uri() + "?mode=ro", + check_same_thread=check_same_thread) ) @classmethod @@ -802,10 +803,12 @@ def _acquire_snapshot(cls, db: sqlite3.Connection) -> sqlite3.Connection: @classmethod def read_snapshot( - cls, path: Path, contention: StoreContention = DEFAULT_STORE_CONTENTION + cls, path: Path, contention: StoreContention = DEFAULT_STORE_CONTENTION, + *, check_same_thread: bool = True, ) -> sqlite3.Connection: """Open and acquire one consistent read-only snapshot with SQLite's busy handler.""" - return cls._acquire_snapshot(cls._open_reader(path, contention)) + return cls._acquire_snapshot(cls._open_reader( + path, contention, check_same_thread=check_same_thread)) @classmethod def recovering_snapshot(cls, path: Path) -> sqlite3.Connection: @@ -816,12 +819,14 @@ def recovering_snapshot(cls, path: Path) -> sqlite3.Connection: @classmethod @contextmanager def opened_reader( - cls, path: Path, contention: StoreContention = DEFAULT_STORE_CONTENTION + cls, path: Path, contention: StoreContention = DEFAULT_STORE_CONTENTION, + *, check_same_thread: bool = True, ) -> Iterator[sqlite3.Connection]: """Read existing committed data without initializing storage or permitting writes.""" with CoordinationReadUnavailable.checking(): try: - with closing(cls.read_snapshot(path, contention)) as db: + with closing(cls.read_snapshot(path, contention, + check_same_thread=check_same_thread)) as db: try: yield db finally: diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 95969433db..94bbfced98 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -5,10 +5,11 @@ from abc import ABC, abstractmethod from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field, replace from datetime import datetime from pathlib import Path +from threading import RLock from typing import TYPE_CHECKING, Annotated, ClassVar from . import relationships @@ -529,11 +530,27 @@ def __init__( self._display_unread = DisplayUnread() @contextmanager - def snapshot(self, viewer: NamedViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + def snapshot(self, viewer: DisplayViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: """Borrow the bus-owned source cut without preparing a second store.""" with self.bus.snapshot(viewer) as selected: yield selected + @contextmanager + def reading(self, viewer: DisplayViewer) -> Iterator[CapturedBusPresentation]: + """One page-source lifetime owns the SQLite cut and its selection/read document. + + The preparation owner may move a read between workers. A captured presentation + serializes page use and retirement, without keeping any store exclusion held. + """ + with ExitStack() as lifetime: + basis, source = lifetime.enter_context(self.snapshot(viewer)) + captured = CapturedBusPresentation(self, basis, source) + try: + yield captured + finally: + with captured.use: + lifetime.close() + def roster( self, viewer: str, visibility: RosterVisibility = DEFAULT_ROSTER, ) -> CoordinationSnapshot: @@ -562,7 +579,7 @@ def channel_page( ) -> MessagePage: """Local display projection; underlying channel history remains target-owned.""" channel = CommandTarget.named(target).require_channel() - with self.bus.snapshot(viewer) as (basis, source): + with self.snapshot(viewer) as (basis, source): return basis.viewer.read_page(basis.scope(channel.name), bounds, basis, source) def mark_channel_read( @@ -592,6 +609,32 @@ def display_view_metrics( return activity, counted.counts +class CapturedBusPresentation(BusPresentation): + """The existing display API borrowing one original cut, never a second store. + + Its caller closes the reading when the conversation source is replaced or retired. + Read acknowledgements still go through the live ledger and identity validation. + """ + + def __init__(self, original: BusPresentation, basis: CapturedSelection, source: BusSnapshot): + super().__init__(original.bus, original.registry, original.catalog, + original.agents, original.goals) + self.basis, self.source = basis, source + self.use = RLock() + + @contextmanager + def snapshot(self, viewer: DisplayViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + with self.use: + if viewer.resolve(self.basis.registry) != self.basis.viewer: + raise ValueError("A captured display cut belongs to its original viewer") + yield self.basis, self.source + + @contextmanager + def reading(self, viewer: DisplayViewer) -> Iterator[CapturedBusPresentation]: + with self.snapshot(viewer): + yield self + + @dataclass(frozen=True, slots=True) class ChannelView(ToolResult): """Wire form: the channel's own fields beside its roster; agent counts stay in process.""" diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 8c43421d48..9d9dd4d1bd 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -10,7 +10,7 @@ from agent_comms.repo import Checkout from .bus_tables import AwaitingAnswerSource -from .messages import MessageInput +from .messages import BusRecord, MessageInput from .class_fact import ClassFact import asyncio @@ -307,7 +307,7 @@ def flush(self) -> None: from .history_views import HistoryViews from .message_bus import MessageBus from .message_page import MessagePage, PageBounds - from .messages import BusRecord, Message, MessageKind + from .messages import Message, MessageKind from .presentation import BusPresentation from .read_ledger import ReadDocument, ReadLedger from .registration import Registration @@ -730,16 +730,28 @@ def fetch_page( Fetching is not paint proof. A UI may use the basis only after proving that the corresponding inbound tail was contiguous and visibly painted. """ - snapshot = registry.snapshot() - viewer_name = snapshot.canonical_name(viewer.name) + with bus.snapshot(NamedViewer(viewer.name)) as (selection, source): + return cls.fetch_captured( + bus, registry, root, peer, selection, source, + worktree=worktree, bounds=bounds, + ) + + @classmethod + def fetch_captured( + cls, bus: MessageBus, registry: Registration, root: Path, + peer: ThreadTarget, selection: CapturedSelection, source: BusSnapshot, + *, worktree: Checkout, bounds: PageBounds, + ) -> MessagePage: + """Build each DM page from the page source's original identity/read cut.""" + snapshot = selection.registry + viewer_name = selection.require_viewer() peer_name = peer.selected_name(snapshot) scope = DMDisplayScope.capture_human(viewer_name, peer_name, snapshot) viewer_thread = snapshot.threads[viewer_name] peer_thread = snapshot.participant(peer_name) viewer_names, peer_names = scope.first_names, scope.second_names - document = bus.reads.read_conversation(viewer_name, scope.read_key(viewer_name, peer_name, snapshot), snapshot) result = bounds.conversation(scope).displayed( - bus.reads, document, viewer_name, snapshot, snapshot, bus.bus, + selection.reads, selection.document, viewer_name, snapshot, snapshot, source, ) page, displayed = result.page, result.displayed root_info = root.stat() diff --git a/src/agent_comms/store_files.py b/src/agent_comms/store_files.py index 36627b072b..777cff83b0 100644 --- a/src/agent_comms/store_files.py +++ b/src/agent_comms/store_files.py @@ -454,8 +454,9 @@ class StoreContention(ABC): @abstractmethod def timeout(self) -> float: ... - def reader(self, uri: str) -> sqlite3.Connection: - return sqlite3.connect(uri, uri=True, isolation_level=None, timeout=self.timeout) + def reader(self, uri: str, *, check_same_thread: bool = True) -> sqlite3.Connection: + return sqlite3.connect(uri, uri=True, isolation_level=None, timeout=self.timeout, + check_same_thread=check_same_thread) def writer( self, path: str | os.PathLike[str], *, @@ -479,9 +480,9 @@ class StoreBusy(StoreContention): timeout: ClassVar[float] = 0.0 - def reader(self, uri: str) -> sqlite3.Connection: + def reader(self, uri: str, *, check_same_thread: bool = True) -> sqlite3.Connection: with CoordinationReadUnavailable.checking(): - return super().reader(uri) + return super().reader(uri, check_same_thread=check_same_thread) DEFAULT_STORE_CONTENTION = StoreWait() diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 71cade6b79..fe1441fcc5 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Annotated, ClassVar from .agent_backend import AgentBackend, NewThreadBackend, RuntimeLocation -from .channel_targets import CommandersTag, DeclaredTag, Tag +from .channel_targets import CommandersTag, DeclaredTag from .child_process import NoProcess, ProcessIdentity, ProcessState from .cli_result import ThreadToolCatalogResult from .context_segments.wake_values import WakeWorkContext @@ -60,8 +60,8 @@ AgentThreadRole, ObservedTurn, OfferedTools, - ThreadIncarnation, ThreadBirthRecord, + ThreadIncarnation, ThreadRole, TurnFence, TurnIdentity, From 9ef21625b8ab737173983e8b32c2f85ff53c835b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 03:27:01 -0400 Subject: [PATCH 16/24] Keep captured reader retirement inside its page-use owner --- src/agent_comms/presentation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 94bbfced98..4949b73ada 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -548,7 +548,7 @@ def reading(self, viewer: DisplayViewer) -> Iterator[CapturedBusPresentation]: try: yield captured finally: - with captured.use: + with captured.reading(viewer): lifetime.close() def roster( @@ -620,11 +620,11 @@ def __init__(self, original: BusPresentation, basis: CapturedSelection, source: super().__init__(original.bus, original.registry, original.catalog, original.agents, original.goals) self.basis, self.source = basis, source - self.use = RLock() + self._use = RLock() @contextmanager def snapshot(self, viewer: DisplayViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: - with self.use: + with self._use: if viewer.resolve(self.basis.registry) != self.basis.viewer: raise ValueError("A captured display cut belongs to its original viewer") yield self.basis, self.source From 44bbc97e218f178ab6c01f5523500dafb6cf5ba5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 05:45:33 -0400 Subject: [PATCH 17/24] Give each captured thread row its own lazy decoded value --- src/agent_comms/bus.py | 8 +- src/agent_comms/channel_targets.py | 4 +- src/agent_comms/field_codec.py | 8 +- src/agent_comms/historical_views.py | 4 +- src/agent_comms/messages.py | 21 ++-- src/agent_comms/presentation.py | 8 +- src/agent_comms/registry_document.py | 142 ++++++++++++-------------- src/agent_comms/relationships.py | 23 +++-- src/agent_comms/thread_identity.py | 4 +- src/agent_comms/thread_name.py | 6 +- tests/test_display_unread.py | 2 +- tests/test_registration_components.py | 35 ++++++- tests/test_sidebar_observation.py | 17 +-- 13 files changed, 171 insertions(+), 111 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index 6d02d17852..393814897d 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -254,7 +254,10 @@ def page(self, before: int, limit: int) -> tuple[BusRecord, ...]: def input_page(self, before: int, limit: int) -> tuple[MessageInput, ...]: """The same global sequence window, without attachment/report/answer acquisition.""" with self.observing() as db: - return tuple(MessageInput.read_from(db, row.seq) for row in self._page_rows(db, before, limit)) + return tuple( + MessageInput.read_from(db, row.seq) + for row in self._page_rows(db, before, limit) + ) @staticmethod def _page_rows(db: sqlite3.Connection, before: int, limit: int) -> list[BusRow]: @@ -477,7 +480,8 @@ def count_unread( counts[query] = FieldCodec.decode(int, db.execute( f'WITH {ReadCursor.sql("?")}, requests AS (' f'SELECT \'viewer\' AS actor,* FROM "{Addressed.table.declared_name}" ' - f'JOIN "{MessageInput.table.declared_name}" USING(seq) WHERE seq > ? AND seq <= ? ' + f'JOIN "{MessageInput.table.declared_name}" USING(seq) ' + f'WHERE seq > ? AND seq <= ? ' f'AND ({query.predicate}) AND sender NOT IN (SELECT value FROM json_each(?))), ' f'{ReadCursor.joined_sql} SELECT count(*) FROM cursors WHERE {ReadCursor.unread_sql}', (CursorRow.binding(query.cursors), max(after, query.lower_bound), through, diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index f9347cfbd2..cb450e01c8 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -420,9 +420,7 @@ def bindings[C: CliCommand]( def validate(self, message: Message) -> None: if message.sender == self.name and message.type.conversation_message: raise RelationViolationError(f"Thread {message.sender!r} cannot message itself.") - ThreadName.require(self.name, ValueError( - f"Message target {self.name!r} is not a thread name or #channel." - )) + ThreadName.require(self.name) def wake_policy(self, recipient: FrozenRecipient, mentions: frozenset[str]) -> FullWake: return FullWake() diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 4ac23898ee..6583b50434 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -3662,8 +3662,12 @@ def decode_entries( ) -> T: if not isinstance(data, dict): raise MalformedRecord("Expected a record object; fix: supply a JSON object.") - supplied = {key for entry in entries if entry.name in decoded_fields for key in entry.wire_keys} - problems = {key: issue for key, issue in self.missing_fields(data).items() if key not in supplied} + supplied = { + key for entry in entries if entry.name in decoded_fields for key in entry.wire_keys + } + problems = { + key: issue for key, issue in self.missing_fields(data).items() if key not in supplied + } unknown = list(self.unknown_fields(data)) target = self.target values = dict(decoded_fields) diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index 5d775ac178..cdb5d80a96 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -491,7 +491,9 @@ def _require_revision(self, revision: FileObservation) -> None: if FileRevision.capture(self.path) != revision: raise HistoricalSourceChanged("Historical catalogue changed; refresh history") - def attach(self, source_root: Path, *, source_read: HistorySource | None = None) -> HistorySource: + def attach( + self, source_root: Path, *, source_read: HistorySource | None = None, + ) -> HistorySource: """Prepare a source without archive exclusion, then publish it exactly once.""" source_root = source_root.resolve() if source_root == self.path.parent.resolve(): diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index cdfa0348f4..240f9d9d8c 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -446,8 +446,11 @@ def selected( """One cursor reads only the capability tables this value inherits.""" tables = cls.tables() joined: tuple[type[BusTables], ...] = (BusRow, *tables) - offsets = dict(zip(tables, accumulate( - (len(BusRow.columns()), *(len(table.columns()) for table in tables))))) + offsets = dict(zip( + tables, + accumulate((len(BusRow.columns()), *(len(table.columns()) for table in tables))), + strict=False, # The final accumulated value is the row end, not a table start. + )) header = BusRow.qualified("seq") order = "DESC" if descending else "ASC" with closing(db.execute( @@ -461,10 +464,13 @@ def selected( for stored in cursor: original = BusRow.decode_at(stored, 0) try: - value = cls.assembled(table.decode_at(stored, offsets[table]) for table in tables) + value = cls.assembled( + table.decode_at(stored, offsets[table]) for table in tables + ) except MalformedRecord as error: raise RelationViolationError( - f"Bus row {original.seq} has malformed required {cls.__name__} capability: {error}" + f"Bus row {original.seq} has malformed required " + f"{cls.__name__} capability: {error}" ) from error yield value @@ -481,8 +487,11 @@ def history( ) -> Iterator[BusCapability]: """Records after one sequence, using the same joined cursor for full and tail replay.""" tables = TypedTable.members_with(CapabilityRow) - offsets = dict(zip(tables, accumulate( - (len(BusRow.columns()), *(len(table.columns()) for table in tables))))) + offsets = dict(zip( + tables, + accumulate((len(BusRow.columns()), *(len(table.columns()) for table in tables))), + strict=False, # The final accumulated value is the row end, not a table start. + )) header = BusRow.qualified("seq") predicate, parameters = ("", ()) if kinds is None else ( f' AND {BusRow.qualified("kind")} IN ({", ".join("?" for _ in kinds)})', diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index b1176735a0..26a2b788d9 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -767,7 +767,9 @@ def activity_detail(self) -> str: class NoThreadView(NullMember): """A named navigation target has no captured thread presentation.""" - def sort_key(self, order: type[ThreadSort], target: str, sent: float) -> tuple[float, float, str]: + def sort_key( + self, order: type[ThreadSort], target: str, sent: float, + ) -> tuple[float, float, str]: return order.key(target, 0, 0, 0) @@ -796,7 +798,9 @@ def roster( snapshot.seen_at(thread.name), snapshot.owner_binding(thread.name), ) for thread in threads) - def sort_key(self, order: type[ThreadSort], target: str, sent: float) -> tuple[float, float, str]: + def sort_key( + self, order: type[ThreadSort], target: str, sent: float, + ) -> tuple[float, float, str]: return order.key(target, self.thread.birth, self.activity.timestamp, sent) @property diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index d38f2d6d81..ede72f8f31 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -324,30 +324,50 @@ def __init__(self, error: Exception) -> None: @dataclass(frozen=True) -class ThreadRow(ThreadBirthRecord, DeclaredWireType): - """A captured birth and original wire row; goal fields are acquired only on demand.""" +class ThreadRow(DeclaredWireType): + """One retained row owns its lazy birth, narrow projections and bound Thread.""" wire_type = Thread - raw: Annotated[Mapping[str, JsonValue], WireTransient()] = field(kw_only=True) + raw: Annotated[Mapping[str, JsonValue], WireTransient()] + binding: Annotated[Callable[[Thread], Thread], WireTransient()] = field(compare=False) @classmethod def from_wire(cls, raw: object) -> Self: if not isinstance(raw, dict): raise MalformedRecord("thread row must be an object") - return FieldCodec.project_record( - cls, raw, source=Thread, - decoded_fields={"raw": MappingProxyType(raw)}, - ) + return cls(MappingProxyType(raw), ThreadRows.unbound) + + @classmethod + def from_thread(cls, thread: Thread, binding: Callable[[Thread], Thread]) -> Self: + row = cls.from_wire(FieldCodec.encode_fields(thread)).bound(binding) + row.__dict__["decoded"] = binding(thread) + return row + + def bound(self, binding: Callable[[Thread], Thread]) -> Self: + if self.binding == binding: + return self + row = replace(self, binding=binding) + if "decoded" in self.__dict__: + row.__dict__["decoded"] = binding(self.decoded) + return row def to_wire(self) -> dict[str, JsonValue]: return dict(self.raw) - def __reduce__(self) -> tuple[Callable[[object], Self], tuple[dict[str, JsonValue]]]: - return type(self).from_wire, (self.to_wire(),) + def __reduce__(self) -> tuple[ + Callable[[object], Self], tuple[dict[str, JsonValue]], + dict[str, Callable[[Thread], Thread]], + ]: + # The wire stays portable; pickle also retains this cut's physical binding. + return type(self).from_wire, (self.to_wire(),), {"binding": self.binding} + + @cached_property + def birth_record(self) -> ThreadBirthRecord: + return FieldCodec.project_record(ThreadBirthRecord, dict(self.raw), source=Thread) @cached_property def captured_birth(self) -> Mapping[str, object]: - return MappingProxyType({entry.name: entry.current(self) + return MappingProxyType({entry.name: entry.current(self.birth_record) for entry in FieldCodec.field_plans(ThreadBirthRecord) if entry.records}) @@ -363,22 +383,23 @@ def goal_owner(self) -> GoalOwner: GoalOwner, dict(self.raw), source=Thread, decoded_fields=self.captured_birth, ) + @cached_property def decoded(self) -> Thread: - owner = self.goal_owner - values = {entry.name: entry.current(owner) - for entry in FieldCodec.field_plans(GoalOwner) if entry.records} - return FieldCodec.decode_record(Thread, dict(self.raw), decoded_fields=values) + try: + owner = self.goal_owner + values = {entry.name: entry.current(owner) + for entry in FieldCodec.field_plans(GoalOwner) if entry.records} + thread = FieldCodec.decode_record(Thread, dict(self.raw), decoded_fields=values) + except ValueError as error: + raise InvalidRegistryDocument(error) from error + return self.binding(thread) class ThreadRows(ExternalJsonShape, WireValue, MutableMapping[str, Thread]): """A revision's thread rows, decoded and bound only when read.""" def __init__(self) -> None: - # checked_rows proved each stored row an object, so the type keeps that proof. - self._raw: dict[str, dict[str, JsonValue]] = {} - self._decoded: dict[str, Thread] = {} - # Each cut owns its memo of immutable captured rows. - self._captured: dict[str, ThreadRow] = {} + self._rows: dict[str, ThreadRow] = {} self._binding: Callable[[Thread], Thread] = self.unbound @staticmethod @@ -387,17 +408,15 @@ def unbound(thread: Thread) -> Thread: def bind(self, binding: Callable[[Thread], Thread]) -> None: self._binding = binding - self._decoded = {name: binding(thread) for name, thread in self._decoded.items()} + self._rows = {name: row.bound(binding) for name, row in self._rows.items()} def __eq__(self, other: object) -> bool: - """Rows still stored on both sides compare as their JSON; only rows that differ there, - or that either side wrote, are decoded, so a one-row edit never decodes the registry. + """Equal retained wire needs no full decoding; differing rows compare their Threads. Rows equal only rows: a plain mapping is a different value.""" if not isinstance(other, ThreadRows): return NotImplemented return self.keys() == other.keys() and all( - (name in self._raw and name in other._raw and self._raw[name] == other._raw[name]) - or self[name] == other[name] + self._rows[name].raw == other._rows[name].raw or self[name] == other[name] for name in self) def children(self, parent: str) -> tuple[Thread, ...]: @@ -406,25 +425,18 @@ def children(self, parent: str) -> tuple[Thread, ...]: expected = FieldCodec.encode(ForkedParent(parent)) return tuple( self[name] for name in self - if (self._decoded[name].parent.named(parent) if name in self._decoded - else self._raw[name].get(key) == expected) + if self._rows[name].raw.get(key) == expected ) - def captured(self, name: str) -> ThreadRow: - raw = self._raw[name] - if name not in self._captured: - self._captured[name] = ThreadRow.from_wire(raw) - return self._captured[name] - def determining(self, name: str) -> GoalOwner: """Only a goal/role question acquires the declared goal-owner projection.""" - return self._decoded[name] if name in self._decoded else self.captured(name).goal_owner + return self._rows[name].goal_owner def membership(self, name: str) -> ThreadMembership: - return self._decoded[name] if name in self._decoded else self.captured(name).membership + return self._rows[name].membership def birth_record(self, name: str) -> ThreadBirthRecord: - return self._decoded[name] if name in self._decoded else self.captured(name) + return self._rows[name].birth_record def incarnation(self, name: str) -> ThreadIncarnation: return self.birth_record(name).incarnation @@ -434,44 +446,28 @@ def active_goal_owners(self) -> Iterator[Thread]: return (self[name] for name in self for _ in self.determining(name).active_goals()) def __getitem__(self, name: str) -> Thread: - if name not in self._decoded: - self._decoded[name] = self.decode_row(name) - return self._decoded[name] - - def decode_row(self, name: str) -> Thread: - try: - thread = self.captured(name).decoded() - except ValueError as error: - raise InvalidRegistryDocument(error) from error - return self._binding(thread) + return self._rows[name].decoded def __iter__(self) -> Iterator[str]: - return iter(dict.fromkeys((*self._raw, *self._decoded))) + return iter(self._rows) def __len__(self) -> int: - return len(self._raw.keys() | self._decoded.keys()) + return len(self._rows) def __contains__(self, name: object) -> bool: - return name in self._raw or name in self._decoded + return name in self._rows def __setitem__(self, name: str, thread: Thread) -> None: if thread.name != name: raise MalformedRecord("thread name differs from its declaration") - self._captured.pop(name, None) - self._decoded[name] = self._binding(thread) - self._raw.pop(name, None) + self._rows[name] = ThreadRow.from_thread(thread, self._binding) def __delitem__(self, name: str) -> None: - if name not in self: - raise KeyError(name) - self._captured.pop(name, None) - self._raw.pop(name, None) - self._decoded.pop(name, None) + del self._rows[name] def to_wire(self) -> dict[str, JsonValue]: - # A read memo is not an edit: __setitem__ removes raw only for actual writes. - return {name: (dict(self._raw[name]) if name in self._raw - else FieldCodec.encode(self._decoded[name])) for name in self} + # Full acquisition never replaces the retained wire; assignment builds a new row. + return {name: row.to_wire() for name, row in self._rows.items()} @classmethod def schema(cls) -> dict[str, JsonValue]: @@ -490,11 +486,8 @@ def checked_rows(cls, raw: object) -> dict[str, dict[str, JsonValue]]: def birth(self, name: str) -> CreationTime: """One row's creation time, read alone: a stored row is never decoded for it.""" - if name in self._decoded: - return self._decoded[name].created_at try: - key = FieldCodec.field_plan(Thread, "created_at").key - return CreationTime.from_wire(self._raw[name].get(key)) + return self._rows[name].birth_record.created_at except ValueError as error: raise InvalidRegistryDocument(error) from error @@ -517,7 +510,8 @@ def require_distinct_births(self) -> None: @classmethod def from_wire(cls, raw: object) -> Self: result = cls() - result._raw = cls.checked_rows(raw) + result._rows = {name: ThreadRow.from_wire(row) + for name, row in cls.checked_rows(raw).items()} return result @classmethod @@ -529,20 +523,18 @@ def from_threads(cls, threads: Mapping[str, Thread]) -> Self: @classmethod def copied(cls, source: ThreadRows) -> Self: result = cls() - result._raw = source._raw.copy() - result._decoded = source._decoded.copy() - result._captured = source._captured.copy() + result._rows = source._rows.copy() result._binding = source._binding return result def reuse(self, rows: Mapping[str, object], kept: frozenset[str], earlier: ThreadRows) -> None: - retained = {name: earlier._raw[name] for name in kept & earlier._raw.keys()} - changed = self.checked_rows({name: row for name, row in rows.items() if name not in retained}) - captured = ChainMap(retained, changed) - self._raw = {name: captured[name] for name in rows} - self._decoded = {name: earlier._decoded[name] for name in kept if name in earlier._decoded} - self._captured = {name: earlier._captured[name] for name in kept & earlier._captured.keys()} + changed = self.checked_rows({name: row for name, row in rows.items() if name not in kept}) self._binding = earlier._binding + self._rows = { + name: (earlier._rows[name] if name in kept else + ThreadRow.from_wire(changed[name]).bound(self._binding)) + for name in rows + } class FrozenThreadRows(ThreadRows): @@ -1344,7 +1336,9 @@ def process_owners(self, process: ProcessIdentity) -> tuple[str, ...]: def owner_identity(self, name: str) -> OwnerIdentity: canonical = self.canonical_name(name) - return OwnerIdentity(self.threads[canonical].incarnation, self.owner_generations[canonical]) + return OwnerIdentity( + self.threads[canonical].incarnation, self.owner_generations[canonical], + ) def claimed_identity(self, name: str) -> ClaimedIdentity: canonical = self.canonical_name(name) diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 3c8277aa07..d3a0bdd586 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -358,8 +358,10 @@ class ParentRelationshipGroup(RelationshipGroup): @classmethod def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: - return tuple(NamedEntrySource(RelationshipEntrySource.canonical_target(name, inputs.registry)) - for name in inputs.thread.parent.names()) + return tuple( + NamedEntrySource(RelationshipEntrySource.canonical_target(name, inputs.registry)) + for name in inputs.thread.parent.names() + ) @dataclass(frozen=True, slots=True) @@ -370,7 +372,9 @@ class SortableRelationshipGroup(RelationshipGroup): default_order: ClassVar[type[ThreadSort]] @classmethod - def selected_order(cls, orders: Mapping[type[SortableRelationshipGroup], type[ThreadSort]]) -> type[ThreadSort]: + def selected_order( + cls, orders: Mapping[type[SortableRelationshipGroup], type[ThreadSort]], + ) -> type[ThreadSort]: return orders.get(cls, cls.default_order) @classmethod @@ -793,7 +797,9 @@ def _goal_contacts( diagnostics: list[GoalMentionDiagnostic] = [] for owner in registry.threads.active_goal_owners(): for goal in owner.goal.active(): - ThreadRelationships._project_goal_contacts(goal, owner, registry, contacts, diagnostics) + ThreadRelationships._project_goal_contacts( + goal, owner, registry, contacts, diagnostics, + ) return (tuple(row for row in contacts if row.incident(thread)), tuple(row for row in diagnostics if row.owner == thread.name)) @@ -926,7 +932,10 @@ def _recent_messages(self) -> tuple[tuple[MessageInput, ...], bool]: """One bounded sequence page, on a single committed read snapshot.""" with self.bus.bus.reading() as source: messages = source.input_page(source.revision() + 1, self.RECENT_MESSAGES + 1) - return tuple(reversed(messages[:self.RECENT_MESSAGES])), len(messages) > self.RECENT_MESSAGES + return ( + tuple(reversed(messages[:self.RECENT_MESSAGES])), + len(messages) > self.RECENT_MESSAGES, + ) def snapshot(self, owner: str) -> ThreadCommsSnapshot: # One coherent identity/metadata basis; wire payload work is bounded. @@ -938,7 +947,9 @@ def snapshot(self, owner: str) -> ThreadCommsSnapshot: delivery = DeliveryScope.capture(thread.name, registry, self.views.channels.catalog.read()) messages, limited = self._recent_messages() - orders = {row.group: row.order for row in state.orders if row.incarnation == thread.incarnation} + orders = { + row.group: row.order for row in state.orders if row.incarnation == thread.incarnation + } sent: dict[str, float] = {} for order in { group.selected_order(orders) diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 1017b0ad8b..a0bad3be56 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -459,9 +459,7 @@ class ThreadIncarnationRecord: created_at: float def __post_init__(self) -> None: - ThreadName.require(self.name, ValueError( - f"Thread name {self.name!r} must be alphanumeric with hyphens/underscores." - )) + ThreadName.require(self.name) @property def birth(self) -> float: diff --git a/src/agent_comms/thread_name.py b/src/agent_comms/thread_name.py index 8cd8d3d51a..14f6afc14c 100644 --- a/src/agent_comms/thread_name.py +++ b/src/agent_comms/thread_name.py @@ -9,6 +9,8 @@ class ThreadName: ) @classmethod - def require(cls, name: str, refusal: ValueError) -> None: + def require(cls, name: str) -> None: if not name or not set(name) <= cls.characters: - raise refusal + raise ValueError( + f"Thread name {name!r} must be alphanumeric with hyphens/underscores." + ) diff --git a/tests/test_display_unread.py b/tests/test_display_unread.py index e20ffec9d8..d853a49466 100644 --- a/tests/test_display_unread.py +++ b/tests/test_display_unread.py @@ -201,7 +201,7 @@ def test_tag_membership_keeps_alias_births_and_defers_unselected_full_fields(com assert scope.memberships[member.name].incarnation == member.incarnation assert scope.memberships["renamed"].incarnation == member.incarnation assert not scope.includes_participant(unrelated.name) - assert not narrow.threads._decoded + assert all("decoded" not in row.__dict__ for row in narrow.threads._rows.values()) assert original.includes_participant(member.name) comms.registry.unregister("renamed") comms.threads.delete("renamed") diff --git a/tests/test_registration_components.py b/tests/test_registration_components.py index 7787fa0ac1..2cf6405e84 100644 --- a/tests/test_registration_components.py +++ b/tests/test_registration_components.py @@ -214,5 +214,36 @@ def test_captured_thread_row_uses_its_wire_decode_and_pickle_protocol(comms, tmp assert type(captured) is ThreadRow assert captured == row assert captured.to_wire() == row.to_wire() - assert captured.incarnation == thread.incarnation - assert captured.decoded() == thread + assert captured.birth_record.incarnation == thread.incarnation + assert captured.decoded == thread + + +def test_thread_row_copy_reuse_and_pickle_keep_the_bound_cut(comms, tmp_path): + from agent_comms.registry_document import FrozenThreadRows, ThreadRows + from agent_comms.session_file import BusSessionLocation + + first = comms.registry.declare(Thread("first-row", frozenset({"old"}), Checkout(tmp_path))) + second = comms.registry.declare(Thread("second-row", frozenset(), Checkout(tmp_path))) + first = replace(first, session_file=BusSessionLocation(Path("row-session.jsonl"))) + rows = ThreadRows.from_threads({first.name: first, second.name: second}) + rows.bind(comms.registry.store._bind_thread) + before = FrozenThreadRows.copied(rows) + acquired = before[first.name] + assert before[first.name] is acquired + assert acquired.session_file.bound_to(comms.root) is acquired.session_file + replacement = replace(acquired, tags=frozenset({"new"})) + rows[first.name] = replacement + assert rows[first.name] is replacement + assert before[first.name] is acquired + assert before.membership(first.name).tags == frozenset({"old"}) + assert rows.membership(first.name).tags == frozenset({"new"}) + assert rows._rows[second.name] is before._rows[second.name] + reused = ThreadRows() + reused.reuse(rows.to_wire(), frozenset({second.name}), before) + assert reused._rows[second.name] is before._rows[second.name] + assert reused[first.name] == replacement + for restored in (pickle.loads(pickle.dumps(before, protocol=5)), + pickle.loads(pickle.dumps(rows, protocol=5))): + selected = restored[first.name] + assert selected.session_file.bound_to(comms.root) is selected.session_file + assert restored[first.name] is selected diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index 14d3a794db..207e219d32 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -171,9 +171,10 @@ def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( rows = ThreadRows.from_wire(raw) rows.bind(comms.registry.store._bind_thread) if decoded: - rows[selected.name] = selected # A genuine write removes this row from _raw. + rows[selected.name] = selected # A genuine write supplies the row's actual Thread. snapshot = replace(captured, threads=rows) - decoded_before = frozenset(snapshot.threads._decoded) + decoded_before = frozenset(name for name, row in snapshot.threads._rows.items() + if "decoded" in row.__dict__) expected = {thread.name: thread.incarnation.lookup for thread in originals} lookup = snapshot.lookup_names @@ -188,7 +189,8 @@ def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( with pytest.raises(KeyError) as caught: lookup[missing] assert caught.value.args == (missing,) - assert frozenset(snapshot.threads._decoded) == decoded_before + assert frozenset(name for name, row in snapshot.threads._rows.items() + if "decoded" in row.__dict__) == decoded_before acquired = snapshot.born(selected.incarnation.lookup) assert acquired is snapshot.require(selected.name) @@ -196,7 +198,8 @@ def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( assert acquired.session_file.bound_to(comms.root) is acquired.session_file assert lookup.get(selected.incarnation.lookup) == selected.name assert snapshot.born(selected.incarnation.lookup) is acquired - assert frozenset(snapshot.threads._decoded) == decoded_before | {selected.name} + assert frozenset(name for name, row in snapshot.threads._rows.items() + if "decoded" in row.__dict__) == decoded_before | {selected.name} with pytest.raises(InvalidRegistryDocument): snapshot.born(invalid.incarnation.lookup) with pytest.raises(InvalidRegistryDocument): @@ -292,12 +295,12 @@ def test_cold_shape_and_birth_capture_do_not_acquire_unrequested_goal_fields( raw = captured.threads.to_wire() raw[original.name][entry.key] = [] rows = ThreadRows.from_wire(raw) - assert not rows._captured - assert not rows._decoded + assert all("birth_record" not in row.__dict__ for row in rows._rows.values()) + assert all("decoded" not in row.__dict__ for row in rows._rows.values()) snapshot = replace(captured, threads=rows) assert snapshot.incarnation(original.name) == original.incarnation assert original.incarnation.lookup in snapshot.lookup_names - assert not snapshot.threads._decoded + assert all("decoded" not in row.__dict__ for row in snapshot.threads._rows.values()) with pytest.raises(MalformedRecord): snapshot.threads.determining(original.name) with pytest.raises(InvalidRegistryDocument): From 6302e33e7d565ac757ea3fa79bd8a0693dd0839c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 07:37:00 -0400 Subject: [PATCH 18/24] Own written thread rows through their decoded override --- src/agent_comms/field_codec.py | 6 +++ src/agent_comms/registry_document.py | 57 ++++++++++++++------------ src/agent_comms/registry_provenance.py | 4 ++ src/agent_comms/thread_identity.py | 10 +---- 4 files changed, 42 insertions(+), 35 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 6583b50434..b776148d5f 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -2554,6 +2554,12 @@ def decode_record( return cast(T, decoder.decode_selected( data, {}, field_types=field_types, decoded_fields=decoded_fields)) + @classmethod + def recorded_fields(cls, record: object) -> dict[str, object]: + """Already-decoded values supplied by this record's stored-field declarations.""" + return {entry.name: entry.current(record) + for entry in cls.field_plans(type(record)) if entry.records} + @classmethod def project_record( cls, target: type[T], data: object, *, source: type, diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 8a84cde4a5..e93c38e6e8 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -337,65 +337,66 @@ def from_wire(cls, raw: object) -> Self: raise MalformedRecord("thread row must be an object") return cls(MappingProxyType(raw), ThreadRows.unbound) - @classmethod - def from_thread(cls, thread: Thread, binding: Callable[[Thread], Thread]) -> Self: - row = cls.from_wire(thread.to_wire()).bound(binding) - row.__dict__[cls.decoded.func.__name__] = binding(thread) - return row + @staticmethod + def from_thread( + thread: Thread, binding: Callable[[Thread], Thread], + ) -> WrittenThreadRow: + return WrittenThreadRow(MappingProxyType(thread.to_wire()), binding, thread) def bound(self, binding: Callable[[Thread], Thread]) -> Self: - if self.binding == binding: - return self - row = replace(self, binding=binding) - decoded = type(self).decoded.func.__name__ - if decoded in self.__dict__: - row.__dict__[decoded] = binding(self.decoded) - return row + return replace(self, binding=binding) def to_wire(self) -> dict[str, JsonValue]: return dict(self.raw) def __reduce__(self) -> tuple[ - Callable[[object], Self], tuple[dict[str, JsonValue]], + Callable[[object], ThreadRow], tuple[dict[str, JsonValue]], dict[str, Callable[[Thread], Thread]], ]: - # The wire stays portable; pickle also retains this cut's physical binding. - return type(self).from_wire, (self.to_wire(),), {"binding": self.binding} + # Pickle restores a lazy wire row, retaining this cut's physical binding. + return ThreadRow.from_wire, (self.to_wire(),), {"binding": self.binding} @cached_property def birth_record(self) -> ThreadBirthRecord: return FieldCodec.project_record(ThreadBirthRecord, dict(self.raw), source=Thread) - @cached_property - def captured_birth(self) -> Mapping[str, object]: - return MappingProxyType({entry.name: entry.current(self.birth_record) - for entry in FieldCodec.field_plans(ThreadBirthRecord) - if entry.records}) - @cached_property def membership(self) -> ThreadMembership: return FieldCodec.project_record( - ThreadMembership, dict(self.raw), source=Thread, decoded_fields=self.captured_birth, + ThreadMembership, dict(self.raw), source=Thread, + decoded_fields=FieldCodec.recorded_fields(self.birth_record), ) @cached_property def goal_owner(self) -> GoalOwner: return FieldCodec.project_record( - GoalOwner, dict(self.raw), source=Thread, decoded_fields=self.captured_birth, + GoalOwner, dict(self.raw), source=Thread, + decoded_fields=FieldCodec.recorded_fields(self.birth_record), ) @cached_property def decoded(self) -> Thread: try: - owner = self.goal_owner - values = {entry.name: entry.current(owner) - for entry in FieldCodec.field_plans(GoalOwner) if entry.records} - thread = FieldCodec.decode_record(Thread, dict(self.raw), decoded_fields=values) + thread = FieldCodec.decode_record( + Thread, dict(self.raw), + decoded_fields=FieldCodec.recorded_fields(self.goal_owner), + ) except ValueError as error: raise InvalidRegistryDocument(error) from error return self.binding(thread) +@dataclass(frozen=True) +class WrittenThreadRow(ThreadRow): + """An assigned row already holds its full Thread; no wire decode supplies it.""" + + thread: Annotated[Thread, WireTransient()] + + @cached_property + def decoded(self) -> Thread: + return self.binding(self.thread) + + class ThreadRows(ExternalJsonShape, WireValue, MutableMapping[str, Thread]): """A revision's thread rows, decoded and bound only when read.""" @@ -408,6 +409,8 @@ def unbound(thread: Thread) -> Thread: return thread def bind(self, binding: Callable[[Thread], Thread]) -> None: + if self._binding == binding: + return self._binding = binding self._rows = {name: row.bound(binding) for name, row in self._rows.items()} diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index 40d6497be1..84b7609762 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -171,6 +171,10 @@ def participant(self, name: str) -> Member | DeletedThread: return DeletedThread(canonical) raise UnregisteredThreadError(f"Thread {canonical!r} was never registered.") + def registered(self, name: str) -> bool: + """Current membership, including retained aliases, without acquiring a Thread.""" + return self.canonical_name(name) in self.threads + def require_canonical(self, name: str) -> str: canonical = self.canonical_name(name) if canonical not in self.threads: diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index a0bad3be56..fcbf345882 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -229,10 +229,7 @@ def resolved(self, snapshot: RegistryProvenance) -> ThreadIncarnation: return snapshot.incarnation(self.name) def current(self, snapshot: RegistryProvenance) -> bool: - try: - return snapshot.incarnation(self.name) == self - except UnregisteredThreadError: - return False + return snapshot.registered(self.name) and snapshot.incarnation(self.name) == self def recorded_or( self, make: Callable[[RecordedThreadIncarnation], T], otherwise: Callable[[], T] @@ -270,10 +267,7 @@ def matches_recorded_name(self, name: str, snapshot: RegistryProvenance) -> bool The registry owns aliases and birth. A stored name does not supply or manufacture either fact, including after a name is reused. """ - try: - return self.resolved(snapshot) == snapshot.incarnation(name) - except UnregisteredThreadError: - return False + return snapshot.registered(name) and self.resolved(snapshot) == snapshot.incarnation(name) def recorded_names(self, snapshot: RegistryProvenance) -> tuple[ThreadIncarnation, ...]: """Original names retained for this exact current incarnation.""" From 4c600de4b1298a359deafb24b27407e1b2974854 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 08:01:04 -0400 Subject: [PATCH 19/24] Use declared JSON and incarnation values at row and release boundaries --- src/agent_comms/owner_lifecycle.py | 2 +- src/agent_comms/threads.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index be3a0875c2..5f5b979826 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -226,7 +226,7 @@ def matches_current(self, snapshot: RegistrySnapshot, release: OwnerReleaseEvide """The row still binds the released process or, once a dead owner's stop unregistered it, none; a different process is a successor.""" current = snapshot.require(release.name) - return (release.incarnation() == current.incarnation + return (release.incarnation == current.incarnation and not current.process_identity.conflicts_with(release.process_identity)) diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index f22bc0206d..8989797600 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -20,6 +20,7 @@ Argument, Description, FieldCodec, + JsonValue, NullMember, RegistrationPolicy, SingleFieldValue, @@ -639,7 +640,7 @@ def turn_started_by(self, updated_at_ms: int) -> bool: def executing(self) -> bool: return bool(self.turn_record.items_with(Executing, self)) - def to_wire(self) -> dict[str, object]: + def to_wire(self) -> dict[str, JsonValue]: """Schema-derived projection at a JSON boundary, not a hand-maintained mirror.""" return FieldCodec.encode(self) From c1bd98229f01b8f1f8c1e74ecf6a451d61cff934 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:06:03 -0400 Subject: [PATCH 20/24] Give capability joins one acquisition and refusal owner --- src/agent_comms/messages.py | 65 +++++++++++++++++++------------------ tests/test_bus.py | 3 ++ 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 0c6c69792a..1c688b3a64 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -14,7 +14,7 @@ import sqlite3 import time from abc import ABC, abstractmethod -from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence +from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence from contextlib import closing from dataclasses import Field, dataclass, field, fields, make_dataclass, replace from functools import cache @@ -444,7 +444,24 @@ def selected( *, descending: bool = False, ) -> Generator[Self]: """One cursor reads only the capability tables this value inherits.""" - tables = cls.tables() + order = "DESC" if descending else "ASC" + with closing(cls._joined( + db, cls.tables(), f'ORDER BY {BusRow.qualified("seq")} {order}', + parameters, lambda header: cls, + prefix=f"WITH selected AS ({positions}) ", + position_join="JOIN selected USING(seq) ", + )) as rows: + for _, stored in rows: + yield stored + + @classmethod + def _joined[Stored: BusCapability]( + cls, db: sqlite3.Connection, tables: tuple[type[CapabilityRow], ...], + position_clause: str, parameters: tuple[object, ...], + record_type: Callable[[BusRow], type[Stored]], *, + prefix: str = "", position_join: str = "", + ) -> Generator[tuple[BusRow, Stored]]: + """One cursor owns declaration-order joins, decoding and required-row refusal.""" joined: tuple[type[BusTables], ...] = (BusRow, *tables) offsets = dict(zip( tables, @@ -452,27 +469,26 @@ def selected( strict=False, # The final accumulated value is the row end, not a table start. )) header = BusRow.qualified("seq") - order = "DESC" if descending else "ASC" with closing(db.execute( - f"WITH selected AS ({positions}) " - f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in joined)} " - f'FROM "{BusRow.declared_name}" JOIN selected USING(seq) ' + f"{prefix}SELECT {', '.join(row.qualified(*row.column_projection()) for row in joined)} " + f'FROM "{BusRow.declared_name}" {position_join}' + " ".join(f'LEFT JOIN "{table.declared_name}" ON {table.qualified("seq")} = {header}' for table in tables) - + f" ORDER BY {header} {order}", parameters, + + f" {position_clause}", parameters, )) as cursor: for stored in cursor: original = BusRow.decode_at(stored, 0) + kind = record_type(original) try: - value = cls.assembled( - table.decode_at(stored, offsets[table]) for table in tables + value = kind.assembled( + table.decode_at(stored, offsets[table]) for table in kind.tables() ) except MalformedRecord as error: raise RelationViolationError( f"Bus row {original.seq} has malformed required " - f"{cls.__name__} capability: {error}" + f"{kind.__name__} capability: {error}" ) from error - yield value + yield original, value @classmethod def assembled(cls, rows: Iterable[CapabilityRow]) -> Self: @@ -510,27 +526,12 @@ def at_sequences(db: sqlite3.Connection, sequences: tuple[int, ...]) -> tuple[Bu @staticmethod def stored_records( db: sqlite3.Connection, where: str, parameters: tuple[object, ...], - ) -> Iterator[tuple[BusRow, BusRecord]]: - """Each selected header with the record its capability rows assemble, decoded once.""" - tables = TypedTable.members_with(CapabilityRow) - offsets = dict(zip( - tables, - accumulate((len(BusRow.columns()), *(len(table.columns()) for table in tables))), - strict=False, # The final accumulated value is the row end, not a table start. - )) - header = BusRow.qualified("seq") - with closing(db.execute( - f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in (BusRow, *tables))} " - f'FROM "{BusRow.declared_name}" ' - + " ".join(f'LEFT JOIN "{table.declared_name}" ON {table.qualified("seq")} = {header}' - for table in tables) - + f" WHERE {where}", - parameters, - )) as cursor: - for stored in cursor: - row = BusRow.decode_at(stored, 0) - yield row, row.kind.assembled( - table.decode_at(stored, offsets[table]) for table in row.kind.tables()) + ) -> Generator[tuple[BusRow, BusRecord]]: + """Each selected header with its complete original record, decoded once.""" + return BusCapability._joined( + db, TypedTable.members_with(CapabilityRow), f"WHERE {where}", parameters, + lambda header: header.kind, + ) def projections(self) -> Iterator[TypedTable]: return iter(()) diff --git a/tests/test_bus.py b/tests/test_bus.py index 1491acc19a..80e1e3b952 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -763,6 +763,9 @@ def test_required_capability_corruption_is_not_hidden_by_the_join(bus): with pytest.raises(RelationViolationError, match="malformed required Message capability") as refused: Message.read_from(db, 1) assert isinstance(refused.value.__cause__, MalformedRecord) + with pytest.raises(RelationViolationError) as history_refused: + tuple(BusCapability.history(db)) + assert isinstance(history_refused.value.__cause__, MalformedRecord) def test_bulk_history_keeps_one_snapshot_across_pages(bus): From 4cfd1e2093d82002e6765925e23b6ae240a5dca4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:42:15 -0400 Subject: [PATCH 21/24] Keep history pages on their original archive catalogue --- src/agent_comms/historical_views.py | 79 ++++++++++++++++++++--------- tests/test_historical_views.py | 33 ++++++++++++ 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index cdb5d80a96..231afe4518 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -88,11 +88,12 @@ def reading(self, presentation: BusPresentation) -> Iterator[Callable[[PageBound unchanged, and retires it off the UI thread when that source is replaced. """ with presentation.reading(self.viewer) as captured: - yield partial(self.page, captured) + history = captured.bus.history.captured() + yield partial(history.integrated_page, self, captured) def page(self, presentation: BusPresentation, bounds: PageBounds = NEWEST_PAGE) -> MessagePage: - with presentation.reading(self.viewer) as captured: - return captured.bus.history.integrated_page(self, captured, bounds) + with self.reading(presentation) as read: + return read(bounds) @abstractmethod def capture(self, snapshot: RegistryProvenance) -> MessageDisplayScope: @@ -478,14 +479,29 @@ def sources(self) -> tuple[HistorySource, ...]: with self.read_sources() as sources: return sources + def captured(self) -> CapturedHistoryArchive: + """Retain the catalogue and its revision from the same original descriptor.""" + document = self._capture_unlocked() + return CapturedHistoryArchive( + self.path, self.read_copied(document), document.revision, + ) + + @property + def history_revision(self) -> FileObservation: + return self.captured().catalogue_revision + + @contextmanager + def reading(self) -> Iterator[CapturedHistoryArchive]: + """Validate a page's original catalogue before and after using it.""" + captured = self.captured() + self._require_revision(captured.catalogue_revision) + yield captured + self._require_revision(captured.catalogue_revision) + @contextmanager def read_sources(self) -> Iterator[tuple[HistorySource, ...]]: - """Decode one read's catalogue; reject changes without decoding it again.""" - captured = self._capture_unlocked() - sources = self.read_copied(captured) - self._require_revision(captured.revision) - yield sources - self._require_revision(captured.revision) + with self.reading() as captured: + yield captured.sources() def _require_revision(self, revision: FileObservation) -> None: if FileRevision.capture(self.path) != revision: @@ -564,13 +580,17 @@ def page(self, view: HistoryView, presentation: BusPresentation, bounds: PageBou def integrated_page( self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds + ) -> MessagePage: + with self.reading() as captured: + return captured._integrated_page(view, presentation, bounds) + + def _integrated_page( + self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds ) -> MessagePage: page = self._source_page(view, presentation, bounds) pages = deque((page,)) while (following := bounds.after(page)) is not None: part = self._source_page(view, presentation, following) - if part.selection.history_revision != page.selection.history_revision: - raise HistoricalSourceChanged("Historical sources changed; reload history") if not bounds.fits(len(page.messages) + len(part.messages)): break bounds.traversal.append(pages, part) @@ -579,11 +599,16 @@ def integrated_page( def _source_page( self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds + ) -> MessagePage: + page = self._select_source_page(view, presentation, bounds) + return replace(page, selection=replace( + page.selection, history_revision=self.history_revision, + )) + + def _select_source_page( + self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds ) -> MessagePage: historical = bounds.traversal.historical - if not self.sources() and not historical: - return view.live_page(presentation, bounds) - history_revision = FileRevision.capture(self.path) def historical_page() -> MessagePage: history = self.page( view, presentation, bounds if historical else replace(bounds, traversal=OlderTraversal()) @@ -592,27 +617,21 @@ def historical_page() -> MessagePage: latest = view.live_page(presentation, PageBounds(limit=1)) return replace( history, - selection=replace(history.selection, history_revision=history_revision), has_newer=history.has_newer or bool(latest.messages), ) selected = history if historical else page - result = replace(selected, selection=replace(selected.selection, history_revision=history_revision)) if historical: return bounds.traversal.after_exhausted_history( lambda traversal: view.live_page(presentation, replace(bounds, traversal=traversal)), - result, + selected, ) - return result + return selected if historical: return historical_page() page = view.live_page(presentation, bounds) return bounds.traversal.source_page( - replace( - page, - has_older=page.has_older or bool(self.sources()), - selection=replace(page.selection, history_revision=history_revision), - ), + replace(page, has_older=page.has_older or bool(self.sources())), historical_page, ) @@ -624,3 +643,17 @@ def threads(self, name: str | None = None) -> tuple[HistoricalThread, ...]: for thread in source.provenance.threads.values() if name is None or thread.incarnation.matches_recorded_name(name, source.provenance)) return tuple(result) + + +@dataclass(frozen=True, slots=True) +class CapturedHistoryArchive(HistoryArchive, declared_name=None): + """One reading's immutable catalogue; page logic remains on HistoryArchive.""" + + captured_sources: tuple[HistorySource, ...] + catalogue_revision: FileObservation + + def captured(self) -> CapturedHistoryArchive: + return self + + def sources(self) -> tuple[HistorySource, ...]: + return self.captured_sources diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index 84f2c3bc21..100fca0dac 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -57,6 +57,39 @@ def test_missing_catalogue_is_empty_without_creating_it(tmp_path): assert not archive.path.exists() +@pytest.mark.parametrize('exists', [False, True]) +def test_captured_catalogue_refuses_later_creation_or_replacement(tmp_path, exists): + archive = HistoryArchive.at(tmp_path) + if exists: + archive.path.write_text('[]') + captured = archive.captured() + with captured.read_sources() as sources: + assert sources == () + archive.path.write_text('[ ]') + with pytest.raises(HistoricalSourceChanged): + with captured.read_sources(): + pass + with archive.read_sources() as sources: + assert sources == () + + +def test_held_display_reading_rejects_later_archive_without_bus_growth(migrated, tmp_path): + _, _, live, _ = migrated + with live.views.channel_display_reading('#team', viewer=NamedViewer('alice')) as read: + first = read(PageBounds(limit=1)) + revision = live.bus.bus.revision() + later = setup(tmp_path / 'later', 1) + live.views.attach_history(later.root) + assert live.bus.bus.revision() == revision + with pytest.raises(HistoricalSourceChanged): + read(PageBounds(first.oldest_cursor.older(), 1)) + # Retiring the old reading is harmless; a new reading adopts the catalogue. + fresh = live.views.channel_display_page( + '#team', viewer=NamedViewer('alice'), bounds=PageBounds(limit=1), + ) + assert fresh.selection.history_revision != first.selection.history_revision + + def setup(root, count=5): comms = wire(root) comms.registry.declare(Thread('alice', frozenset({'team'}), Checkout(root), created_at=10.0)) From ff5e9d00aa0406c63530f9c893c7609b9c0d3d22 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 12:17:34 -0400 Subject: [PATCH 22/24] Keep display construction and captured reader policy with their owners --- src/agent_comms/bus.py | 18 +++--- src/agent_comms/history_views.py | 36 ++--------- src/agent_comms/message_bus.py | 6 +- src/agent_comms/presentation.py | 12 ++-- src/agent_comms/read_basis.py | 2 +- src/agent_comms/registry_provenance.py | 4 +- src/agent_comms/storage_room.py | 6 +- src/agent_comms/thread_membership.py | 3 + tests/c4_coordination_installed_ui.py | 5 +- tests/test_channel_display.py | 59 ++++++++++++----- tests/test_channel_retirement.py | 8 ++- tests/test_claim_bus_read.py | 2 +- tests/test_current_delivery.py | 2 +- tests/test_dm_scoped_ack.py | 35 ++++++++-- tests/test_dm_scoped_ack_successor.py | 40 +++++++++--- tests/test_goal_reply_lock_scope.py | 4 +- tests/test_historical_page_scope.py | 4 +- tests/test_historical_views.py | 90 +++++++++++++++++--------- tests/test_mark_all_existing_reads.py | 5 +- tests/test_named_target_contracts.py | 6 +- tests/test_ordinary_nk_delivery.py | 5 +- tests/test_read_ledger.py | 76 ++++++++++++++++------ tests/test_s4_read_characterization.py | 10 ++- tests/test_thread_identity.py | 10 ++- tests/test_user_thread_lookup.py | 11 +++- tests/test_view_unread.py | 9 ++- 26 files changed, 314 insertions(+), 154 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index e1eaac070a..53b35cd17c 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -164,7 +164,9 @@ class BusReader(RouteCounts, ABC): """One query implementation for individual reads and a held read snapshot.""" @abstractmethod - def observing(self) -> AbstractContextManager[sqlite3.Connection]: + def observing( + self, *, check_same_thread: bool = True, + ) -> AbstractContextManager[sqlite3.Connection]: """Borrow one read snapshot through the reader's declared lifetime.""" def answering(self, references: tuple[MessageReference, ...], route: NamedTarget, @@ -174,9 +176,9 @@ def answering(self, references: tuple[MessageReference, ...], route: NamedTarget return MessageIdentitySource.answering(connection, references, route, snapshot) @contextmanager - def reading(self) -> Iterator[BusSnapshot]: + def reading(self, *, check_same_thread: bool = True) -> Iterator[BusSnapshot]: """Keep queries and their physical identity on one held snapshot.""" - with self.observing() as db: + with self.observing(check_same_thread=check_same_thread) as db: yield BusSnapshot.from_connection(db) def revision(self) -> int: @@ -520,10 +522,8 @@ def path(self) -> Path: """The file named by this reader's owning declaration.""" @contextmanager - def observing(self) -> Iterator[sqlite3.Connection]: - # A captured display cut moves between the preparation owner's workers. - # Its presentation serializes page use and retirement; it remains read-only. - with SQLiteFile.opened_reader(self.path, check_same_thread=False) as db: + def observing(self, *, check_same_thread: bool = True) -> Iterator[sqlite3.Connection]: + with SQLiteFile.opened_reader(self.path, check_same_thread=check_same_thread) as db: yield db @@ -676,10 +676,10 @@ def from_connection(cls, connection: sqlite3.Connection) -> Self: return cls(identity=FileIdentity.from_stat(path.stat()), path=path, _connection=connection) @contextmanager - def observing(self) -> Iterator[sqlite3.Connection]: + def observing(self, *, check_same_thread: bool = True) -> Iterator[sqlite3.Connection]: """The held snapshot is already attached, so it takes no lock.""" yield self._connection @contextmanager - def reading(self) -> Iterator[BusSnapshot]: + def reading(self, *, check_same_thread: bool = True) -> Iterator[BusSnapshot]: yield self diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index 00379f00c8..660a2e7d66 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -10,8 +10,7 @@ import logging import time from abc import abstractmethod -from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence -from contextlib import contextmanager +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, Annotated @@ -247,42 +246,21 @@ def attach_history(self, source_root: Path, *, source_read: HistorySource | None def historical_threads(self, name: str | None = None) -> tuple[HistoricalThread, ...]: return self.bus.history.threads(name) - def dm_display_page( - self, peer: ThreadTarget, *, worktree: Checkout, bounds: PageBounds = NEWEST_PAGE - ) -> MessagePage: - viewer = self.messaging.user_identity(worktree) - return DMDisplayHistory(viewer.name, peer, worktree).page(self.presentation, bounds) - - @contextmanager - def dm_display_reading( + def dm_display_view( self, peer: ThreadTarget, *, worktree: Checkout, - ) -> Iterator[Callable[[PageBounds], MessagePage]]: + ) -> DMDisplayHistory: viewer = self.messaging.user_identity(worktree) - with DMDisplayHistory(viewer.name, peer, worktree).reading(self.presentation) as pages: - yield pages + return DMDisplayHistory(viewer.name, peer, worktree) - @contextmanager - def channel_display_reading( + def channel_display_view( self, target: str, *, viewer: DisplayViewer = AnonymousViewer(), - ) -> Iterator[Callable[[PageBounds], MessagePage]]: - channel = self.bus.require_channel(target) - with ChannelDisplayHistory(channel, viewer).reading(self.presentation) as pages: - yield pages + ) -> ChannelDisplayHistory: + return ChannelDisplayHistory(self.bus.require_channel(target), viewer) def display_viewer(self, worktree: Checkout) -> NamedViewer: """Select the human reader; page capture resolves it in its own snapshot.""" return NamedViewer(self.messaging.user_identity(worktree).name) - def channel_display_page( - self, - target: str, - *, - viewer: DisplayViewer = AnonymousViewer(), - bounds: PageBounds = NEWEST_PAGE, - ) -> MessagePage: - channel = self.bus.require_channel(target) - return ChannelDisplayHistory(channel, viewer).page(self.presentation, bounds) - def mark_page_read(self, page: MessagePage, *, worktree: Checkout) -> None: """Acknowledge the original read evidence of exactly the painted page.""" page.selection.acknowledge(self, page, worktree=worktree) diff --git a/src/agent_comms/message_bus.py b/src/agent_comms/message_bus.py index 32c5b1485a..685812e81d 100644 --- a/src/agent_comms/message_bus.py +++ b/src/agent_comms/message_bus.py @@ -145,8 +145,10 @@ def channel_declarations(self): return self._channels.read().views(activity) @contextmanager - def snapshot(self, viewer: DisplayViewer = AnonymousViewer()) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: - with self.bus.reading() as source: + def snapshot( + self, viewer: DisplayViewer = AnonymousViewer(), *, check_same_thread: bool = True, + ) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + with self.bus.reading(check_same_thread=check_same_thread) as source: basis = CapturedSelection.load( self._registry, self._channels, self.reads, viewer, source.activity_clocks(), ) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 26a2b788d9..bdbef4d03f 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -530,9 +530,11 @@ def __init__( self._display_unread = DisplayUnread() @contextmanager - def snapshot(self, viewer: DisplayViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + def snapshot( + self, viewer: DisplayViewer, *, check_same_thread: bool = True, + ) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: """Borrow the bus-owned source cut without preparing a second store.""" - with self.bus.snapshot(viewer) as selected: + with self.bus.snapshot(viewer, check_same_thread=check_same_thread) as selected: yield selected @contextmanager @@ -543,7 +545,7 @@ def reading(self, viewer: DisplayViewer) -> Iterator[CapturedBusPresentation]: serializes page use and retirement, without keeping any store exclusion held. """ with ExitStack() as lifetime: - basis, source = lifetime.enter_context(self.snapshot(viewer)) + basis, source = lifetime.enter_context(self.snapshot(viewer, check_same_thread=False)) captured = CapturedBusPresentation(self, basis, source) try: yield captured @@ -623,7 +625,9 @@ def __init__(self, original: BusPresentation, basis: CapturedSelection, source: self._use = RLock() @contextmanager - def snapshot(self, viewer: DisplayViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + def snapshot( + self, viewer: DisplayViewer, *, check_same_thread: bool = True, + ) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: with self._use: if viewer.resolve(self.basis.registry) != self.basis.viewer: raise ValueError("A captured display cut belongs to its original viewer") diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 9d9dd4d1bd..53caf0ab68 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -1232,7 +1232,7 @@ def participant_query(self) -> tuple[str, tuple[object, ...]]: return self.registry.tagged_names(self.tags) def includes_participant(self, name: str) -> bool: - return name in self.memberships and self.tags <= self.memberships[name].tags + return name in self.memberships and self.memberships[name].carries(self.tags) @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index 84b7609762..3a3afd66b8 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -243,8 +243,8 @@ def same_tag_membership(self, earlier: Mapping[str, ThreadMembership], """Only a matching name or its actual birth changing invalidates that channel.""" current = self.memberships for name in current.keys() | earlier.keys(): - before = name in earlier and tags <= earlier[name].tags - after = name in current and tags <= current[name].tags + before = name in earlier and earlier[name].carries(tags) + after = name in current and current[name].carries(tags) if before != after: return False if before and earlier[name].incarnation != current[name].incarnation: diff --git a/src/agent_comms/storage_room.py b/src/agent_comms/storage_room.py index 56c160ac89..9fd69a0ea7 100644 --- a/src/agent_comms/storage_room.py +++ b/src/agent_comms/storage_room.py @@ -123,11 +123,13 @@ class RoomLedger(TypedStore, DurableTables, RuntimeWriter): tables = RoomLedgerRows @classmethod - def _open_reader(cls, path: Path, contention: StoreContention) -> Connection: + def _open_reader( + cls, path: Path, contention: StoreContention, *, check_same_thread: bool = True, + ) -> Connection: """Carry this host-owned file before its first snapshot; then prohibit writes.""" connection = cls.writer( path.resolve().as_uri() + "?mode=rw", contention, - isolation_level=None, uri=True, + isolation_level=None, uri=True, check_same_thread=check_same_thread, ) try: cls.install_schema(connection, WriterOwnedFile()) diff --git a/src/agent_comms/thread_membership.py b/src/agent_comms/thread_membership.py index 7ecdd0d4e0..65e694b58d 100644 --- a/src/agent_comms/thread_membership.py +++ b/src/agent_comms/thread_membership.py @@ -17,6 +17,9 @@ class ThreadMembership(ThreadBirthRecord): tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) + def carries(self, tags: frozenset[str]) -> bool: + return tags <= self.tags + def __post_init__(self) -> None: super().__post_init__() for tag in self.tags: diff --git a/tests/c4_coordination_installed_ui.py b/tests/c4_coordination_installed_ui.py index 513345022c..03ac4abca1 100644 --- a/tests/c4_coordination_installed_ui.py +++ b/tests/c4_coordination_installed_ui.py @@ -61,7 +61,10 @@ async def main(): assert original.outcomes[message.seq, message.message_id] assert comms.views.message_notifications_for_references(AllOutcomes(), (message.reference,) * (NotificationWindow.window_limit + 1)).outcomes == original.outcomes - page = comms.views.channel_display_page('#team', viewer=NamedViewer(comms.messaging.user_identity(Checkout(root)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(root)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(root)) assert comms.views.viewer_snapshot(Checkout(root)).channel_unread['#team'].count == 0 assert comms.bus.pending_count('stopped', CommandTarget.named('#team')) == 1 diff --git a/tests/test_channel_display.py b/tests/test_channel_display.py index 0e4a9a3a7f..f90298db9c 100644 --- a/tests/test_channel_display.py +++ b/tests/test_channel_display.py @@ -61,12 +61,12 @@ def test_default_off_and_opt_in_only_changes_display(tmp_path): before_delivery = comms.bus.pending_counts_opened(source, basis) before_bus = comms.bus.bus.page(comms.bus.bus.revision() + 1, 100) assert bodies(comms.views.history_page(CommandTarget.named("#api"))) == ["exact target"] - assert bodies(comms.views.channel_display_page("#api")) == ["exact target"] + assert bodies(comms.views.channel_display_view("#api").page(comms.views.presentation)) == ["exact target"] selected = comms.channels.set_channel_any_mode("#api", True) assert selected.exact and selected.any_mode assert wire(tmp_path).bus.require_channel("#api").any_mode - page = comms.views.channel_display_page("#api") + page = comms.views.channel_display_view("#api").page(comms.views.presentation) assert bodies(page) == [ "exact target", "member to outsider", @@ -77,12 +77,14 @@ def test_default_off_and_opt_in_only_changes_display(tmp_path): assert len({message.seq for message in page.messages}) == len(page.messages) assert bodies(comms.views.history_page(CommandTarget.named("#api"))) == ["exact target"] assert [message.body for message in list(comms.views.history_page(CommandTarget.named("#api")).messages)] == ["exact target"] - assert bodies(comms.views.channel_display_page("#ui")) == ["unrelated channel"] + assert bodies(comms.views.channel_display_view( + '#ui', + ).page(comms.views.presentation)) == ["unrelated channel"] assert comms.bus.bus.page(comms.bus.bus.revision() + 1, 100) == before_bus with comms.bus.snapshot(viewer=NamedViewer("alice")) as (basis, source): assert comms.bus.pending_counts_opened(source, basis) == before_delivery assert not comms.channels.set_channel_any_mode("api", False).any_mode - assert bodies(comms.views.channel_display_page("#api")) == ["exact target"] + assert bodies(comms.views.channel_display_view("#api").page(comms.views.presentation)) == ["exact target"] def test_mode_rejects_builtins_union_view_and_unknown_without_sidecar_mutation(tmp_path): @@ -102,7 +104,7 @@ def test_mode_rejects_builtins_union_view_and_unknown_without_sidecar_mutation(t comms.channels.set_channel_any_mode("#api", 1) assert (metadata.read_bytes() if metadata.exists() else None) == before with pytest.raises(ValueError): - comms.views.channel_display_page("#missing") + comms.views.channel_display_view("#missing").page(comms.views.presentation) def test_opt_in_user_input_activity_uses_same_member_scope(tmp_path): @@ -114,7 +116,9 @@ def test_opt_in_user_input_activity_uses_same_member_scope(tmp_path): on = comms.views.viewer_snapshot(Checkout(tmp_path)).channels["#api"] assert on.last_user_input > 0 assert comms.views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#api"].count == 0 - assert bodies(comms.views.channel_display_page("#api")) == ["human input to member"] + assert bodies(comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation)) == ["human input to member"] assert bodies(comms.views.history_page(CommandTarget.named("#api"))) == [] @@ -123,19 +127,23 @@ def test_alias_retag_and_tag_rename_reproject_without_new_wire_row(tmp_path): comms.messaging.send("alice", "bob", "old sender DM").message.message_id comms.channels.set_channel_any_mode("api", True) high_water = comms.bus.bus.revision() - assert bodies(comms.views.channel_display_page("#api")) == ["old sender DM"] + assert bodies(comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation)) == ["old sender DM"] initial = comms.views.viewer_snapshot(Checkout(tmp_path)).channels assert initial["#api"].last_activity > 0 comms.registry.rename("alice", "renamed") assert comms.bus.bus.revision() == high_water - assert bodies(comms.views.channel_display_page("#api")) == ["old sender DM"] + assert bodies(comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation)) == ["old sender DM"] renamed = comms.registry.require("renamed") comms.registry.register( replace(renamed, tags=frozenset({"ui"})), comms.registry.status("renamed") ) assert comms.bus.bus.revision() == high_water - assert bodies(comms.views.channel_display_page("#api")) == [] + assert bodies(comms.views.channel_display_view("#api").page(comms.views.presentation)) == [] after = comms.views.viewer_snapshot(Checkout(tmp_path)).channels assert after["#api"].last_activity < initial["#api"].last_activity @@ -145,7 +153,9 @@ def test_alias_retag_and_tag_rename_reproject_without_new_wire_row(tmp_path): assert comms.bus.require_channel("#services").any_mode with pytest.raises(ValueError): comms.bus.require_channel("#api") - assert bodies(comms.views.channel_display_page("#services")) == ["old sender DM"] + assert bodies(comms.views.channel_display_view( + '#services', + ).page(comms.views.presentation)) == ["old sender DM"] comms.channels.delete_tag("services") metadata = comms.channels.catalog.path assert not any( @@ -181,10 +191,14 @@ def test_page_boundaries_match_target_pager(tmp_path): comms.messaging.send("outsider", "#api", "second target").message.message_id comms.messaging.send("bob", "alice", "third projected").message.message_id comms.channels.set_channel_any_mode("#api", True) - rows = comms.views.channel_display_page("#api").messages - head = comms.views.channel_display_page("#api", bounds=PageBounds(NewerTraversal(), 1)) + rows = comms.views.channel_display_view("#api").page(comms.views.presentation).messages + head = comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation, bounds=PageBounds(NewerTraversal(), 1)) assert head.has_newer and not head.has_older - tail = comms.views.channel_display_page("#api", bounds=PageBounds(LiveCursor(rows[-1].seq).older(), 1)) + tail = comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation, bounds=PageBounds(LiveCursor(rows[-1].seq).older(), 1)) assert bodies(tail) == ["second target"] and tail.has_older and tail.has_newer @@ -212,16 +226,25 @@ def burst(): assert started.wait(timeout=3) try: for _ in range(20): - page = comms.views.channel_display_page("#api", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#api', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) if page.newest_seq is not None: comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) - dm = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + dm = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert bodies(dm) == ["DM original"] comms.views.mark_page_read(dm, worktree=Checkout(tmp_path)) finally: writer.join(timeout=10) assert finished.is_set() and not writer.is_alive() and not failures - page = comms.views.channel_display_page("#api", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#api', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) assert bodies(page) == [f"burst {n}" for n in range(20)] comms.registry.rename("alice", "renamed") with pytest.raises(DMScopeChanged): @@ -234,5 +257,7 @@ def test_views_answer_how_far_the_bus_has_grown(tmp_path): comms.messaging.send("outsider", "#api", "to a channel").message.message_id comms.messaging.send("alice", "bob", "to someone else").message.message_id # Every message counts, not only those one conversation shows. - newest_in_channel = comms.views.channel_display_page("#api").messages[-1].seq + newest_in_channel = comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation).messages[-1].seq assert comms.views.latest_sequence() == comms.bus.bus.revision() > newest_in_channel diff --git a/tests/test_channel_retirement.py b/tests/test_channel_retirement.py index 053d0a5017..5b79e120c1 100644 --- a/tests/test_channel_retirement.py +++ b/tests/test_channel_retirement.py @@ -74,9 +74,13 @@ def test_saved_projection_preserves_preferences_history_and_has_no_writable_alia assert set( next(v.members for v in comms.views.channel_views() if v.channel.name == "#engineering") ) == {"a", "b"} - tail = comms.views.channel_display_page("#engineering", bounds=PageBounds(limit=2)) + tail = comms.views.channel_display_view( + '#engineering', + ).page(comms.views.presentation, bounds=PageBounds(limit=2)) assert [m.seq for m in tail.messages] == [3, 4] - older = comms.views.channel_display_page("#engineering", bounds=PageBounds(LiveCursor(3).older(), 2)) + older = comms.views.channel_display_view( + '#engineering', + ).page(comms.views.presentation, bounds=PageBounds(LiveCursor(3).older(), 2)) assert [m.seq for m in older.messages] == [1, 2] assert [m.target for m in read_history(comms, "#engineering")] == [ m.target for m in rows diff --git a/tests/test_claim_bus_read.py b/tests/test_claim_bus_read.py index bc78c6914c..540f1e0fb7 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -218,7 +218,7 @@ def whole_bus(*_, **__): assert ConversationMessagePageRequest(dm_scope).read_opened(comms.bus.bus).messages == () raw_channel = comms.views.history_page(CommandTarget.named("#team")).messages assert [message.body for message in raw_channel] == ["@Alice investigate"] - channel = comms.views.channel_display_page("#team").messages + channel = comms.views.channel_display_view("#team").page(comms.views.presentation).messages assert [message.body for message in channel] == ["@Alice investigate"] assert all(message.role.notification_references(message) for message in channel) with pytest.raises(ValueError): diff --git a/tests/test_current_delivery.py b/tests/test_current_delivery.py index 48d1feac6d..3ab48cf5b8 100644 --- a/tests/test_current_delivery.py +++ b/tests/test_current_delivery.py @@ -54,7 +54,7 @@ def test_current_inbox_bulk_and_index_exclude_rebound_history(routed, rebound): with current.bus.snapshot(viewer=NamedViewer("recipient")) as (basis, source): assert current.bus.pending_counts_opened(source, basis) == {"sender": 1, "#team": 1} assert [row.seq for row in current.bus.inbox("recipient", CommandTarget.named("sender")).page.messages] == [new.seq] - assert old in current.views.channel_display_page("#any").messages + assert old in current.views.channel_display_view("#any").page(current.views.presentation).messages assert [row.body for row in current.views.history_page( CommandTarget.named("recipient"), viewer=NamedViewer("sender"), ).messages] == ["retained history", "current input"] diff --git a/tests/test_dm_scoped_ack.py b/tests/test_dm_scoped_ack.py index b663a6e4f7..cd9df558fb 100644 --- a/tests/test_dm_scoped_ack.py +++ b/tests/test_dm_scoped_ack.py @@ -30,7 +30,10 @@ def test_dm_page_marks_covered_range_preserving_omitted_older_unread(tmp_path: P viewer = comms.messaging.user_identity(Checkout(tmp_path)).name for index in range(50): comms.messaging.send("peer", viewer, f"old {index}").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path), bounds=PageBounds(limit=5)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation, bounds=PageBounds(limit=5)) assert len(page.messages) == 5 and page.has_older assert isinstance(page.selection, DMSelection) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) @@ -51,7 +54,10 @@ def test_peer_delete_same_name_rebind_rejects_stale_page_without_read_ack(tmp_pa comms.messaging.send("other", "#all", "OTHER_CHANNEL_4").message.message_id comms.messaging.send("peer", viewer, "PEER_PAINTED_5").message.message_id comms.messaging.send("other", viewer, "OTHER_UNPAINTED_6").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert [message.body for message in page.messages] == ["PEER_PAINTED_2", "PEER_PAINTED_5"] assert page.newest_seq == 5 and isinstance(page.selection, DMSelection) comms.registry.unregister("peer") @@ -75,7 +81,10 @@ def test_foreign_root_wrong_peer_and_unbounded_or_bool_through_rejected(tmp_path comms.registry.declare(_thread(comms.root, name)) viewer = comms.messaging.user_identity(Checkout(comms.root)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = first.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(first.root)) + page = first.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(first.root), + ).page(first.views.presentation) assert isinstance(page.selection, DMSelection) and page.newest_seq is not None for comms, target, through, error in ( (second, "peer", page.newest_seq, DMScopeChanged), @@ -97,7 +106,10 @@ def test_painted_bound_uses_existing_transcript_refresh_error(tmp_path: Path): comms.registry.declare(_thread(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) with pytest.raises(TranscriptChanged): page.selection.basis.acknowledge( comms.bus, comms.registry, comms.root, "peer", @@ -111,7 +123,10 @@ def test_root_replaced_after_fetch_rejects_mark_without_reading_new_root(tmp_pat comms.registry.declare(_thread(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted in original root").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) previous = tmp_path.with_name(tmp_path.name + "-previous") tmp_path.rename(previous) shutil.copytree(previous, tmp_path, symlinks=True) @@ -125,7 +140,10 @@ def test_independent_read_is_idempotent_and_never_retargets_page(tmp_path: Path) comms.registry.declare(_thread(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.views.mark_user_view_read("peer", worktree=Checkout(tmp_path)) comms.messaging.send("peer", viewer, "unpainted after explicit mark").message.message_id comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) @@ -135,7 +153,10 @@ def test_independent_read_is_idempotent_and_never_retargets_page(tmp_path: Path) def test_empty_dm_basis_is_no_op_without_creating_read_ledger(tmp_path: Path): comms = installed_comms(tmp_path) comms.registry.declare(_thread(tmp_path, "peer")) - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert page.selection.basis.displayed.tail == EmptyTail() page.selection.basis.acknowledge( comms.bus, comms.registry, comms.root, "peer", diff --git a/tests/test_dm_scoped_ack_successor.py b/tests/test_dm_scoped_ack_successor.py index e1a84b31f4..4f7117f3ad 100644 --- a/tests/test_dm_scoped_ack_successor.py +++ b/tests/test_dm_scoped_ack_successor.py @@ -42,7 +42,10 @@ def test_alias_rename_invalidates_old_basis_but_fresh_alias_page_can_mark(tmp_pa comms.registry.declare(_peer(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "before rename").message.message_id - old = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + old = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.threads.rename_managed_thread("peer", "renamed", owner=ProcessIdentity.capture(os.getpid())) markers = tmp_path / "read_ledger.json" marker_before = markers.read_bytes() if markers.exists() else None @@ -53,7 +56,10 @@ def test_alias_rename_invalidates_old_basis_but_fresh_alias_page_can_mark(tmp_pa # The old spelling is now an alias for the SAME incarnation. A fresh # page, not stale name text, is authoritative after the rename. - fresh = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + fresh = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(fresh.selection, DMSelection) and fresh.selection.basis.peer == "renamed" _mark(comms, "peer", tmp_path, fresh) assert comms.bus.pending_count(viewer, CommandTarget.named("renamed")) == 0 @@ -66,7 +72,10 @@ def test_delete_and_same_name_reregister_invalidates_old_peer_basis(tmp_path: Pa comms.registry.declare(_peer(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "old peer painted").message.message_id - old = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + old = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(old.selection, DMSelection) comms.registry.unregister("peer") comms.registry.remove("peer") @@ -80,7 +89,10 @@ def test_delete_and_same_name_reregister_invalidates_old_peer_basis(tmp_path: Pa _mark(comms, "peer", tmp_path, old) assert (markers.read_bytes() if markers.exists() else None) == marker_before assert comms.bus.pending_count(viewer, CommandTarget.named("peer")) == 1 - fresh = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + fresh = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(fresh.selection, DMSelection) assert fresh.selection.basis.peer_identity != old.selection.basis.peer_identity @@ -90,7 +102,10 @@ def test_viewer_rebind_and_foreign_worktree_reject_old_basis(tmp_path: Path): comms.registry.declare(_peer(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "old viewer painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(page.selection, DMSelection) with pytest.raises(DMScopeChanged): comms.views.mark_page_read(page, worktree=Checkout(tmp_path / "foreign")) @@ -110,7 +125,10 @@ def test_old_viewer_alive_but_new_human_selected_rejects_stale_basis(tmp_path: P comms.registry.declare(_peer(tmp_path, "peer")) old_viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", old_viewer, "old viewer painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(page.selection, DMSelection) and page.selection.basis.viewer == old_viewer comms.registry.declare(Thread('new_user', frozenset(), Checkout(tmp_path), role=UserThreadRole)) # Normal registry rename retains the old human declaration but moves its @@ -135,7 +153,10 @@ def test_newest_page_mark_leaves_older_message_unread(tmp_path: Path): viewer = comms.messaging.user_identity(Checkout(tmp_path)).name older = comms.messaging.send("peer", viewer, "older").message newest = comms.messaging.send("peer", viewer, "newest").message - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path), bounds=PageBounds(limit=1)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation, bounds=PageBounds(limit=1)) assert page.messages == (newest,) _mark(comms, "peer", tmp_path, page) assert comms.bus.inbox(viewer, CommandTarget.named("peer")).page.messages == (older,) @@ -147,7 +168,10 @@ def test_lifecycle_and_marker_lock_order_does_not_deadlock(tmp_path: Path): comms.registry.declare(_peer(tmp_path, name)) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) with ThreadPoolExecutor(max_workers=2) as pool: marked = pool.submit(_mark, comms, "peer", tmp_path, page) lifecycle = pool.submit( diff --git a/tests/test_goal_reply_lock_scope.py b/tests/test_goal_reply_lock_scope.py index 7889f184f6..01ba3b50f1 100644 --- a/tests/test_goal_reply_lock_scope.py +++ b/tests/test_goal_reply_lock_scope.py @@ -79,8 +79,8 @@ def __init__(self, path, change): self.changed = False @contextmanager - def reading(self): - with super().reading() as source: + def reading(self, *, check_same_thread: bool = True): + with super().reading(check_same_thread=check_same_thread) as source: yield source if not self.changed: self.changed = True diff --git a/tests/test_historical_page_scope.py b/tests/test_historical_page_scope.py index 62c16f8d3b..5198ffd1bc 100644 --- a/tests/test_historical_page_scope.py +++ b/tests/test_historical_page_scope.py @@ -86,7 +86,9 @@ def reader(live, kind): return lambda bounds: live.views.history_page(CommandTarget.named("carol"), bounds, viewer=NamedViewer(name)) if kind == "any-mode": live.channels.set_channel_any_mode("#api", True) - return lambda bounds: live.views.channel_display_page("#api", bounds=bounds) + return lambda bounds: live.views.channel_display_view( + '#api', + ).page(live.views.presentation, bounds=bounds) target = { "channel": "#api", "any": "#any", diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index 100fca0dac..98c914f58d 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -17,7 +17,7 @@ from agent_comms.coordination_errors import HistoricalSourceChanged from agent_comms.field_codec import InputId from agent_comms.turn_item import FreshNativeId -from agent_comms.historical_views import HistoricalMessage, HistoryArchive, HistorySource +from agent_comms.historical_views import HistoricalMessage, HistoryArchive, HistorySource, HistoryView from agent_comms.installer.backup import bus_files from agent_comms.message_page import MessagePageRequest, PageBounds from agent_comms.messages import Message @@ -75,7 +75,10 @@ def test_captured_catalogue_refuses_later_creation_or_replacement(tmp_path, exis def test_held_display_reading_rejects_later_archive_without_bus_growth(migrated, tmp_path): _, _, live, _ = migrated - with live.views.channel_display_reading('#team', viewer=NamedViewer('alice')) as read: + with live.views.channel_display_view( + '#team', + viewer=NamedViewer('alice'), + ).reading(live.views.presentation) as read: first = read(PageBounds(limit=1)) revision = live.bus.bus.revision() later = setup(tmp_path / 'later', 1) @@ -84,9 +87,10 @@ def test_held_display_reading_rejects_later_archive_without_bus_growth(migrated, with pytest.raises(HistoricalSourceChanged): read(PageBounds(first.oldest_cursor.older(), 1)) # Retiring the old reading is harmless; a new reading adopts the catalogue. - fresh = live.views.channel_display_page( - '#team', viewer=NamedViewer('alice'), bounds=PageBounds(limit=1), - ) + fresh = live.views.channel_display_view( + '#team', + viewer=NamedViewer('alice'), + ).page(live.views.presentation, bounds=PageBounds(limit=1)) assert fresh.selection.history_revision != first.selection.history_revision @@ -101,13 +105,13 @@ def setup(root, count=5): return comms -def collect(comms, method, *args, **kwargs): - page = getattr(comms.views, method)(*args, bounds=PageBounds(limit=3), **kwargs) +def collect(comms, view: HistoryView): + page = view.page(comms.views.presentation, PageBounds(limit=3)) result = list(page.messages) for _ in range(40): if not page.has_older: return result - page = getattr(comms.views, method)(*args, bounds=PageBounds(page.oldest_cursor.older(), 3), **kwargs) + page = view.page(comms.views.presentation, PageBounds(page.oldest_cursor.older(), 3)) result[0:0] = page.messages raise AssertionError("paging did not terminate") @@ -125,18 +129,20 @@ def migrated(tmp_path): @pytest.mark.parametrize( - "method,args,reader", + "view_factory,expected", [ - ("channel_display_page", ("#team",), lambda live: dict(viewer=live.views.display_viewer(Checkout(live.root)))), - ("dm_display_page", (ThreadTarget("alice"),), lambda live: dict(worktree=Checkout(live.root))), - ("channel_display_page", ("#any",), lambda live: dict(viewer=live.views.display_viewer(Checkout(live.root)))), + (lambda live: live.views.channel_display_view( + "#team", viewer=live.views.display_viewer(Checkout(live.root))), 9), + (lambda live: live.views.dm_display_view( + ThreadTarget("alice"), worktree=Checkout(live.root)), 9), + (lambda live: live.views.channel_display_view( + "#any", viewer=live.views.display_viewer(Checkout(live.root))), 18), ], ) -def test_all_history_backward_and_forward(migrated, method, args, reader): +def test_all_history_backward_and_forward(migrated, view_factory, expected): old, prior, live, source = migrated - scope = reader(live) - rows = collect(live, method, *args, **scope) - expected = 18 if args == ("#any",) else 9 + view = view_factory(live) + rows = collect(live, view) assert len(rows) == expected assert len({row.view_key for row in rows}) == expected assert len({row.seq for row in rows}) < expected @@ -144,7 +150,7 @@ def test_all_history_backward_and_forward(migrated, method, args, reader): forward = [] cursor = HistoryCursor(source=source.key, sequence=0) for _ in range(40): - page = getattr(live.views, method)(*args, bounds=PageBounds(cursor.newer(), 2), **scope) + page = view.page(live.views.presentation, PageBounds(cursor.newer(), 2)) forward.extend(page.messages) if not page.has_newer: break @@ -154,7 +160,9 @@ def test_all_history_backward_and_forward(migrated, method, args, reader): def test_original_identity_and_no_delivery_authority(migrated): old, prior, live, source = migrated - rows = collect(live, "channel_display_page", "#team", viewer=live.views.display_viewer(Checkout(live.root))) + rows = collect(live, live.views.channel_display_view( + "#team", viewer=live.views.display_viewer(Checkout(live.root)), + )) historical = [m for m in rows if isinstance(m, HistoricalMessage) and m.source == source] original = list(old.views.history_page(CommandTarget.named("#team")).messages) assert [m.to_wire() for m in historical] == [Message.from_message(m).to_wire() for m in original] @@ -168,8 +176,11 @@ def test_original_identity_and_no_delivery_authority(migrated): def test_sparse_historical_ack_does_not_ack_live(migrated): _, _, live, source = migrated viewer = live.messaging.user_identity(Checkout(live.root)) - page = live.views.channel_display_page( - "#team", viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), + page = live.views.channel_display_view( + '#team', + viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), + ).page( + live.views.presentation, bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older(), 3), ) live.views.mark_page_read(page.select([page.messages[1].seq]), worktree=Checkout(live.root)) @@ -196,11 +207,15 @@ def test_select_painted_page_keeps_its_source_with_attached_history(migrated, co _, _, live, source = migrated bounds = PageBounds(HistoryCursor(source=source.key, sequence=100).older(), 2) if archived else PageBounds(limit=2) if conversation == "channel": - page = live.views.channel_display_page( - "#team", viewer=live.views.display_viewer(Checkout(live.root)), bounds=bounds, - ) + page = live.views.channel_display_view( + '#team', + viewer=live.views.display_viewer(Checkout(live.root)), + ).page(live.views.presentation, bounds=bounds) else: - page = live.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(live.root), bounds=bounds) + page = live.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(live.root), + ).page(live.views.presentation, bounds=bounds) message = page.messages[-1] selected = page.select((message.seq,)) assert selected.messages == (message,) @@ -272,7 +287,12 @@ def test_duplicate_and_newer_incarnations_sessions(tmp_path): def test_stale_cursor_is_rejected(migrated): _, _, live, _ = migrated with pytest.raises(HistoricalSourceChanged): - live.views.channel_display_page("#team", bounds=PageBounds(HistoryCursor(source="detached", sequence=2).older())) + live.views.channel_display_view( + '#team', + ).page( + live.views.presentation, + bounds=PageBounds(HistoryCursor(source='detached', sequence=2).older()), + ) def test_recorded_transcript_witness_roundtrip_keeps_original_namespace(tmp_path): @@ -361,10 +381,10 @@ def test_attach_only_reads_original_roots_and_rejects_changed_snapshot(tmp_path) source = live.views.attach_history(old.root) assert root_bytes(old.root) == original assert not (Path(source.root) / "unknown-input.json").exists() - page = live.views.channel_display_page( - "#team", viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), - bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older()), - ) + page = live.views.channel_display_view( + '#team', + viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), + ).page(live.views.presentation, bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older())) archived = Bus(Bus.path_in(Path(source.root))) original = page.messages[0] archived.append(lambda seq, db: PublicRecord.from_message(Message( @@ -372,8 +392,11 @@ def test_attach_only_reads_original_roots_and_rejects_changed_snapshot(tmp_path) with pytest.raises(ValueError): live.views.mark_page_read(page, worktree=Checkout(live.root)) with pytest.raises(ValueError): - live.views.channel_display_page( - "#team", bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older()) + live.views.channel_display_view( + '#team', + ).page( + live.views.presentation, + bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older()), ) @@ -392,7 +415,10 @@ def test_missing_historical_peer_is_browsable_without_live_registration(migrated _, _, live, source = migrated live.registry.unregister("alice") live.registry.remove("alice") - page = live.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(live.root)) + page = live.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(live.root), + ).page(live.views.presentation) assert page.messages assert all(isinstance(message, HistoricalMessage) for message in page.messages) assert "alice" not in live.registry diff --git a/tests/test_mark_all_existing_reads.py b/tests/test_mark_all_existing_reads.py index 12248cb4cb..12b8292971 100644 --- a/tests/test_mark_all_existing_reads.py +++ b/tests/test_mark_all_existing_reads.py @@ -11,7 +11,10 @@ def test_mark_all_after_partial_reads_preserves_next_unread_message(tmp_path): viewer = comms.messaging.user_identity(Checkout(tmp_path)).name first = comms.messaging.send('alice', '#team', 'already painted').message comms.messaging.send('bob', '#team', 'not painted').message - page = comms.views.channel_display_page('#team', viewer=NamedViewer(viewer)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(viewer), + ).page(comms.views.presentation) selection = page.selection scope = selection.scope page = replace(page, selection=replace(selection, scope=replace( diff --git a/tests/test_named_target_contracts.py b/tests/test_named_target_contracts.py index 004e87c76c..7ec10aed53 100644 --- a/tests/test_named_target_contracts.py +++ b/tests/test_named_target_contracts.py @@ -82,7 +82,7 @@ def test_retained_dm_target_follows_rename_through_send_page_and_revision(tmp_pa delivery = comms.bus.bus.delivery(sent.seq) assert [(row.canonical_thread, row.recipient_lookup) for row in delivery.recipients] == [ ("renamed", target.incarnation.require_recorded().lookup)] - page = comms.views.dm_display_page(target, worktree=worktree) + page = comms.views.dm_display_view(target, worktree=worktree).page(comms.views.presentation) assert page.messages == (Message.from_message(original), Message.from_message(sent)) assert page.selection.basis.peer_identity == target.incarnation generic_page = comms.views.history_page(target, viewer=NamedViewer(sent.sender)) @@ -115,7 +115,7 @@ def test_retained_dm_refuses_a_removed_birth_at_every_selection_boundary(tmp_pat comms.messaging.send_user_message(target, "must not reach successor", worktree=worktree) assert comms.bus.bus.revision() == before with pytest.raises(UnregisteredThreadError): - comms.views.dm_display_page(target, worktree=worktree) + comms.views.dm_display_view(target, worktree=worktree).page(comms.views.presentation) with pytest.raises(UnregisteredThreadError): DMDisplayHistory.revision(comms.views, target, ConversationObservation(), worktree=worktree) with pytest.raises(UnregisteredThreadError): @@ -150,6 +150,6 @@ def replace_before_read(request, source): monkeypatch.setattr(ConversationMessagePageRequest, "read_opened", replace_before_read) with pytest.raises(DMScopeChanged): - comms.views.dm_display_page(target, worktree=worktree) + comms.views.dm_display_view(target, worktree=worktree).page(comms.views.presentation) assert len(successor_messages) == 1 assert comms.bus.bus.lookup(successor_messages[0].seq) == successor_messages[0] diff --git a/tests/test_ordinary_nk_delivery.py b/tests/test_ordinary_nk_delivery.py index 7f77aef6e5..2194fc9c28 100644 --- a/tests/test_ordinary_nk_delivery.py +++ b/tests/test_ordinary_nk_delivery.py @@ -33,4 +33,7 @@ def test_fresh_send_uses_canonical_publication_and_human_delivery_has_no_wake(tm assert isinstance(human.message.role, NoticeRole) assert human.recipients == () assert human.decisions == () - assert comms.views.dm_display_page(ThreadTarget("sender"), worktree=Checkout(tmp_path)).messages[-1] == reply + assert comms.views.dm_display_view( + ThreadTarget('sender'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation).messages[-1] == reply diff --git a/tests/test_read_ledger.py b/tests/test_read_ledger.py index 2962856e2f..5f082fe8aa 100644 --- a/tests/test_read_ledger.py +++ b/tests/test_read_ledger.py @@ -150,7 +150,10 @@ def test_bounded_page_marks_only_shown_messages(tmp_path): comms = prepared(tmp_path) for i in range(5): comms.messaging.send("alice", "#team", f"message {i}").message.message_id - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), bounds=PageBounds(limit=2)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation, bounds=PageBounds(limit=2)) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) assert wire(tmp_path).views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#team"].count == 3 @@ -158,7 +161,10 @@ def test_bounded_page_marks_only_shown_messages(tmp_path): def test_displayed_message_is_read_in_every_projection(tmp_path): comms = prepared(tmp_path) comms.messaging.send("alice", "#team", "visible in both views").message.message_id - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) assert wire(tmp_path).views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#any"].count == 0 @@ -169,7 +175,10 @@ def test_any_mode_expansion_cannot_read_unshown_message_in_same_conversation(tmp hidden = comms.messaging.send("bob", "carol", "no member mentioned").message shown = comms.messaging.send("bob", "carol", "@alice shown by mention").message comms.channels.set_channel_any_mode("#team", True) - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) assert [message.seq for message in page.messages] == [shown.seq] comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) comms.channels.update_tags("bob", add=frozenset({"team"})) @@ -189,10 +198,16 @@ def test_turn_lease_and_unrelated_registry_changes_do_not_invalidate_dm(tmp_path comms.registry.register(replace(peer, process_identity=ProcessIdentity.capture(os.getpid()))) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("alice", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert page.selection.basis.peer_identity == peer.incarnation comms.registry.lease_local_turn("alice", TurnId('0b225c32b06b9107d59b5d92d13b83a3')) - fresh = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + fresh = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert fresh.selection.basis.peer_identity == page.selection.basis.peer_identity comms.registry.register(Thread('unrelated', frozenset(), Checkout(tmp_path))) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) @@ -213,9 +228,11 @@ def test_dm_ack_survives_process_exit_and_rebind_does_not_inherit_reads(tmp_path "import os, sys\n" "from agent_comms.channel_targets import ThreadTarget\n" "from agent_comms.comms import wire\n" + "from agent_comms.repo import Checkout\n" "comms = wire(sys.argv[1])\n" - "page = comms.views.dm_display_page(ThreadTarget('alice'), worktree=sys.argv[1])\n" - "comms.views.mark_page_read(page, worktree=sys.argv[1])\n" + "project = Checkout(sys.argv[1])\n" + "page = comms.views.dm_display_view(ThreadTarget('alice'), worktree=project).page(comms.views.presentation)\n" + "comms.views.mark_page_read(page, worktree=project)\n" "os._exit(9)\n", str(tmp_path), ], @@ -243,12 +260,12 @@ def test_shown_only_property_across_pages_modes_and_reopen(tmp_path, seed): for i in range(12)] for _ in range(18): comms.channels.set_channel_any_mode("#team", rng.choice([True, False])) - page = comms.views.channel_display_page( - rng.choice(["#team", "#any"]), + page = comms.views.channel_display_view( + rng.choice(['#team', '#any']), viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), - bounds=PageBounds( - limit=rng.randrange(1, 8), traversal=FieldCodec.decode(PageCursor, rng.choice([None, 6, 10])).older() - ), + ).page( + comms.views.presentation, + bounds=PageBounds(limit=rng.randrange(1, 8), traversal=FieldCodec.decode(PageCursor, rng.choice([None, 6, 10])).older()), ) if page.messages: through = rng.choice(page.messages).seq @@ -324,7 +341,10 @@ def test_replaced_bus_keeps_no_sequence_read_facts(tmp_path): comms = prepared(tmp_path) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("alice", "#team", "painted").message - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) replacement = tmp_path / "replacement.sqlite3" comms.bus.bus.backup(replacement) @@ -335,7 +355,10 @@ def test_replaced_bus_keeps_no_sequence_read_facts(tmp_path): from agent_comms.coordination_errors import DMScopeChanged with pytest.raises(DMScopeChanged): reopened.views.mark_page_read(page, worktree=Checkout(tmp_path)) - fresh = reopened.views.channel_display_page("#team", viewer=NamedViewer(viewer)) + fresh = reopened.views.channel_display_view( + '#team', + viewer=NamedViewer(viewer), + ).page(reopened.views.presentation) reopened.views.mark_page_read(fresh, worktree=Checkout(tmp_path)) assert reopened.views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#team"].count == 0 @@ -354,7 +377,10 @@ def test_recorded_bus_identity_is_one_member(tmp_path): assert not ledger.read().bus_identity.matches(ObservedPath.at(comms.bus.bus.path)) assert comms.views.viewer_snapshot(Checkout(tmp_path)).unread["alice"].count == 1 - page = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) recorded = RecordedBus(FileIdentity.from_stat(comms.bus.bus.path.stat())) assert ledger.read().bus_identity == recorded == page.selection.basis.bus_identity @@ -375,7 +401,10 @@ def test_recorded_bus_identity_is_one_member(tmp_path): assert reopened.views.viewer_snapshot(Checkout(tmp_path)).unread["alice"].count == 1 with pytest.raises(DMScopeChanged): reopened.views.mark_page_read(page, worktree=Checkout(tmp_path)) - fresh = reopened.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + fresh = reopened.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(reopened.views.presentation) reopened.views.mark_page_read(fresh, worktree=Checkout(tmp_path)) assert reopened.views.viewer_snapshot(Checkout(tmp_path)).unread["alice"].count == 0 @@ -383,7 +412,10 @@ def test_recorded_bus_identity_is_one_member(tmp_path): def test_partial_paints_share_one_basis_across_read_progress(tmp_path): comms = prepared(tmp_path) messages = [comms.messaging.send("alice", "#team", f"row {i}").message for i in range(5)] - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), bounds=PageBounds(limit=3)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation, bounds=PageBounds(limit=3)) scope = page.selection.scope assert scope is not None and scope.displayed is not None # Requested sequences outside the original page cannot become read facts. @@ -448,13 +480,19 @@ def current_cursor(facts): comms.channels.set_channel_any_mode("#team", rng.choice([True, False])) elif operation == "display": if rng.choice([True, False]): - page = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path), bounds=PageBounds(limit=3)) + page = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation, bounds=PageBounds(limit=3)) basis = page.selection.basis.displayed kind, target = "dm", "alice" proof = page.selection.basis else: target = rng.choice(["#team", "#any"]) - page = comms.views.channel_display_page(target, viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), bounds=PageBounds(limit=3)) + page = comms.views.channel_display_view( + target, + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation, bounds=PageBounds(limit=3)) basis = page.selection.scope.displayed kind, proof = "channel", page.selection.scope selected = {m.seq for m in page.messages if rng.choice([True, False])} diff --git a/tests/test_s4_read_characterization.py b/tests/test_s4_read_characterization.py index 290ebc5e03..56ccfe7f74 100644 --- a/tests/test_s4_read_characterization.py +++ b/tests/test_s4_read_characterization.py @@ -17,7 +17,10 @@ def test_replaced_human_incarnation_rejects_painted_proof_and_prior_reads(tmp_pa comms.registry.declare(Thread('worker', frozenset({'team'}), Checkout(tmp_path))) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name message = comms.messaging.send("worker", "#team", "painted").message - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) assert message.seq in comms.bus.reads.cursor(viewer, comms.registry.snapshot()).for_conversation(ReadLedger.conversation(message, comms.registry.snapshot())) original = comms.registry.require(viewer) @@ -27,7 +30,10 @@ def test_replaced_human_incarnation_rejects_painted_proof_and_prior_reads(tmp_pa assert message.seq not in comms.bus.reads.cursor(viewer, comms.registry.snapshot()).for_conversation(ReadLedger.conversation(message, comms.registry.snapshot())) with pytest.raises(DMScopeChanged): comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) - refreshed = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + refreshed = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(refreshed, worktree=Checkout(tmp_path)) assert message.seq in comms.bus.reads.cursor(viewer, comms.registry.snapshot()).for_conversation(ReadLedger.conversation(message, comms.registry.snapshot())) diff --git a/tests/test_thread_identity.py b/tests/test_thread_identity.py index 7a0afa1882..4edfb0275b 100644 --- a/tests/test_thread_identity.py +++ b/tests/test_thread_identity.py @@ -257,7 +257,10 @@ def test_saved_read_ledger_survives_reopen_and_new_ack(tmp_path): comms.registry.declare(Thread('owner', frozenset(), Checkout(tmp_path))) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name message = comms.messaging.send("owner", viewer, "already painted").message - page = comms.views.dm_display_page(ThreadTarget("owner"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('owner'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) path = comms.bus.reads.path document = FieldCodec.decode(ReadDocument, json.loads(path.read_text())) @@ -271,7 +274,10 @@ def test_saved_read_ledger_survives_reopen_and_new_ack(tmp_path): conversation = reopened.bus.reads.conversation(message, snapshot) assert message.seq in reopened.bus.reads.cursor(viewer, snapshot).for_conversation(conversation) next_message = reopened.messaging.send("owner", viewer, "new paint").message - page = reopened.views.dm_display_page(ThreadTarget("owner"), worktree=Checkout(tmp_path)) + page = reopened.views.dm_display_view( + ThreadTarget('owner'), + worktree=Checkout(tmp_path), + ).page(reopened.views.presentation) reopened.views.mark_page_read(page, worktree=Checkout(tmp_path)) seen = reopened.bus.reads.cursor(viewer, reopened.registry.snapshot()).for_conversation(conversation) assert all(sequence in seen for sequence in (message.seq, next_message.seq)) diff --git a/tests/test_user_thread_lookup.py b/tests/test_user_thread_lookup.py index 4222093b6a..cbe690e53a 100644 --- a/tests/test_user_thread_lookup.py +++ b/tests/test_user_thread_lookup.py @@ -41,9 +41,14 @@ def test_channel_and_dm_history_keep_the_human_as_sender(tmp_path): human = comms.registry.user_thread().existing()[0] assert channel.sender == direct.sender == human.name assert channel.sender_role is direct.sender_role is UserThreadRole - channel_page = comms.views.channel_display_page( - '#comms', viewer=comms.views.display_viewer(Checkout(tmp_path))) - direct_page = comms.views.dm_display_page(ThreadTarget('peer'), worktree=Checkout(tmp_path)) + channel_page = comms.views.channel_display_view( + '#comms', + viewer=comms.views.display_viewer(Checkout(tmp_path)), + ).page(comms.views.presentation) + direct_page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert channel.message_id in {message.message_id for message in channel_page.messages} assert direct.message_id in {message.message_id for message in direct_page.messages} assert all(message.sender == human.name for message in (*channel_page.messages, *direct_page.messages)) diff --git a/tests/test_view_unread.py b/tests/test_view_unread.py index 834c3215fd..421673b1f9 100644 --- a/tests/test_view_unread.py +++ b/tests/test_view_unread.py @@ -98,7 +98,10 @@ def test_any_mode_rejects_stale_painted_page_after_participant_joins(tmp_path): comms.channels.set_channel_any_mode("#team", True) comms.messaging.send("carol", "dave", "hidden old DM").message comms.messaging.send("alice", "bob", "painted DM").message - painted = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + painted = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) assert [message.seq for message in painted.messages] == [2] comms.channels.update_tags("carol", add=frozenset({"team"})) @@ -131,5 +134,7 @@ def test_crashed_sender_reopen_does_not_hide_unpainted_any_mode_dm(): assert child.returncode == 9 reopened = wire(root) reopened.channels.set_channel_any_mode("#team", True) - assert [message.seq for message in list(reopened.views.channel_display_page("#team").messages)] == [1, 2] + assert [message.seq for message in list(reopened.views.channel_display_view( + '#team', + ).page(reopened.views.presentation).messages)] == [1, 2] assert reopened.views.viewer_snapshot(Checkout(root)).channel_unread["#team"].count == 2 From e90e4b53971cd1420701bd4a29acacafecda6e87 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:34:45 -0400 Subject: [PATCH 23/24] Keep the history traversal in integrated_page; the catalogue check wraps it as checked_page The gate charged NoneAbsence at historical_views.py:597 as introduced: the traversal body, unchanged from main, moved into a new _integrated_page scope and integrated_page became the revision-checking wrapper. The traversal keeps its name and scope; the wrapper is the new member, checked_page, and HistoryView.reading binds it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/historical_views.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index e257ff8ead..dcd8fc8dc4 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -89,7 +89,7 @@ def reading(self, presentation: BusPresentation) -> Iterator[Callable[[PageBound """ with presentation.reading(self.viewer) as captured: history = captured.bus.history.captured() - yield partial(history.integrated_page, self, captured) + yield partial(history.checked_page, self, captured) def page(self, presentation: BusPresentation, bounds: PageBounds = NEWEST_PAGE) -> MessagePage: with self.reading(presentation) as read: @@ -578,13 +578,14 @@ def page(self, view: HistoryView, presentation: BusPresentation, bounds: PageBou ) return MessagePage((), False, False, NoDisplaySelection()) - def integrated_page( + def checked_page( self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds ) -> MessagePage: + """One integrated page read from a catalogue checked unchanged before and after it.""" with self.reading() as captured: - return captured._integrated_page(view, presentation, bounds) + return captured.integrated_page(view, presentation, bounds) - def _integrated_page( + def integrated_page( self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds ) -> MessagePage: # One traversal asks once whether live messages follow its archives. From 40963e0f8ad302abc8da89169b0b8d939a6889e6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:50:11 -0400 Subject: [PATCH 24/24] Page history tests through one reader, so main's history_page test runs again test_normal_comms_api_contains_history_and_execution_stays_live came in from main still calling collect(live, "history_page", target), the helper's old shape; this branch had changed collect to take a HistoryView, so it raised TypeError at test_historical_views.py:358. collect now takes the page reader itself, and each caller passes the one it reads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- tests/test_historical_views.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index 98c914f58d..74f13d4fa2 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -4,7 +4,9 @@ from agent_comms.repo import Checkout import json import os +from collections.abc import Callable from dataclasses import replace +from functools import partial from pathlib import Path import pytest @@ -17,9 +19,9 @@ from agent_comms.coordination_errors import HistoricalSourceChanged from agent_comms.field_codec import InputId from agent_comms.turn_item import FreshNativeId -from agent_comms.historical_views import HistoricalMessage, HistoryArchive, HistorySource, HistoryView +from agent_comms.historical_views import HistoricalMessage, HistoryArchive, HistorySource from agent_comms.installer.backup import bus_files -from agent_comms.message_page import MessagePageRequest, PageBounds +from agent_comms.message_page import MessagePage, MessagePageRequest, PageBounds from agent_comms.messages import Message from agent_comms.page_traversal import HistoryCursor from agent_comms.read_basis import ( @@ -105,13 +107,14 @@ def setup(root, count=5): return comms -def collect(comms, view: HistoryView): - page = view.page(comms.views.presentation, PageBounds(limit=3)) +def collect(read: Callable[[PageBounds], MessagePage]): + """Every message one page reader yields, paging backward three at a time.""" + page = read(PageBounds(limit=3)) result = list(page.messages) for _ in range(40): if not page.has_older: return result - page = view.page(comms.views.presentation, PageBounds(page.oldest_cursor.older(), 3)) + page = read(PageBounds(page.oldest_cursor.older(), 3)) result[0:0] = page.messages raise AssertionError("paging did not terminate") @@ -142,7 +145,7 @@ def migrated(tmp_path): def test_all_history_backward_and_forward(migrated, view_factory, expected): old, prior, live, source = migrated view = view_factory(live) - rows = collect(live, view) + rows = collect(partial(view.page, live.views.presentation)) assert len(rows) == expected assert len({row.view_key for row in rows}) == expected assert len({row.seq for row in rows}) < expected @@ -160,9 +163,9 @@ def test_all_history_backward_and_forward(migrated, view_factory, expected): def test_original_identity_and_no_delivery_authority(migrated): old, prior, live, source = migrated - rows = collect(live, live.views.channel_display_view( + rows = collect(partial(live.views.channel_display_view( "#team", viewer=live.views.display_viewer(Checkout(live.root)), - )) + ).page, live.views.presentation)) historical = [m for m in rows if isinstance(m, HistoricalMessage) and m.source == source] original = list(old.views.history_page(CommandTarget.named("#team")).messages) assert [m.to_wire() for m in historical] == [Message.from_message(m).to_wire() for m in original] @@ -355,10 +358,10 @@ def test_detached_recorded_witness_cannot_rebind_to_same_name_live_thread(migrat def test_normal_comms_api_contains_history_and_execution_stays_live(migrated): _, _, live, _ = migrated viewer = NamedViewer(live.messaging.user_identity(Checkout(live.root)).name) - assert len(collect(live, "history_page", CommandTarget.named("#team"))) == 9 - assert len(collect(live, "history_page", CommandTarget.named("alice"), viewer=viewer)) == 9 - assert len(collect(live, "history_page", CommandTarget.named("#any"))) == 18 - for message in collect(live, "history_page", CommandTarget.named("#any")): + assert len(collect(partial(live.views.history_page, CommandTarget.named("#team")))) == 9 + assert len(collect(partial(live.views.history_page, CommandTarget.named("alice"), viewer=viewer))) == 9 + assert len(collect(partial(live.views.history_page, CommandTarget.named("#any")))) == 18 + for message in collect(partial(live.views.history_page, CommandTarget.named("#any"))): if isinstance(message, HistoricalMessage): assert not message.role.response_policy(message).starts_turn assert not message.role.response_policy(message).starts_turn_for(message, "bob")