diff --git a/pyproject.toml b/pyproject.toml index 332937a694..bf724a5d55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ agent-comms-commander = "agent_comms.commander.cli:main" agent-comms-integrate = "agent_comms.integration:main" agent-comms-commander-skill = "agent_comms_hooks.skill:main" agent-comms-commander-reread = "agent_comms_hooks.reread:main" +agent-comms-prime = "agent_comms_hooks.prime:main" agent-comms-commander-deferral-guard = "agent_comms_hooks.deferral:main" agent-comms-commander-bounded-waits = "agent_comms_hooks.bounded_waits:main" agent-comms-read-gate = "agent_comms_hooks.read_gate:main" diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index 2f4b1ce036..45dee1f3e9 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 d905a17a2c..f1c8da8849 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -38,6 +38,7 @@ Claimed, ClaimMessage, Message, + StoredWireHistory, MessageKind, PublicRecord, ) @@ -49,6 +50,7 @@ ResourceClaim, ) from agent_comms.errors import CommittedMessageError, RelationViolationError, UnclaimedError +from agent_comms.coordination_errors import CoordinationReadUnavailable from agent_comms.field_codec import ClaimId, FieldCodec, MessageId, ThreadLookup from agent_comms.locked_store import BusFile, FileStore, SQLiteReader, StoreCommittedError from agent_comms.message_reference import MessageReference @@ -130,13 +132,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) @@ -174,6 +181,10 @@ def reading(self) -> Iterator[BusSnapshot]: with self.observing() as db: yield BusSnapshot.from_connection(db) + @abstractmethod + def captured_reading(self) -> AbstractContextManager[BusSnapshot]: + """Retain a cut independently of ordinary reads on the acquiring thread.""" + def revision(self) -> int: with self.observing() as db: return BusRow.last_seq(db) @@ -186,10 +197,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: @@ -253,9 +264,36 @@ def keyed_messages(self, publication_key: str) -> tuple[BusRecord, ...]: def page(self, before: int, limit: int) -> tuple[BusRecord, ...]: with self.observing() as db: - return BusCapability.at_sequences(db, tuple(row.seq for row in BusRow.select( - db, where="{seq} < ?", parameters=(before,), - order_by=("seq",), descending=True, limit=limit))) + return BusCapability.at_sequences( + db, tuple(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, + ) + + @contextmanager + def stored_message_history( + self, predicate: str = "1", parameters: tuple[object, ...] = (), + *, through: int, + ) -> Iterator[StoredWireHistory]: + """Stream declared public wire fields from one read snapshot, without Messages.""" + with self.observing() as db: + query = Message.stored_wire_query(db, MessageIdentitySource) + query = (f'SELECT seq, timestamp, wire FROM ({query}) ' + f'WHERE seq <= ? AND ({predicate})') + yield StoredWireHistory(db, query, (through, *parameters)) def message_history(self) -> Iterator[Message]: """Public bulk export, paged within one committed SQLite snapshot.""" @@ -288,22 +326,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 BusCapability.at_sequences(db, tuple(header.seq for header in headers)) + descending=descending, + )) def latest_conversations( self, predicate: str, parameters: tuple[object, ...], through: int, @@ -469,7 +507,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, @@ -498,6 +537,14 @@ def observing(self) -> Iterator[sqlite3.Connection]: with self.reader_snapshot() as db: yield db + @contextmanager + def captured_reading(self) -> Iterator[BusSnapshot]: + # Unlike a nested ordinary read, a presentation outlives this worker's + # next request. Its transaction must never enter the thread-local pool. + with CoordinationReadUnavailable.checking(): + with closing(self.read_snapshot(self.path, check_same_thread=False)) as db: + yield BusSnapshot.from_connection(db) + class Bus(FileStore, StoredBusReader, ReconciledFile, WalStore, CarriedWriter, BusFile, Watched): """One SQLite transaction owns the sequence and all of a record's fields.""" @@ -655,3 +702,7 @@ def observing(self) -> Iterator[sqlite3.Connection]: @contextmanager def reading(self) -> Iterator[BusSnapshot]: yield self + + @contextmanager + def captured_reading(self) -> Iterator[BusSnapshot]: + yield self diff --git a/src/agent_comms/bus_tables.py b/src/agent_comms/bus_tables.py index 8186493cbf..8c3ba48c68 100644 --- a/src/agent_comms/bus_tables.py +++ b/src/agent_comms/bus_tables.py @@ -132,6 +132,17 @@ def require_matched(cls, matches: Iterable[ReferenceMatch], refusal: Exception) if not all(match.matched for match in matches): raise refusal + @classmethod + def reference_predicate( + cls, references: tuple[MessageReference, ...], + ) -> tuple[str, tuple[object, ...]]: + plans = FieldCodec.field_plans(MessageReference) + columns = cls._column_list(tuple(plan.name for plan in plans)) + selected = ', '.join('json_extract(value, ?)' for plan in plans) + return (f'({columns}) IN (SELECT {selected} FROM json_each(?))', + (*(f'$.{plan.key}' for plan in plans), + FieldCodec.compact([FieldCodec.encode(reference) for reference in references]))) + @classmethod def require_sequences(cls, db: sqlite3.Connection, sequences: tuple[int, ...]) -> None: matches = ReferenceMatch.read(db.execute( diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index cad7959829..314470b1bd 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -16,6 +16,7 @@ from .repo import Checkout from .response_policy import DirectPolicy, MentionedPolicy from .thread_identity import ThreadIncarnation +from .thread_name import ThreadName from .typed_table import sql_literal from .wake_policy import BoundedTriageWake, FullWake, MessageAudience @@ -23,8 +24,7 @@ from .audience_manifest import AudiencedMessage, FrozenRecipient from .catalog_document import CatalogAccess, CatalogDocument from .channels import Channel - from .cli_command import CliCommand - from .cli_result import CliResult + from .cli_command import CliCommand, CommandBinding from .comms import Comms from .goal_management import Goals from .historical_views import ChannelHistory, DMHistory, HistoryView @@ -166,7 +166,7 @@ def resolve(self, snapshot: RegistrySnapshot, require_channel: Callable[[str], C def bindings[C: CliCommand]( self, declaration: type[C], comms: Comms, channel: SurroundingChannel, resolved, *, project: Checkout, snapshot: RegistrySnapshot, catalog: CatalogAccess, - ) -> tuple[C, ...]: ... + ) -> tuple[CommandBinding, ...]: ... def history_view(self, viewer: DisplayViewer, require_channel: Callable[[str], Channel], channel: type[ChannelHistory], direct: type[DMHistory]) -> HistoryView: @@ -177,7 +177,7 @@ def history_result(self, require_channel: Callable[[str], Channel], messages: tu raise ValueError("History requires a #channel or DM peer.") def contexts( - self, declaration: type[CliCommand[CliResult]], channel: ChannelSelection, + self, declaration: type[CliCommand], channel: ChannelSelection, ) -> tuple[SurroundingChannel, ...]: return (NoChannel(),) @@ -393,7 +393,7 @@ def history_result(self, require_channel: Callable[[str], Channel], messages: tu return self.history_result_type.for_target(self.name, messages, has_older) def contexts( - self, declaration: type[CliCommand[CliResult]], channel: ChannelSelection, + self, declaration: type[CliCommand], channel: ChannelSelection, ) -> tuple[SurroundingChannel, ...]: return declaration.selection_channels(channel, self.name) @@ -413,15 +413,13 @@ def resolve(self, snapshot: RegistrySnapshot, require_channel: Callable[[str], C def bindings[C: CliCommand]( self, declaration: type[C], comms: Comms, channel: SurroundingChannel, resolved: ResolvedThread, *, project: Checkout, snapshot: RegistrySnapshot, catalog: CatalogAccess, - ) -> tuple[C, ...]: + ) -> tuple[CommandBinding, ...]: return declaration.thread_bindings(comms, resolved.thread, resolved.status, channel, catalog=catalog) 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.") - allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") - if not set(self.name) <= allowed: - raise 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() @@ -483,10 +481,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: @@ -562,7 +557,7 @@ def resolve(self, snapshot: RegistrySnapshot, require_channel: Callable[[str], C def bindings[C: CliCommand]( self, declaration: type[C], comms: Comms, channel: SurroundingChannel, resolved: ResolvedChannel, *, project: Checkout, snapshot: RegistrySnapshot, catalog: CatalogAccess, - ) -> tuple[C, ...]: + ) -> tuple[CommandBinding, ...]: return declaration.channel_bindings(comms, resolved, project=project, snapshot=snapshot, catalog=catalog) def validate(self, sender: Message | None = None): @@ -654,7 +649,7 @@ def resolve(self, snapshot: RegistrySnapshot, require_channel: Callable[[str], C def bindings[C: CliCommand]( self, declaration: type[C], comms: Comms, channel: SurroundingChannel, resolved, *, project: Checkout, snapshot: RegistrySnapshot, catalog: CatalogAccess, - ) -> tuple[C, ...]: + ) -> tuple[CommandBinding, ...]: return declaration.project_bindings(comms, resolved) diff --git a/src/agent_comms/check_root.py b/src/agent_comms/check_root.py index 7070dc907a..a9ce91a426 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,11 +94,11 @@ 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, 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/cli_command.py b/src/agent_comms/cli_command.py index a520dacacf..f4f7d11c07 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -16,6 +16,7 @@ from agent_comms.lifecycle_capability import LifecycleCapability, Successful from agent_comms.registry_document import RegistrySnapshot from agent_comms.target_fields import TargetField +from agent_comms.thread_model import CatalogSelection from dataclasses import dataclass, field, replace from pydantic import JsonValue from collections.abc import Mapping @@ -27,7 +28,7 @@ from agent_comms.field_codec import FieldPlan, TargetBound from agent_comms.launch_root import LaunchRoot from inspect import iscoroutinefunction, signature -from types import get_original_bases +from types import MappingProxyType, get_original_bases from typing import TYPE_CHECKING, TextIO, cast, get_args, get_origin if TYPE_CHECKING: @@ -63,15 +64,156 @@ def __init_subclass__(cls) -> None: Answer = TypeVar("Answer", bound=CliResult, covariant=True, default=CliResult) +class CommandBinding(ABC): + """A declared operation can collect fields before a complete command exists.""" + + @property + @abstractmethod + def declaration(self) -> type[CliCommand]: ... + + @property + def editable_fields(self) -> tuple[TargetField, ...]: + return tuple(self.field_value(entry) + for entry in FieldCodec.argument_fields(self.declaration) + if not entry.setting(TargetBound, False)) + + @abstractmethod + def field_value(self, entry: FieldPlan) -> TargetField: ... + + @abstractmethod + def retained_payload(self) -> dict[str, object]: + """Already decoded constructor values, never serialized for editing.""" + + def for_editor(self, comms: Comms, target: str, project: Checkout) -> Self: + return self + + def for_fields(self, arguments: Mapping[str, object]) -> CommandBinding: + return self.edited(self.declaration.editor_arguments(arguments)) if arguments else self + + async def prepare_field(self, item: TargetField, catalogs: ConfigOptions) -> TargetField: + return await item.prepare(catalogs) + + async def prepare_fields( + self, arguments: Mapping[str, object] = MappingProxyType({}), + ) -> tuple[TargetField, ...]: + binding = self.for_fields(arguments) + catalogs = ConfigOptions.configured() + return tuple([await binding.prepare_field(item, catalogs) + for item in binding.editable_fields]) + + def authorize_edits(self, arguments: Mapping[str, object]) -> frozenset[str]: + bound = frozenset(entry.key for entry in FieldCodec.argument_fields(self.declaration) + if entry.setting(TargetBound, False)) + if bound & arguments.keys(): + raise ValueError("Target-bound parameters cannot be overridden") + if arguments.keys() - {item.name for item in self.editable_fields}: + raise ValueError("Parameters not offered by this target command cannot be overridden") + return bound + + @staticmethod + def decoded_edits(declaration: type, arguments: Mapping[str, object]) -> dict[str, object]: + return {name: value + for entry in FieldCodec.argument_fields(declaration) if entry.key in arguments + for name, value in entry.decoded(arguments).items()} + + def edited(self, arguments: Mapping[str, object]) -> CliCommand: + self.authorize_edits(arguments) + values = {**self.retained_payload(), **self.decoded_edits(self.declaration, arguments)} + for parameter in self.editable_fields: + parameter.plan.require_constructor_input(values) + return FieldCodec.construct(self.declaration, values) + + @abstractmethod + def confirmation(self) -> str: ... + + @classmethod + @abstractmethod + def selection_confirmation(cls, commands: tuple[Self, ...]) -> str: ... + + @abstractmethod + def with_confirmation(self, confirmed: bool) -> CommandBinding: ... + + +class CatalogBinding(CommandBinding): + """Only a binding declaring catalog inputs has a catalog question to answer.""" + + @property + @abstractmethod + def catalog_context(self) -> CatalogSelection: ... + + async def prepare_field(self, item: TargetField, catalogs: ConfigOptions) -> TargetField: + return await item.prepare_catalog(catalogs, self.catalog_context) + + +@dataclass(frozen=True) +class CommandContext(CatalogSelection): + """A declaration of supplied fields, not an alternate command editor; the one declared + command inheriting it owns the fields it lacks.""" + + @classmethod + def command(cls) -> type[CliCommand]: + (command,) = CliCommand.members_with(cls) + return command + + +@dataclass(frozen=True) +class PendingCommandBinding(CatalogBinding): + """Only supplied context, borrowing the concrete command's declared fields.""" + + context: CommandContext + + @property + def declaration(self) -> type[CliCommand]: + return type(self.context).command() + + @property + def catalog_context(self) -> CatalogSelection: + return self.context + + def retained_payload(self) -> dict[str, object]: + return {entry.name: entry.current(self.context) + for entry in FieldCodec.field_plans(type(self.context))} + + def field_value(self, entry: FieldPlan) -> TargetField: + for supplied in FieldCodec.field_plans(type(self.context)): + if supplied.declared is entry.declared: + return TargetField.of(entry, supplied.current(self.context)) + return TargetField.omitted(entry) + + def for_fields(self, arguments: Mapping[str, object]) -> PendingCommandBinding: + self.authorize_edits(arguments) + context_fields = {entry.key for entry in FieldCodec.argument_fields(type(self.context))} + wire = FieldCodec.editor_arguments(type(self.context), { + key: value for key, value in arguments.items() if key in context_fields + }) + changes = self.decoded_edits(type(self.context), wire) + return replace(self, context=replace(self.context, **changes)) if changes else self + + def confirmation(self) -> str: + return '' + + @classmethod + def selection_confirmation(cls, commands: tuple[Self, ...]) -> str: + return '' + + def with_confirmation(self, confirmed: bool) -> CliCommand: + return self.edited({}).with_confirmation(confirmed) + + @dataclass(frozen=True, kw_only=True) class CliCommand( ModuleMembers, DeclaredCommand, CliExecution[Answer], + CommandBinding, Generic[Answer], affix="CliCommand", discovery_package="agent_comms.cli_verbs", ): + @property + def declaration(self) -> type[Self]: + return type(self) + def schedule(self, batch: TargetBatchSchedule, comms: Comms) -> None: result = TargetCompleted(batch.target, self, self.apply(comms)) batch.outcomes.append((batch.position, result)) @@ -125,21 +267,21 @@ def selection_channels(cls, channel: ChannelSelection, return channel.channels_for(target) @classmethod - def thread_bindings(cls, comms: Comms, thread: Thread, status: ThreadStatus, channel: SurroundingChannel, *, catalog: CatalogAccess) -> tuple[Self, ...]: + def thread_bindings(cls, comms: Comms, thread: Thread, status: ThreadStatus, channel: SurroundingChannel, *, catalog: CatalogAccess) -> tuple[CommandBinding, ...]: return () @classmethod - def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[Self, ...]: + def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[CommandBinding, ...]: return () @classmethod - def channel_bindings(cls, comms: Comms, resolved: ResolvedChannel, *, project: Checkout, snapshot: RegistrySnapshot, catalog: CatalogAccess) -> tuple[Self, ...]: + def channel_bindings(cls, comms: Comms, resolved: ResolvedChannel, *, project: Checkout, snapshot: RegistrySnapshot, catalog: CatalogAccess) -> tuple[CommandBinding, ...]: return () @classmethod def bindings(cls, comms: Comms, target: CommandTarget, channel: SurroundingChannel, *, project: Checkout, snapshot: RegistrySnapshot, catalog: CatalogAccess, - resolved: ResolvedThread | ResolvedChannel | Checkout) -> tuple[Self, ...]: + resolved: ResolvedThread | ResolvedChannel | Checkout) -> tuple[CommandBinding, ...]: return target.bindings( cls, comms, channel, resolved, project=project, snapshot=snapshot, catalog=catalog) @@ -160,7 +302,7 @@ def target_catalog(cls, comms: Comms, target: TargetSelection, catalog = (comms.channels.catalog.read() if channel.needs_catalog or any(item.needs_channel_state for item in selected) else NoCatalog()) resolved_targets = tuple((name, name.resolve(snapshot, comms.bus.require_channel)) for name in available_targets) - grouped: dict[type[CliCommand], dict[CliCommand, None]] = {} + grouped: dict[type[CliCommand], dict[CommandBinding, None]] = {} for member in target.command_members(cls): selected_channels = channel if member.channel_fields() else NoChannel() for name, resolved in resolved_targets: @@ -176,23 +318,13 @@ def target_catalog(cls, comms: Comms, target: TargetSelection, channel if declaration.channel_fields() else NoChannel()) for declaration, commands in grouped.items()) - def for_editor(self, comms: Comms, target: str, project: Checkout) -> Self: - return self + def field_value(self, entry: FieldPlan) -> TargetField: + return TargetField.of(entry, entry.current(self)) - @property - def editable_fields(self) -> tuple[TargetField, ...]: - """The command owns the parameters offered by its target action.""" - - return tuple( - TargetField.of(entry, entry.current(self)) - for entry in FieldCodec.argument_fields(type(self)) - if not entry.setting(TargetBound, False) - ) - - async def prepare_fields(self) -> tuple[TargetField, ...]: - - catalogs = ConfigOptions.configured() - return tuple([await item.prepare(catalogs, self) for item in self.editable_fields]) + def retained_payload(self) -> dict[str, object]: + return {entry.name: entry.current(self) + for entry in FieldCodec.argument_fields(type(self)) + if entry.setting(TargetBound, False)} def describe(self, comms: Comms, target: CommandTarget, project: Checkout) -> TargetAction: @@ -235,23 +367,6 @@ def with_confirmation(self, confirmed: bool) -> Self: raise ValueError(self.confirmation()) return self - def edited(self, arguments: Mapping[str, object]) -> Self: - bound = { - entry.key for entry in FieldCodec.argument_fields(type(self)) - if entry.setting(TargetBound, False) - } - if bound & arguments.keys(): - raise ValueError("Target-bound parameters cannot be overridden") - editable = {item.name for item in self.editable_fields} - if arguments.keys() - editable: - raise ValueError("Parameters not offered by this target command cannot be overridden") - encoded = FieldCodec.encode(self) - return type(self).from_payload({ - **{key: encoded[key] for key in bound & encoded.keys()}, - type(self).family_discriminator: FieldCodec.encode(type(self)), - **arguments, - }) - @classmethod def execute_target(cls, comms: Comms, target: TargetSelection, arguments: Mapping[str, object], *, confirmed: bool = False, @@ -271,7 +386,7 @@ def execute_target(cls, comms: Comms, target: TargetSelection, arguments: Mappin @dataclass(frozen=True) class TargetAction: """A selection keeps the bound commands, not a separate permission list.""" - bound: tuple[CliCommand, ...] + bound: tuple[CommandBinding, ...] targets: tuple[str, ...] declaration: type[CliCommand] channels: ChannelSelection = NoChannel() @@ -280,8 +395,10 @@ class TargetAction: def editable_fields(self) -> tuple[TargetField, ...]: return self.bound[0].editable_fields - async def prepare_fields(self) -> tuple[TargetField, ...]: - return await self.bound[0].prepare_fields() + async def prepare_fields( + self, arguments: Mapping[str, object] = MappingProxyType({}), + ) -> tuple[TargetField, ...]: + return await self.bound[0].prepare_fields(arguments) @property def label(self) -> str: @@ -289,14 +406,14 @@ def label(self) -> str: @property def confirmation(self) -> str: - groups: dict[type[CliCommand], list[CliCommand]] = {} + groups: dict[type[CommandBinding], list[CommandBinding]] = {} for command in self.bound: groups.setdefault(type(command), []).append(command) return '\n'.join(filter(None, (declaration.selection_confirmation(tuple(commands)) for declaration, commands in groups.items()))) def edited(self, arguments: Mapping[str, object]) -> TargetAction: - return replace(self, bound=tuple(command.edited(type(command).editor_arguments(arguments)) + return replace(self, bound=tuple(command.edited(command.declaration.editor_arguments(arguments)) for command in self.bound)) def with_confirmation(self, confirmed: bool) -> TargetAction: @@ -480,7 +597,7 @@ def run(self, declaration: type[CliCommand], comms: Comms, arguments: Mapping[st if member.catalog_declaration() is declaration) if declaration.catalog_declaration() is declaration else (declaration,)) outcomes: list[tuple[int, TargetCompleted | TargetFailed]] = [] - planned: dict[CliCommand, tuple[int, CommandTarget, SurroundingChannel]] = {} + planned: dict[CommandBinding, tuple[int, CommandTarget, SurroundingChannel]] = {} together: dict[type[AppliedTogether], list[tuple[int, CommandTarget, AppliedTogether]]] = {} for name in self.targets: try: @@ -511,7 +628,7 @@ def run(self, declaration: type[CliCommand], comms: Comms, arguments: Mapping[st current = comms.registry.snapshot() current_catalog = (comms.channels.catalog.read() if context.needs_catalog or name.needs_channel_state else NoCatalog()) - if bound_command not in type(bound_command).bindings( + if bound_command not in bound_command.declaration.bindings( comms, name, context, project=project, snapshot=current, catalog=current_catalog, resolved=name.resolve(current, comms.bus.require_channel)): raise CommandSelectionChanged( diff --git a/src/agent_comms/cli_commands.py b/src/agent_comms/cli_commands.py index 50654d9bd0..1241a675de 100644 --- a/src/agent_comms/cli_commands.py +++ b/src/agent_comms/cli_commands.py @@ -29,7 +29,7 @@ from .cli_result import CliResult from .comms import Comms from .declared_family import DeclaredFamily -from .field_codec import Argument, PathText, WireForm, WireOmitDefault, boundary_decoder +from .field_codec import Argument, PathText, WireForm, WireOmitDefault from .history_result import HistoryPageResult from .goals import Goal from .lifecycle_capability import Complete @@ -76,16 +76,6 @@ def _tags(value: str) -> list[str]: return [tag.strip() for tag in value.split(",") if tag.strip()] -@boundary_decoder -def _tag_choice_text(value: str | None) -> str | None: - """Command-line and editor text is the given tags' wire; no text inherits.""" - return value - - -def _tag_text(value: frozenset[str] | None) -> str | None: - return None if value is None else ', '.join(sorted(value)) - - def _shell_words(value: str) -> list[str]: return shlex.split(value) diff --git a/src/agent_comms/cli_support/named_thread.py b/src/agent_comms/cli_support/named_thread.py index badadaaecd..be9f7808d8 100644 --- a/src/agent_comms/cli_support/named_thread.py +++ b/src/agent_comms/cli_support/named_thread.py @@ -1,6 +1,7 @@ from __future__ import annotations from agent_comms.catalog_document import CatalogAccess +from agent_comms.cli_command import CommandBinding from agent_comms.channel_targets import OneChannel, ResolvedChannel from agent_comms.channel_targets import ResolvedThread from agent_comms.cli_support.named import NamedCliCommand @@ -24,7 +25,7 @@ def channel_members(cls, resolved: ResolvedChannel) -> tuple[ResolvedThread, ... return () @classmethod - def channel_bindings(cls, comms: Comms, resolved: ResolvedChannel, *, project: Checkout, snapshot: RegistrySnapshot, catalog: CatalogAccess) -> tuple[Self, ...]: + def channel_bindings(cls, comms: Comms, resolved: ResolvedChannel, *, project: Checkout, snapshot: RegistrySnapshot, catalog: CatalogAccess) -> tuple[CommandBinding, ...]: return tuple(bound for member in cls.channel_members(resolved) for bound in cls.thread_bindings( comms, member.thread, member.status, OneChannel(resolved.channel.name), catalog=catalog)) diff --git a/src/agent_comms/cli_support/thread_bound.py b/src/agent_comms/cli_support/thread_bound.py index cac53354a5..6b6e2d8129 100644 --- a/src/agent_comms/cli_support/thread_bound.py +++ b/src/agent_comms/cli_support/thread_bound.py @@ -4,13 +4,12 @@ from abc import abstractmethod from agent_comms.catalog_document import CatalogAccess -from agent_comms.cli_command import CliCommand +from agent_comms.cli_command import CliCommand, CommandBinding from agent_comms.comms import Comms from agent_comms.thread_execution import ThreadCommandAvailability from agent_comms.thread_status import ThreadStatus from agent_comms.threads import Thread from dataclasses import dataclass -from typing import Self @dataclass(frozen=True, kw_only=True) @@ -18,9 +17,9 @@ class ThreadBoundCliCommand(ThreadCommandAvailability, CliCommand): @classmethod def thread_bindings(cls, comms: Comms, thread: Thread, status: ThreadStatus, channel: SurroundingChannel, *, - catalog: CatalogAccess) -> tuple[Self, ...]: + catalog: CatalogAccess) -> tuple[CommandBinding, ...]: return (cls.thread_binding(thread),) if cls.available_for_thread(thread, status) else () @classmethod @abstractmethod - def thread_binding(cls, thread: Thread) -> Self: ... + def thread_binding(cls, thread: Thread) -> CommandBinding: ... diff --git a/src/agent_comms/cli_support/thread_creation.py b/src/agent_comms/cli_support/thread_creation.py new file mode 100644 index 0000000000..8f8f1aeae4 --- /dev/null +++ b/src/agent_comms/cli_support/thread_creation.py @@ -0,0 +1,61 @@ +"""The launch settings and field-derived construction shared by new and fork.""" +from __future__ import annotations + +import asyncio +from abc import abstractmethod +from dataclasses import dataclass, fields +from pathlib import Path +from typing import Annotated + +from agent_comms.cli_command import CatalogBinding, CliCommand, CommandContext +from agent_comms.cli_commands import NameArgument, ThreadCreatedResult +from agent_comms.comms import Comms +from agent_comms.config_options import ConfigOptions, ModelConfigOption, ThinkingLevelConfigOption +from agent_comms.field_codec import Argument, Catalog, Multiline +from agent_comms.native_settings import NativeThreadDefaults +from agent_comms.pi_vocabulary import ThinkingLevelField +from agent_comms.repo import Checkout, LaunchCheckout +from agent_comms.thread_management import ThreadCreationSpec +from agent_comms.thread_model import CatalogSelection, ThreadModel + + +@dataclass(frozen=True, kw_only=True) +class ThreadLaunchSettings(CommandContext): + """Acquired launch settings; the creator has not supplied a child name.""" + task: Annotated[str, Multiline(), Argument(flags=("--task",))] = "" + project: Annotated[Checkout, Argument(flags=("--project",))] + model: Annotated[ + ThreadModel, Catalog(ModelConfigOption), Argument(flags=("--model",)), + ] = NativeThreadDefaults.model + thinking_level: Annotated[ + ThinkingLevelField, Catalog(ThinkingLevelConfigOption), Argument(flags=("--thinking-level",)), + ] = NativeThreadDefaults.thinking_level + + @property + def project_path(self) -> Path: + return self.project.path + + +@dataclass(frozen=True, kw_only=True) +class ThreadCreationCliCommand( + NameArgument, ThreadLaunchSettings, CatalogBinding, CliCommand[ThreadCreatedResult], declared_name=None, +): + @property + @abstractmethod + def spec_type(self) -> type[ThreadCreationSpec]: ... + + @property + def catalog_context(self) -> CatalogSelection: + return self + + def specification(self) -> ThreadCreationSpec: + return self.spec_type.from_task(**{ + **{item.name: getattr(self, item.name) for item in fields(self.spec_type)}, + 'project': LaunchCheckout.checked(self.project.path), + }) + + def apply(self, ctx: Comms) -> ThreadCreatedResult: + spec = self.specification() + asyncio.run(ConfigOptions.configured().validate(self)) + child = ctx.threads.create(spec) + return ThreadCreatedResult(child.name, child.process_identity) diff --git a/src/agent_comms/cli_verbs/fork.py b/src/agent_comms/cli_verbs/fork.py index 0bfbdaa9da..16f4991ab3 100644 --- a/src/agent_comms/cli_verbs/fork.py +++ b/src/agent_comms/cli_verbs/fork.py @@ -1,34 +1,32 @@ from __future__ import annotations -from agent_comms.channel_targets import DeclaredTag -from agent_comms.cli_verbs.new import NewThreadCliCommand +from agent_comms.cli_command import PendingCommandBinding +from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand, ThreadLaunchSettings from agent_comms.cli_support.thread_bound import ThreadBoundCliCommand -from agent_comms.comms import Comms from agent_comms.field_codec import Argument from agent_comms.field_codec import TargetBound from agent_comms.thread_execution import NativeOwnerCommand from agent_comms.thread_management import ForkSpec -from agent_comms.thread_management import GivenTags +from agent_comms.thread_management import InheritedTags, TagChoice from agent_comms.threads import Thread -from agent_comms.repo import Checkout from dataclasses import dataclass from typing import Annotated -from typing import Self @dataclass(frozen=True, kw_only=True) -class ForkCliCommand(NativeOwnerCommand, ThreadBoundCliCommand, NewThreadCliCommand): +class ForkContext(ThreadLaunchSettings): + parent: Annotated[str, TargetBound(), Argument(flags=("--parent",))] + + +@dataclass(frozen=True, kw_only=True) +class ForkCliCommand(NativeOwnerCommand, ThreadBoundCliCommand, ForkContext, ThreadCreationCliCommand): help = "Fork a child pi thread" spec_type = ForkSpec - parent: Annotated[ - str, TargetBound(), Argument(flags=("--parent",)) - ] - @classmethod - def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[Self, ...]: - return () + tags: Annotated[TagChoice, Argument()] = InheritedTags() @classmethod - def thread_binding(cls, thread: Thread) -> Self: - return cls(name='', parent=thread.name, tags=GivenTags(DeclaredTag.for_child(thread.tags)), project=thread.worktree, - model=thread.model, thinking_level=thread.thinking_level) + def thread_binding(cls, thread: Thread) -> PendingCommandBinding: + return PendingCommandBinding(ForkContext( + parent=thread.name, project=thread.worktree, + model=thread.model, thinking_level=thread.thinking_level)) diff --git a/src/agent_comms/cli_verbs/new.py b/src/agent_comms/cli_verbs/new.py index d24a4b42a9..2661ab09fe 100644 --- a/src/agent_comms/cli_verbs/new.py +++ b/src/agent_comms/cli_verbs/new.py @@ -1,76 +1,31 @@ from __future__ import annotations -from agent_comms.cli_command import CliCommand -from agent_comms.cli_commands import NameArgument -from agent_comms.cli_commands import ThreadCreatedResult -from agent_comms.cli_commands import _tag_choice_text +from agent_comms.cli_command import PendingCommandBinding +from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand, ThreadLaunchSettings from agent_comms.comms import Comms -from agent_comms.config_options import ConfigOptions -from agent_comms.config_options import ModelConfigOption -from agent_comms.config_options import ThinkingLevelConfigOption from agent_comms.field_codec import Argument -from agent_comms.field_codec import Catalog -from agent_comms.field_codec import EditorFormat -from agent_comms.field_codec import FieldCodec -from agent_comms.field_codec import Multiline -from agent_comms.field_codec import Normalize from agent_comms.native_settings import NativeThreadDefaults -from agent_comms.repo import Checkout, LaunchCheckout -from agent_comms.pi_vocabulary import ThinkingLevelField -from agent_comms.thread_management import InheritedTags +from agent_comms.repo import Checkout +from agent_comms.thread_management import GivenTags from agent_comms.thread_management import NewThreadSpec -from agent_comms.thread_management import TagChoice -from agent_comms.thread_model import CatalogSelection, ThreadModel -from pathlib import Path from dataclasses import dataclass -from dataclasses import fields -from typing import Annotated -from typing import ClassVar -from typing import Self -import asyncio +from typing import Annotated, ClassVar @dataclass(frozen=True, kw_only=True) -class NewThreadCliCommand(NameArgument, CatalogSelection, CliCommand, declared_name="new"): +class NewThreadContext(ThreadLaunchSettings): + """The launch settings a project offers before the creator names the child.""" + + +@dataclass(frozen=True, kw_only=True) +class NewThreadCliCommand(NewThreadContext, ThreadCreationCliCommand, declared_name="new"): help = "Create a new pi thread" spec_type: ClassVar[type[NewThreadSpec]] = NewThreadSpec - task: Annotated[ - str, Multiline(), Argument(flags=("--task",)) - ] = "" - tags: Annotated[ - TagChoice, - EditorFormat(FieldCodec.encode), - Normalize(_tag_choice_text), - Argument(flags=("--tags",)), - ] = InheritedTags() - project: Annotated[ - Checkout, - Argument(flags=("--project",)), - ] - model: Annotated[ - ThreadModel, Catalog(ModelConfigOption), Argument(flags=("--model",)), - ] = NativeThreadDefaults.model - thinking_level: Annotated[ - ThinkingLevelField, - Catalog(ThinkingLevelConfigOption), - Argument(flags=("--thinking-level",)), - ] = NativeThreadDefaults.thinking_level + tags: Annotated[GivenTags, Argument()] @classmethod - def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[Self, ...]: + def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[PendingCommandBinding, ...]: defaults = NativeThreadDefaults() - return (cls(name='', project=project, model=defaults.model, - thinking_level=defaults.thinking_level),) - - @property - def project_path(self) -> Path: - return self.project.path - - def apply(self, ctx: Comms) -> ThreadCreatedResult: - spec = self.spec_type.from_task(**{ - **{item.name: getattr(self, item.name) for item in fields(self.spec_type)}, - 'project': LaunchCheckout.checked(self.project.path), - }) - asyncio.run(ConfigOptions.configured().validate(self)) - child = ctx.threads.create(spec) - return ThreadCreatedResult(child.name, child.process_identity) + return (PendingCommandBinding(NewThreadContext( + project=project, model=defaults.model, + thinking_level=defaults.thinking_level)),) diff --git a/src/agent_comms/cli_verbs/thread_tags.py b/src/agent_comms/cli_verbs/thread_tags.py index e7eff77318..0f031b7a9f 100644 --- a/src/agent_comms/cli_verbs/thread_tags.py +++ b/src/agent_comms/cli_verbs/thread_tags.py @@ -4,13 +4,10 @@ from agent_comms.catalog_document import CatalogAccess from agent_comms.cli_commands import ThreadTagsResult -from agent_comms.cli_commands import _tag_text -from agent_comms.cli_commands import _tags from agent_comms.cli_support.named import NamedCliCommand from agent_comms.comms import Comms from agent_comms.field_codec import Argument -from agent_comms.field_codec import EditorFormat -from agent_comms.field_codec import Normalize +from agent_comms.launch_runtime import TagListText from agent_comms.thread_status import ThreadStatus from agent_comms.threads import Thread from dataclasses import dataclass @@ -23,8 +20,7 @@ class ThreadTagsCliCommand(NamedCliCommand, declared_name="thread-tags"): help = "Edit thread tags" tags: Annotated[ frozenset[str], - Normalize(_tags), - EditorFormat(_tag_text), + TagListText, Argument( flags=("--tags",), options={"help": "Complete tags, separated by commas"} ), diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 237e159301..8e7b426acc 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -79,7 +79,7 @@ from agent_comms.presentation import AllOutcomes, MessageNotification from agent_comms.recorded_time import RecordedEventTime from agent_comms.registry_document import RegisteredCommanderProject -from agent_comms.repo import Repo +from agent_comms.repo import Checkout, Repo from agent_comms.repository_name import RepositoryName from agent_comms.slop import Slop from agent_comms.standing_rules import ( @@ -389,7 +389,8 @@ def order_file(self, path: str) -> OrderFile: def plan_reading(self, todos: tuple[Todo, ...]) -> PlanReading: repository = Repo.declaring(self.repos, PlanPhase.__module__) return NominalPlanReadings().prepare_reading(repository, todos, tuple( - phase.phase_evidence() for phase in PlanPhase.members_with(PlanPhase))) + phase.phase_evidence() for phase in PlanPhase.members_with(PlanPhase)), + history=Checkout(repository.checkout).read()) @boundary_decoder def model_copy(self, *, update: Mapping[str, object] | None = None, diff --git a/src/agent_comms/commander/publication_rules.py b/src/agent_comms/commander/publication_rules.py index ee1357e367..c7a0eaeff1 100644 --- a/src/agent_comms/commander/publication_rules.py +++ b/src/agent_comms/commander/publication_rules.py @@ -9,13 +9,14 @@ from agent_comms.field_codec import OrderId, PathText from agent_comms.head_review import PullRequestHead from agent_comms.heartbeat_subjects import HeartbeatSubject, UnlandedHeadSubject +from agent_comms.repo import UnpushedHead from agent_comms.repository_name import RepositoryName from agent_comms.standing_rules import ( DatedStandingRule, HeadLine, HeldBranch, HeldBranchRule, UndatedStandingRule, ) from agent_comms.thread_identity import ThreadIncarnation from agent_comms.todos import PullRequestLink -from agent_comms.work_branches import CommitBase, UnpushedHead, WorkBranches +from agent_comms.work_branches import CommitBase, WorkBranches if TYPE_CHECKING: from collections.abc import Mapping, Sequence diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 819b4caff9..238601be00 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -62,7 +62,7 @@ RestoreHolderTodo, Todo, ) -from agent_comms.turn_digests import OrderEditCountRead, TurnDigestRead, TurnsSince +from agent_comms.turn_digests import TurnDigestRead, TurnsSince from agent_comms.ui_model.unit_state import AwaitingCommander, UnitState from agent_comms.work_dispatch import ReusedWorkDispatch, WorkDispatch from agent_comms_hooks.watch import DeafWatch @@ -787,13 +787,12 @@ class Unjudged(ReasonedStandingRule, UndatedStandingRule): " reviewed head (one verdict judges every edit before it); fork judges for the rest") @classmethod - def reason(cls, comms: Comms, commander: str) -> str | None: + def reason(cls, comms: Comms, commander: str, frame: HeartbeatFrame) -> str | None: """The total, then each order's edits made since its last head verdict.""" - counts = tuple((read, read.read()) - for read in OrderEditCountRead.for_commander(comms, commander)) - total = sum(count for _, count in counts) + counts = frame.edit_counts(comms) + total = sum(counts.values()) if total <= cls.limit: return None - named = ", ".join(f"{read.thread.name} {read.todo.id} {count}" - for read, count in counts if count) + named = ", ".join(f"{hold.holder.name} {hold.todo.id} {count}" + for hold in frame.live for count in (counts[hold.todo.id],) if count) return f"{total} edits since each order's last head verdict: {named}" diff --git a/src/agent_comms/commander/vibe_check.py b/src/agent_comms/commander/vibe_check.py index 69a251aa6a..7444b670d4 100644 --- a/src/agent_comms/commander/vibe_check.py +++ b/src/agent_comms/commander/vibe_check.py @@ -46,7 +46,6 @@ from agent_comms.todos import OwnerRequest, Todo from agent_comms.tool_result import ToolResult from agent_comms.ui_model.unit_state import UnitState -from agent_comms.turn_digests import OrderEditCountRead class Escalation: @@ -86,9 +85,7 @@ def from_frame(cls, comms: Comms, commander: str, frame: HeartbeatFrame) -> Self return cls.from_orders( frame.todos, len(tuple(ReviewTimerUp.conditions(comms, frame))), - sum(read.read() for read in OrderEditCountRead.judgeable( - comms.root, frame.live, frame.pulls.progress(frame.todos, comms.registry), - frame.workplaces)), + sum(frame.edit_counts(comms).values()), ) @classmethod diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index 9d3301c85f..68c5aabd95 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -38,7 +38,7 @@ from agent_comms.thread_identity import ThreadIncarnation from agent_comms.todos import OrderProgress, ProgressPoint, ProgressRead, ProgressSource, PullRequest from agent_comms.tool_result import ToolResult -from agent_comms.turn_digests import LastTurns, OrderEditCountRead +from agent_comms.turn_digests import LastTurns from agent_comms.ui_model.unit_state import ExpandedHudGroup, HudGroup, UnitClock, UnitState, UrgentGroup from agent_comms.task_attachments.commander_pings import ( AlarmGroup, AlarmSection, CommanderDelivery, CommanderUpdate, Ping, @@ -329,11 +329,7 @@ def of(cls, comms: Comms, commander: str, frame: HeartbeatFrame, progress = pulls.progress(held, comms.registry) timers = frame.review_timers installed = InstalledBuild.read() - edits = dict.fromkeys((todo.id for todo in held), 0) | { - read.todo.id: read.read() - for read in OrderEditCountRead.judgeable( - comms.root, frame.live, progress, frame.workplaces) - } + edits = frame.edit_counts(comms) units = [] for state in states: unit, orders = state.unit, state.source.todos 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/exporting.py b/src/agent_comms/exporting.py index e63f80a52b..9f3a1645d6 100644 --- a/src/agent_comms/exporting.py +++ b/src/agent_comms/exporting.py @@ -5,10 +5,13 @@ import hashlib import math import os +import sqlite3 from abc import abstractmethod from collections import deque -from collections.abc import Callable, Iterable, Mapping -from dataclasses import asdict, dataclass +from collections.abc import Callable, Iterable, Iterator, Mapping +from dataclasses import asdict, dataclass, replace +from contextlib import contextmanager +from threading import Event from datetime import UTC, datetime from pathlib import Path from typing import BinaryIO, ClassVar @@ -18,7 +21,9 @@ from .declared_family import DeclaredFamily from .field_codec import FieldCodec, NullMember, SingleFieldValue, SubclassUnion from .message_reference import MessageReference -from .messages import Message +from .messages import Message, StoredWireHistory, StoredWireMessage +from .bus_tables import MessageIdentitySource +from .read_basis import DMDisplayScope from .registration import Registration from .store_files import AtomicFile, ExclusiveAtomicFile @@ -43,6 +48,18 @@ def row(self, message: Message) -> bytes: def json_record(record: Mapping[str, object]) -> str: return FieldCodec.compact(record, ensure_ascii=False) + def stored_rows( + self, source: StoredWireHistory, limit: WireExportLimit, + header_size: int, started_at: float, stats: ExportSelectionStats, + ) -> Iterable[WireExportRow]: + """Render each selected row from its capability tables, decoded once.""" + def rendered() -> Iterator[WireExportRow]: + for message in source.messages(): + stats.source_messages += 1 + yield WireExportRow(message.seq, message.timestamp, self.row(message)) + + return limit.select(rendered(), header_size, started_at, stats) + class SelectableWireExportFormat(WireExportFormat): """Formats whose complete input is the ordinary wire export selection.""" @@ -51,14 +68,27 @@ class SelectableWireExportFormat(WireExportFormat): @dataclass(frozen=True) class JsonlFormat(SelectableWireExportFormat): importable: ClassVar[bool] = True + row_prefix: ClassVar[bytes] = b'{"record":"message","message":' + row_suffix: ClassVar[bytes] = b'}\n' def header(self, metadata: Mapping[str, object]) -> bytes: return (self.json_record(metadata) + "\n").encode() def row(self, message: Message) -> bytes: - return (self.json_record({ - "record": "message", "message": Message.from_message(message).to_wire(), - }) + "\n").encode() + return self.row_prefix + self.json_record(Message.from_message(message).to_wire()).encode() + self.row_suffix + + def stored_rows( + self, source: StoredWireHistory, limit: WireExportLimit, + header_size: int, started_at: float, stats: ExportSelectionStats, + ) -> Iterable[WireExportRow]: + """The stored wire text is this format's row, so the limit selects it in SQL.""" + return limit.stored_rows(source, self, header_size, started_at, stats) + + def stored_row(self, message: StoredWireMessage) -> bytes: + return self.row_prefix + message.wire.encode() + self.row_suffix + + def stored_size(self, source: StoredWireHistory) -> str: + return f"length(CAST(wire AS BLOB)) + {len(self.row_prefix) + len(self.row_suffix)}" @dataclass(frozen=True) @@ -93,10 +123,11 @@ def row(self, message: Message) -> bytes: @dataclass(frozen=True) class ResolvedExportScope: - """One canonical scope and its alias/catalog-resolved predicate snapshot.""" + """One canonical scope and its required alias/catalog-resolved SQL predicate.""" scope: WireExportScope - matches: Callable[[Message], bool] + predicate: str + parameters: tuple[object, ...] class WireExportScope(DeclaredFamily, affix="Scope"): @@ -113,7 +144,7 @@ def resolve(self, require_channel: Callable[[str], Channel], registry: Registrat @dataclass(frozen=True, slots=True) class EverythingScope(WireExportScope): def resolve(self, require_channel: Callable[[str], Channel], registry: Registration) -> ResolvedExportScope: - return ResolvedExportScope(self, lambda message: True) + return ResolvedExportScope(self, "1", ()) @dataclass(frozen=True, slots=True) @@ -123,7 +154,8 @@ class AuthoredSourceScope(WireExportScope): sources: tuple[MessageReference, ...] def resolve(self, require_channel: Callable[[str], Channel], registry: Registration) -> ResolvedExportScope: - return ResolvedExportScope(self, lambda message: message.reference in self.sources) + predicate, parameters = MessageIdentitySource.reference_predicate(self.sources) + return ResolvedExportScope(self, predicate, parameters) @dataclass(frozen=True, slots=True) @@ -133,7 +165,9 @@ class ChannelScope(WireExportScope): def resolve(self, require_channel: Callable[[str], Channel], registry: Registration) -> ResolvedExportScope: channel = require_channel(self.channel) channel.require_routable() - return ResolvedExportScope(self, channel.history_scope().includes) + history = channel.history_scope() + predicate, parameters = history.index_predicate + return ResolvedExportScope(self, predicate, parameters) @dataclass(frozen=True, slots=True) @@ -150,17 +184,14 @@ def __post_init__(self) -> None: raise ValueError("A DM wire-export scope requires two distinct participants.") def resolve(self, require_channel: Callable[[str], Channel], registry: Registration) -> ResolvedExportScope: - first = registry.entry(self.participants[0]).entry - second = registry.entry(self.participants[1]).entry + snapshot = registry.snapshot() + first = snapshot.entry(self.participants[0]) + second = snapshot.entry(self.participants[1]) canonical = DmScope((first.thread.name, second.thread.name)) first_names, second_names = first.names, second.names - def matches(message: Message) -> bool: - return (message.sender in first_names and message.target in second_names) or ( - message.sender in second_names and message.target in first_names - ) - - return ResolvedExportScope(canonical, matches) + predicate, parameters = DMDisplayScope(first_names, second_names).index_predicate + return ResolvedExportScope(canonical, predicate, parameters) class ScopeFlag(SubclassUnion, affix="ScopeFlag"): @@ -214,6 +245,14 @@ def byte_ceiling(self) -> int | None: def time_cutoff(self) -> float | None: return None + def stored_rows( + self, source: StoredWireHistory, format: JsonlFormat, + header_size: int, started_at: float, stats: ExportSelectionStats, + ) -> Iterable[WireExportRow]: + stats.source_messages = source.count() + return self.select((WireExportRow(message.seq, message.timestamp, format.stored_row(message)) + for message in source.rows()), header_size, started_at, stats) + def validate_header(self, size: int) -> None: ceiling = self.byte_ceiling() if ceiling is not None and size > ceiling: @@ -231,7 +270,7 @@ def select( def filtered() -> Iterable[WireExportRow]: for row in rows: if cutoff is not None: - timestamp = row.message.timestamp + timestamp = row.timestamp if not math.isfinite(timestamp) or timestamp <= 0: stats.invalid_time += 1 continue @@ -274,6 +313,18 @@ def __post_init__(self) -> None: def byte_ceiling(self) -> int: return self.value + def stored_rows( + self, source: StoredWireHistory, format: JsonlFormat, + header_size: int, started_at: float, stats: ExportSelectionStats, + ) -> Iterable[WireExportRow]: + stats.source_messages = source.count() + capacity = self.value - header_size + size = format.stored_size(source) + first = source.newest_suffix(size, capacity) + for message in source.rows(after=first): + yield WireExportRow(message.seq, message.timestamp, format.stored_row(message)) + stats.oversized = source.count_where(f"{size} > ?", (capacity,)) + def retain( self, rows: Iterable[WireExportRow], @@ -380,10 +431,10 @@ class WireExportProvenance(CliResult): output_sha256: str truncated: bool redacted_messages: int - first_sequence: int | None - last_sequence: int | None - first_timestamp: float | None - last_timestamp: float | None + first_sequence: int | None = None + last_sequence: int | None = None + first_timestamp: float | None = None + last_timestamp: float | None = None def to_wire(self) -> dict[str, object]: return { @@ -395,25 +446,111 @@ def to_wire(self) -> dict[str, object]: } +class WireExportSelection: + __slots__ = () + + def select_first(self, row: WireExportRow) -> WireExportSelection: + return self + + def describe_first(self, metadata: WireExportProvenance) -> WireExportProvenance: + return metadata + + def describe_last(self, metadata: WireExportProvenance) -> WireExportProvenance: + return metadata + + +class NoSelection(WireExportSelection): + __slots__ = () + + def select_first(self, row: WireExportRow) -> WireExportRow: + return row + + @dataclass(frozen=True, slots=True) -class WireExportRow: - message: Message +class WireExportRow(WireExportSelection): + seq: int + timestamp: float data: bytes + def describe_first(self, metadata: WireExportProvenance) -> WireExportProvenance: + return replace(metadata, first_sequence=self.seq, first_timestamp=self.timestamp) + + def describe_last(self, metadata: WireExportProvenance) -> WireExportProvenance: + return replace(metadata, last_sequence=self.seq, last_timestamp=self.timestamp) + + +@dataclass(frozen=True) +class WireExportProgress: + bytes_written: int + messages_written: int + + +class WireExportCancelled(InterruptedError): + """The owner cancelled this export; staging must unwind without publication.""" + + +class WireExportObserver: + """Stateless default for CLI and retained-context exports.""" + + def advanced(self, progress: WireExportProgress) -> None: + pass + + def check(self) -> None: + pass + + @contextmanager + def reading(self, source: StoredWireHistory) -> Iterator[None]: + yield + + +class CancellableWireExport(WireExportObserver): + def __init__(self) -> None: + self.stop_requested = Event() + super().__init__() + + def cancel(self) -> None: + self.stop_requested.set() + + def cancelled(self) -> bool: + return self.stop_requested.is_set() + + def check(self) -> None: + if self.cancelled(): + raise WireExportCancelled("Wire export cancelled") + + @contextmanager + def reading(self, source: StoredWireHistory) -> Iterator[None]: + self.check() + source.db.set_progress_handler(self.cancelled, 1000) + try: + yield + except sqlite3.OperationalError: + self.check() + raise + finally: + source.db.set_progress_handler(None, 0) + class _ArtifactWriter: """Hashing byte writer over one private temporary file.""" - def __init__(self, output: BinaryIO) -> None: + def __init__(self, output: BinaryIO, observer: WireExportObserver) -> None: self.output = output + self.observer = observer self.digest = hashlib.sha256() self.size = 0 + self.messages = 0 def write(self, data: bytes) -> None: + self.observer.check() self.output.write(data) self.digest.update(data) self.size += len(data) + def report(self) -> None: + self.output.flush() + self.observer.advanced(WireExportProgress(self.size, self.messages)) + @dataclass(eq=False, repr=False, kw_only=True) class WireTranscriptExporter: @@ -433,8 +570,28 @@ def export( destination: Path, *, overwrite: bool = False, + observer: WireExportObserver = WireExportObserver(), + ) -> WireExportProvenance: + """Export authored in-memory context through the same atomic stream writer.""" + return self._export(destination, lambda stats, header: self.limit.select( + self._rows(messages, stats), header, self.boundary.export_started_at, stats), + overwrite=overwrite, observer=observer) + + def export_stored( + self, source: StoredWireHistory, destination: Path, *, overwrite: bool = False, + observer: WireExportObserver = WireExportObserver(), + ) -> WireExportProvenance: + """Select the stored suffix, then write ascending rows without Message construction.""" + with observer.reading(source): + return self._export(destination, lambda stats, header: self.format.stored_rows( + source, self.limit, header, self.boundary.export_started_at, stats), + overwrite=overwrite, observer=observer) + + def _export( + self, destination: Path, + rows: Callable[[ExportSelectionStats, int], Iterable[WireExportRow]], + *, overwrite: bool, observer: WireExportObserver, ) -> WireExportProvenance: - """Consume ascending messages once and atomically publish the artifact.""" header = self._header() self.limit.validate_header(len(header)) @@ -445,26 +602,29 @@ def export( artifact = AtomicFile(destination) if overwrite else ExclusiveAtomicFile(destination) stats = ExportSelectionStats() - exported = 0 - first_selected: Message | None = None - last_selected: Message | None = None + first_selected: WireExportSelection = NoSelection() + last_selected: WireExportSelection = NoSelection() with artifact.staged() as output: - writer = _ArtifactWriter(output) + writer = _ArtifactWriter(output, observer) writer.write(header) - rows = self._rows(messages, stats) - for row in self.limit.select( - rows, len(header), self.boundary.export_started_at, stats - ): + writer.report() + reported = 0 + for row in rows(stats, len(header)): writer.write(row.data) - exported += 1 - if first_selected is None: - first_selected = row.message - last_selected = row.message + writer.messages += 1 + if writer.size - reported >= 65536: + writer.report() + reported = writer.size + first_selected = first_selected.select_first(row) + last_selected = row + writer.report() + observer.check() bytes_written = writer.size output_sha256 = writer.digest.hexdigest() + exported = writer.messages omitted = stats.source_messages - exported - return WireExportProvenance( + metadata = WireExportProvenance( destination=str(destination), format=self.format, scope=self.scope, @@ -480,11 +640,8 @@ def export( output_sha256=output_sha256, truncated=omitted > 0, redacted_messages=0, - first_sequence=first_selected.seq if first_selected else None, - last_sequence=last_selected.seq if last_selected else None, - first_timestamp=first_selected.timestamp if first_selected else None, - last_timestamp=last_selected.timestamp if last_selected else None, ) + return last_selected.describe_last(first_selected.describe_first(metadata)) def _header(self) -> bytes: metadata = { @@ -511,7 +668,7 @@ def _rows( if message.seq > self.boundary.through_seq: return stats.source_messages += 1 - yield WireExportRow(message, self.format.row(message)) + yield WireExportRow(message.seq, message.timestamp, self.format.row(message)) @staticmethod def _validate_sequence(message: Message, previous: int) -> int: diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 012756c134..1e74306159 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -14,7 +14,7 @@ import types from abc import ABC, abstractmethod from collections.abc import Callable, Collection, Hashable, Iterable, Iterator, Mapping, Sequence -from dataclasses import MISSING, Field, dataclass, field, fields, is_dataclass +from dataclasses import MISSING, Field, dataclass, field, fields, is_dataclass, make_dataclass from datetime import datetime from enum import Enum, IntFlag from functools import cache, cached_property, lru_cache, partial, reduce @@ -394,10 +394,6 @@ class Normalize(ValueDirective[Callable[[object], object]]): pass -class EditorFormat(ValueDirective[Callable[[object], JsonValue]]): - pass - - class Editor(ValueDirective[type]): pass @@ -679,18 +675,15 @@ def argument_normalizer(self) -> Callable[[object], object]: def editor_normalizer(self) -> Callable[[object], object]: return self.setting(Normalize, self.value.decode_editor) - @cached_property - def editor_formatter(self) -> Callable[[object], JsonValue]: - return self.setting(EditorFormat, self.value.encode_editor) - def argument_payload(self, value: object) -> object: return self.argument_normalizer(value) def editor_payload(self, value: object) -> object: return self.editor_normalizer(value) - def editor_default(self, value: object) -> JsonValue: - return self.editor_formatter(value) + def require_constructor_input(self, values: Mapping[str, object]) -> None: + if self.constructor_required and self.name not in values: + raise MalformedRecord(f'Missing required field {self.key}') @property def description(self) -> str: @@ -709,8 +702,14 @@ def contribute(self, fields): def wire_keys(self) -> tuple[str, ...]: return (self.key,) - def encoded(self, value, *, key=None): - return {key or self.key: value} + def encoded(self, value: JsonValue, *, key: str) -> dict[str, JsonValue]: + return {key: value} + + def project_encoded[V]( + self, value: V, record: Callable[[Mapping[str, V]], V], + ) -> V: + """Project an already encoded field through its declared record shape.""" + return record({self.key: value}) def schema_fields(self): annotation, declared = self.annotation, self.declared @@ -840,9 +839,14 @@ 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) -> dict[str, JsonValue]: return {} + def project_encoded[V]( + self, value: V, record: Callable[[Mapping[str, V]], V], + ) -> V: + return record({}) + def decoded(self, data: object) -> dict[str, object]: raise MalformedRecord(f"Missing decode context: {self.name}") @@ -901,7 +905,14 @@ 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) -> dict[str, JsonValue]: + if not isinstance(value, dict): + raise MalformedRecord("An inline record must encode a JSON object.") + return value + + def project_encoded[V]( + self, value: V, record: Callable[[Mapping[str, V]], V], + ) -> V: return value def captured(self, data: object) -> dict[str, object]: @@ -1017,6 +1028,44 @@ class DecoderPlan(ABC): target: object + @property + def editor_annotation(self) -> object: + return str + + @property + def native_editor_annotation(self) -> object: + return self.editor_annotation + + @property + def editor_members(self) -> tuple[EditorMember, ...]: + return () + + def editor_member(self, declared: DecoderPlan) -> EditorMember: + return ScalarEditorMember(declared) + + def selected_editor_member(self, value: object) -> EditorMember: + return self.editor_member(self) + + def project_editor(self, native: object, wire: Callable[[], object]) -> object: + return self.format_editor(wire()) + + def editor_argument(self, value: object) -> object: + return value + + def accepts_value(self, value: object) -> bool: + """Select a native member at this decoder boundary.""" + return isinstance(value, self.target) + + @abstractmethod + def decode_bound(self, data: object, context: Mapping[str, object]) -> object: ... + + def format_editor(self, wire: object) -> str: + return json.dumps(wire) + + @boundary_decoder + def parse_editor(self, text: object) -> object: + return None if text is None else FieldCodec.parse(FieldCodec.decode(str, text)) + def sql_checks(self, column: str, quote: Callable[[object], str]) -> tuple[str, ...]: """Stored-value predicates; the SQL owner quotes its column and constants.""" return () @@ -1048,6 +1097,123 @@ def external_decoders(self, data: object) -> Iterable[ExternalDecoder]: return () +class MemberDraftFactory[Draft](ABC): + """An integration constructs drafts; selections own which construction applies.""" + + @abstractmethod + def unchosen(self, member: UnchosenMember) -> Draft: ... + + @abstractmethod + def member(self, member: EditorMember, value: object) -> Draft: ... + + +class MemberSelection(ABC): + def argument( + self, key: str, value: object, decode: Callable[[object], object], + ) -> Mapping[str, object]: + return {key: decode(value)} + + @property + @abstractmethod + def declaration(self) -> object: ... + + @property + @abstractmethod + def label(self) -> str: ... + + @abstractmethod + def draft[Draft](self, factory: MemberDraftFactory[Draft], value: object) -> Draft: ... + + +@dataclass(frozen=True) +class UnchosenMember(MemberSelection): + def argument( + self, key: str, value: object, decode: Callable[[object], object], + ) -> Mapping[str, object]: + return {} + + @property + def declaration(self) -> object: + return type(self) + + @property + def label(self) -> str: + return 'Choose a member…' + + def draft[Draft](self, factory: MemberDraftFactory[Draft], value: object) -> Draft: + return factory.unchosen(self) + + +@dataclass(frozen=True) +class EditorMember(MemberSelection): + """Read-only member metadata; only actual inputs enter a form's ObjectState.""" + + decoder: DecoderPlan + + @property + def declaration(self) -> object: + return self.decoder.target + + @property + def label(self) -> str: + return str(self.declaration) + + def draft[Draft](self, factory: MemberDraftFactory[Draft], value: object) -> Draft: + return factory.member(self, value) + + @property + @abstractmethod + def fields(self) -> tuple[FieldPlan, ...]: ... + + def current(self, entry: FieldPlan, value: object) -> object: + return value + + @abstractmethod + def wire_payload(self, arguments: Mapping[str, object]) -> object: ... + + def complete(self, arguments: Mapping[str, object]) -> object: + return self.decoder.decode_bound(self.wire_payload(arguments), {}) + + +class ScalarEditorMember(EditorMember): + @cached_property + def fields(self) -> tuple[FieldPlan, ...]: + declaration = make_dataclass('MemberInput', [('value', self.declaration)], + frozen=True, kw_only=True) + return FieldCodec.field_plans(declaration) + + def wire_payload(self, arguments: Mapping[str, object]) -> object: + entry, = self.fields + return entry.editor_payload(entry.raw(dict(arguments))) + + +@dataclass(frozen=True) +class RecordEditorMember(EditorMember): + plans: tuple[FieldPlan, ...] + + @property + def fields(self) -> tuple[FieldPlan, ...]: + return self.plans + + def current(self, entry: FieldPlan, value: object) -> object: + return entry.current(value) + + def wire_payload(self, arguments: Mapping[str, object]) -> object: + return {entry.key: entry.editor_payload(arguments[entry.key]) + for entry in self.fields if entry.key in arguments} + + +class NullEditorMember(EditorMember): + @property + def fields(self) -> tuple[FieldPlan, ...]: + return () + + def wire_payload(self, arguments: Mapping[str, object]) -> object: + if arguments: + raise MalformedRecord('A null member has no editor inputs') + return None + + @dataclass(frozen=True, slots=True) class ValueDecoder[T](DecoderPlan, JsonSchemaOwner): """One annotation owns its wire conversion, draft projection and choice domain.""" @@ -1113,10 +1279,9 @@ def representation_plan( @classmethod @cache def for_members[M](cls, members: tuple[type[M], ...]) -> UnionDecoder[M]: - return UnionDecoder( - reduce(or_, members), - tuple(compile_decoder(member) for member in members), - ) + target = reduce(or_, members) + alternatives = tuple(compile_decoder(member) for member in members) + return UnionDecoder(target, alternatives) @abstractmethod def decode(self, data: object) -> T: @@ -1156,10 +1321,6 @@ def decode_resolved(self, value: T) -> T: raise MalformedRecord(f"Resolved value does not match {self.target}") return value - def accepts_value(self, value: object) -> bool: - """Select an in-process union case using its compiled declaration.""" - return isinstance(value, self.target) - def accepts_external_value(self, value: object) -> bool: """A structural container match does not validate its contents.""" return False @@ -1183,15 +1344,18 @@ def decode_selected(self, data, selections): def decode_keys(self, data, keys, selections): raise TypeError("Selected keys require a mapping field") - editor_annotation = str + @property + def editor_wire(self) -> DecoderPlan: + return self + + def editor_initial(self, value: T) -> object: + return self.editor_wire.project_editor(value, lambda: self.encode(value)) def encode_editor(self, value: T) -> str: - encoded = FieldCodec.encode(value, self.target) - return json.dumps(encoded) + return self.editor_wire.format_editor(self.encode(value)) - @boundary_decoder - def decode_editor(self, value): - return None if value is None else json.loads(value) + def decode_editor(self, value: object) -> object: + return self.editor_wire.parse_editor(value) def editor_choices(self) -> tuple[tuple[str, object], ...]: raise TypeError("The declared decoder does not offer finite choices") @@ -1226,6 +1390,10 @@ def sql_checks(self, column: str, quote: Callable[[object], str]) -> tuple[str, def wire_structure(self) -> DecoderPlan: return self.representation.wire_structure(self.structural) + @property + def editor_wire(self) -> DecoderPlan: + return self.wire_structure + @property def accepts_null(self) -> bool: return self.wire_structure.accepts_null @@ -2546,7 +2714,7 @@ 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) @@ -2559,15 +2727,37 @@ 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 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, + 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, ...]: @@ -2708,7 +2898,7 @@ def _project_structure(cls, value: object, view: type[ViewExclusion], annotation @classmethod def _project_record(cls, value: object, view: type[ViewExclusion]) -> 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) )) @@ -2739,8 +2929,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( @@ -2915,6 +3108,12 @@ def fits_wire(self, data: object) -> bool: def accepts_value(self, value: object) -> bool: return self.accepts(value) + def parse_editor(self, text: object) -> object: + value = super().parse_editor(text) + if not self.accepts_value(value): + raise MalformedRecord(f'Expected native {self.target}, received {type(value).__name__}') + return value + def accepts_external_value(self, value: object) -> bool: return self.accepts(value) @@ -2939,6 +3138,9 @@ class _NullDecoder(_ExactScalarDecoder[None]): annotations = (type(None),) json_type = "null" + def editor_member(self, declared: DecoderPlan) -> EditorMember: + return NullEditorMember(declared) + def sql_checks(self, column: str, quote: Callable[[object], str]) -> tuple[str, ...]: return (f"{column} IS NULL",) @@ -2951,11 +3153,11 @@ class _TextDecoder(_ExactScalarDecoder[str]): def encode(self, value: str) -> str: return str(super().encode(value)) - def encode_editor(self, value: str) -> str: - return value + def format_editor(self, wire: object) -> str: + return self.decode(wire) - def decode_editor(self, value): - return value + def parse_editor(self, text: object) -> object: + return text class _FlagDecoder(_ExactScalarDecoder[bool]): @@ -3183,13 +3385,31 @@ def decode_external(self, data: object, record: Mapping[str, object]) -> T: def editor_annotation(self): return Union[tuple(item.editor_annotation for item in self.alternatives)] + @property + def native_editor_annotation(self) -> object: + return self.target + + @property + def editor_members(self) -> tuple[EditorMember, ...]: + return tuple(member.editor_wire.editor_member(member) for member in self.alternatives) + + def selected_editor_member(self, value: object) -> EditorMember: + member = self.selected(value) + return member.editor_wire.editor_member(member) + + def project_editor(self, native: object, wire: Callable[[], object]) -> object: + return native + + def editor_argument(self, value: object) -> object: + return self.format_editor(FieldCodec.encode(value, self.target)) + def argument_options(self, argument: Argument) -> dict[str, object]: return self.alternatives[0].argument_options(argument) def accepts_value(self, value: object) -> bool: return any(alternative.accepts_value(value) for alternative in self.alternatives) - def selected(self, value: T) -> ValueDecoder[T]: + def selected(self, value: object) -> ValueDecoder[T]: for alternative in self.alternatives: if alternative.accepts_value(value): return alternative @@ -3580,13 +3800,16 @@ class _RecordDecoder[T](_ContextDecoder[T]): def plan(self) -> FieldCodec._RecordPlan: return FieldCodec._record_plan(self.target) + def editor_member(self, declared: DecoderPlan) -> EditorMember: + return RecordEditorMember(declared, self.plan.bindings) + def fits_wire(self, data: object) -> TypeGuard[dict[str, object]]: if not isinstance(data, dict): return False 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( @@ -3645,23 +3868,34 @@ 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( @@ -3749,14 +3983,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/heartbeat_frame.py b/src/agent_comms/heartbeat_frame.py index c307e1c914..26cdffee5d 100644 --- a/src/agent_comms/heartbeat_frame.py +++ b/src/agent_comms/heartbeat_frame.py @@ -5,7 +5,8 @@ from collections.abc import Mapping import time from dataclasses import dataclass -from typing import TYPE_CHECKING, Self +from functools import cached_property +from typing import TYPE_CHECKING, ClassVar, Self from agent_comms.head_review import PullRequestHead from agent_comms.held_orders import HeldOrders @@ -15,6 +16,9 @@ from agent_comms.recorded_time import RecordedEventTime from agent_comms.standing_rules import OwedAnswer, StandingRule from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.threads import Thread +from agent_comms.transcript_events import TurnDigest +from agent_comms.turn_digests import LastTurns, OrderEditCountRead, RecentTurnsSince, TurnDigestRead from agent_comms.storage_rules import EditWorkplaces from agent_comms.todos import ( ClosedHeadReading, HeadReviewRecord, ProgressRead, StoredClosedHead, StoredHeadReview, @@ -41,13 +45,14 @@ def prepare(cls, comms: Comms, commander: str, pulls: ProgressRead, project = snapshot.commander_project.require() for repo in project.repository_names(): pulls.carry_reviews(comms.goals, repo) - StandingRule.prepare_all(comms, commander, holds, pulls) repository = Repo.declaring(project.repos, PlanPhase.__module__) plan = plans.prepare_reading( repository, comms.goals.todos.history_for_commander(snapshot.require(commander).incarnation), tuple(phase.phase_evidence() for phase in PlanPhase.members_with(PlanPhase)), + history=pulls.checkout(repository.checkout).branches.history, ) + StandingRule.prepare_all(comms, commander, holds, pulls) return cls(holds.live, holds.orphaned, pulls, plan) @@ -60,6 +65,40 @@ class HeartbeatFrame(CommanderInputs): head_reviews: Mapping[PullRequestHead, tuple[HeadReviewRecord, ...]] workplaces: Mapping[ThreadIncarnation, EditWorkplaces] review_timers: tuple[tuple[Todo, RecordedEventTime], ...] + recent: ClassVar[LastTurns] = LastTurns(2) + + @cached_property + def _turns(self) -> dict[ThreadIncarnation, tuple[TurnDigest, ...]]: + return {} + + @cached_property + def _edit_counts(self) -> dict[str, int]: + return {} + + def turns_for(self, comms: Comms, thread: Thread) -> tuple[TurnDigest, ...]: + """One original turn read covering this holder's review cuts and recent window.""" + identity = thread.incarnation + if identity not in self._turns: + since = min((comms.goals.todos.unjudged_since(todo) + for todo in self.holders.get(identity, ())), default=time.time()) + self._turns[identity] = TurnDigestRead( + comms.root, thread, RecentTurnsSince(since, recent=self.recent)).read().turns + return self._turns[identity] + + def recent_turns(self, comms: Comms, thread: Thread) -> tuple[TurnDigest, ...]: + return self.recent.take(self.turns_for(comms, thread)) + + def edit_counts(self, comms: Comms) -> Mapping[str, int]: + """The count projection shares the frame's workplaces, progress and turn digests.""" + for read in OrderEditCountRead.judgeable( + comms.root, self.live, self.pulls.progress(self.todos, comms.registry), + self.workplaces): + if read.todo.id not in self._edit_counts: + self._edit_counts[read.todo.id] = read.from_turns( + self.turns_for(comms, read.thread)) + for todo in self.todos: + self._edit_counts.setdefault(todo.id, 0) + return self._edit_counts @classmethod def capture(cls, comms: Comms, commander: str, inputs: CommanderInputs, diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index fc369c5358..e257ff8ead 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -76,12 +76,24 @@ 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: + history = captured.bus.history.captured() + yield partial(history.integrated_page, self, captured) + def page(self, presentation: BusPresentation, bounds: PageBounds = NEWEST_PAGE) -> MessagePage: - return presentation.bus.history.integrated_page(self, presentation, bounds) + with self.reading(presentation) as read: + return read(bounds) @abstractmethod def capture(self, snapshot: RegistryProvenance) -> MessageDisplayScope: @@ -238,19 +250,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) @@ -470,20 +479,37 @@ 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: 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(): @@ -536,15 +562,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, @@ -555,6 +580,12 @@ 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: # One traversal asks once whether live messages follow its archives. live_follows = cache(lambda: bool(view.live_page(presentation, PageBounds(limit=1)).messages)) @@ -576,11 +607,17 @@ def integrated_page( def _source_page( self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds, live_follows: Callable[[], bool], + ) -> MessagePage: + page = self._select_source_page(view, presentation, bounds, live_follows) + return replace(page, selection=replace( + page.selection, history_revision=self.history_revision, + )) + + def _select_source_page( + self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds, + live_follows: Callable[[], bool], ) -> 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()) @@ -588,27 +625,21 @@ def historical_page() -> MessagePage: if history.messages: return replace( history, - selection=replace(history.selection, history_revision=history_revision), has_newer=history.has_newer or live_follows(), ) 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, ) @@ -620,3 +651,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/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index 6a8a0e0b7a..535dbd54a3 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -47,6 +47,7 @@ WireExportFormat, WireExportLimit, WireExportProvenance, + WireExportObserver, WireExportScope, WireTranscriptExporter, ) @@ -246,26 +247,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: + def dm_display_view( + self, peer: ThreadTarget, *, worktree: Checkout, + ) -> DMDisplayHistory: viewer = self.messaging.user_identity(worktree) - return DMDisplayHistory(viewer.name, peer, worktree).page(self.presentation, bounds) + return DMDisplayHistory(viewer.name, peer, worktree) + + def channel_display_view( + self, target: str, *, viewer: DisplayViewer = AnonymousViewer(), + ) -> 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) @@ -303,19 +299,23 @@ def export_wire( limit: WireExportLimit, overwrite: bool = False, export_started_at: float | None = None, + observer: WireExportObserver = WireExportObserver(), ) -> WireExportProvenance: """Export one fixed, non-mutating snapshot of authoritative wire rows.""" started_at = time.time() if export_started_at is None else export_started_at resolved = scope.resolve(self.bus.require_channel, self.registry) with self.bus.bus.reading() as source: through = source.revision() - selected = (message for message in source.message_history() if resolved.matches(message)) - return WireTranscriptExporter( - format=format, - scope=resolved.scope, - limit=limit, - boundary=WireExportBoundary(through, started_at), - ).export(selected, Path(destination).expanduser(), overwrite=overwrite) + with source.stored_message_history( + resolved.predicate, resolved.parameters, through=through, + ) as selected: + return WireTranscriptExporter( + format=format, + scope=resolved.scope, + limit=limit, + boundary=WireExportBoundary(through, started_at), + ).export_stored(selected, Path(destination).expanduser(), overwrite=overwrite, + observer=observer) def channel_views( self, visibility: RosterVisibility = DEFAULT_ROSTER, diff --git a/src/agent_comms/installer/backup.py b/src/agent_comms/installer/backup.py index 58ba8a5db0..4e927643b4 100644 --- a/src/agent_comms/installer/backup.py +++ b/src/agent_comms/installer/backup.py @@ -10,6 +10,7 @@ import time from abc import ABC, abstractmethod from collections.abc import Callable, Collection, Iterable +from contextlib import ExitStack from dataclasses import dataclass, field from datetime import datetime from functools import cache, partial @@ -125,11 +126,14 @@ def bus_paths(files: FilesRequest, external: tuple[type[ExternalStore], ...]) -> for resource in store.resources(files))) -def settle_bus_files(files: FilesRequest, external: tuple[type[ExternalStore], ...] = ()) -> None: - """Each selected declaration owns whether settling is valid for its file.""" - paths = bus_paths(files, external) - for resource in RootFile.owned_files(files, paths): - resource.owner.settle(files.location(resource)) +def pinned_bus_files(files: FilesRequest, external: tuple[type[ExternalStore], ...] = () + ) -> ExitStack: + """Hold every selected file's bytes still until the returned stack closes; each + declaration owns how its file is pinned.""" + with ExitStack() as pins: + for resource in RootFile.owned_files(files, bus_paths(files, external)): + pins.enter_context(resource.owner.pinned(files.location(resource))) + return pins.pop_all() def bus_files(files: FilesRequest, external: tuple[type[ExternalStore], ...] = () diff --git a/src/agent_comms/installer/refusals.py b/src/agent_comms/installer/refusals.py index ce41ee6580..7d11acd672 100644 --- a/src/agent_comms/installer/refusals.py +++ b/src/agent_comms/installer/refusals.py @@ -38,6 +38,18 @@ def __init__(self, outcome: ChildOutcome, errors: str) -> None: self.errors = errors +class OwnersNotRestarted(UpgradeRefused): + """The install failed, then starting its stopped owners again failed too. The install's + own failure is the cause; the refused restart only follows from it, so both are named + (run 040380a8, 2026-10-30: the restart's "not retired yet" was the only line printed).""" + + def __init__(self, cause: BaseException, restart: UpgradeRefused) -> None: + self.cause = cause + self.restart = restart + super().__init__(f"{type(cause).__name__}: {cause}; then restarting the stopped " + f"owners failed: {restart}") + + class InstallRolledBack(UpgradeRefused): """The original installation was restored after a failed live pass.""" diff --git a/src/agent_comms/installer/upgrade.py b/src/agent_comms/installer/upgrade.py index 3887fcbfe7..401f21523d 100644 --- a/src/agent_comms/installer/upgrade.py +++ b/src/agent_comms/installer/upgrade.py @@ -60,7 +60,7 @@ SavedFile, StoreBackup, bus_files, - settle_bus_files, + pinned_bus_files, ) from agent_comms.installer.carry import CarryChain from agent_comms.installer.commands import InstallCommands @@ -124,7 +124,9 @@ from agent_comms.installer.probes.owners import OwnerActionsProbe, RunningOwnersProbe from agent_comms.installer.probes.publication import PublicationProbe, SelectedRouteProbe from agent_comms.installer.probes.services import CommanderHeartbeatProbe, ServingProbe -from agent_comms.installer.refusals import InstallRolledBack, InterpreterWorkerDied, UpgradeRefused +from agent_comms.installer.refusals import ( + InstallRolledBack, InterpreterWorkerDied, OwnersNotRestarted, UpgradeRefused, +) from agent_comms.installer.reporting import ( RootWriter, RuntimeWriter, @@ -480,6 +482,22 @@ def stop(self, stack: Stack, root: Path) -> None: for outcome in stack.owners(root, StopCliCommand, running): outcome.require_success(UpgradeRefused) + @contextmanager + def given_back_on(self, stack: Stack, root: Path) -> Iterator[None]: + """Every exit starts the stopped owners on ``stack`` again, unless a published + runtime took them over (``give_back.pop_all``). When the install itself is failing, + a refused restart carries that failure instead of replacing it.""" + self.give_back.callback(self.start_on, stack, root) + try: + yield + except BaseException as failure: + try: + self.give_back.close() + except UpgradeRefused as restart: + raise OwnersNotRestarted(failure, restart) from failure + raise + self.give_back.close() + def start_on(self, stack: Stack, root: Path) -> tuple[OwnerStart, ...]: self.started = stack.owners(root, StartCliCommand, self.stopped) for outcome in self.started: @@ -954,8 +972,7 @@ class StopsOwners: def owners_stopped(cls, root: Path, installed: Stack, staged: TargetStack) -> Iterator[OwnersStopped]: owners = OwnersStopped() - with owners.give_back: - owners.give_back.callback(owners.start_on, installed, root) + with owners.given_back_on(installed, root): owners.stop(installed, root) yield owners @@ -981,8 +998,7 @@ def owners_stopped(cls, root: Path, installed: Stack, RootRuntimeStore.at(root).adopt(installed.runtime) maintenance = MaintenanceBarrier(RegistryStore.at(root).path) owners = HeldOwnersStopped() - with owners.give_back: - owners.give_back.callback(owners.start_on, installed, root) + with owners.given_back_on(installed, root): with owners.hold: with BeginMaintenancePhase().running(clock): held = maintenance.begin(MaintenanceOperator.current()) @@ -1043,6 +1059,7 @@ class PreparedMigration(Extendable): before: dict[DeclaredFile | UndeclaredFile, dict[Path, SavedFile]] read: BusReadBack files: FilesRequest + pins: ExitStack writer_stores: tuple[type[ExternalStore], ...] = () @property @@ -1052,31 +1069,33 @@ def root(self) -> Path: @classmethod def capture(cls, hold: MaintenanceState, outgoing: Stack, files: FilesRequest, steps: tuple[Carry, ...] = ()) -> PreparedMigration: - """Snapshot the held bus and the chain's declared external locations. - Only bus resources settle; external bytes reach backup without a prior write.""" + """Snapshot the held bus and the chain's declared external locations, pinned until + ``carry`` compares them, so only a step's own write can move their bytes.""" writer_stores = Carry.writer_declarations(steps) - with BeforeMigrationFileScanPhase().running(outgoing.commands.clock): - settle_bus_files(files, writer_stores) + with BeforeMigrationFileScanPhase().running(outgoing.commands.clock), \ + pinned_bus_files(files, writer_stores) as pins: before = bus_files(files, writer_stores) - return cls(hold, before, outgoing.verify(files.root), files, writer_stores) + return cls(hold, before, outgoing.verify(files.root), files, pins.pop_all(), + writer_stores) def carry( self, pending: tuple[Carry, ...], staged: TargetStack, reconcile_arguments: tuple[str, ...] = (), ) -> CarriedPass: """One migration sequence for rehearsal and the backed-up live bus.""" - steps = [StepRun(step.declared_name, carried) for step, carried in zip( - pending, staged.call_steps(pending, MigrateOperation(apply=True)), strict=True)] - staged.probe(WriterOwnedOpeningProbe( - self.files, self.writer_stores, - scratch=StorageRule.load(self.files.source_root()).scratch_base)) - reconciled = staged.cli(self.root, ReconcileStoresCliCommand, *reconcile_arguments) - steps.append(StepRun(ReconcileStoresCliCommand.declared_name, Carried( - len(reconciled.rewritten), rewritten=reconciled.rewritten))) - declared = tuple(steps) - with AfterMigrationFileScanPhase().running(staged.commands.clock): - files = carried(self.files, self.before, declared, - RehearsalFiles.declared(declared), self.writer_stores) + with self.pins: + steps = [StepRun(step.declared_name, carried) for step, carried in zip( + pending, staged.call_steps(pending, MigrateOperation(apply=True)), strict=True)] + staged.probe(WriterOwnedOpeningProbe( + self.files, self.writer_stores, + scratch=StorageRule.load(self.files.source_root()).scratch_base)) + reconciled = staged.cli(self.root, ReconcileStoresCliCommand, *reconcile_arguments) + steps.append(StepRun(ReconcileStoresCliCommand.declared_name, Carried( + len(reconciled.rewritten), rewritten=reconciled.rewritten))) + declared = tuple(steps) + with AfterMigrationFileScanPhase().running(staged.commands.clock): + files = carried(self.files, self.before, declared, + RehearsalFiles.declared(declared), self.writer_stores) passed = CarriedPass(declared, files, staged.verify(self.root)) passed.require_read(self.read) return passed @@ -1148,6 +1167,8 @@ def live(self, rehearsal: RehearsedFiles, report: InstallReport) -> CarriedRun: migration = PreparedMigration.capture( maintenance.held_by(MaintenanceOperator.current()), self.installed, files, pending) + # A failure before the carry still releases the pins. + holder.push(migration.pins) backup, selected_route = self.backed_up(selected) except BaseException as error: with AbortMaintenancePhase().running(self.staged.commands.clock): diff --git a/src/agent_comms/locked_store.py b/src/agent_comms/locked_store.py index 2b57fcbddd..e18a6b1ff9 100644 --- a/src/agent_comms/locked_store.py +++ b/src/agent_comms/locked_store.py @@ -200,8 +200,10 @@ def writers_drained(path: Path) -> Iterator[None]: yield @staticmethod - def settle(path: Path) -> None: - """A plain file keeps no committed bytes beside it, so there is nothing to settle.""" + @contextmanager + def pinned(path: Path) -> Iterator[None]: + """A plain file's bytes move only when something writes it; there is nothing to pin.""" + yield @staticmethod def digest(path: Path) -> str: @@ -870,10 +872,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: @@ -1013,14 +1017,15 @@ def writers_drained(cls, path: Path) -> Iterator[None]: held.execute("ROLLBACK") @classmethod - def settle(cls, path: Path) -> None: - wal = cls.wal_path(path) - if not (wal.is_file() and wal.stat().st_size): - return - with cls.exclusive_writer(path) as db: - busy, _, _ = db.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() - if busy: - raise sqlite3.OperationalError(f"Another connection holds the WAL of {path}") + @contextmanager + def pinned(cls, path: Path) -> Iterator[None]: + """Keep the file's bytes where they are while held. Only a database's last close + checkpoints its WAL into the file (new bytes, same rows); one open connection means + no other close is the last. It holds no transaction, so it waits on and blocks no + reader or writer, and a read-only close never checkpoints.""" + with closing(cls._open_reader(path, DEFAULT_STORE_CONTENTION)) as db: + db.execute("SELECT name FROM sqlite_master LIMIT 1").fetchone() + yield @classmethod def digest(cls, path: Path) -> str: diff --git a/src/agent_comms/message_bus.py b/src/agent_comms/message_bus.py index 32c5b1485a..245047dfcc 100644 --- a/src/agent_comms/message_bus.py +++ b/src/agent_comms/message_bus.py @@ -144,13 +144,17 @@ def channel_declarations(self): with self.opened_activity() as activity: return self._channels.read().views(activity) + def capture_selection(self, viewer: DisplayViewer, source: BusSnapshot) -> CapturedSelection: + return CapturedSelection.load( + self._registry, self._channels, self.reads, viewer, source.activity_clocks(), + ) + @contextmanager - def snapshot(self, viewer: DisplayViewer = AnonymousViewer()) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + def snapshot( + self, viewer: DisplayViewer = AnonymousViewer(), + ) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: with self.bus.reading() as source: - basis = CapturedSelection.load( - self._registry, self._channels, self.reads, viewer, source.activity_clocks(), - ) - yield basis, source + yield self.capture_selection(viewer, source), source def inbox( self, name: str, target: NamedTarget = AnyChannel.target(), *, diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index f91b7540c6..000bc67290 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 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 @@ -47,6 +47,7 @@ FieldCodec, InlineRecord, MessageId, + MalformedRecord, NullMember, PublicationExclude, ThreadLookup, @@ -429,14 +430,65 @@ 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, + ) -> Generator[Self]: + """One cursor reads only the capability tables this value inherits.""" + 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, + 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"{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" {position_clause}", parameters, + )) as cursor: + for stored in cursor: + original = BusRow.decode_at(stored, 0) + kind = record_type(original) + try: + 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"{kind.__name__} capability: {error}" + ) from error + yield original, value @classmethod def assembled(cls, rows: Iterable[CapabilityRow]) -> Self: @@ -474,24 +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))))) - 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(()) @@ -1064,15 +1104,36 @@ def display_metadata(self) -> dict: """Live messages carry no historical provenance decoration.""" return {} + @classmethod + def stored_wire_query(cls, db: sqlite3.Connection, identity: type[TypedTable]) -> str: + """Project the public envelope from its declared stored fields, not domain objects.""" + tables = cls.tables() + stored = {name: table for table in tables for name in table.column_projection()} + pieces = [stored[plan.name].wire_record(db, plan) + for plan in FieldCodec.field_plans(cls) if plan.records] + identity_key = cls.message_id.wire_name + pieces.append("json_object('" + identity_key + "', " + identity.qualified("message_id") + ")") + fragments = " UNION ALL ".join(f"SELECT substr(({piece}), 2, length(({piece})) - 2) AS body" + for piece in pieces) + wire = "'{' || COALESCE((SELECT group_concat(NULLIF(body, ''), ',') FROM (" + fragments + ")), '') || '}'" + columns = ", ".join(f'{table.qualified(name)} AS "{name}"' + for name, table in stored.items()) + joins = " ".join(table.join_to(BusRow) for table in (*tables, identity)) + extra = tuple(name for name in identity.column_projection() if name not in stored) + identity_columns = ', '.join(f'{identity.qualified(name)} AS "{name}"' for name in extra) + return (f'SELECT {columns}, {identity_columns}, {wire} AS wire ' + f'FROM "{BusRow.declared_name}" {joins}') + def to_wire(self) -> dict: return {"seq": self.seq, "id": self.message_id, **FieldCodec.encode(self.envelope)} @classmethod - def from_wire(cls, data: Mapping) -> Message: + def from_wire(cls, data: object) -> Message: # Content identity is derived from the declared message fields. plan = FieldCodec.field_plan(Message, 'type') kind = cast(builtins.type[MessageKind], plan.value.decode(plan.raw(data))) - return kind.decode_message({"ts": 0.0, **data}) + # The field decoder has already required a record; do not validate/copy it again. + return kind.decode_message({"ts": 0.0, **cast(Mapping[str, object], data)}) def response_eligibility( self, audience: Sequence[str], *, aliases: Mapping[str, str] = NO_ALIASES @@ -1204,6 +1265,65 @@ def __init_subclass__(cls) -> None: cls.tables() +@dataclass(frozen=True) +class StoredWireMessage: + """One public encoded envelope from a held SQLite read, with no decoded Message.""" + + seq: int + timestamp: float + wire: str + + +@dataclass(frozen=True) +class StoredWireHistory: + """A captured public-envelope query; cursors stream and retire with the read.""" + + db: sqlite3.Connection + query: str + parameters: tuple[object, ...] + + def rows(self, *, after: int = 0, descending: bool = False) -> Iterator[StoredWireMessage]: + order = "DESC" if descending else "ASC" + with closing(self.db.execute( + f"SELECT seq, timestamp, wire FROM ({self.query}) WHERE seq >= ? ORDER BY seq {order}", + (*self.parameters, after), + )) as cursor: + for row in cursor: + yield StoredWireMessage(*row) + + def messages(self, page: int = 256) -> Iterator[Message]: + """The selected rows' public messages, decoded once from their capability tables.""" + with closing(self.db.execute( + f"SELECT seq FROM ({self.query}) ORDER BY seq", self.parameters, + )) as cursor: + while sequences := tuple(FieldCodec.decode(int, seq) for seq, in cursor.fetchmany(page)): + for record in BusCapability.at_sequences(self.db, sequences): + for message in record.messages(): + yield Message.from_message(message) + + def count(self) -> int: + return self.count_where("1") + + def count_where(self, predicate: str, parameters: tuple[object, ...] = ()) -> int: + return FieldCodec.decode(int, self.db.execute( + f"SELECT count(*) FROM ({self.query}) WHERE {predicate}", + (*self.parameters, *parameters)).fetchone()[0]) + + def newest_suffix(self, size: str, capacity: int) -> int: + """SQLite sums encoded byte lengths newest-first; Python retains only a sequence.""" + first = self.db.execute(f"SELECT COALESCE(max(seq), 0) + 1 FROM ({self.query})", + self.parameters).fetchone()[0] + with closing(self.db.execute( + f"SELECT seq, sum({size}) OVER (ORDER BY seq DESC ROWS UNBOUNDED PRECEDING) " + f"FROM ({self.query})", self.parameters, + )) as cursor: + for sequence, retained_bytes in cursor: + if retained_bytes > capacity: + break + first = sequence + return FieldCodec.decode(int, first) + + class PublicRecord(BusRecord): sender_lookup: ClassVar[PublicSender] = PublicSender() diff --git a/src/agent_comms/native_claim_epoch.py b/src/agent_comms/native_claim_epoch.py index 2bcf1d1177..1b82f64e9d 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 b4b8f6983d..5f5b979826 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -206,7 +206,7 @@ class ReleasedOwner(ThreadIncarnationRecord): process_identity: Annotated[ProcessState, Column(storage=JsonStorage, nullable=True)] 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): @@ -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)) @@ -266,14 +266,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) @@ -284,13 +284,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 @@ -311,13 +312,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/phase_evidence.py b/src/agent_comms/phase_evidence.py index 7d42c31c2c..8dc10216b2 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -16,7 +16,8 @@ from collections import Counter from collections.abc import Iterable, Iterator, Mapping from contextlib import contextmanager -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace +from functools import cached_property from pathlib import Path, PurePosixPath from threading import Lock from subprocess import TimeoutExpired @@ -25,8 +26,7 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec from agent_comms.head_review import HeadReview -from agent_comms.private_path import FileObservation, FileRevision -from agent_comms.repo import GitCommandError, Repo +from agent_comms.repo import Checkout, CheckoutHistory, GitCommandError, Repo if TYPE_CHECKING: from agent_comms.todos import Todo @@ -65,28 +65,35 @@ class MainTree: repo: Repo commit: str census: Census = field(compare=False, repr=False) + history: CheckoutHistory = field(compare=False, repr=False, kw_only=True) branch: ClassVar[str] = "main" source: ClassVar[str] = ".py" parsed: dict[str, tuple[str, MainModule]] = field( default_factory=dict, compare=False, repr=False, kw_only=True, ) + def __post_init__(self) -> None: + self.history.include(self.commit) + @classmethod def of(cls, repo: Repo, census: Census) -> MainTree: - return cls(repo, Repo.git(repo.checkout, "rev-parse", f"{repo.remote}/{cls.branch}").strip(), - census) + return cls.from_history(repo, census, Checkout(repo.checkout).read()) - def advanced(self) -> MainTree: - """An explicitly selected historical tree never moves.""" - return self + @classmethod + def from_history(cls, repo: Repo, census: Census, history: CheckoutHistory) -> MainTree: + return cls(repo, history.resolve(f"{repo.remote}/{cls.branch}"), census, history=history) def has(self, path: str) -> bool: return not self.missing((path,))[0] + @cached_property + def objects(self) -> dict[str, str]: + """This tree's membership, including directories, without requiring blob contents.""" + return Repo.tree_objects(self.repo.checkout, self.commit, (), "-r", "-t") + def missing(self, paths: tuple[str, ...]) -> tuple[bool, ...]: """Tree membership does not require a present path's promised contents.""" - entries = Repo.tree_objects(self.repo.checkout, self.commit, paths) - return tuple(path not in entries for path in paths) + return tuple(path not in self.objects for path in paths) def grep(self, *options: str, pattern: str, paths: tuple[str, ...]) -> list[str]: """``git grep -E`` rows of this tree's Python modules, like ``module`` and ``modules``; @@ -143,8 +150,10 @@ def modules(self, paths: tuple[str, ...]) -> Iterator[MainModule]: """Every Python module under ``paths``. Each path keeps its last parsed blob, so every evidence reading one commit parses it once, and the rest are read in one ``git cat-file --batch``.""" + for deleted in self.parsed.keys() - self.objects.keys(): + del self.parsed[deleted] listed = Repo.tree_objects(self.repo.checkout, self.commit, paths, "-r") - blobs = [(blob, name) for name, blob in listed.items() if name.endswith(self.source)] + blobs = [(self.objects[name], name) for name in listed if name.endswith(self.source)] fresh = [(blob, name) for blob, name in blobs if self.parsed.get(name, ("", None))[0] != blob] contents = Repo.blobs(self.repo.checkout, *(blob for blob, _ in fresh)) @@ -154,37 +163,7 @@ def modules(self, paths: tuple[str, ...]) -> Iterator[MainModule]: yield from (self.parsed[name][1] for _, name in blobs) def contains(self, sha: str) -> bool: - return self.repo.contains(sha, self.commit) - - -@dataclass(frozen=True) -class LiveMainTree(MainTree): - """The fetched main reference and its current immutable tree, owned by its reader.""" - - references: tuple[Path, Path] = field(compare=False) - revisions: tuple[FileObservation, ...] = field(compare=False) - - @classmethod - def of(cls, repo: Repo, census: Census) -> LiveMainTree: - loose, packed = Repo.git( - repo.checkout, 'rev-parse', '--path-format=absolute', - '--git-path', f'refs/remotes/{repo.remote}/{cls.branch}', '--git-path', 'packed-refs', - ).splitlines() - references = (Path(loose), Path(packed)) - revisions = tuple(FileRevision.capture(path) for path in references) - tree = MainTree.of(repo, census) - return cls(repo, tree.commit, census, references, revisions) - - def advanced(self) -> LiveMainTree: - revisions = tuple(FileRevision.capture(path) for path in self.references) - if revisions == self.revisions: - return self - tree = MainTree.of(self.repo, self.census) - # Capture before reading Git: a racing reference move is seen on the next read. - return type(self)( - self.repo, tree.commit, self.census, self.references, revisions, - parsed=self.parsed if tree.commit == self.commit else {}, - ) + return self.history.contains(sha, self.commit) class UnreadableEvidenceError(Exception): @@ -263,8 +242,8 @@ def read(self, evidence: PhaseEvidence) -> EvidenceRead: self.reads[evidence] = EvidenceFound(evidence.holds(self)) return self.reads[evidence] - def following(self, todos: tuple[Todo, ...]) -> PlanReading: - main = self.main.advanced() + def following(self, todos: tuple[Todo, ...], main: MainTree) -> PlanReading: + main = replace(main, parsed=self.main.parsed) return type(self)(todos, main, { evidence: answer for evidence, answer in self.reads.items() if answer.retained(evidence, self.main, main) @@ -283,13 +262,13 @@ class PlanReadings: census: ClassVar[Census] - def observed(self, repo: Repo, todos: tuple[Todo, ...]) -> PlanReading: - return PlanReading(todos, MainTree.of(repo, self.census)) + def observed(self, repo: Repo, todos: tuple[Todo, ...], history: CheckoutHistory) -> PlanReading: + return PlanReading(todos, MainTree.from_history(repo, self.census, history)) def prepare_reading(self, repo: Repo, todos: tuple[Todo, ...], - evidence: tuple[PhaseEvidence, ...]) -> PlanReading: + evidence: tuple[PhaseEvidence, ...], *, history: CheckoutHistory) -> PlanReading: """Capture the main cut and acquire inputs without evaluating the plan.""" - reading = self.observed(repo, todos) + reading = self.observed(repo, todos, history) for declared in evidence: reading.prepare(declared) return reading @@ -302,7 +281,8 @@ def observe_reading(self, reading: PlanReading, evidence: tuple[PhaseEvidence, . def read(self, repo: Repo, todos: tuple[Todo, ...], evidence: tuple[PhaseEvidence, ...]) -> PlanReading: - return self.observe_reading(self.prepare_reading(repo, todos, evidence), evidence) + return self.observe_reading(self.prepare_reading( + repo, todos, evidence, history=Checkout(repo.checkout).read()), evidence) def retain(self, reading: PlanReading) -> None: """An ordinary command releases its reading when its caller does.""" @@ -317,16 +297,21 @@ def __init__(self) -> None: self.plans: tuple[PlanReading, ...] = () self.lock = Lock() - def observed(self, repo: Repo, todos: tuple[Todo, ...]) -> PlanReading: + def observed( + self, repo: Repo, todos: tuple[Todo, ...], history: CheckoutHistory, + ) -> PlanReading: + current = super().observed(repo, todos, history) with self.lock: plans = self.plans for previous in plans: if previous.main.repo == repo: - return previous.following(todos) - return PlanReading(todos, LiveMainTree.of(repo, self.census)) + return previous.following(todos, current.main) + return current def retain(self, reading: PlanReading) -> None: - retained = reading.following(reading.todos) + # Keep the pinned commit, parsed modules and stable answers, not the pass's graph. + retained = reading.following(reading.todos, replace( + reading.main, history=Checkout(reading.main.repo.checkout).read())) with self.lock: self.plans = (*(plan for plan in self.plans if plan.main.repo != retained.main.repo), retained) @@ -378,6 +363,7 @@ class CommitMerged(MainEvidence): def prepare(self, reading: PlanReading) -> None: Repo.require_commits(reading.main.repo.checkout, reading.main.repo.remote, self.sha, reading.main.commit) + reading.main.history.include(self.sha, reading.main.commit) def read(self, main: MainTree) -> bool: return main.contains(self.sha) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 13724e8e86..b6c0b02d06 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -5,11 +5,12 @@ 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 functools import partial from pathlib import Path +from threading import RLock from typing import TYPE_CHECKING, Annotated, ClassVar from . import relationships @@ -28,7 +29,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 ( @@ -37,6 +38,7 @@ JsonShapeFamily, JsonShapeMember, NullMember, + PresentMember, PresenceExclude, RecordMember, SubclassUnion, @@ -529,11 +531,30 @@ 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: + source = lifetime.enter_context(self.bus.bus.captured_reading()) + basis = self.bus.capture_selection(viewer, source) + captured = CapturedBusPresentation(self, basis, source) + try: + yield captured + finally: + with captured.reading(viewer): + lifetime.close() + def roster( self, viewer: str, visibility: RosterVisibility = DEFAULT_ROSTER, ) -> CoordinationSnapshot: @@ -562,7 +583,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 +613,34 @@ 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.""" @@ -720,8 +769,30 @@ def activity_detail(self) -> str: return " · ".join(part for part in (self.activity.detail, *shown.status.details) if part) +class ThreadPresence(ABC): + """A person-row consumer visits only complete captured thread views.""" + + def views(self) -> tuple[ThreadView, ...]: + return () + + @abstractmethod + def sort_key( + self, order: type[ThreadSort], target: str, sent: float, + ) -> tuple[float, float, str]: ... + + @dataclass(frozen=True, slots=True) -class ThreadView(ThreadRecord): +class NoThreadView(NullMember, ThreadPresence): + """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, ThreadPresence): activity: Annotated[ObservedActivity, PresenceExclude()] runtime: Annotated[RuntimeInfo, PresenceExclude()] last_seen: float @@ -745,6 +816,14 @@ def roster( snapshot.seen_at(thread.name), snapshot.owner_binding(thread.name), ) for thread in threads) + def views(self) -> tuple[ThreadView, ...]: + return (self,) + + 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/private_sidecar.py b/src/agent_comms/private_sidecar.py index 3cded7e1bb..975ed61a1a 100644 --- a/src/agent_comms/private_sidecar.py +++ b/src/agent_comms/private_sidecar.py @@ -377,9 +377,10 @@ def writers_drained(path: Path) -> Iterator[None]: yield @staticmethod - def settle(path: Path) -> None: - # The snapshot protocol has no WAL or checkpoint operation. - pass + @contextmanager + def pinned(path: Path) -> Iterator[None]: + # Published inodes are immutable and the snapshot protocol has no WAL to checkpoint. + yield @classmethod def copy_file(cls, source: str | Path, target: str | Path) -> Path: diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 3658398384..53caf0ab68 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -6,10 +6,11 @@ """ from __future__ import annotations + 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 @@ -28,13 +29,14 @@ 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 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"): @@ -728,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() @@ -875,20 +889,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)) @@ -951,6 +968,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.""" @@ -1122,10 +1146,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.""" + + @property + @abstractmethod + def participant_query(self) -> tuple[str, tuple[object, ...]]: ... - participant_names: frozenset[str] = frozenset() + @abstractmethod + def includes_participant(self, name: str) -> bool: ... def capture_deliveries(self, reads: ReadLedger, viewer: str, snapshot: RegistrySnapshot, source: BusSnapshot, *, document: ReadDocument, @@ -1134,40 +1163,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.""" + + participants: frozenset[str] = frozenset() + + @property + def participant_query(self) -> tuple[str, tuple[object, ...]]: + return "SELECT value FROM json_each(?)", (json.dumps(sorted(self.participants)),) + + def includes_participant(self, name: str) -> bool: + return name in self.participants + + +@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.memberships[name].carries(self.tags) @dataclass(frozen=True, slots=True) @@ -1178,18 +1243,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; @@ -1209,8 +1292,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) @@ -1227,10 +1309,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 da6ddef71f..61762430c7 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -21,6 +21,7 @@ from .declared_family import DeclaredFamily from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import ( + DeclaredWireType, ExternalJsonShape, FieldCodec, MalformedRecord, @@ -29,6 +30,7 @@ ThreadLookup, WireOmitDefault, WireRequired, + WireTransient, WireValue, ) from .registration_change import ( @@ -57,6 +59,7 @@ OfferedTools, OwnerIdentity, RecordedThreadIncarnation, + ThreadBirthRecord, ThreadIncarnation, ThreadIncarnationRecord, ThreadAuthority, @@ -71,7 +74,8 @@ ThreadStatus, ) from .thread_text import ForkedParent, NoParent -from .threads import Thread, ThreadWorkState +from .thread_membership import ThreadMembership +from .threads import GoalOwner, Thread, ThreadWorkState from .turn_lease import ( FinishedTurn, FinishedTurnFence, NoFinishedTurn, StoredTurnRecord, TurnCompletion, TurnLeaseFence, TurnRecord, @@ -108,6 +112,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.""" + 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) @@ -312,13 +323,85 @@ def __init__(self, error: Exception) -> None: super().__init__(f"Invalid registry document: {error}") +@dataclass(frozen=True) +class ThreadRow(DeclaredWireType): + """One retained row owns its lazy birth, narrow projections and bound Thread.""" + + wire_type = Thread + 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 cls(MappingProxyType(raw), ThreadRows.unbound) + + @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: + return replace(self, binding=binding) + + def to_wire(self) -> dict[str, JsonValue]: + return dict(self.raw) + + def __reduce__(self) -> tuple[ + Callable[[object], ThreadRow], tuple[dict[str, JsonValue]], + dict[str, Callable[[Thread], Thread]], + ]: + # 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 membership(self) -> ThreadMembership: + return FieldCodec.project_record( + 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=FieldCodec.recorded_fields(self.birth_record), + ) + + @cached_property + def decoded(self) -> Thread: + try: + 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.""" 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] = {} + self._rows: dict[str, ThreadRow] = {} self._binding: Callable[[Thread], Thread] = self.unbound @staticmethod @@ -326,18 +409,18 @@ def unbound(thread: Thread) -> Thread: return thread def bind(self, binding: Callable[[Thread], Thread]) -> None: + if self._binding == binding: + return 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, ...]: @@ -346,46 +429,49 @@ 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 __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 determining(self, name: str) -> GoalOwner: + """Only a goal/role question acquires the declared goal-owner projection.""" + return self._rows[name].goal_owner - def decode_row(self, row: object) -> Thread: - try: - thread = FieldCodec.decode(Thread, row) - except ValueError as error: - raise InvalidRegistryDocument(error) from error - return self._binding(thread) + def membership(self, name: str) -> ThreadMembership: + return self._rows[name].membership + + def birth_record(self, name: str) -> ThreadBirthRecord: + return self._rows[name].birth_record + + def incarnation(self, name: str) -> ThreadIncarnation: + return self.birth_record(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 in self for _ in self.determining(name).active_goals()) + + def __getitem__(self, name: str) -> 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._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._raw.pop(name, None) - self._decoded.pop(name, None) + del self._rows[name] def to_wire(self) -> dict[str, JsonValue]: - return {**self._raw, **{name: FieldCodec.encode(thread) - for name, thread in self._decoded.items()}} + # 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]: @@ -396,31 +482,28 @@ 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") return dict(raw) 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: - return CreationTime.from_wire(self._raw[name].get("created_at")) + return self._rows[name].birth_record.created_at except ValueError as error: raise InvalidRegistryDocument(error) from error def births(self) -> set[float]: - return {self.birth(name) for name in self} + return {self.birth(name).to_wire() for name in self} - def lookup_names(self) -> dict[ThreadLookup, str]: - """Each row's creation lookup, read from its birth alone: resolving one owner never - decodes the other threads.""" - return { - ThreadIncarnationRecord(name, self.birth(name).to_wire()).incarnation() - .require_recorded().lookup: name - for name in self - } + def lookup_names(self) -> Mapping[ThreadLookup, str]: + """Derive this cut's lookup names from the declared birth owner, never full threads.""" + return MappingProxyType({ + ThreadIncarnationRecord(name, self.birth(name).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.""" @@ -431,7 +514,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 @@ -440,24 +524,21 @@ 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._rows = source._rows.copy() + result._binding = source._binding return result def reuse(self, rows: Mapping[str, object], kept: frozenset[str], earlier: ThreadRows) -> None: - self._raw = self.checked_rows(rows) - self._decoded = {name: earlier._decoded[name] for name in kept if name in earlier._decoded} + 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): @@ -471,11 +552,11 @@ def __delitem__(self, name: str) -> None: 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], @@ -1135,7 +1216,7 @@ def __post_init__(self) -> None: @cached_property def lookup_names(self) -> Mapping[ThreadLookup, str]: - return MappingProxyType(self.threads.lookup_names()) + return self.threads.lookup_names() def born(self, lookup: ThreadLookup) -> Thread: """The one thread a creation lookup names; only its row decodes.""" @@ -1260,7 +1341,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/registry_provenance.py b/src/agent_comms/registry_provenance.py index cfb9219477..c17742c40e 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,6 +19,7 @@ from .messages import PublicSender from .routing import DeliveryScope from .thread_identity import ThreadIncarnation +from .thread_membership import ThreadMembership from .threads import Thread, ThreadProvenance if TYPE_CHECKING: @@ -135,11 +137,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.""" @@ -168,12 +181,21 @@ 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 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) - 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)] + + def membership(self, name: str) -> ThreadMembership: + return self.require(name) @dataclass(frozen=True, slots=True) @@ -200,6 +222,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 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: + 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/relationships.py b/src/agent_comms/relationships.py index 97dda8bae1..cfdb277dae 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 Iterator, 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 @@ -27,7 +28,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, @@ -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,), @@ -111,12 +113,11 @@ def entry(self, identity: ThreadIncarnation, person: presentation.ThreadView | N 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) @@ -242,7 +243,7 @@ class GoalMentionDiagnostic: class RelationshipEntry: target: str kind: str - person: presentation.ThreadView | None = None + person: presentation.ThreadView | presentation.NoThreadView sequence: int = 0 timestamp: float = 0 detail: str = "" @@ -258,10 +259,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.views()) + for previous in earlier.person.views() + ) @dataclass(frozen=True, slots=True) @@ -276,11 +278,35 @@ 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, + 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 build(cls, context: RelationshipContext) -> RelationshipGroup: - return cls({entry.target: entry for entry in cls.supply(context)}) + def from_entries(cls, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> RelationshipGroup: + return cls(entries) + + @classmethod + 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 () @@ -288,21 +314,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: Message) -> tuple[str, ...]: ... + def targets(inputs: RelationshipInputs, message: MessageInput) -> tuple[NamedTarget, ...]: ... class InboundRelationshipGroup(RecentRelationshipGroup): @@ -310,11 +333,12 @@ class InboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent inbound" @staticmethod - def targets(context: RelationshipContext, message: Message) -> 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): @@ -322,10 +346,10 @@ class OutboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent outbound" @staticmethod - def targets(context: RelationshipContext, message: Message) -> 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): @@ -333,8 +357,11 @@ 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) @@ -345,23 +372,20 @@ 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 build(cls, context: RelationshipContext) -> SortableRelationshipGroup: - order = cls.selected_order(context) + def from_entries(cls, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> SortableRelationshipGroup: + order = cls.selected_order(context.orders) def key(item: RelationshipEntry) -> tuple[float, float, str]: - person = item.person - 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, - ) + return item.person.sort_key(order, item.target, context.sent.get(item.target, 0)) - return cls({entry.target: entry for entry in sorted(cls.supply(context), key=key)}, order) + return cls({entry.target: entry for entry in sorted(entries.values(), key=key)}, order) def selectable(self) -> tuple[SortableRelationshipGroup, ...]: return (self,) @@ -381,12 +405,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.threads.values() - for parent in child.parent.names() - if context.registry.canonical_name(parent) == context.thread.name + NamedEntrySource(RelationshipEntrySource.canonical_target(child.name, inputs.registry)) + for child in inputs.registry.children(inputs.thread.name) ) @@ -396,40 +418,101 @@ 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 - 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)) - return tuple(entries.values()) + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + selected: dict[ThreadIncarnation, list[MutualThreadRelation]] = {} + for relation in (*inputs.edges, *inputs.goal_contacts): + for identity in relation.counterparts(inputs.thread): + 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[Message, ...] + 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] + - def entry(self, name: str, *, message: Message | None = None, +@dataclass(frozen=True, slots=True) +class RelationshipContext(RelationshipInputs): + people: Mapping[str, presentation.ThreadView] + + def entry(self, target: NamedTarget, *, sequence: int = 0, timestamp: float = 0, detail: str = "") -> RelationshipEntry: - target = CommandTarget.named(name) - name = target.canonical_name(self.registry.canonical_name) return RelationshipEntry( - name, target.kind, self.people.get(name), - message.seq if message else 0, message.timestamp if message else 0, detail, + target.name, target.kind, self.people.get(target.name, presentation.NoThreadView()), + sequence, timestamp, 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, sequence=self.message.seq, timestamp=self.message.timestamp, + 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.views()) + 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 @@ -557,7 +640,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, ) @@ -712,13 +795,11 @@ 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) + 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)) @@ -757,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( @@ -807,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))), @@ -846,11 +928,14 @@ 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) - return tuple(reversed(messages[:self.RECENT_MESSAGES])), len(messages) > self.RECENT_MESSAGES + 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: # One coherent identity/metadata basis; wire payload work is bounded. @@ -860,26 +945,25 @@ 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, - {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()), - tuple(group.build(context) for group in RelationshipGroup.members_with(RelationshipGroup)), + RelationshipGroup.capture(inputs, self.views), limited, len(messages), unresolved_goal_mentions=goal_diagnostics, diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index 6c9618a93d..969b38b68f 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -2,12 +2,15 @@ from __future__ import annotations +import heapq +import re import shutil import subprocess from abc import ABC, abstractmethod from collections.abc import Iterator from contextlib import ExitStack, contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, field +from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING, Annotated, ClassVar, Self @@ -16,7 +19,7 @@ from .coordination_errors import CommandFailure from .declared_family import DeclaredFamily from .errors import MissingSourceLine -from .field_codec import FieldCodec, PresentMember, SingleFieldValue, WireValue +from .field_codec import FieldCodec, GitCommitText, PresentMember, SingleFieldValue, WireValue from .native_build_inputs import GitLaunch from .repository_name import RepositoryName @@ -508,6 +511,16 @@ def prepared(cls, path: Path) -> Self: checkout.prepare() return checkout + @cached_property + def common_dir(self) -> Path: + """Git's shared ref and object owner, independent of this worktree's HEAD.""" + return Path(Repo.git(self.path, "rev-parse", "--path-format=absolute", + "--git-common-dir").strip()).resolve() + + def read(self) -> CheckoutHistory: + """One operation's refs and commit graph; the caller owns this read's lifetime.""" + return CheckoutHistory(self) + def submodules(self) -> tuple[PinnedSubmodule, ...]: if not (self.path / ".gitmodules").is_file(): return () @@ -546,8 +559,7 @@ def relocating(self, destination: Path) -> Iterator[CheckoutTransfer]: raise ValueError(f'{source} is not an isolated linked worktree') if destination.exists() or destination.is_symlink(): raise ValueError(f'Checkout destination {destination} is occupied') - common = Path(Repo.git(source, 'rev-parse', '--path-format=absolute', - '--git-common-dir').strip()) + common = self.common_dir modules = tuple(SubmoduleTransfer.capture(module, source, destination) for module in self.initialized_submodules()) transfer = CheckoutTransfer(source, destination, @@ -584,12 +596,435 @@ def retire(self) -> None: held = self.held_work() if held: raise CheckoutHoldsWorkError(self.path, held) - common = Path( - Repo.git(self.path, "rev-parse", "--path-format=absolute", "--git-common-dir").strip() - ) + common = self.common_dir Repo.git(common.parent, "worktree", "remove", "--force", str(self.path)) +@dataclass(frozen=True) +class UnpushedHead: + head: str + commits: int = field(compare=False) + upstream: str = field(compare=False) + + +@dataclass(frozen=True) +class BranchUpstream(DeclaredFamily): + """Git's upstream decoded once; each member owns acquisition and publication answers.""" + + branch: str + prefix: ClassVar[str] = "refs/heads/" + + @classmethod + def from_git(cls, name: str, upstream: str, remote: str, reference: str) -> BranchUpstream: + """Decode foreign for-each-ref fields before the reference enters our family.""" + branch = name.removeprefix(cls.prefix) + if not upstream: + return NoUpstream(branch) + if remote == Repo.local_remote: + return LocalUpstream(branch, upstream) + return PublishedUpstream(branch, upstream, remote, reference) + + def publication_inputs(self) -> tuple[tuple[str, str], ...]: + """Only published upstreams supply remote/reference acquisition requirements.""" + return () + + def tracking_name(self) -> str: + return "" + + def unpushed(self, history: CheckoutHistory, head: str, + published: tuple[str, ...]) -> tuple[UnpushedHead, ...]: + # A local, absent or deleted upstream cannot make its stale tracking ref publication. + tracking = self.tracking_name() + count = history.unpublished(head, tracking) + return (UnpushedHead(head, count, tracking),) if count else () + + +class NoUpstream(BranchUpstream): + """This local branch declares no upstream.""" + + +class DetachedHead(BranchUpstream): + """Git names a checkout on no branch `HEAD`, as during a rebase; it declares no upstream.""" + + +@dataclass(frozen=True) +class TrackedUpstream(BranchUpstream, declared_name=None): + tracking: str + + def tracking_name(self) -> str: + return self.tracking + + +class LocalUpstream(TrackedUpstream): + """Git's dot remote names another local branch, never a publication.""" + + +@dataclass(frozen=True) +class PublishedUpstream(TrackedUpstream): + remote: str + reference: str + + def publication_inputs(self) -> tuple[tuple[str, str], ...]: + return ((self.remote, self.reference),) + + def unpushed(self, history: CheckoutHistory, head: str, + published: tuple[str, ...]) -> tuple[UnpushedHead, ...]: + if published: + (remote_head,) = published + count = history.count(head, remote_head) + return (UnpushedHead(head, count, self.tracking),) if count else () + return super().unpushed(history, head, published) + + +@dataclass(frozen=True) +class GitReference: + """A repository ref; local branch members additionally own their decoded upstream.""" + + name: str + commit: Annotated[str, GitCommitText] + + @classmethod + def listed(cls, row: str) -> GitReference: + name, commit, upstream, remote, remote_ref = row.split("\0") + sha = GitCommitText.decode(commit) + if name.startswith(BranchUpstream.prefix): + return LocalGitReference( + name, sha, BranchUpstream.from_git(name, upstream, remote, remote_ref), + ) + return cls(name, sha) + + def upstreams(self) -> tuple[BranchUpstream, ...]: + return () + + +@dataclass(frozen=True) +class LocalGitReference(GitReference): + upstream: BranchUpstream + + def upstreams(self) -> tuple[BranchUpstream, ...]: + return (self.upstream,) + + +@dataclass(frozen=True) +class GitHead(ABC): + """A worktree's own HEAD, decoded independently of the repository's shared refs.""" + + checkout: Checkout + reference: ClassVar[str] = "HEAD" + + @classmethod + def listed(cls, record: str) -> GitHead: + # Git's porcelain record opens with "worktree PATH" and "HEAD SHA", in that order; + # the attribute lines after them declare the branch or the detached state. + worktree, head, *attributes = record.split("\0") + path = worktree.removeprefix("worktree ") + sha = head.removeprefix(f"{cls.reference} ") + symbolic = next((line.removeprefix("branch ") for line in attributes + if line.startswith("branch ")), "") + checkout = Checkout(Path(path).resolve()) + commit = GitCommitText.decode(sha) + if not commit.strip("0"): + return UnbornGitHead(checkout, symbolic) + if symbolic: + return AttachedGitHead(checkout, sha=commit, symbolic=symbolic) + if "detached" not in attributes: + raise ValueError(f"Git HEAD in {path} has neither a branch nor a detached declaration") + return DetachedGitHead(checkout, commit) + + @property + @abstractmethod + def branch(self) -> str: ... + + @abstractmethod + def revision(self) -> str: ... + + def required(self) -> tuple[str, ...]: + return () + + def checked_branch(self) -> tuple[str, ...]: + return () + + +@dataclass(frozen=True) +class SymbolicGitHead(GitHead): + symbolic: str + + @property + def branch(self) -> str: + return self.symbolic.removeprefix("refs/heads/") + + +@dataclass(frozen=True) +class UnbornGitHead(SymbolicGitHead): + def revision(self) -> str: + raise UnavailableCommitError(self.checkout.path, (self.reference,)) + + +@dataclass(frozen=True) +class CommittedGitHead(GitHead): + sha: Annotated[str, GitCommitText] + + def revision(self) -> str: + return self.sha + + def required(self) -> tuple[str, ...]: + return (self.sha,) + + +@dataclass(frozen=True) +class AttachedGitHead(CommittedGitHead, SymbolicGitHead): + def checked_branch(self) -> tuple[str, ...]: + return (self.branch,) + + +@dataclass(frozen=True) +class DetachedGitHead(CommittedGitHead): + @property + def branch(self) -> str: + return self.reference + + +class RepositoryReads: + """A pass's repository reads, keyed by Git's common directory, not worktree aliases.""" + + def __init__(self) -> None: + self._histories: dict[Path, CheckoutHistory] = {} + self._worktree_histories: dict[Path, CheckoutHistory] = {} + + def history_for(self, checkout: Checkout) -> CheckoutHistory: + checkout = checkout.resolved() + if checkout.path not in self._worktree_histories: + common = checkout.common_dir + if common not in self._histories: + self._histories[common] = checkout.read() + history = self._histories[common] + self._worktree_histories.update((path, history) for path in history.heads) + self._worktree_histories[checkout.path] = history + return self._worktree_histories[checkout.path] + + +@dataclass(frozen=True) +class GitCommit: + """The commit facts needed by ancestry and order progress, decoded once.""" + + sha: Annotated[str, GitCommitText] + parents: tuple[Annotated[str, GitCommitText], ...] + at: float + message: str + + +@dataclass(frozen=True) +class CommitSelection: + """One revision walk, independent of the problem-message filter applied to it.""" + + refs: tuple[str, ...] + excluded: tuple[str, ...] + first_parent: bool = False + + +class CheckoutHistory: + """A pass's selected refs and immutable commit graph, never a command-output cache. + + Acquisitions declare additional heads before the graph is first consumed. A later + acquisition cannot silently change an answer already observed in this cut. + """ + + def __init__(self, checkout: Checkout) -> None: + self.checkout = checkout + self.required: set[str] = set() + self._ancestry: dict[frozenset[str], int] = {} + self._remote_selections: dict[str, int] = {} + self._bases: dict[int, tuple[str, ...]] = {} + self._walks: dict[CommitSelection, tuple[GitCommit, ...]] = {} + + @cached_property + def refs(self) -> dict[str, GitReference]: + return {reference.name: reference for reference in map(GitReference.listed, Repo.git( + self.checkout.common_dir, "for-each-ref", + "--format=%(refname)%00%(objectname)%00%(upstream)" + "%00%(upstream:remotename)%00%(upstream:remoteref)", + "refs/heads/", "refs/remotes/", + ).splitlines())} + + @cached_property + def heads(self) -> dict[Path, GitHead]: + """One listing supplies every linked worktree's distinct HEAD before the graph cut.""" + listed = Repo.git(self.checkout.common_dir, "worktree", "list", "--porcelain", "-z") + return {head.checkout.path: head for head in ( + GitHead.listed(record) for record in listed.split("\0\0") + if record and "bare" not in record.split("\0"))} + + def require_read(self) -> dict[str, GitReference]: + """Read the ref cut, propagating unreadable-checkout errors to its caller.""" + return self.refs + + def head_for(self, checkout: Checkout) -> GitHead: + """Select by the worktree identity; shared refs cannot answer its HEAD.""" + return self.heads[checkout.resolved().path] + + def include(self, *heads: str) -> None: + """Prepare acquired objects before observation; an already read graph stays immutable.""" + self.required.update(heads) + + @cached_property + def graph(self) -> dict[str, GitCommit]: + heads = tuple(dict.fromkeys(( + *self.required, *(reference.commit for reference in self.refs.values()), + *(sha for head in self.heads.values() for sha in head.required()), + ))) + if not heads: + return {} + text = Repo.git(self.checkout.common_dir, "rev-list", "--stdin", "--no-commit-header", + "--format=%H%x00%P%x00%ct%x00%B%x00", + input="".join(f"{head}\n" for head in heads)) + commits = {} + for row in text.split("\0\n"): + if not row: + continue + sha, parents, at, message = row.split("\0", 3) + commit = GitCommit( + GitCommitText.decode(sha), + tuple(map(GitCommitText.decode, parents.split())), float(at), message, + ) + commits[commit.sha] = commit + return commits + + def resolve(self, revision: str) -> str: + """Resolve captured ref names or an unambiguous captured commit abbreviation.""" + revision = revision.removesuffix("^{commit}") + for name in (revision, f"refs/heads/{revision}", f"refs/remotes/{revision}"): + if name in self.refs: + return self.refs[name].commit + if revision in self.graph: + return revision + matches = tuple(sha for sha in self.graph if sha.startswith(revision)) + if len(matches) != 1: + raise UnavailableCommitError(self.checkout.path, (revision,)) + return matches[0] + + def commit(self, revision: str) -> GitCommit: + return self.graph[self.resolve(revision)] + + def references(self, prefix: str) -> tuple[str, ...]: + return tuple(name for name in self.refs if name.startswith(prefix)) + + @cached_property + def positions(self) -> dict[str, int]: + return {sha: position for position, sha in enumerate(self.graph)} + + @cached_property + def records(self) -> tuple[GitCommit, ...]: + return tuple(self.graph.values()) + + def bit(self, sha: str) -> int: + return 1 << self.positions[sha] + + def selected(self, bits: int) -> Iterator[GitCommit]: + while bits: + first = bits & -bits + yield self.records[first.bit_length() - 1] + bits ^= first + + def ancestors(self, *revisions: str) -> int: + """Memoized compact reachability; shallow boundaries are graph leaves.""" + selection = frozenset(self.resolve(revision) for revision in revisions) + if selection not in self._ancestry: + pending = list(selection) + found = 0 + while pending: + sha = pending.pop() + bit = self.bit(sha) + if found & bit: + continue + known = self._ancestry.get(frozenset((sha,)), 0) + if known: + found |= known + continue + found |= bit + pending.extend(parent for parent in self.graph[sha].parents + if parent in self.graph) + self._ancestry[selection] = found + return self._ancestry[selection] + + def contains(self, ancestor: str, revision: str) -> bool: + return bool(self.ancestors(revision) & self.bit(self.resolve(ancestor))) + + def unpublished(self, head: str, tracking: str = "") -> int: + """The whole remote selection has one closure, not one DFS for each remote ref.""" + if tracking not in self._remote_selections: + self._remote_selections[tracking] = self.ancestors(*( + ref for ref in self.references("refs/remotes/") if ref != tracking)) + return (self.ancestors(head) & ~self._remote_selections[tracking]).bit_count() + + def walk(self, selection: CommitSelection) -> tuple[GitCommit, ...]: + """Traverse each selection once; problem filters share the resulting commit order.""" + if selection not in self._walks: + seen = self.ancestors(*selection.excluded) + pending: list[tuple[float, int, str]] = [] + sequence = 0 + for ref in selection.refs: + commit = self.commit(ref) + heapq.heappush(pending, (-commit.at, sequence, commit.sha)) + sequence += 1 + commits: list[GitCommit] = [] + while pending: + _at, _sequence, sha = heapq.heappop(pending) + bit = self.bit(sha) + if seen & bit: + continue + seen |= bit + commit = self.graph[sha] + commits.append(commit) + parents = commit.parents[:1] if selection.first_parent else commit.parents + for parent in parents: + if parent in self.graph and not seen & self.bit(parent): + heapq.heappush(pending, (-self.graph[parent].at, sequence, parent)) + sequence += 1 + self._walks[selection] = tuple(commits) + return self._walks[selection] + + def commits(self, refs: tuple[str, ...], *excluded: str, + pattern: str = "", first_parent: bool = False) -> Iterator[GitCommit]: + """Filter a captured newest-first walk without traversing the graph again.""" + matching = re.compile(pattern, re.IGNORECASE | re.MULTILINE) + return (commit for commit in self.walk(CommitSelection(refs, excluded, first_parent)) + if matching.search(commit.message)) + + @cached_property + def children(self) -> dict[str, tuple[str, ...]]: + """Each captured commit's children, inverted once from the parent edges.""" + children: dict[str, list[str]] = {sha: [] for sha in self.graph} + for commit in self.records: + for parent in commit.parents: + if parent in children: + children[parent].append(commit.sha) + return {sha: tuple(shas) for sha, shas in children.items()} + + def merge_base(self, left: str, right: str) -> tuple[str, ...]: + """Best common ancestors: common commits with no child in common. + + The common set is ancestor-closed, so a best base is either a tip or the parent of a + commit only one side reaches; only those few candidates are tested, never the whole + shared history. + """ + tips = (self.resolve(left), self.resolve(right)) + common = self.ancestors(tips[0]) & self.ancestors(tips[1]) + if common not in self._bases: + candidates = dict.fromkeys((*tips, *( + parent for commit in self.selected( + self.ancestors(tips[0]) ^ self.ancestors(tips[1])) + for parent in commit.parents if parent in self.graph))) + best = tuple(self.graph[sha] for sha in candidates + if common & self.bit(sha) + and not any(common & self.bit(child) for child in self.children[sha])) + self._bases[common] = tuple(commit.sha for commit in sorted( + best, key=lambda commit: commit.at, reverse=True)) + return self._bases[common] + + def count(self, head: str, *excluded: str) -> int: + return (self.ancestors(head) & ~self.ancestors(*excluded)).bit_count() + + @dataclass(frozen=True) class SubmoduleTransfer: """An initialized module's own Git directory and its original worktree setting.""" diff --git a/src/agent_comms/rule_deal.py b/src/agent_comms/rule_deal.py index 4baac53f9e..b48b3d21d0 100644 --- a/src/agent_comms/rule_deal.py +++ b/src/agent_comms/rule_deal.py @@ -453,6 +453,18 @@ def reason(self) -> str: return "Re-dealt because this session was cleared: its context is empty." +@dataclass(frozen=True) +class PrimedDeal(DealTrigger): + """The owner asked for the rules again with /prime; the variant says which readers dealt.""" + + variant: str + """The /prime variant as the owner spells it (full, no-papers, no-indictment, light).""" + + def reason(self) -> str: + return (f"Re-dealt on purpose: the owner ran /prime ({self.variant}). Nothing broke and" + " nothing compacted; he wants these read fresh, now.") + + @dataclass(frozen=True) class RefreshDeal(DealTrigger): """A declared share of the context window filled before any compaction.""" diff --git a/src/agent_comms/skills/prime/SKILL.md b/src/agent_comms/skills/prime/SKILL.md new file mode 100644 index 0000000000..8d5f9d4528 --- /dev/null +++ b/src/agent_comms/skills/prime/SKILL.md @@ -0,0 +1,20 @@ +--- +name: prime +description: Deal this session the fleet's papers and rules again, through the rule gate, when Tristan says /prime or that the agents are misaligned or ignoring him. Variants full (default), no-papers, no-indictment, light; `--to THREAD...` primes native fleet threads instead. +--- + +# Prime + +Tristan calls /prime when a session has drifted back to its own frame. It is a fresh deal, not a reminder: run it, then do what it prints. + +1. Run in Bash, choosing the variant he named (default `full`): + `agent-comms-prime --variant full|no-papers|no-indictment|light` + - `full`: the papers, every rule, and for a commander the indictment. The same deal as a compaction. + Every variant keeps every rule, including the closing rules dealt last. + - `no-papers`: the rules without the papers, for when the papers are still in context. + - `no-indictment`: the papers and rules without the indictment. + - `light`: the rules alone. Use it mid-context so the window doesn't fill. +2. The gate now refuses every tool except reading the dealt files and writing the audit. Read each printed file in full, in the printed order, and answer each rule file in the audit (whole greentext lines verbatim, what they did, why it holds, what you broke). How many read rules may stand unanswered before nothing new opens is the gate's window, `Unengaged.batch` in `agent_comms/rule_deal.py`; its refusal names the number when you reach it. A paper carries no rule, so its parts open together. +3. Answer every quoted prompt of Tristan's in the audit's `owner_words`, then do what he asked. + +To prime the fleet: `agent-comms-prime --variant light --to UNIT [UNIT...]` deals each named native (Pi) thread through Core's gate. A Claude session is refused there; tell that session to run /prime itself. diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index ecb3868971..fba0b3045d 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -803,7 +803,7 @@ class ReasonedStandingRule(StandingRule, declared_name=None): @classmethod @abstractmethod - def reason(cls, comms: Comms, commander: str) -> str | None: ... + def reason(cls, comms: Comms, commander: str, frame: HeartbeatFrame) -> str | None: ... @classmethod def found(cls, why: str, comms: Comms, commander: str, @@ -813,7 +813,7 @@ def found(cls, why: str, comms: Comms, commander: str, @classmethod def scan_observed(cls, comms: Comms, commander: str, observed: Sequence[UnitState], frame: HeartbeatFrame) -> tuple[Self, ...]: - why = cls.reason(comms, commander) + why = cls.reason(comms, commander, frame) return (cls.found(why, comms, commander, observed, frame),) if why else () def line(self) -> str: @@ -1181,7 +1181,7 @@ def hunted(cls, comms: Comms, commander: str) -> float: return max((hunt.at.recorded_at("A hunt") for hunt in hunts), default=0) @classmethod - def reason(cls, comms: Comms, commander: str) -> str | None: + def reason(cls, comms: Comms, commander: str, frame: HeartbeatFrame) -> str | None: age = time.time() - cls.hunted(comms, commander) return f"no hunt in the last {cls.seconds // 60} min" if age > cls.seconds else None diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index f07a82f7a5..64cbb07ee4 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -1,37 +1,47 @@ from __future__ import annotations +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping +from dataclasses import dataclass, replace +from functools import singledispatchmethod + from pydantic import JsonValue -from typing import cast from agent_comms import field_codec -from agent_comms.field_codec import FieldCodec -from collections.abc import Callable -from dataclasses import dataclass -from dataclasses import replace +from agent_comms.field_codec import FieldCodec, boundary_decoder from agent_comms.config_options import CatalogChoice, CatalogConfigOption, ConfigOptions from agent_comms.thread_model import CatalogSelection @dataclass(frozen=True) -class TargetField: - """The declared field owns parsing, editor hints and requiredness.""" +class TargetField(ABC): + """The declared field owns parsing, editor hints and input presence.""" plan: field_codec.FieldPlan - value: object @classmethod def of(cls, plan: field_codec.FieldPlan, value: object) -> TargetField: - editor: type[TargetField] = ( + editor: type[SuppliedTargetField] = ( CatalogTargetField if field_codec.Catalog.single(plan.annotation) - else plan.setting(field_codec.Editor, cls) + else plan.setting(field_codec.Editor, SuppliedTargetField) ) return editor(plan, value) + @classmethod + def omitted(cls, plan: field_codec.FieldPlan) -> TargetField: + if plan.constructor_required: + return UnsuppliedTargetField(plan) + return cls.of(plan, plan.default()) + @property def annotation(self) -> object: return self.plan.annotation + @property + def editor_annotation(self) -> object: + return self.plan.value.editor_wire.native_editor_annotation + @property def name(self) -> str: return self.plan.key @@ -45,16 +55,40 @@ def description(self) -> str: return self.plan.description @property - def editor_default(self) -> JsonValue: - return self.plan.editor_default(self.value) + def editor_members(self) -> tuple[field_codec.EditorMember, ...]: + return self.plan.value.editor_wire.editor_members + + @property + def editor_selections(self) -> tuple[field_codec.MemberSelection, ...]: + return self.editor_members + + def editor_selection(self, value: object) -> field_codec.MemberSelection: + return self.plan.value.editor_wire.selected_editor_member(value) + + @property + @abstractmethod + def editor_initial(self) -> object: ... + + @abstractmethod + def default_schema(self) -> dict[str, JsonValue]: ... + + def argument(self, value: object) -> Mapping[str, object]: + return {self.name: self.editor_argument(value)} + + def editor_argument(self, value: object) -> object: + return self.plan.value.editor_wire.editor_argument(value) def editor_hints(self, choices: Callable[[ChoiceTargetField], object]) -> tuple[object, ...]: """An unrestricted field does not declare a finite-choice editor.""" return () - async def prepare(self, catalogs: ConfigOptions, selection: object) -> TargetField: + async def prepare(self, catalogs: ConfigOptions) -> TargetField: return self + async def prepare_catalog(self, catalogs: ConfigOptions, + selection: CatalogSelection) -> TargetField: + return await self.prepare(catalogs) + @property def required(self) -> bool: return self.plan.constructor_required @@ -64,13 +98,78 @@ def value_schema(self) -> dict[str, JsonValue]: def json_schema(self) -> dict[str, JsonValue]: return {**self.value_schema(), 'multiline': self.multiline, - 'description': self.description, 'editor_default': self.editor_default} + 'description': self.description, **self.default_schema()} + @dataclass(frozen=True) -class ChoiceTargetField(TargetField): +class SuppliedTargetField(TargetField): + value: object + + @property + def editor_initial(self) -> object: + return self.plan.value.editor_initial(self.value) + + @property + def editor_default(self) -> str: + return self.plan.value.encode_editor(self.value) + + def default_schema(self) -> dict[str, JsonValue]: + return {'editor_default': self.editor_default} + + +@dataclass(frozen=True) +class UnsuppliedTargetField(TargetField): + """A required answer has no value; the optional editor owns its unset control.""" + + @property + def editor_annotation(self) -> object: + return self.plan.value.editor_wire.native_editor_annotation | type(None) + + @property + def editor_initial(self) -> object: + return None + + def default_schema(self) -> dict[str, JsonValue]: + return {} + + @property + def editor_selections(self) -> tuple[field_codec.MemberSelection, ...]: + return (field_codec.UnchosenMember(), *super().editor_selections) + + @singledispatchmethod + @boundary_decoder + def editor_selection(self, value: object) -> field_codec.MemberSelection: + return super().editor_selection(value) + + @editor_selection.register + @boundary_decoder + def _unchosen_selection(self, value: None) -> field_codec.MemberSelection: + return field_codec.UnchosenMember() + + def argument(self, value: object) -> Mapping[str, object]: + return self.editor_selection(value).argument(self.name, value, self.editor_argument) + + +@dataclass(frozen=True) +class ChoiceTargetField(SuppliedTargetField): """A prepared finite declaration, from its annotation or configuration catalog.""" options: tuple[CatalogChoice, ...] = () + @property + def editor_annotation(self) -> object: + return self.plan.value.editor_wire.editor_annotation + + @property + def editor_members(self) -> tuple[field_codec.EditorMember, ...]: + return () + + @property + def editor_initial(self) -> object: + return self.editor_default + + def editor_argument(self, value: object) -> object: + return value + @property def editor_declarations(self) -> tuple[object, ...]: return () @@ -79,8 +178,7 @@ def editor_declarations(self) -> tuple[object, ...]: def choices(self) -> tuple[tuple[str, str], ...]: return tuple((choice.name, choice.editor_value) for choice in self.options) - async def prepare(self, catalogs: ConfigOptions, - selection: object) -> ChoiceTargetField: + async def prepare(self, catalogs: ConfigOptions) -> ChoiceTargetField: decoder = field_codec.compile_decoder(self.annotation) options = (CatalogChoice( name=label, value=decoder.encode_editor(decoder.decode(value))) @@ -90,6 +188,7 @@ async def prepare(self, catalogs: ConfigOptions, def editor_hints(self, choices: Callable[[ChoiceTargetField], object]) -> tuple[object, ...]: return (choices(self),) + class CatalogTargetField(ChoiceTargetField): @property def catalog(self) -> type[CatalogConfigOption]: @@ -99,10 +198,10 @@ def catalog(self) -> type[CatalogConfigOption]: def editor_declarations(self) -> tuple[object, ...]: return (self.catalog,) - async def prepare(self, catalogs: ConfigOptions, - selection: object) -> CatalogTargetField: + async def prepare_catalog(self, catalogs: ConfigOptions, + selection: CatalogSelection) -> CatalogTargetField: catalog = catalogs.catalog_for(self.catalog) - options = await catalog.display_choices(cast(CatalogSelection, selection)) + options = await catalog.display_choices(selection) decoder = field_codec.compile_decoder(self.annotation) return replace(self, options=tuple(CatalogChoice(name=item.name, value=decoder.encode_editor(decoder.decode(item.value))) for item in options)) 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 33b5bde8fa..fcbf345882 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -6,7 +6,10 @@ from __future__ import annotations +from .thread_name import ThreadName + from .class_fact import ClassFact +from .thread_creation import CreationSeconds, CreationTime, RequiredCreationTime import hashlib import math from abc import ABC, abstractmethod @@ -24,7 +27,7 @@ UnknownFamilyMember, declared_words, ) -from .errors import RelationViolationError +from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import ( DisclosureIdentity, FieldCodec, @@ -191,7 +194,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: @@ -209,15 +212,24 @@ 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 not original.current(snapshot): + 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): 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 + return snapshot.registered(self.name) and snapshot.incarnation(self.name) == self def recorded_or( self, make: Callable[[RecordedThreadIncarnation], T], otherwise: Callable[[], T] @@ -255,8 +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. """ - thread = snapshot.threads.get(snapshot.canonical_name(name)) - return thread is not None and self.resolved(snapshot) == thread.incarnation + 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.""" @@ -434,25 +445,47 @@ 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 + def __post_init__(self) -> None: + ThreadName.require(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 - 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): + """A registered declaration retains its recorded/generated creation-clock capability.""" + + 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, + )) + super().__post_init__() @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/thread_management.py b/src/agent_comms/thread_management.py index f1bcfd79ae..247cc9946a 100644 --- a/src/agent_comms/thread_management.py +++ b/src/agent_comms/thread_management.py @@ -4,7 +4,7 @@ import asyncio import re -from abc import abstractmethod +from abc import ABC, abstractmethod from collections.abc import Sequence from contextlib import ExitStack from dataclasses import dataclass, field, replace @@ -76,9 +76,6 @@ class RenameThreadResult(ToolResult): class TagChoice(SubclassUnion, affix="Tags"): """Select explicit, inherited or changed tags from an existing set.""" - def for_new(self) -> frozenset[str]: - return self.for_child(frozenset()) - @abstractmethod def for_child(self, parent: frozenset[str]) -> frozenset[str]: ... @@ -148,27 +145,36 @@ def local_directory(self) -> Path: @dataclass(frozen=True, slots=True, kw_only=True) -class NewThreadSpec: - """Prepare the session, then register the thread under its registry lock.""" +class ThreadCreationSpec(ABC): + """Shared launch settings and construction after membership has been decided.""" name: str project: LaunchCheckout model: ThreadModel thinking_level: ThinkingLevelField task: LaunchInput = field(default_factory=LaunchInput) - tags: TagChoice = InheritedTags() @classmethod - def from_task(cls, *, task: str = "", **values) -> NewThreadSpec: + def from_task(cls, *, task: str = "", **values) -> Self: return cls(task=TaskLaunchInput(task) if task else LaunchInput(), **values) - def declaration(self) -> Thread: - return Thread(name=self.name, task=self.task.thread_text(), tags=self.tags.for_new(), + def declaration(self, tags: frozenset[str]) -> Thread: + return Thread(name=self.name, task=self.task.thread_text(), tags=tags, worktree=self.project, model=self.model, thinking_level=self.thinking_level) + @abstractmethod + def declare(self, threads: ThreadManagement, pi_bin: str, runtime: LaunchRuntime) -> Thread: ... + + +@dataclass(frozen=True, slots=True, kw_only=True) +class NewThreadSpec(ThreadCreationSpec): + """A new thread has no parent; its creator supplies its membership.""" + + tags: GivenTags + def declare(self, threads: ThreadManagement, pi_bin: str, runtime: LaunchRuntime) -> Thread: project = self.project.path - thread = self.declaration() + thread = self.declaration(self.tags.tags) threads.registry.require_declaration_available(thread) location = thread.runtime.new_location(threads.root, project) saved = RegistrySessionLocation.from_path(location.read_key, threads.root) @@ -176,10 +182,11 @@ def declare(self, threads: ThreadManagement, pi_bin: str, runtime: LaunchRuntime @dataclass(frozen=True, slots=True, kw_only=True) -class ForkSpec(NewThreadSpec): +class ForkSpec(ThreadCreationSpec): """A fork differs only in its parent-derived source and inherited settings.""" parent: str + tags: TagChoice = InheritedTags() def _parent(self, threads: ThreadManagement) -> Thread: parent = threads.registry.require(self.parent) @@ -189,8 +196,8 @@ def _parent(self, threads: ThreadManagement) -> Thread: def child(self, parent: Thread, runtime: RuntimeLocation, threads: ThreadManagement) -> Thread: saved = RegistrySessionLocation.from_path(runtime.read_key, threads.root) - return replace(self.declaration(), - parent=ForkedParent(parent.name), tags=self.tags.for_child(DeclaredTag.for_child(parent.tags)), + return replace(self.declaration(self.tags.for_child(DeclaredTag.for_child(parent.tags))), + parent=ForkedParent(parent.name), backend=parent.backend, session_file=saved) def declare(self, threads: ThreadManagement, pi_bin: str, runtime: LaunchRuntime) -> Thread: @@ -559,7 +566,7 @@ def _delete_originals(self, originals: Sequence[Thread]) -> tuple[ThreadDeleted, for original in originals ) - def create(self, spec: NewThreadSpec) -> Thread: + def create(self, spec: ThreadCreationSpec) -> Thread: """Prepare the session, then register and launch the thread with its task as first input.""" resolved_bin = ConfiguredBinary().configured() runtime = LaunchRuntime.running() diff --git a/src/agent_comms/thread_membership.py b/src/agent_comms/thread_membership.py new file mode 100644 index 0000000000..65e694b58d --- /dev/null +++ b/src/agent_comms/thread_membership.py @@ -0,0 +1,26 @@ +"""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 carries(self, tags: frozenset[str]) -> bool: + return tags <= self.tags + + def __post_init__(self) -> None: + super().__post_init__() + for tag in self.tags: + Tag.validate(tag) diff --git a/src/agent_comms/thread_name.py b/src/agent_comms/thread_name.py new file mode 100644 index 0000000000..65c4f3f251 --- /dev/null +++ b/src/agent_comms/thread_name.py @@ -0,0 +1,14 @@ +"""The spelling rule shared by thread declarations and addressed targets.""" + +from typing import ClassVar + + +class ThreadName: + characters: ClassVar[frozenset[str]] = frozenset( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" + ) + + @classmethod + def require(cls, name: str) -> None: + if not name or not set(name) <= cls.characters: + raise ValueError(f"Thread name {name!r} must be alphanumeric with hyphens/underscores.") diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index ebcbd7c4ac..01f57570ba 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -4,13 +4,14 @@ 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 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 .context_segments.wake_values import WakeWorkContext from .declared_family import DeclaredFamily @@ -19,6 +20,7 @@ Argument, Description, FieldCodec, + JsonValue, NullMember, RegistrationPolicy, SingleFieldValue, @@ -28,6 +30,7 @@ WireRequired, ) from .goals import ( + Goal, GoalDeclaration, NoGoal, ) @@ -43,25 +46,25 @@ ) from .registration_inheritance import ( InheritApplied, - InheritEmpty, InheritPrevious, ) from .repo import Checkout from .session_file import NoSessionFile, SessionFile 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, + ThreadBirthRecord, ThreadIncarnation, - ThreadIncarnationRecord, ThreadRole, TurnFence, TurnIdentity, UnobservedTools, ) +from .thread_membership import ThreadMembership from .thread_model import ( CatalogSelection, DeclaredModel, @@ -187,13 +190,10 @@ def configured(self, thread: Thread) -> Thread: @dataclass(frozen=True, kw_only=True) -class ThreadProvenance: +class ThreadProvenance(ThreadMembership): """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 ) @@ -218,19 +218,6 @@ 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) - for tag in self.tags: - Tag.validate(tag) - @staticmethod def capture(thread: ThreadProvenance) -> ThreadProvenance: """Freeze only determining provenance from the original typed declaration.""" @@ -242,11 +229,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() - @property def acting_identity(self) -> ThreadIncarnation: """Who this thread's own tool calls act as: a #commanders member holds commander @@ -264,8 +246,21 @@ class ThreadCatalogFile(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.""" def answering_thread(self, reader: Thread) -> Thread: @@ -289,10 +284,7 @@ def answering_thread(self, reader: Thread) -> Thread: service_tier: Annotated[ServiceTier, RegistrationPolicy(InheritPrevious)] = ( field(default_factory=ServiceTier.default, 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[ @@ -313,9 +305,6 @@ def answering_thread(self, reader: Thread) -> Thread: 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 ) @@ -655,7 +644,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) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index e86e86cf16..0bfcbfec46 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -16,7 +16,7 @@ import sys import time from abc import ABC, abstractmethod -from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence +from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, Sequence from contextlib import contextmanager from dataclasses import dataclass, field, fields, replace from datetime import UTC, datetime @@ -133,7 +133,7 @@ UnrecordedEventTime, ) from agent_comms.registry_store import RegistryStore -from agent_comms.repo import Checkout, GitCommandError, Repo +from agent_comms.repo import Checkout, GitCommandError, Repo, RepositoryReads from agent_comms.repository_name import RepositoryName from agent_comms.slop import DeclarationDerivation, Slop from agent_comms.storage_rules import EditWorkplaces, StorageRule, UnitStorage @@ -1018,10 +1018,11 @@ class PullRequestUpdatePage: page_info: PageInfo -class PullRequests: - """Forge facts and checkout-local worktrees, cached within one board read.""" +class PullRequests(RepositoryReads): + """Forge facts and repository reads shared within one board read.""" def __init__(self, todos: Sequence[Todo] = ()) -> None: + super().__init__() self._read: dict[tuple[RepositoryName, int], ListedPullRequest] = {} self._open: dict[RepositoryName, tuple[int, ...]] = {} self._worktrees: dict[Path, Mapping[str, tuple[str, ...]]] = {} @@ -1126,7 +1127,7 @@ def worktrees(self, repository: Repo) -> Mapping[str, tuple[str, ...]]: """One canonical local checkout's retained listing, not its forge namesake's.""" checkout = Checkout(repository.checkout).resolved().path if checkout not in self._worktrees: - self._worktrees[checkout] = WorkBranches(checkout).worktrees() + self._worktrees[checkout] = WorkBranches(checkout, reads=self).worktrees() return self._worktrees[checkout] def working_on(self, goals: Goals, repo: RepositoryName, @@ -1135,7 +1136,7 @@ def working_on(self, goals: Goals, repo: RepositoryName, when no order says so. One worktree read per local checkout in this frame.""" snapshot = goals.registry.snapshot() repository = Repo.selected(snapshot.commander_project.require().repos, repo.to_wire()) - reader = WorkBranches(repository.checkout) + reader = WorkBranches(repository.checkout, reads=self) return tuple(dict.fromkeys( snapshot.require(name).incarnation for checkout in reader.checkouts(snapshot, row.branch, self.worktrees(repository)) @@ -1145,7 +1146,7 @@ def carry_reviews(self, goals: Goals, repo: RepositoryName) -> None: """Advance the frame's forge facts only after the existing verdict posts its carry.""" project = goals.registry.snapshot().commander_project.require() repository = Repo.selected(project.repos, repo.to_wire()) - reader = WorkBranches(repository.checkout) + reader = WorkBranches(repository.checkout, reads=self) for row in self.open(repo): if not row.has_head_review: current = ReadPullRequestHead(repo, row.number, row.head, base=row.base) @@ -1246,25 +1247,6 @@ def listed(cls, line: str) -> ProgressPoint: return cls(sha, float(at)) -@dataclass(frozen=True) -class LoggedCommit: - """One commit of a frame's history walk with the message every problem pattern greps.""" - point: ProgressPoint - message: str - - format: ClassVar[str] = f"{ProgressPoint.format}%x00%B%x00" - - @classmethod - def walked(cls, output: str) -> tuple[LoggedCommit, ...]: - fields = output.split("\0") - return tuple(cls(ProgressPoint.listed(head), message) - for head, message in zip(fields[0::2], fields[1::2]) if head.strip()) - - def matches(self, pattern: re.Pattern[str]) -> bool: - """Git's --grep: the pattern searched line by line, ignoring case.""" - return bool(pattern.search(self.message)) - - @dataclass(frozen=True) class DistanceReading(DisclosedRecord): at: float @@ -1372,6 +1354,14 @@ def pushes(self, pattern: str) -> Iterator[tuple[str, float]]: ... def require_read(self) -> None: """A retired checkout needs no local read; present and unread ones decide.""" + def remote_refs(self) -> tuple[str, ...]: + """Every published remote reference this history holds; a retired checkout has none.""" + return () + + def unpublished_commits(self, todo: Todo, refs: tuple[str, ...]) -> tuple[ProgressPoint, ...]: + """Problem commits no remote reference reaches.""" + return self.read_commits(todo, refs, *self.remote_refs()) + def read_commits(self, todo: Todo, refs: tuple[str, ...], *excluded: str) -> tuple[ProgressPoint, ...]: """Consume this capture's selected refs inside its checkout failure boundary.""" @@ -1430,27 +1420,29 @@ def pushes(self, pattern: str) -> Iterator[tuple[str, float]]: raise WorkBranchError(self.reason) +@dataclass(frozen=True) class PresentCheckout(ProgressCheckout): + reads: RepositoryReads = field(default_factory=RepositoryReads, compare=False, repr=False, + kw_only=True) default_reference: ClassVar[str] = f"refs/remotes/{Repo.remote}/HEAD" @property def default_refs(self) -> tuple[str, ...]: return (self.default_reference,) if self.default_reference in self.origin_refs else () - def git(self, *args: str) -> str: - return Repo.git(self.path, *args) - @cached_property def head(self) -> str: - return self.git("rev-parse", "--verify", "HEAD^{commit}") + return self.branches.head.revision() def require_read(self) -> None: self.head + def remote_refs(self) -> tuple[str, ...]: + return self.branches.history.references("refs/remotes/") + @cached_property def origin_refs(self) -> tuple[str, ...]: - return tuple(self.git("for-each-ref", "--format=%(refname)", - f"refs/remotes/{Repo.remote}/").splitlines()) + return self.branches.history.references(f"refs/remotes/{Repo.remote}/") @cached_property def origin_pushes(self) -> tuple[tuple[str, float], ...]: @@ -1461,7 +1453,7 @@ def origin_pushes(self) -> tuple[tuple[str, float], ...]: if not self.origin_refs: return () pushes = [] - for row in self.git("reflog", "show", "--date=unix", "--grep-reflog=update by push", + for row in Repo.git(self.path, "reflog", "show", "--date=unix", "--grep-reflog=update by push", "--format=%gD%x09%H", *self.origin_refs).splitlines(): selector, sha = row.split("\t") pushes.append((sha, float(selector.rsplit("@{", 1)[1].removesuffix("}")))) @@ -1470,7 +1462,7 @@ def origin_pushes(self) -> tuple[tuple[str, float], ...]: @cached_property def branches(self) -> WorkBranches: """One Git branch reader for this checkout during the progress frame.""" - return WorkBranches(self.path) + return WorkBranches(self.path, reads=self.reads) @cached_property def check_notes(self) -> dict[str, tuple[CheckedCommit, ...]]: @@ -1489,26 +1481,12 @@ def references(self, storage: StorageRule, owner: Thread) -> tuple[str, ...]: for checkout in map(Path, paths))) return (*owned, *self.origin_refs) - @cached_property - def walks(self) -> dict[tuple[tuple[str, ...], tuple[str, ...]], tuple[LoggedCommit, ...]]: - """This frame's history walks, one per selection of refs; every order greps them.""" - return {} - - def walk(self, refs: tuple[str, ...], excluded: tuple[str, ...]) -> tuple[LoggedCommit, ...]: - if (refs, excluded) not in self.walks: - self.walks[refs, excluded] = LoggedCommit.walked(self.git( - "log", f"--format={LoggedCommit.format}", *refs, "--not", *excluded)) - return self.walks[refs, excluded] - def commits(self, pattern: str, refs: tuple[str, ...], *excluded: str) -> Iterator[ProgressPoint]: """Newest problem commit across owned branches and retained origin references.""" - if refs: - matcher = re.compile(pattern, re.IGNORECASE | re.MULTILINE) - for commit in self.walk(refs, excluded): - if commit.matches(matcher): - yield commit.point - return + for commit in self.branches.history.commits(refs, *excluded, pattern=pattern): + yield ProgressPoint(commit.sha, commit.at) + return def checked_commits(self, order: CheckedProblemOrder, refs: tuple[str, ...], *excluded: str) -> Iterator[ProgressPoint]: @@ -1532,11 +1510,14 @@ def readings(self, source: ProgressSource, refs: tuple[str, ...]) -> DistanceRea return DistanceReadings() readings: list[DistanceReading] = [] for tip in self.read_commits(source.todo, refs, *self.default_refs): - base = self.git("merge-base", tip.sha, self.default_reference).strip() - commits = [ProgressPoint.listed(line) for line in reversed(self.git( - "log", f"--max-count={DistanceReadings.window}", "--first-parent", - f"--format={ProgressPoint.format}", tip.sha, "--not", base).splitlines())] - points = [ProgressPoint(base, source.since), *commits][-DistanceReadings.window:] + bases = self.branches.history.merge_base(tip.sha, self.default_reference) + if not bases: + raise WorkBranchError(f"Unrelated progress and main in {self.path}") + base = bases[0] + commits = [ProgressPoint(commit.sha, commit.at) for commit in islice( + self.branches.history.commits((tip.sha,), base, first_parent=True), + DistanceReadings.window)] + points = [ProgressPoint(base, source.since), *reversed(commits)][-DistanceReadings.window:] readings.extend(reading for point in points for reading in self.recorded_distances( source.todo.order.checked(), base, point)) return DistanceReadings(tuple(readings)) @@ -1545,7 +1526,7 @@ def recorded_distances(self, order: CheckedProblemOrder, base: str, point: ProgressPoint) -> Iterator[DistanceReading]: """Read the check run result; without a recorded result the distance is unknown.""" for run in order.check.recorded_among(self.stored(point.sha)): - touched = self.git("diff", "--name-only", base, point.sha).split() + touched = self.branches.changed(base, point.sha) yield DistanceReading(point.at, order.check.result.distance(run).plus( order.untouched(touched)), order.check.command) @@ -1555,11 +1536,11 @@ def pushes(self, pattern: str) -> Iterator[tuple[str, float]]: return ((sha, at) for sha, at in self.origin_pushes if sha in published) -class CheckoutReads: - """Share checkout readers within one frame; each capture selects its refs once, - while retained commit points and push reflogs belong to that frame alone.""" +class CheckoutReads(RepositoryReads): + """Share repository histories and distinct worktree readers within one frame.""" def __init__(self) -> None: + super().__init__() self._checkouts: dict[Path, PresentCheckout] = {} self._branch_mains: dict[RepositoryName, str] = {} self._branch_facts: dict[Path, BranchDifference] = {} @@ -1567,7 +1548,8 @@ def __init__(self) -> None: def checkout(self, path: Path) -> PresentCheckout: """This read's one reader of the checkout at path.""" - return self._checkouts.setdefault(path, PresentCheckout(path)) + path = path.resolve() + return self._checkouts.setdefault(path, PresentCheckout(path, reads=self)) def progress_checkout(self, path: Path) -> ProgressCheckout: """The checkout at path, or a retired one that holds no history: this read alone @@ -1620,8 +1602,7 @@ class ProgressRead(PullRequests, CheckoutReads): """One read of order progress: its GitHub facts and its checkouts' Git facts.""" def __init__(self, todos: Sequence[Todo] = ()) -> None: - CheckoutReads.__init__(self) - PullRequests.__init__(self, todos) + super().__init__(todos) self._progress: dict[str, OrderProgress] = {} def unread(self, todos: Iterable[Todo], error: Exception) -> None: @@ -1731,7 +1712,7 @@ def from_refs(cls, todo: Todo, pulls: ProgressRead, checkout: ProgressCheckout, worktrees: tuple[PresentCheckout, ...], refs: tuple[str, ...]) -> Self: source = super().from_refs(todo, pulls, checkout, worktrees, refs) return replace(source, - unpublished_commits=checkout.read_commits(todo, refs, "--remotes"), + unpublished_commits=checkout.unpublished_commits(todo, refs), recorded_readings=checkout.readings(source, refs)) @@ -2792,7 +2773,7 @@ def delivery_rates(self) -> Iterator[tuple[str, float]]: """Undelivered orders provide no observed delivery duration.""" return iter(()) - def untouched(self, touched: Sequence[str]) -> int: + def untouched(self, touched: Collection[str]) -> int: """The numbered edits naming a file:line where ``touched`` changed none of those files.""" def changed(name: str) -> bool: return any(path.endswith(name.lstrip("/")) or name.endswith("/" + path) @@ -5948,7 +5929,9 @@ def captured_facts_for(cls, db: sqlite3.Connection, problems: tuple[str, ...]) - class TodoHistory: """Shared history queries; a board snapshot overrides only its aggregate lookup.""" - def phases_checked(self, commanders: frozenset[ThreadIncarnation]) -> dict[type[OrderPhase], float]: + def phases_checked( + self, commanders: frozenset[ThreadIncarnation], + ) -> dict[type[OrderPhase], float]: """The latest checked declaration per phase, including declarations retained by folds. Decode the recursive order envelope in SQL, then aggregate only checked declarations; @@ -6082,7 +6065,8 @@ def for_request(self, request: OwnerRequestId) -> tuple[Todo, ...]: def _select(self, where: str, parameters: tuple, *, reader: Callable[[sqlite3.Connection, str, tuple], CapturedRows[Todo]] = Todo.capture_rows, ) -> tuple[Todo, ...]: - """Capture selected rows and their aggregates together; decode after releasing the snapshot.""" + """Capture selected rows and their aggregates together; + decode after releasing the snapshot.""" if not self.path.exists(): return () with self.snapshot() as db: diff --git a/src/agent_comms/turn_digests.py b/src/agent_comms/turn_digests.py index 71d3cac965..1b56c279a7 100644 --- a/src/agent_comms/turn_digests.py +++ b/src/agent_comms/turn_digests.py @@ -5,7 +5,7 @@ from agent_comms.field_codec import ActivityExclude from abc import abstractmethod -from collections.abc import Iterable, Iterator, Mapping +from collections.abc import Callable, Iterable, Iterator, Mapping from dataclasses import dataclass, field, replace from pathlib import Path from typing import TYPE_CHECKING, Annotated, ClassVar, Self @@ -188,6 +188,17 @@ def full(self, taken: int) -> bool: return False +@dataclass(frozen=True) +class RecentTurnsSince(TurnsSince): + """Keep the recent turn window as well as every turn since an order's review cut.""" + + recent: LastTurns = field(kw_only=True) + + def take(self, turns: Iterable[TurnDigest]) -> tuple[TurnDigest, ...]: + remaining = iter(turns) + return (*self.recent.take(remaining), *super().take(remaining)) + + @dataclass(frozen=True) class ThreadRead[Result](ConversationPreparation): """The original native/external source selection, shared by typed readers.""" @@ -276,9 +287,12 @@ class OrderEditRead[Result](OrderRead[Result]): workplaces: EditWorkplaces progress: OrderProgress = NoCommitProgress() - def read(self) -> Result: + def when_readable(self, operation: Callable[[], Result]) -> Result: self.progress.require_read() - return super().read() + return operation() + + def read(self) -> Result: + return self.when_readable(super().read) @classmethod def for_commander(cls, comms: Comms, commander: str) -> Iterator[Self]: @@ -318,6 +332,16 @@ def owned(self, edits: Iterable[EditCall]) -> tuple[EditCall, ...]: if edit.id not in judged and self.workplaces.problems_for(edit.path) == frozenset((self.todo.id,))) + @abstractmethod + def project_edits(self, edits: tuple[EditCall, ...]) -> Result: ... + + def from_turns(self, turns: tuple[TurnDigest, ...]) -> Result: + return self.when_readable(lambda: self.project_edits(self.edits_from_turns(turns))) + + def from_external(self, source: StoreRead) -> Result: + return self.when_readable(lambda: self.project_edits( + self.owned(source.unjudged(self.todo, self.bound.at)))) + def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ...]: since = self.bound.at edits = {edit.id: edit for turn in reversed(turns) @@ -331,24 +355,18 @@ def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ... class OrderEditsRead(OrderEditRead[tuple[EditCall, ...]]): """Original unjudged calls; external events are selected before decoding.""" - def from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ...]: - return self.edits_from_turns(turns) - - def from_external(self, source: StoreRead) -> tuple[EditCall, ...]: - return self.owned(source.unjudged(self.todo, self.bound.at)) + def project_edits(self, edits: tuple[EditCall, ...]) -> tuple[EditCall, ...]: + return edits class OrderEditCountRead(OrderEditRead[int]): """Load counts the edits each order owns; ownership reads each edit's path.""" - def read(self) -> int: - return self.progress.unjudged(super().read) + def when_readable(self, operation: Callable[[], int]) -> int: + return self.progress.unjudged(operation) - def from_turns(self, turns: tuple[TurnDigest, ...]) -> int: - return len(self.edits_from_turns(turns)) - - def from_external(self, source: StoreRead) -> int: - return len(self.owned(source.unjudged(self.todo, self.bound.at))) + def project_edits(self, edits: tuple[EditCall, ...]) -> int: + return len(edits) @dataclass(frozen=True) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 691d80a681..17a5c1e688 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -142,6 +142,11 @@ def to_sql(cls, value: object) -> object: def from_sql(cls, value: object) -> object: return value + @classmethod + def json_text(cls, db: sqlite3.Connection, expression: str) -> str: + """The stored value as JSON text, without a domain-object round trip.""" + return f"json_quote({expression})" + @classmethod def for_type(cls, annotation: object) -> type[SqlStorage]: annotation, _ = _base_type(annotation) @@ -181,6 +186,12 @@ class RealStorage(SqlStorage): def accepts(cls, annotation: object) -> bool: return annotation is float + @classmethod + def json_text(cls, db: sqlite3.Connection, expression: str) -> str: + """The codec owns exact float spelling; SQLite's formatters differ.""" + db.create_function(cls.declared_name, 1, FieldCodec.compact, deterministic=True) + return f'"{cls.declared_name}"({expression})' + class BooleanStorage(SqlStorage): sql_type = "INTEGER" @@ -197,6 +208,10 @@ def constraints(cls, column: str, annotation: object = None) -> tuple[str, ...]: def to_sql(cls, value: object) -> int | None: return None if value is None else int(value) + @classmethod + def json_text(cls, db: sqlite3.Connection, expression: str) -> str: + return f"CASE {expression} WHEN 1 THEN 'true' WHEN 0 THEN 'false' ELSE 'null' END" + @classmethod def from_sql(cls, value: object) -> bool: if type(value) is not int or value not in (0, 1): @@ -267,6 +282,10 @@ def accepts(cls, annotation: object) -> bool: def to_sql(cls, value: object) -> str | None: return None if value is None else FieldCodec.compact(value, allow_nan=False) + @classmethod + def json_text(cls, db: sqlite3.Connection, expression: str) -> str: + return f"COALESCE({expression}, 'null')" + @classmethod def from_sql(cls, value: object) -> object: if type(value) is not str: @@ -493,6 +512,20 @@ class _Field(SqlValue): def reference(self, alias: str) -> str: return f"{alias}.{_identifier(self.wire_name)}" + def wire_record(self, db: sqlite3.Connection, plan: FieldPlan, alias: str) -> str: + expression = self.reference(alias) + projected = plan.project_encoded(self.storage.json_text(db, expression), self.json_record) + if not plan.constructor_required and plan.omitted(plan.default()): + default = SQLiteLiteral.of(self.encode(plan.default())) + return f"CASE WHEN {expression} IS {default} THEN '{{}}' ELSE {projected} END" + return projected + + @staticmethod + def json_record(values: Mapping[str, str]) -> str: + pieces = [sql_literal(json.dumps(key) + ':') + ' || ' + expression + for key, expression in values.items()] + return "'{' || " + " || ',' || ".join(pieces) + " || '}'" if pieces else "'{}'" + @property def lifecycle_family(self) -> type[LifecycleState]: return cast(type[LifecycleState], _base_type(self.annotation)[0]) @@ -2451,12 +2484,27 @@ def stream( """Rows decoded as the caller consumes them; close it to release the query.""" return cls._query(db, where, parameters, order_by, descending, key, row_type, limit) + @classmethod + def wire_record(cls, db: sqlite3.Connection, plan: FieldPlan) -> str: + """Project one declared field's stored value into its public wire record.""" + item, = cls._fields_named((plan.name,)) + return item.wire_record(db, plan, cls.declared_name) + @classmethod def qualified(cls, *names: str) -> str: """Columns named through this table, for one select joining tables that share names.""" table = _identifier(cls.declared_name) return ", ".join(f"{table}.{_identifier(item.wire_name)}" for item in cls._fields_named(names)) + @classmethod + def join_to(cls, other: type[TypedTable]) -> str: + """Join a capability to the other table's declared primary key.""" + keys = tuple(item.name for item in other._fields if item.column.primary_key) + if not keys: + raise TypeError(f"{other.__name__} declares no join key") + condition = " AND ".join(f"{cls.qualified(name)} = {other.qualified(name)}" for name in keys) + return f'JOIN {_identifier(cls.declared_name)} ON {condition}' + @classmethod def decode_at(cls, row: tuple[object, ...], offset: int) -> Self: """Decode this table's columns, selected by ``qualified`` in declaration order at ``offset``.""" diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index 6b3e8d3189..4fe2972f93 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -41,7 +41,6 @@ Todo, ) from agent_comms.transcript_events import OrderCall, TurnDigest -from agent_comms.turn_digests import LastTurns, TurnDigestRead from agent_comms.work_branches import WorkBranchError if TYPE_CHECKING: @@ -122,7 +121,7 @@ def pending(self) -> tuple[Todo, ...]: @cached_property def turns(self) -> tuple[TurnDigest, ...]: - return TurnDigestRead(self.comms.root, self.thread, LastTurns(2)).read().turns + return self.frame.recent_turns(self.comms, self.thread) @cached_property def calls(self) -> tuple[OrderCall, ...]: diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index 72f7480341..daf26363f4 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -25,7 +25,10 @@ from .code_lines import CodeLines from .declared_family import DeclaredFamily from .field_codec import GitCommitText, PathText, projected -from .repo import GitCommandError, Repo +from .repo import ( + BranchUpstream, Checkout, CheckoutHistory, DetachedHead, GitCommandError, GitHead, + Repo, RepositoryReads, UnpushedHead, +) from .repository_name import RepositoryName from .wire_models import WireModel @@ -145,6 +148,33 @@ class SourceGrowth: limit: ClassVar[int] = 40 tree: ClassVar[str] = "src/" + @classmethod + @lru_cache(maxsize=512) + def capture(cls, checkout: Path, base: str, head: str, files: frozenset[str], *, + timeout: float) -> SourceGrowth: + """Immutable tree growth, cached without retaining a pass's branch reader.""" + paths = tuple(path for path in sorted(files) + if path.startswith(cls.tree) and path.endswith(".py")) + if not paths: + return cls(()) + # Only a tree-proved added/deleted side contributes zero, never a missing blob. + sources = tuple( + (path, blob, weight) + for commit, weight in ((base, -1), (head, 1)) + for path, blob in Repo.tree_objects(checkout, commit, paths).items() + ) + contents = Repo.blobs(checkout, *(blob for _path, blob, _weight in sources), + timeout=timeout) + growth = dict.fromkeys(paths, 0) + unparsed: list[UnparsedSource] = [] + for (path, _blob, weight), content in zip(sources, contents, strict=True): + try: + growth[path] += weight * CodeLines.of(content.decode()) + except SyntaxError as error: + # A pushed branch is foreign text: the parse refusal is the branch's fact. + unparsed.append(UnparsedSource.of(path, error)) + return cls(tuple(growth.items()), tuple(unparsed)) + @property def total(self) -> int: return sum(lines for _path, lines in self.files) @@ -259,13 +289,6 @@ class ForgeQuery[Repository](ForgeAnswer): data: ForgeData[Repository] -@dataclass(frozen=True) -class UnpushedHead: - head: str - commits: int = field(compare=False) - upstream: str = field(compare=False) - - @dataclass(frozen=True) class BranchComparison: base: str @@ -273,87 +296,13 @@ class BranchComparison: paths: tuple[Annotated[Path, PathText], ...] -@dataclass(frozen=True) -class BranchUpstream(DeclaredFamily): - """Git's upstream decoded once; each member owns acquisition and publication answers.""" - - branch: str - - @classmethod - def from_git(cls, row: str) -> BranchUpstream: - branch, tracking, remote, reference = row.split("\0") - if not tracking: - return NoUpstream(branch) - if remote == Repo.local_remote: - return LocalUpstream(branch, tracking) - return PublishedUpstream(branch, tracking, remote, reference) - - def publication_inputs(self) -> tuple[tuple[str, str], ...]: - """Only published upstreams supply remote/reference acquisition requirements.""" - return () - - def require_prepared(self, reader: WorkBranches) -> None: - """An absent or local upstream requires no published remote head.""" - - def tracking_name(self) -> str: - return "" - - def unpushed(self, reader: WorkBranches, head: str) -> tuple[UnpushedHead, ...]: - # A local, absent or deleted upstream cannot make its stale tracking ref publication. - tracking = self.tracking_name() - remotes = tuple(ref for ref in reader._git( - "for-each-ref", "--format=%(refname)", "refs/remotes/", - ).splitlines() if ref != tracking) - count = int(reader._git("rev-list", "--count", head, "--not", *remotes)) - return (UnpushedHead(head, count, tracking),) if count else () - - -class NoUpstream(BranchUpstream): - """This local branch declares no upstream.""" - - -class DetachedHead(BranchUpstream): - """Git names a checkout on no branch `HEAD`, as during a rebase; it declares no upstream.""" - - -@dataclass(frozen=True) -class TrackedUpstream(BranchUpstream, declared_name=None): - tracking: str - - def tracking_name(self) -> str: - return self.tracking - - -class LocalUpstream(TrackedUpstream): - """Git's dot remote names another local branch, never a publication.""" - - -@dataclass(frozen=True) -class PublishedUpstream(TrackedUpstream): - remote: str - reference: str - - def publication_inputs(self) -> tuple[tuple[str, str], ...]: - return ((self.remote, self.reference),) - - def require_prepared(self, reader: WorkBranches) -> None: - Repo.require_local_commits(reader.checkout, - *reader.selected_heads(self.remote, self.reference)) - - def unpushed(self, reader: WorkBranches, head: str) -> tuple[UnpushedHead, ...]: - published = reader.selected_heads(self.remote, self.reference) - if published: - (remote_head,) = published - count = int(reader._git("rev-list", "--count", f"{remote_head}..{head}")) - return (UnpushedHead(head, count, self.tracking),) if count else () - return super().unpushed(reader, head) - - @dataclass(frozen=True) class WorkBranches: """One repository's branches with commits that main does not have.""" checkout: Path + reads: RepositoryReads = field(default_factory=RepositoryReads, compare=False, repr=False, + kw_only=True) _remote_heads: dict[str, dict[str, str]] = field( default_factory=dict, init=False, @@ -397,10 +346,18 @@ def _gh(self, *arguments: str) -> str: def _git(self, *arguments: str, input: str = "") -> str: return Repo.git(self.checkout, *arguments, timeout=self.timeout, input=input) + @cached_property + def history(self) -> CheckoutHistory: + return self.reads.history_for(Checkout(self.checkout)) + + @cached_property + def head(self) -> GitHead: + return self.history.head_for(Checkout(self.checkout)) + def read_checkout(self) -> CheckoutRead: """Whether git can open this checkout at all; the one probe every reader asks first.""" try: - self._git("--no-optional-locks", "rev-parse", "--git-dir") + self.history.require_read() except GitCommandError as error: return UnreadableCheckoutRead(self.checkout, str(error)) return ReadableCheckoutRead(self) @@ -429,9 +386,7 @@ def uncommitted(self) -> tuple[Path, ...]: @property def local_work(self) -> bool: """Uncommitted files or HEAD commits absent from every published remote ref.""" - return bool(self.uncommitted()) or bool( - self._git("rev-list", "--max-count=1", "HEAD", "--not", "--remotes") - ) + return bool(self.uncommitted()) or bool(self.history.unpublished(self.head.revision())) def remote_heads(self, remote: str, reference: str) -> tuple[str, ...]: """One complete published-ref cut per remote for this reader, not one call per branch.""" @@ -466,21 +421,20 @@ def prepare_pull_request(self, base: str, head: str) -> None: with Repo.acquiring(): self.remote_heads(Repo.remote, f"refs/heads/{base}") Repo.require_commits(self.checkout, Repo.remote, self._pull_request_base(base), head) + self.history.include(self._pull_request_base(base), head) def pull_request_base(self, base: str, head: str) -> str: base_head = self._pull_request_base(base) - Repo.require_local_commits(self.checkout, base_head, head) + self.history.commit(base_head) + self.history.commit(head) return base_head @cached_property def upstreams(self) -> dict[str, BranchUpstream]: return {record.branch: record for record in ( - DetachedHead("HEAD"), - *(BranchUpstream.from_git(line) for line in self._git( - "for-each-ref", - "--format=%(refname:strip=2)%00%(upstream)%00%(upstream:remotename)%00%(upstream:remoteref)", - "refs/heads/", - ).splitlines()), + DetachedHead(self.head.reference), + *(upstream for reference in self.history.refs.values() + for upstream in reference.upstreams()), )} def prepare_upstreams(self) -> None: @@ -493,8 +447,10 @@ def prepare_upstreams(self) -> None: for remote, refs in references.items(): Repo.enter_phase() heads = tuple(dict.fromkeys( - head for reference in sorted(refs) for head in self.remote_heads(remote, reference))) + head for reference in sorted(refs) + for head in self.remote_heads(remote, reference))) Repo.require_commits(self.checkout, remote, *heads) + self.history.include(*heads) def pull_request_patch_id(self, base: str, head: str) -> str: base_head = self.pull_request_base(base, head) @@ -521,16 +477,12 @@ def pull_request_diff(self, base: str, head: str) -> BranchComparison: def commit_base(self, main: str, head: str) -> CommitBase: """Git's best common ancestor; exit 1 means the histories share none.""" - try: - return SharedCommitBase(self._git('merge-base', main, head).strip()) - except GitCommandError as error: - if not error.unrelated_history(): - raise - return UnrelatedCommitBase() + bases = self.history.merge_base(main, head) + return SharedCommitBase(bases[0]) if bases else UnrelatedCommitBase() def commits_since(self, head: str, *excluded: str) -> tuple[str, ...]: - return tuple(self._git('rev-list', '--no-merges', head, - '--not', *excluded).splitlines()) + return tuple(commit.sha for commit in self.history.commits((head,), *excluded) + if len(commit.parents) < 2) def whole_patch_ids(self, base: str, head: str) -> frozenset[str]: """One stable fingerprint of the entire immutable diff, including multi-commit squash.""" @@ -559,13 +511,20 @@ def require_upstream(self, branch: str) -> BranchUpstream: if branch not in self.upstreams: raise WorkBranchError(f"Unprepared branch {branch!r} in {self.checkout}") upstream = self.upstreams[branch] - upstream.require_prepared(self) + for published in self.published(upstream): + self.history.commit(published) return upstream + def published(self, upstream: BranchUpstream) -> tuple[str, ...]: + """The upstream's heads in this reader's prepared remote cut; none when unpublished.""" + return tuple(head for remote, reference in upstream.publication_inputs() + for head in self.selected_heads(remote, reference)) + def unpushed(self, branch: str, head: str) -> tuple[UnpushedHead, ...]: """Capture publication at the source reader, never through another clone's refs.""" - Repo.require_local_commits(self.checkout, head) - return self.require_upstream(branch).unpushed(self, head) + self.history.commit(head) + upstream = self.require_upstream(branch) + return upstream.unpushed(self.history, head, self.published(upstream)) @cached_property def repository(self) -> tuple[str, str]: @@ -574,7 +533,7 @@ def repository(self) -> tuple[str, str]: @cached_property def origin_main(self) -> str: - return self._git("rev-parse", f"refs/remotes/{Repo.remote}/{self.main}").strip() + return self.history.resolve(f"refs/remotes/{Repo.remote}/{self.main}") def difference(self, main: str) -> BranchDifference: """Git owns ancestry and changed paths; pin both commits before comparing.""" @@ -593,55 +552,31 @@ def difference(self, main: str) -> BranchDifference: raise WorkBranchError( f"HEAD reflog in {self.checkout} has no readable movement time" ) from unread - branch = self._git("rev-parse", "--abbrev-ref", "HEAD").strip() - base = self._git("merge-base", main, head).strip() - behind = int(self._git("rev-list", "--count", f"{base}..{main}")) + branch = self.head.branch + bases = self.history.merge_base(main, head) + if not bases: + raise WorkBranchError(f"Unrelated main and HEAD in {self.checkout}") + base = bases[0] + behind = self.history.count(main, base) files = self.changed(base, head) return BranchDifference( RepositoryName(*self.repository), branch, head, moved_at, main, behind, files, - self.source_growth(base, head, files), self.unpushed(branch, head), + SourceGrowth.capture(self.checkout, base, head, files, timeout=self.timeout), + self.unpushed(branch, head), ) def changed(self, base: str, head: str) -> frozenset[str]: return frozenset(path for path in self._git( "diff", "--name-only", "-z", "--no-renames", base, head).split("\0") if path) - @lru_cache(maxsize=512) - def source_growth(self, base: str, head: str, files: frozenset[str]) -> SourceGrowth: - """Git owns both trees; each changed src module is counted once on each side. - - Both commits are immutable, so one pair is counted once per process: the - heartbeat asks again every pass and the answer cannot move until a SHA does. - """ - paths = tuple(path for path in sorted(files) - if path.startswith(SourceGrowth.tree) and path.endswith(".py")) - if not paths: - return SourceGrowth(()) - # Only a tree-proved added/deleted side contributes zero, never a missing blob. - sources = tuple( - (path, blob, weight) - for commit, weight in ((base, -1), (head, 1)) - for path, blob in Repo.tree_objects(self.checkout, commit, paths).items() - ) - contents = Repo.blobs(self.checkout, *(blob for _path, blob, _weight in sources), - timeout=self.timeout) - growth = dict.fromkeys(paths, 0) - unparsed: list[UnparsedSource] = [] - for (path, _blob, weight), content in zip(sources, contents, strict=True): - try: - growth[path] += weight * CodeLines.of(content.decode()) - except SyntaxError as error: - # A pushed branch is foreign text: the parse refusal is the branch's fact. - unparsed.append(UnparsedSource.of(path, error)) - return SourceGrowth(tuple(growth.items()), tuple(unparsed)) - def reviewed_growth(self, pr: int, head: str, branch: str) -> SourceGrowth: """A pull request head against its declared base branch, so a request stacked on another is charged only its own lines; fetched without touching FETCH_HEAD.""" self._git("fetch", "-q", "--no-write-fetch-head", Repo.remote, branch, f"refs/pull/{pr}/head") base = self._git("merge-base", f"refs/remotes/{Repo.remote}/{branch}", head).strip() - return self.source_growth(base, head, self.changed(base, head)) + return SourceGrowth.capture(self.checkout, base, head, self.changed(base, head), + timeout=self.timeout) def _pages(self, query: str, answer: type[ForgeQuery], **variables: str): pages: list[tuple[str, ...]] = [()] @@ -656,18 +591,9 @@ def _pages(self, query: str, answer: type[ForgeQuery], **variables: str): def worktrees(self) -> dict[str, tuple[str, ...]]: """Branch name -> worktree paths that have it checked out.""" checked: dict[str, list[str]] = {} - path = None - head = "" - for line in self._git("worktree", "list", "--porcelain").splitlines(): - if line.startswith("worktree "): - path = line.removeprefix("worktree ") - head = "" - elif line.startswith("HEAD "): - head = line.removeprefix("HEAD ") - elif line.startswith("branch refs/heads/") and path is not None and head.strip("0"): - # Git retains a worktree's symbolic branch after its ref is deleted, - # with an all-zero HEAD. That is not an existing local branch. - checked.setdefault(line.removeprefix("branch refs/heads/"), []).append(path) + for head in self.history.heads.values(): + for branch in head.checked_branch(): + checked.setdefault(branch, []).append(str(head.checkout.path)) return {branch: tuple(paths) for branch, paths in checked.items()} def checkouts(self, snapshot: RegistrySnapshot, branch: str, @@ -790,12 +716,7 @@ def errors(self) -> tuple[str, ...]: return () def worktrees(self) -> tuple[Path, ...]: - listed = self.branches._git("worktree", "list", "--porcelain", "-z") - return tuple( - Path(field.removeprefix("worktree ")).resolve() - for field in listed.split("\0") - if field.startswith("worktree ") - ) + return tuple(self.branches.history.heads) @dataclass(frozen=True) diff --git a/src/agent_comms_hooks/prime.py b/src/agent_comms_hooks/prime.py new file mode 100644 index 0000000000..57ab1989a6 --- /dev/null +++ b/src/agent_comms_hooks/prime.py @@ -0,0 +1,63 @@ +"""The owner's /prime: deal the rules again on demand, through the same gate a compaction uses. + +"hey guys do /prime, you're all misaligned" (Tristan, 2026-10-30). A paste of the rules can be +skimmed; a deal cannot, because the gate refuses every other tool until each dealt file is read +and answered in a fresh audit. The variant picks which readers deal (full, no-papers, +no-indictment, light), so a mid-context prime need not fill the window. + +Run with no target in a Claude session, it deals that session. With `--to`, it deals each named +thread whose deal Core holds (a native Pi thread); a Claude session's deal lives beside its own +transcript, so its thread family refuses and that session runs /prime itself. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence + +from agent_comms.cli_bridges.claude_transcript import RunningClaude +from agent_comms.comms import client_wire +from agent_comms.pi_rule_gate import PiRedeal +from agent_comms_hooks.skill import DeckStart, FullPrime, Prime, SessionSource +from agent_comms_hooks.watch import WatchClaim + + +def session_start() -> DeckStart: + """This Claude session as a deal: its id from the environment Claude exports to every Bash + call, its transcript found by that id.""" + session = WatchClaim.environment_session() + return DeckStart(session, "Prime", str(RunningClaude(session).source), SessionSource("prime")) + + +def prime_threads(prime: type[Prime], names: Sequence[str]) -> None: + """Deal each named thread the variant's readers through its Core-held gate, on the bus + route the client resolves.""" + with client_wire() as comms: + for name in names: + thread = comms.registry.require(name) + thread.execution.require_native() + PiRedeal.of(comms, thread).deal_from(prime.commander, prime.fleet, prime.trigger()) + print(f"{name}: dealt {prime.__name__}") + + +def main() -> None: + parser = argparse.ArgumentParser(prog="agent-comms-prime", description=__doc__) + parser.add_argument( + "--variant", type=Prime.member_named, default=FullPrime, metavar="|".join(Prime.names()) + ) + parser.add_argument( + "--to", + nargs="+", + metavar="THREAD", + default=(), + help="deal these native threads instead of this session", + ) + arguments = parser.parse_args() + if arguments.to: + prime_threads(arguments.variant, arguments.to) + return + print("\n\n".join(session_start().primed(arguments.variant))) + + +if __name__ == "__main__": + main() diff --git a/src/agent_comms_hooks/skill.py b/src/agent_comms_hooks/skill.py index 0c0012585e..4b0707ddfc 100644 --- a/src/agent_comms_hooks/skill.py +++ b/src/agent_comms_hooks/skill.py @@ -31,8 +31,8 @@ from agent_comms.pattern_sources import PostmortemFailure from agent_comms.registry_store import RegistryStore from agent_comms.rule_deal import ( - ClearedDeal, CompactedDeal, CompactionDeal, DealTrigger, DealtSources, RuleDeal, StartedDeal, - UnstampedCompactionDeal, + ClearedDeal, CompactedDeal, CompactionDeal, DealTrigger, DealtSources, PrimedDeal, RuleDeal, + StartedDeal, UnstampedCompactionDeal, ) from agent_comms.slop import Slop from agent_comms.todos import TodoStore @@ -71,6 +71,11 @@ def proofs(cls) -> tuple[Path, ...]: """The papers every rule is a consequence of, read before any rule.""" return CommanderPackage.paper() + @classmethod + def indictment(cls) -> tuple[Path, ...]: + """The record of what the reader actually did; a fleet session is dealt none.""" + return () + @classmethod def rules(cls) -> tuple[RuleFile, ...]: return tuple(rule for rule in RuleFile.packaged() if rule.audience in cls.audiences) @@ -176,10 +181,16 @@ class CommanderReader(FleetReader, audience=CommanderAudience): deck_suffix = ".deck.json" + @classmethod + def indictment(cls) -> tuple[Path, ...]: + return (CommanderPackage.indictment(),) + @classmethod def streams(cls) -> tuple[tuple[str, ...], ...]: - indictment = cls.paragraphs(CommanderPackage.indictment().read_text(encoding="utf-8")) - return (cls.voice(), indictment, cls.tells(), cls.failures()) + indictment = tuple(paragraph for path in cls.indictment() + for paragraph in cls.paragraphs(path.read_text(encoding="utf-8"))) + return tuple(stream for stream in (cls.voice(), indictment, cls.tells(), cls.failures()) + if stream) @classmethod def leading(cls) -> tuple[Path, ...]: @@ -187,7 +198,7 @@ def leading(cls) -> tuple[Path, ...]: indictment after them: read once every proof and rule is understood, it is the record of what the model still did anyway, never a preface to skim (Tristan, 2026-10-30).""" return (*super().leading(), CommanderPackage.skill(), Slop.catalog() / "README.md", - CommanderPackage.indictment()) + *cls.indictment()) @classmethod def answered(cls) -> frozenset[str]: @@ -223,6 +234,58 @@ class CommanderRulesReader(RulesOnly, CommanderReader, audience=CommanderAudienc """The commander's refresh.""" +class IndictmentFree(DeckReader, audience=FleetAudience): + """A prime asked for mid-context without the indictment, so it does not fill the window.""" + + @classmethod + def indictment(cls) -> tuple[Path, ...]: + return () + + +class CommanderIndictmentFreeReader(IndictmentFree, CommanderReader, audience=CommanderAudience): + """The commander's papers and rules without the indictment.""" + + +class CommanderLightReader(RulesOnly, IndictmentFree, CommanderReader, audience=CommanderAudience): + """The commander's rules alone: no papers, no indictment.""" + + +class Prime(DeclaredFamily): + """A deal the owner asks for on demand (/prime, `agent-comms-prime`). Each member names + the readers it deals by role; the readers own which files those are, so a variant is a + selection over the one dealt order, never a second list.""" + + fleet: ClassVar[type[DeckReader]] + commander: ClassVar[type[DeckReader]] + + @classmethod + def trigger(cls) -> PrimedDeal: + """Why this variant's deal was dealt: the owner asked for it by this variant's name.""" + return PrimedDeal(cls.declared_name) + + +class FullPrime(Prime, declared_name="full"): + """Papers, rules and, for a commander, the indictment: the same deal as a compaction.""" + + fleet = FleetReader + commander = CommanderReader + + +class PaperlessPrime(Prime, declared_name="no-papers"): + fleet = FleetRulesReader + commander = CommanderRulesReader + + +class IndictmentlessPrime(Prime, declared_name="no-indictment"): + fleet = FleetReader + commander = CommanderIndictmentFreeReader + + +class LightPrime(Prime, declared_name="light"): + fleet = FleetRulesReader + commander = CommanderLightReader + + @dataclass(frozen=True) class DealtDeck(ClaudeTranscriptHook, RuleDeal): """The deal and the slices the session start hook dealt beside the transcript.""" @@ -236,7 +299,7 @@ class StartReason(DeclaredFamily): def deal(self, start: DeckStart, reader: DeckReader) -> tuple[str, ...]: """The context is gone: the deck is dealt from its start and every file is read again.""" - return start.dealt(reader) + return start.dealt(reader, self.trigger(start)) def trigger(self, start: DeckStart) -> DealTrigger: """The cause every refusal of this deal opens with.""" @@ -269,7 +332,7 @@ class Resume(StartReason, declared_name="resume"): were demanded again.""" def deal(self, start: DeckStart, reader: DeckReader) -> tuple[str, ...]: - return start.current().resume(lambda: start.dealt(reader), ()) + return start.current().resume(lambda: start.dealt(reader, self.trigger(start)), ()) class SessionSource(str): @@ -279,6 +342,15 @@ def reason(self) -> StartReason: return StartReason.member_or_default(self, LostContext)() +class UnboundPrimeError(Exception): + """/prime asked of a session no bus thread holds: no gate holds a deal for it, so it is + refused out loud instead of printing nothing.""" + + def __init__(self, start: DeckStart) -> None: + super().__init__(f"session {start.session_id} is bound to no bus thread, so there is no " + "gate to deal it; bind it to the bus first") + + @dataclass(frozen=True) class DeckStart(DealtDeck): """A session start, resume or compaction: the deal, its order and the deck.""" @@ -300,10 +372,20 @@ def taken(self, reader: DeckReader) -> tuple[str, ...]: """A start that lost the context deals the deck; a resume keeps the deal it holds.""" return self.source.reason().deal(self, reader) - def dealt(self, reader: DeckReader) -> tuple[str, ...]: + def primed(self, prime: type[Prime]) -> tuple[str, ...]: + """The owner's /prime: a fresh deal of the variant's reader for this session's role, + whatever its context holds, through the same gate as a compaction.""" + return WatchClaim(self.session_id).state().by_role( + unbound=self.refuse_unbound, + fleet=lambda record: self.dealt(prime.fleet(), prime.trigger()), + commander=lambda record: self.dealt(prime.commander(), prime.trigger())) + + def refuse_unbound(self) -> tuple[str, ...]: + raise UnboundPrimeError(self) + + def dealt(self, reader: DeckReader, trigger: DealTrigger) -> tuple[str, ...]: """The order to read every dealt file, once, by its packaged path, opening with why.""" prompts = self.prompts() - trigger = self.source.reason().trigger(self) self.prepare(reader, prompts, trigger) sources = reader.sources() return ( @@ -389,7 +471,8 @@ def widened(self, reader: DeckReader) -> tuple[str, ...]: deck = self.deck(reader) if deck.exists(): return () - said = self.current().widen(self, type(reader), lambda: self.dealt(reader), ()) + said = self.current().widen( + self, type(reader), lambda: self.dealt(reader, self.source.reason().trigger(self)), ()) deck.write_text(json.dumps(reader.slices()), encoding="utf-8") return said 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_bus.py b/tests/test_bus.py index c79ba6849a..80e1e3b952 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -38,6 +38,7 @@ InfoKind, MembershipTransition, Message, + MessageInput, MessageKind, MessageRoleWire, MessageRoutes, @@ -50,7 +51,7 @@ RelationViolationError, UnclaimedError, ) -from agent_comms.field_codec import GitCommitText, MessageId, MessageIdText +from agent_comms.field_codec import FieldCodec, GitCommitText, MalformedRecord, MessageId, MessageIdText from agent_comms.message_reference import MessageReference from agent_comms.read_basis import RepliesDisplayScope from agent_comms.response_acknowledgement import ( @@ -123,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") @@ -134,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)) @@ -246,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] @@ -392,6 +401,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 @@ -432,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 @@ -556,9 +586,12 @@ def test_scoped_and_frozen_source_queries_are_sequence_bounded(bus): Message("author", "worker", "notice", ClaimHistoryKind, seq=seq))) reply = bus.append(lambda seq, db: record_from(PrivateRecord, Message("author", "worker", "reply", InfoKind, seq=seq, answers=(original.reference,)))) - assert bus.scoped_page("target=?", ("worker",), descending=True, limit=1) == (reply,) - assert bus.scoped_page("target=?", ("worker",), upper=reply.seq, limit=1) == (notice,) - assert bus.scoped_page("target=?", ("worker",), kinds=(InfoKind,), limit=2) == (reply,) + assert bus.scoped_page("target=?", ("worker",), descending=True, limit=1) == ( + Message.from_message(reply),) + assert bus.scoped_page("target=?", ("worker",), upper=reply.seq, limit=1) == ( + Message.from_message(notice),) + assert bus.scoped_page("target=?", ("worker",), kinds=(InfoKind,), limit=2) == ( + Message.from_message(reply),) assert bus.conversation("b" * 32, limit=2) == (reply, notice) assert bus.sent("a" * 32, limit=1) == (reply,) assert bus.direct_replies("b" * 32, after=original.seq, limit=1, descending=False, @@ -722,6 +755,19 @@ 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) + 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): 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 d8687297cd..8b27186110 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", participants=frozenset({"mentioned"})), ChannelActivity(20.0, 20.0)), - (MemberActivityDisplayScope("#member", participant_names=frozenset({"worker"})), + (NamedActivityDisplayScope("#member", participants=frozenset({"worker"})), ChannelActivity(20.0, 20.0)), - (MemberActivityDisplayScope("#member", frozenset({"#team"}), - participant_names=frozenset({"human"})), + (NamedActivityDisplayScope("#member", frozenset({"#team"}), + participants=frozenset({"human"})), ChannelActivity(30.0, 20.0)), ) with bus.reading() as captured, captured.observing() as db: 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_checkout_submodules.py b/tests/test_checkout_submodules.py index 74741593fe..501912a6cf 100644 --- a/tests/test_checkout_submodules.py +++ b/tests/test_checkout_submodules.py @@ -137,7 +137,7 @@ def test_empty_project_does_not_become_the_callers_checkout(tmp_path, monkeypatc git(tmp_path, 'init', '-q') monkeypatch.chdir(tmp_path) comms = wire(tmp_path / 'wire') - result = core('--root', str(comms.root), 'new', '--name', 'empty-project', '--project', '') + result = core('--root', str(comms.root), 'new', '--name', 'empty-project', '--tags', '', '--project', '') assert result.returncode != 0 assert 'Project path must not be empty' in result.stdout + result.stderr assert 'empty-project' not in comms.registry.snapshot().threads 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_cli.py b/tests/test_cli.py index 73d5586a73..e4fda55671 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -670,7 +670,10 @@ def test_result_kind_decides_exit_code_and_navigation_targets(): assert not launched.open_targets() and not batch.open_targets() created = ThreadCreatedResult("child", NoProcess()) assert created.open_targets() == ("child",) - command = NewThreadCliCommand(name="child", project=LaunchCheckout(Path("/project")), model=UndeclaredModel()) + from agent_comms.thread_management import GivenTags + + command = NewThreadCliCommand(name="child", tags=GivenTags(frozenset()), + project=LaunchCheckout(Path("/project")), model=UndeclaredModel()) from agent_comms.channel_targets import ProjectTarget completed = TargetCompleted(ProjectTarget(Checkout(Path("/project"))), command, created) diff --git a/tests/test_commander_forks.py b/tests/test_commander_forks.py index 37a1a2707c..12099d36dc 100644 --- a/tests/test_commander_forks.py +++ b/tests/test_commander_forks.py @@ -327,7 +327,8 @@ def test_fork_and_parent_judge_show_same_unjudged_edits(fork_bus): def test_unjudged_reminder_names_each_order_with_edits(fork_bus): comms, source, _ = fork_bus record_edits(comms, source, Unjudged.limit + 1) - assert Unjudged.reason(comms, "lead") == ( + frame = capture_frame(comms, "lead", ProgressRead()) + assert Unjudged.reason(comms, "lead", frame) == ( f"{Unjudged.limit + 1} edits since each order's last head verdict: " f"retained P40 {Unjudged.limit + 1}") diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index 99c7d78c2e..55523d1cae 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -47,7 +47,7 @@ from agent_comms.messages import ConversationRole, InfoKind, Message from agent_comms.transcript_events import IncomingTranscript from agent_comms.ui_model.message_category import HeartbeatCategory, InboundCategory, all_categories -from agent_comms.repo import Checkout +from agent_comms.repo import Checkout, UnpushedHead from agent_comms.threads import Thread from test_holder_standing_rules import state_cards from test_unit_state import recorded_turn @@ -633,7 +633,6 @@ def test_latest_observation_follows_the_commander_rename_alias(notification_bus) def test_new_local_commit_on_an_unpushed_branch_is_not_a_resolution(notification_bus, tmp_path): from agent_comms.commander.publication_rules import UnpushedBranch - from agent_comms.work_branches import UnpushedHead comms = notification_bus holder = comms.registry.require("worker").incarnation diff --git a/tests/test_commander_skill_hook.py b/tests/test_commander_skill_hook.py index 8db15dd239..203b233a3b 100644 --- a/tests/test_commander_skill_hook.py +++ b/tests/test_commander_skill_hook.py @@ -17,7 +17,8 @@ import pytest -from agent_comms.rule_deal import CompactedDeal, StartedDeal, Unengaged, UnstampedCompactionDeal +from agent_comms.rule_deal import (CompactedDeal, StartedDeal, Unengaged, + UnstampedCompactionDeal) from agent_comms.cli_bridges.monitor import BoardReply, ReadBoard, SessionRefused, SessionRequest from agent_comms.cli_bridges.shared import Binding, DeliveredBoardContent @@ -51,7 +52,8 @@ from agent_comms_hooks.forks import SubagentHook from agent_comms.rule_deal import AuditDue, OwnerWordsAnswer, ReadSpan, RuleAudit, SessionAudit from agent_comms_hooks.read_gate import GatedCall -from agent_comms_hooks.skill import CommanderReader, DeckReader, DeckStart, FleetReader +from agent_comms_hooks.skill import (CommanderReader, DeckReader, DeckStart, FleetReader, FullPrime, + Prime, UnboundPrimeError) from agent_comms_hooks.watch import DeafWatch, LiveWatch, WatchClaim, WatchRecord from conftest import engaged from test_commander_notifications import beat, lead_hud @@ -451,6 +453,41 @@ def test_every_deal_reads_the_paper_before_any_rule(): assert sources[len(paper)] == RuleFile.packaged()[0].path +def test_the_prime_variants_cover_papers_and_indictment_once_each_with_the_indictment_last(): + """/prime deals a selection of the one reader list: each variant keeps or drops the papers + and the commander's indictment, every rule stays, and a kept indictment stays last.""" + paper = set(CommanderPackage.paper()) + indictment = CommanderPackage.indictment() + kept = set() + for prime in Prime.members_with(Prime): + commander = prime.commander.sources() + assert indictment not in prime.fleet.sources() + assert all(rule.path in commander for rule in CommanderReader.rules()) + kept.add((paper <= set(commander), indictment in commander)) + if indictment in commander: + assert prime.commander.leading()[-1] == indictment + assert prime.commander.slices() and prime.fleet.slices() + assert kept == {(True, True), (True, False), (False, True), (False, False)} + + +def test_every_prime_variant_says_it_was_primed_never_that_the_session_started(): + for prime in Prime.members_with(Prime): + reason = prime.trigger().reason() + assert f"/prime ({prime.declared_name})" in reason + assert reason != StartedDeal().reason() + + +def test_prime_in_a_session_no_bus_thread_holds_is_refused_out_loud(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + transcript = tmp_path / "session.jsonl" + transcript.write_text("") + start = DeckStart.decode({"hook_event_name": "SessionStart", "session_id": "loose", + "transcript_path": str(transcript)}) + with pytest.raises(UnboundPrimeError, match="loose"): + start.primed(FullPrime) + assert not transcript.with_suffix(".deal.json").exists() + + def test_the_commander_session_gets_its_sources(tmp_path, monkeypatch, commander_bus): monkeypatch.setenv("HOME", str(tmp_path / "home")) transcript = tmp_path / "session.jsonl" 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_display_unread.py b/tests/test_display_unread.py index 1d47bb85dd..d853a49466 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 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") + 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_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_editor_defaults.py b/tests/test_editor_defaults.py index c4b6e38e8a..9a2a1dd2fa 100644 --- a/tests/test_editor_defaults.py +++ b/tests/test_editor_defaults.py @@ -4,7 +4,11 @@ import pytest from agent_comms.cli_verbs.fork import ForkCliCommand -from agent_comms.field_codec import FieldCodec, compile_decoder +from dataclasses import dataclass + +from agent_comms.field_codec import FieldCodec, MalformedRecord, UnchosenMember, compile_decoder +from agent_comms.target_fields import TargetField +from agent_comms.thread_management import ChangedTags, GivenTags, InheritedTags, TagChoice from agent_comms.pi_vocabulary import HighThinkingLevel, NoThinkingOverride, ThinkingLevelField from agent_comms.repo import LaunchCheckout from agent_comms.thread_execution import ExternalThreadExecution, ThreadExecution @@ -19,6 +23,10 @@ (str | None, None, None), (bool, True, True), (tuple[int, ...], (1, 2), [1, 2]), + (TagChoice, GivenTags(frozenset({'auth'})), 'auth'), + (TagChoice, InheritedTags(), None), + (TagChoice, ChangedTags(frozenset({'auth'}), frozenset({'old'})), + {'add': ['auth'], 'remove': ['old']}), )) def test_declared_default_editor_round_trip(annotation, value, encoded): decoder = compile_decoder(annotation) @@ -41,3 +49,74 @@ def test_fork_default_retains_annotated_thinking_representation(source_repositor assert compile_decoder(str).encode_editor('literal text') == 'literal text' +@pytest.mark.parametrize('annotation,value', ( + (TagChoice, GivenTags(frozenset())), + (TagChoice, GivenTags(frozenset({'auth'}))), + (TagChoice, InheritedTags()), + (TagChoice, ChangedTags(frozenset({'auth'}), frozenset({'old'}))), + (int | float, 3), + (int | float, 3.0), + (int | float | None, None), +)) +def test_native_union_member_and_field_derived_transaction(annotation: object, value: object) -> None: + decoder = compile_decoder(annotation) + assert decoder.editor_initial(value) is value + member = decoder.editor_wire.selected_editor_member(value) + assert member.declaration is type(value) + assert tuple(item.declaration for item in decoder.editor_wire.editor_members).count(type(value)) == 1 + parameters = tuple(TargetField.of(entry, member.current(entry, value)) for entry in member.fields) + arguments = {key: item for parameter in parameters + for key, item in parameter.argument(parameter.editor_initial).items()} + result = member.complete(arguments) + assert type(result) is type(value) + assert result == value + json.dumps(decoder.encode_editor(value)) + assert decoder.decode_editor(decoder.editor_wire.editor_argument(value)) == FieldCodec.encode(value, annotation) + + +def test_overlapping_scalar_wire_does_not_change_selected_native_member() -> None: + decoder = compile_decoder(int | float) + member = decoder.editor_wire.selected_editor_member(3.0) + assert member.declaration is float + assert member.complete({'value': '3.0'}) == 3.0 + with pytest.raises(MalformedRecord): + member.complete({'value': '3'}) + with pytest.raises(MalformedRecord): + FieldCodec.decode(int | float, 3) + + +def test_native_union_default_does_not_leak_into_public_json_schema() -> None: + plan = next(entry for entry in FieldCodec.argument_fields(ForkCliCommand) if entry.name == 'tags') + value = ChangedTags(frozenset({'auth'}), frozenset()) + parameter = TargetField.of(plan, value) + assert parameter.editor_initial is value + assert parameter.editor_selection(value).declaration is ChangedTags + assert isinstance(parameter.editor_default, str) + json.dumps(parameter.json_schema()) + + +def test_unchosen_selection_is_not_a_supplied_null_member() -> None: + @dataclass(frozen=True) + class RequiredUnion: + value: int | float | None + + plan, = FieldCodec.argument_fields(RequiredUnion) + omitted = TargetField.omitted(plan) + unchosen = omitted.editor_selection(omitted.editor_initial) + assert isinstance(unchosen, UnchosenMember) + assert unchosen.declaration is UnchosenMember + assert unchosen.label == 'Choose a member…' + assert unchosen.declaration in tuple(member.declaration for member in omitted.editor_selections) + assert omitted.editor_selection(3).declaration is int + supplied_null = TargetField.of(plan, None).editor_selection(None) + assert supplied_null.declaration is type(None) + + +def test_record_member_inputs_remain_required_without_invented_values() -> None: + decoder = compile_decoder(TagChoice) + member = decoder.editor_wire.selected_editor_member(ChangedTags(frozenset(), frozenset())) + assert all(entry.constructor_required for entry in member.fields) + with pytest.raises(MalformedRecord): + member.complete({}) + + diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 9b41eb166a..12af3959cd 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -42,19 +42,21 @@ async def cli(programs, root, *arguments): class TestEndToEndLifecycle: - @pytest.mark.parametrize('operation,parent_arguments', [ - ('new', ()), ('fork', ('--parent', 'parent')), + @pytest.mark.parametrize('operation,creation_arguments,expected_tags', [ + ('new', ('--tags', 'chosen'), frozenset({'chosen'})), + ('new', ('--tags', ''), frozenset()), + ('fork', ('--parent', 'parent'), frozenset({'inherited'})), ]) async def test_new_and_fork_require_a_declared_checkout( - self, native, source_programs, operation, parent_arguments, + self, native, source_programs, operation, creation_arguments, expected_tags, ): await native.author_history() comms = wire(native.root) await cli(source_programs, native.root, 'register', '--name', 'parent', - '--worktree', str(native.project), '--execution', 'native') + '--tags', 'inherited', '--worktree', str(native.project), '--execution', 'native') parent = comms.registry.require('parent') comms.threads.attach_session(parent, str(native.session), process=parent.process_identity) - command = (operation, '--name', 'probe', *parent_arguments) + command = (operation, '--name', 'probe', *creation_arguments) plain = native.project.parent / 'plain' plain.mkdir() for project_arguments in ((), ('--project', ''), ('--project', str(plain))): @@ -66,7 +68,9 @@ async def test_new_and_fork_require_a_declared_checkout( '--project', str(native.project), '--model', 'response-local/fixture', '--thinking-level', 'off') assert created['created'] == 'probe' - assert comms.registry.require('probe').worktree == Checkout(native.project.resolve()) + child = comms.registry.require('probe') + assert child.worktree == Checkout(native.project.resolve()) + assert child.tags == expected_tags finally: for thread in comms.registry.snapshot().threads.values(): if thread.process_alive: diff --git a/tests/test_exporting.py b/tests/test_exporting.py index e41857b8b4..cea3b95052 100644 --- a/tests/test_exporting.py +++ b/tests/test_exporting.py @@ -26,7 +26,10 @@ WireExportLimit, WireTranscriptExporter, ) -from agent_comms.messages import Message, InfoKind +from agent_comms.bus import Bus, ClaimRecord +from agent_comms.envelope_claim_transitions import UnscopedClaims +from agent_comms.field_codec import ClaimId +from agent_comms.messages import ClaimNoticeKind, Message, InfoKind from agent_comms.threads import Thread @@ -425,3 +428,21 @@ def test_wire_codec_preserves_timestamp_spelling_and_identity(timestamp): reopened = Message.from_wire(json.loads(json.dumps(record))) assert json.dumps(reopened.to_wire()) == json.dumps(record) assert reopened.message_id == source.message_id + + +def test_stored_text_and_jsonl_export_render_claim_notices(tmp_path): + def claim_notice(seq, db): + notice = Message("worker", "worker", "Claimed", ClaimNoticeKind, timestamp=5.0, seq=seq) + return ClaimRecord.of(notice, UnscopedClaims( + owner="worker", incarnation="1", seq=seq, message_id=notice.message_id, + claims=("/owned",), generation=ClaimId("f" * 32))) + + bus = Bus.install(Bus.path_in(tmp_path)) + stored = bus.append(claim_notice) + for format, name in ((TextFormat(), "claims.txt"), (JsonlFormat(), "claims.jsonl")): + with bus.stored_message_history(through=stored.seq) as source: + receipt = exporter(format=format, through=stored.seq).export_stored( + source, tmp_path / name) + assert receipt.exported_messages == 1 + assert "type=claim-notice" in (tmp_path / "claims.txt").read_text() + assert records(tmp_path / "claims.jsonl")[1]["message"]["type"] == "claim-notice" diff --git a/tests/test_fleet_replay.py b/tests/test_fleet_replay.py index d84f49e4d1..8389206240 100644 --- a/tests/test_fleet_replay.py +++ b/tests/test_fleet_replay.py @@ -33,7 +33,7 @@ from agent_comms.goals import CountCheckResult, OrderCheck from agent_comms.order_runs import ApplicationOrderRun, MechanicalOrderRun from agent_comms.recorded_time import RecordedEventTime -from agent_comms.repo import Checkout, Repo +from agent_comms.repo import Checkout, Repo, UnpushedHead from agent_comms.service_passes import ServicePassStore from agent_comms.repository_name import RepositoryName from agent_comms.standing_rules import ( @@ -65,7 +65,7 @@ from agent_comms.transcript_events import ToolStartTranscript from agent_comms.turn_item import NativeItemId, NativeTurnId from agent_comms.ui_model.unit_state import Unreadable -from agent_comms.work_branches import SourceGrowth, UnpushedHead, WorkBranchError +from agent_comms.work_branches import SourceGrowth, WorkBranchError from conftest import declare_test_bus from fixture_git import IDENTITY from github_fixture import GitHubFixture 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 84f2c3bc21..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 @@ -57,6 +57,43 @@ 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_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) + 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_view( + '#team', + viewer=NamedViewer('alice'), + ).page(live.views.presentation, 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)) @@ -68,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") @@ -92,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 @@ -111,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 @@ -121,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] @@ -135,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)) @@ -163,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,) @@ -239,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): @@ -328,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( @@ -339,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()), ) @@ -359,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_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_named_target_contracts.py b/tests/test_named_target_contracts.py index 58ad93e6cd..7ec10aed53 100644 --- a/tests/test_named_target_contracts.py +++ b/tests/test_named_target_contracts.py @@ -9,8 +9,10 @@ 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 from bus_record_fixture import context_comms from agent_comms.threads import Thread @@ -64,15 +66,24 @@ 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) 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) + 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)) assert generic_page.messages == page.messages @@ -92,18 +103,36 @@ 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) 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): 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) @@ -121,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_order_progress.py b/tests/test_order_progress.py index d9ddc2c5c9..bb81e21c63 100644 --- a/tests/test_order_progress.py +++ b/tests/test_order_progress.py @@ -214,4 +214,4 @@ def test_every_order_greps_one_history_walk_per_selection(tmp_path): expected = git(worktree, 'log', '-1', '--extended-regexp', '--regexp-ignore-case', f'--grep={pattern}', '--format=%H', *refs, '--not') assert [point.sha for point in checkout.commits(pattern, refs)] == expected.split() - assert tuple(checkout.walks) == ((refs, ()),) + assert len(checkout.branches.history._walks) == 1 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_owner_release_store.py b/tests/test_owner_release_store.py index 3f0ebada86..46577bf310 100644 --- a/tests/test_owner_release_store.py +++ b/tests/test_owner_release_store.py @@ -13,20 +13,25 @@ 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) 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_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_recipient_activity_index.py b/tests/test_recipient_activity_index.py index a335853017..9d319f49d1 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,39 @@ 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.registry.unregister("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_reconcile_stores.py b/tests/test_reconcile_stores.py index c344fdaa80..adc78b9d6c 100644 --- a/tests/test_reconcile_stores.py +++ b/tests/test_reconcile_stores.py @@ -250,11 +250,10 @@ def test_read_back_under_the_hold_writes_nothing_on_a_drifted_bus(private_comms) assert bus_files(LiveFilesRequest(root)) == before -def test_a_settled_bus_reconciles_without_moving_committed_wal_frames(private_comms): - """An owner that ends without the last close leaves its commits in the WAL. The practice - copy is a SQLite backup holding none, so the live bus is settled before its snapshot; - otherwise reconcile's own last close moves the frames into the file, and the carry - refuses a change no step names.""" +def test_a_pinned_bus_reconciles_without_moving_committed_wal_frames(private_comms): + """An owner that ends without the last close leaves its commits in the WAL. Unpinned, + reconcile's own last close moves those frames into the file (same rows, new bytes) and + the carry refuses a change no step names; the pin keeps every close from being last.""" root = private_comms.root # The child rewrites one row in place: frames in the WAL, the same rows in the store. framed = subprocess.run( @@ -278,9 +277,11 @@ def test_a_settled_bus_reconciles_without_moving_committed_wal_frames(private_co capture_output=True, text=True, check=True, timeout=60) database = Path(framed.stdout.strip()) assert SQLiteFile.wal_path(database).stat().st_size - with maintenance_held(root) as holder: - SQLiteFile.settle(database) - before = bus_files(LiveFilesRequest(root)) + with maintenance_held(root) as holder, SQLiteFile.pinned(database): + # A reader of the latest snapshot (a bridge, a `sqlite3 -readonly`) is open when the + # pin is taken and closes during the carry; neither waits on the other. + with closing(SQLiteFile.read_snapshot(database)): + before = bus_files(LiveFilesRequest(root)) completed = reconcile(root, holder) assert completed.returncode == 0, completed.stdout + completed.stderr assert json.loads(completed.stdout)["rewritten"] == [] diff --git a/tests/test_registration_components.py b/tests/test_registration_components.py index 507d11b937..2cf6405e84 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,17 +158,92 @@ 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): - from agent_comms.registry_document import ThreadRows +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 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. + 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 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(comms, tmp_path): + from agent_comms.registry_document import ThreadRows + + 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] + 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"] + + +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.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_relationships.py b/tests/test_relationships.py index 05b967883b..a0470ccc68 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -4,17 +4,25 @@ 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 RosterVisibility +from agent_comms.registry_document import InvalidRegistryDocument, RosterVisibility, ThreadRows from agent_comms.relationships import ( ChildrenRelationshipGroup, CollaboratingRelationshipGroup, InboundRelationshipGroup, - OutboundRelationshipGroup, ParentRelationshipGroup, RelationshipEdit, + 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 +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 @@ -37,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"))): @@ -120,6 +128,71 @@ 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", "origin", "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: 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") + 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) + 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, + MappingProxyType(orders), MappingProxyType(sent), + ) + 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.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.views()} + 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") + + 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)] @@ -181,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.views() == () + 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,) @@ -226,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.views() == () and not row.available for action in ("add", "update"): with pytest.raises(ValueError): comms.relationships.edit( @@ -242,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" @@ -300,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") @@ -317,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.views() == () assert "Retained explicit contact" in unavailable.detail @@ -348,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) @@ -366,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) diff --git a/tests/test_runtime_source.py b/tests/test_runtime_source.py index 58fa6e9944..3fa7c06ff1 100644 --- a/tests/test_runtime_source.py +++ b/tests/test_runtime_source.py @@ -7,7 +7,7 @@ import pytest from agent_comms.installer.commands import InstallCommands -from agent_comms.installer.refusals import UpgradeRefused +from agent_comms.installer.refusals import OwnersNotRestarted, UpgradeRefused from agent_comms.installer.upgrade import OwnersStopped, Stack from agent_comms.launch_runtime import LaunchRuntime, RestoredRuntime, RunningRuntime, RunningSource from agent_comms.maintenance_barrier import TargetRuntime @@ -54,13 +54,29 @@ def test_restart_refusal_is_retained_and_chained(comms, tmp_path): with InstallCommands() as commands: stack = Stack(Path(__file__).resolve().parents[1], commands, home=tmp_path, runtime=LaunchRuntime.running()) - with pytest.raises(UpgradeRefused) as failed: - with owners.give_back: - owners.give_back.callback(owners.start_on, stack, comms.root) + with pytest.raises(OwnersNotRestarted) as failed: + with owners.given_back_on(stack, comms.root): raise UpgradeRefused("original stop refusal") assert owners.started assert owners.started[0].name == owners.stopped[0] - assert failed.value.__context__.args == ("original stop refusal",) + assert failed.value.__cause__.args == ("original stop refusal",) with pytest.raises(UpgradeRefused) as recorded: owners.started[0].require_success(UpgradeRefused) - assert str(failed.value) == str(recorded.value) + assert str(failed.value.restart) == str(recorded.value) + assert "original stop refusal" in str(failed.value) + + +def test_a_succeeding_install_restarts_its_owners_unless_taken_over(comms, tmp_path): + with InstallCommands() as commands: + stack = Stack(Path(__file__).resolve().parents[1], commands, home=tmp_path, + runtime=LaunchRuntime.running()) + owners = OwnersStopped(stopped=["p717-owner-that-does-not-exist"]) + with pytest.raises(UpgradeRefused) as refused: + with owners.given_back_on(stack, comms.root): + pass + assert not isinstance(refused.value, OwnersNotRestarted) + assert owners.started[0].name == owners.stopped[0] + taken = OwnersStopped(stopped=["p717-owner-that-does-not-exist"]) + with taken.given_back_on(stack, comms.root): + taken.retired() + assert taken.started == () 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_sidebar_observation.py b/tests/test_sidebar_observation.py index 750f599054..207e219d32 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -4,17 +4,20 @@ import pickle from collections.abc import Mapping from dataclasses import fields, replace +from pathlib import Path 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.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 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 @@ -58,10 +61,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.lookup_names # 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.born(original.incarnation.require_recorded().lookup) == original + lookup = original.incarnation.require_recorded().lookup + assert restored.born(lookup) == original + assert dict(restored.lookup_names) == {lookup: original.name} + assert list(restored.lookup_names.items()) == [(lookup, original.name)] + assert list(restored.lookup_names.values()) == [original.name] + assert restored.lookup_names == {lookup: original.name} + with pytest.raises(TypeError): + restored.lookup_names[lookup] = original.name + with pytest.raises(TypeError): + del restored.lookup_names[lookup] for declaration in fields(restored): value = getattr(restored, declaration.name) if isinstance(value, Mapping): @@ -92,7 +105,13 @@ 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.lookup_names + lookup = original.incarnation.lookup comms.registry.rename(original.name, "renamed") + assert comms.registry.snapshot().born(lookup).name == "renamed" + assert before[lookup] == original.name + assert captured.born(lookup) == original 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 +123,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() before = comms.views.viewer_snapshot(Checkout(comms.root.parent)).references comms.threads.delete(original.name) + deleted = comms.registry.snapshot().lookup_names recreated = declare_reference(original.name) + current = comms.registry.snapshot() + assert original.incarnation.lookup not in deleted + assert original.incarnation.lookup not in current.lookup_names + assert current.born(recreated.incarnation.lookup) == recreated + assert captured.born(original.incarnation.lookup) == original + with pytest.raises(UnregisteredThreadError): + current.born(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 +155,209 @@ 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 supplies the row's actual Thread. + snapshot = replace(captured, threads=rows) + 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 + 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(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) + 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) == selected.name + assert snapshot.born(selected.incarnation.lookup) is acquired + 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): + {key: snapshot.born(key) for key in lookup} + with pytest.raises(UnregisteredThreadError): + 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()) + unread = declare_reference("unread") + comms.registry.rename(original.name, "renamed") + captured = comms.registry.snapshot() + 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") + + references = CurrentDisplayReferences.from_registry(str(comms.root), snapshot, ("#comms",)) + 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", ( + 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 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 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): + snapshot.born(original.incarnation.lookup) + + +def test_lookup_metadata_is_derived_from_each_unchanged_captured_row_set( + comms, declare_reference, +): + original = declare_reference(status=RunningThreadStatus()) + declare_reference("unrelated") + before = comms.registry.snapshot() + lookup = original.incarnation.lookup + original_index = before.lookup_names + changed = comms.registry.update_thread( + original.name, lambda thread: replace(thread, task=GivenThreadText("data-only edit")), + ) + after = comms.registry.snapshot() + assert after.born(lookup) == changed + assert after.lookup_names == original_index + assert original_index[lookup] == original.name + assert before.born(lookup) == original + assert tuple(after.lookup_names) == tuple(after.incarnation(name).require_recorded().lookup + for name in after.threads) + comms.registry.rename(original.name, "renamed") + renamed = comms.registry.snapshot() + assert renamed.born(lookup).name == "renamed" + assert original_index[lookup] == original.name + assert after.born(lookup).name == original.name + assert tuple(renamed.lookup_names) == 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"), []), + (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}).determining(original.name) + + +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}).determining(original.name) + + 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 diff --git a/tests/test_store_backup.py b/tests/test_store_backup.py index 5651d5a967..afb76bc6a6 100644 --- a/tests/test_store_backup.py +++ b/tests/test_store_backup.py @@ -2,12 +2,12 @@ import multiprocessing import os import sqlite3 -from contextlib import closing +from contextlib import ExitStack, closing from pathlib import Path import pytest -from agent_comms.installer.backup import BackupName, StoreBackup +from agent_comms.installer.backup import BackupName, SavedFile, StoreBackup from agent_comms.coordination_database import ( CoordinationStore, InvalidStore, MissingStore, ReadableStore, ) @@ -104,6 +104,26 @@ def test_observation_classifies_missing_foreign_and_busy_files_without_inventing writer.execute("ROLLBACK") +def test_a_pinned_store_keeps_its_bytes_through_every_other_close(tmp_path): + """Only a database's last close checkpoints its WAL into the file; the pin is never + last, and it waits on no reader of the latest snapshot.""" + path = ExternalTranscript.path_in(tmp_path) + members = (path, SQLiteFile.wal_path(path)) + with ExitStack() as connections: + writer = connections.enter_context(closing(sqlite3.connect(path, isolation_level=None))) + writer.execute("PRAGMA journal_mode=WAL") + writer.execute("PRAGMA wal_autocheckpoint=0") + writer.execute("CREATE TABLE events (body TEXT)") + writer.execute("INSERT INTO events VALUES ('framed')") + connections.enter_context(closing(SQLiteFile.read_snapshot(path))) + with SQLiteFile.pinned(path): + before = {member: SavedFile.at(member) for member in members} + connections.close() + assert {member: SavedFile.at(member) for member in members} == before + with closing(SQLiteFile.read_snapshot(path)) as reader: + assert [tuple(row) for row in reader.execute("SELECT body FROM events")] == [("framed",)] + + def test_tree_copy_keeps_committed_wal_rows(tmp_path): root = tmp_path / 'source' root.mkdir() diff --git a/tests/test_thread_creation.py b/tests/test_thread_creation.py new file mode 100644 index 0000000000..3dbb64f2b7 --- /dev/null +++ b/tests/test_thread_creation.py @@ -0,0 +1,125 @@ +"""Creation contracts through the actual CLI decoder, editor binding and registry. + +Author does not execute these controls; the integration owner selects them once. +""" +from dataclasses import replace +import json + +import pytest + +from agent_comms.cli import build_parser +from agent_comms.cli_command import PendingCommandBinding +from agent_comms.cli_verbs.fork import ForkCliCommand +from agent_comms.cli_verbs.new import NewThreadCliCommand +from agent_comms.field_codec import MalformedRecord, compile_decoder +from agent_comms.target_fields import UnsuppliedTargetField +from agent_comms.launch_runtime import LaunchRuntime +from agent_comms.repo import Checkout +from agent_comms.thread_management import GivenTags, InheritedTags + + +def parsed(arguments): + namespace = build_parser(arguments).parse_args(arguments) + return namespace.command_declaration.from_arguments(namespace) + + +def test_new_cli_requires_an_explicit_tag_argument(source_repository): + with pytest.raises(MalformedRecord): + parsed(['new', '--name', 'child', '--project', str(source_repository.checkout)]) + + +@pytest.mark.parametrize('text,expected', ( + ('auth,review', frozenset({'auth', 'review'})), + ('', frozenset()), +)) +def test_new_cli_membership_reaches_registration(comms, source_repository, text, expected): + command = parsed(['new', '--name', 'child', '--project', str(source_repository.checkout), + '--tags', text]) + assert command.tags == GivenTags(expected) + # New's declaration path reserves its session but does not launch a provider. + child = command.specification().declare(comms.threads, '', LaunchRuntime.running()) + assert child.tags == expected + assert comms.registry.require(child.name).tags == expected + + +def test_pending_new_binding_offers_missing_tags_without_constructing_the_command(comms, source_repository): + binding, = NewThreadCliCommand.project_bindings(comms, Checkout(source_repository.checkout)) + assert type(binding) is PendingCommandBinding + name = next(item for item in binding.editable_fields if item.name == 'name') + assert type(name) is UnsuppliedTargetField + assert name.argument(None) == {} + with pytest.raises(MalformedRecord): + binding.edited(NewThreadCliCommand.editor_arguments({'tags': ''})) + tags = next(item for item in binding.editable_fields if item.name == 'tags') + assert tags.required + assert type(tags) is UnsuppliedTargetField + assert 'editor_default' not in tags.json_schema() + assert tags.argument(None) == {} + assert tags.argument('') == {'tags': ''} + with pytest.raises(MalformedRecord): + binding.edited(NewThreadCliCommand.editor_arguments({'name': 'child'})) + + +@pytest.mark.parametrize('text,expected', ( + ('auth', frozenset({'auth'})), + ('', frozenset()), +)) +def test_pending_new_binding_distinguishes_explicit_empty_from_missing(comms, source_repository, text, expected): + binding, = NewThreadCliCommand.project_bindings(comms, Checkout(source_repository.checkout)) + command = binding.edited(NewThreadCliCommand.editor_arguments({'name': 'child', 'tags': text})) + assert command.tags == GivenTags(expected) + assert command.project == Checkout(source_repository.checkout) + + +def test_pending_catalog_context_can_change_before_tags_are_supplied(comms, source_repository): + binding, = NewThreadCliCommand.project_bindings(comms, Checkout(source_repository.checkout)) + prepared = binding.for_fields({'task': 'child task', 'name': 'child'}) + assert prepared.context.task == 'child task' + assert type(next(item for item in prepared.editable_fields if item.name == 'name')) is UnsuppliedTargetField + tags = next(item for item in prepared.editable_fields if item.name == 'tags') + assert tags.argument(None) == {} + assert tags.argument('') == {'tags': ''} + + +@pytest.mark.parametrize('creation_arguments,forbidden', ( + (('new', '--tags', 'auth'), {'not_a_field': 'value'}), + (('fork', '--parent', 'parent'), {'not_a_field': 'value'}), + (('fork', '--parent', 'parent'), {'parent': 'different-parent'}), +)) +def test_complete_creation_commands_keep_command_edit_authorization(source_repository, creation_arguments, forbidden): + command = parsed([*creation_arguments, '--name', 'child', + '--project', str(source_repository.checkout)]) + with pytest.raises(ValueError): + command.edited(forbidden) + + +def test_structured_editor_reports_malformed_json_at_the_data_boundary(): + decoder = compile_decoder(tuple[int, ...]) + with pytest.raises(MalformedRecord) as refused: + decoder.decode_editor('[1,') + assert type(refused.value.__cause__) is json.JSONDecodeError + assert decoder.decode(decoder.decode_editor('[1, 2]')) == (1, 2) + + +def test_fork_cli_defaults_to_actual_parent_membership(comms, source_repository): + parent_command = parsed(['new', '--name', 'parent', '--project', str(source_repository.checkout), + '--tags', 'original']) + parent = parent_command.specification().declare(comms.threads, '', LaunchRuntime.running()) + command = parsed(['fork', '--name', 'child', '--parent', parent.name, + '--project', str(source_repository.checkout)]) + assert command.tags == InheritedTags() + binding = ForkCliCommand.thread_binding(parent) + assert type(binding) is PendingCommandBinding + assert binding.context.parent == parent.name + assert type(next(item for item in binding.editable_fields if item.name == 'name')) is UnsuppliedTargetField + with pytest.raises(MalformedRecord): + binding.edited({}) + with pytest.raises(ValueError, match='Target-bound'): + binding.edited({'name': 'child', 'parent': 'other'}) + editor = binding.edited(ForkCliCommand.editor_arguments({'name': 'child'})) + assert editor.tags == InheritedTags() + # A real later parent record, not the stale tags at editor opening, supplies membership. + current = comms.registry.declare(replace(parent, tags=frozenset({'current'}))) + child = command.specification().child(current, current.runtime, comms.threads) + assert child.tags == current.tags == frozenset({'current'}) + assert editor.tags.for_child(current.tags) == current.tags 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 diff --git a/tests/test_watch_git_preparation.py b/tests/test_watch_git_preparation.py index 08d9e4a724..50462fc98d 100644 --- a/tests/test_watch_git_preparation.py +++ b/tests/test_watch_git_preparation.py @@ -35,9 +35,9 @@ BusStorageAbsent, CommitMerged, EvidenceFound, EvidenceUnread, MainTree, PathsAbsent, RetainedPlanReadings, ) -from agent_comms.repo import GitCommandError, Repo, UnavailableCommitError +from agent_comms.repo import Checkout, GitCommandError, Repo, UnavailableCommitError from agent_comms.todos import DraftPullRequest, UnmergedPullRequest -from agent_comms.work_branches import WorkBranchError, WorkBranches +from agent_comms.work_branches import SourceGrowth, WorkBranchError, WorkBranches from source_revision_fixture import SourceRepository @@ -253,12 +253,12 @@ def test_failed_plan_acquisition_is_unknown_and_retries_without_main_moving(git_ evidence = CommitMerged(head) plans = RetainedNominalReadings() Repo.git(repo.checkout, "remote", "set-url", "origin", str(repo.checkout / "absent-remote")) - failed = plans.prepare_reading(repo, (), (evidence,)) + failed = plans.prepare_reading(repo, (), (evidence,), history=Checkout(repo.checkout).read()) assert isinstance(failed.read(evidence), EvidenceUnread) assert head in failed.describe(evidence) and "origin" in failed.describe(evidence) plans.observe_reading(failed, (evidence,)) Repo.git(repo.checkout, "remote", "set-url", "origin", str(case.source.remote_path)) - recovered = plans.prepare_reading(repo, (), (evidence,)) + recovered = plans.prepare_reading(repo, (), (evidence,), history=Checkout(repo.checkout).read()) assert recovered.main.commit == failed.main.commit assert repo.has_commit(head) prepared = case.snapshot() @@ -269,7 +269,8 @@ def test_failed_plan_acquisition_is_unknown_and_retries_without_main_moving(git_ def test_unexpected_plan_object_loss_is_unknown_not_phase_completion(git_preparation): repo = git_preparation.repository - reading = RetainedNominalReadings().prepare_reading(repo, (), ()) + reading = RetainedNominalReadings().prepare_reading( + repo, (), (), history=Checkout(repo.checkout).read()) missing = CommitMerged("f" * 40) assert isinstance(reading.read(missing), EvidenceUnread) assert not reading.holds(missing) @@ -308,7 +309,7 @@ def test_promised_content_is_unreadable_not_absent_and_never_lazily_acquired( trace = tmp_path / "observation.trace2.jsonl" monkeypatch.setenv("GIT_TRACE2_EVENT", str(trace)) before = case.snapshot() - main = MainTree(repo, head, NominalCensus()) + main = MainTree(repo, head, NominalCensus(), history=Checkout(repo.checkout).read()) assert main.has("src/promised.py") assert not main.has("src/deleted.py") assert main.missing(paths) == (False, True, False) @@ -320,12 +321,12 @@ def test_promised_content_is_unreadable_not_absent_and_never_lazily_acquired( changed = reader.changed(base, head) assert changed == frozenset(paths) with pytest.raises(UnavailableCommitError): - reader.source_growth(base, head, changed) + SourceGrowth.capture(repo.checkout, base, head, changed, timeout=reader.timeout) with pytest.raises(GitCommandError): Repo.git_pipeline(repo.checkout, ("cat-file", "blob", objects[0]), ("hash-object", "--stdin")) evidence = BusStorageAbsent() plans = RetainedNominalReadings() - failed = plans.prepare_reading(repo, (), (evidence,)) + failed = plans.prepare_reading(repo, (), (evidence,), history=Checkout(repo.checkout).read()) plans.observe_reading(failed, (evidence,)) assert isinstance(failed.read(evidence), EvidenceUnread) assert "required blob unavailable" in failed.describe(evidence) @@ -339,11 +340,12 @@ def test_promised_content_is_unreadable_not_absent_and_never_lazily_acquired( # Only this explicit joined acquisition changes Git. The same-main unknown must retry. Repo.git(repo.checkout, "fetch", "--quiet", "--no-tags", "origin", *objects) prepared = case.snapshot() - recovered = plans.prepare_reading(repo, (), (evidence,)) + recovered = plans.prepare_reading(repo, (), (evidence,), history=Checkout(repo.checkout).read()) plans.observe_reading(recovered, (evidence,)) assert recovered.main.commit == failed.main.commit == head assert recovered.read(evidence) == EvidenceFound(True) - assert dict(reader.source_growth(base, head, changed).files) == { + assert dict(SourceGrowth.capture( + repo.checkout, base, head, changed, timeout=reader.timeout).files) == { "src/added.py": 1, "src/deleted.py": -1, "src/promised.py": 1, } assert case.snapshot() == prepared @@ -740,7 +742,8 @@ def test_pushed_conflict_markers_are_the_branchs_fact_not_an_error(tmp_path): Repo.git(checkout, "commit", "-q", "-am", "conflict markers pushed") head = Repo.git(checkout, "rev-parse", "HEAD").strip() reader = WorkBranches(checkout) - growth = reader.source_growth(base, head, reader.changed(base, head)) + growth = SourceGrowth.capture(reader.checkout, base, head, reader.changed(base, head), + timeout=reader.timeout) assert [(source.path, source.line) for source in growth.unparsed] == [("src/owner.py", 1)] with pytest.raises(WorkBranchError, match="src/owner.py:1"): growth.require_parsed() diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index 985f612733..ca4400a3b2 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -47,7 +47,7 @@ from agent_comms.nominal_census import NominalCensus from agent_comms.phase_evidence import MainTree, PlanReading from agent_comms.plan_phases import PlanPhase -from agent_comms.repo import Repo +from agent_comms.repo import Checkout, Repo SOURCES = Path(inspect.getfile(agent_comms)).parents[1] REPOSITORY = SOURCES.parent @@ -193,7 +193,8 @@ def guard_roots(self) -> frozenset[str]: @cache def reading(path: Path, commit: str) -> PlanReading: """The plan phases as the commit declares them, read once per checkout and commit.""" - return PlanReading((), MainTree(Repo.of(path), commit, NominalCensus())) + return PlanReading((), MainTree( + Repo.of(path), commit, NominalCensus(), history=Checkout(path).read())) def failing_guards(self, commit: str) -> frozenset[type[PlanPhase]]: reading = self.reading(self.path, commit)