From fa931e9e75c6edd7d980571eeda175a4eebbd6ac Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 15:29:18 -0400 Subject: [PATCH 01/74] Stream wire JSONL from declared stored fields and SQL suffix selection --- src/agent_comms/bus.py | 15 +++++ src/agent_comms/exporting.py | 95 ++++++++++++++++++++++++++------ src/agent_comms/field_codec.py | 16 ++++++ src/agent_comms/history_views.py | 16 +++--- src/agent_comms/messages.py | 67 ++++++++++++++++++++++ src/agent_comms/typed_table.py | 27 +++++++++ 6 files changed, 212 insertions(+), 24 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index 57efe574de..3cacb8e4ad 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -36,6 +36,7 @@ Claimed, ClaimMessage, Message, + StoredWireHistory, MessageKind, PublicRecord, ) @@ -243,6 +244,20 @@ def page(self, before: int, limit: int) -> tuple[BusRecord, ...]: 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.""" + identity = MessageIdentitySource + query = Message.stored_wire_query(identity.qualified("message_id")) + query += (f' JOIN "{identity.declared_name}" ON ' + f'{identity.qualified("seq")} = {BusRow.qualified("seq")}') + query = f'SELECT * FROM ({query}) WHERE seq <= ? AND ({predicate})' + with self.observing() as db: + yield StoredWireHistory(db, query, (through, *parameters)) + def message_history(self) -> Iterator[Message]: """Public bulk export, paged within one committed SQLite snapshot.""" with self.reading() as source: diff --git a/src/agent_comms/exporting.py b/src/agent_comms/exporting.py index e63f80a52b..7195cfcf9f 100644 --- a/src/agent_comms/exporting.py +++ b/src/agent_comms/exporting.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import json import math import os from abc import abstractmethod @@ -18,7 +19,7 @@ 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 .registration import Registration from .store_files import AtomicFile, ExclusiveAtomicFile @@ -41,7 +42,20 @@ def row(self, message: Message) -> bytes: @staticmethod def json_record(record: Mapping[str, object]) -> str: - return FieldCodec.compact(record, ensure_ascii=False) + return json.dumps(record, ensure_ascii=False, separators=(",", ":")) + + def stored_row(self, message: StoredWireMessage) -> bytes: + return self.row(message.decode()) + + def newest_suffix(self, source: StoredWireHistory, capacity: int) -> int: + first = 0 + used = 0 + for message in source.rows(descending=True): + used += len(self.stored_row(message)) + if used > capacity: + return first or message.seq + 1 + first = message.seq + return first class SelectableWireExportFormat(WireExportFormat): @@ -60,6 +74,13 @@ def row(self, message: Message) -> bytes: "record": "message", "message": Message.from_message(message).to_wire(), }) + "\n").encode() + def stored_row(self, message: StoredWireMessage) -> bytes: + return ('{"record":"message","message":' + message.wire + '}\n').encode() + + def newest_suffix(self, source: StoredWireHistory, capacity: int) -> int: + overhead = len(b'{"record":"message","message":}\n') + return source.newest_suffix(f"length(CAST(wire AS BLOB)) + {overhead}", capacity) + @dataclass(frozen=True) class TextFormat(SelectableWireExportFormat): @@ -97,6 +118,8 @@ class ResolvedExportScope: scope: WireExportScope matches: Callable[[Message], bool] + predicate: str = "1" + parameters: tuple[object, ...] = () class WireExportScope(DeclaredFamily, affix="Scope"): @@ -123,7 +146,9 @@ 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) + sequences = tuple(reference.seq for reference in self.sources) + return ResolvedExportScope(self, lambda message: message.reference in self.sources, + 'seq IN (' + ','.join('?' for _ in sequences) + ')', sequences) @dataclass(frozen=True, slots=True) @@ -133,7 +158,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, history.includes, predicate, parameters) @dataclass(frozen=True, slots=True) @@ -160,7 +187,10 @@ def matches(message: Message) -> bool: message.sender in second_names and message.target in first_names ) - return ResolvedExportScope(canonical, matches) + predicate = '((sender IN ({a}) AND target IN ({b})) OR (sender IN ({b}) AND target IN ({a})))'.format( + a=','.join('?' for _ in first_names), b=','.join('?' for _ in second_names)) + return ResolvedExportScope(canonical, matches, predicate, + (*first_names, *second_names, *second_names, *first_names)) class ScopeFlag(SubclassUnion, affix="ScopeFlag"): @@ -214,6 +244,14 @@ def byte_ceiling(self) -> int | None: def time_cutoff(self) -> float | None: return None + def stored_rows( + self, source: StoredWireHistory, format: WireExportFormat, + 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 +269,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 +312,15 @@ def __post_init__(self) -> None: def byte_ceiling(self) -> int: return self.value + def stored_rows( + self, source: StoredWireHistory, format: WireExportFormat, + header_size: int, started_at: float, stats: ExportSelectionStats, + ) -> Iterable[WireExportRow]: + stats.source_messages = source.count() + first = format.newest_suffix(source, self.value - header_size) + return (WireExportRow(message.seq, message.timestamp, format.stored_row(message)) + for message in source.rows(after=first)) + def retain( self, rows: Iterable[WireExportRow], @@ -397,7 +444,8 @@ def to_wire(self) -> dict[str, object]: @dataclass(frozen=True, slots=True) class WireExportRow: - message: Message + seq: int + timestamp: float data: bytes @@ -434,7 +482,23 @@ def export( *, overwrite: bool = False, ) -> WireExportProvenance: - """Consume ascending messages once and atomically publish the artifact.""" + """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) + + def export_stored( + self, source: StoredWireHistory, destination: Path, *, overwrite: bool = False, + ) -> WireExportProvenance: + """Select the stored suffix, then write ascending rows without Message construction.""" + return self._export(destination, lambda stats, header: self.limit.stored_rows( + source, self.format, header, self.boundary.export_started_at, stats), overwrite=overwrite) + + def _export( + self, destination: Path, + rows: Callable[[ExportSelectionStats, int], Iterable[WireExportRow]], + *, overwrite: bool, + ) -> WireExportProvenance: header = self._header() self.limit.validate_header(len(header)) @@ -446,20 +510,17 @@ 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: WireExportRow | None = None + last_selected: WireExportRow | None = None with artifact.staged() as output: writer = _ArtifactWriter(output) writer.write(header) - rows = self._rows(messages, stats) - for row in self.limit.select( - rows, len(header), self.boundary.export_started_at, stats - ): + 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 + first_selected = row + last_selected = row bytes_written = writer.size output_sha256 = writer.digest.hexdigest() @@ -511,7 +572,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 db0eb0ab3f..bf479b48bf 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -652,6 +652,12 @@ def wire_keys(self) -> tuple[str, ...]: def encoded(self, value, *, key=None): return {key or self.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 base = FieldCodec.representation(annotation)[0] @@ -783,6 +789,11 @@ def wire_keys(self) -> tuple[str, ...]: def encoded(self, value: object, *, key: str | None = None) -> dict[str, object]: 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}") @@ -844,6 +855,11 @@ def wire_keys(self) -> tuple[str, ...]: def encoded(self, value, *, key=None): 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]: if not isinstance(data, dict): raise MalformedRecord("Expected a record object.") diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index e5c1c1571e..0b7f912c3a 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -307,13 +307,15 @@ def export_wire( 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) def channel_views( self, visibility: RosterVisibility = DEFAULT_ROSTER, diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 7425d003dd..8f21400e7f 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -1002,6 +1002,26 @@ def display_metadata(self) -> dict: """Live messages carry no historical provenance decoration.""" return {} + @classmethod + def stored_wire_query(cls, identity: str) -> str: + """Project the public envelope from its declared stored fields, not domain objects.""" + tables = cls.tables() + stored = {item.name: (table, item) for table in tables for item in table._fields} + pieces = [item.wire_record(plan, table.declared_name) + for plan in FieldCodec.field_plans(cls) if plan.records + for table, item in (stored[plan.name],)] + identity_key = FieldCodec._projections(cls, "wire")["message_id"].wire_name + pieces.append("json_object('" + identity_key + "', " + identity + ")") + 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'{item.reference(table.declared_name)} AS "{name}"' + for name, (table, item) in stored.items()) + sequence = BusRow.qualified("seq") + joins = " ".join(f'JOIN "{table.declared_name}" ON {table.qualified("seq")} = {sequence}' + for table in tables) + return f'SELECT {columns}, {wire} AS wire FROM "{BusRow.declared_name}" {joins}' + def to_wire(self) -> dict: return {"seq": self.seq, "id": self.message_id, **FieldCodec.encode(self.envelope)} @@ -1131,6 +1151,53 @@ 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 + + def decode(self) -> Message: + return Message.from_wire(FieldCodec.parse(self.wire)) + + +@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 count(self) -> int: + return self.db.execute(f"SELECT count(*) FROM ({self.query})", self.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 first + + class PublicRecord(BusRecord): sender_lookup: ClassVar[PublicSender] = PublicSender() diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index db1a6f6bac..6aaa8b1a42 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -141,6 +141,11 @@ def to_sql(cls, value: object) -> object: def from_sql(cls, value: object) -> object: return value + @classmethod + def json_text(cls, expression: str) -> str: + """The stored value as JSON text, without a Python decode/encode trip.""" + return f"json_quote({expression})" + @classmethod def for_type(cls, annotation: object) -> type[SqlStorage]: annotation, _ = _base_type(annotation) @@ -196,6 +201,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, 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): @@ -266,6 +275,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, expression: str) -> str: + return f"COALESCE({expression}, 'null')" + @classmethod def from_sql(cls, value: object) -> object: if type(value) is not str: @@ -492,6 +505,20 @@ class _Field(SqlValue): def reference(self, alias: str) -> str: return f"{alias}.{_identifier(self.wire_name)}" + def wire_record(self, plan: FieldPlan, alias: str) -> str: + expression = self.reference(alias) + projected = plan.project_encoded(self.storage.json_text(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]) From 19d359f4ef85e4335d854d213a496e1ae48bd19b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 15:37:54 -0400 Subject: [PATCH 02/74] Publish export-owned progress and cooperative SQLite cancellation --- src/agent_comms/exporting.py | 117 ++++++++++++++++++++++++++----- src/agent_comms/history_views.py | 5 +- src/agent_comms/messages.py | 6 +- 3 files changed, 108 insertions(+), 20 deletions(-) diff --git a/src/agent_comms/exporting.py b/src/agent_comms/exporting.py index 7195cfcf9f..185acc396f 100644 --- a/src/agent_comms/exporting.py +++ b/src/agent_comms/exporting.py @@ -6,10 +6,13 @@ import json 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 dataclasses import asdict, dataclass, field +from contextlib import contextmanager +from threading import Event from datetime import UTC, datetime from pathlib import Path from typing import BinaryIO, ClassVar @@ -47,6 +50,9 @@ def json_record(record: Mapping[str, object]) -> str: def stored_row(self, message: StoredWireMessage) -> bytes: return self.row(message.decode()) + def oversized_rows(self, source: StoredWireHistory, capacity: int) -> int: + return sum(len(self.stored_row(message)) > capacity for message in source.rows()) + def newest_suffix(self, source: StoredWireHistory, capacity: int) -> int: first = 0 used = 0 @@ -65,21 +71,26 @@ 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_row(self, message: StoredWireMessage) -> bytes: - return ('{"record":"message","message":' + message.wire + '}\n').encode() + return self.row_prefix + message.wire.encode() + self.row_suffix + + def stored_size(self) -> str: + return f"length(CAST(wire AS BLOB)) + {len(self.row_prefix) + len(self.row_suffix)}" + + def oversized_rows(self, source: StoredWireHistory, capacity: int) -> int: + return source.count_where(f"{self.stored_size()} > ?", (capacity,)) def newest_suffix(self, source: StoredWireHistory, capacity: int) -> int: - overhead = len(b'{"record":"message","message":}\n') - return source.newest_suffix(f"length(CAST(wire AS BLOB)) + {overhead}", capacity) + return source.newest_suffix(self.stored_size(), capacity) @dataclass(frozen=True) @@ -317,9 +328,11 @@ def stored_rows( header_size: int, started_at: float, stats: ExportSelectionStats, ) -> Iterable[WireExportRow]: stats.source_messages = source.count() - first = format.newest_suffix(source, self.value - header_size) - return (WireExportRow(message.seq, message.timestamp, format.stored_row(message)) - for message in source.rows(after=first)) + capacity = self.value - header_size + first = format.newest_suffix(source, capacity) + for message in source.rows(after=first): + yield WireExportRow(message.seq, message.timestamp, format.stored_row(message)) + stats.oversized = format.oversized_rows(source, capacity) def retain( self, @@ -449,19 +462,76 @@ class WireExportRow: data: bytes +@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 + + def cancelled(self) -> bool: + return False + + @contextmanager + def reading(self, source: StoredWireHistory): + 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) + + +@dataclass +class CancellableWireExport(WireExportObserver): + stop_requested: Event = field(default_factory=Event) + + 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") + + 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: @@ -481,23 +551,27 @@ 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) + 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.""" - return self._export(destination, lambda stats, header: self.limit.stored_rows( - source, self.format, header, self.boundary.export_started_at, stats), overwrite=overwrite) + with observer.reading(source): + return self._export(destination, lambda stats, header: self.limit.stored_rows( + source, self.format, header, self.boundary.export_started_at, stats), + overwrite=overwrite, observer=observer) def _export( self, destination: Path, rows: Callable[[ExportSelectionStats, int], Iterable[WireExportRow]], - *, overwrite: bool, + *, overwrite: bool, observer: WireExportObserver, ) -> WireExportProvenance: header = self._header() self.limit.validate_header(len(header)) @@ -509,21 +583,28 @@ def _export( artifact = AtomicFile(destination) if overwrite else ExclusiveAtomicFile(destination) stats = ExportSelectionStats() - exported = 0 first_selected: WireExportRow | None = None last_selected: WireExportRow | None = None with artifact.staged() as output: - writer = _ArtifactWriter(output) + writer = _ArtifactWriter(output, observer) writer.write(header) + writer.report() + reported = 0 for row in rows(stats, len(header)): writer.write(row.data) - exported += 1 + writer.messages += 1 + if writer.size - reported >= 65536: + writer.report() + reported = writer.size if first_selected is None: first_selected = 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( destination=str(destination), diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index 0b7f912c3a..80a74adabc 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -45,6 +45,7 @@ WireExportFormat, WireExportLimit, WireExportProvenance, + WireExportObserver, WireExportScope, WireTranscriptExporter, ) @@ -301,6 +302,7 @@ 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 @@ -315,7 +317,8 @@ def export_wire( scope=resolved.scope, limit=limit, boundary=WireExportBoundary(through, started_at), - ).export_stored(selected, Path(destination).expanduser(), overwrite=overwrite) + ).export_stored(selected, Path(destination).expanduser(), overwrite=overwrite, + observer=observer) def channel_views( self, visibility: RosterVisibility = DEFAULT_ROSTER, diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 8f21400e7f..cbcb4104ad 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -1181,7 +1181,11 @@ def rows(self, *, after: int = 0, descending: bool = False) -> Iterator[StoredWi yield StoredWireMessage(*row) def count(self) -> int: - return self.db.execute(f"SELECT count(*) FROM ({self.query})", self.parameters).fetchone()[0] + return self.count_where("1") + + def count_where(self, predicate: str, parameters: tuple[object, ...] = ()) -> int: + return 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.""" From 3b67e7ec46ea761c611aa93b709aac4ee9cf4a7b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 16:07:06 -0400 Subject: [PATCH 03/74] Keep one scope and cutoff owner and preserve stored floating point values --- src/agent_comms/bus.py | 8 ++-- src/agent_comms/bus_tables.py | 11 +++++ src/agent_comms/exporting.py | 78 ++++++++++++++-------------------- src/agent_comms/messages.py | 21 +++++---- src/agent_comms/typed_table.py | 14 ++++++ 5 files changed, 72 insertions(+), 60 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index 3cacb8e4ad..756c6360f4 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -250,11 +250,9 @@ def stored_message_history( *, through: int, ) -> Iterator[StoredWireHistory]: """Stream declared public wire fields from one read snapshot, without Messages.""" - identity = MessageIdentitySource - query = Message.stored_wire_query(identity.qualified("message_id")) - query += (f' JOIN "{identity.declared_name}" ON ' - f'{identity.qualified("seq")} = {BusRow.qualified("seq")}') - query = f'SELECT * FROM ({query}) WHERE seq <= ? AND ({predicate})' + query = Message.stored_wire_query(MessageIdentitySource) + query = (f'SELECT seq, timestamp, wire FROM ({query}) ' + f'WHERE seq <= ? AND ({predicate})') with self.observing() as db: yield StoredWireHistory(db, query, (through, *parameters)) diff --git a/src/agent_comms/bus_tables.py b/src/agent_comms/bus_tables.py index 4195c5cd3f..9f21b25d28 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/exporting.py b/src/agent_comms/exporting.py index 185acc396f..ed946b69c1 100644 --- a/src/agent_comms/exporting.py +++ b/src/agent_comms/exporting.py @@ -3,14 +3,13 @@ from __future__ import annotations import hashlib -import json 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, field +from collections.abc import Callable, Iterable, Iterator, Mapping +from dataclasses import asdict, dataclass from contextlib import contextmanager from threading import Event from datetime import UTC, datetime @@ -23,6 +22,8 @@ from .field_codec import FieldCodec, NullMember, SingleFieldValue, SubclassUnion from .message_reference import MessageReference 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 @@ -45,23 +46,18 @@ def row(self, message: Message) -> bytes: @staticmethod def json_record(record: Mapping[str, object]) -> str: - return json.dumps(record, ensure_ascii=False, separators=(",", ":")) + return FieldCodec.compact(record, ensure_ascii=False) def stored_row(self, message: StoredWireMessage) -> bytes: return self.row(message.decode()) - def oversized_rows(self, source: StoredWireHistory, capacity: int) -> int: - return sum(len(self.stored_row(message)) > capacity for message in source.rows()) + def encoded_size(self, wire: str) -> int: + return len(self.row(StoredWireMessage.decode_wire(wire))) - def newest_suffix(self, source: StoredWireHistory, capacity: int) -> int: - first = 0 - used = 0 - for message in source.rows(descending=True): - used += len(self.stored_row(message)) - if used > capacity: - return first or message.seq + 1 - first = message.seq - return first + def stored_size(self, source: StoredWireHistory) -> str: + function = self.declared_name + "_wire_export_size" + source.db.create_function(function, 1, self.encoded_size, deterministic=True) + return f'"{function}"(wire)' class SelectableWireExportFormat(WireExportFormat): @@ -83,15 +79,9 @@ def row(self, message: Message) -> bytes: def stored_row(self, message: StoredWireMessage) -> bytes: return self.row_prefix + message.wire.encode() + self.row_suffix - def stored_size(self) -> str: + def stored_size(self, source: StoredWireHistory) -> str: return f"length(CAST(wire AS BLOB)) + {len(self.row_prefix) + len(self.row_suffix)}" - def oversized_rows(self, source: StoredWireHistory, capacity: int) -> int: - return source.count_where(f"{self.stored_size()} > ?", (capacity,)) - - def newest_suffix(self, source: StoredWireHistory, capacity: int) -> int: - return source.newest_suffix(self.stored_size(), capacity) - @dataclass(frozen=True) class TextFormat(SelectableWireExportFormat): @@ -125,12 +115,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 = "1" - parameters: tuple[object, ...] = () + predicate: str + parameters: tuple[object, ...] class WireExportScope(DeclaredFamily, affix="Scope"): @@ -147,7 +136,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) @@ -157,9 +146,8 @@ class AuthoredSourceScope(WireExportScope): sources: tuple[MessageReference, ...] def resolve(self, require_channel: Callable[[str], Channel], registry: Registration) -> ResolvedExportScope: - sequences = tuple(reference.seq for reference in self.sources) - return ResolvedExportScope(self, lambda message: message.reference in self.sources, - 'seq IN (' + ','.join('?' for _ in sequences) + ')', sequences) + predicate, parameters = MessageIdentitySource.reference_predicate(self.sources) + return ResolvedExportScope(self, predicate, parameters) @dataclass(frozen=True, slots=True) @@ -171,7 +159,7 @@ def resolve(self, require_channel: Callable[[str], Channel], registry: Registrat channel.require_routable() history = channel.history_scope() predicate, parameters = history.index_predicate - return ResolvedExportScope(self, history.includes, predicate, parameters) + return ResolvedExportScope(self, predicate, parameters) @dataclass(frozen=True, slots=True) @@ -188,20 +176,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 - ) - - predicate = '((sender IN ({a}) AND target IN ({b})) OR (sender IN ({b}) AND target IN ({a})))'.format( - a=','.join('?' for _ in first_names), b=','.join('?' for _ in second_names)) - return ResolvedExportScope(canonical, matches, predicate, - (*first_names, *second_names, *second_names, *first_names)) + predicate, parameters = DMDisplayScope(first_names, second_names).index_predicate + return ResolvedExportScope(canonical, predicate, parameters) class ScopeFlag(SubclassUnion, affix="ScopeFlag"): @@ -329,10 +311,11 @@ def stored_rows( ) -> Iterable[WireExportRow]: stats.source_messages = source.count() capacity = self.value - header_size - first = format.newest_suffix(source, capacity) + 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 = format.oversized_rows(source, capacity) + stats.oversized = source.count_where(f"{size} > ?", (capacity,)) def retain( self, @@ -485,7 +468,7 @@ def cancelled(self) -> bool: return False @contextmanager - def reading(self, source: StoredWireHistory): + def reading(self, source: StoredWireHistory) -> Iterator[None]: self.check() source.db.set_progress_handler(self.cancelled, 1000) try: @@ -497,9 +480,10 @@ def reading(self, source: StoredWireHistory): source.db.set_progress_handler(None, 0) -@dataclass class CancellableWireExport(WireExportObserver): - stop_requested: Event = field(default_factory=Event) + def __init__(self) -> None: + self.stop_requested = Event() + super().__init__() def cancel(self) -> None: self.stop_requested.set() diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index cbcb4104ad..23144aae8d 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -1003,24 +1003,25 @@ def display_metadata(self) -> dict: return {} @classmethod - def stored_wire_query(cls, identity: str) -> str: + def stored_wire_query(cls, identity: type[TypedTable]) -> str: """Project the public envelope from its declared stored fields, not domain objects.""" tables = cls.tables() stored = {item.name: (table, item) for table in tables for item in table._fields} pieces = [item.wire_record(plan, table.declared_name) for plan in FieldCodec.field_plans(cls) if plan.records for table, item in (stored[plan.name],)] - identity_key = FieldCodec._projections(cls, "wire")["message_id"].wire_name - pieces.append("json_object('" + identity_key + "', " + identity + ")") + 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'{item.reference(table.declared_name)} AS "{name}"' for name, (table, item) in stored.items()) - sequence = BusRow.qualified("seq") - joins = " ".join(f'JOIN "{table.declared_name}" ON {table.qualified("seq")} = {sequence}' - for table in tables) - return f'SELECT {columns}, {wire} AS wire FROM "{BusRow.declared_name}" {joins}' + 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)} @@ -1159,8 +1160,12 @@ class StoredWireMessage: timestamp: float wire: str + @staticmethod + def decode_wire(wire: str) -> Message: + return Message.from_wire(FieldCodec.parse(wire)) + def decode(self) -> Message: - return Message.from_wire(FieldCodec.parse(self.wire)) + return self.decode_wire(self.wire) @dataclass(frozen=True) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 6aaa8b1a42..848e4d5623 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -185,6 +185,11 @@ class RealStorage(SqlStorage): def accepts(cls, annotation: object) -> bool: return annotation is float + @classmethod + def json_text(cls, expression: str) -> str: + # Alternate-form-2 retains the stored double; json_quote rounds to 15 digits. + return f"CASE WHEN {expression} IS NULL THEN 'null' ELSE printf('%0!.26g', {expression}) END" + class BooleanStorage(SqlStorage): sql_type = "INTEGER" @@ -2428,6 +2433,15 @@ def qualified(cls, *names: str) -> str: 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``.""" From e0ac8408051889eda416f85835d14056b392d815 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 16:41:38 -0400 Subject: [PATCH 04/74] Let the cancellation capability own SQLite interruption --- src/agent_comms/exporting.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/agent_comms/exporting.py b/src/agent_comms/exporting.py index ed946b69c1..8f734a95cf 100644 --- a/src/agent_comms/exporting.py +++ b/src/agent_comms/exporting.py @@ -464,20 +464,9 @@ def advanced(self, progress: WireExportProgress) -> None: def check(self) -> None: pass - def cancelled(self) -> bool: - return False - @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) + yield class CancellableWireExport(WireExportObserver): @@ -495,6 +484,18 @@ 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.""" From a9aea28e03b6669aae603612fb90bce8e7d7ebd2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:53:47 -0400 Subject: [PATCH 05/74] Keep export selection and wire projection with their owners --- src/agent_comms/exporting.py | 52 ++++++++++++++++++++++++---------- src/agent_comms/messages.py | 15 +++++----- src/agent_comms/typed_table.py | 6 ++++ 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/agent_comms/exporting.py b/src/agent_comms/exporting.py index 8f734a95cf..6eb30d2e19 100644 --- a/src/agent_comms/exporting.py +++ b/src/agent_comms/exporting.py @@ -9,7 +9,7 @@ from abc import abstractmethod from collections import deque from collections.abc import Callable, Iterable, Iterator, Mapping -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, replace from contextlib import contextmanager from threading import Event from datetime import UTC, datetime @@ -423,10 +423,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 { @@ -438,12 +438,38 @@ 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: +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: @@ -568,8 +594,8 @@ def _export( artifact = AtomicFile(destination) if overwrite else ExclusiveAtomicFile(destination) stats = ExportSelectionStats() - first_selected: WireExportRow | None = None - last_selected: WireExportRow | None = None + first_selected: WireExportSelection = NoSelection() + last_selected: WireExportSelection = NoSelection() with artifact.staged() as output: writer = _ArtifactWriter(output, observer) writer.write(header) @@ -581,8 +607,7 @@ def _export( if writer.size - reported >= 65536: writer.report() reported = writer.size - if first_selected is None: - first_selected = row + first_selected = first_selected.select_first(row) last_selected = row writer.report() observer.check() @@ -591,7 +616,7 @@ def _export( exported = writer.messages omitted = stats.source_messages - exported - return WireExportProvenance( + metadata = WireExportProvenance( destination=str(destination), format=self.format, scope=self.scope, @@ -607,11 +632,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 = { diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 23144aae8d..cc9392c075 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -433,8 +433,10 @@ def history( f' AND {BusRow.qualified("kind")} IN ({", ".join("?" for _ in kinds)})', tuple(BusRow._fields_named(("kind",))[0].encode(kind) for kind in kinds), ) + columns = [BusRow.qualified(*BusRow.column_projection())] + columns.extend(table.qualified(*table.column_projection()) for table in tables) with closing(db.execute( - f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in (BusRow, *tables))} " + f"SELECT {', '.join(columns)} " f'FROM "{BusRow.declared_name}" ' + " ".join(f'LEFT JOIN "{table.declared_name}" ON {table.qualified("seq")} = {header}' for table in tables) @@ -1006,17 +1008,16 @@ def display_metadata(self) -> dict: def stored_wire_query(cls, identity: type[TypedTable]) -> str: """Project the public envelope from its declared stored fields, not domain objects.""" tables = cls.tables() - stored = {item.name: (table, item) for table in tables for item in table._fields} - pieces = [item.wire_record(plan, table.declared_name) - for plan in FieldCodec.field_plans(cls) if plan.records - for table, item in (stored[plan.name],)] + stored = {name: table for table in tables for name in table.column_projection()} + pieces = [stored[plan.name].wire_record(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'{item.reference(table.declared_name)} AS "{name}"' - for name, (table, item) in stored.items()) + 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) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 848e4d5623..1a01d8f4e8 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -2427,6 +2427,12 @@ 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, 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(plan, cls.declared_name) + @classmethod def qualified(cls, *names: str) -> str: """Columns named through this table, for one select joining tables that share names.""" From 05333679375ce465ad3494e4d348a752072f678f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:26:22 -0400 Subject: [PATCH 06/74] Keep foreign wire and SQLite values at typed decoding boundaries --- src/agent_comms/messages.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index cc9392c075..b0506dac3d 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -1028,11 +1028,12 @@ 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 @@ -1190,8 +1191,9 @@ def count(self) -> int: return self.count_where("1") def count_where(self, predicate: str, parameters: tuple[object, ...] = ()) -> int: - return self.db.execute(f"SELECT count(*) FROM ({self.query}) WHERE {predicate}", - (*self.parameters, *parameters)).fetchone()[0] + 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.""" @@ -1205,7 +1207,7 @@ def newest_suffix(self, size: str, capacity: int) -> int: if retained_bytes > capacity: break first = sequence - return first + return FieldCodec.decode(int, first) class PublicRecord(BusRecord): From eeee8ec6f99cb5f9a43ad0a3648ce7eb09db3aaf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:52:06 -0400 Subject: [PATCH 07/74] Project captured display identities through registry rows --- src/agent_comms/display_references.py | 6 ++--- src/agent_comms/registry_document.py | 36 ++++++++++++++++++++------ src/agent_comms/registry_provenance.py | 17 +++++++++--- tests/test_sidebar_observation.py | 19 ++++++++++++++ 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/agent_comms/display_references.py b/src/agent_comms/display_references.py index 43ea9875b7..b0be1c8e42 100644 --- a/src/agent_comms/display_references.py +++ b/src/agent_comms/display_references.py @@ -158,8 +158,8 @@ class DisplayReferences(RootedReferences): @staticmethod def recorded(root, source) -> DisplayReferences: registry = source.provenance - targets = {name: HistoricalDisplayReference(str(root), thread.incarnation, source.key) - for name, thread in registry.named_members()} + targets = {name: HistoricalDisplayReference(str(root), incarnation, source.key) + for name, incarnation in registry.named_incarnations()} return DisplayReferences(str(root), targets) def for_source(self, source) -> DisplayReferences: @@ -209,7 +209,7 @@ class CurrentDisplayReferences(SnapshotRestored, DisplayReferences): @classmethod def from_registry(cls, root: str, registry: RegistrySnapshot, channels: tuple[str, ...] = ()) -> Self: - return cls(root, {name: thread.incarnation for name, thread in registry.named_members()}, channels) + return cls(root, dict(registry.named_incarnations()), channels) def __post_init__(self) -> None: """The target and lookup views of the one stored member map.""" diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index f948421b7c..99f43754eb 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, field, replace from functools import cached_property from types import MappingProxyType -from typing import TYPE_CHECKING, Literal, Annotated, Self +from typing import TYPE_CHECKING, Literal, Annotated, Self, cast from pydantic import JsonValue @@ -55,6 +55,7 @@ OwnerIdentity, RecordedThreadIncarnation, ThreadIncarnation, + ThreadIncarnationRecord, ThreadAuthority, TurnIdentity, ) @@ -102,6 +103,13 @@ class RegistryPresence[StatusMap: Mapping[str, ThreadStatus]](RegistryNames[Thre def children(self, name: str) -> tuple[Thread, ...]: return self.threads.children(self.canonical_name(name)) + def incarnation(self, name: str) -> ThreadIncarnation: + """This captured row owns identity without acquiring its full declaration.""" + canonical = self.canonical_name(name) + if canonical not in self.threads: + raise UnregisteredThreadError(f"Thread {canonical!r} is not registered.") + return self.threads.incarnation(canonical) + def active_candidates(self) -> Iterator[Thread]: return (self.threads[name] for name, status in self.statuses.items() if status.may_be_active) @@ -344,6 +352,21 @@ def children(self, parent: str) -> tuple[Thread, ...]: else self._raw[name].get(key) == expected) ) + def creation_time(self, name: str) -> CreationTime: + """Read the declared birth only; absence must not invoke a generated default.""" + try: + if name in self._decoded: + return self._decoded[name].created_at + plan = FieldCodec.field_plan(Thread, "created_at") + return cast(CreationTime, plan.value.decode_bound(plan.raw(self._raw[name]), {})) + except ValueError as error: + raise InvalidRegistryDocument(error) from error + + def incarnation(self, name: str) -> ThreadIncarnation: + if name in self._decoded: + return self._decoded[name].incarnation + return ThreadIncarnationRecord(name, self.creation_time(name).to_wire()).incarnation() + def __getitem__(self, name: str) -> Thread: if name not in self._decoded: self._decoded[name] = self.decode_row(self._raw[name]) @@ -390,9 +413,11 @@ def schema(cls) -> dict[str, JsonValue]: def checked_rows(cls, raw: object) -> dict[str, dict[str, JsonValue]]: if not isinstance(raw, dict): raise MalformedRecord("registry threads must be an object") + name_key = FieldCodec.field_plan(Thread, "name").key for name, row in raw.items(): - if not isinstance(name, str) or not isinstance(row, dict) or row.get("name") != name: + if not isinstance(name, str) or not isinstance(row, dict) or row.get(name_key) != name: raise MalformedRecord("thread name differs from its declaration") + Thread.require_name(name) return dict(raw) def require_distinct_births(self) -> None: @@ -400,12 +425,7 @@ def require_distinct_births(self) -> None: a stored row's birth is read alone, without decoding the rest of its thread.""" births: set[float] = set() for name in self: - try: - birth = (self._decoded[name].created_at if name in self._decoded - else CreationTime.from_wire(self._raw[name].get("created_at"))) - except ValueError as error: - raise InvalidRegistryDocument(error) from error - births.add(birth.distinct_from(births)) + births.add(self.creation_time(name).distinct_from(births)) @classmethod def from_wire(cls, raw: object) -> Self: diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index b64031fed8..3c07c335a5 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -125,11 +125,22 @@ def claim_name(self, wanted: str, owner: str | None = None) -> str: name, suffix = f"{base}-{suffix}", suffix + 1 return name + def declared_names(self) -> Iterator[str]: + """The current namespace, including each declaration's retained aliases.""" + yield from self.threads + yield from self.aliases + def named_members(self) -> Iterator[tuple[str, Member]]: """Original declarations under their canonical and retained alias names.""" - yield from self.threads.items() - for alias in self.aliases: - yield alias, self.require(alias) + return ((name, self.require(name)) for name in self.declared_names()) + + def incarnation(self, name: str) -> ThreadIncarnation: + """Recorded namespaces already hold their original nominal members.""" + return self.require(name).incarnation + + def named_incarnations(self) -> Iterator[tuple[str, ThreadIncarnation]]: + """Project identity through the namespace owner, never a consumer's roster.""" + return ((name, self.incarnation(name)) for name in self.declared_names()) def names_for(self, name: str) -> frozenset[str]: """Current and retained names of one declared member.""" diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index 27656fea0b..cea9781f99 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -127,6 +127,25 @@ def test_current_reference_membership_and_lookup_share_the_retained_registry(com restored.targets["unowned"] = reference +@pytest.mark.parametrize("decoded", (False, True)) +def test_current_reference_acquires_only_captured_incarnations(comms, declare_reference, decoded): + original = declare_reference() + comms.registry.rename(original.name, "renamed") + captured = comms.registry.snapshot() + snapshot = replace(captured, threads=ThreadRows.from_wire(captured.threads.to_wire())) + if decoded: + snapshot.require("renamed") + acquired_before = dict(snapshot.threads._decoded) + + references = CurrentDisplayReferences.from_registry(str(comms.root), snapshot, ("#comms",)) + + assert snapshot.threads._decoded == acquired_before + assert references.members[original.name].name == "renamed" + assert references.members["renamed"].wire_birth == original.incarnation.wire_birth + assert references.find(f"@{original.name}")[0].reference.name == "renamed" + assert references.find("#comms")[0].reference.name == "#comms" + + def test_current_reference_source_projection_keeps_historical_identity(comms, declare_reference): original = declare_reference() references = comms.views.viewer_snapshot(Checkout(comms.root.parent)).references From d1fdcfc881f7d795c7af6a28e50ff9f59423474f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:55:21 -0400 Subject: [PATCH 08/74] Acquire relationship projections through captured rows and input capabilities --- src/agent_comms/attempt_recovery.py | 2 +- src/agent_comms/bus.py | 16 ++++- src/agent_comms/field_codec.py | 37 ++++++++-- src/agent_comms/native_claim_epoch.py | 2 +- src/agent_comms/owner_lifecycle.py | 13 ++-- src/agent_comms/registry_document.py | 93 +++++++++++++++++--------- src/agent_comms/registry_provenance.py | 12 ++-- src/agent_comms/relationships.py | 86 +++++++++++++++--------- src/agent_comms/thread_creation.py | 6 +- src/agent_comms/thread_identity.py | 30 ++++++++- src/agent_comms/threads.py | 48 ++++++------- tests/test_bus.py | 21 +++++- tests/test_owner_release_store.py | 2 +- tests/test_registration_components.py | 11 ++- tests/test_relationships.py | 69 ++++++++++++++++++- tests/test_sidebar_observation.py | 42 ++++++++++-- 16 files changed, 362 insertions(+), 128 deletions(-) diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index d21945b693..94951dfc78 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -191,7 +191,7 @@ def require_session_exit(self, session_files: tuple[Path, ...]) -> None: if not self.issued_for(self._store): raise RecoveryBlocked("native exit observation requires acquired owner loss") session_dir = ( - self._store.session.path.parent / "native-sessions" / self.release.incarnation().lookup + self._store.session.path.parent / "native-sessions" / self.release.incarnation.lookup ) proc = Path("/proc") if not proc.is_dir(): diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index a2232377f5..09a571c10c 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -239,9 +239,19 @@ def keyed_messages(self, publication_key: str) -> tuple[BusRecord, ...]: def page(self, before: int, limit: int) -> tuple[BusRecord, ...]: with self.observing() as db: - return tuple(row.kind.read_from(db, row.seq) for row in BusRow.select( - db, where="{seq} < ?", parameters=(before,), - order_by=("seq",), descending=True, limit=limit)) + return tuple(row.kind.read_from(db, row.seq) for row in self._page_rows(db, before, limit)) + + def input_page(self, before: int, limit: int) -> tuple[MessageInput, ...]: + """The same global sequence window, without attachment/report/answer acquisition.""" + with self.observing() as db: + return tuple(MessageInput.read_from(db, row.seq) for row in self._page_rows(db, before, limit)) + + @staticmethod + def _page_rows(db: sqlite3.Connection, before: int, limit: int) -> list[BusRow]: + return BusRow.select( + db, where="{seq} < ?", parameters=(before,), + order_by=("seq",), descending=True, limit=limit, + ) def message_history(self) -> Iterator[Message]: """Public bulk export, paged within one committed SQLite snapshot.""" diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index db0eb0ab3f..c33ef57729 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -2397,10 +2397,10 @@ def _tag(value: object) -> dict[str, str]: return RecordTag.of(value) @classmethod - def encode_fields(cls, value: object) -> dict[str, object]: + def encode_fields(cls, value: object) -> dict[str, JsonValue]: """Encode declared fields, except ``wire_exclude`` ones, including inside a custom wire representation.""" - return _member_decoder(type(value)).encode(value) + return cast(dict[str, JsonValue], _member_decoder(type(value)).encode(value)) @classmethod def decode_bound(cls, target: TypeForm[T], data: object, **context: object) -> T: @@ -2410,15 +2410,31 @@ def decode_bound(cls, target: TypeForm[T], data: object, **context: object) -> T def decode_record( cls, target: type[T], data: object, *, field_types: Mapping[str, type[object]] = types.MappingProxyType({}), + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), ) -> T: - """Decode declared fields, with nominal narrowing supplied by their contextual owner.""" + """Continue a boundary's record decode, retaining fields it already decoded.""" decoder = _RecordDecoder(target) for name, narrowed in field_types.items(): annotation, _ = cls.representation(cls.field_plan(target, name).annotation) if not issubclass(narrowed, annotation): raise TypeError(f"{target.__name__}.{name} cannot widen its declared type") return cast(T, decoder.decode_selected( - data, {}, field_types=field_types)) + data, {}, field_types=field_types, decoded_fields=decoded_fields)) + + @classmethod + def project_record( + cls, target: type[T], data: object, *, source: type, + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), + ) -> T: + """Acquire a declared record projection without decoding unrelated source fields.""" + raw = _RecordDecoder(source).checked(data) + projected: dict[str, object] = {} + for entry in cls._record_plan(target).bindings: + original = cls.field_plan(source, entry.name) + captured = original.captured(raw) + if entry.name in captured: + projected[entry.key] = captured[entry.name] + return cls.decode_record(target, projected, decoded_fields=decoded_fields) @classmethod def argument_fields(cls, target: type) -> tuple[FieldPlan, ...]: @@ -3487,23 +3503,30 @@ def decode_selected( self, data: object, selections: Mapping[tuple[type, str], object], *, context: Mapping[str, object] = {}, field_types: Mapping[str, type[object]] = types.MappingProxyType({}), + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), ) -> T: - return self.decode_entries(data, self.plan.bindings, selections, context, field_types) + return self.decode_entries( + data, self.plan.bindings, selections, context, field_types, decoded_fields, + ) def decode_entries( self, data: object, entries: tuple[FieldPlan, ...], selections: Mapping[tuple[type, str], object], context: Mapping[str, object], field_types: Mapping[str, type[object]], + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), ) -> T: if not isinstance(data, dict): raise MalformedRecord("Expected a record object; fix: supply a JSON object.") - problems = self.missing_fields(data) + supplied = {key for entry in entries if entry.name in decoded_fields for key in entry.wire_keys} + problems = {key: issue for key, issue in self.missing_fields(data).items() if key not in supplied} unknown = list(self.unknown_fields(data)) target = self.target - values = {} + values = dict(decoded_fields) for entry in entries: key = (target, entry.name) try: + if entry.name in decoded_fields: + continue if entry.name in field_types: narrowed = compile_decoder(field_types[entry.name]) values.update(entry.decoded_using( diff --git a/src/agent_comms/native_claim_epoch.py b/src/agent_comms/native_claim_epoch.py index 3de1d2f085..4eaee74310 100644 --- a/src/agent_comms/native_claim_epoch.py +++ b/src/agent_comms/native_claim_epoch.py @@ -105,7 +105,7 @@ def reservation_violation(self) -> bool: def require_release(self, acknowledgement, snapshot, current) -> None: sent = ClaimedIdentity(current.incarnation, self.value) - fence = ClaimedIdentity(acknowledgement.incarnation(), acknowledgement.before) + fence = ClaimedIdentity(acknowledgement.incarnation, acknowledgement.before) if not fence.includes(sent): raise RelationViolationError("Native release predates the sending claim_scope") if snapshot.claim_generation(current.name) == acknowledgement.after: diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index 67cdec8852..f9a6e4a4d3 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -209,7 +209,7 @@ def require_process(self) -> ProcessIdentity: return self.process_identity.require(f"Thread {self.name!r} has no released process") def matches(self, expected: Thread) -> bool: - return self.incarnation() == expected.incarnation and self.process_identity == expected.process_identity + return self.incarnation == expected.incarnation and self.process_identity == expected.process_identity class OwnerReleaseStoreRows(StoreRows): @@ -259,14 +259,14 @@ def require_retired(self, snapshot: RegistrySnapshot, thread: Thread, raise RelationViolationError("Retired session acquired another process") def require_loss(self, snapshot: RegistrySnapshot, release: OwnerReleaseEvidence) -> None: - if self.owner.incarnation != release.incarnation(): + if self.owner.incarnation != release.incarnation: raise RelationViolationError("Retired session belongs to another incarnation") if snapshot.owner_identity(release.name).generation <= self.owner.generation: raise RelationViolationError("Retired session still holds its original owner lease") def matches_current(self, snapshot: RegistrySnapshot, release: OwnerReleaseEvidence) -> bool: self.require_loss(snapshot, release) - return release.incarnation().current(snapshot) + return release.incarnation.current(snapshot) @dataclass(frozen=True) @@ -277,13 +277,14 @@ class OwnerReleaseEvidence(OwnerReleaseStoreRows, ReleasedOwner, OwnerRelease, T completion: Annotated[OwnerCompletion, Column()] = ProcessExitedOwnerCompletion() def __post_init__(self) -> None: + super().__post_init__() if self.before <= 0 or self.after <= self.before: raise ValueError("Owner release requires an advanced claim_scope and a closed turn") def current(self, snapshot: RegistrySnapshot, expected: Thread, claim_scope: int) -> bool: if self.before != claim_scope or not self.matches(expected): return False - if not self.incarnation().current(snapshot) or not self.completion.matches_current(snapshot, self): + if not self.incarnation.current(snapshot) or not self.completion.matches_current(snapshot, self): return False return self.after == snapshot.claim_generation(self.name) and snapshot.status(self.name).stopped @@ -299,13 +300,13 @@ def require_owner_loss(self, snapshot, owner_identity): Input generation belongs to the recorded input, not to owner-loss proof. Neither this release nor missing input proves bytes were unwritten. """ - if not self.incarnation().current(snapshot): + if not self.incarnation.current(snapshot): raise RelationViolationError("Released native owner incarnation changed") current = snapshot.require(self.name) owner_identity.require_recorded_owner(current) self.completion.require_loss(snapshot, self) actual = snapshot.claimed_identity(current.name) - released = ClaimedIdentity(self.incarnation(), self.after) + released = ClaimedIdentity(self.incarnation, self.after) if not actual.includes(released): raise RelationViolationError("Native release claim_scope regressed") return self diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 99f43754eb..45b03003a6 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, field, replace from functools import cached_property from types import MappingProxyType -from typing import TYPE_CHECKING, Literal, Annotated, Self, cast +from typing import TYPE_CHECKING, Literal, Annotated, Self from pydantic import JsonValue @@ -26,6 +26,7 @@ SlicePolicy, WireOmitDefault, WireRequired, + WireTransient, WireValue, ) from .registration_change import ( @@ -55,7 +56,6 @@ OwnerIdentity, RecordedThreadIncarnation, ThreadIncarnation, - ThreadIncarnationRecord, ThreadAuthority, TurnIdentity, ) @@ -67,7 +67,7 @@ ThreadStatus, ) from .thread_text import ForkedParent, NoParent -from .threads import Thread, ThreadWorkState +from .threads import GoalOwner, Thread, ThreadWorkState from .turn_lease import ( FinishedTurn, FinishedTurnFence, NoFinishedTurn, StoredTurnRecord, TurnCompletion, TurnLeaseFence, TurnRecord, @@ -105,10 +105,7 @@ def children(self, name: str) -> tuple[Thread, ...]: def incarnation(self, name: str) -> ThreadIncarnation: """This captured row owns identity without acquiring its full declaration.""" - canonical = self.canonical_name(name) - if canonical not in self.threads: - raise UnregisteredThreadError(f"Thread {canonical!r} is not registered.") - return self.threads.incarnation(canonical) + return self.threads.incarnation(self.require_canonical(name)) def active_candidates(self) -> Iterator[Thread]: return (self.threads[name] for name, status in self.statuses.items() @@ -314,12 +311,46 @@ def __init__(self, error: Exception) -> None: super().__init__(f"Invalid registry document: {error}") +@dataclass(frozen=True) +class ThreadRow(GoalOwner): + """One captured declaration: determining facts are nominal, only the rest stays raw.""" + + remainder: Annotated[Mapping[str, JsonValue], WireTransient()] = field( + default_factory=dict, kw_only=True, + ) + + @classmethod + def capture(cls, raw: dict[str, JsonValue]) -> ThreadRow: + keys = { + key for entry in FieldCodec.field_plans(cls) if entry.records + for key in FieldCodec.field_plan(Thread, entry.name).wire_keys + } + return FieldCodec.project_record( + cls, raw, source=Thread, + decoded_fields={"remainder": MappingProxyType({ + key: value for key, value in raw.items() if key not in keys + })}, + ) + + def to_wire(self) -> dict[str, JsonValue]: + return {**self.remainder, **FieldCodec.encode_fields(self)} + + def __reduce__(self): + # The row owns its immutable remainder; restoration re-enters the same boundary. + return type(self).capture, (self.to_wire(),) + + def decoded(self) -> Thread: + values = {entry.name: entry.current(self) + for entry in FieldCodec.field_plans(type(self)) if entry.records} + return FieldCodec.decode_record(Thread, dict(self.remainder), decoded_fields=values) + + class ThreadRows(ExternalJsonShape, WireValue, MutableMapping[str, Thread]): """A revision's thread rows, decoded and bound only when read.""" def __init__(self) -> None: # checked_rows proved each stored row an object, so the type keeps that proof. - self._raw: dict[str, dict[str, JsonValue]] = {} + self._raw: dict[str, ThreadRow] = {} self._decoded: dict[str, Thread] = {} self._binding: Callable[[Thread], Thread] = self.unbound @@ -349,32 +380,28 @@ def children(self, parent: str) -> tuple[Thread, ...]: return tuple( self[name] for name in self if (self._decoded[name].parent.named(parent) if name in self._decoded - else self._raw[name].get(key) == expected) + else self._raw[name].remainder.get(key) == expected) ) def creation_time(self, name: str) -> CreationTime: - """Read the declared birth only; absence must not invoke a generated default.""" - try: - if name in self._decoded: - return self._decoded[name].created_at - plan = FieldCodec.field_plan(Thread, "created_at") - return cast(CreationTime, plan.value.decode_bound(plan.raw(self._raw[name]), {})) - except ValueError as error: - raise InvalidRegistryDocument(error) from error + return ChainMap(self._decoded, self._raw)[name].created_at def incarnation(self, name: str) -> ThreadIncarnation: - if name in self._decoded: - return self._decoded[name].incarnation - return ThreadIncarnationRecord(name, self.creation_time(name).to_wire()).incarnation() + return ChainMap(self._decoded, self._raw)[name].incarnation + + def active_goal_owners(self) -> Iterator[Thread]: + """Select through the shared goal/role declarations before full thread acquisition.""" + return (self[name] for name, row in ChainMap(self._decoded, self._raw).items() + for _ in row.active_goals()) def __getitem__(self, name: str) -> Thread: if name not in self._decoded: self._decoded[name] = self.decode_row(self._raw[name]) return self._decoded[name] - def decode_row(self, row: object) -> Thread: + def decode_row(self, row: ThreadRow) -> Thread: try: - thread = FieldCodec.decode(Thread, row) + thread = row.decoded() except ValueError as error: raise InvalidRegistryDocument(error) from error return self._binding(thread) @@ -401,8 +428,8 @@ def __delitem__(self, name: str) -> None: self._decoded.pop(name, None) def to_wire(self) -> dict[str, JsonValue]: - return {**self._raw, **{name: FieldCodec.encode(thread) - for name, thread in self._decoded.items()}} + return {**{name: row.to_wire() for name, row in self._raw.items()}, + **{name: FieldCodec.encode(thread) for name, thread in self._decoded.items()}} @classmethod def schema(cls) -> dict[str, JsonValue]: @@ -410,15 +437,18 @@ def schema(cls) -> dict[str, JsonValue]: return {"type": "object", "additionalProperties": FieldCodec.record_schema(Thread)} @classmethod - def checked_rows(cls, raw: object) -> dict[str, dict[str, JsonValue]]: + def checked_rows(cls, raw: object) -> dict[str, ThreadRow]: if not isinstance(raw, dict): raise MalformedRecord("registry threads must be an object") - name_key = FieldCodec.field_plan(Thread, "name").key + result: dict[str, ThreadRow] = {} for name, row in raw.items(): - if not isinstance(name, str) or not isinstance(row, dict) or row.get(name_key) != name: + if not isinstance(name, str) or not isinstance(row, dict): raise MalformedRecord("thread name differs from its declaration") - Thread.require_name(name) - return dict(raw) + captured = ThreadRow.capture(row) + if captured.name != name: + raise MalformedRecord("thread name differs from its declaration") + result[name] = captured + return result def require_distinct_births(self) -> None: """Rows decode late, so a read of one thread never sees two sharing a creation lookup; @@ -454,7 +484,10 @@ def freeze(self) -> ThreadRows: return result def reuse(self, rows: Mapping[str, object], kept: frozenset[str], earlier: ThreadRows) -> None: - self._raw = self.checked_rows(rows) + retained = {name: earlier._raw[name] for name in kept & earlier._raw.keys()} + changed = self.checked_rows({name: row for name, row in rows.items() if name not in retained}) + captured = ChainMap(retained, changed) + self._raw = {name: captured[name] for name in rows} self._decoded = {name: earlier._decoded[name] for name in kept if name in earlier._decoded} self._binding = earlier._binding diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index 3c07c335a5..4f4da2529e 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -169,12 +169,14 @@ def participant(self, name: str) -> Member | DeletedThread: return DeletedThread(canonical) raise UnregisteredThreadError(f"Thread {canonical!r} was never registered.") - def require(self, name: str) -> Member: + def require_canonical(self, name: str) -> str: canonical = self.canonical_name(name) - try: - return self.threads[canonical] - except KeyError as error: - raise UnregisteredThreadError(f"Thread {canonical!r} is not registered.") from error + if canonical not in self.threads: + raise UnregisteredThreadError(f"Thread {canonical!r} is not registered.") + return canonical + + def require(self, name: str) -> Member: + return self.threads[self.require_canonical(name)] @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 97dda8bae1..c543d7a2a8 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -27,7 +27,7 @@ from .goals import Goal, GoalMentionBinding from .locked_store import DocumentChange from .maintenance_barrier import CarriedDocument -from .messages import Message +from .messages import MessageInput from .peer_relevance import ( ChannelmateRelevance, CollaboratorRelevance, @@ -280,7 +280,34 @@ def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: @classmethod def build(cls, context: RelationshipContext) -> RelationshipGroup: - return cls({entry.target: entry for entry in cls.supply(context)}) + return cls.from_entries({entry.target: entry for entry in cls.supply(context)}, context) + + @classmethod + def from_entries(cls, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> RelationshipGroup: + return cls(entries) + + @classmethod + def capture(cls, context: RelationshipContext, views: HistoryViews) -> tuple[RelationshipGroup, ...]: + """Select declared entries first; acquire their views once on this identity cut.""" + selected = tuple(group.build(context) for group in cls.members_with(cls)) + names = dict.fromkeys(entry.target for group in selected for entry in group.entries.values()) + context.people.update((view.thread.name, view) for view in presentation.ThreadView.roster( + context.registry, views.agents, views.goals, RosterVisibility(archived=True), names=names, + )) + return tuple(group.presented(context) for group in selected) + + @staticmethod + def present_entry(entry: RelationshipEntry, context: RelationshipContext) -> RelationshipEntry: + return replace(entry, person=context.people.get(entry.target)) + + def ordered_entries(self, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> dict[str, RelationshipEntry]: + return entries + + def presented(self, context: RelationshipContext) -> RelationshipGroup: + entries = {name: self.present_entry(entry, context) for name, entry in self.entries.items()} + return replace(self, entries=self.ordered_entries(entries, context)) def selectable(self) -> tuple[SortableRelationshipGroup, ...]: return () @@ -302,7 +329,7 @@ def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: @staticmethod @abstractmethod - def targets(context: RelationshipContext, message: Message) -> tuple[str, ...]: ... + def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: ... class InboundRelationshipGroup(RecentRelationshipGroup): @@ -310,7 +337,7 @@ class InboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent inbound" @staticmethod - def targets(context: RelationshipContext, message: Message) -> tuple[str, ...]: + def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: sender = context.registry.canonical_name(message.sender) if sender == context.thread.name or not context.delivery.delivers(message.sender, message.target): return () @@ -322,7 +349,7 @@ class OutboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent outbound" @staticmethod - def targets(context: RelationshipContext, message: Message) -> tuple[str, ...]: + def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: if context.registry.canonical_name(message.sender) != context.thread.name: return () return (CommandTarget.named(message.target).canonical_name(context.registry.canonical_name),) @@ -349,19 +376,22 @@ def selected_order(cls, context: RelationshipContext) -> type[ThreadSort]: return context.orders.get(cls, cls.default_order) @classmethod - def build(cls, context: RelationshipContext) -> SortableRelationshipGroup: - order = cls.selected_order(context) + def from_entries(cls, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> SortableRelationshipGroup: + return cls(entries, cls.selected_order(context)) + def ordered_entries(self, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> dict[str, RelationshipEntry]: def key(item: RelationshipEntry) -> tuple[float, float, str]: person = item.person - return order.key( + return self.order.key( item.target, person.thread.created_at if person else 0, person.activity.timestamp if person else 0, context.sent.get(item.target, 0) if person else 0, ) - return cls({entry.target: entry for entry in sorted(cls.supply(context), key=key)}, order) + return {entry.target: entry for entry in sorted(entries.values(), key=key)} def selectable(self) -> tuple[SortableRelationshipGroup, ...]: return (self,) @@ -384,9 +414,7 @@ class ChildrenRelationshipGroup(SortableRelationshipGroup): def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: return tuple( context.entry(child.name) - for child in context.registry.threads.values() - for parent in child.parent.names() - if context.registry.canonical_name(parent) == context.thread.name + for child in context.registry.children(context.thread.name) ) @@ -402,25 +430,31 @@ def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: identity = relation.counterpart(context.thread) if identity is None: continue - person = context.people.get(identity.name) - available = person is not None and person.thread.incarnation == identity - entries[identity] = relation.entry(identity, person, available, entries.get(identity)) + entries[identity] = relation.entry( + identity, None, identity.current(context.registry), entries.get(identity), + ) return tuple(entries.values()) + @staticmethod + def present_entry(entry: RelationshipEntry, context: RelationshipContext) -> RelationshipEntry: + person = context.people.get(entry.target) + available = entry.available and person is not None + return replace(entry, person=person if available else None, available=available) + @dataclass(frozen=True, slots=True) class RelationshipContext: registry: RegistrySnapshot thread: Thread people: dict[str, presentation.ThreadView] - messages: tuple[Message, ...] + messages: tuple[MessageInput, ...] delivery: DeliveryScope edges: tuple[Collaboration, ...] goal_contacts: tuple[GoalDerivedContact, ...] orders: dict[type[SortableRelationshipGroup], type[ThreadSort]] sent: dict[str, float] - def entry(self, name: str, *, message: Message | None = None, + def entry(self, name: str, *, message: MessageInput | None = None, detail: str = "") -> RelationshipEntry: target = CommandTarget.named(name) name = target.canonical_name(self.registry.canonical_name) @@ -712,11 +746,7 @@ def _goal_contacts( active goals in ONE registry snapshot; never changes an explicit contact or delivery.""" contacts: list[GoalDerivedContact] = [] diagnostics: list[GoalMentionDiagnostic] = [] - for owner in ( - executor - for thread in registry.threads.values() - for executor in thread.role.executor(thread) - ): + for owner in registry.threads.active_goal_owners(): for goal in owner.goal.active(): ThreadRelationships._project_goal_contacts(goal, owner, registry, contacts, diagnostics) return (tuple(row for row in contacts if row.incident(thread)), @@ -846,10 +876,10 @@ def set_order(self, owner: str, group: type[SortableRelationshipGroup], ) return order - def _recent_messages(self) -> tuple[tuple[Message, ...], bool]: + def _recent_messages(self) -> tuple[tuple[MessageInput, ...], bool]: """One bounded sequence page, on a single committed read snapshot.""" with self.bus.bus.reading() as source: - messages = source.page(source.revision() + 1, self.RECENT_MESSAGES + 1) + messages = source.input_page(source.revision() + 1, self.RECENT_MESSAGES + 1) return tuple(reversed(messages[:self.RECENT_MESSAGES])), len(messages) > self.RECENT_MESSAGES def snapshot(self, owner: str) -> ThreadCommsSnapshot: @@ -860,14 +890,10 @@ def snapshot(self, owner: str) -> ThreadCommsSnapshot: edges = state.collaborations goal_contacts, goal_diagnostics = self._goal_contacts(registry, thread) delivery = DeliveryScope.capture(thread.name, registry, self.views.channels.catalog.read()) - people = { - view.thread.name: view - for view in self.views.thread_views(RosterVisibility(archived=True)) - } messages, limited = self._recent_messages() context = RelationshipContext( - registry, thread, people, messages, delivery, edges, goal_contacts, + registry, thread, {}, messages, delivery, edges, goal_contacts, {row.group: row.order for row in state.orders if row.incarnation == thread.incarnation}, {}, ) @@ -879,7 +905,7 @@ def snapshot(self, owner: str) -> ThreadCommsSnapshot: return ThreadCommsSnapshot( thread.name, str(self.root.resolve()), - tuple(group.build(context) for group in RelationshipGroup.members_with(RelationshipGroup)), + RelationshipGroup.capture(context, self.views), limited, len(messages), unresolved_goal_mentions=goal_diagnostics, diff --git a/src/agent_comms/thread_creation.py b/src/agent_comms/thread_creation.py index 693f37487d..d66fe6a19e 100644 --- a/src/agent_comms/thread_creation.py +++ b/src/agent_comms/thread_creation.py @@ -6,11 +6,12 @@ import time from abc import ABC, abstractmethod from collections.abc import Set +from typing import Annotated, TypeAlias from metaclass_registry.mro_dispatch import handles from .errors import RelationViolationError -from .field_codec import FieldCodec, FieldRepresentation, WireValue +from .field_codec import FieldCodec, FieldRepresentation, WireRequired, WireValue class CreationTime(float, WireValue, ABC): @@ -67,3 +68,6 @@ def encode(cls, value: CreationTime) -> float: @classmethod def schema(cls) -> dict[str, object]: return CreationTime.schema() + + +RequiredCreationTime: TypeAlias = Annotated[CreationTime, CreationSeconds, WireRequired()] diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 880aef1716..0e24bfdf0a 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -7,6 +7,7 @@ from __future__ import annotations from .class_fact import ClassFact +from .thread_creation import CreationSeconds, CreationTime, RequiredCreationTime import hashlib import math from abc import ABC, abstractmethod @@ -190,7 +191,7 @@ def schema(cls) -> dict: @classmethod def from_wire(cls, data: dict) -> ThreadIncarnation: - return FieldCodec.decode(ThreadIncarnationRecord, data).incarnation() + return FieldCodec.decode(ThreadIncarnationRecord, data).incarnation @classmethod def deleted(cls, name: str) -> DeletedThreadIncarnation: @@ -403,14 +404,25 @@ def recorded_as(cls, name: str) -> CoreWatcherIncarnation: return cls.watcher() -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True) class ThreadIncarnationRecord: """The single exact external name/birth record; members own its meaning.""" name: str created_at: float + @staticmethod + def require_name(name: str) -> None: + allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + if not name or not set(name) <= allowed: + raise ValueError(f"Thread name {name!r} must be alphanumeric with hyphens/underscores.") + + def __post_init__(self) -> None: + self.require_name(self.name) + + @cached_property def incarnation(self) -> ThreadIncarnation: + """One birth derivation, shared by full declarations and captured narrow rows.""" try: member = BirthlessIncarnation.member_selected(self.created_at) except UnknownFamilyMember: @@ -419,9 +431,21 @@ def incarnation(self) -> ThreadIncarnation: @classmethod def decode(cls, name: str, birth: float) -> ThreadIncarnation: - return FieldCodec.decode(cls, {"name": name, "created_at": birth}).incarnation() + return FieldCodec.decode(cls, {"name": name, "created_at": birth}).incarnation + + +@dataclass(frozen=True, kw_only=True) +class ThreadBirthRecord(ThreadIncarnationRecord): + """A registered declaration retains its recorded/generated creation-clock capability.""" + created_at: RequiredCreationTime + + def __post_init__(self) -> None: + object.__setattr__(self, "created_at", FieldCodec.decode( + Annotated[CreationTime, CreationSeconds], self.created_at, + )) + super().__post_init__() @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 2d96466a8c..12b03f5f9e 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -4,6 +4,7 @@ import os from abc import ABC, abstractmethod +from collections.abc import Iterator from dataclasses import dataclass, field, fields, replace from functools import cached_property from pathlib import Path @@ -29,6 +30,7 @@ WireRequired, ) from .goals import ( + Goal, GoalDeclaration, NoGoal, ) @@ -53,14 +55,14 @@ from .session_file import NoSessionFile, SessionFile from .store_files import AtomicFile from .task_sources import TaskScope -from .thread_creation import CreationSeconds, CreationTime, GeneratedCreationTime +from .thread_creation import GeneratedCreationTime, RequiredCreationTime from .thread_execution import NativeThreadExecution, ThreadExecution from .thread_identity import ( AgentThreadRole, ObservedTurn, OfferedTools, ThreadIncarnation, - ThreadIncarnationRecord, + ThreadBirthRecord, ThreadRole, TurnFence, TurnIdentity, @@ -192,13 +194,11 @@ def configured(self, thread: Thread) -> Thread: @dataclass(frozen=True, kw_only=True) -class ThreadProvenance: +class ThreadProvenance(ThreadBirthRecord): """Recorded thread identity, without permission to own a process or start a turn.""" - name: str = field(kw_only=False) tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) worktree: Checkout = field(kw_only=False) - created_at: Annotated[CreationTime, CreationSeconds, WireRequired()] session_file: Annotated[SessionFile, RegistrationPolicy(InheritApplied)] = field( default_factory=NoSessionFile ) @@ -223,16 +223,8 @@ def writable_runtime(self) -> RuntimeLocation: def display_title(self) -> str: return self.title.otherwise(self.name) - @staticmethod - def require_name(name: str) -> None: - allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") - if not name or not set(name) <= allowed: - raise ValueError(f"Thread name {name!r} must be alphanumeric with hyphens/underscores.") - def __post_init__(self) -> None: - object.__setattr__(self, "created_at", FieldCodec.decode( - Annotated[CreationTime, CreationSeconds], self.created_at)) - self.require_name(self.incarnation.name) + super().__post_init__() for tag in self.tags: Tag.validate(tag) @@ -247,11 +239,6 @@ def capture(thread: ThreadProvenance) -> ThreadProvenance: } ) - @cached_property - def incarnation(self) -> ThreadIncarnation: - """The declaration's birth member, created once and shared by its readers.""" - return ThreadIncarnationRecord(self.name, self.created_at.to_wire()).incarnation() - class ThreadCatalog(DurableFile, PatternFile): """The tool catalog a native launch reads from its thread's session directory.""" @@ -260,8 +247,21 @@ class ThreadCatalog(DurableFile, PatternFile): filename: ClassVar[str] = "native-sessions/{lookup}/comms-catalog.json" +@dataclass(frozen=True, kw_only=True) +class GoalOwner(ThreadBirthRecord): + """The declared goal and executor membership, shared by full and captured rows.""" + + goal: Annotated[GoalDeclaration, RegistrationPolicy(GoalDeclaration)] = ( + field(default_factory=NoGoal) + ) + role: Annotated[type[ThreadRole], RegistrationPolicy(InheritPrevious)] = AgentThreadRole + + def active_goals(self) -> Iterator[Goal]: + return (goal for owner in self.role.executor(self) for goal in owner.goal.active()) + + @dataclass(frozen=True, slots=True) -class Thread(ThreadProvenance, CatalogSelection): +class Thread(ThreadProvenance, GoalOwner, CatalogSelection): """Declares one agent thread's identity and provenance.""" parent: ThreadParent = field(default_factory=NoParent) @@ -281,10 +281,7 @@ class Thread(ThreadProvenance, CatalogSelection): service_tier: Annotated[ServiceTier, RegistrationPolicy(InheritPrevious)] = ( field(default=ServiceTier.PRIORITY, kw_only=True) ) - goal: Annotated[GoalDeclaration, RegistrationPolicy(GoalDeclaration)] = ( - field(default_factory=NoGoal) - ) - created_at: Annotated[CreationTime, CreationSeconds, WireRequired()] = field( + created_at: RequiredCreationTime = field( default_factory=GeneratedCreationTime.now, kw_only=True ) storage_roots: Annotated[ @@ -305,9 +302,6 @@ class Thread(ThreadProvenance, CatalogSelection): project_resume_due: Annotated[ bool, RegistrationPolicy(InheritPrevious), WireOmitDefault() ] = False - role: Annotated[type[ThreadRole], RegistrationPolicy(InheritPrevious)] = ( - AgentThreadRole - ) execution: Annotated[type[ThreadExecution], WireOmitDefault()] = field( default=NativeThreadExecution, kw_only=True ) diff --git a/tests/test_bus.py b/tests/test_bus.py index 8ef0bf11fb..683a82cee3 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -41,6 +41,7 @@ InfoKind, MembershipTransition, Message, + MessageInput, MessageKind, MessageRoleWire, MessageRoutes, @@ -54,7 +55,7 @@ RelationViolationError, UnclaimedError, ) -from agent_comms.field_codec import GitCommitText, MessageId, MessageIdText, TimestampText +from agent_comms.field_codec import FieldCodec, GitCommitText, MessageId, MessageIdText, TimestampText from agent_comms.message_reference import MessageReference from agent_comms.read_basis import RepliesDisplayScope from agent_comms.response_acknowledgement import ( @@ -381,6 +382,24 @@ def test_real_message_record_round_trip(bus, declaration, role): assert bus.lookup(stored.seq) == stored == record assert Bus(bus.path).lookup(stored.seq) == stored assert bus.page(stored.seq + 1, 10) == (stored,) + expected_input = FieldCodec.construct(MessageInput, { + entry.name: entry.current(stored) for entry in FieldCodec.field_plans(MessageInput) + }) + with bus.reading() as snapshot: + with snapshot.observing() as db: + allowed = {table.declared_name for table in MessageInput.tables()} | {BusRow.declared_name} + + def input_reads_only(action, table, column, database, trigger): + return (sqlite3.SQLITE_DENY if action == sqlite3.SQLITE_READ and table not in allowed + else sqlite3.SQLITE_OK) + + db.set_authorizer(input_reads_only) + try: + assert snapshot.input_page(stored.seq + 1, 10) == (expected_input,) + with pytest.raises(sqlite3.DatabaseError): + snapshot.page(stored.seq + 1, 10) + finally: + db.set_authorizer(None) assert stored.message is stored assert stored.publication_snapshot == message.publication_snapshot assert type(stored.publication_snapshot) is Message diff --git a/tests/test_owner_release_store.py b/tests/test_owner_release_store.py index 3f0ebada86..8115c37640 100644 --- a/tests/test_owner_release_store.py +++ b/tests/test_owner_release_store.py @@ -26,7 +26,7 @@ def test_release_survives_metadata_changes_and_reads_only_its_row(tmp_path): OwnerReleaseEvidence.update(db, where='name=?', parameters=('other',), after=0) record = releases.read_many(('worker',))['worker'] assert record.current(comms.registry.snapshot(), originals[0], generation) - assert record.incarnation() == originals[0].incarnation + assert record.incarnation == originals[0].incarnation assert record.process_identity == originals[0].process_identity releases.remove_threads((originals[0],)) assert isinstance(releases.read_many(('worker',))['worker'], NoRelease) diff --git a/tests/test_registration_components.py b/tests/test_registration_components.py index 507d11b937..fd3019238d 100644 --- a/tests/test_registration_components.py +++ b/tests/test_registration_components.py @@ -158,11 +158,16 @@ def test_lifecycle_document_survives_fresh_process_restart(tmp_path): def test_thread_rows_compare_unread_rows_as_stored_and_decode_only_what_changed(tmp_path): - from agent_comms.registry_document import ThreadRows + from agent_comms.field_codec import FieldCodec + from agent_comms.registry_document import InvalidRegistryDocument, ThreadRows from agent_comms.thread_execution import ExternalThreadExecution - # A row that cannot decode proves an unread, unchanged row is never decoded to compare. - stored = {'unread': {'name': 'unread', 'not': 'a thread'}} + # A real declaration with malformed non-determining data stays lazy until a full read. + row = FieldCodec.encode_fields(Thread('unread', frozenset(), Checkout(tmp_path))) + row[FieldCodec.field_plan(Thread, 'model').key] = 123 + stored = {'unread': row} + with pytest.raises(InvalidRegistryDocument): + ThreadRows.from_wire(stored)['unread'] assert ThreadRows.from_wire(stored) == ThreadRows.from_wire(stored) written = ThreadRows.from_wire(stored) written['unread'] = Thread( diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 05b967883b..032c2d840f 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -7,14 +7,19 @@ import pytest -from agent_comms.registry_document import RosterVisibility +from agent_comms.registry_document import InvalidRegistryDocument, RosterVisibility, ThreadRows from agent_comms.relationships import ( ChildrenRelationshipGroup, CollaboratingRelationshipGroup, InboundRelationshipGroup, - OutboundRelationshipGroup, ParentRelationshipGroup, RelationshipEdit, + OutboundRelationshipGroup, ParentRelationshipGroup, RelationshipContext, RelationshipEdit, + RelationshipGroup, SortableRelationshipGroup, ) from agent_comms.comms import wire from conftest import installed_comms -from agent_comms.display_order import LastActivityThreadSort +from agent_comms.display_order import LastActivityThreadSort, ThreadSort +from agent_comms.field_codec import FieldCodec +from agent_comms.bus import PublicRecord +from agent_comms.messages import InfoKind, Message, NoticeRole +from agent_comms.routing import DeliveryScope from agent_comms.thread_text import ForkedParent, NoParent from agent_comms.tools import invoke_tool from agent_comms.threads import Thread @@ -120,6 +125,64 @@ def test_alias_and_persisted_thread_sort(tmp_path): assert collaboration_rows(comms, "reviewer")[0].target == "renamed" +def test_recent_relationships_keep_the_global_cut_and_exact_preview(tmp_path): + comms = setup_wire(tmp_path) + bus = comms.bus.bus + bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "owner", "evicted relevant input", InfoKind, seq=seq), + )) + for _ in range(comms.relationships.RECENT_MESSAGES): + bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "peer", "unrelated global row", InfoKind, seq=seq), + )) + snapshot = comms.relationships.snapshot("owner") + assert snapshot.history_limited and snapshot.history_messages == comms.relationships.RECENT_MESSAGES + assert not snapshot.groups_by_type[InboundRelationshipGroup].entries + + body = "preview " * 50 + latest = bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "owner", body, InfoKind, seq=seq), + )) + bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "owner", "not a conversation", InfoKind, seq=seq, role=NoticeRole()), + )) + snapshot = comms.relationships.snapshot("owner") + entry = snapshot.groups_by_type[InboundRelationshipGroup].entries["peer"] + assert entry.sequence == latest.seq and entry.timestamp == latest.timestamp + assert entry.detail == f"peer → owner\n{body[:240]}" + assert snapshot.history_limited and snapshot.history_messages == comms.relationships.RECENT_MESSAGES + + +@pytest.mark.parametrize("order", ThreadSort.members_with(ThreadSort)) +def test_group_selection_hydrates_only_selected_threads_on_its_captured_cut(tmp_path, order): + comms = setup_wire(tmp_path) + comms.registry.declare(Thread("unselected", frozenset(), Checkout(tmp_path))) + comms.relationships.edit("owner", RelationshipEdit.member_named("add"), "peer", "Real contact") + captured = comms.registry.snapshot() + raw = captured.threads.to_wire() + raw["unselected"][FieldCodec.field_plan(Thread, "model").key] = 123 + registry = replace(captured, threads=ThreadRows.from_wire(raw)) + thread = registry.require("owner") + contacts, _ = comms.relationships._goal_contacts(registry, thread) + context = RelationshipContext( + registry, thread, {}, (), + DeliveryScope.capture(thread.name, registry, comms.views.channels.catalog.read()), + comms.relationships.store.read().resolved(registry).collaborations, contacts, + {group: order for group in SortableRelationshipGroup.members_with(SortableRelationshipGroup)}, + order.sent_timestamps(comms.views, registry=registry), + ) + groups = {type(group): group for group in RelationshipGroup.capture(context, comms.views)} + for group_type, target in ((ParentRelationshipGroup, "origin"), + (ChildrenRelationshipGroup, "child"), + (CollaboratingRelationshipGroup, "peer")): + entry = groups[group_type].entries[target] + assert entry.available and entry.person.thread is registry.require(target) + assert set(context.people) == {"origin", "child", "peer"} + assert all(selected.order is order for group in groups.values() for selected in group.selectable()) + with pytest.raises(InvalidRegistryDocument): + registry.require("unselected") + + def test_concurrent_declaring_agents_do_not_lose_updates(tmp_path): comms = setup_wire(tmp_path) names = [f"worker-{index}" for index in range(8)] diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index cea9781f99..3b26d1667c 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -7,10 +7,10 @@ import pytest -from agent_comms.registry_document import ThreadRows +from agent_comms.registry_document import InvalidRegistryDocument, ThreadRows from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import MalformedRecord +from agent_comms.field_codec import FieldCodec, MalformedRecord from agent_comms.display_references import CurrentDisplayReferences, HistoricalDisplayReference from agent_comms.historical_views import HistorySource from agent_comms.registry_document import HistoricalRootPurpose @@ -130,22 +130,52 @@ def test_current_reference_membership_and_lookup_share_the_retained_registry(com @pytest.mark.parametrize("decoded", (False, True)) def test_current_reference_acquires_only_captured_incarnations(comms, declare_reference, decoded): original = declare_reference() + unread = declare_reference("unread") comms.registry.rename(original.name, "renamed") captured = comms.registry.snapshot() - snapshot = replace(captured, threads=ThreadRows.from_wire(captured.threads.to_wire())) + raw = captured.threads.to_wire() + raw[unread.name][FieldCodec.field_plan(Thread, "model").key] = 123 + snapshot = replace(captured, threads=ThreadRows.from_wire(raw)) if decoded: snapshot.require("renamed") - acquired_before = dict(snapshot.threads._decoded) references = CurrentDisplayReferences.from_registry(str(comms.root), snapshot, ("#comms",)) - - assert snapshot.threads._decoded == acquired_before + for _ in range(100): + repeated = CurrentDisplayReferences.from_registry(str(comms.root), snapshot, ("#comms",)) + assert repeated.members["renamed"] is references.members["renamed"] + assert repeated.members["unread"] is references.members["unread"] + with pytest.raises(InvalidRegistryDocument): + snapshot.require(unread.name) assert references.members[original.name].name == "renamed" assert references.members["renamed"].wire_birth == original.incarnation.wire_birth assert references.find(f"@{original.name}")[0].reference.name == "renamed" assert references.find("#comms")[0].reference.name == "#comms" +@pytest.mark.parametrize("entry,value", ( + (FieldCodec.field_plan(Thread, "name"), "invalid name"), + (FieldCodec.field_plan(Thread, "created_at"), []), + (FieldCodec.field_plan(Thread, "goal"), []), + (FieldCodec.field_plan(Thread, "role"), []), +)) +def test_captured_reference_rows_refuse_malformed_determining_fields(declare_reference, entry, value): + original = declare_reference() + raw = FieldCodec.encode_fields(original) + raw[entry.key] = value + with pytest.raises(MalformedRecord): + ThreadRows.from_wire({original.name: raw}) + + +def test_captured_reference_rows_refuse_unknown_or_absent_required_fields(declare_reference): + original = declare_reference() + raw = FieldCodec.encode_fields(original) + missing_birth = dict(raw) + del missing_birth[FieldCodec.field_plan(Thread, "created_at").key] + for malformed in ({**raw, "undeclared_field": "refuse"}, missing_birth): + with pytest.raises(MalformedRecord): + ThreadRows.from_wire({original.name: malformed}) + + def test_current_reference_source_projection_keeps_historical_identity(comms, declare_reference): original = declare_reference() references = comms.views.viewer_snapshot(Checkout(comms.root.parent)).references From b8c141898d0187e6f99566307d6a1cfc7983064b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 15:27:55 -0400 Subject: [PATCH 09/74] Keep birth projection and retained rows on their wire owners --- src/agent_comms/field_codec.py | 27 +++++++++++-------- src/agent_comms/registry_document.py | 39 ++++++++++++---------------- src/agent_comms/thread_identity.py | 14 ++++++++-- 3 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index c33ef57729..4859d4567e 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -649,7 +649,7 @@ def contribute(self, fields): def wire_keys(self) -> tuple[str, ...]: return (self.key,) - def encoded(self, value, *, key=None): + def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: return {key or self.key: value} def schema_fields(self): @@ -780,7 +780,7 @@ def contribute(self, fields: list[FieldPlan]) -> tuple[str, ...]: def wire_keys(self) -> tuple[str, ...]: return () - def encoded(self, value: object, *, key: str | None = None) -> dict[str, object]: + def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: return {} def decoded(self, data: object) -> dict[str, object]: @@ -841,7 +841,9 @@ def contribute(self, fields): def wire_keys(self) -> tuple[str, ...]: return tuple(entry.key for entry in self.record.fields) - def encoded(self, value, *, key=None): + def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: + if not isinstance(value, dict): + raise MalformedRecord("An inline record must encode a JSON object.") return value def captured(self, data: object) -> dict[str, object]: @@ -2400,7 +2402,7 @@ def _tag(value: object) -> dict[str, str]: def encode_fields(cls, value: object) -> dict[str, JsonValue]: """Encode declared fields, except ``wire_exclude`` ones, including inside a custom wire representation.""" - return cast(dict[str, JsonValue], _member_decoder(type(value)).encode(value)) + return _member_decoder(type(value)).encode(value) @classmethod def decode_bound(cls, target: TypeForm[T], data: object, **context: object) -> T: @@ -2575,7 +2577,7 @@ def _project_structure(cls, value: object, view: str, annotation: object) -> Jso @classmethod def _project_record(cls, value: object, view: str) -> JsonValue: """A record's view: its tag, its view's fields and the properties the view owns.""" - result = cls._tag(value) + result: dict[str, JsonValue] = {**cls._tag(value)} result.update(cls._record_plan(type(value)).encoded( value, view, lambda entry, current: cls.project(current, view, entry.annotation) )) @@ -2606,8 +2608,11 @@ def required_wire_keys(self) -> frozenset[str]: def record_fields(self) -> tuple[tuple[Field[object], str], ...]: return tuple((plan.declared, plan.key) for plan in self.bindings) - def encoded(self, value, view, encode_value) -> dict[str, object]: - result: dict[str, object] = {} + def encoded( + self, value: object, view: str, + encode_value: Callable[[FieldPlan, object], JsonValue], + ) -> dict[str, JsonValue]: + result: dict[str, JsonValue] = {} for entry in self.bindings: current = entry.current(value) if not entry.excluded_in(view) and not entry.omitted( @@ -3444,11 +3449,11 @@ def fits_wire(self, data: object) -> TypeGuard[dict[str, object]]: plan = self.plan return plan.required_wire_keys <= data.keys() <= plan.accepted_keys - def encode(self, value: object) -> dict[str, object]: + def encode(self, value: object) -> dict[str, JsonValue]: if not isinstance(value, self.target): raise MalformedRecord(f"Expected {self.target.__name__} record") result = self.plan.encoded( - value, "wire", lambda entry, current: entry.value.encode(current) + value, "wire", lambda entry, current: FieldCodec.encode(current, entry.annotation) ) result.update( (member.wire_name, FieldCodec.encode(getattr(value, name))) @@ -3614,14 +3619,14 @@ def decode(self, data: object) -> BaseModel: with MalformedRecord.refusing(f"Invalid {self.target.__name__}"): return self.target.model_validate(data) - def encode(self, value: object) -> object: + def encode(self, value: object) -> dict[str, JsonValue]: if not isinstance(value, self.target): raise TypeError(f"Expected {self.target.__name__}, received {type(value).__name__}") return value.model_dump(mode="json", by_alias=True, exclude_none=True) @lru_cache(maxsize=None) -def _member_decoder(target: object) -> ValueDecoder[object]: +def _member_decoder(target: object) -> _RecordDecoder[object] | _PydanticDecoder: """A concrete record, enum or scalar, after any family tag is removed.""" if isinstance(target, type) and is_dataclass(target): return _RecordDecoder(target) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 45b03003a6..37d4999f69 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -19,6 +19,7 @@ from .declared_family import DeclaredFamily from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import ( + DeclaredWireType, ExternalJsonShape, FieldCodec, MalformedRecord, @@ -312,37 +313,30 @@ def __init__(self, error: Exception) -> None: @dataclass(frozen=True) -class ThreadRow(GoalOwner): - """One captured declaration: determining facts are nominal, only the rest stays raw.""" +class ThreadRow(GoalOwner, DeclaredWireType): + """A captured Thread wire value and its already-decoded determining fields.""" - remainder: Annotated[Mapping[str, JsonValue], WireTransient()] = field( - default_factory=dict, kw_only=True, - ) + wire_type = Thread + raw: Annotated[Mapping[str, JsonValue], WireTransient()] = field(kw_only=True) @classmethod - def capture(cls, raw: dict[str, JsonValue]) -> ThreadRow: - keys = { - key for entry in FieldCodec.field_plans(cls) if entry.records - for key in FieldCodec.field_plan(Thread, entry.name).wire_keys - } + def from_wire(cls, raw: object) -> Self: + wire = FieldCodec.decode(dict[str, JsonValue], raw) return FieldCodec.project_record( - cls, raw, source=Thread, - decoded_fields={"remainder": MappingProxyType({ - key: value for key, value in raw.items() if key not in keys - })}, + cls, wire, source=Thread, + decoded_fields={"raw": MappingProxyType(wire)}, ) def to_wire(self) -> dict[str, JsonValue]: - return {**self.remainder, **FieldCodec.encode_fields(self)} + return dict(self.raw) def __reduce__(self): - # The row owns its immutable remainder; restoration re-enters the same boundary. - return type(self).capture, (self.to_wire(),) + return type(self).from_wire, (self.to_wire(),) def decoded(self) -> Thread: values = {entry.name: entry.current(self) for entry in FieldCodec.field_plans(type(self)) if entry.records} - return FieldCodec.decode_record(Thread, dict(self.remainder), decoded_fields=values) + return FieldCodec.decode_record(Thread, dict(self.raw), decoded_fields=values) class ThreadRows(ExternalJsonShape, WireValue, MutableMapping[str, Thread]): @@ -380,7 +374,7 @@ def children(self, parent: str) -> tuple[Thread, ...]: return tuple( self[name] for name in self if (self._decoded[name].parent.named(parent) if name in self._decoded - else self._raw[name].remainder.get(key) == expected) + else self._raw[name].raw.get(key) == expected) ) def creation_time(self, name: str) -> CreationTime: @@ -428,8 +422,9 @@ def __delitem__(self, name: str) -> None: self._decoded.pop(name, None) def to_wire(self) -> dict[str, JsonValue]: - return {**{name: row.to_wire() for name, row in self._raw.items()}, - **{name: FieldCodec.encode(thread) for name, thread in self._decoded.items()}} + # A read memo is not an edit: __setitem__ removes raw only for actual writes. + return {name: FieldCodec.encode(row) + for name, row in ChainMap(self._raw, self._decoded).items()} @classmethod def schema(cls) -> dict[str, JsonValue]: @@ -444,7 +439,7 @@ def checked_rows(cls, raw: object) -> dict[str, ThreadRow]: for name, row in raw.items(): if not isinstance(name, str) or not isinstance(row, dict): raise MalformedRecord("thread name differs from its declaration") - captured = ThreadRow.capture(row) + captured = ThreadRow.from_wire(row) if captured.name != name: raise MalformedRecord("thread name differs from its declaration") result[name] = captured diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 0e24bfdf0a..77308cc1a6 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -420,13 +420,19 @@ def require_name(name: str) -> None: def __post_init__(self) -> None: self.require_name(self.name) + @property + def birth(self) -> float: + """The external identity clock; float records already carry its wire value.""" + return self.created_at + @cached_property def incarnation(self) -> ThreadIncarnation: """One birth derivation, shared by full declarations and captured narrow rows.""" + birth = self.birth try: - member = BirthlessIncarnation.member_selected(self.created_at) + member = BirthlessIncarnation.member_selected(birth) except UnknownFamilyMember: - return RecordedThreadIncarnation(self.name, self.created_at) + return RecordedThreadIncarnation(self.name, birth) return member.recorded_as(self.name) @classmethod @@ -441,6 +447,10 @@ class ThreadBirthRecord(ThreadIncarnationRecord): created_at: RequiredCreationTime + @property + def birth(self) -> float: + return self.created_at.to_wire() + def __post_init__(self) -> None: object.__setattr__(self, "created_at", FieldCodec.decode( Annotated[CreationTime, CreationSeconds], self.created_at, From b6ee56792b064990945c0ae0c20b2da03612b408 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 15:54:22 -0400 Subject: [PATCH 10/74] Retain group source inputs before acquiring complete relationship views --- src/agent_comms/presentation.py | 8 +- src/agent_comms/relationships.py | 250 ++++++++++++++++---------- tests/test_registration_components.py | 24 +++ tests/test_relationships.py | 93 ++++++++-- 4 files changed, 258 insertions(+), 117 deletions(-) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 90613f2fca..d531d2b477 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -36,6 +36,7 @@ JsonShapeFamily, JsonShapeMember, NullMember, + PresentMember, PresenceExclude, RecordMember, SubclassUnion, @@ -720,7 +721,12 @@ def activity_detail(self) -> str: @dataclass(frozen=True, slots=True) -class ThreadView(ThreadRecord): +class NoThreadView(NullMember): + """A named navigation target has no captured thread presentation.""" + + +@dataclass(frozen=True, slots=True) +class ThreadView(ThreadRecord, PresentMember): activity: Annotated[ObservedActivity, PresenceExclude()] runtime: Annotated[RuntimeInfo, PresenceExclude()] last_seen: float diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index c543d7a2a8..25f8827f05 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -9,17 +9,18 @@ import time from abc import ABC, abstractmethod -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field, fields, replace from functools import cmp_to_key from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING, Annotated, ClassVar, Literal from agent_comms.field_codec import GoalId from agent_comms import field_codec from . import presentation -from .channel_targets import CommandTarget +from .channel_targets import CommandTarget, NamedTarget from .command import Command from .declared_family import DeclaredFamily from .display_order import CreatedThreadSort, LastActivityThreadSort, ThreadSort @@ -100,10 +101,11 @@ def incident(self, thread: Thread) -> bool: def entry_goal_contacts(self) -> tuple[GoalDerivedContact, ...]: return () - def entry(self, identity: ThreadIncarnation, person: presentation.ThreadView | None, + def entry(self, identity: ThreadIncarnation, + person: presentation.ThreadView | presentation.NoThreadView, available: bool, current: RelationshipEntry | None) -> RelationshipEntry: return RelationshipEntry( - identity.name, "thread", person if available else None, + identity.name, "thread", person if available else presentation.NoThreadView(), detail=f"{current.detail}\n{self.note}" if current and current.detail else self.note, available=available, sources=(*current.sources, self.source) if current else (self.source,), @@ -242,7 +244,9 @@ class GoalMentionDiagnostic: class RelationshipEntry: target: str kind: str - person: presentation.ThreadView | None = None + person: presentation.ThreadView | presentation.NoThreadView = field( + default_factory=presentation.NoThreadView, + ) sequence: int = 0 timestamp: float = 0 detail: str = "" @@ -258,10 +262,11 @@ def revalidates(self, earlier: RelationshipEntry) -> bool: """A fresh navigation target must retain its identity and incarnation.""" if not self.available or self.identity != earlier.identity: return False - if earlier.person is None: - return True - return (self.person is not None - and self.person.thread.incarnation == earlier.person.thread.incarnation) + return all( + any(person.thread.incarnation == previous.thread.incarnation + for person in self.person.present()) + for previous in earlier.person.present() + ) @dataclass(frozen=True, slots=True) @@ -276,11 +281,16 @@ class RelationshipGroup(DeclaredFamily, affix="RelationshipGroup"): @classmethod @abstractmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: ... + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: ... @classmethod - def build(cls, context: RelationshipContext) -> RelationshipGroup: - return cls.from_entries({entry.target: entry for entry in cls.supply(context)}, context) + def build(cls, context: RelationshipContext, + sources: tuple[RelationshipEntrySource, ...]) -> RelationshipGroup: + entries: dict[str, RelationshipEntry] = {} + for source in sources: + entry = source.entry(context) + entries[entry.target] = entry + return cls.from_entries(entries, context) @classmethod def from_entries(cls, entries: dict[str, RelationshipEntry], @@ -288,26 +298,18 @@ def from_entries(cls, entries: dict[str, RelationshipEntry], return cls(entries) @classmethod - def capture(cls, context: RelationshipContext, views: HistoryViews) -> tuple[RelationshipGroup, ...]: - """Select declared entries first; acquire their views once on this identity cut.""" - selected = tuple(group.build(context) for group in cls.members_with(cls)) - names = dict.fromkeys(entry.target for group in selected for entry in group.entries.values()) - context.people.update((view.thread.name, view) for view in presentation.ThreadView.roster( - context.registry, views.agents, views.goals, RosterVisibility(archived=True), names=names, - )) - return tuple(group.presented(context) for group in selected) - - @staticmethod - def present_entry(entry: RelationshipEntry, context: RelationshipContext) -> RelationshipEntry: - return replace(entry, person=context.people.get(entry.target)) - - def ordered_entries(self, entries: dict[str, RelationshipEntry], - context: RelationshipContext) -> dict[str, RelationshipEntry]: - return entries - - def presented(self, context: RelationshipContext) -> RelationshipGroup: - entries = {name: self.present_entry(entry, context) for name, entry in self.entries.items()} - return replace(self, entries=self.ordered_entries(entries, context)) + def capture(cls, inputs: RelationshipInputs, views: HistoryViews) -> tuple[RelationshipGroup, ...]: + """Retain original inputs once, then construct complete results on one roster cut.""" + selected = tuple((group, group.sources(inputs)) for group in cls.members_with(cls)) + names = dict.fromkeys(source.name for _, sources in selected for source in sources) + people = MappingProxyType({view.thread.name: view for view in presentation.ThreadView.roster( + inputs.registry, views.agents, views.goals, RosterVisibility(archived=True), names=names, + )}) + context = RelationshipContext( + **{item.name: getattr(inputs, item.name) for item in fields(RelationshipInputs)}, + people=people, + ) + return tuple(group.build(context, sources) for group, sources in selected) def selectable(self) -> tuple[SortableRelationshipGroup, ...]: return () @@ -315,21 +317,18 @@ def selectable(self) -> tuple[SortableRelationshipGroup, ...]: class RecentRelationshipGroup(RelationshipGroup): @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: - entries = {} - for message in reversed(context.messages): + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + selected: dict[str, RecentEntrySource] = {} + for message in reversed(inputs.messages): if not message.role.counts_as_conversation(): continue - for target in cls.targets(context, message): - entries.setdefault(target, context.entry( - target, message=message, - detail=f"{message.sender} → {message.target}\n{message.body[:240]}", - )) - return tuple(entries.values()) + for target in cls.targets(inputs, message): + selected.setdefault(target.name, RecentEntrySource(target, message)) + return tuple(selected.values()) @staticmethod @abstractmethod - def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: ... + def targets(inputs: RelationshipInputs, message: MessageInput) -> tuple[NamedTarget, ...]: ... class InboundRelationshipGroup(RecentRelationshipGroup): @@ -337,11 +336,12 @@ class InboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent inbound" @staticmethod - def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: - sender = context.registry.canonical_name(message.sender) - if sender == context.thread.name or not context.delivery.delivers(message.sender, message.target): + def targets(inputs: RelationshipInputs, message: MessageInput) -> tuple[NamedTarget, ...]: + sender = inputs.registry.canonical_name(message.sender) + if sender == inputs.thread.name or not inputs.delivery.delivers(message.sender, message.target): return () - return CommandTarget.named(message.target).inbound_relations(sender) + return tuple(RelationshipEntrySource.canonical_target(name, inputs.registry) + for name in CommandTarget.named(message.target).inbound_relations(sender)) class OutboundRelationshipGroup(RecentRelationshipGroup): @@ -349,10 +349,10 @@ class OutboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent outbound" @staticmethod - def targets(context: RelationshipContext, message: MessageInput) -> tuple[str, ...]: - if context.registry.canonical_name(message.sender) != context.thread.name: + def targets(inputs: RelationshipInputs, message: MessageInput) -> tuple[NamedTarget, ...]: + if inputs.registry.canonical_name(message.sender) != inputs.thread.name: return () - return (CommandTarget.named(message.target).canonical_name(context.registry.canonical_name),) + return (RelationshipEntrySource.canonical_target(message.target, inputs.registry),) class ParentRelationshipGroup(RelationshipGroup): @@ -360,8 +360,9 @@ class ParentRelationshipGroup(RelationshipGroup): empty_text = "Not forked" @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: - return tuple(context.entry(name) for name in context.thread.parent.names()) + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + return tuple(NamedEntrySource(RelationshipEntrySource.canonical_target(name, inputs.registry)) + for name in inputs.thread.parent.names()) @dataclass(frozen=True, slots=True) @@ -372,26 +373,24 @@ class SortableRelationshipGroup(RelationshipGroup): default_order: ClassVar[type[ThreadSort]] @classmethod - def selected_order(cls, context: RelationshipContext) -> type[ThreadSort]: - return context.orders.get(cls, cls.default_order) + def selected_order(cls, orders: Mapping[type[SortableRelationshipGroup], type[ThreadSort]]) -> type[ThreadSort]: + return orders.get(cls, cls.default_order) @classmethod def from_entries(cls, entries: dict[str, RelationshipEntry], context: RelationshipContext) -> SortableRelationshipGroup: - return cls(entries, cls.selected_order(context)) + order = cls.selected_order(context.orders) - def ordered_entries(self, entries: dict[str, RelationshipEntry], - context: RelationshipContext) -> dict[str, RelationshipEntry]: def key(item: RelationshipEntry) -> tuple[float, float, str]: - person = item.person - return self.order.key( + people = item.person.present() + return order.key( item.target, - person.thread.created_at if person else 0, - person.activity.timestamp if person else 0, - context.sent.get(item.target, 0) if person else 0, + sum(person.thread.birth for person in people), + sum(person.activity.timestamp for person in people), + sum(context.sent.get(item.target, 0) for _ in people), ) - return {entry.target: entry for entry in sorted(entries.values(), key=key)} + return cls({entry.target: entry for entry in sorted(entries.values(), key=key)}, order) def selectable(self) -> tuple[SortableRelationshipGroup, ...]: return (self,) @@ -411,10 +410,10 @@ class ChildrenRelationshipGroup(SortableRelationshipGroup): default_order = CreatedThreadSort @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: return tuple( - context.entry(child.name) - for child in context.registry.children(context.thread.name) + NamedEntrySource(RelationshipEntrySource.canonical_target(child.name, inputs.registry)) + for child in inputs.registry.children(inputs.thread.name) ) @@ -424,46 +423,102 @@ class CollaboratingRelationshipGroup(SortableRelationshipGroup): default_order = LastActivityThreadSort @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: - entries: dict[ThreadIncarnation, RelationshipEntry] = {} - for relation in (*context.edges, *context.goal_contacts): - identity = relation.counterpart(context.thread) - if identity is None: - continue - entries[identity] = relation.entry( - identity, None, identity.current(context.registry), entries.get(identity), - ) - return tuple(entries.values()) - - @staticmethod - def present_entry(entry: RelationshipEntry, context: RelationshipContext) -> RelationshipEntry: - person = context.people.get(entry.target) - available = entry.available and person is not None - return replace(entry, person=person if available else None, available=available) + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + selected: dict[ThreadIncarnation, list[MutualThreadRelation]] = {} + for relation in (*inputs.edges, *inputs.goal_contacts): + identity = relation.counterpart(inputs.thread) + if identity is not None: + selected.setdefault(identity, []).append(relation) + return tuple(MutualEntrySource(identity, tuple(relations)) + for identity, relations in selected.items()) @dataclass(frozen=True, slots=True) -class RelationshipContext: +class RelationshipInputs: + """One captured selection basis, before acquiring any person presentation.""" + registry: RegistrySnapshot thread: Thread - people: dict[str, presentation.ThreadView] messages: tuple[MessageInput, ...] delivery: DeliveryScope edges: tuple[Collaboration, ...] goal_contacts: tuple[GoalDerivedContact, ...] - orders: dict[type[SortableRelationshipGroup], type[ThreadSort]] - sent: dict[str, float] + orders: Mapping[type[SortableRelationshipGroup], type[ThreadSort]] + sent: Mapping[str, float] + + +@dataclass(frozen=True, slots=True) +class RelationshipContext(RelationshipInputs): + people: Mapping[str, presentation.ThreadView] - def entry(self, name: str, *, message: MessageInput | None = None, + def entry(self, target: NamedTarget, *, message: MessageInput | None = None, detail: str = "") -> RelationshipEntry: - target = CommandTarget.named(name) - name = target.canonical_name(self.registry.canonical_name) return RelationshipEntry( - name, target.kind, self.people.get(name), + target.name, target.kind, self.people.get(target.name, presentation.NoThreadView()), message.seq if message else 0, message.timestamp if message else 0, detail, ) +class RelationshipEntrySource(ABC): + """An original group input, never an unfinished public entry or a wire record.""" + + __slots__ = () + + @property + @abstractmethod + def name(self) -> str: ... + + @abstractmethod + def entry(self, context: RelationshipContext) -> RelationshipEntry: ... + + @staticmethod + def canonical_target(name: str, registry: RegistrySnapshot) -> NamedTarget: + target = CommandTarget.named(name) + return replace(target, name=target.selected_name(registry)) + + +@dataclass(frozen=True, slots=True) +class NamedEntrySource(RelationshipEntrySource): + target: NamedTarget + + @property + def name(self) -> str: + return self.target.name + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + return context.entry(self.target) + + +@dataclass(frozen=True, slots=True) +class RecentEntrySource(NamedEntrySource): + message: MessageInput + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + return context.entry( + self.target, message=self.message, + detail=f"{self.message.sender} → {self.message.target}\n{self.message.body[:240]}", + ) + + +@dataclass(frozen=True, slots=True) +class MutualEntrySource(RelationshipEntrySource): + identity: ThreadIncarnation + relations: tuple[MutualThreadRelation, ...] + + @property + def name(self) -> str: + return self.identity.name + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + person = context.people.get(self.name, presentation.NoThreadView()) + available = any(view.thread.incarnation == self.identity for view in person.present()) + first, *remaining = self.relations + entry = first.entry(self.identity, person, available, None) + for relation in remaining: + entry = relation.entry(self.identity, person, available, entry) + return entry + + @dataclass(frozen=True, slots=True) class ThreadCommsSnapshot: owner: str @@ -892,20 +947,21 @@ def snapshot(self, owner: str) -> ThreadCommsSnapshot: delivery = DeliveryScope.capture(thread.name, registry, self.views.channels.catalog.read()) messages, limited = self._recent_messages() - context = RelationshipContext( - registry, thread, {}, messages, delivery, edges, goal_contacts, - {row.group: row.order for row in state.orders if row.incarnation == thread.incarnation}, - {}, - ) + orders = {row.group: row.order for row in state.orders if row.incarnation == thread.incarnation} + sent: dict[str, float] = {} for order in { - group.selected_order(context) + group.selected_order(orders) for group in SortableRelationshipGroup.members_with(SortableRelationshipGroup) }: - context.sent.update(order.sent_timestamps(self.views, registry=registry)) + sent.update(order.sent_timestamps(self.views, registry=registry)) + inputs = RelationshipInputs( + registry, thread, messages, delivery, edges, goal_contacts, + MappingProxyType(orders), MappingProxyType(sent), + ) return ThreadCommsSnapshot( thread.name, str(self.root.resolve()), - RelationshipGroup.capture(context, self.views), + RelationshipGroup.capture(inputs, self.views), limited, len(messages), unresolved_goal_mentions=goal_diagnostics, diff --git a/tests/test_registration_components.py b/tests/test_registration_components.py index fd3019238d..ae11d24799 100644 --- a/tests/test_registration_components.py +++ b/tests/test_registration_components.py @@ -176,3 +176,27 @@ def test_thread_rows_compare_unread_rows_as_stored_and_decode_only_what_changed( other['unread'] = written['unread'] assert written == other assert written != ThreadRows.from_wire({}) + + +def test_birth_projection_agrees_for_full_thread_and_stored_float_records(tmp_path): + from agent_comms.thread_identity import ThreadIncarnationRecord + + thread = Thread("owner", frozenset(), Checkout(tmp_path)) + stored = ThreadIncarnationRecord(thread.name, thread.created_at.to_wire()) + assert thread.birth == stored.birth + assert thread.incarnation == stored.incarnation + assert FieldCodec.decode(type(stored), FieldCodec.encode(stored)) == stored + + +def test_full_thread_read_keeps_original_raw_wire_until_an_actual_assignment(tmp_path): + from agent_comms.registry_document import ThreadRows + + thread = Thread("owner", frozenset({"kept"}), Checkout(tmp_path)) + rows = ThreadRows.from_wire({thread.name: FieldCodec.encode_fields(thread)}) + tags_key = FieldCodec.field_plan(Thread, "tags").key + kept_tags = rows.to_wire()[thread.name][tags_key] + loaded = rows[thread.name] + assert loaded == thread + assert rows.to_wire()[thread.name][tags_key] is kept_tags + rows[thread.name] = replace(loaded, tags=frozenset({"assigned"})) + assert rows.to_wire()[thread.name][tags_key] == ["assigned"] diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 032c2d840f..22da3146d9 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -4,16 +4,19 @@ import json from concurrent.futures import ProcessPoolExecutor from dataclasses import replace +from pathlib import Path +from types import MappingProxyType import pytest from agent_comms.registry_document import InvalidRegistryDocument, RosterVisibility, ThreadRows from agent_comms.relationships import ( ChildrenRelationshipGroup, CollaboratingRelationshipGroup, InboundRelationshipGroup, - OutboundRelationshipGroup, ParentRelationshipGroup, RelationshipContext, RelationshipEdit, - RelationshipGroup, SortableRelationshipGroup, + NamedEntrySource, OutboundRelationshipGroup, ParentRelationshipGroup, + RelationshipContext, RelationshipEdit, RelationshipEntry, RelationshipEntrySource, + RelationshipGroup, RelationshipInputs, SortableRelationshipGroup, ) -from agent_comms.comms import wire +from agent_comms.comms import Comms, wire from conftest import installed_comms from agent_comms.display_order import LastActivityThreadSort, ThreadSort from agent_comms.field_codec import FieldCodec @@ -42,7 +45,7 @@ def change_shared_peer(root, instruction): wire(root).relationships.edit(owner, RelationshipEdit.member_named(action), other, "Race") -def setup_wire(tmp_path): +def setup_wire(tmp_path: Path) -> Comms: comms = installed_comms(tmp_path / "wire") for name, parent in (("origin", NoParent()), ("owner", ForkedParent("origin")), ("peer", NoParent()), ("child", ForkedParent("owner"))): @@ -154,7 +157,9 @@ def test_recent_relationships_keep_the_global_cut_and_exact_preview(tmp_path): @pytest.mark.parametrize("order", ThreadSort.members_with(ThreadSort)) -def test_group_selection_hydrates_only_selected_threads_on_its_captured_cut(tmp_path, order): +def test_group_selection_hydrates_only_selected_threads_on_its_captured_cut( + tmp_path: Path, order: type[ThreadSort], +): comms = setup_wire(tmp_path) comms.registry.declare(Thread("unselected", frozenset(), Checkout(tmp_path))) comms.relationships.edit("owner", RelationshipEdit.member_named("add"), "peer", "Real contact") @@ -164,20 +169,25 @@ def test_group_selection_hydrates_only_selected_threads_on_its_captured_cut(tmp_ registry = replace(captured, threads=ThreadRows.from_wire(raw)) thread = registry.require("owner") contacts, _ = comms.relationships._goal_contacts(registry, thread) - context = RelationshipContext( - registry, thread, {}, (), + orders = {group: order for group in SortableRelationshipGroup.members_with(SortableRelationshipGroup)} + sent = order.sent_timestamps(comms.views, registry=registry) + inputs = RelationshipInputs( + registry, thread, (), DeliveryScope.capture(thread.name, registry, comms.views.channels.catalog.read()), comms.relationships.store.read().resolved(registry).collaborations, contacts, - {group: order for group in SortableRelationshipGroup.members_with(SortableRelationshipGroup)}, - order.sent_timestamps(comms.views, registry=registry), + MappingProxyType(orders), MappingProxyType(sent), ) - groups = {type(group): group for group in RelationshipGroup.capture(context, comms.views)} + original_orders, original_sent = dict(orders), dict(sent) + groups = {type(group): group for group in RelationshipGroup.capture(inputs, comms.views)} for group_type, target in ((ParentRelationshipGroup, "origin"), (ChildrenRelationshipGroup, "child"), (CollaboratingRelationshipGroup, "peer")): entry = groups[group_type].entries[target] - assert entry.available and entry.person.thread is registry.require(target) - assert set(context.people) == {"origin", "child", "peer"} + assert entry.available and entry.person.require_present().thread is registry.require(target) + selected = {person.thread.name for group in groups.values() + for entry in group.entries.values() for person in entry.person.present()} + assert selected == {"origin", "child", "peer"} + assert inputs.orders == original_orders and inputs.sent == original_sent assert all(selected.order is order for group in groups.values() for selected in group.selectable()) with pytest.raises(InvalidRegistryDocument): registry.require("unselected") @@ -244,7 +254,9 @@ def test_deleted_peer_survives_unrelated_edit_and_explicit_remove(tmp_path): missing = collaboration_rows(comms)[0] assert missing.target == "peer", "Deleted identity must remain copyable" - assert missing.available is False and missing.person is None + assert missing.available is False and missing.person.present() == () + snapshot = comms.relationships.snapshot("owner") + assert FieldCodec.decode(type(snapshot), FieldCodec.encode(snapshot)) == snapshot assert missing.detail == original.note assert comms.relationships.collaborations("owner") == (original,) @@ -289,7 +301,7 @@ def test_reused_peer_name_does_not_rebind_or_overwrite_historical_work(tmp_path) ) row = collaboration_rows(comms)[0] - assert row.target == "peer" and row.person is None and not row.available + assert row.target == "peer" and row.person.present() == () and not row.available for action in ("add", "update"): with pytest.raises(ValueError): comms.relationships.edit( @@ -305,7 +317,7 @@ def test_reused_peer_name_does_not_rebind_or_overwrite_historical_work(tmp_path) ) assert new.peer_created != original.peer_created row = collaboration_rows(comms)[0] - assert row.available and row.person.thread.created_at == new.peer_created + assert row.available and row.person.require_present().thread.birth == new.peer_created assert collaboration_rows(comms, "peer")[0].target == "owner" @@ -363,7 +375,7 @@ def test_retained_alias_contact_without_projected_person_is_unavailable(tmp_path visible = collaboration_rows(comms)[0] assert ( visible.available - and visible.person.thread.incarnation == comms.registry.require("renamed-peer").incarnation + and visible.person.require_present().thread.incarnation == comms.registry.require("renamed-peer").incarnation ) assert visible.target == "renamed-peer" assert visible.sources == ("explicit", "goal_mention") @@ -380,7 +392,7 @@ def test_retained_alias_contact_without_projected_person_is_unavailable(tmp_path unavailable = collaboration_rows(comms)[0] assert unavailable.target == "renamed-peer" assert unavailable.sources == ("explicit", "goal_mention") - assert not unavailable.available and unavailable.person is None + assert not unavailable.available and unavailable.person.present() == () assert "Retained explicit contact" in unavailable.detail @@ -411,8 +423,9 @@ class SortedInboundRelationshipGroup(SortableRelationshipGroup, InboundRelations assert selected.current_entry( SortedInboundRelationshipGroup, replace(earlier, kind="channel") ) is None - reused = replace(earlier.person, thread=replace( - earlier.person.thread, created_at=earlier.person.thread.created_at + 1 + person = earlier.person.require_present() + reused = replace(person, thread=replace( + person.thread, created_at=person.thread.created_at + 1 )) assert selected.current_entry( SortedInboundRelationshipGroup, replace(earlier, person=reused) @@ -429,3 +442,45 @@ class SortedInboundRelationshipGroup(SortableRelationshipGroup, InboundRelations assert FieldCodec.decode(type(reopened), FieldCodec.encode(reopened)) == reopened finally: RelationshipGroup.__registry__.pop(SortedInboundRelationshipGroup.declared_name) + + +def test_new_group_retains_its_one_original_source_through_complete_presentation(tmp_path: Path): + comms = setup_wire(tmp_path) + selections: list[RelationshipInputs] = [] + originals: list[NamedEntrySource] = [] + built: list[NamedEntrySource] = [] + + class CapturedEntrySource(NamedEntrySource): + __slots__ = () + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + built.append(self) + assert context.registry is selections[0].registry + assert context.people[self.name].thread is context.registry.require(self.name) + with pytest.raises(TypeError): + context.people["unexpected"] = context.people[self.name] + return super().entry(context) + + class CapturedRelationshipGroup(RelationshipGroup): + title = "One original source" + + @classmethod + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + selections.append(inputs) + assert len(selections) == 1 + source = CapturedEntrySource( + CapturedEntrySource.canonical_target("peer", inputs.registry), + ) + originals.append(source) + return (source,) + + try: + snapshot = comms.relationships.snapshot("owner") + group = snapshot.groups_by_type[CapturedRelationshipGroup] + assert len(selections) == len(originals) == len(built) == 1 + assert built[0] is originals[0] + entry = group.entries["peer"] + assert entry.person.require_present().thread is selections[0].registry.require("peer") + assert entry.available and entry.target == originals[0].name + finally: + RelationshipGroup.__registry__.pop(CapturedRelationshipGroup.declared_name) From 6a8a18d19c2448cd83bc4ba44154f3dd68092606 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 16:40:00 -0400 Subject: [PATCH 11/74] Require the already supplied person at relationship entry construction --- src/agent_comms/relationships.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 25f8827f05..1951ee10c1 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -244,9 +244,7 @@ class GoalMentionDiagnostic: class RelationshipEntry: target: str kind: str - person: presentation.ThreadView | presentation.NoThreadView = field( - default_factory=presentation.NoThreadView, - ) + person: presentation.ThreadView | presentation.NoThreadView sequence: int = 0 timestamp: float = 0 detail: str = "" From d9b4c5ed112b965223a34759f9639dd7554157b4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:16:41 -0400 Subject: [PATCH 12/74] Acquire selected lookup threads through the captured row owner --- src/agent_comms/registry_document.py | 31 +++++++++- tests/test_recipient_activity_index.py | 39 +++++++++++- tests/test_sidebar_observation.py | 82 +++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 37d4999f69..5d7e7b9711 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -25,6 +25,7 @@ MalformedRecord, FieldPlan, SlicePolicy, + ThreadLookup, WireOmitDefault, WireRequired, WireTransient, @@ -495,6 +496,31 @@ def __delitem__(self, name: str) -> None: raise TypeError("snapshot thread rows are read-only") +@dataclass(frozen=True, slots=True, eq=False) +class ThreadLookupIndex(Mapping[ThreadLookup, Thread]): + """A captured cut's lookup keys; complete values still belong to its thread rows.""" + + rows: ThreadRows + _names: Mapping[ThreadLookup, str] = field(init=False, repr=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "_names", MappingProxyType({ + self.rows.incarnation(name).require_recorded().lookup: name for name in self.rows + })) + + def __getitem__(self, lookup: ThreadLookup) -> Thread: + return self.rows[self._names[lookup]] + + def __contains__(self, lookup: object) -> bool: + return lookup in self._names + + def __iter__(self) -> Iterator[ThreadLookup]: + return iter(self._names) + + def __len__(self) -> int: + return len(self._names) + + class ThreadRowsSlice(OwnRow[ThreadRows]): @classmethod def copy(cls, value: ThreadRows) -> ThreadRows: @@ -1123,9 +1149,8 @@ def __post_init__(self) -> None: self.threads.require_distinct_births() @cached_property - def by_lookup(self) -> Mapping[str, Thread]: - return MappingProxyType({thread.incarnation.require_recorded().lookup: thread - for thread in self.threads.values()}) + def by_lookup(self) -> Mapping[ThreadLookup, Thread]: + return ThreadLookupIndex(self.threads) def owner_generation(self, name: str) -> int: return self.owner_generations[self.canonical_name(name)] diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 6d35c16edb..73c6bee280 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -11,13 +11,16 @@ from agent_comms.activity import Activity, ActivityState, ActivityStore from agent_comms.audience_manifest import FrozenRecipient from agent_comms.comms import wire -from agent_comms.errors import RelationViolationError +from agent_comms.errors import RelationViolationError, UnregisteredThreadError +from agent_comms.field_codec import ThreadLookup from agent_comms.notification_assignment import NotificationAssignment from agent_comms.presentation import AllOutcomes, MessageNotification, OwnerOutcomes from agent_comms.messages import InfoKind, Message from agent_comms.read_ledger import ReadDocument, ReadLedger from agent_comms.read_basis import ConversationCursor, ReadCursor -from agent_comms.registry_provenance import RegistryProvenance +from agent_comms.registry_provenance import ( + RegisteredRecipient, RegistryProvenance, RetiredRecipient, +) from agent_comms.registry_document import RegistrySnapshot from agent_comms.thread_identity import ( UnrecordedThreadIncarnation, @@ -65,6 +68,38 @@ def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_own assert not hasattr(unrecorded.threads["second"].incarnation, "lookup") +def test_captured_lookup_keeps_original_recipient_identity_and_retirement(tmp_path): + comms = canonical_goal_wire(tmp_path / "bus") + for name in ("sender", "peer", "other"): + comms.registry.declare(Thread(name, frozenset(), Checkout(tmp_path))) + sent = comms.messaging.send("sender", "peer", "retained recipient").message + original = comms.bus.bus.delivery(sent.seq) + captured = comms.registry.snapshot() + peer = captured.require("peer") + recipient = FrozenRecipient(peer.incarnation.lookup, peer.name) + assert recipient in original.recipients + assert captured.recipient(recipient, original) == RegisteredRecipient(peer) + with pytest.raises(RelationViolationError): + captured.recipient(FrozenRecipient(recipient.recipient_lookup, "other"), original) + foreign = FrozenRecipient(ThreadLookup.mint(), peer.name) + with pytest.raises(UnregisteredThreadError): + captured.recipient(foreign, original) + + comms.registry.rename(peer.name, "renamed") + renamed = comms.registry.snapshot() + assert renamed.recipient(recipient, original) == RegisteredRecipient(renamed.require("renamed")) + comms.threads.delete("renamed") + deleted = comms.registry.snapshot() + assert deleted.recipient(recipient, original) == RetiredRecipient(recipient.recipient_lookup) + with pytest.raises(UnregisteredThreadError): + deleted.recipient(foreign, original) + recreated = comms.registry.declare(Thread("renamed", frozenset(), Checkout(tmp_path))) + current = comms.registry.snapshot() + assert recreated.incarnation.lookup != recipient.recipient_lookup + assert current.recipient(recipient, original) == RetiredRecipient(recipient.recipient_lookup) + assert captured.recipient(recipient, original) == RegisteredRecipient(peer) + + def test_activity_store_selects_only_requested_threads(tmp_path): store = ActivityStore(tmp_path) for index in range(10): diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index 3b26d1667c..a0009168d0 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -4,13 +4,15 @@ import pickle from collections.abc import Mapping from dataclasses import fields, replace +from pathlib import Path import pytest from agent_comms.registry_document import InvalidRegistryDocument, ThreadRows -from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.errors import RelationViolationError, UnregisteredThreadError +from agent_comms.field_codec import FieldCodec, MalformedRecord, ThreadLookup +from agent_comms.session_file import BusSessionLocation from agent_comms.display_references import CurrentDisplayReferences, HistoricalDisplayReference from agent_comms.historical_views import HistorySource from agent_comms.registry_document import HistoricalRootPurpose @@ -58,10 +60,20 @@ def test_registry_snapshot_constructor_and_pickle_own_immutable_maps(comms, decl supplied = dict(captured.threads) snapshot = replace(captured, threads=ThreadRows.from_threads(supplied)) supplied.clear() + assert snapshot.by_lookup # Derived cached views must not enter the pickle. for restored in (snapshot, pickle.loads(pickle.dumps(snapshot, protocol=5))): assert restored.purpose == captured.purpose assert restored.threads[original.name] == original - assert restored.by_lookup[original.incarnation.require_recorded().lookup] == original + lookup = original.incarnation.require_recorded().lookup + assert restored.by_lookup[lookup] == original + assert dict(restored.by_lookup) == {lookup: original} + assert list(restored.by_lookup.items()) == [(lookup, original)] + assert list(restored.by_lookup.values()) == [original] + assert restored.by_lookup == {lookup: original} + with pytest.raises(TypeError): + restored.by_lookup[lookup] = original + with pytest.raises(TypeError): + del restored.by_lookup[lookup] for declaration in fields(restored): value = getattr(restored, declaration.name) if isinstance(value, Mapping): @@ -92,7 +104,12 @@ def test_snapshot_constructor_retains_namespace_validation_and_derives_birth_ind def test_current_reference_resolves_original_birth_after_registry_rename(comms, declare_reference): original = declare_reference(status=RunningThreadStatus()) + captured = comms.registry.snapshot() + before = captured.by_lookup + lookup = original.incarnation.lookup comms.registry.rename(original.name, "renamed") + assert comms.registry.snapshot().by_lookup[lookup].name == "renamed" + assert before[lookup].name == original.name snapshot = comms.views.viewer_snapshot(Checkout(comms.root.parent)) references = pickle.loads(pickle.dumps(snapshot, protocol=5)).references current = references.thread(original.incarnation) @@ -104,9 +121,18 @@ def test_current_reference_resolves_original_birth_after_registry_rename(comms, def test_recreated_thread_reference_resolves_new_birth_not_same_name(comms, declare_reference): original = declare_reference() + captured = comms.registry.snapshot().by_lookup before = comms.views.viewer_snapshot(Checkout(comms.root.parent)).references comms.threads.delete(original.name) + deleted = comms.registry.snapshot().by_lookup recreated = declare_reference(original.name) + current = comms.registry.snapshot().by_lookup + assert original.incarnation.lookup not in deleted + assert original.incarnation.lookup not in current + assert current[recreated.incarnation.lookup] == recreated + assert captured[original.incarnation.lookup] == original + with pytest.raises(KeyError): + current[original.incarnation.lookup] snapshot = comms.views.viewer_snapshot(Checkout(comms.root.parent)) references = pickle.loads(pickle.dumps(snapshot, protocol=5)).references assert recreated.incarnation != original.incarnation @@ -127,6 +153,56 @@ def test_current_reference_membership_and_lookup_share_the_retained_registry(com restored.targets["unowned"] = reference +@pytest.mark.parametrize("decoded", (False, True)) +def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( + comms, declare_reference, decoded, +): + originals = tuple(declare_reference(f"lookup-{number}") for number in range(12)) + selected = replace( + originals[0], session_file=BusSessionLocation(Path("lookup-selected.jsonl")), + ) + invalid = originals[-1] + captured = comms.registry.snapshot() + raw = captured.threads.to_wire() + raw[selected.name] = FieldCodec.encode_fields(selected) + raw[invalid.name][FieldCodec.field_plan(Thread, "model").key] = 123 + rows = ThreadRows.from_wire(raw) + rows.bind(comms.registry.store._bind_thread) + if decoded: + rows[selected.name] = selected # A genuine write removes this row from _raw. + snapshot = replace(captured, threads=rows) + decoded_before = frozenset(snapshot.threads._decoded) + expected = {thread.name: thread.incarnation.lookup for thread in originals} + + lookup = snapshot.by_lookup + assert lookup + assert len(lookup) == len(originals) + assert tuple(lookup) == tuple(expected[name] for name in snapshot.threads) + assert set(lookup.keys()) == set(expected.values()) + assert all(key in lookup and key in lookup.keys() for key in expected.values()) + missing = ThreadLookup.mint() + assert missing not in lookup and missing not in lookup.keys() + assert lookup.get(missing) is None + with pytest.raises(KeyError) as caught: + lookup[missing] + assert caught.value.args == (missing,) + assert frozenset(snapshot.threads._decoded) == decoded_before + + acquired = lookup[selected.incarnation.lookup] + assert acquired is snapshot.require(selected.name) + assert acquired.session_file == selected.session_file + assert acquired.session_file.bound_to(comms.root) is acquired.session_file + assert lookup.get(selected.incarnation.lookup) is acquired + assert lookup[selected.incarnation.lookup] is acquired + assert frozenset(snapshot.threads._decoded) == decoded_before | {selected.name} + with pytest.raises(InvalidRegistryDocument): + lookup[invalid.incarnation.lookup] + with pytest.raises(InvalidRegistryDocument): + dict(lookup) + with pytest.raises(UnregisteredThreadError): + comms.registry.require_lookup(missing) + + @pytest.mark.parametrize("decoded", (False, True)) def test_current_reference_acquires_only_captured_incarnations(comms, declare_reference, decoded): original = declare_reference() From ae91096e44dc8e76f6c431665ba977ac33091608 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:56:11 -0400 Subject: [PATCH 13/74] Repair nominal acquisition interfaces and real fixture preconditions --- src/agent_comms/field_codec.py | 8 +++--- src/agent_comms/registry_document.py | 17 ++++++----- src/agent_comms/relationships.py | 39 +++++++++++++------------- src/agent_comms/thread_identity.py | 5 ---- tests/test_mutual_relation_identity.py | 6 ++-- tests/test_recipient_activity_index.py | 1 + tests/test_registration_components.py | 28 ++++++++++++++---- tests/test_relationships.py | 2 +- tests/test_sidebar_observation.py | 2 +- 9 files changed, 61 insertions(+), 47 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 4859d4567e..3cb7be0359 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -649,8 +649,8 @@ def contribute(self, fields): def wire_keys(self) -> tuple[str, ...]: return (self.key,) - def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: - return {key or self.key: value} + def encoded(self, value: JsonValue, *, key: str) -> dict[str, JsonValue]: + return {key: value} def schema_fields(self): annotation, declared = self.annotation, self.declared @@ -780,7 +780,7 @@ def contribute(self, fields: list[FieldPlan]) -> tuple[str, ...]: def wire_keys(self) -> tuple[str, ...]: return () - def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: + def encoded(self, value: JsonValue, *, key: str) -> dict[str, JsonValue]: return {} def decoded(self, data: object) -> dict[str, object]: @@ -841,7 +841,7 @@ def contribute(self, fields): def wire_keys(self) -> tuple[str, ...]: return tuple(entry.key for entry in self.record.fields) - def encoded(self, value: JsonValue, *, key: str | None = None) -> dict[str, JsonValue]: + def encoded(self, value: JsonValue, *, key: str) -> dict[str, JsonValue]: if not isinstance(value, dict): raise MalformedRecord("An inline record must encode a JSON object.") return value diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 5d7e7b9711..d4e83e01dd 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -331,7 +331,7 @@ def from_wire(cls, raw: object) -> Self: def to_wire(self) -> dict[str, JsonValue]: return dict(self.raw) - def __reduce__(self): + def __reduce__(self) -> tuple[Callable[[object], Self], tuple[dict[str, JsonValue]]]: return type(self).from_wire, (self.to_wire(),) def decoded(self) -> Thread: @@ -378,16 +378,19 @@ def children(self, parent: str) -> tuple[Thread, ...]: else self._raw[name].raw.get(key) == expected) ) + def determining(self, name: str) -> GoalOwner: + """The current nominal determining row, without acquiring a complete declaration.""" + return self._decoded[name] if name in self._decoded else self._raw[name] + def creation_time(self, name: str) -> CreationTime: - return ChainMap(self._decoded, self._raw)[name].created_at + return self.determining(name).created_at def incarnation(self, name: str) -> ThreadIncarnation: - return ChainMap(self._decoded, self._raw)[name].incarnation + return self.determining(name).incarnation def active_goal_owners(self) -> Iterator[Thread]: """Select through the shared goal/role declarations before full thread acquisition.""" - return (self[name] for name, row in ChainMap(self._decoded, self._raw).items() - for _ in row.active_goals()) + return (self[name] for name in self for _ in self.determining(name).active_goals()) def __getitem__(self, name: str) -> Thread: if name not in self._decoded: @@ -424,8 +427,8 @@ def __delitem__(self, name: str) -> None: def to_wire(self) -> dict[str, JsonValue]: # A read memo is not an edit: __setitem__ removes raw only for actual writes. - return {name: FieldCodec.encode(row) - for name, row in ChainMap(self._raw, self._decoded).items()} + return {name: FieldCodec.encode(self._raw[name] if name in self._raw else self._decoded[name]) + for name in self} @classmethod def schema(cls) -> dict[str, JsonValue]: diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 1951ee10c1..1baa850dd6 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -9,7 +9,7 @@ import time from abc import ABC, abstractmethod -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass, field, fields, replace from functools import cmp_to_key from pathlib import Path @@ -113,12 +113,11 @@ def entry(self, identity: ThreadIncarnation, if current else self.entry_goal_contacts(), ) - def counterpart(self, thread: Thread) -> ThreadIncarnation | None: + def counterparts(self, thread: Thread) -> Iterator[ThreadIncarnation]: if self.owner_incarnation == thread.incarnation: - return self.peer_incarnation - if self.peer_incarnation == thread.incarnation: - return self.owner_incarnation - return None + yield self.peer_incarnation + elif self.peer_incarnation == thread.incarnation: + yield self.owner_incarnation @dataclass(frozen=True, slots=True) @@ -424,8 +423,7 @@ class CollaboratingRelationshipGroup(SortableRelationshipGroup): def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: selected: dict[ThreadIncarnation, list[MutualThreadRelation]] = {} for relation in (*inputs.edges, *inputs.goal_contacts): - identity = relation.counterpart(inputs.thread) - if identity is not None: + for identity in relation.counterparts(inputs.thread): selected.setdefault(identity, []).append(relation) return tuple(MutualEntrySource(identity, tuple(relations)) for identity, relations in selected.items()) @@ -449,11 +447,11 @@ class RelationshipInputs: class RelationshipContext(RelationshipInputs): people: Mapping[str, presentation.ThreadView] - def entry(self, target: NamedTarget, *, message: MessageInput | None = None, + def entry(self, target: NamedTarget, *, sequence: int = 0, timestamp: float = 0, detail: str = "") -> RelationshipEntry: return RelationshipEntry( target.name, target.kind, self.people.get(target.name, presentation.NoThreadView()), - message.seq if message else 0, message.timestamp if message else 0, detail, + sequence, timestamp, detail, ) @@ -493,7 +491,7 @@ class RecentEntrySource(NamedEntrySource): def entry(self, context: RelationshipContext) -> RelationshipEntry: return context.entry( - self.target, message=self.message, + self.target, sequence=self.message.seq, timestamp=self.message.timestamp, detail=f"{self.message.sender} → {self.message.target}\n{self.message.body[:240]}", ) @@ -644,7 +642,7 @@ def active(self) -> Collaboration | None: edge for edge in self.pairs if self.second is not None - and edge.counterpart(self.first) == self.second.incarnation + and self.second.incarnation in edge.counterparts(self.first) ), None, ) @@ -840,10 +838,10 @@ def peer_relations(self, owner: str) -> tuple[PeerRelation, ...]: granted[peer.incarnation].append(ChannelmateRelevance) relation: MutualThreadRelation for relation in (*edges, *contacts): - identity = relation.counterpart(thread) - if identity in granted: - granted[identity].append(relation.relevance) - records.setdefault(identity, []).append(relation.record(identity)) + for identity in relation.counterparts(thread): + if identity in granted: + granted[identity].append(relation.relevance) + records.setdefault(identity, []).append(relation.record(identity)) return tuple(sorted( ( PeerRelation( @@ -890,10 +888,11 @@ def contacts(self, owner: str) -> Contacts: contacts, diagnostics = self._goal_contacts(registry, thread) visible: dict[ThreadIncarnation, RelationshipEntry] = {} for relation in (*explicit, *contacts): - identity = relation.counterpart(thread) - visible[identity] = relation.entry( - identity, None, identity.current(registry), visible.get(identity), - ) + for identity in relation.counterparts(thread): + visible[identity] = relation.entry( + identity, presentation.NoThreadView(), identity.current(registry), + visible.get(identity), + ) return Contacts( explicit, tuple(sorted(visible.values(), key=lambda row: (row.target, not row.available))), diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 77308cc1a6..c4fddcc183 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -435,11 +435,6 @@ def incarnation(self) -> ThreadIncarnation: return RecordedThreadIncarnation(self.name, birth) return member.recorded_as(self.name) - @classmethod - def decode(cls, name: str, birth: float) -> ThreadIncarnation: - return FieldCodec.decode(cls, {"name": name, "created_at": birth}).incarnation - - @dataclass(frozen=True, kw_only=True) class ThreadBirthRecord(ThreadIncarnationRecord): diff --git a/tests/test_mutual_relation_identity.py b/tests/test_mutual_relation_identity.py index b367ef03a4..1da49cf084 100644 --- a/tests/test_mutual_relation_identity.py +++ b/tests/test_mutual_relation_identity.py @@ -29,8 +29,8 @@ def peer_incarnation(self): relation = ReviewRelation() assert relation.incident(first) and relation.incident(second) - assert relation.counterpart(first) == second.incarnation - assert relation.counterpart(second) == first.incarnation + assert tuple(relation.counterparts(first)) == (second.incarnation,) + assert tuple(relation.counterparts(second)) == (first.incarnation,) assert not relation.incident(replacement) - assert relation.counterpart(replacement) is None + assert tuple(relation.counterparts(replacement)) == () assert relation.record(second.incarnation).peer == "second" diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 73c6bee280..9d44cf70e5 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -88,6 +88,7 @@ def test_captured_lookup_keeps_original_recipient_identity_and_retirement(tmp_pa comms.registry.rename(peer.name, "renamed") renamed = comms.registry.snapshot() assert renamed.recipient(recipient, original) == RegisteredRecipient(renamed.require("renamed")) + comms.registry.unregister("renamed") comms.threads.delete("renamed") deleted = comms.registry.snapshot() assert deleted.recipient(recipient, original) == RetiredRecipient(recipient.recipient_lookup) diff --git a/tests/test_registration_components.py b/tests/test_registration_components.py index ae11d24799..7787fa0ac1 100644 --- a/tests/test_registration_components.py +++ b/tests/test_registration_components.py @@ -3,6 +3,7 @@ from agent_comms.repo import Checkout import json import os +import pickle import subprocess import sys from dataclasses import dataclass, field, replace @@ -157,21 +158,21 @@ def test_lifecycle_document_survives_fresh_process_restart(tmp_path): assert restored.snapshot().owner_generations["renamed"] > 0 -def test_thread_rows_compare_unread_rows_as_stored_and_decode_only_what_changed(tmp_path): +def test_thread_rows_compare_unread_rows_as_stored_and_decode_only_what_changed(comms, tmp_path): from agent_comms.field_codec import FieldCodec from agent_comms.registry_document import InvalidRegistryDocument, ThreadRows from agent_comms.thread_execution import ExternalThreadExecution # A real declaration with malformed non-determining data stays lazy until a full read. - row = FieldCodec.encode_fields(Thread('unread', frozenset(), Checkout(tmp_path))) + thread = comms.registry.declare(Thread('unread', frozenset(), Checkout(tmp_path))) + row = FieldCodec.encode_fields(thread) row[FieldCodec.field_plan(Thread, 'model').key] = 123 stored = {'unread': row} with pytest.raises(InvalidRegistryDocument): ThreadRows.from_wire(stored)['unread'] assert ThreadRows.from_wire(stored) == ThreadRows.from_wire(stored) written = ThreadRows.from_wire(stored) - written['unread'] = Thread( - 'unread', frozenset(), Checkout(tmp_path), execution=ExternalThreadExecution) + written['unread'] = replace(thread, execution=ExternalThreadExecution) other = ThreadRows.from_wire({}) other['unread'] = written['unread'] assert written == other @@ -188,10 +189,10 @@ def test_birth_projection_agrees_for_full_thread_and_stored_float_records(tmp_pa assert FieldCodec.decode(type(stored), FieldCodec.encode(stored)) == stored -def test_full_thread_read_keeps_original_raw_wire_until_an_actual_assignment(tmp_path): +def test_full_thread_read_keeps_original_raw_wire_until_an_actual_assignment(comms, tmp_path): from agent_comms.registry_document import ThreadRows - thread = Thread("owner", frozenset({"kept"}), Checkout(tmp_path)) + thread = comms.registry.declare(Thread("owner", frozenset({"kept"}), Checkout(tmp_path))) rows = ThreadRows.from_wire({thread.name: FieldCodec.encode_fields(thread)}) tags_key = FieldCodec.field_plan(Thread, "tags").key kept_tags = rows.to_wire()[thread.name][tags_key] @@ -200,3 +201,18 @@ def test_full_thread_read_keeps_original_raw_wire_until_an_actual_assignment(tmp assert rows.to_wire()[thread.name][tags_key] is kept_tags rows[thread.name] = replace(loaded, tags=frozenset({"assigned"})) assert rows.to_wire()[thread.name][tags_key] == ["assigned"] + + +def test_captured_thread_row_uses_its_wire_decode_and_pickle_protocol(comms, tmp_path): + from agent_comms.registry_document import ThreadRow + + thread = comms.registry.declare(Thread("captured", frozenset({"kept"}), Checkout(tmp_path))) + row = ThreadRow.from_wire(FieldCodec.encode_fields(thread)) + decoded = FieldCodec.decode(ThreadRow, FieldCodec.encode(row)) + restored = pickle.loads(pickle.dumps(row, protocol=5)) + for captured in (decoded, restored): + assert type(captured) is ThreadRow + assert captured == row + assert captured.to_wire() == row.to_wire() + assert captured.incarnation == thread.incarnation + assert captured.decoded() == thread diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 22da3146d9..3324caf6cf 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -136,7 +136,7 @@ def test_recent_relationships_keep_the_global_cut_and_exact_preview(tmp_path): )) for _ in range(comms.relationships.RECENT_MESSAGES): bus.append(lambda seq, db: PublicRecord.from_message( - Message("peer", "peer", "unrelated global row", InfoKind, seq=seq), + Message("peer", "origin", "unrelated global row", InfoKind, seq=seq), )) snapshot = comms.relationships.snapshot("owner") assert snapshot.history_limited and snapshot.history_messages == comms.relationships.RECENT_MESSAGES diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index a0009168d0..2be4020a64 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -205,7 +205,7 @@ def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( @pytest.mark.parametrize("decoded", (False, True)) def test_current_reference_acquires_only_captured_incarnations(comms, declare_reference, decoded): - original = declare_reference() + original = declare_reference(status=RunningThreadStatus()) unread = declare_reference("unread") comms.registry.rename(original.name, "renamed") captured = comms.registry.snapshot() From 9b105df4dd82d1134de29093dd270c09cc6c04fa Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:28:32 -0400 Subject: [PATCH 14/74] Serialize retained thread rows through their existing raw owner --- src/agent_comms/registry_document.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index d4e83e01dd..5f6c7cf692 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -427,8 +427,8 @@ def __delitem__(self, name: str) -> None: def to_wire(self) -> dict[str, JsonValue]: # A read memo is not an edit: __setitem__ removes raw only for actual writes. - return {name: FieldCodec.encode(self._raw[name] if name in self._raw else self._decoded[name]) - for name in self} + return {name: (self._raw[name].to_wire() if name in self._raw + else FieldCodec.encode(self._decoded[name])) for name in self} @classmethod def schema(cls) -> dict[str, JsonValue]: From 0524dd6bb9b9ea0c60d6a58d22905429b48192fa Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:43:06 -0400 Subject: [PATCH 15/74] Let ThreadIncarnation strictly resolve its original current peer --- src/agent_comms/channel_targets.py | 5 +---- src/agent_comms/thread_identity.py | 12 +++++++++++- tests/test_named_target_contracts.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index fdea37d61a..320dbd2596 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -483,10 +483,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "name", self.incarnation.name) def selected_name(self, snapshot: RegistryProvenance) -> str: - if not self.incarnation.current(snapshot): - raise UnregisteredThreadError( - f"Original DM peer {self.name!r} is no longer registered.") - return super().selected_name(snapshot) + return self.incarnation.require_current(snapshot).name def require_publication(self, message: Message, snapshot: RegistryProvenance, require_channel: Callable[[str], Channel]) -> None: diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index c4fddcc183..9ad9f876d3 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -25,7 +25,7 @@ UnknownFamilyMember, declared_words, ) -from .errors import RelationViolationError +from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import ( FieldCodec, TurnId, @@ -209,6 +209,16 @@ def require_recorded(self) -> RecordedThreadIncarnation: def renamed(self, name: str) -> ThreadIncarnation: """Retain this member's birth state under an original alias.""" + def require_current(self, snapshot: RegistryProvenance) -> ThreadIncarnation: + """Require this original birth and return its current canonical incarnation.""" + original = self.require_recorded() + current = snapshot.incarnation(original.name) + if current != original: + raise UnregisteredThreadError( + f"Thread {original.name!r} no longer has its recorded birth." + ) + return current + def resolved(self, snapshot: RegistryProvenance) -> ThreadIncarnation: """Follow retained rename aliases only for this exact historical owner.""" if not self.current(snapshot): diff --git a/tests/test_named_target_contracts.py b/tests/test_named_target_contracts.py index 58ad93e6cd..b3599cd9f5 100644 --- a/tests/test_named_target_contracts.py +++ b/tests/test_named_target_contracts.py @@ -11,6 +11,7 @@ from agent_comms.message_page import ConversationMessagePageRequest from agent_comms.read_basis import NamedViewer from agent_comms.thread_execution import ExternalThreadExecution +from agent_comms.thread_identity import BirthlessIncarnation from bus_record_fixture import context_comms from agent_comms.threads import Thread @@ -64,8 +65,17 @@ def test_retained_dm_target_follows_rename_through_send_page_and_revision(tmp_pa comms = context_comms(tmp_path) worktree = Checkout(tmp_path) target = IncarnationThreadTarget(comms.registry.require("Alice").incarnation) + original_cut = comms.registry.snapshot() + assert target.incarnation.require_current(original_cut).name == "Alice" original = comms.messaging.send_user_message(target, "before rename", worktree=worktree) comms.registry.rename("Alice", "renamed") + renamed_cut = comms.registry.snapshot() + current = target.incarnation.require_current(renamed_cut) + assert current == target.incarnation + assert current.name == "renamed" + assert target.selected_name(renamed_cut) == current.name + assert target.incarnation.resolved(renamed_cut).name == current.name + assert target.incarnation.require_current(original_cut).name == "Alice" sent = comms.messaging.send_user_message(target, "after rename", worktree=worktree) assert sent.target == "renamed" delivery = comms.bus.bus.delivery(sent.seq) @@ -92,6 +102,13 @@ def test_retained_dm_refuses_a_removed_birth_at_every_selection_boundary(tmp_pat comms.registry.remove("Alice") if recreate: comms.registry.declare(Thread("Alice", frozenset({"team"}), worktree)) + snapshot = comms.registry.snapshot() + assert not target.incarnation.current(snapshot) + assert target.incarnation.resolved(snapshot) is target.incarnation + with pytest.raises(UnregisteredThreadError): + target.incarnation.require_current(snapshot) + with pytest.raises(UnregisteredThreadError): + target.selected_name(snapshot) before = comms.bus.bus.revision() with pytest.raises(HumanSendRefusedError): comms.messaging.send_user_message(target, "must not reach successor", worktree=worktree) @@ -104,6 +121,17 @@ def test_retained_dm_refuses_a_removed_birth_at_every_selection_boundary(tmp_pat comms.views.history_page(target, viewer=NamedViewer(viewer.name)) +@pytest.mark.parametrize("member", BirthlessIncarnation.members_with(BirthlessIncarnation)) +def test_birthless_identity_cannot_strictly_select_a_current_peer(named_target_journey, member): + incarnation = member.recorded_as("Alice") + snapshot = named_target_journey.registry.snapshot() + assert incarnation.resolved(snapshot) is incarnation + with pytest.raises(ValueError): + incarnation.require_current(snapshot) + with pytest.raises(ValueError): + IncarnationThreadTarget(incarnation) + + def test_dm_page_refuses_replacement_between_registry_capture_and_bus_read(tmp_path, monkeypatch): comms = context_comms(tmp_path) worktree = Checkout(tmp_path) From 544dc198f550ea0a71a695ea7a1aeb11a9a5147e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:54:45 -0400 Subject: [PATCH 16/74] Defer captured goal fields and carry lookup metadata across revisions --- src/agent_comms/presentation.py | 8 +- src/agent_comms/registry_document.py | 137 ++++++++++++++++++--------- src/agent_comms/relationships.py | 8 +- src/agent_comms/thread_identity.py | 16 ++-- tests/test_sidebar_observation.py | 55 ++++++++++- 5 files changed, 161 insertions(+), 63 deletions(-) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index d531d2b477..95969433db 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -27,7 +27,7 @@ from .coordination_database import CoordinationStore from .coordination_errors import DMPaintedBoundChanged, StaleRevision from .declared_family import DeclaredFamily -from .display_order import ChannelSort, NameChannelSort +from .display_order import ChannelSort, NameChannelSort, ThreadSort from .display_references import CurrentDisplayReferences from .external_transcript import ExternalTranscript from .field_codec import ( @@ -724,6 +724,9 @@ def activity_detail(self) -> str: class NoThreadView(NullMember): """A named navigation target has no captured thread presentation.""" + def sort_key(self, order: type[ThreadSort], target: str, sent: float) -> tuple[float, float, str]: + return order.key(target, 0, 0, 0) + @dataclass(frozen=True, slots=True) class ThreadView(ThreadRecord, PresentMember): @@ -750,6 +753,9 @@ def roster( snapshot.seen_at(thread.name), snapshot.owner_binding(thread.name), ) for thread in threads) + def sort_key(self, order: type[ThreadSort], target: str, sent: float) -> tuple[float, float, str]: + return order.key(target, self.thread.birth, self.activity.timestamp, sent) + @property def presentation(self) -> ThreadPresentation: """One declaration-owned interpretation for every thread view.""" diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 5f6c7cf692..94eab9665f 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -57,6 +57,7 @@ OfferedTools, OwnerIdentity, RecordedThreadIncarnation, + ThreadBirthRecord, ThreadIncarnation, ThreadAuthority, TurnIdentity, @@ -314,18 +315,19 @@ def __init__(self, error: Exception) -> None: @dataclass(frozen=True) -class ThreadRow(GoalOwner, DeclaredWireType): - """A captured Thread wire value and its already-decoded determining fields.""" +class ThreadRow(ThreadBirthRecord, DeclaredWireType): + """A captured birth and original wire row; goal fields are acquired only on demand.""" wire_type = Thread raw: Annotated[Mapping[str, JsonValue], WireTransient()] = field(kw_only=True) @classmethod def from_wire(cls, raw: object) -> Self: - wire = FieldCodec.decode(dict[str, JsonValue], raw) + if not isinstance(raw, dict): + raise MalformedRecord("thread row must be an object") return FieldCodec.project_record( - cls, wire, source=Thread, - decoded_fields={"raw": MappingProxyType(wire)}, + cls, raw, source=Thread, + decoded_fields={"raw": MappingProxyType(raw)}, ) def to_wire(self) -> dict[str, JsonValue]: @@ -334,9 +336,18 @@ def to_wire(self) -> dict[str, JsonValue]: def __reduce__(self) -> tuple[Callable[[object], Self], tuple[dict[str, JsonValue]]]: return type(self).from_wire, (self.to_wire(),) + @cached_property + def goal_owner(self) -> GoalOwner: + captured = {entry.name: entry.current(self) + for entry in FieldCodec.field_plans(ThreadBirthRecord) if entry.records} + return FieldCodec.project_record( + GoalOwner, dict(self.raw), source=Thread, decoded_fields=captured, + ) + def decoded(self) -> Thread: - values = {entry.name: entry.current(self) - for entry in FieldCodec.field_plans(type(self)) if entry.records} + owner = self.goal_owner + values = {entry.name: entry.current(owner) + for entry in FieldCodec.field_plans(GoalOwner) if entry.records} return FieldCodec.decode_record(Thread, dict(self.raw), decoded_fields=values) @@ -345,8 +356,11 @@ class ThreadRows(ExternalJsonShape, WireValue, MutableMapping[str, Thread]): def __init__(self) -> None: # checked_rows proved each stored row an object, so the type keeps that proof. - self._raw: dict[str, ThreadRow] = {} + self._raw: dict[str, dict[str, JsonValue]] = {} self._decoded: dict[str, Thread] = {} + # Same-cut copies share read memos; an actual assignment detaches them. + self._captured: dict[str, ThreadRow] = {} + self._lookup_names: dict[ThreadLookup, str] = {} self._binding: Callable[[Thread], Thread] = self.unbound @staticmethod @@ -375,18 +389,33 @@ def children(self, parent: str) -> tuple[Thread, ...]: return tuple( self[name] for name in self if (self._decoded[name].parent.named(parent) if name in self._decoded - else self._raw[name].raw.get(key) == expected) + else self._raw[name].get(key) == expected) ) + def captured(self, name: str) -> ThreadRow: + if name not in self._captured: + self._captured[name] = ThreadRow.from_wire(self._raw[name]) + return self._captured[name] + def determining(self, name: str) -> GoalOwner: - """The current nominal determining row, without acquiring a complete declaration.""" - return self._decoded[name] if name in self._decoded else self._raw[name] + """Only a goal/role question acquires the declared goal-owner projection.""" + return self._decoded[name] if name in self._decoded else self.captured(name).goal_owner + + def birth_record(self, name: str) -> ThreadBirthRecord: + return self._decoded[name] if name in self._decoded else self.captured(name) def creation_time(self, name: str) -> CreationTime: - return self.determining(name).created_at + return self.birth_record(name).created_at def incarnation(self, name: str) -> ThreadIncarnation: - return self.determining(name).incarnation + return self.birth_record(name).incarnation + + def lookup_names(self) -> Mapping[ThreadLookup, str]: + """Prime once for this cut, including its document and detached snapshot copies.""" + if not self._lookup_names: + captured = {self.incarnation(name).require_recorded().lookup: name for name in self} + self._lookup_names.update(captured) + return MappingProxyType(self._lookup_names) def active_goal_owners(self) -> Iterator[Thread]: """Select through the shared goal/role declarations before full thread acquisition.""" @@ -394,12 +423,12 @@ def active_goal_owners(self) -> Iterator[Thread]: def __getitem__(self, name: str) -> Thread: if name not in self._decoded: - self._decoded[name] = self.decode_row(self._raw[name]) + self._decoded[name] = self.decode_row(name) return self._decoded[name] - def decode_row(self, row: ThreadRow) -> Thread: + def decode_row(self, name: str) -> Thread: try: - thread = row.decoded() + thread = self.captured(name).decoded() except ValueError as error: raise InvalidRegistryDocument(error) from error return self._binding(thread) @@ -416,18 +445,27 @@ def __contains__(self, name: object) -> bool: def __setitem__(self, name: str, thread: Thread) -> None: if thread.name != name: raise MalformedRecord("thread name differs from its declaration") + if not self._lookup_names or self._lookup_names.get( + thread.incarnation.require_recorded().lookup + ) != name: + self._lookup_names = {} + self._captured = self._captured.copy() + self._captured.pop(name, None) self._decoded[name] = self._binding(thread) self._raw.pop(name, None) def __delitem__(self, name: str) -> None: if name not in self: raise KeyError(name) + self._lookup_names = {} + self._captured = self._captured.copy() + self._captured.pop(name, None) self._raw.pop(name, None) self._decoded.pop(name, None) def to_wire(self) -> dict[str, JsonValue]: # A read memo is not an edit: __setitem__ removes raw only for actual writes. - return {name: (self._raw[name].to_wire() if name in self._raw + return {name: (dict(self._raw[name]) if name in self._raw else FieldCodec.encode(self._decoded[name])) for name in self} @classmethod @@ -436,18 +474,14 @@ def schema(cls) -> dict[str, JsonValue]: return {"type": "object", "additionalProperties": FieldCodec.record_schema(Thread)} @classmethod - def checked_rows(cls, raw: object) -> dict[str, ThreadRow]: + def checked_rows(cls, raw: object) -> dict[str, dict[str, JsonValue]]: if not isinstance(raw, dict): raise MalformedRecord("registry threads must be an object") - result: dict[str, ThreadRow] = {} + name_key = FieldCodec.field_plan(Thread, "name").key for name, row in raw.items(): - if not isinstance(name, str) or not isinstance(row, dict): - raise MalformedRecord("thread name differs from its declaration") - captured = ThreadRow.from_wire(row) - if captured.name != name: + if not isinstance(name, str) or not isinstance(row, dict) or row.get(name_key) != name: raise MalformedRecord("thread name differs from its declaration") - result[name] = captured - return result + return dict(raw) def require_distinct_births(self) -> None: """Rows decode late, so a read of one thread never sees two sharing a creation lookup; @@ -468,18 +502,14 @@ def from_threads(cls, threads: Mapping[str, Thread]) -> Self: result.update(threads) return result - def copy(self) -> ThreadRows: - result = ThreadRows() - result._raw = self._raw.copy() - result._decoded = self._decoded.copy() - result._binding = self._binding - return result - - def freeze(self) -> ThreadRows: - result = FrozenThreadRows() - result._raw = self._raw.copy() - result._decoded = self._decoded.copy() - result._binding = self._binding + @classmethod + def copied(cls, source: ThreadRows) -> Self: + result = cls() + result._raw = source._raw.copy() + result._decoded = source._decoded.copy() + result._captured = source._captured + result._lookup_names = source._lookup_names + result._binding = source._binding return result def reuse(self, rows: Mapping[str, object], kept: frozenset[str], earlier: ThreadRows) -> None: @@ -488,7 +518,25 @@ def reuse(self, rows: Mapping[str, object], kept: frozenset[str], earlier: Threa captured = ChainMap(retained, changed) self._raw = {name: captured[name] for name in rows} self._decoded = {name: earlier._decoded[name] for name in kept if name in earlier._decoded} + self._captured = {name: earlier._captured[name] for name in kept & earlier._captured.keys()} self._binding = earlier._binding + if earlier._lookup_names: + removed = (earlier._raw.keys() | earlier._decoded.keys()) - rows.keys() + retired = {earlier.incarnation(name).require_recorded().lookup for name in removed} + added: dict[ThreadLookup, str] = {} + for name in rows.keys() - kept: + lookup = self.incarnation(name).require_recorded().lookup + if earlier._lookup_names.get(lookup) != name: + added[lookup] = name + if name in earlier: + retired.add(earlier.incarnation(name).require_recorded().lookup) + if retired or added: + self._lookup_names = earlier._lookup_names.copy() + for lookup in retired: + self._lookup_names.pop(lookup) + self._lookup_names.update(added) + else: + self._lookup_names = earlier._lookup_names class FrozenThreadRows(ThreadRows): @@ -504,12 +552,7 @@ class ThreadLookupIndex(Mapping[ThreadLookup, Thread]): """A captured cut's lookup keys; complete values still belong to its thread rows.""" rows: ThreadRows - _names: Mapping[ThreadLookup, str] = field(init=False, repr=False) - - def __post_init__(self) -> None: - object.__setattr__(self, "_names", MappingProxyType({ - self.rows.incarnation(name).require_recorded().lookup: name for name in self.rows - })) + _names: Mapping[ThreadLookup, str] = field(repr=False) def __getitem__(self, lookup: ThreadLookup) -> Thread: return self.rows[self._names[lookup]] @@ -518,7 +561,7 @@ def __contains__(self, lookup: object) -> bool: return lookup in self._names def __iter__(self) -> Iterator[ThreadLookup]: - return iter(self._names) + return (self.rows.incarnation(name).require_recorded().lookup for name in self.rows) def __len__(self) -> int: return len(self._names) @@ -527,11 +570,11 @@ def __len__(self) -> int: class ThreadRowsSlice(OwnRow[ThreadRows]): @classmethod def copy(cls, value: ThreadRows) -> ThreadRows: - return value.copy() + return ThreadRows.copied(value) @classmethod def freeze(cls, value: ThreadRows) -> ThreadRows: - return value.freeze() + return FrozenThreadRows.copied(value) @classmethod def reuse(cls, rows: Mapping[str, object], kept: frozenset[str], @@ -1153,7 +1196,7 @@ def __post_init__(self) -> None: @cached_property def by_lookup(self) -> Mapping[ThreadLookup, Thread]: - return ThreadLookupIndex(self.threads) + return ThreadLookupIndex(self.threads, self.threads.lookup_names()) def owner_generation(self, name: str) -> int: return self.owner_generations[self.canonical_name(name)] diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 1baa850dd6..3c8277aa07 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -379,13 +379,7 @@ def from_entries(cls, entries: dict[str, RelationshipEntry], order = cls.selected_order(context.orders) def key(item: RelationshipEntry) -> tuple[float, float, str]: - people = item.person.present() - return order.key( - item.target, - sum(person.thread.birth for person in people), - sum(person.activity.timestamp for person in people), - sum(context.sent.get(item.target, 0) for _ in people), - ) + return item.person.sort_key(order, item.target, context.sent.get(item.target, 0)) return cls({entry.target: entry for entry in sorted(entries.values(), key=key)}, order) diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 9ad9f876d3..488efd9251 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -213,7 +213,7 @@ def require_current(self, snapshot: RegistryProvenance) -> ThreadIncarnation: """Require this original birth and return its current canonical incarnation.""" original = self.require_recorded() current = snapshot.incarnation(original.name) - if current != original: + if not original.current(snapshot): raise UnregisteredThreadError( f"Thread {original.name!r} no longer has its recorded birth." ) @@ -223,11 +223,13 @@ def resolved(self, snapshot: RegistryProvenance) -> ThreadIncarnation: """Follow retained rename aliases only for this exact historical owner.""" if not self.current(snapshot): return self - return snapshot.threads[snapshot.canonical_name(self.name)].incarnation + return snapshot.incarnation(self.name) def current(self, snapshot: RegistryProvenance) -> bool: - thread = snapshot.threads.get(snapshot.canonical_name(self.name)) - return thread is not None and thread.incarnation == self + try: + return snapshot.incarnation(self.name) == self + except UnregisteredThreadError: + return False def recorded_or( self, make: Callable[[RecordedThreadIncarnation], T], otherwise: Callable[[], T] @@ -256,8 +258,10 @@ def matches_recorded_name(self, name: str, snapshot: RegistryProvenance) -> bool The registry owns aliases and birth. A stored name does not supply or manufacture either fact, including after a name is reused. """ - thread = snapshot.threads.get(snapshot.canonical_name(name)) - return thread is not None and self.resolved(snapshot) == thread.incarnation + try: + return self.resolved(snapshot) == snapshot.incarnation(name) + except UnregisteredThreadError: + return False def recorded_names(self, snapshot: RegistryProvenance) -> tuple[ThreadIncarnation, ...]: """Original names retained for this exact current incarnation.""" diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index 2be4020a64..4e01f08b35 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -17,6 +17,7 @@ from agent_comms.historical_views import HistorySource from agent_comms.registry_document import HistoricalRootPurpose from agent_comms.thread_status import RunningThreadStatus, StoppedThreadStatus +from agent_comms.thread_text import GivenThreadText from agent_comms.ui_model.sidebar import SidebarModel from agent_comms.ui_model.unread import ExactUnread, UnobservedUnread from agent_comms.threads import Thread @@ -228,6 +229,56 @@ def test_current_reference_acquires_only_captured_incarnations(comms, declare_re assert references.find("#comms")[0].reference.name == "#comms" +@pytest.mark.parametrize("entry", ( + FieldCodec.field_plan(Thread, "goal"), FieldCodec.field_plan(Thread, "role"), +)) +def test_cold_shape_and_birth_capture_do_not_acquire_unrequested_goal_fields( + comms, declare_reference, entry, +): + original = declare_reference() + captured = comms.registry.snapshot() + raw = captured.threads.to_wire() + raw[original.name][entry.key] = [] + rows = ThreadRows.from_wire(raw) + assert not rows._captured + assert not rows._decoded + snapshot = replace(captured, threads=rows) + assert snapshot.incarnation(original.name) == original.incarnation + assert original.incarnation.lookup in snapshot.by_lookup + assert not snapshot.threads._decoded + with pytest.raises(MalformedRecord): + snapshot.threads.determining(original.name) + with pytest.raises(InvalidRegistryDocument): + snapshot.by_lookup[original.incarnation.lookup] + + +def test_lookup_metadata_is_carried_from_snapshot_through_data_only_revision( + comms, declare_reference, +): + original = declare_reference(status=RunningThreadStatus()) + declare_reference("unrelated") + before = comms.registry.snapshot() + lookup = original.incarnation.lookup + original_index = before.by_lookup + metadata = before.threads._lookup_names + changed = comms.registry.update_thread( + original.name, lambda thread: replace(thread, task=GivenThreadText("data-only edit")), + ) + after = comms.registry.snapshot() + assert after.by_lookup[lookup] == changed + assert after.threads._lookup_names is metadata + assert original_index[lookup] == original + assert tuple(after.by_lookup) == tuple(after.incarnation(name).require_recorded().lookup + for name in after.threads) + comms.registry.rename(original.name, "renamed") + renamed = comms.registry.snapshot() + assert renamed.by_lookup[lookup].name == "renamed" + assert original_index[lookup].name == original.name + assert after.by_lookup[lookup].name == original.name + assert tuple(renamed.by_lookup) == tuple(renamed.incarnation(name).require_recorded().lookup + for name in renamed.threads) + + @pytest.mark.parametrize("entry,value", ( (FieldCodec.field_plan(Thread, "name"), "invalid name"), (FieldCodec.field_plan(Thread, "created_at"), []), @@ -239,7 +290,7 @@ def test_captured_reference_rows_refuse_malformed_determining_fields(declare_ref raw = FieldCodec.encode_fields(original) raw[entry.key] = value with pytest.raises(MalformedRecord): - ThreadRows.from_wire({original.name: raw}) + ThreadRows.from_wire({original.name: raw}).determining(original.name) def test_captured_reference_rows_refuse_unknown_or_absent_required_fields(declare_reference): @@ -249,7 +300,7 @@ def test_captured_reference_rows_refuse_unknown_or_absent_required_fields(declar del missing_birth[FieldCodec.field_plan(Thread, "created_at").key] for malformed in ({**raw, "undeclared_field": "refuse"}, missing_birth): with pytest.raises(MalformedRecord): - ThreadRows.from_wire({original.name: malformed}) + ThreadRows.from_wire({original.name: malformed}).determining(original.name) def test_current_reference_source_projection_keeps_historical_identity(comms, declare_reference): From 5933cd95dbe2f21c1de6b108a0ba85be778f41d8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:01:03 -0400 Subject: [PATCH 17/74] Separate explicit new-thread tags from parent-derived fork membership --- .../cli_support/thread_creation.py | 53 ++++++++++++++++ src/agent_comms/cli_verbs/fork.py | 14 ++--- src/agent_comms/cli_verbs/new.py | 62 ++----------------- src/agent_comms/thread_management.py | 37 ++++++----- tests/test_cli.py | 5 +- 5 files changed, 89 insertions(+), 82 deletions(-) create mode 100644 src/agent_comms/cli_support/thread_creation.py 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..6c34493c72 --- /dev/null +++ b/src/agent_comms/cli_support/thread_creation.py @@ -0,0 +1,53 @@ +"""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 CliCommand +from agent_comms.cli_commands import NameArgument, ThreadCreatedResult, _tag_choice_text +from agent_comms.comms import Comms +from agent_comms.config_options import ConfigOptions, ModelConfigOption, ThinkingLevelConfigOption +from agent_comms.field_codec import Argument, Catalog, EditorFormat, FieldCodec, Multiline, Normalize +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 + + +type TagArgument[T] = Annotated[ + T, EditorFormat(FieldCodec.encode), Normalize(_tag_choice_text), Argument(flags=("--tags",)), +] + + +@dataclass(frozen=True, kw_only=True) +class ThreadCreationCliCommand(NameArgument, CatalogSelection, CliCommand, declared_name=None): + 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 + @abstractmethod + def spec_type(self) -> type[ThreadCreationSpec]: ... + + @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) diff --git a/src/agent_comms/cli_verbs/fork.py b/src/agent_comms/cli_verbs/fork.py index b36253a47a..e6b9c1637f 100644 --- a/src/agent_comms/cli_verbs/fork.py +++ b/src/agent_comms/cli_verbs/fork.py @@ -1,33 +1,29 @@ from __future__ import annotations -from agent_comms.cli_verbs.new import NewThreadCliCommand +from agent_comms.cli_support.thread_creation import TagArgument, ThreadCreationCliCommand 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 ForkCliCommand(NativeOwnerCommand, ThreadBoundCliCommand, 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: TagArgument[TagChoice] = InheritedTags() @classmethod def thread_binding(cls, thread: Thread) -> Self: - return cls(name='', parent=thread.name, tags=GivenTags(thread.tags), project=thread.worktree, + return cls(name='', 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..e7661e5b44 100644 --- a/src/agent_comms/cli_verbs/new.py +++ b/src/agent_comms/cli_verbs/new.py @@ -1,76 +1,24 @@ 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_support.thread_creation import TagArgument, ThreadCreationCliCommand 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 @dataclass(frozen=True, kw_only=True) -class NewThreadCliCommand(NameArgument, CatalogSelection, CliCommand, declared_name="new"): +class NewThreadCliCommand(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: TagArgument[GivenTags] @classmethod def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[Self, ...]: 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) diff --git a/src/agent_comms/thread_management.py b/src/agent_comms/thread_management.py index 2f79f5b457..cddd5e9f97 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 @@ -72,9 +72,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]: ... @@ -144,27 +141,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) @@ -172,10 +178,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) @@ -185,8 +192,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(parent.tags), + return replace(self.declaration(self.tags.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: @@ -541,7 +548,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/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) From 984d8f45cc68d1dbf4d4f9a5866c0739998824c7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:15:56 -0400 Subject: [PATCH 18/74] Collect required creation inputs through native command bindings --- src/agent_comms/channel_targets.py | 10 +- src/agent_comms/cli_command.py | 156 ++++++++++++++---- .../cli_support/thread_creation.py | 31 ++-- src/agent_comms/cli_verbs/fork.py | 4 +- src/agent_comms/cli_verbs/new.py | 15 +- src/agent_comms/target_fields.py | 8 +- tests/test_checkout_submodules.py | 2 +- tests/test_end_to_end.py | 16 +- tests/test_thread_creation.py | 84 ++++++++++ 9 files changed, 262 insertions(+), 64 deletions(-) create mode 100644 tests/test_thread_creation.py diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index fdea37d61a..0023b5bdc7 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -23,7 +23,7 @@ from .audience_manifest import AudiencedMessage, FrozenRecipient from .catalog_document import CatalogAccess, CatalogDocument from .channels import Channel - from .cli_command import CliCommand + from .cli_command import CliCommand, CommandBinding from .cli_result import CliResult from .comms import Comms from .goal_management import Goals @@ -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: @@ -413,7 +413,7 @@ 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: @@ -562,7 +562,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): @@ -653,7 +653,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/cli_command.py b/src/agent_comms/cli_command.py index 55778a9a38..f5a6b3bb14 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 @@ -63,15 +64,128 @@ 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 + @abstractmethod + def editable_fields(self) -> tuple[TargetField, ...]: ... + + def for_editor(self, comms: Comms, target: str, project: Checkout) -> Self: + return self + + @property + def catalog_context(self) -> object: + 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_fields(self, arguments: Mapping[str, object] = {}) -> tuple[TargetField, ...]: + binding = self.for_fields(arguments) + catalogs = ConfigOptions.configured() + return tuple([await item.prepare(catalogs, binding.catalog_context) + 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 + + @abstractmethod + def edited(self, arguments: Mapping[str, object]) -> CliCommand: ... + + @abstractmethod + def confirmation(self) -> str: ... + + @classmethod + @abstractmethod + def selection_confirmation(cls, commands: tuple[Self, ...]) -> str: ... + + @abstractmethod + def with_confirmation(self, confirmed: bool) -> CommandBinding: ... + + +@dataclass(frozen=True) +class CommandContext(CatalogSelection): + """A partial command's supplied fields, with native edits decoded by their plans.""" + + def edited(self, arguments: Mapping[str, object]) -> Self: + changes = {name: value + for entry in FieldCodec.argument_fields(type(self)) if entry.key in arguments + for name, value in entry.decoded({ + entry.key: entry.editor_payload(arguments[entry.key]), + }).items()} + return replace(self, **changes) if changes else self + + +@dataclass(frozen=True) +class PendingCommandBinding(CommandBinding): + """Only supplied context, borrowing the concrete command's declared fields.""" + + command: type[CliCommand] + context: CommandContext + + @property + def declaration(self) -> type[CliCommand]: + return self.command + + @property + def catalog_context(self) -> CatalogSelection: + return self.context + + @property + def editable_fields(self) -> tuple[TargetField, ...]: + supplied = {entry.name: entry.current(self.context) + for entry in FieldCodec.argument_fields(type(self.context))} + return tuple(TargetField.of(entry, supplied[entry.name] if entry.name in supplied else entry.default()) + for entry in FieldCodec.argument_fields(self.declaration) + if not entry.setting(TargetBound, False)) + + def for_fields(self, arguments: Mapping[str, object]) -> PendingCommandBinding: + return replace(self, context=self.context.edited(arguments)) + + def edited(self, arguments: Mapping[str, object]) -> CliCommand: + self.authorize_edits(arguments) + return self.declaration.from_payload({ + **FieldCodec.encode(self.context), + self.declaration.family_discriminator: FieldCodec.encode(self.declaration), + **arguments, + }) + + 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 +239,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 +274,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,9 +290,6 @@ 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 - @property def editable_fields(self) -> tuple[TargetField, ...]: """The command owns the parameters offered by its target action.""" @@ -189,11 +300,6 @@ def editable_fields(self) -> tuple[TargetField, ...]: 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 describe(self, comms: Comms, target: CommandTarget, project: Checkout) -> TargetAction: bound = self.for_editor(comms, target.name, project) @@ -236,15 +342,7 @@ def with_confirmation(self, confirmed: bool) -> Self: 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") + bound = self.authorize_edits(arguments) encoded = FieldCodec.encode(self) return type(self).from_payload({ **{key: encoded[key] for key in bound & encoded.keys()}, @@ -271,7 +369,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 +378,8 @@ 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] = {}) -> tuple[TargetField, ...]: + return await self.bound[0].prepare_fields(arguments) @property def label(self) -> str: @@ -289,14 +387,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 +578,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 +609,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_support/thread_creation.py b/src/agent_comms/cli_support/thread_creation.py index 6c34493c72..8e964102a7 100644 --- a/src/agent_comms/cli_support/thread_creation.py +++ b/src/agent_comms/cli_support/thread_creation.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Annotated -from agent_comms.cli_command import CliCommand +from agent_comms.cli_command import CliCommand, CommandContext from agent_comms.cli_commands import NameArgument, ThreadCreatedResult, _tag_choice_text from agent_comms.comms import Comms from agent_comms.config_options import ConfigOptions, ModelConfigOption, ThinkingLevelConfigOption @@ -16,16 +16,17 @@ 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 +from agent_comms.thread_model import ThreadModel -type TagArgument[T] = Annotated[ - T, EditorFormat(FieldCodec.encode), Normalize(_tag_choice_text), Argument(flags=("--tags",)), -] +TAG_ARGUMENT = ( + EditorFormat(FieldCodec.encode), Normalize(_tag_choice_text), Argument(flags=("--tags",)), +) @dataclass(frozen=True, kw_only=True) -class ThreadCreationCliCommand(NameArgument, CatalogSelection, CliCommand, declared_name=None): +class ThreadCreationFields(NameArgument, CommandContext): + """Supplied launch context is also the complete command's field declaration.""" task: Annotated[str, Multiline(), Argument(flags=("--task",))] = "" project: Annotated[Checkout, Argument(flags=("--project",))] model: Annotated[ @@ -35,19 +36,25 @@ class ThreadCreationCliCommand(NameArgument, CatalogSelection, CliCommand, decla ThinkingLevelField, Catalog(ThinkingLevelConfigOption), Argument(flags=("--thinking-level",)), ] = NativeThreadDefaults.thinking_level - @property - @abstractmethod - def spec_type(self) -> type[ThreadCreationSpec]: ... - @property def project_path(self) -> Path: return self.project.path - def apply(self, ctx: Comms) -> ThreadCreatedResult: - spec = self.spec_type.from_task(**{ + +@dataclass(frozen=True, kw_only=True) +class ThreadCreationCliCommand(ThreadCreationFields, CliCommand, declared_name=None): + @property + @abstractmethod + def spec_type(self) -> type[ThreadCreationSpec]: ... + + 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 e6b9c1637f..548bf1b13a 100644 --- a/src/agent_comms/cli_verbs/fork.py +++ b/src/agent_comms/cli_verbs/fork.py @@ -1,6 +1,6 @@ from __future__ import annotations -from agent_comms.cli_support.thread_creation import TagArgument, ThreadCreationCliCommand +from agent_comms.cli_support.thread_creation import TAG_ARGUMENT, ThreadCreationCliCommand from agent_comms.cli_support.thread_bound import ThreadBoundCliCommand from agent_comms.field_codec import Argument from agent_comms.field_codec import TargetBound @@ -21,7 +21,7 @@ class ForkCliCommand(NativeOwnerCommand, ThreadBoundCliCommand, ThreadCreationCl str, TargetBound(), Argument(flags=("--parent",)) ] - tags: TagArgument[TagChoice] = InheritedTags() + tags: Annotated[TagChoice, *TAG_ARGUMENT] = InheritedTags() @classmethod def thread_binding(cls, thread: Thread) -> Self: diff --git a/src/agent_comms/cli_verbs/new.py b/src/agent_comms/cli_verbs/new.py index e7661e5b44..349a389107 100644 --- a/src/agent_comms/cli_verbs/new.py +++ b/src/agent_comms/cli_verbs/new.py @@ -1,24 +1,25 @@ from __future__ import annotations -from agent_comms.cli_support.thread_creation import TagArgument, ThreadCreationCliCommand +from agent_comms.cli_command import PendingCommandBinding +from agent_comms.cli_support.thread_creation import TAG_ARGUMENT, ThreadCreationCliCommand, ThreadCreationFields from agent_comms.comms import Comms from agent_comms.native_settings import NativeThreadDefaults from agent_comms.repo import Checkout from agent_comms.thread_management import GivenTags from agent_comms.thread_management import NewThreadSpec from dataclasses import dataclass -from typing import ClassVar -from typing import Self +from typing import Annotated, ClassVar @dataclass(frozen=True, kw_only=True) class NewThreadCliCommand(ThreadCreationCliCommand, declared_name="new"): help = "Create a new pi thread" spec_type: ClassVar[type[NewThreadSpec]] = NewThreadSpec - tags: TagArgument[GivenTags] + tags: Annotated[GivenTags, *TAG_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),) + return (PendingCommandBinding(cls, ThreadCreationFields( + name='', project=project, model=defaults.model, + thinking_level=defaults.thinking_level)),) diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index f07a82f7a5..85e3ea0b73 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -6,7 +6,7 @@ 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 MISSING, dataclass from dataclasses import replace from agent_comms.config_options import CatalogChoice, CatalogConfigOption, ConfigOptions from agent_comms.thread_model import CatalogSelection @@ -44,9 +44,13 @@ def multiline(self) -> bool: def description(self) -> str: return self.plan.description + @property + def supplied(self) -> bool: + return self.value is not MISSING + @property def editor_default(self) -> JsonValue: - return self.plan.editor_default(self.value) + return self.plan.editor_default(self.value) if self.supplied else None def editor_hints(self, choices: Callable[[ChoiceTargetField], object]) -> tuple[object, ...]: """An unrestricted field does not declare a finite-choice editor.""" diff --git a/tests/test_checkout_submodules.py b/tests/test_checkout_submodules.py index 60700222a2..b4166031fc 100644 --- a/tests/test_checkout_submodules.py +++ b/tests/test_checkout_submodules.py @@ -136,7 +136,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_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_thread_creation.py b/tests/test_thread_creation.py new file mode 100644 index 0000000000..b4bea079c4 --- /dev/null +++ b/tests/test_thread_creation.py @@ -0,0 +1,84 @@ +"""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 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 +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 + tags = next(item for item in binding.editable_fields if item.name == 'tags') + assert tags.required and not tags.supplied + assert tags.editor_default is None + 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({'name': 'child'}) + assert prepared.context.name == 'child' + assert not next(item for item in prepared.editable_fields if item.name == 'tags').supplied + + +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() + editor = ForkCliCommand.thread_binding(parent) + 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 From f9d18ba8c47e48812026e98678963ebac45cd9bf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:31:25 -0400 Subject: [PATCH 19/74] Keep native context edits distinct from complete command authorization --- src/agent_comms/cli_command.py | 4 ++-- tests/test_thread_creation.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/cli_command.py b/src/agent_comms/cli_command.py index f5a6b3bb14..afb03cfe71 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -118,7 +118,7 @@ def with_confirmation(self, confirmed: bool) -> CommandBinding: ... class CommandContext(CatalogSelection): """A partial command's supplied fields, with native edits decoded by their plans.""" - def edited(self, arguments: Mapping[str, object]) -> Self: + def with_editor_values(self, arguments: Mapping[str, object]) -> Self: changes = {name: value for entry in FieldCodec.argument_fields(type(self)) if entry.key in arguments for name, value in entry.decoded({ @@ -151,7 +151,7 @@ def editable_fields(self) -> tuple[TargetField, ...]: if not entry.setting(TargetBound, False)) def for_fields(self, arguments: Mapping[str, object]) -> PendingCommandBinding: - return replace(self, context=self.context.edited(arguments)) + return replace(self, context=self.context.with_editor_values(arguments)) def edited(self, arguments: Mapping[str, object]) -> CliCommand: self.authorize_edits(arguments) diff --git a/tests/test_thread_creation.py b/tests/test_thread_creation.py index b4bea079c4..9f435cead0 100644 --- a/tests/test_thread_creation.py +++ b/tests/test_thread_creation.py @@ -68,6 +68,18 @@ def test_pending_catalog_context_can_change_before_tags_are_supplied(comms, sour assert not next(item for item in prepared.editable_fields if item.name == 'tags').supplied +@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_fork_cli_defaults_to_actual_parent_membership(comms, source_repository): parent_command = parsed(['new', '--name', 'parent', '--project', str(source_repository.checkout), '--tags', 'original']) From ee7b54782a1111ff1bdbb2d29e46a7a236b9aeff Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:22:19 -0400 Subject: [PATCH 20/74] Complete creation bindings through native field and presence owners --- src/agent_comms/cli_command.py | 118 ++++++++++-------- src/agent_comms/cli_commands.py | 12 +- .../cli_support/thread_creation.py | 21 ++-- src/agent_comms/cli_verbs/fork.py | 4 +- src/agent_comms/cli_verbs/new.py | 5 +- src/agent_comms/cli_verbs/thread_tags.py | 8 +- src/agent_comms/field_codec.py | 87 +++++++++---- src/agent_comms/target_fields.py | 95 ++++++++++---- src/agent_comms/thread_management.py | 3 +- tests/test_thread_creation.py | 9 +- 10 files changed, 230 insertions(+), 132 deletions(-) diff --git a/src/agent_comms/cli_command.py b/src/agent_comms/cli_command.py index afb03cfe71..d6f9b42fc7 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -28,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: @@ -72,23 +72,33 @@ class CommandBinding(ABC): 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 editable_fields(self) -> tuple[TargetField, ...]: ... + def field_value(self, entry: FieldPlan) -> TargetField: ... - def for_editor(self, comms: Comms, target: str, project: Checkout) -> Self: - return self + @abstractmethod + def retained_payload(self) -> dict[str, object]: + """Already decoded constructor values, never serialized for editing.""" - @property - def catalog_context(self) -> object: + 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_fields(self, arguments: Mapping[str, object] = {}) -> tuple[TargetField, ...]: + 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 item.prepare(catalogs, binding.catalog_context) + return tuple([await binding.prepare_field(item, catalogs) for item in binding.editable_fields]) def authorize_edits(self, arguments: Mapping[str, object]) -> frozenset[str]: @@ -100,8 +110,12 @@ def authorize_edits(self, arguments: Mapping[str, object]) -> frozenset[str]: raise ValueError("Parameters not offered by this target command cannot be overridden") return bound - @abstractmethod - def edited(self, arguments: Mapping[str, object]) -> CliCommand: ... + def edited(self, arguments: Mapping[str, object]) -> CliCommand: + self.authorize_edits(arguments) + decoded = {name: value + for entry in FieldCodec.argument_fields(self.declaration) if entry.key in arguments + for name, value in entry.decoded(arguments).items()} + return FieldCodec.construct(self.declaration, {**self.retained_payload(), **decoded}) @abstractmethod def confirmation(self) -> str: ... @@ -114,26 +128,33 @@ def selection_confirmation(cls, commands: tuple[Self, ...]) -> str: ... 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 partial command's supplied fields, with native edits decoded by their plans.""" - - def with_editor_values(self, arguments: Mapping[str, object]) -> Self: - changes = {name: value - for entry in FieldCodec.argument_fields(type(self)) if entry.key in arguments - for name, value in entry.decoded({ - entry.key: entry.editor_payload(arguments[entry.key]), - }).items()} - return replace(self, **changes) if changes else self + """A declaration of supplied fields, not an alternate command editor.""" @dataclass(frozen=True) -class PendingCommandBinding(CommandBinding): +class PendingCommandBinding(CatalogBinding): """Only supplied context, borrowing the concrete command's declared fields.""" command: type[CliCommand] context: CommandContext + def __post_init__(self) -> None: + if not issubclass(self.command, type(self.context)): + raise TypeError('A pending context must be a declared parent of its command') + @property def declaration(self) -> type[CliCommand]: return self.command @@ -142,24 +163,24 @@ def declaration(self) -> type[CliCommand]: def catalog_context(self) -> CatalogSelection: return self.context - @property - def editable_fields(self) -> tuple[TargetField, ...]: - supplied = {entry.name: entry.current(self.context) - for entry in FieldCodec.argument_fields(type(self.context))} - return tuple(TargetField.of(entry, supplied[entry.name] if entry.name in supplied else entry.default()) - for entry in FieldCodec.argument_fields(self.declaration) - if not entry.setting(TargetBound, False)) + def retained_payload(self) -> dict[str, object]: + return {entry.name: entry.current(self.context) + for entry in FieldCodec.field_plans(type(self.context))} - def for_fields(self, arguments: Mapping[str, object]) -> PendingCommandBinding: - return replace(self, context=self.context.with_editor_values(arguments)) + 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 edited(self, arguments: Mapping[str, object]) -> CliCommand: + def for_fields(self, arguments: Mapping[str, object]) -> PendingCommandBinding: self.authorize_edits(arguments) - return self.declaration.from_payload({ - **FieldCodec.encode(self.context), - self.declaration.family_discriminator: FieldCodec.encode(self.declaration), - **arguments, - }) + changes = {name: value + for entry in FieldCodec.argument_fields(type(self.context)) if entry.key in arguments + for name, value in entry.decoded({ + entry.key: entry.editor_payload(arguments[entry.key]), + }).items()} + return replace(self, context=replace(self.context, **changes)) if changes else self def confirmation(self) -> str: return '' @@ -290,15 +311,13 @@ def target_catalog(cls, comms: Comms, target: TargetSelection, channel if declaration.channel_fields() else NoChannel()) for declaration, commands in grouped.items()) - @property - def editable_fields(self) -> tuple[TargetField, ...]: - """The command owns the parameters offered by its target action.""" + def field_value(self, entry: FieldPlan) -> TargetField: + return TargetField.of(entry, entry.current(self)) - return tuple( - TargetField.of(entry, entry.current(self)) - for entry in FieldCodec.argument_fields(type(self)) - if not entry.setting(TargetBound, False) - ) + 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: @@ -341,15 +360,6 @@ def with_confirmation(self, confirmed: bool) -> Self: raise ValueError(self.confirmation()) return self - def edited(self, arguments: Mapping[str, object]) -> Self: - bound = self.authorize_edits(arguments) - 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, @@ -378,7 +388,9 @@ class TargetAction: def editable_fields(self) -> tuple[TargetField, ...]: return self.bound[0].editable_fields - async def prepare_fields(self, arguments: Mapping[str, object] = {}) -> tuple[TargetField, ...]: + async def prepare_fields( + self, arguments: Mapping[str, object] = MappingProxyType({}), + ) -> tuple[TargetField, ...]: return await self.bound[0].prepare_fields(arguments) @property diff --git a/src/agent_comms/cli_commands.py b/src/agent_comms/cli_commands.py index 7f067c70e8..5ff848499d 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/thread_creation.py b/src/agent_comms/cli_support/thread_creation.py index 8e964102a7..07c617a558 100644 --- a/src/agent_comms/cli_support/thread_creation.py +++ b/src/agent_comms/cli_support/thread_creation.py @@ -7,21 +7,16 @@ from pathlib import Path from typing import Annotated -from agent_comms.cli_command import CliCommand, CommandContext -from agent_comms.cli_commands import NameArgument, ThreadCreatedResult, _tag_choice_text +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, EditorFormat, FieldCodec, Multiline, Normalize +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 ThreadModel - - -TAG_ARGUMENT = ( - EditorFormat(FieldCodec.encode), Normalize(_tag_choice_text), Argument(flags=("--tags",)), -) +from agent_comms.thread_model import CatalogSelection, ThreadModel @dataclass(frozen=True, kw_only=True) @@ -42,11 +37,17 @@ def project_path(self) -> Path: @dataclass(frozen=True, kw_only=True) -class ThreadCreationCliCommand(ThreadCreationFields, CliCommand, declared_name=None): +class ThreadCreationCliCommand( + ThreadCreationFields, 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)}, diff --git a/src/agent_comms/cli_verbs/fork.py b/src/agent_comms/cli_verbs/fork.py index 548bf1b13a..c39dd652cc 100644 --- a/src/agent_comms/cli_verbs/fork.py +++ b/src/agent_comms/cli_verbs/fork.py @@ -1,6 +1,6 @@ from __future__ import annotations -from agent_comms.cli_support.thread_creation import TAG_ARGUMENT, ThreadCreationCliCommand +from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand from agent_comms.cli_support.thread_bound import ThreadBoundCliCommand from agent_comms.field_codec import Argument from agent_comms.field_codec import TargetBound @@ -21,7 +21,7 @@ class ForkCliCommand(NativeOwnerCommand, ThreadBoundCliCommand, ThreadCreationCl str, TargetBound(), Argument(flags=("--parent",)) ] - tags: Annotated[TagChoice, *TAG_ARGUMENT] = InheritedTags() + tags: Annotated[TagChoice, Argument()] = InheritedTags() @classmethod def thread_binding(cls, thread: Thread) -> Self: diff --git a/src/agent_comms/cli_verbs/new.py b/src/agent_comms/cli_verbs/new.py index 349a389107..c0ecc5e229 100644 --- a/src/agent_comms/cli_verbs/new.py +++ b/src/agent_comms/cli_verbs/new.py @@ -1,8 +1,9 @@ from __future__ import annotations from agent_comms.cli_command import PendingCommandBinding -from agent_comms.cli_support.thread_creation import TAG_ARGUMENT, ThreadCreationCliCommand, ThreadCreationFields +from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand, ThreadCreationFields from agent_comms.comms import Comms +from agent_comms.field_codec import Argument from agent_comms.native_settings import NativeThreadDefaults from agent_comms.repo import Checkout from agent_comms.thread_management import GivenTags @@ -15,7 +16,7 @@ class NewThreadCliCommand(ThreadCreationCliCommand, declared_name="new"): help = "Create a new pi thread" spec_type: ClassVar[type[NewThreadSpec]] = NewThreadSpec - tags: Annotated[GivenTags, *TAG_ARGUMENT] + tags: Annotated[GivenTags, Argument()] @classmethod def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[PendingCommandBinding, ...]: 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/field_codec.py b/src/agent_comms/field_codec.py index db0eb0ab3f..aa34645a68 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -369,8 +369,8 @@ class Normalize(ValueDirective[Callable[[object], object]]): pass -class EditorFormat(ValueDirective[Callable[[object], JsonValue]]): - pass +class EditsAsText: + """This member declares the typed editor of its untagged wire family.""" class Editor(ValueDirective[type]): @@ -619,10 +619,6 @@ 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) @@ -630,7 +626,7 @@ def editor_payload(self, value: object) -> object: return self.editor_normalizer(value) def editor_default(self, value: object) -> JsonValue: - return self.editor_formatter(value) + return self.value.encode_editor(value) @property def description(self) -> str: @@ -957,6 +953,17 @@ class DecoderPlan(ABC): target: object + @property + def editor_annotation(self) -> object: + return str + + def format_editor(self, wire: object) -> JsonValue: + return json.dumps(wire) + + @boundary_decoder + def parse_editor(self, text: object) -> object: + return None if text is None else json.loads(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 () @@ -1053,10 +1060,14 @@ 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) + editors = tuple(member for member in members if EditsAsText in member.__mro__) + if len(editors) > 1: + raise TypeError(f'{target} declares more than one typed-text editor: {editors}') + if editors: + return _TextEditedUnionDecoder(target, alternatives, compile_decoder(editors[0]).editor_wire) + return UnionDecoder(target, alternatives) @abstractmethod def decode(self, data: object) -> T: @@ -1123,15 +1134,15 @@ 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 encode_editor(self, value: T) -> str: - encoded = FieldCodec.encode(value, self.target) - return json.dumps(encoded) + def encode_editor(self, value: T) -> JsonValue: + return self.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.parse_editor(value) def editor_choices(self) -> tuple[tuple[str, object], ...]: raise TypeError("The declared decoder does not offer finite choices") @@ -1166,6 +1177,20 @@ 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 editor_annotation(self) -> object: + return self.wire_structure.editor_annotation + + def format_editor(self, wire: object) -> JsonValue: + return self.wire_structure.format_editor(wire) + + def parse_editor(self, text: object) -> object: + return self.wire_structure.parse_editor(text) + @property def accepts_null(self) -> bool: return self.wire_structure.accepts_null @@ -2793,11 +2818,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) -> JsonValue: + return cast(JsonValue, wire) - def decode_editor(self, value): - return value + def parse_editor(self, text: object) -> object: + return text class _FlagDecoder(_ExactScalarDecoder[bool]): @@ -3088,6 +3113,24 @@ def decode_candidates( first_error = None +@dataclass(frozen=True, slots=True) +class _TextEditedUnionDecoder[T](UnionDecoder[T]): + """One member's declaration owns typed text; other members remain wire choices.""" + + editor: DecoderPlan + + @property + def editor_annotation(self) -> object: + annotation = self.editor.editor_annotation + return Union[tuple((annotation, type(None)))] if self.accepts_null else annotation + + def format_editor(self, wire: object) -> JsonValue: + return self.editor.format_editor(wire) + + def parse_editor(self, text: object) -> object: + return self.editor.parse_editor(text) + + @dataclass(frozen=True, slots=True) class _ArrayDecoder[T: Iterable[object]](ValueDecoder[T]): container: ClassVar[type] diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index 85e3ea0b73..aa40e9b7a9 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -1,37 +1,46 @@ from __future__ import annotations +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping +from dataclasses import dataclass, replace + 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 MISSING, 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 field_codec.compile_decoder(self.annotation).editor_annotation + @property def name(self) -> str: return self.plan.key @@ -45,20 +54,26 @@ def description(self) -> str: return self.plan.description @property - def supplied(self) -> bool: - return self.value is not MISSING + @abstractmethod + def editor_default(self) -> JsonValue: ... - @property - def editor_default(self) -> JsonValue: - return self.plan.editor_default(self.value) if self.supplied else None + @abstractmethod + def default_schema(self) -> dict[str, JsonValue]: ... + + def argument(self, value: object) -> Mapping[str, object]: + return {self.name: 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 @@ -68,10 +83,44 @@ 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 SuppliedTargetField(TargetField): + value: object + + @property + def editor_default(self) -> JsonValue: + return self.plan.editor_default(self.value) + + def default_schema(self) -> dict[str, JsonValue]: + return {'editor_default': self.editor_default} + @dataclass(frozen=True) -class ChoiceTargetField(TargetField): +class UnsuppliedTargetField(TargetField): + """A required answer has no value; the optional editor owns its unset control.""" + + @property + def editor_annotation(self) -> object: + return field_codec.compile_decoder(self.annotation).editor_annotation | type(None) + + @property + def editor_default(self) -> JsonValue: + return None + + def default_schema(self) -> dict[str, JsonValue]: + return {} + + @boundary_decoder + def argument(self, value: object) -> Mapping[str, object]: + """Decode the optional control's raw presence once, not the field's wire value.""" + return {} if value is None else super().argument(value) + + +@dataclass(frozen=True) +class ChoiceTargetField(SuppliedTargetField): """A prepared finite declaration, from its annotation or configuration catalog.""" options: tuple[CatalogChoice, ...] = () @@ -83,8 +132,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))) @@ -94,6 +142,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]: @@ -103,10 +152,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_management.py b/src/agent_comms/thread_management.py index cddd5e9f97..5be0b40458 100644 --- a/src/agent_comms/thread_management.py +++ b/src/agent_comms/thread_management.py @@ -38,6 +38,7 @@ from .errors import RelationViolationError from .field_codec import ( DeclaredWireType, + EditsAsText, FieldCodec, NullMember, RecordMember, @@ -77,7 +78,7 @@ def for_child(self, parent: frozenset[str]) -> frozenset[str]: ... @dataclass(frozen=True, slots=True) -class GivenTags(DeclaredWireType, TagChoice): +class GivenTags(EditsAsText, DeclaredWireType, TagChoice): """Tags spelled as comma-separated text on the command line, in the editor and in tools.""" wire_type = str diff --git a/tests/test_thread_creation.py b/tests/test_thread_creation.py index 9f435cead0..e7cf080452 100644 --- a/tests/test_thread_creation.py +++ b/tests/test_thread_creation.py @@ -44,8 +44,11 @@ def test_pending_new_binding_offers_missing_tags_without_constructing_the_comman binding, = NewThreadCliCommand.project_bindings(comms, Checkout(source_repository.checkout)) assert type(binding) is PendingCommandBinding tags = next(item for item in binding.editable_fields if item.name == 'tags') - assert tags.required and not tags.supplied + assert tags.required assert tags.editor_default is None + 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'})) @@ -65,7 +68,9 @@ def test_pending_catalog_context_can_change_before_tags_are_supplied(comms, sour binding, = NewThreadCliCommand.project_bindings(comms, Checkout(source_repository.checkout)) prepared = binding.for_fields({'name': 'child'}) assert prepared.context.name == 'child' - assert not next(item for item in prepared.editable_fields if item.name == 'tags').supplied + 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', ( From 89a4397f953604d4e1333aea3edf185b36c4009c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:45:55 -0400 Subject: [PATCH 21/74] Leave child names unsupplied until creation form completion --- src/agent_comms/cli_command.py | 23 +++++++------ src/agent_comms/cli_support/named_thread.py | 3 +- src/agent_comms/cli_support/thread_bound.py | 7 ++-- .../cli_support/thread_creation.py | 6 ++-- src/agent_comms/cli_verbs/fork.py | 21 ++++++------ src/agent_comms/cli_verbs/new.py | 6 ++-- src/agent_comms/field_codec.py | 2 +- tests/test_thread_creation.py | 32 ++++++++++++++++--- 8 files changed, 66 insertions(+), 34 deletions(-) diff --git a/src/agent_comms/cli_command.py b/src/agent_comms/cli_command.py index d6f9b42fc7..08e274f0e5 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -110,12 +110,17 @@ def authorize_edits(self, arguments: Mapping[str, object]) -> frozenset[str]: 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) - decoded = {name: value - for entry in FieldCodec.argument_fields(self.declaration) if entry.key in arguments - for name, value in entry.decoded(arguments).items()} - return FieldCodec.construct(self.declaration, {**self.retained_payload(), **decoded}) + return FieldCodec.construct(self.declaration, { + **self.retained_payload(), **self.decoded_edits(self.declaration, arguments), + }) @abstractmethod def confirmation(self) -> str: ... @@ -175,11 +180,11 @@ def field_value(self, entry: FieldPlan) -> TargetField: def for_fields(self, arguments: Mapping[str, object]) -> PendingCommandBinding: self.authorize_edits(arguments) - changes = {name: value - for entry in FieldCodec.argument_fields(type(self.context)) if entry.key in arguments - for name, value in entry.decoded({ - entry.key: entry.editor_payload(arguments[entry.key]), - }).items()} + 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: 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 index 07c617a558..8f8f1aeae4 100644 --- a/src/agent_comms/cli_support/thread_creation.py +++ b/src/agent_comms/cli_support/thread_creation.py @@ -20,8 +20,8 @@ @dataclass(frozen=True, kw_only=True) -class ThreadCreationFields(NameArgument, CommandContext): - """Supplied launch context is also the complete command's field declaration.""" +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[ @@ -38,7 +38,7 @@ def project_path(self) -> Path: @dataclass(frozen=True, kw_only=True) class ThreadCreationCliCommand( - ThreadCreationFields, CatalogBinding, CliCommand[ThreadCreatedResult], declared_name=None, + NameArgument, ThreadLaunchSettings, CatalogBinding, CliCommand[ThreadCreatedResult], declared_name=None, ): @property @abstractmethod diff --git a/src/agent_comms/cli_verbs/fork.py b/src/agent_comms/cli_verbs/fork.py index c39dd652cc..14bdedb8db 100644 --- a/src/agent_comms/cli_verbs/fork.py +++ b/src/agent_comms/cli_verbs/fork.py @@ -1,6 +1,7 @@ from __future__ import annotations -from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand +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.field_codec import Argument from agent_comms.field_codec import TargetBound @@ -10,20 +11,22 @@ from agent_comms.threads import Thread from dataclasses import dataclass from typing import Annotated -from typing import Self @dataclass(frozen=True, kw_only=True) -class ForkCliCommand(NativeOwnerCommand, ThreadBoundCliCommand, ThreadCreationCliCommand): +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",)) - ] tags: Annotated[TagChoice, Argument()] = InheritedTags() @classmethod - def thread_binding(cls, thread: Thread) -> Self: - return cls(name='', parent=thread.name, project=thread.worktree, - model=thread.model, thinking_level=thread.thinking_level) + def thread_binding(cls, thread: Thread) -> PendingCommandBinding: + return PendingCommandBinding(cls, 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 c0ecc5e229..aad00c4ff7 100644 --- a/src/agent_comms/cli_verbs/new.py +++ b/src/agent_comms/cli_verbs/new.py @@ -1,7 +1,7 @@ from __future__ import annotations from agent_comms.cli_command import PendingCommandBinding -from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand, ThreadCreationFields +from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand, ThreadLaunchSettings from agent_comms.comms import Comms from agent_comms.field_codec import Argument from agent_comms.native_settings import NativeThreadDefaults @@ -21,6 +21,6 @@ class NewThreadCliCommand(ThreadCreationCliCommand, declared_name="new"): @classmethod def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[PendingCommandBinding, ...]: defaults = NativeThreadDefaults() - return (PendingCommandBinding(cls, ThreadCreationFields( - name='', project=project, model=defaults.model, + return (PendingCommandBinding(cls, ThreadLaunchSettings( + project=project, model=defaults.model, thinking_level=defaults.thinking_level)),) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index aa34645a68..776c48255e 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -962,7 +962,7 @@ def format_editor(self, wire: object) -> JsonValue: @boundary_decoder def parse_editor(self, text: object) -> object: - return None if text is None else json.loads(text) + 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.""" diff --git a/tests/test_thread_creation.py b/tests/test_thread_creation.py index e7cf080452..cedf3eb331 100644 --- a/tests/test_thread_creation.py +++ b/tests/test_thread_creation.py @@ -3,6 +3,7 @@ Author does not execute these controls; the integration owner selects them once. """ from dataclasses import replace +import json import pytest @@ -10,7 +11,8 @@ 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 +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 @@ -43,6 +45,11 @@ def test_new_cli_membership_reaches_registration(comms, source_repository, text, 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 tags.editor_default is None @@ -66,8 +73,9 @@ def test_pending_new_binding_distinguishes_explicit_empty_from_missing(comms, so 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({'name': 'child'}) - assert prepared.context.name == 'child' + 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': ''} @@ -85,6 +93,14 @@ def test_complete_creation_commands_keep_command_edit_authorization(source_repos 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']) @@ -92,7 +108,15 @@ def test_fork_cli_defaults_to_actual_parent_membership(comms, source_repository) command = parsed(['fork', '--name', 'child', '--parent', parent.name, '--project', str(source_repository.checkout)]) assert command.tags == InheritedTags() - editor = ForkCliCommand.thread_binding(parent) + 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'}))) From 8c08089255bd42c5ce32f88249e8c01c6fa72b73 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:52:02 -0400 Subject: [PATCH 22/74] Remove invalid trailing comma from pending context edits --- src/agent_comms/cli_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/cli_command.py b/src/agent_comms/cli_command.py index 08e274f0e5..760d788bfe 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -182,7 +182,7 @@ 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, + 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 From acf0a67b6d696f38c65bc2efd90347e1b5933a65 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:21:09 -0400 Subject: [PATCH 23/74] Keep native union members and derive their editor transactions --- src/agent_comms/cli_command.py | 7 +- src/agent_comms/field_codec.py | 195 ++++++++++++++++++++------- src/agent_comms/target_fields.py | 53 +++++++- src/agent_comms/thread_management.py | 3 +- tests/test_editor_defaults.py | 62 ++++++++- 5 files changed, 257 insertions(+), 63 deletions(-) diff --git a/src/agent_comms/cli_command.py b/src/agent_comms/cli_command.py index 760d788bfe..d06b70a9c6 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -118,9 +118,10 @@ def decoded_edits(declaration: type, arguments: Mapping[str, object]) -> dict[st def edited(self, arguments: Mapping[str, object]) -> CliCommand: self.authorize_edits(arguments) - return FieldCodec.construct(self.declaration, { - **self.retained_payload(), **self.decoded_edits(self.declaration, 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: ... diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 776c48255e..589d084a8f 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -12,7 +12,7 @@ import types from abc import ABC, abstractmethod from collections.abc import Callable, Iterable, Iterator, Mapping -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 @@ -369,10 +369,6 @@ class Normalize(ValueDirective[Callable[[object], object]]): pass -class EditsAsText: - """This member declares the typed editor of its untagged wire family.""" - - class Editor(ValueDirective[type]): pass @@ -625,9 +621,16 @@ def argument_payload(self, value: object) -> object: def editor_payload(self, value: object) -> object: return self.editor_normalizer(value) - def editor_default(self, value: object) -> JsonValue: + def editor_default(self, value: object) -> str: return self.value.encode_editor(value) + def editor_initial(self, value: object) -> object: + return self.value.editor_initial(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: return Argument.declared(self.annotation).description(self.name) @@ -957,7 +960,34 @@ class DecoderPlan(ABC): def editor_annotation(self) -> object: return str - def format_editor(self, wire: object) -> JsonValue: + @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: + raise TypeError(f'{self.target} does not declare a union editor') + + 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 @@ -995,6 +1025,73 @@ def external_decoders(self, data: object) -> Iterable[ExternalDecoder]: return () +@dataclass(frozen=True) +class EditorMember(ABC): + """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) + + @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.""" @@ -1062,11 +1159,6 @@ def representation_plan( def for_members[M](cls, members: tuple[type[M], ...]) -> UnionDecoder[M]: target = reduce(or_, members) alternatives = tuple(compile_decoder(member) for member in members) - editors = tuple(member for member in members if EditsAsText in member.__mro__) - if len(editors) > 1: - raise TypeError(f'{target} declares more than one typed-text editor: {editors}') - if editors: - return _TextEditedUnionDecoder(target, alternatives, compile_decoder(editors[0]).editor_wire) return UnionDecoder(target, alternatives) @abstractmethod @@ -1107,10 +1199,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 @@ -1138,11 +1226,14 @@ def decode_keys(self, data, keys, selections): def editor_wire(self) -> DecoderPlan: return self - def encode_editor(self, value: T) -> JsonValue: - return self.format_editor(self.encode(value)) + 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: + return self.editor_wire.format_editor(self.encode(value)) def decode_editor(self, value: object) -> object: - return self.parse_editor(value) + 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") @@ -1181,16 +1272,6 @@ def wire_structure(self) -> DecoderPlan: def editor_wire(self) -> DecoderPlan: return self.wire_structure - @property - def editor_annotation(self) -> object: - return self.wire_structure.editor_annotation - - def format_editor(self, wire: object) -> JsonValue: - return self.wire_structure.format_editor(wire) - - def parse_editor(self, text: object) -> object: - return self.wire_structure.parse_editor(text) - @property def accepts_null(self) -> bool: return self.wire_structure.accepts_null @@ -2780,7 +2861,13 @@ def fits_wire(self, data: object) -> bool: return self.accepts(data) def accepts_value(self, value: object) -> bool: - return self.accepts(value) + return type(value) is self.target + + 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) @@ -2806,6 +2893,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",) @@ -2818,8 +2908,8 @@ class _TextDecoder(_ExactScalarDecoder[str]): def encode(self, value: str) -> str: return str(super().encode(value)) - def format_editor(self, wire: object) -> JsonValue: - return cast(JsonValue, wire) + def format_editor(self, wire: object) -> str: + return self.decode(wire) def parse_editor(self, text: object) -> object: return text @@ -3050,13 +3140,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 @@ -3113,24 +3221,6 @@ def decode_candidates( first_error = None -@dataclass(frozen=True, slots=True) -class _TextEditedUnionDecoder[T](UnionDecoder[T]): - """One member's declaration owns typed text; other members remain wire choices.""" - - editor: DecoderPlan - - @property - def editor_annotation(self) -> object: - annotation = self.editor.editor_annotation - return Union[tuple((annotation, type(None)))] if self.accepts_null else annotation - - def format_editor(self, wire: object) -> JsonValue: - return self.editor.format_editor(wire) - - def parse_editor(self, text: object) -> object: - return self.editor.parse_editor(text) - - @dataclass(frozen=True, slots=True) class _ArrayDecoder[T: Iterable[object]](ValueDecoder[T]): container: ClassVar[type] @@ -3465,6 +3555,9 @@ 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 diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index aa40e9b7a9..29e4adda58 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -39,7 +39,7 @@ def annotation(self) -> object: @property def editor_annotation(self) -> object: - return field_codec.compile_decoder(self.annotation).editor_annotation + return self.plan.value.editor_wire.native_editor_annotation @property def name(self) -> str: @@ -53,15 +53,29 @@ def multiline(self) -> bool: def description(self) -> str: return self.plan.description + @property + def editor_members(self) -> tuple[field_codec.EditorMember, ...]: + return self.plan.value.editor_wire.editor_members + + def editor_selection(self, value: object) -> object: + return self.plan.value.editor_wire.selected_editor_member(value).declaration + + @property + @abstractmethod + def editor_initial(self) -> object: ... + @property @abstractmethod - def editor_default(self) -> JsonValue: ... + def editor_default(self) -> str | None: ... @abstractmethod def default_schema(self) -> dict[str, JsonValue]: ... def argument(self, value: object) -> Mapping[str, object]: - return {self.name: value} + 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.""" @@ -91,7 +105,11 @@ class SuppliedTargetField(TargetField): value: object @property - def editor_default(self) -> JsonValue: + def editor_initial(self) -> object: + return self.plan.editor_initial(self.value) + + @property + def editor_default(self) -> str: return self.plan.editor_default(self.value) def default_schema(self) -> dict[str, JsonValue]: @@ -104,15 +122,23 @@ class UnsuppliedTargetField(TargetField): @property def editor_annotation(self) -> object: - return field_codec.compile_decoder(self.annotation).editor_annotation | type(None) + return self.plan.value.editor_wire.native_editor_annotation | type(None) + + @property + def editor_initial(self) -> object: + return None @property - def editor_default(self) -> JsonValue: + def editor_default(self) -> None: return None def default_schema(self) -> dict[str, JsonValue]: return {} + @boundary_decoder + def editor_selection(self, value: object) -> object: + return None if value is None else super().editor_selection(value) + @boundary_decoder def argument(self, value: object) -> Mapping[str, object]: """Decode the optional control's raw presence once, not the field's wire value.""" @@ -124,6 +150,21 @@ 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 () diff --git a/src/agent_comms/thread_management.py b/src/agent_comms/thread_management.py index 5be0b40458..cddd5e9f97 100644 --- a/src/agent_comms/thread_management.py +++ b/src/agent_comms/thread_management.py @@ -38,7 +38,6 @@ from .errors import RelationViolationError from .field_codec import ( DeclaredWireType, - EditsAsText, FieldCodec, NullMember, RecordMember, @@ -78,7 +77,7 @@ def for_child(self, parent: frozenset[str]) -> frozenset[str]: ... @dataclass(frozen=True, slots=True) -class GivenTags(EditsAsText, DeclaredWireType, TagChoice): +class GivenTags(DeclaredWireType, TagChoice): """Tags spelled as comma-separated text on the command line, in the editor and in tools.""" wire_type = str diff --git a/tests/test_editor_defaults.py b/tests/test_editor_defaults.py index c4b6e38e8a..1ce950cf2e 100644 --- a/tests/test_editor_defaults.py +++ b/tests/test_editor_defaults.py @@ -4,7 +4,9 @@ import pytest from agent_comms.cli_verbs.fork import ForkCliCommand -from agent_comms.field_codec import FieldCodec, compile_decoder +from agent_comms.field_codec import FieldCodec, MalformedRecord, 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 +21,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 +47,57 @@ 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) is ChangedTags + assert isinstance(parameter.editor_default, str) + json.dumps(parameter.json_schema()) + + +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({}) + + From 7d3d6ac4f9e8c70271d73198691b2abe15dc467a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:40:03 -0400 Subject: [PATCH 24/74] Make unchosen a nominal member of editor selection --- src/agent_comms/field_codec.py | 42 +++++++++++++++++++++++++++++++- src/agent_comms/target_fields.py | 23 ++++++++++++++--- tests/test_editor_defaults.py | 23 +++++++++++++++-- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 589d084a8f..19f69af185 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -1025,8 +1025,45 @@ 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): + @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): + @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(ABC): +class EditorMember(MemberSelection): """Read-only member metadata; only actual inputs enter a form's ObjectState.""" decoder: DecoderPlan @@ -1039,6 +1076,9 @@ def declaration(self) -> object: 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, ...]: ... diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index 29e4adda58..782edb78d0 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Mapping from dataclasses import dataclass, replace +from functools import singledispatchmethod from pydantic import JsonValue @@ -57,8 +58,12 @@ def description(self) -> str: def editor_members(self) -> tuple[field_codec.EditorMember, ...]: return self.plan.value.editor_wire.editor_members - def editor_selection(self, value: object) -> object: - return self.plan.value.editor_wire.selected_editor_member(value).declaration + @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 @@ -135,9 +140,19 @@ def editor_default(self) -> 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 editor_selection(self, value: object) -> object: - return None if value is None else super().editor_selection(value) + def _unchosen_selection(self, value: None) -> field_codec.MemberSelection: + return field_codec.UnchosenMember() @boundary_decoder def argument(self, value: object) -> Mapping[str, object]: diff --git a/tests/test_editor_defaults.py b/tests/test_editor_defaults.py index 1ce950cf2e..9a2a1dd2fa 100644 --- a/tests/test_editor_defaults.py +++ b/tests/test_editor_defaults.py @@ -4,7 +4,9 @@ import pytest from agent_comms.cli_verbs.fork import ForkCliCommand -from agent_comms.field_codec import FieldCodec, MalformedRecord, 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 @@ -88,11 +90,28 @@ def test_native_union_default_does_not_leak_into_public_json_schema() -> None: value = ChangedTags(frozenset({'auth'}), frozenset()) parameter = TargetField.of(plan, value) assert parameter.editor_initial is value - assert parameter.editor_selection(value) is ChangedTags + 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())) From 502402a5294b42bcd22dea4250588b0ef43b9f91 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:04:38 -0400 Subject: [PATCH 25/74] Ask the value decoder directly for editor projections --- src/agent_comms/field_codec.py | 6 ------ src/agent_comms/target_fields.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 19f69af185..6cf5718230 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -621,12 +621,6 @@ def argument_payload(self, value: object) -> object: def editor_payload(self, value: object) -> object: return self.editor_normalizer(value) - def editor_default(self, value: object) -> str: - return self.value.encode_editor(value) - - def editor_initial(self, value: object) -> object: - return self.value.editor_initial(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}') diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index 782edb78d0..5cd068266e 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -111,11 +111,11 @@ class SuppliedTargetField(TargetField): @property def editor_initial(self) -> object: - return self.plan.editor_initial(self.value) + return self.plan.value.editor_initial(self.value) @property def editor_default(self) -> str: - return self.plan.editor_default(self.value) + return self.plan.value.encode_editor(self.value) def default_schema(self) -> dict[str, JsonValue]: return {'editor_default': self.editor_default} From 85a31ac9872b949efa02f48a48b29ced057d8ee5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 21:18:34 -0400 Subject: [PATCH 26/74] Remove absent editor defaults and reuse scalar admission --- src/agent_comms/field_codec.py | 2 +- src/agent_comms/target_fields.py | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 6cf5718230..a852d7e989 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -2895,7 +2895,7 @@ def fits_wire(self, data: object) -> bool: return self.accepts(data) def accepts_value(self, value: object) -> bool: - return type(value) is self.target + return self.accepts(value) def parse_editor(self, text: object) -> object: value = super().parse_editor(text) diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index 5cd068266e..48192859a6 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -69,10 +69,6 @@ def editor_selection(self, value: object) -> field_codec.MemberSelection: @abstractmethod def editor_initial(self) -> object: ... - @property - @abstractmethod - def editor_default(self) -> str | None: ... - @abstractmethod def default_schema(self) -> dict[str, JsonValue]: ... @@ -133,10 +129,6 @@ def editor_annotation(self) -> object: def editor_initial(self) -> object: return None - @property - def editor_default(self) -> None: - return None - def default_schema(self) -> dict[str, JsonValue]: return {} From f4f0750598f7f51ae0799f122cce4b87fabd2523 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:01:03 -0400 Subject: [PATCH 27/74] Separate explicit new-thread tags from parent-derived fork membership --- .../cli_support/thread_creation.py | 53 ++++++++++++++++ src/agent_comms/cli_verbs/fork.py | 14 ++--- src/agent_comms/cli_verbs/new.py | 62 ++----------------- src/agent_comms/thread_management.py | 37 ++++++----- tests/test_cli.py | 5 +- 5 files changed, 89 insertions(+), 82 deletions(-) create mode 100644 src/agent_comms/cli_support/thread_creation.py 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..6c34493c72 --- /dev/null +++ b/src/agent_comms/cli_support/thread_creation.py @@ -0,0 +1,53 @@ +"""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 CliCommand +from agent_comms.cli_commands import NameArgument, ThreadCreatedResult, _tag_choice_text +from agent_comms.comms import Comms +from agent_comms.config_options import ConfigOptions, ModelConfigOption, ThinkingLevelConfigOption +from agent_comms.field_codec import Argument, Catalog, EditorFormat, FieldCodec, Multiline, Normalize +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 + + +type TagArgument[T] = Annotated[ + T, EditorFormat(FieldCodec.encode), Normalize(_tag_choice_text), Argument(flags=("--tags",)), +] + + +@dataclass(frozen=True, kw_only=True) +class ThreadCreationCliCommand(NameArgument, CatalogSelection, CliCommand, declared_name=None): + 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 + @abstractmethod + def spec_type(self) -> type[ThreadCreationSpec]: ... + + @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) diff --git a/src/agent_comms/cli_verbs/fork.py b/src/agent_comms/cli_verbs/fork.py index b36253a47a..e6b9c1637f 100644 --- a/src/agent_comms/cli_verbs/fork.py +++ b/src/agent_comms/cli_verbs/fork.py @@ -1,33 +1,29 @@ from __future__ import annotations -from agent_comms.cli_verbs.new import NewThreadCliCommand +from agent_comms.cli_support.thread_creation import TagArgument, ThreadCreationCliCommand 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 ForkCliCommand(NativeOwnerCommand, ThreadBoundCliCommand, 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: TagArgument[TagChoice] = InheritedTags() @classmethod def thread_binding(cls, thread: Thread) -> Self: - return cls(name='', parent=thread.name, tags=GivenTags(thread.tags), project=thread.worktree, + return cls(name='', 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..e7661e5b44 100644 --- a/src/agent_comms/cli_verbs/new.py +++ b/src/agent_comms/cli_verbs/new.py @@ -1,76 +1,24 @@ 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_support.thread_creation import TagArgument, ThreadCreationCliCommand 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 @dataclass(frozen=True, kw_only=True) -class NewThreadCliCommand(NameArgument, CatalogSelection, CliCommand, declared_name="new"): +class NewThreadCliCommand(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: TagArgument[GivenTags] @classmethod def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[Self, ...]: 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) diff --git a/src/agent_comms/thread_management.py b/src/agent_comms/thread_management.py index 2f79f5b457..cddd5e9f97 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 @@ -72,9 +72,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]: ... @@ -144,27 +141,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) @@ -172,10 +178,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) @@ -185,8 +192,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(parent.tags), + return replace(self.declaration(self.tags.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: @@ -541,7 +548,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/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) From 51928ec9937bd1e7e54aa11dfd0eedad92d80498 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:15:56 -0400 Subject: [PATCH 28/74] Collect required creation inputs through native command bindings --- src/agent_comms/channel_targets.py | 10 +- src/agent_comms/cli_command.py | 156 ++++++++++++++---- .../cli_support/thread_creation.py | 31 ++-- src/agent_comms/cli_verbs/fork.py | 4 +- src/agent_comms/cli_verbs/new.py | 15 +- src/agent_comms/target_fields.py | 8 +- tests/test_checkout_submodules.py | 2 +- tests/test_end_to_end.py | 16 +- tests/test_thread_creation.py | 84 ++++++++++ 9 files changed, 262 insertions(+), 64 deletions(-) create mode 100644 tests/test_thread_creation.py diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index fdea37d61a..0023b5bdc7 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -23,7 +23,7 @@ from .audience_manifest import AudiencedMessage, FrozenRecipient from .catalog_document import CatalogAccess, CatalogDocument from .channels import Channel - from .cli_command import CliCommand + from .cli_command import CliCommand, CommandBinding from .cli_result import CliResult from .comms import Comms from .goal_management import Goals @@ -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: @@ -413,7 +413,7 @@ 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: @@ -562,7 +562,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): @@ -653,7 +653,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/cli_command.py b/src/agent_comms/cli_command.py index 55778a9a38..f5a6b3bb14 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 @@ -63,15 +64,128 @@ 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 + @abstractmethod + def editable_fields(self) -> tuple[TargetField, ...]: ... + + def for_editor(self, comms: Comms, target: str, project: Checkout) -> Self: + return self + + @property + def catalog_context(self) -> object: + 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_fields(self, arguments: Mapping[str, object] = {}) -> tuple[TargetField, ...]: + binding = self.for_fields(arguments) + catalogs = ConfigOptions.configured() + return tuple([await item.prepare(catalogs, binding.catalog_context) + 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 + + @abstractmethod + def edited(self, arguments: Mapping[str, object]) -> CliCommand: ... + + @abstractmethod + def confirmation(self) -> str: ... + + @classmethod + @abstractmethod + def selection_confirmation(cls, commands: tuple[Self, ...]) -> str: ... + + @abstractmethod + def with_confirmation(self, confirmed: bool) -> CommandBinding: ... + + +@dataclass(frozen=True) +class CommandContext(CatalogSelection): + """A partial command's supplied fields, with native edits decoded by their plans.""" + + def edited(self, arguments: Mapping[str, object]) -> Self: + changes = {name: value + for entry in FieldCodec.argument_fields(type(self)) if entry.key in arguments + for name, value in entry.decoded({ + entry.key: entry.editor_payload(arguments[entry.key]), + }).items()} + return replace(self, **changes) if changes else self + + +@dataclass(frozen=True) +class PendingCommandBinding(CommandBinding): + """Only supplied context, borrowing the concrete command's declared fields.""" + + command: type[CliCommand] + context: CommandContext + + @property + def declaration(self) -> type[CliCommand]: + return self.command + + @property + def catalog_context(self) -> CatalogSelection: + return self.context + + @property + def editable_fields(self) -> tuple[TargetField, ...]: + supplied = {entry.name: entry.current(self.context) + for entry in FieldCodec.argument_fields(type(self.context))} + return tuple(TargetField.of(entry, supplied[entry.name] if entry.name in supplied else entry.default()) + for entry in FieldCodec.argument_fields(self.declaration) + if not entry.setting(TargetBound, False)) + + def for_fields(self, arguments: Mapping[str, object]) -> PendingCommandBinding: + return replace(self, context=self.context.edited(arguments)) + + def edited(self, arguments: Mapping[str, object]) -> CliCommand: + self.authorize_edits(arguments) + return self.declaration.from_payload({ + **FieldCodec.encode(self.context), + self.declaration.family_discriminator: FieldCodec.encode(self.declaration), + **arguments, + }) + + 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 +239,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 +274,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,9 +290,6 @@ 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 - @property def editable_fields(self) -> tuple[TargetField, ...]: """The command owns the parameters offered by its target action.""" @@ -189,11 +300,6 @@ def editable_fields(self) -> tuple[TargetField, ...]: 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 describe(self, comms: Comms, target: CommandTarget, project: Checkout) -> TargetAction: bound = self.for_editor(comms, target.name, project) @@ -236,15 +342,7 @@ def with_confirmation(self, confirmed: bool) -> Self: 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") + bound = self.authorize_edits(arguments) encoded = FieldCodec.encode(self) return type(self).from_payload({ **{key: encoded[key] for key in bound & encoded.keys()}, @@ -271,7 +369,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 +378,8 @@ 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] = {}) -> tuple[TargetField, ...]: + return await self.bound[0].prepare_fields(arguments) @property def label(self) -> str: @@ -289,14 +387,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 +578,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 +609,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_support/thread_creation.py b/src/agent_comms/cli_support/thread_creation.py index 6c34493c72..8e964102a7 100644 --- a/src/agent_comms/cli_support/thread_creation.py +++ b/src/agent_comms/cli_support/thread_creation.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Annotated -from agent_comms.cli_command import CliCommand +from agent_comms.cli_command import CliCommand, CommandContext from agent_comms.cli_commands import NameArgument, ThreadCreatedResult, _tag_choice_text from agent_comms.comms import Comms from agent_comms.config_options import ConfigOptions, ModelConfigOption, ThinkingLevelConfigOption @@ -16,16 +16,17 @@ 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 +from agent_comms.thread_model import ThreadModel -type TagArgument[T] = Annotated[ - T, EditorFormat(FieldCodec.encode), Normalize(_tag_choice_text), Argument(flags=("--tags",)), -] +TAG_ARGUMENT = ( + EditorFormat(FieldCodec.encode), Normalize(_tag_choice_text), Argument(flags=("--tags",)), +) @dataclass(frozen=True, kw_only=True) -class ThreadCreationCliCommand(NameArgument, CatalogSelection, CliCommand, declared_name=None): +class ThreadCreationFields(NameArgument, CommandContext): + """Supplied launch context is also the complete command's field declaration.""" task: Annotated[str, Multiline(), Argument(flags=("--task",))] = "" project: Annotated[Checkout, Argument(flags=("--project",))] model: Annotated[ @@ -35,19 +36,25 @@ class ThreadCreationCliCommand(NameArgument, CatalogSelection, CliCommand, decla ThinkingLevelField, Catalog(ThinkingLevelConfigOption), Argument(flags=("--thinking-level",)), ] = NativeThreadDefaults.thinking_level - @property - @abstractmethod - def spec_type(self) -> type[ThreadCreationSpec]: ... - @property def project_path(self) -> Path: return self.project.path - def apply(self, ctx: Comms) -> ThreadCreatedResult: - spec = self.spec_type.from_task(**{ + +@dataclass(frozen=True, kw_only=True) +class ThreadCreationCliCommand(ThreadCreationFields, CliCommand, declared_name=None): + @property + @abstractmethod + def spec_type(self) -> type[ThreadCreationSpec]: ... + + 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 e6b9c1637f..548bf1b13a 100644 --- a/src/agent_comms/cli_verbs/fork.py +++ b/src/agent_comms/cli_verbs/fork.py @@ -1,6 +1,6 @@ from __future__ import annotations -from agent_comms.cli_support.thread_creation import TagArgument, ThreadCreationCliCommand +from agent_comms.cli_support.thread_creation import TAG_ARGUMENT, ThreadCreationCliCommand from agent_comms.cli_support.thread_bound import ThreadBoundCliCommand from agent_comms.field_codec import Argument from agent_comms.field_codec import TargetBound @@ -21,7 +21,7 @@ class ForkCliCommand(NativeOwnerCommand, ThreadBoundCliCommand, ThreadCreationCl str, TargetBound(), Argument(flags=("--parent",)) ] - tags: TagArgument[TagChoice] = InheritedTags() + tags: Annotated[TagChoice, *TAG_ARGUMENT] = InheritedTags() @classmethod def thread_binding(cls, thread: Thread) -> Self: diff --git a/src/agent_comms/cli_verbs/new.py b/src/agent_comms/cli_verbs/new.py index e7661e5b44..349a389107 100644 --- a/src/agent_comms/cli_verbs/new.py +++ b/src/agent_comms/cli_verbs/new.py @@ -1,24 +1,25 @@ from __future__ import annotations -from agent_comms.cli_support.thread_creation import TagArgument, ThreadCreationCliCommand +from agent_comms.cli_command import PendingCommandBinding +from agent_comms.cli_support.thread_creation import TAG_ARGUMENT, ThreadCreationCliCommand, ThreadCreationFields from agent_comms.comms import Comms from agent_comms.native_settings import NativeThreadDefaults from agent_comms.repo import Checkout from agent_comms.thread_management import GivenTags from agent_comms.thread_management import NewThreadSpec from dataclasses import dataclass -from typing import ClassVar -from typing import Self +from typing import Annotated, ClassVar @dataclass(frozen=True, kw_only=True) class NewThreadCliCommand(ThreadCreationCliCommand, declared_name="new"): help = "Create a new pi thread" spec_type: ClassVar[type[NewThreadSpec]] = NewThreadSpec - tags: TagArgument[GivenTags] + tags: Annotated[GivenTags, *TAG_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),) + return (PendingCommandBinding(cls, ThreadCreationFields( + name='', project=project, model=defaults.model, + thinking_level=defaults.thinking_level)),) diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index f07a82f7a5..85e3ea0b73 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -6,7 +6,7 @@ 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 MISSING, dataclass from dataclasses import replace from agent_comms.config_options import CatalogChoice, CatalogConfigOption, ConfigOptions from agent_comms.thread_model import CatalogSelection @@ -44,9 +44,13 @@ def multiline(self) -> bool: def description(self) -> str: return self.plan.description + @property + def supplied(self) -> bool: + return self.value is not MISSING + @property def editor_default(self) -> JsonValue: - return self.plan.editor_default(self.value) + return self.plan.editor_default(self.value) if self.supplied else None def editor_hints(self, choices: Callable[[ChoiceTargetField], object]) -> tuple[object, ...]: """An unrestricted field does not declare a finite-choice editor.""" diff --git a/tests/test_checkout_submodules.py b/tests/test_checkout_submodules.py index 60700222a2..b4166031fc 100644 --- a/tests/test_checkout_submodules.py +++ b/tests/test_checkout_submodules.py @@ -136,7 +136,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_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_thread_creation.py b/tests/test_thread_creation.py new file mode 100644 index 0000000000..b4bea079c4 --- /dev/null +++ b/tests/test_thread_creation.py @@ -0,0 +1,84 @@ +"""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 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 +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 + tags = next(item for item in binding.editable_fields if item.name == 'tags') + assert tags.required and not tags.supplied + assert tags.editor_default is None + 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({'name': 'child'}) + assert prepared.context.name == 'child' + assert not next(item for item in prepared.editable_fields if item.name == 'tags').supplied + + +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() + editor = ForkCliCommand.thread_binding(parent) + 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 From 5c66f0ae36d12260bd6b6885a2185bd728122598 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:31:25 -0400 Subject: [PATCH 29/74] Keep native context edits distinct from complete command authorization --- src/agent_comms/cli_command.py | 4 ++-- tests/test_thread_creation.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/cli_command.py b/src/agent_comms/cli_command.py index f5a6b3bb14..afb03cfe71 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -118,7 +118,7 @@ def with_confirmation(self, confirmed: bool) -> CommandBinding: ... class CommandContext(CatalogSelection): """A partial command's supplied fields, with native edits decoded by their plans.""" - def edited(self, arguments: Mapping[str, object]) -> Self: + def with_editor_values(self, arguments: Mapping[str, object]) -> Self: changes = {name: value for entry in FieldCodec.argument_fields(type(self)) if entry.key in arguments for name, value in entry.decoded({ @@ -151,7 +151,7 @@ def editable_fields(self) -> tuple[TargetField, ...]: if not entry.setting(TargetBound, False)) def for_fields(self, arguments: Mapping[str, object]) -> PendingCommandBinding: - return replace(self, context=self.context.edited(arguments)) + return replace(self, context=self.context.with_editor_values(arguments)) def edited(self, arguments: Mapping[str, object]) -> CliCommand: self.authorize_edits(arguments) diff --git a/tests/test_thread_creation.py b/tests/test_thread_creation.py index b4bea079c4..9f435cead0 100644 --- a/tests/test_thread_creation.py +++ b/tests/test_thread_creation.py @@ -68,6 +68,18 @@ def test_pending_catalog_context_can_change_before_tags_are_supplied(comms, sour assert not next(item for item in prepared.editable_fields if item.name == 'tags').supplied +@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_fork_cli_defaults_to_actual_parent_membership(comms, source_repository): parent_command = parsed(['new', '--name', 'parent', '--project', str(source_repository.checkout), '--tags', 'original']) From b2f766f924e2b6a17ec2b6261cc181ae0c314eef Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:22:19 -0400 Subject: [PATCH 30/74] Complete creation bindings through native field and presence owners --- src/agent_comms/cli_command.py | 118 ++++++++++-------- src/agent_comms/cli_commands.py | 12 +- .../cli_support/thread_creation.py | 21 ++-- src/agent_comms/cli_verbs/fork.py | 4 +- src/agent_comms/cli_verbs/new.py | 5 +- src/agent_comms/cli_verbs/thread_tags.py | 8 +- src/agent_comms/field_codec.py | 87 +++++++++---- src/agent_comms/target_fields.py | 95 ++++++++++---- src/agent_comms/thread_management.py | 3 +- tests/test_thread_creation.py | 9 +- 10 files changed, 230 insertions(+), 132 deletions(-) diff --git a/src/agent_comms/cli_command.py b/src/agent_comms/cli_command.py index afb03cfe71..d6f9b42fc7 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -28,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: @@ -72,23 +72,33 @@ class CommandBinding(ABC): 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 editable_fields(self) -> tuple[TargetField, ...]: ... + def field_value(self, entry: FieldPlan) -> TargetField: ... - def for_editor(self, comms: Comms, target: str, project: Checkout) -> Self: - return self + @abstractmethod + def retained_payload(self) -> dict[str, object]: + """Already decoded constructor values, never serialized for editing.""" - @property - def catalog_context(self) -> object: + 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_fields(self, arguments: Mapping[str, object] = {}) -> tuple[TargetField, ...]: + 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 item.prepare(catalogs, binding.catalog_context) + return tuple([await binding.prepare_field(item, catalogs) for item in binding.editable_fields]) def authorize_edits(self, arguments: Mapping[str, object]) -> frozenset[str]: @@ -100,8 +110,12 @@ def authorize_edits(self, arguments: Mapping[str, object]) -> frozenset[str]: raise ValueError("Parameters not offered by this target command cannot be overridden") return bound - @abstractmethod - def edited(self, arguments: Mapping[str, object]) -> CliCommand: ... + def edited(self, arguments: Mapping[str, object]) -> CliCommand: + self.authorize_edits(arguments) + decoded = {name: value + for entry in FieldCodec.argument_fields(self.declaration) if entry.key in arguments + for name, value in entry.decoded(arguments).items()} + return FieldCodec.construct(self.declaration, {**self.retained_payload(), **decoded}) @abstractmethod def confirmation(self) -> str: ... @@ -114,26 +128,33 @@ def selection_confirmation(cls, commands: tuple[Self, ...]) -> str: ... 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 partial command's supplied fields, with native edits decoded by their plans.""" - - def with_editor_values(self, arguments: Mapping[str, object]) -> Self: - changes = {name: value - for entry in FieldCodec.argument_fields(type(self)) if entry.key in arguments - for name, value in entry.decoded({ - entry.key: entry.editor_payload(arguments[entry.key]), - }).items()} - return replace(self, **changes) if changes else self + """A declaration of supplied fields, not an alternate command editor.""" @dataclass(frozen=True) -class PendingCommandBinding(CommandBinding): +class PendingCommandBinding(CatalogBinding): """Only supplied context, borrowing the concrete command's declared fields.""" command: type[CliCommand] context: CommandContext + def __post_init__(self) -> None: + if not issubclass(self.command, type(self.context)): + raise TypeError('A pending context must be a declared parent of its command') + @property def declaration(self) -> type[CliCommand]: return self.command @@ -142,24 +163,24 @@ def declaration(self) -> type[CliCommand]: def catalog_context(self) -> CatalogSelection: return self.context - @property - def editable_fields(self) -> tuple[TargetField, ...]: - supplied = {entry.name: entry.current(self.context) - for entry in FieldCodec.argument_fields(type(self.context))} - return tuple(TargetField.of(entry, supplied[entry.name] if entry.name in supplied else entry.default()) - for entry in FieldCodec.argument_fields(self.declaration) - if not entry.setting(TargetBound, False)) + def retained_payload(self) -> dict[str, object]: + return {entry.name: entry.current(self.context) + for entry in FieldCodec.field_plans(type(self.context))} - def for_fields(self, arguments: Mapping[str, object]) -> PendingCommandBinding: - return replace(self, context=self.context.with_editor_values(arguments)) + 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 edited(self, arguments: Mapping[str, object]) -> CliCommand: + def for_fields(self, arguments: Mapping[str, object]) -> PendingCommandBinding: self.authorize_edits(arguments) - return self.declaration.from_payload({ - **FieldCodec.encode(self.context), - self.declaration.family_discriminator: FieldCodec.encode(self.declaration), - **arguments, - }) + changes = {name: value + for entry in FieldCodec.argument_fields(type(self.context)) if entry.key in arguments + for name, value in entry.decoded({ + entry.key: entry.editor_payload(arguments[entry.key]), + }).items()} + return replace(self, context=replace(self.context, **changes)) if changes else self def confirmation(self) -> str: return '' @@ -290,15 +311,13 @@ def target_catalog(cls, comms: Comms, target: TargetSelection, channel if declaration.channel_fields() else NoChannel()) for declaration, commands in grouped.items()) - @property - def editable_fields(self) -> tuple[TargetField, ...]: - """The command owns the parameters offered by its target action.""" + def field_value(self, entry: FieldPlan) -> TargetField: + return TargetField.of(entry, entry.current(self)) - return tuple( - TargetField.of(entry, entry.current(self)) - for entry in FieldCodec.argument_fields(type(self)) - if not entry.setting(TargetBound, False) - ) + 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: @@ -341,15 +360,6 @@ def with_confirmation(self, confirmed: bool) -> Self: raise ValueError(self.confirmation()) return self - def edited(self, arguments: Mapping[str, object]) -> Self: - bound = self.authorize_edits(arguments) - 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, @@ -378,7 +388,9 @@ class TargetAction: def editable_fields(self) -> tuple[TargetField, ...]: return self.bound[0].editable_fields - async def prepare_fields(self, arguments: Mapping[str, object] = {}) -> tuple[TargetField, ...]: + async def prepare_fields( + self, arguments: Mapping[str, object] = MappingProxyType({}), + ) -> tuple[TargetField, ...]: return await self.bound[0].prepare_fields(arguments) @property diff --git a/src/agent_comms/cli_commands.py b/src/agent_comms/cli_commands.py index 7f067c70e8..5ff848499d 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/thread_creation.py b/src/agent_comms/cli_support/thread_creation.py index 8e964102a7..07c617a558 100644 --- a/src/agent_comms/cli_support/thread_creation.py +++ b/src/agent_comms/cli_support/thread_creation.py @@ -7,21 +7,16 @@ from pathlib import Path from typing import Annotated -from agent_comms.cli_command import CliCommand, CommandContext -from agent_comms.cli_commands import NameArgument, ThreadCreatedResult, _tag_choice_text +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, EditorFormat, FieldCodec, Multiline, Normalize +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 ThreadModel - - -TAG_ARGUMENT = ( - EditorFormat(FieldCodec.encode), Normalize(_tag_choice_text), Argument(flags=("--tags",)), -) +from agent_comms.thread_model import CatalogSelection, ThreadModel @dataclass(frozen=True, kw_only=True) @@ -42,11 +37,17 @@ def project_path(self) -> Path: @dataclass(frozen=True, kw_only=True) -class ThreadCreationCliCommand(ThreadCreationFields, CliCommand, declared_name=None): +class ThreadCreationCliCommand( + ThreadCreationFields, 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)}, diff --git a/src/agent_comms/cli_verbs/fork.py b/src/agent_comms/cli_verbs/fork.py index 548bf1b13a..c39dd652cc 100644 --- a/src/agent_comms/cli_verbs/fork.py +++ b/src/agent_comms/cli_verbs/fork.py @@ -1,6 +1,6 @@ from __future__ import annotations -from agent_comms.cli_support.thread_creation import TAG_ARGUMENT, ThreadCreationCliCommand +from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand from agent_comms.cli_support.thread_bound import ThreadBoundCliCommand from agent_comms.field_codec import Argument from agent_comms.field_codec import TargetBound @@ -21,7 +21,7 @@ class ForkCliCommand(NativeOwnerCommand, ThreadBoundCliCommand, ThreadCreationCl str, TargetBound(), Argument(flags=("--parent",)) ] - tags: Annotated[TagChoice, *TAG_ARGUMENT] = InheritedTags() + tags: Annotated[TagChoice, Argument()] = InheritedTags() @classmethod def thread_binding(cls, thread: Thread) -> Self: diff --git a/src/agent_comms/cli_verbs/new.py b/src/agent_comms/cli_verbs/new.py index 349a389107..c0ecc5e229 100644 --- a/src/agent_comms/cli_verbs/new.py +++ b/src/agent_comms/cli_verbs/new.py @@ -1,8 +1,9 @@ from __future__ import annotations from agent_comms.cli_command import PendingCommandBinding -from agent_comms.cli_support.thread_creation import TAG_ARGUMENT, ThreadCreationCliCommand, ThreadCreationFields +from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand, ThreadCreationFields from agent_comms.comms import Comms +from agent_comms.field_codec import Argument from agent_comms.native_settings import NativeThreadDefaults from agent_comms.repo import Checkout from agent_comms.thread_management import GivenTags @@ -15,7 +16,7 @@ class NewThreadCliCommand(ThreadCreationCliCommand, declared_name="new"): help = "Create a new pi thread" spec_type: ClassVar[type[NewThreadSpec]] = NewThreadSpec - tags: Annotated[GivenTags, *TAG_ARGUMENT] + tags: Annotated[GivenTags, Argument()] @classmethod def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[PendingCommandBinding, ...]: 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/field_codec.py b/src/agent_comms/field_codec.py index db0eb0ab3f..aa34645a68 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -369,8 +369,8 @@ class Normalize(ValueDirective[Callable[[object], object]]): pass -class EditorFormat(ValueDirective[Callable[[object], JsonValue]]): - pass +class EditsAsText: + """This member declares the typed editor of its untagged wire family.""" class Editor(ValueDirective[type]): @@ -619,10 +619,6 @@ 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) @@ -630,7 +626,7 @@ def editor_payload(self, value: object) -> object: return self.editor_normalizer(value) def editor_default(self, value: object) -> JsonValue: - return self.editor_formatter(value) + return self.value.encode_editor(value) @property def description(self) -> str: @@ -957,6 +953,17 @@ class DecoderPlan(ABC): target: object + @property + def editor_annotation(self) -> object: + return str + + def format_editor(self, wire: object) -> JsonValue: + return json.dumps(wire) + + @boundary_decoder + def parse_editor(self, text: object) -> object: + return None if text is None else json.loads(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 () @@ -1053,10 +1060,14 @@ 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) + editors = tuple(member for member in members if EditsAsText in member.__mro__) + if len(editors) > 1: + raise TypeError(f'{target} declares more than one typed-text editor: {editors}') + if editors: + return _TextEditedUnionDecoder(target, alternatives, compile_decoder(editors[0]).editor_wire) + return UnionDecoder(target, alternatives) @abstractmethod def decode(self, data: object) -> T: @@ -1123,15 +1134,15 @@ 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 encode_editor(self, value: T) -> str: - encoded = FieldCodec.encode(value, self.target) - return json.dumps(encoded) + def encode_editor(self, value: T) -> JsonValue: + return self.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.parse_editor(value) def editor_choices(self) -> tuple[tuple[str, object], ...]: raise TypeError("The declared decoder does not offer finite choices") @@ -1166,6 +1177,20 @@ 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 editor_annotation(self) -> object: + return self.wire_structure.editor_annotation + + def format_editor(self, wire: object) -> JsonValue: + return self.wire_structure.format_editor(wire) + + def parse_editor(self, text: object) -> object: + return self.wire_structure.parse_editor(text) + @property def accepts_null(self) -> bool: return self.wire_structure.accepts_null @@ -2793,11 +2818,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) -> JsonValue: + return cast(JsonValue, wire) - def decode_editor(self, value): - return value + def parse_editor(self, text: object) -> object: + return text class _FlagDecoder(_ExactScalarDecoder[bool]): @@ -3088,6 +3113,24 @@ def decode_candidates( first_error = None +@dataclass(frozen=True, slots=True) +class _TextEditedUnionDecoder[T](UnionDecoder[T]): + """One member's declaration owns typed text; other members remain wire choices.""" + + editor: DecoderPlan + + @property + def editor_annotation(self) -> object: + annotation = self.editor.editor_annotation + return Union[tuple((annotation, type(None)))] if self.accepts_null else annotation + + def format_editor(self, wire: object) -> JsonValue: + return self.editor.format_editor(wire) + + def parse_editor(self, text: object) -> object: + return self.editor.parse_editor(text) + + @dataclass(frozen=True, slots=True) class _ArrayDecoder[T: Iterable[object]](ValueDecoder[T]): container: ClassVar[type] diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index 85e3ea0b73..aa40e9b7a9 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -1,37 +1,46 @@ from __future__ import annotations +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping +from dataclasses import dataclass, replace + 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 MISSING, 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 field_codec.compile_decoder(self.annotation).editor_annotation + @property def name(self) -> str: return self.plan.key @@ -45,20 +54,26 @@ def description(self) -> str: return self.plan.description @property - def supplied(self) -> bool: - return self.value is not MISSING + @abstractmethod + def editor_default(self) -> JsonValue: ... - @property - def editor_default(self) -> JsonValue: - return self.plan.editor_default(self.value) if self.supplied else None + @abstractmethod + def default_schema(self) -> dict[str, JsonValue]: ... + + def argument(self, value: object) -> Mapping[str, object]: + return {self.name: 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 @@ -68,10 +83,44 @@ 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 SuppliedTargetField(TargetField): + value: object + + @property + def editor_default(self) -> JsonValue: + return self.plan.editor_default(self.value) + + def default_schema(self) -> dict[str, JsonValue]: + return {'editor_default': self.editor_default} + @dataclass(frozen=True) -class ChoiceTargetField(TargetField): +class UnsuppliedTargetField(TargetField): + """A required answer has no value; the optional editor owns its unset control.""" + + @property + def editor_annotation(self) -> object: + return field_codec.compile_decoder(self.annotation).editor_annotation | type(None) + + @property + def editor_default(self) -> JsonValue: + return None + + def default_schema(self) -> dict[str, JsonValue]: + return {} + + @boundary_decoder + def argument(self, value: object) -> Mapping[str, object]: + """Decode the optional control's raw presence once, not the field's wire value.""" + return {} if value is None else super().argument(value) + + +@dataclass(frozen=True) +class ChoiceTargetField(SuppliedTargetField): """A prepared finite declaration, from its annotation or configuration catalog.""" options: tuple[CatalogChoice, ...] = () @@ -83,8 +132,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))) @@ -94,6 +142,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]: @@ -103,10 +152,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_management.py b/src/agent_comms/thread_management.py index cddd5e9f97..5be0b40458 100644 --- a/src/agent_comms/thread_management.py +++ b/src/agent_comms/thread_management.py @@ -38,6 +38,7 @@ from .errors import RelationViolationError from .field_codec import ( DeclaredWireType, + EditsAsText, FieldCodec, NullMember, RecordMember, @@ -77,7 +78,7 @@ def for_child(self, parent: frozenset[str]) -> frozenset[str]: ... @dataclass(frozen=True, slots=True) -class GivenTags(DeclaredWireType, TagChoice): +class GivenTags(EditsAsText, DeclaredWireType, TagChoice): """Tags spelled as comma-separated text on the command line, in the editor and in tools.""" wire_type = str diff --git a/tests/test_thread_creation.py b/tests/test_thread_creation.py index 9f435cead0..e7cf080452 100644 --- a/tests/test_thread_creation.py +++ b/tests/test_thread_creation.py @@ -44,8 +44,11 @@ def test_pending_new_binding_offers_missing_tags_without_constructing_the_comman binding, = NewThreadCliCommand.project_bindings(comms, Checkout(source_repository.checkout)) assert type(binding) is PendingCommandBinding tags = next(item for item in binding.editable_fields if item.name == 'tags') - assert tags.required and not tags.supplied + assert tags.required assert tags.editor_default is None + 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'})) @@ -65,7 +68,9 @@ def test_pending_catalog_context_can_change_before_tags_are_supplied(comms, sour binding, = NewThreadCliCommand.project_bindings(comms, Checkout(source_repository.checkout)) prepared = binding.for_fields({'name': 'child'}) assert prepared.context.name == 'child' - assert not next(item for item in prepared.editable_fields if item.name == 'tags').supplied + 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', ( From d44eff33cae41c4e6600513a3fd4543a236cb806 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:45:55 -0400 Subject: [PATCH 31/74] Leave child names unsupplied until creation form completion --- src/agent_comms/cli_command.py | 23 +++++++------ src/agent_comms/cli_support/named_thread.py | 3 +- src/agent_comms/cli_support/thread_bound.py | 7 ++-- .../cli_support/thread_creation.py | 6 ++-- src/agent_comms/cli_verbs/fork.py | 21 ++++++------ src/agent_comms/cli_verbs/new.py | 6 ++-- src/agent_comms/field_codec.py | 2 +- tests/test_thread_creation.py | 32 ++++++++++++++++--- 8 files changed, 66 insertions(+), 34 deletions(-) diff --git a/src/agent_comms/cli_command.py b/src/agent_comms/cli_command.py index d6f9b42fc7..08e274f0e5 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -110,12 +110,17 @@ def authorize_edits(self, arguments: Mapping[str, object]) -> frozenset[str]: 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) - decoded = {name: value - for entry in FieldCodec.argument_fields(self.declaration) if entry.key in arguments - for name, value in entry.decoded(arguments).items()} - return FieldCodec.construct(self.declaration, {**self.retained_payload(), **decoded}) + return FieldCodec.construct(self.declaration, { + **self.retained_payload(), **self.decoded_edits(self.declaration, arguments), + }) @abstractmethod def confirmation(self) -> str: ... @@ -175,11 +180,11 @@ def field_value(self, entry: FieldPlan) -> TargetField: def for_fields(self, arguments: Mapping[str, object]) -> PendingCommandBinding: self.authorize_edits(arguments) - changes = {name: value - for entry in FieldCodec.argument_fields(type(self.context)) if entry.key in arguments - for name, value in entry.decoded({ - entry.key: entry.editor_payload(arguments[entry.key]), - }).items()} + 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: 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 index 07c617a558..8f8f1aeae4 100644 --- a/src/agent_comms/cli_support/thread_creation.py +++ b/src/agent_comms/cli_support/thread_creation.py @@ -20,8 +20,8 @@ @dataclass(frozen=True, kw_only=True) -class ThreadCreationFields(NameArgument, CommandContext): - """Supplied launch context is also the complete command's field declaration.""" +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[ @@ -38,7 +38,7 @@ def project_path(self) -> Path: @dataclass(frozen=True, kw_only=True) class ThreadCreationCliCommand( - ThreadCreationFields, CatalogBinding, CliCommand[ThreadCreatedResult], declared_name=None, + NameArgument, ThreadLaunchSettings, CatalogBinding, CliCommand[ThreadCreatedResult], declared_name=None, ): @property @abstractmethod diff --git a/src/agent_comms/cli_verbs/fork.py b/src/agent_comms/cli_verbs/fork.py index c39dd652cc..14bdedb8db 100644 --- a/src/agent_comms/cli_verbs/fork.py +++ b/src/agent_comms/cli_verbs/fork.py @@ -1,6 +1,7 @@ from __future__ import annotations -from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand +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.field_codec import Argument from agent_comms.field_codec import TargetBound @@ -10,20 +11,22 @@ from agent_comms.threads import Thread from dataclasses import dataclass from typing import Annotated -from typing import Self @dataclass(frozen=True, kw_only=True) -class ForkCliCommand(NativeOwnerCommand, ThreadBoundCliCommand, ThreadCreationCliCommand): +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",)) - ] tags: Annotated[TagChoice, Argument()] = InheritedTags() @classmethod - def thread_binding(cls, thread: Thread) -> Self: - return cls(name='', parent=thread.name, project=thread.worktree, - model=thread.model, thinking_level=thread.thinking_level) + def thread_binding(cls, thread: Thread) -> PendingCommandBinding: + return PendingCommandBinding(cls, 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 c0ecc5e229..aad00c4ff7 100644 --- a/src/agent_comms/cli_verbs/new.py +++ b/src/agent_comms/cli_verbs/new.py @@ -1,7 +1,7 @@ from __future__ import annotations from agent_comms.cli_command import PendingCommandBinding -from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand, ThreadCreationFields +from agent_comms.cli_support.thread_creation import ThreadCreationCliCommand, ThreadLaunchSettings from agent_comms.comms import Comms from agent_comms.field_codec import Argument from agent_comms.native_settings import NativeThreadDefaults @@ -21,6 +21,6 @@ class NewThreadCliCommand(ThreadCreationCliCommand, declared_name="new"): @classmethod def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[PendingCommandBinding, ...]: defaults = NativeThreadDefaults() - return (PendingCommandBinding(cls, ThreadCreationFields( - name='', project=project, model=defaults.model, + return (PendingCommandBinding(cls, ThreadLaunchSettings( + project=project, model=defaults.model, thinking_level=defaults.thinking_level)),) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index aa34645a68..776c48255e 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -962,7 +962,7 @@ def format_editor(self, wire: object) -> JsonValue: @boundary_decoder def parse_editor(self, text: object) -> object: - return None if text is None else json.loads(text) + 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.""" diff --git a/tests/test_thread_creation.py b/tests/test_thread_creation.py index e7cf080452..cedf3eb331 100644 --- a/tests/test_thread_creation.py +++ b/tests/test_thread_creation.py @@ -3,6 +3,7 @@ Author does not execute these controls; the integration owner selects them once. """ from dataclasses import replace +import json import pytest @@ -10,7 +11,8 @@ 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 +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 @@ -43,6 +45,11 @@ def test_new_cli_membership_reaches_registration(comms, source_repository, text, 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 tags.editor_default is None @@ -66,8 +73,9 @@ def test_pending_new_binding_distinguishes_explicit_empty_from_missing(comms, so 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({'name': 'child'}) - assert prepared.context.name == 'child' + 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': ''} @@ -85,6 +93,14 @@ def test_complete_creation_commands_keep_command_edit_authorization(source_repos 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']) @@ -92,7 +108,15 @@ def test_fork_cli_defaults_to_actual_parent_membership(comms, source_repository) command = parsed(['fork', '--name', 'child', '--parent', parent.name, '--project', str(source_repository.checkout)]) assert command.tags == InheritedTags() - editor = ForkCliCommand.thread_binding(parent) + 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'}))) From a44c624e76f831751755f9ed229b250141bdbdb4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:52:02 -0400 Subject: [PATCH 32/74] Remove invalid trailing comma from pending context edits --- src/agent_comms/cli_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/cli_command.py b/src/agent_comms/cli_command.py index 08e274f0e5..760d788bfe 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -182,7 +182,7 @@ 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, + 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 From 6e53464e88b2e7976afbd0f156f51d4d04bc8c05 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:21:09 -0400 Subject: [PATCH 33/74] Keep native union members and derive their editor transactions --- src/agent_comms/cli_command.py | 7 +- src/agent_comms/field_codec.py | 195 ++++++++++++++++++++------- src/agent_comms/target_fields.py | 53 +++++++- src/agent_comms/thread_management.py | 3 +- tests/test_editor_defaults.py | 62 ++++++++- 5 files changed, 257 insertions(+), 63 deletions(-) diff --git a/src/agent_comms/cli_command.py b/src/agent_comms/cli_command.py index 760d788bfe..d06b70a9c6 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -118,9 +118,10 @@ def decoded_edits(declaration: type, arguments: Mapping[str, object]) -> dict[st def edited(self, arguments: Mapping[str, object]) -> CliCommand: self.authorize_edits(arguments) - return FieldCodec.construct(self.declaration, { - **self.retained_payload(), **self.decoded_edits(self.declaration, 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: ... diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 776c48255e..589d084a8f 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -12,7 +12,7 @@ import types from abc import ABC, abstractmethod from collections.abc import Callable, Iterable, Iterator, Mapping -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 @@ -369,10 +369,6 @@ class Normalize(ValueDirective[Callable[[object], object]]): pass -class EditsAsText: - """This member declares the typed editor of its untagged wire family.""" - - class Editor(ValueDirective[type]): pass @@ -625,9 +621,16 @@ def argument_payload(self, value: object) -> object: def editor_payload(self, value: object) -> object: return self.editor_normalizer(value) - def editor_default(self, value: object) -> JsonValue: + def editor_default(self, value: object) -> str: return self.value.encode_editor(value) + def editor_initial(self, value: object) -> object: + return self.value.editor_initial(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: return Argument.declared(self.annotation).description(self.name) @@ -957,7 +960,34 @@ class DecoderPlan(ABC): def editor_annotation(self) -> object: return str - def format_editor(self, wire: object) -> JsonValue: + @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: + raise TypeError(f'{self.target} does not declare a union editor') + + 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 @@ -995,6 +1025,73 @@ def external_decoders(self, data: object) -> Iterable[ExternalDecoder]: return () +@dataclass(frozen=True) +class EditorMember(ABC): + """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) + + @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.""" @@ -1062,11 +1159,6 @@ def representation_plan( def for_members[M](cls, members: tuple[type[M], ...]) -> UnionDecoder[M]: target = reduce(or_, members) alternatives = tuple(compile_decoder(member) for member in members) - editors = tuple(member for member in members if EditsAsText in member.__mro__) - if len(editors) > 1: - raise TypeError(f'{target} declares more than one typed-text editor: {editors}') - if editors: - return _TextEditedUnionDecoder(target, alternatives, compile_decoder(editors[0]).editor_wire) return UnionDecoder(target, alternatives) @abstractmethod @@ -1107,10 +1199,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 @@ -1138,11 +1226,14 @@ def decode_keys(self, data, keys, selections): def editor_wire(self) -> DecoderPlan: return self - def encode_editor(self, value: T) -> JsonValue: - return self.format_editor(self.encode(value)) + 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: + return self.editor_wire.format_editor(self.encode(value)) def decode_editor(self, value: object) -> object: - return self.parse_editor(value) + 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") @@ -1181,16 +1272,6 @@ def wire_structure(self) -> DecoderPlan: def editor_wire(self) -> DecoderPlan: return self.wire_structure - @property - def editor_annotation(self) -> object: - return self.wire_structure.editor_annotation - - def format_editor(self, wire: object) -> JsonValue: - return self.wire_structure.format_editor(wire) - - def parse_editor(self, text: object) -> object: - return self.wire_structure.parse_editor(text) - @property def accepts_null(self) -> bool: return self.wire_structure.accepts_null @@ -2780,7 +2861,13 @@ def fits_wire(self, data: object) -> bool: return self.accepts(data) def accepts_value(self, value: object) -> bool: - return self.accepts(value) + return type(value) is self.target + + 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) @@ -2806,6 +2893,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",) @@ -2818,8 +2908,8 @@ class _TextDecoder(_ExactScalarDecoder[str]): def encode(self, value: str) -> str: return str(super().encode(value)) - def format_editor(self, wire: object) -> JsonValue: - return cast(JsonValue, wire) + def format_editor(self, wire: object) -> str: + return self.decode(wire) def parse_editor(self, text: object) -> object: return text @@ -3050,13 +3140,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 @@ -3113,24 +3221,6 @@ def decode_candidates( first_error = None -@dataclass(frozen=True, slots=True) -class _TextEditedUnionDecoder[T](UnionDecoder[T]): - """One member's declaration owns typed text; other members remain wire choices.""" - - editor: DecoderPlan - - @property - def editor_annotation(self) -> object: - annotation = self.editor.editor_annotation - return Union[tuple((annotation, type(None)))] if self.accepts_null else annotation - - def format_editor(self, wire: object) -> JsonValue: - return self.editor.format_editor(wire) - - def parse_editor(self, text: object) -> object: - return self.editor.parse_editor(text) - - @dataclass(frozen=True, slots=True) class _ArrayDecoder[T: Iterable[object]](ValueDecoder[T]): container: ClassVar[type] @@ -3465,6 +3555,9 @@ 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 diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index aa40e9b7a9..29e4adda58 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -39,7 +39,7 @@ def annotation(self) -> object: @property def editor_annotation(self) -> object: - return field_codec.compile_decoder(self.annotation).editor_annotation + return self.plan.value.editor_wire.native_editor_annotation @property def name(self) -> str: @@ -53,15 +53,29 @@ def multiline(self) -> bool: def description(self) -> str: return self.plan.description + @property + def editor_members(self) -> tuple[field_codec.EditorMember, ...]: + return self.plan.value.editor_wire.editor_members + + def editor_selection(self, value: object) -> object: + return self.plan.value.editor_wire.selected_editor_member(value).declaration + + @property + @abstractmethod + def editor_initial(self) -> object: ... + @property @abstractmethod - def editor_default(self) -> JsonValue: ... + def editor_default(self) -> str | None: ... @abstractmethod def default_schema(self) -> dict[str, JsonValue]: ... def argument(self, value: object) -> Mapping[str, object]: - return {self.name: value} + 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.""" @@ -91,7 +105,11 @@ class SuppliedTargetField(TargetField): value: object @property - def editor_default(self) -> JsonValue: + def editor_initial(self) -> object: + return self.plan.editor_initial(self.value) + + @property + def editor_default(self) -> str: return self.plan.editor_default(self.value) def default_schema(self) -> dict[str, JsonValue]: @@ -104,15 +122,23 @@ class UnsuppliedTargetField(TargetField): @property def editor_annotation(self) -> object: - return field_codec.compile_decoder(self.annotation).editor_annotation | type(None) + return self.plan.value.editor_wire.native_editor_annotation | type(None) + + @property + def editor_initial(self) -> object: + return None @property - def editor_default(self) -> JsonValue: + def editor_default(self) -> None: return None def default_schema(self) -> dict[str, JsonValue]: return {} + @boundary_decoder + def editor_selection(self, value: object) -> object: + return None if value is None else super().editor_selection(value) + @boundary_decoder def argument(self, value: object) -> Mapping[str, object]: """Decode the optional control's raw presence once, not the field's wire value.""" @@ -124,6 +150,21 @@ 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 () diff --git a/src/agent_comms/thread_management.py b/src/agent_comms/thread_management.py index 5be0b40458..cddd5e9f97 100644 --- a/src/agent_comms/thread_management.py +++ b/src/agent_comms/thread_management.py @@ -38,7 +38,6 @@ from .errors import RelationViolationError from .field_codec import ( DeclaredWireType, - EditsAsText, FieldCodec, NullMember, RecordMember, @@ -78,7 +77,7 @@ def for_child(self, parent: frozenset[str]) -> frozenset[str]: ... @dataclass(frozen=True, slots=True) -class GivenTags(EditsAsText, DeclaredWireType, TagChoice): +class GivenTags(DeclaredWireType, TagChoice): """Tags spelled as comma-separated text on the command line, in the editor and in tools.""" wire_type = str diff --git a/tests/test_editor_defaults.py b/tests/test_editor_defaults.py index c4b6e38e8a..1ce950cf2e 100644 --- a/tests/test_editor_defaults.py +++ b/tests/test_editor_defaults.py @@ -4,7 +4,9 @@ import pytest from agent_comms.cli_verbs.fork import ForkCliCommand -from agent_comms.field_codec import FieldCodec, compile_decoder +from agent_comms.field_codec import FieldCodec, MalformedRecord, 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 +21,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 +47,57 @@ 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) is ChangedTags + assert isinstance(parameter.editor_default, str) + json.dumps(parameter.json_schema()) + + +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({}) + + From d50cc189031fb97a1bcb70464934a605c749c00e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:40:03 -0400 Subject: [PATCH 34/74] Make unchosen a nominal member of editor selection --- src/agent_comms/field_codec.py | 42 +++++++++++++++++++++++++++++++- src/agent_comms/target_fields.py | 23 ++++++++++++++--- tests/test_editor_defaults.py | 23 +++++++++++++++-- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 589d084a8f..19f69af185 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -1025,8 +1025,45 @@ 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): + @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): + @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(ABC): +class EditorMember(MemberSelection): """Read-only member metadata; only actual inputs enter a form's ObjectState.""" decoder: DecoderPlan @@ -1039,6 +1076,9 @@ def declaration(self) -> object: 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, ...]: ... diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index 29e4adda58..782edb78d0 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Mapping from dataclasses import dataclass, replace +from functools import singledispatchmethod from pydantic import JsonValue @@ -57,8 +58,12 @@ def description(self) -> str: def editor_members(self) -> tuple[field_codec.EditorMember, ...]: return self.plan.value.editor_wire.editor_members - def editor_selection(self, value: object) -> object: - return self.plan.value.editor_wire.selected_editor_member(value).declaration + @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 @@ -135,9 +140,19 @@ def editor_default(self) -> 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 editor_selection(self, value: object) -> object: - return None if value is None else super().editor_selection(value) + def _unchosen_selection(self, value: None) -> field_codec.MemberSelection: + return field_codec.UnchosenMember() @boundary_decoder def argument(self, value: object) -> Mapping[str, object]: diff --git a/tests/test_editor_defaults.py b/tests/test_editor_defaults.py index 1ce950cf2e..9a2a1dd2fa 100644 --- a/tests/test_editor_defaults.py +++ b/tests/test_editor_defaults.py @@ -4,7 +4,9 @@ import pytest from agent_comms.cli_verbs.fork import ForkCliCommand -from agent_comms.field_codec import FieldCodec, MalformedRecord, 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 @@ -88,11 +90,28 @@ def test_native_union_default_does_not_leak_into_public_json_schema() -> None: value = ChangedTags(frozenset({'auth'}), frozenset()) parameter = TargetField.of(plan, value) assert parameter.editor_initial is value - assert parameter.editor_selection(value) is ChangedTags + 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())) From 7b139e1450083df31fdff9ecf5bdcf6737fc8b81 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:04:38 -0400 Subject: [PATCH 35/74] Ask the value decoder directly for editor projections --- src/agent_comms/field_codec.py | 6 ------ src/agent_comms/target_fields.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 19f69af185..6cf5718230 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -621,12 +621,6 @@ def argument_payload(self, value: object) -> object: def editor_payload(self, value: object) -> object: return self.editor_normalizer(value) - def editor_default(self, value: object) -> str: - return self.value.encode_editor(value) - - def editor_initial(self, value: object) -> object: - return self.value.editor_initial(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}') diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index 782edb78d0..5cd068266e 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -111,11 +111,11 @@ class SuppliedTargetField(TargetField): @property def editor_initial(self) -> object: - return self.plan.editor_initial(self.value) + return self.plan.value.editor_initial(self.value) @property def editor_default(self) -> str: - return self.plan.editor_default(self.value) + return self.plan.value.encode_editor(self.value) def default_schema(self) -> dict[str, JsonValue]: return {'editor_default': self.editor_default} From 3b94c9bd598faa2d4b4eb6c53f577b3d1182ea0e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 21:18:34 -0400 Subject: [PATCH 36/74] Remove absent editor defaults and reuse scalar admission --- src/agent_comms/field_codec.py | 2 +- src/agent_comms/target_fields.py | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 6cf5718230..a852d7e989 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -2895,7 +2895,7 @@ def fits_wire(self, data: object) -> bool: return self.accepts(data) def accepts_value(self, value: object) -> bool: - return type(value) is self.target + return self.accepts(value) def parse_editor(self, text: object) -> object: value = super().parse_editor(text) diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index 5cd068266e..48192859a6 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -69,10 +69,6 @@ def editor_selection(self, value: object) -> field_codec.MemberSelection: @abstractmethod def editor_initial(self) -> object: ... - @property - @abstractmethod - def editor_default(self) -> str | None: ... - @abstractmethod def default_schema(self) -> dict[str, JsonValue]: ... @@ -133,10 +129,6 @@ def editor_annotation(self) -> object: def editor_initial(self) -> object: return None - @property - def editor_default(self) -> None: - return None - def default_schema(self) -> dict[str, JsonValue]: return {} From e66d9b763033b487cd34066e7f6bf8df9074294d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 23:08:58 -0400 Subject: [PATCH 37/74] Join channel activity to captured tag declarations --- src/agent_comms/bus.py | 33 ++++--- src/agent_comms/check_root.py | 4 +- src/agent_comms/messages.py | 41 ++++++-- src/agent_comms/read_basis.py | 127 +++++++++++++++++++------ src/agent_comms/registry_document.py | 40 ++++++-- src/agent_comms/registry_provenance.py | 45 ++++++++- src/agent_comms/threads.py | 20 ++-- tests/test_bus_activity.py | 10 +- 8 files changed, 244 insertions(+), 76 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index 09a571c10c..9bfc4c6a30 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -130,13 +130,18 @@ def counts(self) -> dict[str, int]: def advanced(self, source: BusReader, selection: DisplayUnreadSelection) -> DisplayUnread: queries = selection.queries() through = source.revision() - if through == self.through and tuple(self.counted) == queries: - return self earlier = {query.counting: query for query in self.counted} - kept = {query: query for query in queries if query in self.counted} - moved = {query: (earlier[query.counting], parts) for query in queries - if query not in kept and query.counting in earlier - and (parts := query.moved(earlier[query.counting]))} + reusable = {query: earlier[query.counting] for query in queries + if query.counting in earlier + and query.same_membership(earlier[query.counting])} + kept = {query: previous for query, previous in reusable.items() + if query == previous} + if through == self.through and len(kept) == len(queries): + if tuple(self.counted) == queries: + return self + return DisplayUnread(through, {query: self.counted[kept[query]] for query in queries}) + moved = {query: (previous, parts) for query, previous in reusable.items() + if query not in kept and (parts := query.moved(previous))} progressed = (*kept, *(previous for previous, _ in moved.values())) appended = source.count_unread( progressed if through != self.through else (), through=through, after=self.through) @@ -180,10 +185,10 @@ def target_activity(self, predicate: str, parameters: tuple[object, ...]) -> Cha with self.observing() as db: return BusActivityIndex.aggregate(db, predicate, parameters) - def participant_activity(self, names: frozenset[str]) -> ChannelActivity: - marks = ','.join('?' for _ in names) + def participant_activity(self, predicate: str, + parameters: tuple[object, ...]) -> ChannelActivity: with self.observing() as db: - return BusDisplayIndex.aggregate(db, f'participant IN ({marks})', tuple(names)) + return BusDisplayIndex.aggregate(db, predicate, parameters) def claim_projection(self) -> ClaimProjection: with self.observing() as db: @@ -284,22 +289,22 @@ def scoped_page( upper: int | None = None, descending: bool = False, limit: int = 100, kinds: tuple[type[MessageKind], ...] = (), ) -> tuple[Message, ...]: - """A captured display scope narrows the addressed table before decoding.""" + """A captured display scope reads public message capabilities, not delivery payloads.""" with self.observing() as db: upper = BusRow.last_seq(db) + 1 if upper is None else upper order = "DESC" if descending else "ASC" members = "" if kinds: members = MessageInput.render(' AND {type} IN (') + ','.join('?' for _ in kinds) + ')' - rows = db.execute( + return tuple(Message.selected( + db, f'SELECT seq FROM "{Addressed.table.declared_name}" ' f'JOIN "{MessageInput.table.declared_name}" USING(seq) ' f'WHERE ({predicate}) AND seq > ? AND seq < ?{members} ' f'ORDER BY seq {order} LIMIT ?', (*parameters, lower, upper, *(kind.declared_name for kind in kinds), limit), - ) - headers = (BusRow.require(db, seq=FieldCodec.decode(int, row[0])) for row in rows) - return tuple(header.kind.read_from(db, header.seq) for header in headers) + descending=descending, + )) def latest_conversations( self, predicate: str, parameters: tuple[object, ...], through: int, diff --git a/src/agent_comms/check_root.py b/src/agent_comms/check_root.py index 7070dc907a..39b9751480 100644 --- a/src/agent_comms/check_root.py +++ b/src/agent_comms/check_root.py @@ -24,7 +24,7 @@ from .launch_runtime import ThreadLaunch from .native_entries import ImportedSessionWriter, NativeEntry from .native_session_files import CheckSession -from .read_basis import MemberActivityDisplayScope +from .read_basis import NamedActivityDisplayScope from .registry_document import RosterVisibility from .session_file import RegistrySessionLocation from .thread_storage import StorageRoots @@ -94,7 +94,7 @@ def _populate(self, copied: Comms) -> None: targets = frozenset( target for name in names for target in catalog.targets_for(snapshot.require(name).tags) ) - scope = MemberActivityDisplayScope("#check", targets, participant_names=selected | aliases) + scope = NamedActivityDisplayScope("#check", targets, names=selected | aliases) with self.source.bus.bus.reading() as source: messages = scope.history(source, limit=self.max_records) copied.registry.store.prepare_native_root() diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 74de34bb2d..634cf931f7 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -427,14 +427,39 @@ def rows(self) -> tuple[CapabilityRow, ...]: @classmethod def read_from(cls, db: sqlite3.Connection, seq: int) -> Self: - """Reconstruct this declared value from its own capability tables.""" - rows = [] - for table in cls.tables(): - stored = table.select(db, seq=seq) - if not stored: - raise RelationViolationError(f"Bus row {seq} lacks {table.declared_name}") - rows.append(stored[0]) - return cls.assembled(rows) + """Reconstruct one declared value through the shared joined acquisition.""" + with closing(cls.selected( + db, f'SELECT seq FROM "{BusRow.declared_name}" WHERE seq=?', (seq,), + )) as values: + for value in values: + return value + raise RelationViolationError(f"Bus row {seq} lacks {cls.__name__}") + + @classmethod + def selected( + cls, db: sqlite3.Connection, positions: str, parameters: tuple[object, ...], + *, descending: bool = False, + ) -> Iterator[Self]: + """One cursor reads only the capability tables this value inherits.""" + tables = cls.tables() + offsets = dict(zip(tables, accumulate( + (len(BusRow.columns()), *(len(table.columns()) for table in tables))))) + header = BusRow.qualified("seq") + order = "DESC" if descending else "ASC" + with closing(db.execute( + f"WITH selected AS ({positions}) " + f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in (BusRow, *tables))} " + f'FROM "{BusRow.declared_name}" JOIN selected USING(seq) ' + + " ".join(f'LEFT JOIN "{table.declared_name}" ON {table.qualified("seq")} = {header}' + for table in tables) + + f" ORDER BY {header} {order}", parameters, + )) as cursor: + for stored in cursor: + original = BusRow.decode_at(stored, 0) + for table in tables: + if stored[offsets[table]] is None: + raise RelationViolationError(f"Bus row {original.seq} lacks {table.declared_name}") + yield cls.assembled(table.decode_at(stored, offsets[table]) for table in tables) @classmethod def assembled(cls, rows: Iterable[CapabilityRow]) -> Self: diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 275375d3a8..f9cd68b286 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -28,7 +28,7 @@ from .coordination_errors import DMPaintedBoundChanged, DMScopeChanged, HistoricalSourceChanged from .declared_family import DeclaredFamily from .errors import RelationViolationError, UnregisteredThreadError -from .field_codec import FieldCodec, NullMember, PathText, SingleFieldValue, SubclassUnion +from .field_codec import FieldCodec, NullMember, PathText, SingleFieldValue, SubclassUnion, WireTransient from .mentions import ThreadMention from .message_reference import MessageReference from .private_path import FileObservation, MissingFile @@ -311,6 +311,7 @@ def flush(self) -> None: from .registration import Registration from .registry_document import RegistrySnapshot, UserThreadLookup from .registry_provenance import RegistryProvenance + from .threads import ThreadMembership from .threads import Thread @@ -952,6 +953,13 @@ def counted(self, cursor: ReadCursor, snapshot: RegistryProvenance return ((key, value) for key, value in cursor.conversations.items() if key.in_targets(targets, snapshot)) + @property + def memberships(self) -> Mapping[str, ThreadMembership]: + return {} + + def same_membership(self, earlier: MessageDisplayScope) -> bool: + return True + @abstractmethod def includes(self, message: Message) -> bool: """Apply the captured predicate to a validated authoritative message.""" @@ -1123,10 +1131,15 @@ def capture_all(self, reads: ReadLedger, viewer: str, snapshot: RegistrySnapshot @dataclass(frozen=True, slots=True) -class MemberActivityDisplayScope(AllTargetsDisplayScope): - """Member activity may match a sender, DM peer or mention on any target.""" +class ParticipantActivityDisplayScope(AllTargetsDisplayScope, declared_name=None): + """Sender, DM peer and mention activity share one relational predicate.""" - participant_names: frozenset[str] = frozenset() + @property + @abstractmethod + def participant_query(self) -> tuple[str, tuple[object, ...]]: ... + + @abstractmethod + def includes_participant(self, name: str) -> bool: ... def capture_deliveries(self, reads: ReadLedger, viewer: str, snapshot: RegistrySnapshot, source: BusSnapshot, *, document: ReadDocument, @@ -1135,40 +1148,76 @@ def capture_deliveries(self, reads: ReadLedger, viewer: str, snapshot: RegistryS rows = source.selected_sequences(*self.intersection(audience), through) return reads.capture(viewer, rows, snapshot, source, document=document) - @classmethod - def capture(cls, channel: TagChannel, snapshot: RegistryProvenance) -> MemberActivityDisplayScope: - members = frozenset(name for name, thread in snapshot.threads.items() - if channel.tags <= thread.tags) - names = members | frozenset(alias for alias, owner in snapshot.aliases.items() - if owner in members) - return cls(channel.name, channel.history_targets, participant_names=names) - @property def condition(self) -> tuple[str, tuple]: - targets, names = tuple(self.targets), tuple(self.participant_names) - target_marks, name_marks = ",".join("?" for _ in targets), ",".join("?" for _ in names) + targets = tuple(self.targets) + members, parameters = self.participant_query + target_marks = ",".join("?" for _ in targets) mention_key = FieldCodec.field_plan(ThreadMention, "thread").key - return (f"{{target}} IN ({target_marks}) OR {{sender}} IN ({name_marks}) OR " - f"({{target}} NOT LIKE '#%' AND {{target}} IN ({name_marks})) OR " + return (f"{{target}} IN ({target_marks}) OR {{sender}} IN ({members}) OR " + f"({{target}} NOT LIKE '#%' AND {{target}} IN ({members})) OR " f"EXISTS (SELECT 1 FROM json_each({{mentions}}) " - f"WHERE json_extract(value,?) IN ({name_marks}))", - (*targets, *names, *names, f"$.{mention_key}", *names)) + f"WHERE json_extract(value,?) IN ({members}))", + (*targets, *parameters, *parameters, f"$.{mention_key}", *parameters)) def includes(self, message: Message) -> bool: return self._includes(message.sender, message.target, (mention.thread for mention in message.mentions)) def activity(self, source: BusReader): - + members, parameters = self.participant_query return ChannelActivity.combine(( ChannelDisplayScope(self.channel, self.targets).activity(source), - source.participant_activity(self.participant_names), + source.participant_activity(f"participant IN ({members})", parameters), )) def _includes(self, sender: str, target: str, mentions: Iterable[str]) -> bool: - return (target in self.targets or sender in self.participant_names - or not self.participant_names.isdisjoint(CommandTarget.named(target).direct_names) - or any(name in self.participant_names for name in mentions)) + return (target in self.targets or self.includes_participant(sender) + or any(self.includes_participant(name) + for name in CommandTarget.named(target).direct_names) + or any(self.includes_participant(name) for name in mentions)) + + +@dataclass(frozen=True, slots=True) +class NamedActivityDisplayScope(ParticipantActivityDisplayScope): + """An explicitly selected set, used by root checks rather than channel tags.""" + + names: frozenset[str] = frozenset() + + @property + def participant_query(self) -> tuple[str, tuple[object, ...]]: + return "SELECT value FROM json_each(?)", (json.dumps(sorted(self.names)),) + + def includes_participant(self, name: str) -> bool: + return name in self.names + + +@dataclass(frozen=True, slots=True) +class MemberActivityDisplayScope(ParticipantActivityDisplayScope): + """A channel declaration joined to the original immutable namespace's tags.""" + + tags: frozenset[str] = frozenset() + registry: Annotated[RegistryProvenance, WireTransient()] = field( + kw_only=True, compare=False, repr=False, + ) + + @classmethod + def capture(cls, channel: TagChannel, snapshot: RegistryProvenance) -> MemberActivityDisplayScope: + return cls(channel.name, channel.history_targets, tags=channel.tags, registry=snapshot) + + @property + def memberships(self) -> Mapping[str, ThreadMembership]: + return self.registry.memberships + + def same_membership(self, earlier: MessageDisplayScope) -> bool: + return self.registry.same_tag_membership(earlier.memberships, self.tags) + + @property + def participant_query(self) -> tuple[str, tuple[object, ...]]: + return self.registry.tagged_names(self.tags) + + def includes_participant(self, name: str) -> bool: + return name in self.memberships and self.tags <= self.memberships[name].tags @dataclass(frozen=True, slots=True) @@ -1179,18 +1228,36 @@ class UnreadQuery: moved for some conversations differs from its earlier count only in those conversations' rows above the lower of their two high waters.""" - channel: str + scope: ChannelDisplayScope cursors: tuple[CursorRow, ...] lower_bound: int - predicate: str - parameters: tuple viewer_names: str + target_groups: tuple[tuple[str, ...], ...] = () + + @property + def channel(self) -> str: + return self.scope.channel + + @property + def predicate(self) -> str: + predicate, _ = self.scope.index_predicate + for targets in self.target_groups: + predicate = f"({predicate}) AND target IN ({', '.join('?' * len(targets))})" + return predicate + + @property + def parameters(self) -> tuple: + _, parameters = self.scope.index_predicate + return (*parameters, *(target for targets in self.target_groups for target in targets)) + + def same_membership(self, earlier: UnreadQuery) -> bool: + return self.scope.same_membership(earlier.scope) @property def counting(self) -> UnreadQuery: """The same rows and rule with no read cursor: what stays fixed while reading. The lower bound is the cursor's own floor over the scope's targets, so it moves with it.""" - return replace(self, cursors=(), lower_bound=0) + return replace(self, cursors=(), lower_bound=0, target_groups=()) def moved(self, earlier: UnreadQuery) -> tuple[UnreadQuery, ...]: """The earlier and current counts over only the conversations whose cursor moved; @@ -1210,8 +1277,7 @@ def within(self, targets: tuple[str, ...], floor: int) -> UnreadQuery: """This query over only these targets' rows above floor; every row at or below a moved conversation's lower high water is read under both cursors.""" return replace(self, lower_bound=max(self.lower_bound, floor), - predicate=f"({self.predicate}) AND target IN ({', '.join('?' * len(targets))})", - parameters=(*self.parameters, *targets)) + target_groups=(*self.target_groups, targets)) @dataclass(frozen=True, slots=True) @@ -1228,10 +1294,9 @@ def queries(self) -> tuple[UnreadQuery, ...]: names = json.dumps(self.viewer_names) return tuple( UnreadQuery( - scope.channel, + scope, ReadCursor.rows(self.registry, {"viewer": self.seen.counted(scope, self.registry)}), self.seen.lower_bound(scope, self.registry), - *scope.index_predicate, names, ) for scope in self.scopes diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 94eab9665f..fee9393217 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -70,7 +70,7 @@ ThreadStatus, ) from .thread_text import ForkedParent, NoParent -from .threads import GoalOwner, Thread, ThreadWorkState +from .threads import GoalOwner, Thread, ThreadMembership, ThreadWorkState from .turn_lease import ( FinishedTurn, FinishedTurnFence, NoFinishedTurn, StoredTurnRecord, TurnCompletion, TurnLeaseFence, TurnRecord, @@ -110,6 +110,9 @@ def incarnation(self, name: str) -> ThreadIncarnation: """This captured row owns identity without acquiring its full declaration.""" return self.threads.incarnation(self.require_canonical(name)) + def membership(self, name: str) -> ThreadMembership: + return self.threads.membership(self.require_canonical(name)) + def active_candidates(self) -> Iterator[Thread]: return (self.threads[name] for name, status in self.statuses.items() if status.may_be_active) @@ -336,6 +339,14 @@ def to_wire(self) -> dict[str, JsonValue]: def __reduce__(self) -> tuple[Callable[[object], Self], tuple[dict[str, JsonValue]]]: return type(self).from_wire, (self.to_wire(),) + @cached_property + def membership(self) -> ThreadMembership: + captured = {entry.name: entry.current(self) + for entry in FieldCodec.field_plans(ThreadBirthRecord) if entry.records} + return FieldCodec.project_record( + ThreadMembership, dict(self.raw), source=Thread, decoded_fields=captured, + ) + @cached_property def goal_owner(self) -> GoalOwner: captured = {entry.name: entry.current(self) @@ -393,14 +404,18 @@ def children(self, parent: str) -> tuple[Thread, ...]: ) def captured(self, name: str) -> ThreadRow: + raw = self._raw[name] if name not in self._captured: - self._captured[name] = ThreadRow.from_wire(self._raw[name]) + self._captured[name] = ThreadRow.from_wire(raw) return self._captured[name] def determining(self, name: str) -> GoalOwner: """Only a goal/role question acquires the declared goal-owner projection.""" return self._decoded[name] if name in self._decoded else self.captured(name).goal_owner + def membership(self, name: str) -> ThreadMembership: + return self._decoded[name] if name in self._decoded else self.captured(name).membership + def birth_record(self, name: str) -> ThreadBirthRecord: return self._decoded[name] if name in self._decoded else self.captured(name) @@ -445,11 +460,16 @@ def __contains__(self, name: object) -> bool: def __setitem__(self, name: str, thread: Thread) -> None: if thread.name != name: raise MalformedRecord("thread name differs from its declaration") - if not self._lookup_names or self._lookup_names.get( - thread.incarnation.require_recorded().lookup - ) != name: + if self._lookup_names: + lookup = thread.incarnation.require_recorded().lookup + if self._lookup_names.get(lookup) != name: + names = self._lookup_names.copy() + if name in self: + names.pop(self.incarnation(name).require_recorded().lookup) + names[lookup] = name + self._lookup_names = names + else: self._lookup_names = {} - self._captured = self._captured.copy() self._captured.pop(name, None) self._decoded[name] = self._binding(thread) self._raw.pop(name, None) @@ -457,8 +477,12 @@ def __setitem__(self, name: str, thread: Thread) -> None: def __delitem__(self, name: str) -> None: if name not in self: raise KeyError(name) - self._lookup_names = {} - self._captured = self._captured.copy() + if self._lookup_names: + names = self._lookup_names.copy() + names.pop(self.incarnation(name).require_recorded().lookup) + self._lookup_names = names + else: + self._lookup_names = {} self._captured.pop(name, None) self._raw.pop(name, None) self._decoded.pop(name, None) diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index 4f4da2529e..2095edcf74 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, replace from functools import cached_property, partial from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING, Generic, TypeVar from agent_comms.field_codec import FieldCodec, ThreadLookup @@ -18,7 +19,7 @@ from .messages import PublicSender from .routing import DeliveryScope from .thread_identity import ThreadIncarnation -from .threads import Thread, ThreadProvenance +from .threads import Thread, ThreadMembership, ThreadProvenance if TYPE_CHECKING: from .audience_manifest import FrozenRecipient @@ -178,6 +179,9 @@ def require_canonical(self, name: str) -> str: def require(self, name: str) -> Member: return self.threads[self.require_canonical(name)] + def membership(self, name: str) -> ThreadMembership: + return self.require(name) + @dataclass(frozen=True, slots=True) class SenderBindings: @@ -203,6 +207,45 @@ def require_alias_names(self) -> None: if any(target not in names or alias in names for alias, target in self.aliases.items()): raise RelationViolationError("Provenance alias has no exclusive retained owner") + @cached_property + def memberships(self) -> Mapping[str, ThreadMembership]: + """One immutable tag/identity relation from this namespace, including retained aliases.""" + canonical = {name: self.membership(name) for name in self.threads} + return MappingProxyType({ + **canonical, + **{alias: canonical[owner] for alias, owner in self.aliases.items()}, + }) + + @cached_property + def membership_json(self) -> str: + return FieldCodec.compact(FieldCodec.encode( + dict(self.memberships), dict[str, ThreadMembership], + )) + + def tagged_names(self, tags: frozenset[str]) -> tuple[str, tuple[object, ...]]: + """A relational tag predicate, not an expanded channel-member name list.""" + key = FieldCodec.field_plan(ThreadMembership, "tags").key + return ( + "SELECT member.key FROM json_each(?) AS member " + "WHERE NOT EXISTS (SELECT 1 FROM json_each(?) AS wanted " + "WHERE NOT EXISTS (SELECT 1 FROM json_each(member.value, ?) AS owned " + "WHERE owned.value = wanted.value))", + (self.membership_json, json.dumps(sorted(tags)), f"$.{key}"), + ) + + def same_tag_membership(self, earlier: Mapping[str, ThreadMembership], + tags: frozenset[str]) -> bool: + """Only a matching name or its actual birth changing invalidates that channel.""" + current = self.memberships + for name in current.keys() | earlier.keys(): + before = name in earlier and tags <= earlier[name].tags + after = name in current and tags <= current[name].tags + if before != after: + return False + if before and earlier[name].incarnation != current[name].incarnation: + return False + return True + def bound_to(self, root: Path) -> RegistryProvenance: """Recorded transcripts resolve under the root this namespace was captured from.""" threads = {name: replace(thread, session_file=thread.session_file.bound_to(root)) diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 12b03f5f9e..6ae2fea1fb 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -194,10 +194,21 @@ def configured(self, thread: Thread) -> Thread: @dataclass(frozen=True, kw_only=True) -class ThreadProvenance(ThreadBirthRecord): - """Recorded thread identity, without permission to own a process or start a turn.""" +class ThreadMembership(ThreadBirthRecord): + """The original thread identity and its declared channel tags.""" tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) + + def __post_init__(self) -> None: + super().__post_init__() + for tag in self.tags: + Tag.validate(tag) + + +@dataclass(frozen=True, kw_only=True) +class ThreadProvenance(ThreadMembership): + """Recorded thread identity, without permission to own a process or start a turn.""" + worktree: Checkout = field(kw_only=False) session_file: Annotated[SessionFile, RegistrationPolicy(InheritApplied)] = field( default_factory=NoSessionFile @@ -223,11 +234,6 @@ def writable_runtime(self) -> RuntimeLocation: def display_title(self) -> str: return self.title.otherwise(self.name) - def __post_init__(self) -> None: - super().__post_init__() - for tag in self.tags: - Tag.validate(tag) - @staticmethod def capture(thread: ThreadProvenance) -> ThreadProvenance: """Freeze only determining provenance from the original typed declaration.""" diff --git a/tests/test_bus_activity.py b/tests/test_bus_activity.py index d8687297cd..410e218431 100644 --- a/tests/test_bus_activity.py +++ b/tests/test_bus_activity.py @@ -8,7 +8,7 @@ from agent_comms.mentions import ThreadMention from agent_comms.messages import InfoKind, Message, NoticeRole from agent_comms.read_basis import ( - AllChannelDisplayScope, ChannelDisplayScope, MemberActivityDisplayScope, + AllChannelDisplayScope, ChannelDisplayScope, NamedActivityDisplayScope, ) from agent_comms.thread_identity import UserThreadRole @@ -32,12 +32,12 @@ def test_activity_family_tracks_original_roles_targets_and_member_scope(bus): scopes = ( (ChannelDisplayScope("#team", frozenset({"#team"})), ChannelActivity(30.0, 0.0)), (AllChannelDisplayScope("#any"), ChannelActivity(30.0, 20.0)), - (MemberActivityDisplayScope("#member", participant_names=frozenset({"mentioned"})), + (NamedActivityDisplayScope("#member", names=frozenset({"mentioned"})), ChannelActivity(20.0, 20.0)), - (MemberActivityDisplayScope("#member", participant_names=frozenset({"worker"})), + (NamedActivityDisplayScope("#member", names=frozenset({"worker"})), ChannelActivity(20.0, 20.0)), - (MemberActivityDisplayScope("#member", frozenset({"#team"}), - participant_names=frozenset({"human"})), + (NamedActivityDisplayScope("#member", frozenset({"#team"}), + names=frozenset({"human"})), ChannelActivity(30.0, 20.0)), ) with bus.reading() as captured, captured.observing() as db: From d66befedac1aa9f1e7446039554f401a483e1a8e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 23:28:52 -0400 Subject: [PATCH 38/74] Delete the stale ThreadRows.lookup_names left by the #3632 merge Two definitions in one class: the later uncached copy overrode the cached immutable map at line 432 and called incarnation as a method. One owner remains; the unused ThreadIncarnationRecord import goes with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/agent_comms/registry_document.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index b1e5df5750..f3e040df1e 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -61,7 +61,6 @@ RecordedThreadIncarnation, ThreadBirthRecord, ThreadIncarnation, - ThreadIncarnationRecord, ThreadAuthority, TurnIdentity, ) @@ -511,17 +510,6 @@ def checked_rows(cls, raw: object) -> dict[str, dict[str, JsonValue]]: raise MalformedRecord("thread name differs from its declaration") return dict(raw) - def lookup_names(self) -> dict[ThreadLookup, str]: - """Each row's creation lookup, read from its birth alone like the distinctness check: - resolving one owner never decodes the other threads.""" - return { - (self._decoded[name].incarnation if name in self._decoded - else ThreadIncarnationRecord(name, CreationTime.from_wire( - self._raw[name].get("created_at")).to_wire()).incarnation() - ).require_recorded().lookup: name - for name in self - } - def require_distinct_births(self) -> None: """Rows decode late, so a read of one thread never sees two sharing a creation lookup; a stored row's birth is read alone, without decoding the rest of its thread.""" From b5d760a8f8363231dbe0532ca658fcd6de2cf253 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 00:19:19 -0400 Subject: [PATCH 39/74] Keep channel membership and capability refusal on their declarations --- src/agent_comms/check_root.py | 2 +- src/agent_comms/messages.py | 19 +++-- src/agent_comms/read_basis.py | 9 +-- src/agent_comms/registry_document.py | 3 +- src/agent_comms/registry_provenance.py | 3 +- src/agent_comms/thread_membership.py | 23 ++++++ src/agent_comms/threads.py | 16 +---- tests/test_bus.py | 12 +++- tests/test_bus_activity.py | 6 +- tests/test_display_unread.py | 99 +++++++++++++++++++++++++- tests/test_named_target_contracts.py | 3 +- tests/test_owner_release_store.py | 11 ++- tests/test_sidebar_observation.py | 51 +++++++++++++ 13 files changed, 220 insertions(+), 37 deletions(-) create mode 100644 src/agent_comms/thread_membership.py diff --git a/src/agent_comms/check_root.py b/src/agent_comms/check_root.py index 39b9751480..ffccd6e239 100644 --- a/src/agent_comms/check_root.py +++ b/src/agent_comms/check_root.py @@ -94,7 +94,7 @@ def _populate(self, copied: Comms) -> None: targets = frozenset( target for name in names for target in catalog.targets_for(snapshot.require(name).tags) ) - scope = NamedActivityDisplayScope("#check", targets, names=selected | aliases) + scope = NamedActivityDisplayScope("#check", targets, participants=selected | aliases) with self.source.bus.bus.reading() as source: messages = scope.history(source, limit=self.max_records) copied.registry.store.prepare_native_root() diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 634cf931f7..684cbf1c11 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -12,7 +12,7 @@ import sqlite3 import time from abc import ABC, abstractmethod -from collections.abc import Iterable, Iterator, Mapping, Sequence +from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence from contextlib import closing from dataclasses import Field, dataclass, field, fields, make_dataclass, replace from functools import cache @@ -45,6 +45,7 @@ FieldCodec, InlineRecord, MessageId, + MalformedRecord, NullMember, PublicationExclude, ThreadLookup, @@ -439,16 +440,17 @@ def read_from(cls, db: sqlite3.Connection, seq: int) -> Self: def selected( cls, db: sqlite3.Connection, positions: str, parameters: tuple[object, ...], *, descending: bool = False, - ) -> Iterator[Self]: + ) -> Generator[Self]: """One cursor reads only the capability tables this value inherits.""" tables = cls.tables() + joined: tuple[type[BusTables], ...] = (BusRow, *tables) offsets = dict(zip(tables, accumulate( (len(BusRow.columns()), *(len(table.columns()) for table in tables))))) header = BusRow.qualified("seq") order = "DESC" if descending else "ASC" with closing(db.execute( f"WITH selected AS ({positions}) " - f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in (BusRow, *tables))} " + f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in joined)} " f'FROM "{BusRow.declared_name}" JOIN selected USING(seq) ' + " ".join(f'LEFT JOIN "{table.declared_name}" ON {table.qualified("seq")} = {header}' for table in tables) @@ -456,10 +458,13 @@ def selected( )) as cursor: for stored in cursor: original = BusRow.decode_at(stored, 0) - for table in tables: - if stored[offsets[table]] is None: - raise RelationViolationError(f"Bus row {original.seq} lacks {table.declared_name}") - yield cls.assembled(table.decode_at(stored, offsets[table]) for table in tables) + try: + value = cls.assembled(table.decode_at(stored, offsets[table]) for table in tables) + except MalformedRecord as error: + raise RelationViolationError( + f"Bus row {original.seq} has malformed required {cls.__name__} capability: {error}" + ) from error + yield value @classmethod def assembled(cls, rows: Iterable[CapabilityRow]) -> Self: diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index f9cd68b286..2e6dcd7e54 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -6,6 +6,7 @@ """ from __future__ import annotations + from agent_comms.repo import Checkout from .bus_tables import AwaitingAnswerSource @@ -35,6 +36,7 @@ from .projection_source import ProducedRow, ProjectionSource from .recorded_bus import RecordedBus, RecordedBusIdentity from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation +from .thread_membership import ThreadMembership class DisplayViewer(SubclassUnion, affix="Viewer"): @@ -311,7 +313,6 @@ def flush(self) -> None: from .registration import Registration from .registry_document import RegistrySnapshot, UserThreadLookup from .registry_provenance import RegistryProvenance - from .threads import ThreadMembership from .threads import Thread @@ -1182,14 +1183,14 @@ def _includes(self, sender: str, target: str, mentions: Iterable[str]) -> bool: class NamedActivityDisplayScope(ParticipantActivityDisplayScope): """An explicitly selected set, used by root checks rather than channel tags.""" - names: frozenset[str] = frozenset() + participants: frozenset[str] = frozenset() @property def participant_query(self) -> tuple[str, tuple[object, ...]]: - return "SELECT value FROM json_each(?)", (json.dumps(sorted(self.names)),) + return "SELECT value FROM json_each(?)", (json.dumps(sorted(self.participants)),) def includes_participant(self, name: str) -> bool: - return name in self.names + return name in self.participants @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index f3e040df1e..d369fd0c39 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -72,7 +72,8 @@ ThreadStatus, ) from .thread_text import ForkedParent, NoParent -from .threads import GoalOwner, Thread, ThreadMembership, ThreadWorkState +from .thread_membership import ThreadMembership +from .threads import GoalOwner, Thread, ThreadWorkState from .turn_lease import ( FinishedTurn, FinishedTurnFence, NoFinishedTurn, StoredTurnRecord, TurnCompletion, TurnLeaseFence, TurnRecord, diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index 2095edcf74..40d6497be1 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -19,7 +19,8 @@ from .messages import PublicSender from .routing import DeliveryScope from .thread_identity import ThreadIncarnation -from .threads import Thread, ThreadMembership, ThreadProvenance +from .thread_membership import ThreadMembership +from .threads import Thread, ThreadProvenance if TYPE_CHECKING: from .audience_manifest import FrozenRecipient diff --git a/src/agent_comms/thread_membership.py b/src/agent_comms/thread_membership.py new file mode 100644 index 0000000000..7ecdd0d4e0 --- /dev/null +++ b/src/agent_comms/thread_membership.py @@ -0,0 +1,23 @@ +"""The captured thread identity and its declared channel membership.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Annotated + +from .channel_targets import Tag +from .field_codec import RegistrationPolicy +from .registration_inheritance import InheritEmpty +from .thread_identity import ThreadBirthRecord + + +@dataclass(frozen=True, kw_only=True) +class ThreadMembership(ThreadBirthRecord): + """The original thread identity and its declared channel tags.""" + + tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) + + def __post_init__(self) -> None: + super().__post_init__() + for tag in self.tags: + Tag.validate(tag) diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 05ed87cab9..7d60e59b90 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Annotated, ClassVar from .agent_backend import AgentBackend, NewThreadBackend, RuntimeLocation -from .channel_targets import DeclaredTag, Tag +from .channel_targets import DeclaredTag from .child_process import NoProcess, ProcessIdentity, ProcessState from .cli_result import ThreadToolCatalogResult from .context_segments.wake_values import WakeWorkContext @@ -48,7 +48,6 @@ ) from .registration_inheritance import ( InheritApplied, - InheritEmpty, InheritPrevious, ) from .repo import Checkout @@ -68,6 +67,7 @@ TurnIdentity, UnobservedTools, ) +from .thread_membership import ThreadMembership from .thread_model import ( CatalogSelection, DeclaredModel, @@ -193,18 +193,6 @@ def configured(self, thread: Thread) -> Thread: return self.service_tier.configured(self.model.configured(thought)) -@dataclass(frozen=True, kw_only=True) -class ThreadMembership(ThreadBirthRecord): - """The original thread identity and its declared channel tags.""" - - tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) - - def __post_init__(self) -> None: - super().__post_init__() - for tag in self.tags: - Tag.validate(tag) - - @dataclass(frozen=True, kw_only=True) class ThreadProvenance(ThreadMembership): """Recorded thread identity, without permission to own a process or start a turn.""" diff --git a/tests/test_bus.py b/tests/test_bus.py index c33c1b7855..7dca38c925 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -55,7 +55,7 @@ RelationViolationError, UnclaimedError, ) -from agent_comms.field_codec import FieldCodec, GitCommitText, MessageId, MessageIdText, TimestampText +from agent_comms.field_codec import FieldCodec, GitCommitText, MalformedRecord, MessageId, MessageIdText, TimestampText from agent_comms.message_reference import MessageReference from agent_comms.read_basis import RepliesDisplayScope from agent_comms.response_acknowledgement import ( @@ -733,6 +733,16 @@ def build(seq, db): assert all(table.count(db) == 0 for table in TypedTable.members_with(BusTables)) +def test_required_capability_corruption_is_not_hidden_by_the_join(bus): + with bus.writing() as db: + # A retained header without its required capability rows is corruption, + # not an empty page or an absent optional field. + BusRow(1, PublicRecord, 1.0).insert(db) + with pytest.raises(RelationViolationError, match="malformed required Message capability") as refused: + Message.read_from(db, 1) + assert isinstance(refused.value.__cause__, MalformedRecord) + + def test_bulk_history_keeps_one_snapshot_across_pages(bus): assert tuple(bus.message_history()) == () originals = tuple(Message.from_message(bus.append(lambda seq, db: PublicRecord.from_message( diff --git a/tests/test_bus_activity.py b/tests/test_bus_activity.py index 410e218431..8b27186110 100644 --- a/tests/test_bus_activity.py +++ b/tests/test_bus_activity.py @@ -32,12 +32,12 @@ def test_activity_family_tracks_original_roles_targets_and_member_scope(bus): scopes = ( (ChannelDisplayScope("#team", frozenset({"#team"})), ChannelActivity(30.0, 0.0)), (AllChannelDisplayScope("#any"), ChannelActivity(30.0, 20.0)), - (NamedActivityDisplayScope("#member", names=frozenset({"mentioned"})), + (NamedActivityDisplayScope("#member", participants=frozenset({"mentioned"})), ChannelActivity(20.0, 20.0)), - (NamedActivityDisplayScope("#member", names=frozenset({"worker"})), + (NamedActivityDisplayScope("#member", participants=frozenset({"worker"})), ChannelActivity(20.0, 20.0)), (NamedActivityDisplayScope("#member", frozenset({"#team"}), - names=frozenset({"human"})), + participants=frozenset({"human"})), ChannelActivity(30.0, 20.0)), ) with bus.reading() as captured, captured.observing() as db: diff --git a/tests/test_display_unread.py b/tests/test_display_unread.py index 1d47bb85dd..e20ffec9d8 100644 --- a/tests/test_display_unread.py +++ b/tests/test_display_unread.py @@ -1,8 +1,10 @@ from dataclasses import replace +import pytest + from agent_comms.audience_manifest import FrozenRecipient from agent_comms.bus import Bus, DisplayUnread -from agent_comms.field_codec import ThreadLookup +from agent_comms.field_codec import FieldCodec, MalformedRecord, ThreadLookup from agent_comms.messages import InfoKind, Message, PublicRecord from agent_comms.notification_progress import UnrecordedProgress from agent_comms.presentation import MessageNotification @@ -11,9 +13,15 @@ ChannelDisplayScope, DisplayedConversation, DisplayUnreadSelection, + MemberActivityDisplayScope, ReadCursor, ) +from agent_comms.registry_document import ThreadRows from agent_comms.registry_provenance import RegistryProvenance +from agent_comms.repo import Checkout +from agent_comms.thread_status import RunningThreadStatus +from agent_comms.thread_text import GivenThreadText +from agent_comms.threads import Thread from agent_comms.thread_presentation import ( ThreadPresentation, WorkingThreadPresentationStatus, @@ -120,6 +128,95 @@ class RevisedRegistry(RegistryProvenance): assert fourth == source.display_unread(reread, through=source.revision()) +def test_tagged_unread_reuses_unchanged_membership_and_recounts_actual_tag_changes(comms, bus): + member = comms.registry.declare( + Thread("member", frozenset({"team"}), Checkout(comms.root.parent)), + status=RunningThreadStatus(), + ) + other = comms.registry.declare(Thread("other", frozenset(), Checkout(comms.root.parent))) + channel = comms.bus.require_channel("#team") + for sender, target in ((member.name, other.name), (other.name, member.name)): + bus.append(lambda seq, db, sender=sender, target=target: PublicRecord.from_message( + Message(sender, target, "member activity", InfoKind, seq=seq))) + + def selection(snapshot): + return DisplayUnreadSelection( + ReadCursor(), (MemberActivityDisplayScope.capture(channel, snapshot),), + ("viewer",), snapshot, + ) + + original = selection(comms.registry.snapshot()) + with bus.reading() as source: + first = DisplayUnread().advanced(source, original) + assert first.counts == {"#team": 2} + comms.registry.update_thread( + other.name, lambda thread: replace(thread, task=GivenThreadText("unrelated edit")), + ) + revised = selection(comms.registry.snapshot()) + assert revised.queries() == original.queries() + assert revised.queries()[0].same_membership(original.queries()[0]) + statements = [] + with bus.reading() as source, source.observing() as db: + changes = db.total_changes + db.set_trace_callback(statements.append) + assert first.advanced(source, revised).counts == first.counts + assert not any(query.startswith("WITH read_cursors") for query in statements) + assert db.total_changes == changes + bus.append(lambda seq, db: PublicRecord.from_message( + Message(other.name, member.name, "appended activity", InfoKind, seq=seq))) + statements.clear() + with bus.reading() as source, source.observing() as db: + db.set_trace_callback(statements.append) + appended = first.advanced(source, revised) + counted = [query for query in statements if query.startswith("WITH read_cursors")] + assert counted and all(f"seq > {first.through}" in query for query in counted) + assert appended.counts == {"#team": 3} + assert appended == source.display_unread(revised, through=source.revision()) + comms.registry.update_thread(member.name, lambda thread: replace(thread, tags=frozenset())) + retagged = selection(comms.registry.snapshot()) + assert not retagged.queries()[0].same_membership(revised.queries()[0]) + with bus.reading() as source: + recounted = appended.advanced(source, retagged) + assert recounted.counts == {"#team": 0} + assert recounted == source.display_unread(retagged, through=source.revision()) + + +def test_tag_membership_keeps_alias_births_and_defers_unselected_full_fields(comms): + member = comms.registry.declare( + Thread("member", frozenset({"team"}), Checkout(comms.root.parent)), + status=RunningThreadStatus(), + ) + unrelated = comms.registry.declare(Thread("other", frozenset(), Checkout(comms.root.parent))) + channel = comms.bus.require_channel("#team") + before = comms.registry.snapshot() + original = MemberActivityDisplayScope.capture(channel, before) + comms.registry.rename(member.name, "renamed") + renamed = comms.registry.snapshot() + raw = renamed.threads.to_wire() + raw[unrelated.name][FieldCodec.field_plan(Thread, "model").key] = [] + narrow = replace(renamed, threads=ThreadRows.from_wire(raw)) + scope = MemberActivityDisplayScope.capture(channel, narrow) + assert scope.includes_participant(member.name) + assert scope.includes_participant("renamed") + assert scope.memberships[member.name].incarnation == member.incarnation + assert scope.memberships["renamed"].incarnation == member.incarnation + assert not scope.includes_participant(unrelated.name) + assert not narrow.threads._decoded + assert original.includes_participant(member.name) + comms.registry.unregister("renamed") + comms.threads.delete("renamed") + replacement = comms.registry.declare( + Thread(member.name, frozenset({"team"}), Checkout(comms.root.parent)), + ) + recreated = MemberActivityDisplayScope.capture(channel, comms.registry.snapshot()) + assert replacement.incarnation != member.incarnation + assert not recreated.same_membership(original) + raw["renamed"][FieldCodec.field_plan(Thread, "tags").key] = 7 + malformed = replace(renamed, threads=ThreadRows.from_wire(raw)) + with pytest.raises(MalformedRecord): + MemberActivityDisplayScope.capture(channel, malformed).memberships + + def test_status_row_equality_ignores_an_older_notification_outside_shown_lines(): lookup = ThreadLookup.mint() older = MessageNotification( diff --git a/tests/test_named_target_contracts.py b/tests/test_named_target_contracts.py index b3599cd9f5..004e87c76c 100644 --- a/tests/test_named_target_contracts.py +++ b/tests/test_named_target_contracts.py @@ -9,6 +9,7 @@ from agent_comms.coordination_errors import DMScopeChanged from agent_comms.historical_views import ConversationObservation, DMDisplayHistory from agent_comms.message_page import ConversationMessagePageRequest +from agent_comms.messages import Message from agent_comms.read_basis import NamedViewer from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.thread_identity import BirthlessIncarnation @@ -82,7 +83,7 @@ def test_retained_dm_target_follows_rename_through_send_page_and_revision(tmp_pa assert [(row.canonical_thread, row.recipient_lookup) for row in delivery.recipients] == [ ("renamed", target.incarnation.require_recorded().lookup)] page = comms.views.dm_display_page(target, worktree=worktree) - assert page.messages == (original, sent) + assert page.messages == (Message.from_message(original), Message.from_message(sent)) assert page.selection.basis.peer_identity == target.incarnation generic_page = comms.views.history_page(target, viewer=NamedViewer(sent.sender)) assert generic_page.messages == page.messages diff --git a/tests/test_owner_release_store.py b/tests/test_owner_release_store.py index 8115c37640..46577bf310 100644 --- a/tests/test_owner_release_store.py +++ b/tests/test_owner_release_store.py @@ -13,14 +13,19 @@ def test_release_survives_metadata_changes_and_reads_only_its_row(tmp_path): comms = wire(tmp_path) originals = [comms.registry.declare(Thread(name, frozenset(), Checkout(tmp_path))) for name in ('worker', 'other')] - generation = comms.registry.snapshot().claim_generation('worker') + before = comms.registry.snapshot() + selected = tuple((thread, before.claim_generation(thread.name)) for thread in originals) + generation = before.claim_generation('worker') + # This store contract needs a stopped cut, not physical process retirement. + # NoProcess stop correctly produces no release; record the explicit fixture here. for thread in originals: - comms.owners.stop(thread.name) + comms.registry.unregister(thread.name) snapshot = comms.registry.snapshot() + releases = comms.owners.releases + releases.record(snapshot, selected) current = snapshot.threads['worker'] comms.registry.register(replace(current, title=GivenThreadText('changed title'), task=GivenThreadText('changed task')), status=snapshot.statuses['worker']) - releases = comms.owners.releases # An invalid unrelated row must not even be decoded by this lookup. with releases.writing() as db: OwnerReleaseEvidence.update(db, where='name=?', parameters=('other',), after=0) diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index b312d066ad..ddf2d04edd 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -205,6 +205,57 @@ def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( comms.registry.require_lookup(missing) +@pytest.mark.parametrize("change", ("add", "delete", "rebirth")) +@pytest.mark.parametrize("new_first", (False, True)) +def test_lookup_membership_stays_with_empty_and_unprimed_cuts( + comms, declare_reference, change, new_first, +): + original = declare_reference() + template = comms.registry.snapshot() + rows = ThreadRows.from_wire( + {} if change == "add" else {original.name: FieldCodec.encode_fields(original)} + ) + before = replace(template, threads=rows) + # Even an obtained empty view must remain empty after the shared rows change. + empty_view = before.lookup_names if change == "add" else None + if change == "delete": + del rows[original.name] + expected_after = () + else: + replacement = original + if change == "rebirth": + comms.threads.delete(original.name) + replacement = declare_reference(original.name) + assert replacement.incarnation != original.incarnation + rows[replacement.name] = replacement + expected_after = (replacement,) + after = replace(template, threads=rows) + expected_before = () if change == "add" else (original,) + cuts = ((before, expected_before), (after, expected_after)) + candidates = {original.incarnation.lookup, ThreadLookup.mint()} + candidates.update(thread.incarnation.lookup for thread in expected_after) + for snapshot, expected in reversed(cuts) if new_first else cuts: + lookup = snapshot.lookup_names + names = {thread.incarnation.lookup: thread.name for thread in expected} + assert tuple(lookup) == tuple(names) + assert tuple(lookup.keys()) == tuple(names) + assert len(lookup) == len(snapshot.threads) == len(names) + assert dict(lookup) == names + for key in candidates: + assert (key in lookup) == (key in names) + assert lookup.get(key) == names.get(key) + if key in names: + assert lookup[key] == names[key] + assert snapshot.born(key) is snapshot.threads[names[key]] + else: + with pytest.raises(KeyError): + lookup[key] + with pytest.raises(UnregisteredThreadError): + snapshot.born(key) + if empty_view is not None: + assert not empty_view and not tuple(empty_view) + + @pytest.mark.parametrize("decoded", (False, True)) def test_current_reference_acquires_only_captured_incarnations(comms, declare_reference, decoded): original = declare_reference(status=RunningThreadStatus()) From e1ee783f1a2354563ea6cb8806d49c877c37b439 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 01:34:09 -0400 Subject: [PATCH 40/74] Preserve scoped copy records and mutate actual producer bodies --- src/agent_comms/check_root.py | 8 ++++---- src/agent_comms/read_basis.py | 21 ++++++++++++--------- tests/test_bus.py | 23 +++++++++++++++++------ 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/agent_comms/check_root.py b/src/agent_comms/check_root.py index ffccd6e239..a9ce91a426 100644 --- a/src/agent_comms/check_root.py +++ b/src/agent_comms/check_root.py @@ -96,9 +96,9 @@ def _populate(self, copied: Comms) -> None: ) scope = NamedActivityDisplayScope("#check", targets, participants=selected | aliases) with self.source.bus.bus.reading() as source: - messages = scope.history(source, limit=self.max_records) + history = scope.history(source, limit=self.max_records) copied.registry.store.prepare_native_root() - Bus.install(Bus.path_in(copied.root), history=messages) + Bus.install(Bus.path_in(copied.root), history=history) copied.channels.catalog.replace(catalog) declarations = {} for name in names: @@ -131,7 +131,7 @@ def _populate(self, copied: Comms) -> None: edit.document.advance_claim(name) edit.document.owners.advance(name) edit.commit() - if messages: + if history: # Imported history is displayable, not fresh input for this root. # Position acceptance itself; human read markers own a different fact. privacy = copied.registry.store.privacy() @@ -141,7 +141,7 @@ def _populate(self, copied: Comms) -> None: AcceptanceFrontier( wire_root_id=privacy.namespace, recipient_lookup=thread.incarnation.require_recorded().lookup, - through_seq=messages[-1].seq, + through_seq=history[-1].seq, ).advance(db) def _session(self, original: Thread, root: Path, slot: str, project: Path) -> str: diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index a354039146..8c43421d48 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -307,7 +307,7 @@ def flush(self) -> None: from .history_views import HistoryViews from .message_bus import MessageBus from .message_page import MessagePage, PageBounds - from .messages import Message, MessageKind + from .messages import BusRecord, Message, MessageKind from .presentation import BusPresentation from .read_ledger import ReadDocument, ReadLedger from .registration import Registration @@ -877,20 +877,23 @@ def records( return source.scoped_page(predicate, parameters, lower=lower, upper=upper, descending=descending, limit=limit, kinds=kinds) - def history(self, source: BusReader, *, limit: int) -> tuple[Message, ...]: - """A bounded recent selection closed over its answers, without crossing this scope. + def history(self, source: BusReader, *, limit: int) -> tuple[BusRecord, ...]: + """Full copy records selected by this scope, closed over their disclosed answers. - A copied message keeps only the answers this scope discloses: a reference to a message - outside it would name that message, and its original stays on the source bus.""" - selected: dict[int, Message] = {} + Display pages stay lightweight. A copy acquires each original record on the held + reader so its declared history_record retains deliveries and removes claims. + References outside this scope stay on the source bus. + """ + selected: dict[int, BusRecord] = {} with source.reading() as reader: for message in self.records(reader, descending=True, limit=limit): - added: dict[int, Message] = {} + added: dict[int, BusRecord] = {} pending = [message] while pending and len(selected) + len(added) <= limit: - answer = pending.pop() - if answer.seq in selected or answer.seq in added: + candidate = pending.pop() + if candidate.seq in selected or candidate.seq in added: continue + answer = reader.lookup(candidate.seq) disclosed = tuple(original for original in reader.messages_for_references(answer.answers) if self.includes(original)) diff --git a/tests/test_bus.py b/tests/test_bus.py index 0ce9acced7..1491acc19a 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -124,8 +124,8 @@ def edit_inside( module = ast.parse(path.read_text()) declaration, = (node for node in module.body if isinstance(node, ast.ClassDef) and node.name == owner.__name__) - statement, = (node for node in declaration.body - if member in ProducedTable.bound_names(ast.unparse(node))) + statement = next(node for node in reversed(declaration.body) + if member in ProducedTable.bound_names(ast.unparse(node))) change(statement) path.write_text(ast.unparse(ast.fix_missing_locations(module)) + "\n") @@ -135,6 +135,11 @@ def return_no_rows(statement: ast.stmt) -> None: statement.body = [ast.Return(value=ast.Tuple(elts=[], ctx=ast.Load()))] +def return_inherited_routes(statement: ast.stmt) -> None: + assert isinstance(statement, ast.FunctionDef) + statement.body = ast.parse("return super().route_rows(message)").body + + def widen_spelling(statement: ast.stmt) -> None: assert isinstance(statement, ast.Assign) statement.value = ast.BinOp(left=statement.value, op=ast.Add(), right=ast.Constant(1)) @@ -247,12 +252,15 @@ def test_projection_digest_tracks_actual_delivery_production(projection_source: assert after[CommanderUpdatePosition.declared_name] == before[CommanderUpdatePosition.declared_name] -@pytest.mark.parametrize("owner", (MessageRoutes, ClaimNoticeKind)) +@pytest.mark.parametrize("owner, change", ( + (MessageRoutes, return_no_rows), + (ClaimNoticeKind, return_inherited_routes), +)) def test_projection_digest_tracks_inherited_routing( - projection_source: Path, owner: type, + projection_source: Path, owner: type, change: Callable[[ast.stmt], None], ) -> None: before = json.loads(projection_source_digest(projection_source)) - edit_inside(projection_source, owner, "route_rows", return_no_rows) + edit_inside(projection_source, owner, "route_rows", change) after = json.loads(projection_source_digest(projection_source)) assert after[BusRouteCounts.declared_name] != before[BusRouteCounts.declared_name] assert after[AnsweredSource.declared_name] == before[AnsweredSource.declared_name] @@ -451,8 +459,11 @@ def test_record_family_rebuild_and_history_install_preserve_scope(bus, declarati assert bus.lookup_message(record.message_id) == record assert bus.page(record.seq + 1, 10) == (record, original) scope = RepliesDisplayScope(frozenset({"author"}), frozenset({"worker"})) + history = scope.history(bus, limit=2) + assert history == (original, record) + assert tuple(type(item) for item in history) == (type(original), declaration) copied = Bus.install( - Bus.path_in(tmp_path / "selected-history"), history=scope.history(bus, limit=2), + Bus.path_in(tmp_path / "selected-history"), history=history, ) kept = copied.lookup(record.seq) assert kept == record.history_record From 5adaa973e2015b02e7441d590d731279b34306df Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 02:28:26 -0400 Subject: [PATCH 41/74] Let native member selection own omitted editor arguments --- src/agent_comms/field_codec.py | 12 +++++++++++- src/agent_comms/target_fields.py | 4 +--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index a852d7e989..fb63a9dbb7 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -966,7 +966,7 @@ def editor_member(self, declared: DecoderPlan) -> EditorMember: return ScalarEditorMember(declared) def selected_editor_member(self, value: object) -> EditorMember: - raise TypeError(f'{self.target} does not declare a union editor') + return self.editor_member(self) def project_editor(self, native: object, wire: Callable[[], object]) -> object: return self.format_editor(wire()) @@ -1030,6 +1030,11 @@ 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: ... @@ -1044,6 +1049,11 @@ def draft[Draft](self, factory: MemberDraftFactory[Draft], value: object) -> Dra @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) diff --git a/src/agent_comms/target_fields.py b/src/agent_comms/target_fields.py index 48192859a6..64cbb07ee4 100644 --- a/src/agent_comms/target_fields.py +++ b/src/agent_comms/target_fields.py @@ -146,10 +146,8 @@ def editor_selection(self, value: object) -> field_codec.MemberSelection: def _unchosen_selection(self, value: None) -> field_codec.MemberSelection: return field_codec.UnchosenMember() - @boundary_decoder def argument(self, value: object) -> Mapping[str, object]: - """Decode the optional control's raw presence once, not the field's wire value.""" - return {} if value is None else super().argument(value) + return self.editor_selection(value).argument(self.name, value, self.editor_argument) @dataclass(frozen=True) From 1f3a4c3f13491c3c5d54321b408c7a18830e970c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 03:18:35 -0400 Subject: [PATCH 42/74] Hold one display selection and bus cut across history pages --- src/agent_comms/bus.py | 4 ++- src/agent_comms/historical_views.py | 55 ++++++++++++++++------------- src/agent_comms/history_views.py | 19 +++++++++- src/agent_comms/locked_store.py | 17 +++++---- src/agent_comms/presentation.py | 49 +++++++++++++++++++++++-- src/agent_comms/read_basis.py | 24 +++++++++---- src/agent_comms/store_files.py | 9 ++--- src/agent_comms/threads.py | 4 +-- 8 files changed, 134 insertions(+), 47 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index 5dd4d4a3ac..4063763c86 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -508,7 +508,9 @@ def path(self) -> Path: @contextmanager def observing(self) -> Iterator[sqlite3.Connection]: - with SQLiteFile.opened_reader(self.path) as db: + # A captured display cut moves between the preparation owner's workers. + # Its presentation serializes page use and retirement; it remains read-only. + with SQLiteFile.opened_reader(self.path, check_same_thread=False) as db: yield db diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index 19e2e8a795..5d775ac178 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -76,12 +76,23 @@ def viewer(self) -> DisplayViewer: def live_page(self, presentation: BusPresentation, bounds: PageBounds) -> MessagePage: """Bind the query to the bus owner's snapshot and read that exact source.""" - with presentation.bus.snapshot() as (basis, source): + with presentation.snapshot(self.viewer) as (basis, source): request = self.page_request(self.capture(basis.registry), bounds) return request.read_opened(source) + @contextmanager + def reading(self, presentation: BusPresentation) -> Iterator[Callable[[PageBounds], MessagePage]]: + """Borrow one cut for the first tail, subsequent pages and scroll-back. + + The caller retains this lifetime only while its conversation revision is + unchanged, and retires it off the UI thread when that source is replaced. + """ + with presentation.reading(self.viewer) as captured: + yield partial(self.page, captured) + def page(self, presentation: BusPresentation, bounds: PageBounds = NEWEST_PAGE) -> MessagePage: - return presentation.bus.history.integrated_page(self, presentation, bounds) + with presentation.reading(self.viewer) as captured: + return captured.bus.history.integrated_page(self, captured, bounds) @abstractmethod def capture(self, snapshot: RegistryProvenance) -> MessageDisplayScope: @@ -238,19 +249,16 @@ def viewer(self) -> NamedViewer: def live_page(self, presentation: BusPresentation, bounds: PageBounds) -> MessagePage: bus = presentation.bus - snapshot = presentation.registry.snapshot() - peer = self.second.selected_name(snapshot) - missing = peer not in snapshot.threads - if missing and bus.history.threads(peer): - return MessagePage((), False, False, NoDisplaySelection()) - return DMDisplayBasis.fetch_page( - bus, - presentation.registry, - bus.bus.path.parent, - snapshot.require(self.first), - self.second, - worktree=self.worktree, bounds=bounds, - ) + with presentation.snapshot(self.viewer) as (basis, source): + snapshot = basis.registry + peer = self.second.selected_name(snapshot) + missing = peer not in snapshot.threads + if missing and bus.history.threads(peer): + return MessagePage((), False, False, NoDisplaySelection()) + return DMDisplayBasis.fetch_captured( + bus, presentation.registry, bus.bus.path.parent, + self.second, basis, source, worktree=self.worktree, bounds=bounds, + ) @dataclass(frozen=True, slots=True) @@ -536,15 +544,14 @@ def page(self, view: HistoryView, presentation: BusPresentation, bounds: PageBou snapshot = source.provenance current = replace(bounds, traversal=traversal.for_source(index == start)) request = view.page_request(view.capture(snapshot), current) - current_snapshot = presentation.registry.snapshot() - document = presentation.bus.reads.read() - opened = Bus(Bus.path_in(Path(source.root))) - raw = request.read_opened(opened) - projected = replace(raw, messages=tuple( - HistoricalMessage.project(message, source, index, snapshot) - for message in raw.messages)) - page = replace(projected, selection=view.viewer.historical_selection( - projected, presentation, source, opened, document, current_snapshot)) + with presentation.snapshot(view.viewer) as (basis, _): + opened = Bus(Bus.path_in(Path(source.root))) + raw = request.read_opened(opened) + projected = replace(raw, messages=tuple( + HistoricalMessage.project(message, source, index, snapshot) + for message in raw.messages)) + page = replace(projected, selection=view.viewer.historical_selection( + projected, presentation, source, opened, basis.document, basis.registry)) if page.messages: return replace( page, diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index e5c1c1571e..2ccc0d0db9 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -8,7 +8,8 @@ import logging import time from abc import abstractmethod -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence +from contextlib import contextmanager from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, Annotated @@ -250,6 +251,22 @@ def dm_display_page( viewer = self.messaging.user_identity(worktree) return DMDisplayHistory(viewer.name, peer, worktree).page(self.presentation, bounds) + @contextmanager + def dm_display_reading( + self, peer: ThreadTarget, *, worktree: Checkout, + ) -> Iterator[Callable[[PageBounds], MessagePage]]: + viewer = self.messaging.user_identity(worktree) + with DMDisplayHistory(viewer.name, peer, worktree).reading(self.presentation) as pages: + yield pages + + @contextmanager + def channel_display_reading( + self, target: str, *, viewer: DisplayViewer = AnonymousViewer(), + ) -> Iterator[Callable[[PageBounds], MessagePage]]: + channel = self.bus.require_channel(target) + with ChannelDisplayHistory(channel, viewer).reading(self.presentation) as pages: + yield pages + def display_viewer(self, worktree: Checkout) -> NamedViewer: """Select the human reader; page capture resolves it in its own snapshot.""" return NamedViewer(self.messaging.user_identity(worktree).name) diff --git a/src/agent_comms/locked_store.py b/src/agent_comms/locked_store.py index 2713c674f1..55dd843182 100644 --- a/src/agent_comms/locked_store.py +++ b/src/agent_comms/locked_store.py @@ -785,10 +785,11 @@ def _configure_reader(cls, db: sqlite3.Connection) -> sqlite3.Connection: @classmethod def _open_reader( - cls, path: Path, contention: StoreContention + cls, path: Path, contention: StoreContention, *, check_same_thread: bool = True, ) -> sqlite3.Connection: return cls._configure_reader( - contention.reader(path.resolve().as_uri() + "?mode=ro") + contention.reader(path.resolve().as_uri() + "?mode=ro", + check_same_thread=check_same_thread) ) @classmethod @@ -802,10 +803,12 @@ def _acquire_snapshot(cls, db: sqlite3.Connection) -> sqlite3.Connection: @classmethod def read_snapshot( - cls, path: Path, contention: StoreContention = DEFAULT_STORE_CONTENTION + cls, path: Path, contention: StoreContention = DEFAULT_STORE_CONTENTION, + *, check_same_thread: bool = True, ) -> sqlite3.Connection: """Open and acquire one consistent read-only snapshot with SQLite's busy handler.""" - return cls._acquire_snapshot(cls._open_reader(path, contention)) + return cls._acquire_snapshot(cls._open_reader( + path, contention, check_same_thread=check_same_thread)) @classmethod def recovering_snapshot(cls, path: Path) -> sqlite3.Connection: @@ -816,12 +819,14 @@ def recovering_snapshot(cls, path: Path) -> sqlite3.Connection: @classmethod @contextmanager def opened_reader( - cls, path: Path, contention: StoreContention = DEFAULT_STORE_CONTENTION + cls, path: Path, contention: StoreContention = DEFAULT_STORE_CONTENTION, + *, check_same_thread: bool = True, ) -> Iterator[sqlite3.Connection]: """Read existing committed data without initializing storage or permitting writes.""" with CoordinationReadUnavailable.checking(): try: - with closing(cls.read_snapshot(path, contention)) as db: + with closing(cls.read_snapshot(path, contention, + check_same_thread=check_same_thread)) as db: try: yield db finally: diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 95969433db..94bbfced98 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -5,10 +5,11 @@ from abc import ABC, abstractmethod from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field, replace from datetime import datetime from pathlib import Path +from threading import RLock from typing import TYPE_CHECKING, Annotated, ClassVar from . import relationships @@ -529,11 +530,27 @@ def __init__( self._display_unread = DisplayUnread() @contextmanager - def snapshot(self, viewer: NamedViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + def snapshot(self, viewer: DisplayViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: """Borrow the bus-owned source cut without preparing a second store.""" with self.bus.snapshot(viewer) as selected: yield selected + @contextmanager + def reading(self, viewer: DisplayViewer) -> Iterator[CapturedBusPresentation]: + """One page-source lifetime owns the SQLite cut and its selection/read document. + + The preparation owner may move a read between workers. A captured presentation + serializes page use and retirement, without keeping any store exclusion held. + """ + with ExitStack() as lifetime: + basis, source = lifetime.enter_context(self.snapshot(viewer)) + captured = CapturedBusPresentation(self, basis, source) + try: + yield captured + finally: + with captured.use: + lifetime.close() + def roster( self, viewer: str, visibility: RosterVisibility = DEFAULT_ROSTER, ) -> CoordinationSnapshot: @@ -562,7 +579,7 @@ def channel_page( ) -> MessagePage: """Local display projection; underlying channel history remains target-owned.""" channel = CommandTarget.named(target).require_channel() - with self.bus.snapshot(viewer) as (basis, source): + with self.snapshot(viewer) as (basis, source): return basis.viewer.read_page(basis.scope(channel.name), bounds, basis, source) def mark_channel_read( @@ -592,6 +609,32 @@ def display_view_metrics( return activity, counted.counts +class CapturedBusPresentation(BusPresentation): + """The existing display API borrowing one original cut, never a second store. + + Its caller closes the reading when the conversation source is replaced or retired. + Read acknowledgements still go through the live ledger and identity validation. + """ + + def __init__(self, original: BusPresentation, basis: CapturedSelection, source: BusSnapshot): + super().__init__(original.bus, original.registry, original.catalog, + original.agents, original.goals) + self.basis, self.source = basis, source + self.use = RLock() + + @contextmanager + def snapshot(self, viewer: DisplayViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + with self.use: + if viewer.resolve(self.basis.registry) != self.basis.viewer: + raise ValueError("A captured display cut belongs to its original viewer") + yield self.basis, self.source + + @contextmanager + def reading(self, viewer: DisplayViewer) -> Iterator[CapturedBusPresentation]: + with self.snapshot(viewer): + yield self + + @dataclass(frozen=True, slots=True) class ChannelView(ToolResult): """Wire form: the channel's own fields beside its roster; agent counts stay in process.""" diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 8c43421d48..9d9dd4d1bd 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -10,7 +10,7 @@ from agent_comms.repo import Checkout from .bus_tables import AwaitingAnswerSource -from .messages import MessageInput +from .messages import BusRecord, MessageInput from .class_fact import ClassFact import asyncio @@ -307,7 +307,7 @@ def flush(self) -> None: from .history_views import HistoryViews from .message_bus import MessageBus from .message_page import MessagePage, PageBounds - from .messages import BusRecord, Message, MessageKind + from .messages import Message, MessageKind from .presentation import BusPresentation from .read_ledger import ReadDocument, ReadLedger from .registration import Registration @@ -730,16 +730,28 @@ def fetch_page( Fetching is not paint proof. A UI may use the basis only after proving that the corresponding inbound tail was contiguous and visibly painted. """ - snapshot = registry.snapshot() - viewer_name = snapshot.canonical_name(viewer.name) + with bus.snapshot(NamedViewer(viewer.name)) as (selection, source): + return cls.fetch_captured( + bus, registry, root, peer, selection, source, + worktree=worktree, bounds=bounds, + ) + + @classmethod + def fetch_captured( + cls, bus: MessageBus, registry: Registration, root: Path, + peer: ThreadTarget, selection: CapturedSelection, source: BusSnapshot, + *, worktree: Checkout, bounds: PageBounds, + ) -> MessagePage: + """Build each DM page from the page source's original identity/read cut.""" + snapshot = selection.registry + viewer_name = selection.require_viewer() peer_name = peer.selected_name(snapshot) scope = DMDisplayScope.capture_human(viewer_name, peer_name, snapshot) viewer_thread = snapshot.threads[viewer_name] peer_thread = snapshot.participant(peer_name) viewer_names, peer_names = scope.first_names, scope.second_names - document = bus.reads.read_conversation(viewer_name, scope.read_key(viewer_name, peer_name, snapshot), snapshot) result = bounds.conversation(scope).displayed( - bus.reads, document, viewer_name, snapshot, snapshot, bus.bus, + selection.reads, selection.document, viewer_name, snapshot, snapshot, source, ) page, displayed = result.page, result.displayed root_info = root.stat() diff --git a/src/agent_comms/store_files.py b/src/agent_comms/store_files.py index 36627b072b..777cff83b0 100644 --- a/src/agent_comms/store_files.py +++ b/src/agent_comms/store_files.py @@ -454,8 +454,9 @@ class StoreContention(ABC): @abstractmethod def timeout(self) -> float: ... - def reader(self, uri: str) -> sqlite3.Connection: - return sqlite3.connect(uri, uri=True, isolation_level=None, timeout=self.timeout) + def reader(self, uri: str, *, check_same_thread: bool = True) -> sqlite3.Connection: + return sqlite3.connect(uri, uri=True, isolation_level=None, timeout=self.timeout, + check_same_thread=check_same_thread) def writer( self, path: str | os.PathLike[str], *, @@ -479,9 +480,9 @@ class StoreBusy(StoreContention): timeout: ClassVar[float] = 0.0 - def reader(self, uri: str) -> sqlite3.Connection: + def reader(self, uri: str, *, check_same_thread: bool = True) -> sqlite3.Connection: with CoordinationReadUnavailable.checking(): - return super().reader(uri) + return super().reader(uri, check_same_thread=check_same_thread) DEFAULT_STORE_CONTENTION = StoreWait() diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 71cade6b79..fe1441fcc5 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Annotated, ClassVar from .agent_backend import AgentBackend, NewThreadBackend, RuntimeLocation -from .channel_targets import CommandersTag, DeclaredTag, Tag +from .channel_targets import CommandersTag, DeclaredTag from .child_process import NoProcess, ProcessIdentity, ProcessState from .cli_result import ThreadToolCatalogResult from .context_segments.wake_values import WakeWorkContext @@ -60,8 +60,8 @@ AgentThreadRole, ObservedTurn, OfferedTools, - ThreadIncarnation, ThreadBirthRecord, + ThreadIncarnation, ThreadRole, TurnFence, TurnIdentity, From 9ef21625b8ab737173983e8b32c2f85ff53c835b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 03:27:01 -0400 Subject: [PATCH 43/74] Keep captured reader retirement inside its page-use owner --- src/agent_comms/presentation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 94bbfced98..4949b73ada 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -548,7 +548,7 @@ def reading(self, viewer: DisplayViewer) -> Iterator[CapturedBusPresentation]: try: yield captured finally: - with captured.use: + with captured.reading(viewer): lifetime.close() def roster( @@ -620,11 +620,11 @@ def __init__(self, original: BusPresentation, basis: CapturedSelection, source: super().__init__(original.bus, original.registry, original.catalog, original.agents, original.goals) self.basis, self.source = basis, source - self.use = RLock() + self._use = RLock() @contextmanager def snapshot(self, viewer: DisplayViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: - with self.use: + with self._use: if viewer.resolve(self.basis.registry) != self.basis.viewer: raise ValueError("A captured display cut belongs to its original viewer") yield self.basis, self.source From 44bbc97e218f178ab6c01f5523500dafb6cf5ba5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 05:45:33 -0400 Subject: [PATCH 44/74] Give each captured thread row its own lazy decoded value --- src/agent_comms/bus.py | 8 +- src/agent_comms/channel_targets.py | 4 +- src/agent_comms/field_codec.py | 8 +- src/agent_comms/historical_views.py | 4 +- src/agent_comms/messages.py | 21 ++-- src/agent_comms/presentation.py | 8 +- src/agent_comms/registry_document.py | 142 ++++++++++++-------------- src/agent_comms/relationships.py | 23 +++-- src/agent_comms/thread_identity.py | 4 +- src/agent_comms/thread_name.py | 6 +- tests/test_display_unread.py | 2 +- tests/test_registration_components.py | 35 ++++++- tests/test_sidebar_observation.py | 17 +-- 13 files changed, 171 insertions(+), 111 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index 6d02d17852..393814897d 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -254,7 +254,10 @@ def page(self, before: int, limit: int) -> tuple[BusRecord, ...]: def input_page(self, before: int, limit: int) -> tuple[MessageInput, ...]: """The same global sequence window, without attachment/report/answer acquisition.""" with self.observing() as db: - return tuple(MessageInput.read_from(db, row.seq) for row in self._page_rows(db, before, limit)) + return tuple( + MessageInput.read_from(db, row.seq) + for row in self._page_rows(db, before, limit) + ) @staticmethod def _page_rows(db: sqlite3.Connection, before: int, limit: int) -> list[BusRow]: @@ -477,7 +480,8 @@ def count_unread( counts[query] = FieldCodec.decode(int, db.execute( f'WITH {ReadCursor.sql("?")}, requests AS (' f'SELECT \'viewer\' AS actor,* FROM "{Addressed.table.declared_name}" ' - f'JOIN "{MessageInput.table.declared_name}" USING(seq) WHERE seq > ? AND seq <= ? ' + f'JOIN "{MessageInput.table.declared_name}" USING(seq) ' + f'WHERE seq > ? AND seq <= ? ' f'AND ({query.predicate}) AND sender NOT IN (SELECT value FROM json_each(?))), ' f'{ReadCursor.joined_sql} SELECT count(*) FROM cursors WHERE {ReadCursor.unread_sql}', (CursorRow.binding(query.cursors), max(after, query.lower_bound), through, diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index f9347cfbd2..cb450e01c8 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -420,9 +420,7 @@ def bindings[C: CliCommand]( def validate(self, message: Message) -> None: if message.sender == self.name and message.type.conversation_message: raise RelationViolationError(f"Thread {message.sender!r} cannot message itself.") - ThreadName.require(self.name, ValueError( - f"Message target {self.name!r} is not a thread name or #channel." - )) + ThreadName.require(self.name) def wake_policy(self, recipient: FrozenRecipient, mentions: frozenset[str]) -> FullWake: return FullWake() diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 4ac23898ee..6583b50434 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -3662,8 +3662,12 @@ def decode_entries( ) -> T: if not isinstance(data, dict): raise MalformedRecord("Expected a record object; fix: supply a JSON object.") - supplied = {key for entry in entries if entry.name in decoded_fields for key in entry.wire_keys} - problems = {key: issue for key, issue in self.missing_fields(data).items() if key not in supplied} + supplied = { + key for entry in entries if entry.name in decoded_fields for key in entry.wire_keys + } + problems = { + key: issue for key, issue in self.missing_fields(data).items() if key not in supplied + } unknown = list(self.unknown_fields(data)) target = self.target values = dict(decoded_fields) diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index 5d775ac178..cdb5d80a96 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -491,7 +491,9 @@ def _require_revision(self, revision: FileObservation) -> None: if FileRevision.capture(self.path) != revision: raise HistoricalSourceChanged("Historical catalogue changed; refresh history") - def attach(self, source_root: Path, *, source_read: HistorySource | None = None) -> HistorySource: + def attach( + self, source_root: Path, *, source_read: HistorySource | None = None, + ) -> HistorySource: """Prepare a source without archive exclusion, then publish it exactly once.""" source_root = source_root.resolve() if source_root == self.path.parent.resolve(): diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index cdfa0348f4..240f9d9d8c 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -446,8 +446,11 @@ def selected( """One cursor reads only the capability tables this value inherits.""" tables = cls.tables() joined: tuple[type[BusTables], ...] = (BusRow, *tables) - offsets = dict(zip(tables, accumulate( - (len(BusRow.columns()), *(len(table.columns()) for table in tables))))) + offsets = dict(zip( + tables, + accumulate((len(BusRow.columns()), *(len(table.columns()) for table in tables))), + strict=False, # The final accumulated value is the row end, not a table start. + )) header = BusRow.qualified("seq") order = "DESC" if descending else "ASC" with closing(db.execute( @@ -461,10 +464,13 @@ def selected( for stored in cursor: original = BusRow.decode_at(stored, 0) try: - value = cls.assembled(table.decode_at(stored, offsets[table]) for table in tables) + value = cls.assembled( + table.decode_at(stored, offsets[table]) for table in tables + ) except MalformedRecord as error: raise RelationViolationError( - f"Bus row {original.seq} has malformed required {cls.__name__} capability: {error}" + f"Bus row {original.seq} has malformed required " + f"{cls.__name__} capability: {error}" ) from error yield value @@ -481,8 +487,11 @@ def history( ) -> Iterator[BusCapability]: """Records after one sequence, using the same joined cursor for full and tail replay.""" tables = TypedTable.members_with(CapabilityRow) - offsets = dict(zip(tables, accumulate( - (len(BusRow.columns()), *(len(table.columns()) for table in tables))))) + offsets = dict(zip( + tables, + accumulate((len(BusRow.columns()), *(len(table.columns()) for table in tables))), + strict=False, # The final accumulated value is the row end, not a table start. + )) header = BusRow.qualified("seq") predicate, parameters = ("", ()) if kinds is None else ( f' AND {BusRow.qualified("kind")} IN ({", ".join("?" for _ in kinds)})', diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index b1176735a0..26a2b788d9 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -767,7 +767,9 @@ def activity_detail(self) -> str: class NoThreadView(NullMember): """A named navigation target has no captured thread presentation.""" - def sort_key(self, order: type[ThreadSort], target: str, sent: float) -> tuple[float, float, str]: + def sort_key( + self, order: type[ThreadSort], target: str, sent: float, + ) -> tuple[float, float, str]: return order.key(target, 0, 0, 0) @@ -796,7 +798,9 @@ def roster( snapshot.seen_at(thread.name), snapshot.owner_binding(thread.name), ) for thread in threads) - def sort_key(self, order: type[ThreadSort], target: str, sent: float) -> tuple[float, float, str]: + def sort_key( + self, order: type[ThreadSort], target: str, sent: float, + ) -> tuple[float, float, str]: return order.key(target, self.thread.birth, self.activity.timestamp, sent) @property diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index d38f2d6d81..ede72f8f31 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -324,30 +324,50 @@ def __init__(self, error: Exception) -> None: @dataclass(frozen=True) -class ThreadRow(ThreadBirthRecord, DeclaredWireType): - """A captured birth and original wire row; goal fields are acquired only on demand.""" +class ThreadRow(DeclaredWireType): + """One retained row owns its lazy birth, narrow projections and bound Thread.""" wire_type = Thread - raw: Annotated[Mapping[str, JsonValue], WireTransient()] = field(kw_only=True) + raw: Annotated[Mapping[str, JsonValue], WireTransient()] + binding: Annotated[Callable[[Thread], Thread], WireTransient()] = field(compare=False) @classmethod def from_wire(cls, raw: object) -> Self: if not isinstance(raw, dict): raise MalformedRecord("thread row must be an object") - return FieldCodec.project_record( - cls, raw, source=Thread, - decoded_fields={"raw": MappingProxyType(raw)}, - ) + return cls(MappingProxyType(raw), ThreadRows.unbound) + + @classmethod + def from_thread(cls, thread: Thread, binding: Callable[[Thread], Thread]) -> Self: + row = cls.from_wire(FieldCodec.encode_fields(thread)).bound(binding) + row.__dict__["decoded"] = binding(thread) + return row + + def bound(self, binding: Callable[[Thread], Thread]) -> Self: + if self.binding == binding: + return self + row = replace(self, binding=binding) + if "decoded" in self.__dict__: + row.__dict__["decoded"] = binding(self.decoded) + return row def to_wire(self) -> dict[str, JsonValue]: return dict(self.raw) - def __reduce__(self) -> tuple[Callable[[object], Self], tuple[dict[str, JsonValue]]]: - return type(self).from_wire, (self.to_wire(),) + def __reduce__(self) -> tuple[ + Callable[[object], Self], tuple[dict[str, JsonValue]], + dict[str, Callable[[Thread], Thread]], + ]: + # The wire stays portable; pickle also retains this cut's physical binding. + return type(self).from_wire, (self.to_wire(),), {"binding": self.binding} + + @cached_property + def birth_record(self) -> ThreadBirthRecord: + return FieldCodec.project_record(ThreadBirthRecord, dict(self.raw), source=Thread) @cached_property def captured_birth(self) -> Mapping[str, object]: - return MappingProxyType({entry.name: entry.current(self) + return MappingProxyType({entry.name: entry.current(self.birth_record) for entry in FieldCodec.field_plans(ThreadBirthRecord) if entry.records}) @@ -363,22 +383,23 @@ def goal_owner(self) -> GoalOwner: GoalOwner, dict(self.raw), source=Thread, decoded_fields=self.captured_birth, ) + @cached_property def decoded(self) -> Thread: - owner = self.goal_owner - values = {entry.name: entry.current(owner) - for entry in FieldCodec.field_plans(GoalOwner) if entry.records} - return FieldCodec.decode_record(Thread, dict(self.raw), decoded_fields=values) + try: + owner = self.goal_owner + values = {entry.name: entry.current(owner) + for entry in FieldCodec.field_plans(GoalOwner) if entry.records} + thread = FieldCodec.decode_record(Thread, dict(self.raw), decoded_fields=values) + except ValueError as error: + raise InvalidRegistryDocument(error) from error + return self.binding(thread) class ThreadRows(ExternalJsonShape, WireValue, MutableMapping[str, Thread]): """A revision's thread rows, decoded and bound only when read.""" def __init__(self) -> None: - # checked_rows proved each stored row an object, so the type keeps that proof. - self._raw: dict[str, dict[str, JsonValue]] = {} - self._decoded: dict[str, Thread] = {} - # Each cut owns its memo of immutable captured rows. - self._captured: dict[str, ThreadRow] = {} + self._rows: dict[str, ThreadRow] = {} self._binding: Callable[[Thread], Thread] = self.unbound @staticmethod @@ -387,17 +408,15 @@ def unbound(thread: Thread) -> Thread: def bind(self, binding: Callable[[Thread], Thread]) -> None: self._binding = binding - self._decoded = {name: binding(thread) for name, thread in self._decoded.items()} + self._rows = {name: row.bound(binding) for name, row in self._rows.items()} def __eq__(self, other: object) -> bool: - """Rows still stored on both sides compare as their JSON; only rows that differ there, - or that either side wrote, are decoded, so a one-row edit never decodes the registry. + """Equal retained wire needs no full decoding; differing rows compare their Threads. Rows equal only rows: a plain mapping is a different value.""" if not isinstance(other, ThreadRows): return NotImplemented return self.keys() == other.keys() and all( - (name in self._raw and name in other._raw and self._raw[name] == other._raw[name]) - or self[name] == other[name] + self._rows[name].raw == other._rows[name].raw or self[name] == other[name] for name in self) def children(self, parent: str) -> tuple[Thread, ...]: @@ -406,25 +425,18 @@ def children(self, parent: str) -> tuple[Thread, ...]: expected = FieldCodec.encode(ForkedParent(parent)) return tuple( self[name] for name in self - if (self._decoded[name].parent.named(parent) if name in self._decoded - else self._raw[name].get(key) == expected) + if self._rows[name].raw.get(key) == expected ) - def captured(self, name: str) -> ThreadRow: - raw = self._raw[name] - if name not in self._captured: - self._captured[name] = ThreadRow.from_wire(raw) - return self._captured[name] - def determining(self, name: str) -> GoalOwner: """Only a goal/role question acquires the declared goal-owner projection.""" - return self._decoded[name] if name in self._decoded else self.captured(name).goal_owner + return self._rows[name].goal_owner def membership(self, name: str) -> ThreadMembership: - return self._decoded[name] if name in self._decoded else self.captured(name).membership + return self._rows[name].membership def birth_record(self, name: str) -> ThreadBirthRecord: - return self._decoded[name] if name in self._decoded else self.captured(name) + return self._rows[name].birth_record def incarnation(self, name: str) -> ThreadIncarnation: return self.birth_record(name).incarnation @@ -434,44 +446,28 @@ def active_goal_owners(self) -> Iterator[Thread]: return (self[name] for name in self for _ in self.determining(name).active_goals()) def __getitem__(self, name: str) -> Thread: - if name not in self._decoded: - self._decoded[name] = self.decode_row(name) - return self._decoded[name] - - def decode_row(self, name: str) -> Thread: - try: - thread = self.captured(name).decoded() - except ValueError as error: - raise InvalidRegistryDocument(error) from error - return self._binding(thread) + return self._rows[name].decoded def __iter__(self) -> Iterator[str]: - return iter(dict.fromkeys((*self._raw, *self._decoded))) + return iter(self._rows) def __len__(self) -> int: - return len(self._raw.keys() | self._decoded.keys()) + return len(self._rows) def __contains__(self, name: object) -> bool: - return name in self._raw or name in self._decoded + return name in self._rows def __setitem__(self, name: str, thread: Thread) -> None: if thread.name != name: raise MalformedRecord("thread name differs from its declaration") - self._captured.pop(name, None) - self._decoded[name] = self._binding(thread) - self._raw.pop(name, None) + self._rows[name] = ThreadRow.from_thread(thread, self._binding) def __delitem__(self, name: str) -> None: - if name not in self: - raise KeyError(name) - self._captured.pop(name, None) - self._raw.pop(name, None) - self._decoded.pop(name, None) + del self._rows[name] def to_wire(self) -> dict[str, JsonValue]: - # A read memo is not an edit: __setitem__ removes raw only for actual writes. - return {name: (dict(self._raw[name]) if name in self._raw - else FieldCodec.encode(self._decoded[name])) for name in self} + # Full acquisition never replaces the retained wire; assignment builds a new row. + return {name: row.to_wire() for name, row in self._rows.items()} @classmethod def schema(cls) -> dict[str, JsonValue]: @@ -490,11 +486,8 @@ def checked_rows(cls, raw: object) -> dict[str, dict[str, JsonValue]]: def birth(self, name: str) -> CreationTime: """One row's creation time, read alone: a stored row is never decoded for it.""" - if name in self._decoded: - return self._decoded[name].created_at try: - key = FieldCodec.field_plan(Thread, "created_at").key - return CreationTime.from_wire(self._raw[name].get(key)) + return self._rows[name].birth_record.created_at except ValueError as error: raise InvalidRegistryDocument(error) from error @@ -517,7 +510,8 @@ def require_distinct_births(self) -> None: @classmethod def from_wire(cls, raw: object) -> Self: result = cls() - result._raw = cls.checked_rows(raw) + result._rows = {name: ThreadRow.from_wire(row) + for name, row in cls.checked_rows(raw).items()} return result @classmethod @@ -529,20 +523,18 @@ def from_threads(cls, threads: Mapping[str, Thread]) -> Self: @classmethod def copied(cls, source: ThreadRows) -> Self: result = cls() - result._raw = source._raw.copy() - result._decoded = source._decoded.copy() - result._captured = source._captured.copy() + result._rows = source._rows.copy() result._binding = source._binding return result def reuse(self, rows: Mapping[str, object], kept: frozenset[str], earlier: ThreadRows) -> None: - retained = {name: earlier._raw[name] for name in kept & earlier._raw.keys()} - changed = self.checked_rows({name: row for name, row in rows.items() if name not in retained}) - captured = ChainMap(retained, changed) - self._raw = {name: captured[name] for name in rows} - self._decoded = {name: earlier._decoded[name] for name in kept if name in earlier._decoded} - self._captured = {name: earlier._captured[name] for name in kept & earlier._captured.keys()} + changed = self.checked_rows({name: row for name, row in rows.items() if name not in kept}) self._binding = earlier._binding + self._rows = { + name: (earlier._rows[name] if name in kept else + ThreadRow.from_wire(changed[name]).bound(self._binding)) + for name in rows + } class FrozenThreadRows(ThreadRows): @@ -1344,7 +1336,9 @@ def process_owners(self, process: ProcessIdentity) -> tuple[str, ...]: def owner_identity(self, name: str) -> OwnerIdentity: canonical = self.canonical_name(name) - return OwnerIdentity(self.threads[canonical].incarnation, self.owner_generations[canonical]) + return OwnerIdentity( + self.threads[canonical].incarnation, self.owner_generations[canonical], + ) def claimed_identity(self, name: str) -> ClaimedIdentity: canonical = self.canonical_name(name) diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 3c8277aa07..d3a0bdd586 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -358,8 +358,10 @@ class ParentRelationshipGroup(RelationshipGroup): @classmethod def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: - return tuple(NamedEntrySource(RelationshipEntrySource.canonical_target(name, inputs.registry)) - for name in inputs.thread.parent.names()) + return tuple( + NamedEntrySource(RelationshipEntrySource.canonical_target(name, inputs.registry)) + for name in inputs.thread.parent.names() + ) @dataclass(frozen=True, slots=True) @@ -370,7 +372,9 @@ class SortableRelationshipGroup(RelationshipGroup): default_order: ClassVar[type[ThreadSort]] @classmethod - def selected_order(cls, orders: Mapping[type[SortableRelationshipGroup], type[ThreadSort]]) -> type[ThreadSort]: + def selected_order( + cls, orders: Mapping[type[SortableRelationshipGroup], type[ThreadSort]], + ) -> type[ThreadSort]: return orders.get(cls, cls.default_order) @classmethod @@ -793,7 +797,9 @@ def _goal_contacts( diagnostics: list[GoalMentionDiagnostic] = [] for owner in registry.threads.active_goal_owners(): for goal in owner.goal.active(): - ThreadRelationships._project_goal_contacts(goal, owner, registry, contacts, diagnostics) + ThreadRelationships._project_goal_contacts( + goal, owner, registry, contacts, diagnostics, + ) return (tuple(row for row in contacts if row.incident(thread)), tuple(row for row in diagnostics if row.owner == thread.name)) @@ -926,7 +932,10 @@ def _recent_messages(self) -> tuple[tuple[MessageInput, ...], bool]: """One bounded sequence page, on a single committed read snapshot.""" with self.bus.bus.reading() as source: messages = source.input_page(source.revision() + 1, self.RECENT_MESSAGES + 1) - return tuple(reversed(messages[:self.RECENT_MESSAGES])), len(messages) > self.RECENT_MESSAGES + return ( + tuple(reversed(messages[:self.RECENT_MESSAGES])), + len(messages) > self.RECENT_MESSAGES, + ) def snapshot(self, owner: str) -> ThreadCommsSnapshot: # One coherent identity/metadata basis; wire payload work is bounded. @@ -938,7 +947,9 @@ def snapshot(self, owner: str) -> ThreadCommsSnapshot: delivery = DeliveryScope.capture(thread.name, registry, self.views.channels.catalog.read()) messages, limited = self._recent_messages() - orders = {row.group: row.order for row in state.orders if row.incarnation == thread.incarnation} + orders = { + row.group: row.order for row in state.orders if row.incarnation == thread.incarnation + } sent: dict[str, float] = {} for order in { group.selected_order(orders) diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 1017b0ad8b..a0bad3be56 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -459,9 +459,7 @@ class ThreadIncarnationRecord: created_at: float def __post_init__(self) -> None: - ThreadName.require(self.name, ValueError( - f"Thread name {self.name!r} must be alphanumeric with hyphens/underscores." - )) + ThreadName.require(self.name) @property def birth(self) -> float: diff --git a/src/agent_comms/thread_name.py b/src/agent_comms/thread_name.py index 8cd8d3d51a..14f6afc14c 100644 --- a/src/agent_comms/thread_name.py +++ b/src/agent_comms/thread_name.py @@ -9,6 +9,8 @@ class ThreadName: ) @classmethod - def require(cls, name: str, refusal: ValueError) -> None: + def require(cls, name: str) -> None: if not name or not set(name) <= cls.characters: - raise refusal + raise ValueError( + f"Thread name {name!r} must be alphanumeric with hyphens/underscores." + ) diff --git a/tests/test_display_unread.py b/tests/test_display_unread.py index e20ffec9d8..d853a49466 100644 --- a/tests/test_display_unread.py +++ b/tests/test_display_unread.py @@ -201,7 +201,7 @@ def test_tag_membership_keeps_alias_births_and_defers_unselected_full_fields(com assert scope.memberships[member.name].incarnation == member.incarnation assert scope.memberships["renamed"].incarnation == member.incarnation assert not scope.includes_participant(unrelated.name) - assert not narrow.threads._decoded + assert all("decoded" not in row.__dict__ for row in narrow.threads._rows.values()) assert original.includes_participant(member.name) comms.registry.unregister("renamed") comms.threads.delete("renamed") diff --git a/tests/test_registration_components.py b/tests/test_registration_components.py index 7787fa0ac1..2cf6405e84 100644 --- a/tests/test_registration_components.py +++ b/tests/test_registration_components.py @@ -214,5 +214,36 @@ def test_captured_thread_row_uses_its_wire_decode_and_pickle_protocol(comms, tmp assert type(captured) is ThreadRow assert captured == row assert captured.to_wire() == row.to_wire() - assert captured.incarnation == thread.incarnation - assert captured.decoded() == thread + assert captured.birth_record.incarnation == thread.incarnation + assert captured.decoded == thread + + +def test_thread_row_copy_reuse_and_pickle_keep_the_bound_cut(comms, tmp_path): + from agent_comms.registry_document import FrozenThreadRows, ThreadRows + from agent_comms.session_file import BusSessionLocation + + first = comms.registry.declare(Thread("first-row", frozenset({"old"}), Checkout(tmp_path))) + second = comms.registry.declare(Thread("second-row", frozenset(), Checkout(tmp_path))) + first = replace(first, session_file=BusSessionLocation(Path("row-session.jsonl"))) + rows = ThreadRows.from_threads({first.name: first, second.name: second}) + rows.bind(comms.registry.store._bind_thread) + before = FrozenThreadRows.copied(rows) + acquired = before[first.name] + assert before[first.name] is acquired + assert acquired.session_file.bound_to(comms.root) is acquired.session_file + replacement = replace(acquired, tags=frozenset({"new"})) + rows[first.name] = replacement + assert rows[first.name] is replacement + assert before[first.name] is acquired + assert before.membership(first.name).tags == frozenset({"old"}) + assert rows.membership(first.name).tags == frozenset({"new"}) + assert rows._rows[second.name] is before._rows[second.name] + reused = ThreadRows() + reused.reuse(rows.to_wire(), frozenset({second.name}), before) + assert reused._rows[second.name] is before._rows[second.name] + assert reused[first.name] == replacement + for restored in (pickle.loads(pickle.dumps(before, protocol=5)), + pickle.loads(pickle.dumps(rows, protocol=5))): + selected = restored[first.name] + assert selected.session_file.bound_to(comms.root) is selected.session_file + assert restored[first.name] is selected diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index 14d3a794db..207e219d32 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -171,9 +171,10 @@ def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( rows = ThreadRows.from_wire(raw) rows.bind(comms.registry.store._bind_thread) if decoded: - rows[selected.name] = selected # A genuine write removes this row from _raw. + rows[selected.name] = selected # A genuine write supplies the row's actual Thread. snapshot = replace(captured, threads=rows) - decoded_before = frozenset(snapshot.threads._decoded) + decoded_before = frozenset(name for name, row in snapshot.threads._rows.items() + if "decoded" in row.__dict__) expected = {thread.name: thread.incarnation.lookup for thread in originals} lookup = snapshot.lookup_names @@ -188,7 +189,8 @@ def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( with pytest.raises(KeyError) as caught: lookup[missing] assert caught.value.args == (missing,) - assert frozenset(snapshot.threads._decoded) == decoded_before + assert frozenset(name for name, row in snapshot.threads._rows.items() + if "decoded" in row.__dict__) == decoded_before acquired = snapshot.born(selected.incarnation.lookup) assert acquired is snapshot.require(selected.name) @@ -196,7 +198,8 @@ def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( assert acquired.session_file.bound_to(comms.root) is acquired.session_file assert lookup.get(selected.incarnation.lookup) == selected.name assert snapshot.born(selected.incarnation.lookup) is acquired - assert frozenset(snapshot.threads._decoded) == decoded_before | {selected.name} + assert frozenset(name for name, row in snapshot.threads._rows.items() + if "decoded" in row.__dict__) == decoded_before | {selected.name} with pytest.raises(InvalidRegistryDocument): snapshot.born(invalid.incarnation.lookup) with pytest.raises(InvalidRegistryDocument): @@ -292,12 +295,12 @@ def test_cold_shape_and_birth_capture_do_not_acquire_unrequested_goal_fields( raw = captured.threads.to_wire() raw[original.name][entry.key] = [] rows = ThreadRows.from_wire(raw) - assert not rows._captured - assert not rows._decoded + assert all("birth_record" not in row.__dict__ for row in rows._rows.values()) + assert all("decoded" not in row.__dict__ for row in rows._rows.values()) snapshot = replace(captured, threads=rows) assert snapshot.incarnation(original.name) == original.incarnation assert original.incarnation.lookup in snapshot.lookup_names - assert not snapshot.threads._decoded + assert all("decoded" not in row.__dict__ for row in snapshot.threads._rows.values()) with pytest.raises(MalformedRecord): snapshot.threads.determining(original.name) with pytest.raises(InvalidRegistryDocument): From 6302e33e7d565ac757ea3fa79bd8a0693dd0839c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 07:37:00 -0400 Subject: [PATCH 45/74] Own written thread rows through their decoded override --- src/agent_comms/field_codec.py | 6 +++ src/agent_comms/registry_document.py | 57 ++++++++++++++------------ src/agent_comms/registry_provenance.py | 4 ++ src/agent_comms/thread_identity.py | 10 +---- 4 files changed, 42 insertions(+), 35 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 6583b50434..b776148d5f 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -2554,6 +2554,12 @@ def decode_record( return cast(T, decoder.decode_selected( data, {}, field_types=field_types, decoded_fields=decoded_fields)) + @classmethod + def recorded_fields(cls, record: object) -> dict[str, object]: + """Already-decoded values supplied by this record's stored-field declarations.""" + return {entry.name: entry.current(record) + for entry in cls.field_plans(type(record)) if entry.records} + @classmethod def project_record( cls, target: type[T], data: object, *, source: type, diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 8a84cde4a5..e93c38e6e8 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -337,65 +337,66 @@ def from_wire(cls, raw: object) -> Self: raise MalformedRecord("thread row must be an object") return cls(MappingProxyType(raw), ThreadRows.unbound) - @classmethod - def from_thread(cls, thread: Thread, binding: Callable[[Thread], Thread]) -> Self: - row = cls.from_wire(thread.to_wire()).bound(binding) - row.__dict__[cls.decoded.func.__name__] = binding(thread) - return row + @staticmethod + def from_thread( + thread: Thread, binding: Callable[[Thread], Thread], + ) -> WrittenThreadRow: + return WrittenThreadRow(MappingProxyType(thread.to_wire()), binding, thread) def bound(self, binding: Callable[[Thread], Thread]) -> Self: - if self.binding == binding: - return self - row = replace(self, binding=binding) - decoded = type(self).decoded.func.__name__ - if decoded in self.__dict__: - row.__dict__[decoded] = binding(self.decoded) - return row + return replace(self, binding=binding) def to_wire(self) -> dict[str, JsonValue]: return dict(self.raw) def __reduce__(self) -> tuple[ - Callable[[object], Self], tuple[dict[str, JsonValue]], + Callable[[object], ThreadRow], tuple[dict[str, JsonValue]], dict[str, Callable[[Thread], Thread]], ]: - # The wire stays portable; pickle also retains this cut's physical binding. - return type(self).from_wire, (self.to_wire(),), {"binding": self.binding} + # Pickle restores a lazy wire row, retaining this cut's physical binding. + return ThreadRow.from_wire, (self.to_wire(),), {"binding": self.binding} @cached_property def birth_record(self) -> ThreadBirthRecord: return FieldCodec.project_record(ThreadBirthRecord, dict(self.raw), source=Thread) - @cached_property - def captured_birth(self) -> Mapping[str, object]: - return MappingProxyType({entry.name: entry.current(self.birth_record) - for entry in FieldCodec.field_plans(ThreadBirthRecord) - if entry.records}) - @cached_property def membership(self) -> ThreadMembership: return FieldCodec.project_record( - ThreadMembership, dict(self.raw), source=Thread, decoded_fields=self.captured_birth, + ThreadMembership, dict(self.raw), source=Thread, + decoded_fields=FieldCodec.recorded_fields(self.birth_record), ) @cached_property def goal_owner(self) -> GoalOwner: return FieldCodec.project_record( - GoalOwner, dict(self.raw), source=Thread, decoded_fields=self.captured_birth, + GoalOwner, dict(self.raw), source=Thread, + decoded_fields=FieldCodec.recorded_fields(self.birth_record), ) @cached_property def decoded(self) -> Thread: try: - owner = self.goal_owner - values = {entry.name: entry.current(owner) - for entry in FieldCodec.field_plans(GoalOwner) if entry.records} - thread = FieldCodec.decode_record(Thread, dict(self.raw), decoded_fields=values) + thread = FieldCodec.decode_record( + Thread, dict(self.raw), + decoded_fields=FieldCodec.recorded_fields(self.goal_owner), + ) except ValueError as error: raise InvalidRegistryDocument(error) from error return self.binding(thread) +@dataclass(frozen=True) +class WrittenThreadRow(ThreadRow): + """An assigned row already holds its full Thread; no wire decode supplies it.""" + + thread: Annotated[Thread, WireTransient()] + + @cached_property + def decoded(self) -> Thread: + return self.binding(self.thread) + + class ThreadRows(ExternalJsonShape, WireValue, MutableMapping[str, Thread]): """A revision's thread rows, decoded and bound only when read.""" @@ -408,6 +409,8 @@ def unbound(thread: Thread) -> Thread: return thread def bind(self, binding: Callable[[Thread], Thread]) -> None: + if self._binding == binding: + return self._binding = binding self._rows = {name: row.bound(binding) for name, row in self._rows.items()} diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index 40d6497be1..84b7609762 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -171,6 +171,10 @@ def participant(self, name: str) -> Member | DeletedThread: return DeletedThread(canonical) raise UnregisteredThreadError(f"Thread {canonical!r} was never registered.") + def registered(self, name: str) -> bool: + """Current membership, including retained aliases, without acquiring a Thread.""" + return self.canonical_name(name) in self.threads + def require_canonical(self, name: str) -> str: canonical = self.canonical_name(name) if canonical not in self.threads: diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index a0bad3be56..fcbf345882 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -229,10 +229,7 @@ def resolved(self, snapshot: RegistryProvenance) -> ThreadIncarnation: return snapshot.incarnation(self.name) def current(self, snapshot: RegistryProvenance) -> bool: - try: - return snapshot.incarnation(self.name) == self - except UnregisteredThreadError: - return False + return snapshot.registered(self.name) and snapshot.incarnation(self.name) == self def recorded_or( self, make: Callable[[RecordedThreadIncarnation], T], otherwise: Callable[[], T] @@ -270,10 +267,7 @@ def matches_recorded_name(self, name: str, snapshot: RegistryProvenance) -> bool The registry owns aliases and birth. A stored name does not supply or manufacture either fact, including after a name is reused. """ - try: - return self.resolved(snapshot) == snapshot.incarnation(name) - except UnregisteredThreadError: - return False + return snapshot.registered(name) and self.resolved(snapshot) == snapshot.incarnation(name) def recorded_names(self, snapshot: RegistryProvenance) -> tuple[ThreadIncarnation, ...]: """Original names retained for this exact current incarnation.""" From 4c600de4b1298a359deafb24b27407e1b2974854 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 08:01:04 -0400 Subject: [PATCH 46/74] Use declared JSON and incarnation values at row and release boundaries --- src/agent_comms/owner_lifecycle.py | 2 +- src/agent_comms/threads.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index be3a0875c2..5f5b979826 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -226,7 +226,7 @@ def matches_current(self, snapshot: RegistrySnapshot, release: OwnerReleaseEvide """The row still binds the released process or, once a dead owner's stop unregistered it, none; a different process is a successor.""" current = snapshot.require(release.name) - return (release.incarnation() == current.incarnation + return (release.incarnation == current.incarnation and not current.process_identity.conflicts_with(release.process_identity)) diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index f22bc0206d..8989797600 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -20,6 +20,7 @@ Argument, Description, FieldCodec, + JsonValue, NullMember, RegistrationPolicy, SingleFieldValue, @@ -639,7 +640,7 @@ def turn_started_by(self, updated_at_ms: int) -> bool: def executing(self) -> bool: return bool(self.turn_record.items_with(Executing, self)) - def to_wire(self) -> dict[str, object]: + def to_wire(self) -> dict[str, JsonValue]: """Schema-derived projection at a JSON boundary, not a hand-maintained mirror.""" return FieldCodec.encode(self) From c1bd98229f01b8f1f8c1e74ecf6a451d61cff934 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:06:03 -0400 Subject: [PATCH 47/74] Give capability joins one acquisition and refusal owner --- src/agent_comms/messages.py | 65 +++++++++++++++++++------------------ tests/test_bus.py | 3 ++ 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 0c6c69792a..1c688b3a64 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -14,7 +14,7 @@ import sqlite3 import time from abc import ABC, abstractmethod -from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence +from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence from contextlib import closing from dataclasses import Field, dataclass, field, fields, make_dataclass, replace from functools import cache @@ -444,7 +444,24 @@ def selected( *, descending: bool = False, ) -> Generator[Self]: """One cursor reads only the capability tables this value inherits.""" - tables = cls.tables() + order = "DESC" if descending else "ASC" + with closing(cls._joined( + db, cls.tables(), f'ORDER BY {BusRow.qualified("seq")} {order}', + parameters, lambda header: cls, + prefix=f"WITH selected AS ({positions}) ", + position_join="JOIN selected USING(seq) ", + )) as rows: + for _, stored in rows: + yield stored + + @classmethod + def _joined[Stored: BusCapability]( + cls, db: sqlite3.Connection, tables: tuple[type[CapabilityRow], ...], + position_clause: str, parameters: tuple[object, ...], + record_type: Callable[[BusRow], type[Stored]], *, + prefix: str = "", position_join: str = "", + ) -> Generator[tuple[BusRow, Stored]]: + """One cursor owns declaration-order joins, decoding and required-row refusal.""" joined: tuple[type[BusTables], ...] = (BusRow, *tables) offsets = dict(zip( tables, @@ -452,27 +469,26 @@ def selected( strict=False, # The final accumulated value is the row end, not a table start. )) header = BusRow.qualified("seq") - order = "DESC" if descending else "ASC" with closing(db.execute( - f"WITH selected AS ({positions}) " - f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in joined)} " - f'FROM "{BusRow.declared_name}" JOIN selected USING(seq) ' + f"{prefix}SELECT {', '.join(row.qualified(*row.column_projection()) for row in joined)} " + f'FROM "{BusRow.declared_name}" {position_join}' + " ".join(f'LEFT JOIN "{table.declared_name}" ON {table.qualified("seq")} = {header}' for table in tables) - + f" ORDER BY {header} {order}", parameters, + + f" {position_clause}", parameters, )) as cursor: for stored in cursor: original = BusRow.decode_at(stored, 0) + kind = record_type(original) try: - value = cls.assembled( - table.decode_at(stored, offsets[table]) for table in tables + value = kind.assembled( + table.decode_at(stored, offsets[table]) for table in kind.tables() ) except MalformedRecord as error: raise RelationViolationError( f"Bus row {original.seq} has malformed required " - f"{cls.__name__} capability: {error}" + f"{kind.__name__} capability: {error}" ) from error - yield value + yield original, value @classmethod def assembled(cls, rows: Iterable[CapabilityRow]) -> Self: @@ -510,27 +526,12 @@ def at_sequences(db: sqlite3.Connection, sequences: tuple[int, ...]) -> tuple[Bu @staticmethod def stored_records( db: sqlite3.Connection, where: str, parameters: tuple[object, ...], - ) -> Iterator[tuple[BusRow, BusRecord]]: - """Each selected header with the record its capability rows assemble, decoded once.""" - tables = TypedTable.members_with(CapabilityRow) - offsets = dict(zip( - tables, - accumulate((len(BusRow.columns()), *(len(table.columns()) for table in tables))), - strict=False, # The final accumulated value is the row end, not a table start. - )) - header = BusRow.qualified("seq") - with closing(db.execute( - f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in (BusRow, *tables))} " - f'FROM "{BusRow.declared_name}" ' - + " ".join(f'LEFT JOIN "{table.declared_name}" ON {table.qualified("seq")} = {header}' - for table in tables) - + f" WHERE {where}", - parameters, - )) as cursor: - for stored in cursor: - row = BusRow.decode_at(stored, 0) - yield row, row.kind.assembled( - table.decode_at(stored, offsets[table]) for table in row.kind.tables()) + ) -> Generator[tuple[BusRow, BusRecord]]: + """Each selected header with its complete original record, decoded once.""" + return BusCapability._joined( + db, TypedTable.members_with(CapabilityRow), f"WHERE {where}", parameters, + lambda header: header.kind, + ) def projections(self) -> Iterator[TypedTable]: return iter(()) diff --git a/tests/test_bus.py b/tests/test_bus.py index 1491acc19a..80e1e3b952 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -763,6 +763,9 @@ def test_required_capability_corruption_is_not_hidden_by_the_join(bus): with pytest.raises(RelationViolationError, match="malformed required Message capability") as refused: Message.read_from(db, 1) assert isinstance(refused.value.__cause__, MalformedRecord) + with pytest.raises(RelationViolationError) as history_refused: + tuple(BusCapability.history(db)) + assert isinstance(history_refused.value.__cause__, MalformedRecord) def test_bulk_history_keeps_one_snapshot_across_pages(bus): From 4cfd1e2093d82002e6765925e23b6ae240a5dca4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:42:15 -0400 Subject: [PATCH 48/74] Keep history pages on their original archive catalogue --- src/agent_comms/historical_views.py | 79 ++++++++++++++++++++--------- tests/test_historical_views.py | 33 ++++++++++++ 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index cdb5d80a96..231afe4518 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -88,11 +88,12 @@ def reading(self, presentation: BusPresentation) -> Iterator[Callable[[PageBound unchanged, and retires it off the UI thread when that source is replaced. """ with presentation.reading(self.viewer) as captured: - yield partial(self.page, captured) + history = captured.bus.history.captured() + yield partial(history.integrated_page, self, captured) def page(self, presentation: BusPresentation, bounds: PageBounds = NEWEST_PAGE) -> MessagePage: - with presentation.reading(self.viewer) as captured: - return captured.bus.history.integrated_page(self, captured, bounds) + with self.reading(presentation) as read: + return read(bounds) @abstractmethod def capture(self, snapshot: RegistryProvenance) -> MessageDisplayScope: @@ -478,14 +479,29 @@ def sources(self) -> tuple[HistorySource, ...]: with self.read_sources() as sources: return sources + def captured(self) -> CapturedHistoryArchive: + """Retain the catalogue and its revision from the same original descriptor.""" + document = self._capture_unlocked() + return CapturedHistoryArchive( + self.path, self.read_copied(document), document.revision, + ) + + @property + def history_revision(self) -> FileObservation: + return self.captured().catalogue_revision + + @contextmanager + def reading(self) -> Iterator[CapturedHistoryArchive]: + """Validate a page's original catalogue before and after using it.""" + captured = self.captured() + self._require_revision(captured.catalogue_revision) + yield captured + self._require_revision(captured.catalogue_revision) + @contextmanager def read_sources(self) -> Iterator[tuple[HistorySource, ...]]: - """Decode one read's catalogue; reject changes without decoding it again.""" - captured = self._capture_unlocked() - sources = self.read_copied(captured) - self._require_revision(captured.revision) - yield sources - self._require_revision(captured.revision) + with self.reading() as captured: + yield captured.sources() def _require_revision(self, revision: FileObservation) -> None: if FileRevision.capture(self.path) != revision: @@ -564,13 +580,17 @@ def page(self, view: HistoryView, presentation: BusPresentation, bounds: PageBou def integrated_page( self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds + ) -> MessagePage: + with self.reading() as captured: + return captured._integrated_page(view, presentation, bounds) + + def _integrated_page( + self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds ) -> MessagePage: page = self._source_page(view, presentation, bounds) pages = deque((page,)) while (following := bounds.after(page)) is not None: part = self._source_page(view, presentation, following) - if part.selection.history_revision != page.selection.history_revision: - raise HistoricalSourceChanged("Historical sources changed; reload history") if not bounds.fits(len(page.messages) + len(part.messages)): break bounds.traversal.append(pages, part) @@ -579,11 +599,16 @@ def integrated_page( def _source_page( self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds + ) -> MessagePage: + page = self._select_source_page(view, presentation, bounds) + return replace(page, selection=replace( + page.selection, history_revision=self.history_revision, + )) + + def _select_source_page( + self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds ) -> MessagePage: historical = bounds.traversal.historical - if not self.sources() and not historical: - return view.live_page(presentation, bounds) - history_revision = FileRevision.capture(self.path) def historical_page() -> MessagePage: history = self.page( view, presentation, bounds if historical else replace(bounds, traversal=OlderTraversal()) @@ -592,27 +617,21 @@ def historical_page() -> MessagePage: latest = view.live_page(presentation, PageBounds(limit=1)) return replace( history, - selection=replace(history.selection, history_revision=history_revision), has_newer=history.has_newer or bool(latest.messages), ) selected = history if historical else page - result = replace(selected, selection=replace(selected.selection, history_revision=history_revision)) if historical: return bounds.traversal.after_exhausted_history( lambda traversal: view.live_page(presentation, replace(bounds, traversal=traversal)), - result, + selected, ) - return result + return selected if historical: return historical_page() page = view.live_page(presentation, bounds) return bounds.traversal.source_page( - replace( - page, - has_older=page.has_older or bool(self.sources()), - selection=replace(page.selection, history_revision=history_revision), - ), + replace(page, has_older=page.has_older or bool(self.sources())), historical_page, ) @@ -624,3 +643,17 @@ def threads(self, name: str | None = None) -> tuple[HistoricalThread, ...]: for thread in source.provenance.threads.values() if name is None or thread.incarnation.matches_recorded_name(name, source.provenance)) return tuple(result) + + +@dataclass(frozen=True, slots=True) +class CapturedHistoryArchive(HistoryArchive, declared_name=None): + """One reading's immutable catalogue; page logic remains on HistoryArchive.""" + + captured_sources: tuple[HistorySource, ...] + catalogue_revision: FileObservation + + def captured(self) -> CapturedHistoryArchive: + return self + + def sources(self) -> tuple[HistorySource, ...]: + return self.captured_sources diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index 84f2c3bc21..100fca0dac 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -57,6 +57,39 @@ def test_missing_catalogue_is_empty_without_creating_it(tmp_path): assert not archive.path.exists() +@pytest.mark.parametrize('exists', [False, True]) +def test_captured_catalogue_refuses_later_creation_or_replacement(tmp_path, exists): + archive = HistoryArchive.at(tmp_path) + if exists: + archive.path.write_text('[]') + captured = archive.captured() + with captured.read_sources() as sources: + assert sources == () + archive.path.write_text('[ ]') + with pytest.raises(HistoricalSourceChanged): + with captured.read_sources(): + pass + with archive.read_sources() as sources: + assert sources == () + + +def test_held_display_reading_rejects_later_archive_without_bus_growth(migrated, tmp_path): + _, _, live, _ = migrated + with live.views.channel_display_reading('#team', viewer=NamedViewer('alice')) as read: + first = read(PageBounds(limit=1)) + revision = live.bus.bus.revision() + later = setup(tmp_path / 'later', 1) + live.views.attach_history(later.root) + assert live.bus.bus.revision() == revision + with pytest.raises(HistoricalSourceChanged): + read(PageBounds(first.oldest_cursor.older(), 1)) + # Retiring the old reading is harmless; a new reading adopts the catalogue. + fresh = live.views.channel_display_page( + '#team', viewer=NamedViewer('alice'), bounds=PageBounds(limit=1), + ) + assert fresh.selection.history_revision != first.selection.history_revision + + def setup(root, count=5): comms = wire(root) comms.registry.declare(Thread('alice', frozenset({'team'}), Checkout(root), created_at=10.0)) From ff5e9d00aa0406c63530f9c893c7609b9c0d3d22 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 12:17:34 -0400 Subject: [PATCH 49/74] Keep display construction and captured reader policy with their owners --- src/agent_comms/bus.py | 18 +++--- src/agent_comms/history_views.py | 36 ++--------- src/agent_comms/message_bus.py | 6 +- src/agent_comms/presentation.py | 12 ++-- src/agent_comms/read_basis.py | 2 +- src/agent_comms/registry_provenance.py | 4 +- src/agent_comms/storage_room.py | 6 +- src/agent_comms/thread_membership.py | 3 + tests/c4_coordination_installed_ui.py | 5 +- tests/test_channel_display.py | 59 ++++++++++++----- tests/test_channel_retirement.py | 8 ++- tests/test_claim_bus_read.py | 2 +- tests/test_current_delivery.py | 2 +- tests/test_dm_scoped_ack.py | 35 ++++++++-- tests/test_dm_scoped_ack_successor.py | 40 +++++++++--- tests/test_goal_reply_lock_scope.py | 4 +- tests/test_historical_page_scope.py | 4 +- tests/test_historical_views.py | 90 +++++++++++++++++--------- tests/test_mark_all_existing_reads.py | 5 +- tests/test_named_target_contracts.py | 6 +- tests/test_ordinary_nk_delivery.py | 5 +- tests/test_read_ledger.py | 76 ++++++++++++++++------ tests/test_s4_read_characterization.py | 10 ++- tests/test_thread_identity.py | 10 ++- tests/test_user_thread_lookup.py | 11 +++- tests/test_view_unread.py | 9 ++- 26 files changed, 314 insertions(+), 154 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index e1eaac070a..53b35cd17c 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -164,7 +164,9 @@ class BusReader(RouteCounts, ABC): """One query implementation for individual reads and a held read snapshot.""" @abstractmethod - def observing(self) -> AbstractContextManager[sqlite3.Connection]: + def observing( + self, *, check_same_thread: bool = True, + ) -> AbstractContextManager[sqlite3.Connection]: """Borrow one read snapshot through the reader's declared lifetime.""" def answering(self, references: tuple[MessageReference, ...], route: NamedTarget, @@ -174,9 +176,9 @@ def answering(self, references: tuple[MessageReference, ...], route: NamedTarget return MessageIdentitySource.answering(connection, references, route, snapshot) @contextmanager - def reading(self) -> Iterator[BusSnapshot]: + def reading(self, *, check_same_thread: bool = True) -> Iterator[BusSnapshot]: """Keep queries and their physical identity on one held snapshot.""" - with self.observing() as db: + with self.observing(check_same_thread=check_same_thread) as db: yield BusSnapshot.from_connection(db) def revision(self) -> int: @@ -520,10 +522,8 @@ def path(self) -> Path: """The file named by this reader's owning declaration.""" @contextmanager - def observing(self) -> Iterator[sqlite3.Connection]: - # A captured display cut moves between the preparation owner's workers. - # Its presentation serializes page use and retirement; it remains read-only. - with SQLiteFile.opened_reader(self.path, check_same_thread=False) as db: + def observing(self, *, check_same_thread: bool = True) -> Iterator[sqlite3.Connection]: + with SQLiteFile.opened_reader(self.path, check_same_thread=check_same_thread) as db: yield db @@ -676,10 +676,10 @@ def from_connection(cls, connection: sqlite3.Connection) -> Self: return cls(identity=FileIdentity.from_stat(path.stat()), path=path, _connection=connection) @contextmanager - def observing(self) -> Iterator[sqlite3.Connection]: + def observing(self, *, check_same_thread: bool = True) -> Iterator[sqlite3.Connection]: """The held snapshot is already attached, so it takes no lock.""" yield self._connection @contextmanager - def reading(self) -> Iterator[BusSnapshot]: + def reading(self, *, check_same_thread: bool = True) -> Iterator[BusSnapshot]: yield self diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index 00379f00c8..660a2e7d66 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -10,8 +10,7 @@ import logging import time from abc import abstractmethod -from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence -from contextlib import contextmanager +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, Annotated @@ -247,42 +246,21 @@ def attach_history(self, source_root: Path, *, source_read: HistorySource | None def historical_threads(self, name: str | None = None) -> tuple[HistoricalThread, ...]: return self.bus.history.threads(name) - def dm_display_page( - self, peer: ThreadTarget, *, worktree: Checkout, bounds: PageBounds = NEWEST_PAGE - ) -> MessagePage: - viewer = self.messaging.user_identity(worktree) - return DMDisplayHistory(viewer.name, peer, worktree).page(self.presentation, bounds) - - @contextmanager - def dm_display_reading( + def dm_display_view( self, peer: ThreadTarget, *, worktree: Checkout, - ) -> Iterator[Callable[[PageBounds], MessagePage]]: + ) -> DMDisplayHistory: viewer = self.messaging.user_identity(worktree) - with DMDisplayHistory(viewer.name, peer, worktree).reading(self.presentation) as pages: - yield pages + return DMDisplayHistory(viewer.name, peer, worktree) - @contextmanager - def channel_display_reading( + def channel_display_view( self, target: str, *, viewer: DisplayViewer = AnonymousViewer(), - ) -> Iterator[Callable[[PageBounds], MessagePage]]: - channel = self.bus.require_channel(target) - with ChannelDisplayHistory(channel, viewer).reading(self.presentation) as pages: - yield pages + ) -> ChannelDisplayHistory: + return ChannelDisplayHistory(self.bus.require_channel(target), viewer) def display_viewer(self, worktree: Checkout) -> NamedViewer: """Select the human reader; page capture resolves it in its own snapshot.""" return NamedViewer(self.messaging.user_identity(worktree).name) - def channel_display_page( - self, - target: str, - *, - viewer: DisplayViewer = AnonymousViewer(), - bounds: PageBounds = NEWEST_PAGE, - ) -> MessagePage: - channel = self.bus.require_channel(target) - return ChannelDisplayHistory(channel, viewer).page(self.presentation, bounds) - def mark_page_read(self, page: MessagePage, *, worktree: Checkout) -> None: """Acknowledge the original read evidence of exactly the painted page.""" page.selection.acknowledge(self, page, worktree=worktree) diff --git a/src/agent_comms/message_bus.py b/src/agent_comms/message_bus.py index 32c5b1485a..685812e81d 100644 --- a/src/agent_comms/message_bus.py +++ b/src/agent_comms/message_bus.py @@ -145,8 +145,10 @@ def channel_declarations(self): return self._channels.read().views(activity) @contextmanager - def snapshot(self, viewer: DisplayViewer = AnonymousViewer()) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: - with self.bus.reading() as source: + def snapshot( + self, viewer: DisplayViewer = AnonymousViewer(), *, check_same_thread: bool = True, + ) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + with self.bus.reading(check_same_thread=check_same_thread) as source: basis = CapturedSelection.load( self._registry, self._channels, self.reads, viewer, source.activity_clocks(), ) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 26a2b788d9..bdbef4d03f 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -530,9 +530,11 @@ def __init__( self._display_unread = DisplayUnread() @contextmanager - def snapshot(self, viewer: DisplayViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + def snapshot( + self, viewer: DisplayViewer, *, check_same_thread: bool = True, + ) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: """Borrow the bus-owned source cut without preparing a second store.""" - with self.bus.snapshot(viewer) as selected: + with self.bus.snapshot(viewer, check_same_thread=check_same_thread) as selected: yield selected @contextmanager @@ -543,7 +545,7 @@ def reading(self, viewer: DisplayViewer) -> Iterator[CapturedBusPresentation]: serializes page use and retirement, without keeping any store exclusion held. """ with ExitStack() as lifetime: - basis, source = lifetime.enter_context(self.snapshot(viewer)) + basis, source = lifetime.enter_context(self.snapshot(viewer, check_same_thread=False)) captured = CapturedBusPresentation(self, basis, source) try: yield captured @@ -623,7 +625,9 @@ def __init__(self, original: BusPresentation, basis: CapturedSelection, source: self._use = RLock() @contextmanager - def snapshot(self, viewer: DisplayViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + def snapshot( + self, viewer: DisplayViewer, *, check_same_thread: bool = True, + ) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: with self._use: if viewer.resolve(self.basis.registry) != self.basis.viewer: raise ValueError("A captured display cut belongs to its original viewer") diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 9d9dd4d1bd..53caf0ab68 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -1232,7 +1232,7 @@ def participant_query(self) -> tuple[str, tuple[object, ...]]: return self.registry.tagged_names(self.tags) def includes_participant(self, name: str) -> bool: - return name in self.memberships and self.tags <= self.memberships[name].tags + return name in self.memberships and self.memberships[name].carries(self.tags) @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index 84b7609762..3a3afd66b8 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -243,8 +243,8 @@ def same_tag_membership(self, earlier: Mapping[str, ThreadMembership], """Only a matching name or its actual birth changing invalidates that channel.""" current = self.memberships for name in current.keys() | earlier.keys(): - before = name in earlier and tags <= earlier[name].tags - after = name in current and tags <= current[name].tags + before = name in earlier and earlier[name].carries(tags) + after = name in current and current[name].carries(tags) if before != after: return False if before and earlier[name].incarnation != current[name].incarnation: diff --git a/src/agent_comms/storage_room.py b/src/agent_comms/storage_room.py index 56c160ac89..9fd69a0ea7 100644 --- a/src/agent_comms/storage_room.py +++ b/src/agent_comms/storage_room.py @@ -123,11 +123,13 @@ class RoomLedger(TypedStore, DurableTables, RuntimeWriter): tables = RoomLedgerRows @classmethod - def _open_reader(cls, path: Path, contention: StoreContention) -> Connection: + def _open_reader( + cls, path: Path, contention: StoreContention, *, check_same_thread: bool = True, + ) -> Connection: """Carry this host-owned file before its first snapshot; then prohibit writes.""" connection = cls.writer( path.resolve().as_uri() + "?mode=rw", contention, - isolation_level=None, uri=True, + isolation_level=None, uri=True, check_same_thread=check_same_thread, ) try: cls.install_schema(connection, WriterOwnedFile()) diff --git a/src/agent_comms/thread_membership.py b/src/agent_comms/thread_membership.py index 7ecdd0d4e0..65e694b58d 100644 --- a/src/agent_comms/thread_membership.py +++ b/src/agent_comms/thread_membership.py @@ -17,6 +17,9 @@ class ThreadMembership(ThreadBirthRecord): tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) + def carries(self, tags: frozenset[str]) -> bool: + return tags <= self.tags + def __post_init__(self) -> None: super().__post_init__() for tag in self.tags: diff --git a/tests/c4_coordination_installed_ui.py b/tests/c4_coordination_installed_ui.py index 513345022c..03ac4abca1 100644 --- a/tests/c4_coordination_installed_ui.py +++ b/tests/c4_coordination_installed_ui.py @@ -61,7 +61,10 @@ async def main(): assert original.outcomes[message.seq, message.message_id] assert comms.views.message_notifications_for_references(AllOutcomes(), (message.reference,) * (NotificationWindow.window_limit + 1)).outcomes == original.outcomes - page = comms.views.channel_display_page('#team', viewer=NamedViewer(comms.messaging.user_identity(Checkout(root)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(root)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(root)) assert comms.views.viewer_snapshot(Checkout(root)).channel_unread['#team'].count == 0 assert comms.bus.pending_count('stopped', CommandTarget.named('#team')) == 1 diff --git a/tests/test_channel_display.py b/tests/test_channel_display.py index 0e4a9a3a7f..f90298db9c 100644 --- a/tests/test_channel_display.py +++ b/tests/test_channel_display.py @@ -61,12 +61,12 @@ def test_default_off_and_opt_in_only_changes_display(tmp_path): before_delivery = comms.bus.pending_counts_opened(source, basis) before_bus = comms.bus.bus.page(comms.bus.bus.revision() + 1, 100) assert bodies(comms.views.history_page(CommandTarget.named("#api"))) == ["exact target"] - assert bodies(comms.views.channel_display_page("#api")) == ["exact target"] + assert bodies(comms.views.channel_display_view("#api").page(comms.views.presentation)) == ["exact target"] selected = comms.channels.set_channel_any_mode("#api", True) assert selected.exact and selected.any_mode assert wire(tmp_path).bus.require_channel("#api").any_mode - page = comms.views.channel_display_page("#api") + page = comms.views.channel_display_view("#api").page(comms.views.presentation) assert bodies(page) == [ "exact target", "member to outsider", @@ -77,12 +77,14 @@ def test_default_off_and_opt_in_only_changes_display(tmp_path): assert len({message.seq for message in page.messages}) == len(page.messages) assert bodies(comms.views.history_page(CommandTarget.named("#api"))) == ["exact target"] assert [message.body for message in list(comms.views.history_page(CommandTarget.named("#api")).messages)] == ["exact target"] - assert bodies(comms.views.channel_display_page("#ui")) == ["unrelated channel"] + assert bodies(comms.views.channel_display_view( + '#ui', + ).page(comms.views.presentation)) == ["unrelated channel"] assert comms.bus.bus.page(comms.bus.bus.revision() + 1, 100) == before_bus with comms.bus.snapshot(viewer=NamedViewer("alice")) as (basis, source): assert comms.bus.pending_counts_opened(source, basis) == before_delivery assert not comms.channels.set_channel_any_mode("api", False).any_mode - assert bodies(comms.views.channel_display_page("#api")) == ["exact target"] + assert bodies(comms.views.channel_display_view("#api").page(comms.views.presentation)) == ["exact target"] def test_mode_rejects_builtins_union_view_and_unknown_without_sidecar_mutation(tmp_path): @@ -102,7 +104,7 @@ def test_mode_rejects_builtins_union_view_and_unknown_without_sidecar_mutation(t comms.channels.set_channel_any_mode("#api", 1) assert (metadata.read_bytes() if metadata.exists() else None) == before with pytest.raises(ValueError): - comms.views.channel_display_page("#missing") + comms.views.channel_display_view("#missing").page(comms.views.presentation) def test_opt_in_user_input_activity_uses_same_member_scope(tmp_path): @@ -114,7 +116,9 @@ def test_opt_in_user_input_activity_uses_same_member_scope(tmp_path): on = comms.views.viewer_snapshot(Checkout(tmp_path)).channels["#api"] assert on.last_user_input > 0 assert comms.views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#api"].count == 0 - assert bodies(comms.views.channel_display_page("#api")) == ["human input to member"] + assert bodies(comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation)) == ["human input to member"] assert bodies(comms.views.history_page(CommandTarget.named("#api"))) == [] @@ -123,19 +127,23 @@ def test_alias_retag_and_tag_rename_reproject_without_new_wire_row(tmp_path): comms.messaging.send("alice", "bob", "old sender DM").message.message_id comms.channels.set_channel_any_mode("api", True) high_water = comms.bus.bus.revision() - assert bodies(comms.views.channel_display_page("#api")) == ["old sender DM"] + assert bodies(comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation)) == ["old sender DM"] initial = comms.views.viewer_snapshot(Checkout(tmp_path)).channels assert initial["#api"].last_activity > 0 comms.registry.rename("alice", "renamed") assert comms.bus.bus.revision() == high_water - assert bodies(comms.views.channel_display_page("#api")) == ["old sender DM"] + assert bodies(comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation)) == ["old sender DM"] renamed = comms.registry.require("renamed") comms.registry.register( replace(renamed, tags=frozenset({"ui"})), comms.registry.status("renamed") ) assert comms.bus.bus.revision() == high_water - assert bodies(comms.views.channel_display_page("#api")) == [] + assert bodies(comms.views.channel_display_view("#api").page(comms.views.presentation)) == [] after = comms.views.viewer_snapshot(Checkout(tmp_path)).channels assert after["#api"].last_activity < initial["#api"].last_activity @@ -145,7 +153,9 @@ def test_alias_retag_and_tag_rename_reproject_without_new_wire_row(tmp_path): assert comms.bus.require_channel("#services").any_mode with pytest.raises(ValueError): comms.bus.require_channel("#api") - assert bodies(comms.views.channel_display_page("#services")) == ["old sender DM"] + assert bodies(comms.views.channel_display_view( + '#services', + ).page(comms.views.presentation)) == ["old sender DM"] comms.channels.delete_tag("services") metadata = comms.channels.catalog.path assert not any( @@ -181,10 +191,14 @@ def test_page_boundaries_match_target_pager(tmp_path): comms.messaging.send("outsider", "#api", "second target").message.message_id comms.messaging.send("bob", "alice", "third projected").message.message_id comms.channels.set_channel_any_mode("#api", True) - rows = comms.views.channel_display_page("#api").messages - head = comms.views.channel_display_page("#api", bounds=PageBounds(NewerTraversal(), 1)) + rows = comms.views.channel_display_view("#api").page(comms.views.presentation).messages + head = comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation, bounds=PageBounds(NewerTraversal(), 1)) assert head.has_newer and not head.has_older - tail = comms.views.channel_display_page("#api", bounds=PageBounds(LiveCursor(rows[-1].seq).older(), 1)) + tail = comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation, bounds=PageBounds(LiveCursor(rows[-1].seq).older(), 1)) assert bodies(tail) == ["second target"] and tail.has_older and tail.has_newer @@ -212,16 +226,25 @@ def burst(): assert started.wait(timeout=3) try: for _ in range(20): - page = comms.views.channel_display_page("#api", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#api', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) if page.newest_seq is not None: comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) - dm = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + dm = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert bodies(dm) == ["DM original"] comms.views.mark_page_read(dm, worktree=Checkout(tmp_path)) finally: writer.join(timeout=10) assert finished.is_set() and not writer.is_alive() and not failures - page = comms.views.channel_display_page("#api", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#api', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) assert bodies(page) == [f"burst {n}" for n in range(20)] comms.registry.rename("alice", "renamed") with pytest.raises(DMScopeChanged): @@ -234,5 +257,7 @@ def test_views_answer_how_far_the_bus_has_grown(tmp_path): comms.messaging.send("outsider", "#api", "to a channel").message.message_id comms.messaging.send("alice", "bob", "to someone else").message.message_id # Every message counts, not only those one conversation shows. - newest_in_channel = comms.views.channel_display_page("#api").messages[-1].seq + newest_in_channel = comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation).messages[-1].seq assert comms.views.latest_sequence() == comms.bus.bus.revision() > newest_in_channel diff --git a/tests/test_channel_retirement.py b/tests/test_channel_retirement.py index 053d0a5017..5b79e120c1 100644 --- a/tests/test_channel_retirement.py +++ b/tests/test_channel_retirement.py @@ -74,9 +74,13 @@ def test_saved_projection_preserves_preferences_history_and_has_no_writable_alia assert set( next(v.members for v in comms.views.channel_views() if v.channel.name == "#engineering") ) == {"a", "b"} - tail = comms.views.channel_display_page("#engineering", bounds=PageBounds(limit=2)) + tail = comms.views.channel_display_view( + '#engineering', + ).page(comms.views.presentation, bounds=PageBounds(limit=2)) assert [m.seq for m in tail.messages] == [3, 4] - older = comms.views.channel_display_page("#engineering", bounds=PageBounds(LiveCursor(3).older(), 2)) + older = comms.views.channel_display_view( + '#engineering', + ).page(comms.views.presentation, bounds=PageBounds(LiveCursor(3).older(), 2)) assert [m.seq for m in older.messages] == [1, 2] assert [m.target for m in read_history(comms, "#engineering")] == [ m.target for m in rows diff --git a/tests/test_claim_bus_read.py b/tests/test_claim_bus_read.py index bc78c6914c..540f1e0fb7 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -218,7 +218,7 @@ def whole_bus(*_, **__): assert ConversationMessagePageRequest(dm_scope).read_opened(comms.bus.bus).messages == () raw_channel = comms.views.history_page(CommandTarget.named("#team")).messages assert [message.body for message in raw_channel] == ["@Alice investigate"] - channel = comms.views.channel_display_page("#team").messages + channel = comms.views.channel_display_view("#team").page(comms.views.presentation).messages assert [message.body for message in channel] == ["@Alice investigate"] assert all(message.role.notification_references(message) for message in channel) with pytest.raises(ValueError): diff --git a/tests/test_current_delivery.py b/tests/test_current_delivery.py index 48d1feac6d..3ab48cf5b8 100644 --- a/tests/test_current_delivery.py +++ b/tests/test_current_delivery.py @@ -54,7 +54,7 @@ def test_current_inbox_bulk_and_index_exclude_rebound_history(routed, rebound): with current.bus.snapshot(viewer=NamedViewer("recipient")) as (basis, source): assert current.bus.pending_counts_opened(source, basis) == {"sender": 1, "#team": 1} assert [row.seq for row in current.bus.inbox("recipient", CommandTarget.named("sender")).page.messages] == [new.seq] - assert old in current.views.channel_display_page("#any").messages + assert old in current.views.channel_display_view("#any").page(current.views.presentation).messages assert [row.body for row in current.views.history_page( CommandTarget.named("recipient"), viewer=NamedViewer("sender"), ).messages] == ["retained history", "current input"] diff --git a/tests/test_dm_scoped_ack.py b/tests/test_dm_scoped_ack.py index b663a6e4f7..cd9df558fb 100644 --- a/tests/test_dm_scoped_ack.py +++ b/tests/test_dm_scoped_ack.py @@ -30,7 +30,10 @@ def test_dm_page_marks_covered_range_preserving_omitted_older_unread(tmp_path: P viewer = comms.messaging.user_identity(Checkout(tmp_path)).name for index in range(50): comms.messaging.send("peer", viewer, f"old {index}").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path), bounds=PageBounds(limit=5)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation, bounds=PageBounds(limit=5)) assert len(page.messages) == 5 and page.has_older assert isinstance(page.selection, DMSelection) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) @@ -51,7 +54,10 @@ def test_peer_delete_same_name_rebind_rejects_stale_page_without_read_ack(tmp_pa comms.messaging.send("other", "#all", "OTHER_CHANNEL_4").message.message_id comms.messaging.send("peer", viewer, "PEER_PAINTED_5").message.message_id comms.messaging.send("other", viewer, "OTHER_UNPAINTED_6").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert [message.body for message in page.messages] == ["PEER_PAINTED_2", "PEER_PAINTED_5"] assert page.newest_seq == 5 and isinstance(page.selection, DMSelection) comms.registry.unregister("peer") @@ -75,7 +81,10 @@ def test_foreign_root_wrong_peer_and_unbounded_or_bool_through_rejected(tmp_path comms.registry.declare(_thread(comms.root, name)) viewer = comms.messaging.user_identity(Checkout(comms.root)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = first.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(first.root)) + page = first.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(first.root), + ).page(first.views.presentation) assert isinstance(page.selection, DMSelection) and page.newest_seq is not None for comms, target, through, error in ( (second, "peer", page.newest_seq, DMScopeChanged), @@ -97,7 +106,10 @@ def test_painted_bound_uses_existing_transcript_refresh_error(tmp_path: Path): comms.registry.declare(_thread(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) with pytest.raises(TranscriptChanged): page.selection.basis.acknowledge( comms.bus, comms.registry, comms.root, "peer", @@ -111,7 +123,10 @@ def test_root_replaced_after_fetch_rejects_mark_without_reading_new_root(tmp_pat comms.registry.declare(_thread(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted in original root").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) previous = tmp_path.with_name(tmp_path.name + "-previous") tmp_path.rename(previous) shutil.copytree(previous, tmp_path, symlinks=True) @@ -125,7 +140,10 @@ def test_independent_read_is_idempotent_and_never_retargets_page(tmp_path: Path) comms.registry.declare(_thread(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.views.mark_user_view_read("peer", worktree=Checkout(tmp_path)) comms.messaging.send("peer", viewer, "unpainted after explicit mark").message.message_id comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) @@ -135,7 +153,10 @@ def test_independent_read_is_idempotent_and_never_retargets_page(tmp_path: Path) def test_empty_dm_basis_is_no_op_without_creating_read_ledger(tmp_path: Path): comms = installed_comms(tmp_path) comms.registry.declare(_thread(tmp_path, "peer")) - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert page.selection.basis.displayed.tail == EmptyTail() page.selection.basis.acknowledge( comms.bus, comms.registry, comms.root, "peer", diff --git a/tests/test_dm_scoped_ack_successor.py b/tests/test_dm_scoped_ack_successor.py index e1a84b31f4..4f7117f3ad 100644 --- a/tests/test_dm_scoped_ack_successor.py +++ b/tests/test_dm_scoped_ack_successor.py @@ -42,7 +42,10 @@ def test_alias_rename_invalidates_old_basis_but_fresh_alias_page_can_mark(tmp_pa comms.registry.declare(_peer(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "before rename").message.message_id - old = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + old = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.threads.rename_managed_thread("peer", "renamed", owner=ProcessIdentity.capture(os.getpid())) markers = tmp_path / "read_ledger.json" marker_before = markers.read_bytes() if markers.exists() else None @@ -53,7 +56,10 @@ def test_alias_rename_invalidates_old_basis_but_fresh_alias_page_can_mark(tmp_pa # The old spelling is now an alias for the SAME incarnation. A fresh # page, not stale name text, is authoritative after the rename. - fresh = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + fresh = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(fresh.selection, DMSelection) and fresh.selection.basis.peer == "renamed" _mark(comms, "peer", tmp_path, fresh) assert comms.bus.pending_count(viewer, CommandTarget.named("renamed")) == 0 @@ -66,7 +72,10 @@ def test_delete_and_same_name_reregister_invalidates_old_peer_basis(tmp_path: Pa comms.registry.declare(_peer(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "old peer painted").message.message_id - old = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + old = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(old.selection, DMSelection) comms.registry.unregister("peer") comms.registry.remove("peer") @@ -80,7 +89,10 @@ def test_delete_and_same_name_reregister_invalidates_old_peer_basis(tmp_path: Pa _mark(comms, "peer", tmp_path, old) assert (markers.read_bytes() if markers.exists() else None) == marker_before assert comms.bus.pending_count(viewer, CommandTarget.named("peer")) == 1 - fresh = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + fresh = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(fresh.selection, DMSelection) assert fresh.selection.basis.peer_identity != old.selection.basis.peer_identity @@ -90,7 +102,10 @@ def test_viewer_rebind_and_foreign_worktree_reject_old_basis(tmp_path: Path): comms.registry.declare(_peer(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "old viewer painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(page.selection, DMSelection) with pytest.raises(DMScopeChanged): comms.views.mark_page_read(page, worktree=Checkout(tmp_path / "foreign")) @@ -110,7 +125,10 @@ def test_old_viewer_alive_but_new_human_selected_rejects_stale_basis(tmp_path: P comms.registry.declare(_peer(tmp_path, "peer")) old_viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", old_viewer, "old viewer painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(page.selection, DMSelection) and page.selection.basis.viewer == old_viewer comms.registry.declare(Thread('new_user', frozenset(), Checkout(tmp_path), role=UserThreadRole)) # Normal registry rename retains the old human declaration but moves its @@ -135,7 +153,10 @@ def test_newest_page_mark_leaves_older_message_unread(tmp_path: Path): viewer = comms.messaging.user_identity(Checkout(tmp_path)).name older = comms.messaging.send("peer", viewer, "older").message newest = comms.messaging.send("peer", viewer, "newest").message - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path), bounds=PageBounds(limit=1)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation, bounds=PageBounds(limit=1)) assert page.messages == (newest,) _mark(comms, "peer", tmp_path, page) assert comms.bus.inbox(viewer, CommandTarget.named("peer")).page.messages == (older,) @@ -147,7 +168,10 @@ def test_lifecycle_and_marker_lock_order_does_not_deadlock(tmp_path: Path): comms.registry.declare(_peer(tmp_path, name)) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) with ThreadPoolExecutor(max_workers=2) as pool: marked = pool.submit(_mark, comms, "peer", tmp_path, page) lifecycle = pool.submit( diff --git a/tests/test_goal_reply_lock_scope.py b/tests/test_goal_reply_lock_scope.py index 7889f184f6..01ba3b50f1 100644 --- a/tests/test_goal_reply_lock_scope.py +++ b/tests/test_goal_reply_lock_scope.py @@ -79,8 +79,8 @@ def __init__(self, path, change): self.changed = False @contextmanager - def reading(self): - with super().reading() as source: + def reading(self, *, check_same_thread: bool = True): + with super().reading(check_same_thread=check_same_thread) as source: yield source if not self.changed: self.changed = True diff --git a/tests/test_historical_page_scope.py b/tests/test_historical_page_scope.py index 62c16f8d3b..5198ffd1bc 100644 --- a/tests/test_historical_page_scope.py +++ b/tests/test_historical_page_scope.py @@ -86,7 +86,9 @@ def reader(live, kind): return lambda bounds: live.views.history_page(CommandTarget.named("carol"), bounds, viewer=NamedViewer(name)) if kind == "any-mode": live.channels.set_channel_any_mode("#api", True) - return lambda bounds: live.views.channel_display_page("#api", bounds=bounds) + return lambda bounds: live.views.channel_display_view( + '#api', + ).page(live.views.presentation, bounds=bounds) target = { "channel": "#api", "any": "#any", diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index 100fca0dac..98c914f58d 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -17,7 +17,7 @@ from agent_comms.coordination_errors import HistoricalSourceChanged from agent_comms.field_codec import InputId from agent_comms.turn_item import FreshNativeId -from agent_comms.historical_views import HistoricalMessage, HistoryArchive, HistorySource +from agent_comms.historical_views import HistoricalMessage, HistoryArchive, HistorySource, HistoryView from agent_comms.installer.backup import bus_files from agent_comms.message_page import MessagePageRequest, PageBounds from agent_comms.messages import Message @@ -75,7 +75,10 @@ def test_captured_catalogue_refuses_later_creation_or_replacement(tmp_path, exis def test_held_display_reading_rejects_later_archive_without_bus_growth(migrated, tmp_path): _, _, live, _ = migrated - with live.views.channel_display_reading('#team', viewer=NamedViewer('alice')) as read: + with live.views.channel_display_view( + '#team', + viewer=NamedViewer('alice'), + ).reading(live.views.presentation) as read: first = read(PageBounds(limit=1)) revision = live.bus.bus.revision() later = setup(tmp_path / 'later', 1) @@ -84,9 +87,10 @@ def test_held_display_reading_rejects_later_archive_without_bus_growth(migrated, with pytest.raises(HistoricalSourceChanged): read(PageBounds(first.oldest_cursor.older(), 1)) # Retiring the old reading is harmless; a new reading adopts the catalogue. - fresh = live.views.channel_display_page( - '#team', viewer=NamedViewer('alice'), bounds=PageBounds(limit=1), - ) + fresh = live.views.channel_display_view( + '#team', + viewer=NamedViewer('alice'), + ).page(live.views.presentation, bounds=PageBounds(limit=1)) assert fresh.selection.history_revision != first.selection.history_revision @@ -101,13 +105,13 @@ def setup(root, count=5): return comms -def collect(comms, method, *args, **kwargs): - page = getattr(comms.views, method)(*args, bounds=PageBounds(limit=3), **kwargs) +def collect(comms, view: HistoryView): + page = view.page(comms.views.presentation, PageBounds(limit=3)) result = list(page.messages) for _ in range(40): if not page.has_older: return result - page = getattr(comms.views, method)(*args, bounds=PageBounds(page.oldest_cursor.older(), 3), **kwargs) + page = view.page(comms.views.presentation, PageBounds(page.oldest_cursor.older(), 3)) result[0:0] = page.messages raise AssertionError("paging did not terminate") @@ -125,18 +129,20 @@ def migrated(tmp_path): @pytest.mark.parametrize( - "method,args,reader", + "view_factory,expected", [ - ("channel_display_page", ("#team",), lambda live: dict(viewer=live.views.display_viewer(Checkout(live.root)))), - ("dm_display_page", (ThreadTarget("alice"),), lambda live: dict(worktree=Checkout(live.root))), - ("channel_display_page", ("#any",), lambda live: dict(viewer=live.views.display_viewer(Checkout(live.root)))), + (lambda live: live.views.channel_display_view( + "#team", viewer=live.views.display_viewer(Checkout(live.root))), 9), + (lambda live: live.views.dm_display_view( + ThreadTarget("alice"), worktree=Checkout(live.root)), 9), + (lambda live: live.views.channel_display_view( + "#any", viewer=live.views.display_viewer(Checkout(live.root))), 18), ], ) -def test_all_history_backward_and_forward(migrated, method, args, reader): +def test_all_history_backward_and_forward(migrated, view_factory, expected): old, prior, live, source = migrated - scope = reader(live) - rows = collect(live, method, *args, **scope) - expected = 18 if args == ("#any",) else 9 + view = view_factory(live) + rows = collect(live, view) assert len(rows) == expected assert len({row.view_key for row in rows}) == expected assert len({row.seq for row in rows}) < expected @@ -144,7 +150,7 @@ def test_all_history_backward_and_forward(migrated, method, args, reader): forward = [] cursor = HistoryCursor(source=source.key, sequence=0) for _ in range(40): - page = getattr(live.views, method)(*args, bounds=PageBounds(cursor.newer(), 2), **scope) + page = view.page(live.views.presentation, PageBounds(cursor.newer(), 2)) forward.extend(page.messages) if not page.has_newer: break @@ -154,7 +160,9 @@ def test_all_history_backward_and_forward(migrated, method, args, reader): def test_original_identity_and_no_delivery_authority(migrated): old, prior, live, source = migrated - rows = collect(live, "channel_display_page", "#team", viewer=live.views.display_viewer(Checkout(live.root))) + rows = collect(live, live.views.channel_display_view( + "#team", viewer=live.views.display_viewer(Checkout(live.root)), + )) historical = [m for m in rows if isinstance(m, HistoricalMessage) and m.source == source] original = list(old.views.history_page(CommandTarget.named("#team")).messages) assert [m.to_wire() for m in historical] == [Message.from_message(m).to_wire() for m in original] @@ -168,8 +176,11 @@ def test_original_identity_and_no_delivery_authority(migrated): def test_sparse_historical_ack_does_not_ack_live(migrated): _, _, live, source = migrated viewer = live.messaging.user_identity(Checkout(live.root)) - page = live.views.channel_display_page( - "#team", viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), + page = live.views.channel_display_view( + '#team', + viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), + ).page( + live.views.presentation, bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older(), 3), ) live.views.mark_page_read(page.select([page.messages[1].seq]), worktree=Checkout(live.root)) @@ -196,11 +207,15 @@ def test_select_painted_page_keeps_its_source_with_attached_history(migrated, co _, _, live, source = migrated bounds = PageBounds(HistoryCursor(source=source.key, sequence=100).older(), 2) if archived else PageBounds(limit=2) if conversation == "channel": - page = live.views.channel_display_page( - "#team", viewer=live.views.display_viewer(Checkout(live.root)), bounds=bounds, - ) + page = live.views.channel_display_view( + '#team', + viewer=live.views.display_viewer(Checkout(live.root)), + ).page(live.views.presentation, bounds=bounds) else: - page = live.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(live.root), bounds=bounds) + page = live.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(live.root), + ).page(live.views.presentation, bounds=bounds) message = page.messages[-1] selected = page.select((message.seq,)) assert selected.messages == (message,) @@ -272,7 +287,12 @@ def test_duplicate_and_newer_incarnations_sessions(tmp_path): def test_stale_cursor_is_rejected(migrated): _, _, live, _ = migrated with pytest.raises(HistoricalSourceChanged): - live.views.channel_display_page("#team", bounds=PageBounds(HistoryCursor(source="detached", sequence=2).older())) + live.views.channel_display_view( + '#team', + ).page( + live.views.presentation, + bounds=PageBounds(HistoryCursor(source='detached', sequence=2).older()), + ) def test_recorded_transcript_witness_roundtrip_keeps_original_namespace(tmp_path): @@ -361,10 +381,10 @@ def test_attach_only_reads_original_roots_and_rejects_changed_snapshot(tmp_path) source = live.views.attach_history(old.root) assert root_bytes(old.root) == original assert not (Path(source.root) / "unknown-input.json").exists() - page = live.views.channel_display_page( - "#team", viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), - bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older()), - ) + page = live.views.channel_display_view( + '#team', + viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), + ).page(live.views.presentation, bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older())) archived = Bus(Bus.path_in(Path(source.root))) original = page.messages[0] archived.append(lambda seq, db: PublicRecord.from_message(Message( @@ -372,8 +392,11 @@ def test_attach_only_reads_original_roots_and_rejects_changed_snapshot(tmp_path) with pytest.raises(ValueError): live.views.mark_page_read(page, worktree=Checkout(live.root)) with pytest.raises(ValueError): - live.views.channel_display_page( - "#team", bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older()) + live.views.channel_display_view( + '#team', + ).page( + live.views.presentation, + bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older()), ) @@ -392,7 +415,10 @@ def test_missing_historical_peer_is_browsable_without_live_registration(migrated _, _, live, source = migrated live.registry.unregister("alice") live.registry.remove("alice") - page = live.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(live.root)) + page = live.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(live.root), + ).page(live.views.presentation) assert page.messages assert all(isinstance(message, HistoricalMessage) for message in page.messages) assert "alice" not in live.registry diff --git a/tests/test_mark_all_existing_reads.py b/tests/test_mark_all_existing_reads.py index 12248cb4cb..12b8292971 100644 --- a/tests/test_mark_all_existing_reads.py +++ b/tests/test_mark_all_existing_reads.py @@ -11,7 +11,10 @@ def test_mark_all_after_partial_reads_preserves_next_unread_message(tmp_path): viewer = comms.messaging.user_identity(Checkout(tmp_path)).name first = comms.messaging.send('alice', '#team', 'already painted').message comms.messaging.send('bob', '#team', 'not painted').message - page = comms.views.channel_display_page('#team', viewer=NamedViewer(viewer)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(viewer), + ).page(comms.views.presentation) selection = page.selection scope = selection.scope page = replace(page, selection=replace(selection, scope=replace( diff --git a/tests/test_named_target_contracts.py b/tests/test_named_target_contracts.py index 004e87c76c..7ec10aed53 100644 --- a/tests/test_named_target_contracts.py +++ b/tests/test_named_target_contracts.py @@ -82,7 +82,7 @@ def test_retained_dm_target_follows_rename_through_send_page_and_revision(tmp_pa delivery = comms.bus.bus.delivery(sent.seq) assert [(row.canonical_thread, row.recipient_lookup) for row in delivery.recipients] == [ ("renamed", target.incarnation.require_recorded().lookup)] - page = comms.views.dm_display_page(target, worktree=worktree) + page = comms.views.dm_display_view(target, worktree=worktree).page(comms.views.presentation) assert page.messages == (Message.from_message(original), Message.from_message(sent)) assert page.selection.basis.peer_identity == target.incarnation generic_page = comms.views.history_page(target, viewer=NamedViewer(sent.sender)) @@ -115,7 +115,7 @@ def test_retained_dm_refuses_a_removed_birth_at_every_selection_boundary(tmp_pat comms.messaging.send_user_message(target, "must not reach successor", worktree=worktree) assert comms.bus.bus.revision() == before with pytest.raises(UnregisteredThreadError): - comms.views.dm_display_page(target, worktree=worktree) + comms.views.dm_display_view(target, worktree=worktree).page(comms.views.presentation) with pytest.raises(UnregisteredThreadError): DMDisplayHistory.revision(comms.views, target, ConversationObservation(), worktree=worktree) with pytest.raises(UnregisteredThreadError): @@ -150,6 +150,6 @@ def replace_before_read(request, source): monkeypatch.setattr(ConversationMessagePageRequest, "read_opened", replace_before_read) with pytest.raises(DMScopeChanged): - comms.views.dm_display_page(target, worktree=worktree) + comms.views.dm_display_view(target, worktree=worktree).page(comms.views.presentation) assert len(successor_messages) == 1 assert comms.bus.bus.lookup(successor_messages[0].seq) == successor_messages[0] diff --git a/tests/test_ordinary_nk_delivery.py b/tests/test_ordinary_nk_delivery.py index 7f77aef6e5..2194fc9c28 100644 --- a/tests/test_ordinary_nk_delivery.py +++ b/tests/test_ordinary_nk_delivery.py @@ -33,4 +33,7 @@ def test_fresh_send_uses_canonical_publication_and_human_delivery_has_no_wake(tm assert isinstance(human.message.role, NoticeRole) assert human.recipients == () assert human.decisions == () - assert comms.views.dm_display_page(ThreadTarget("sender"), worktree=Checkout(tmp_path)).messages[-1] == reply + assert comms.views.dm_display_view( + ThreadTarget('sender'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation).messages[-1] == reply diff --git a/tests/test_read_ledger.py b/tests/test_read_ledger.py index 2962856e2f..5f082fe8aa 100644 --- a/tests/test_read_ledger.py +++ b/tests/test_read_ledger.py @@ -150,7 +150,10 @@ def test_bounded_page_marks_only_shown_messages(tmp_path): comms = prepared(tmp_path) for i in range(5): comms.messaging.send("alice", "#team", f"message {i}").message.message_id - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), bounds=PageBounds(limit=2)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation, bounds=PageBounds(limit=2)) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) assert wire(tmp_path).views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#team"].count == 3 @@ -158,7 +161,10 @@ def test_bounded_page_marks_only_shown_messages(tmp_path): def test_displayed_message_is_read_in_every_projection(tmp_path): comms = prepared(tmp_path) comms.messaging.send("alice", "#team", "visible in both views").message.message_id - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) assert wire(tmp_path).views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#any"].count == 0 @@ -169,7 +175,10 @@ def test_any_mode_expansion_cannot_read_unshown_message_in_same_conversation(tmp hidden = comms.messaging.send("bob", "carol", "no member mentioned").message shown = comms.messaging.send("bob", "carol", "@alice shown by mention").message comms.channels.set_channel_any_mode("#team", True) - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) assert [message.seq for message in page.messages] == [shown.seq] comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) comms.channels.update_tags("bob", add=frozenset({"team"})) @@ -189,10 +198,16 @@ def test_turn_lease_and_unrelated_registry_changes_do_not_invalidate_dm(tmp_path comms.registry.register(replace(peer, process_identity=ProcessIdentity.capture(os.getpid()))) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("alice", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert page.selection.basis.peer_identity == peer.incarnation comms.registry.lease_local_turn("alice", TurnId('0b225c32b06b9107d59b5d92d13b83a3')) - fresh = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + fresh = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert fresh.selection.basis.peer_identity == page.selection.basis.peer_identity comms.registry.register(Thread('unrelated', frozenset(), Checkout(tmp_path))) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) @@ -213,9 +228,11 @@ def test_dm_ack_survives_process_exit_and_rebind_does_not_inherit_reads(tmp_path "import os, sys\n" "from agent_comms.channel_targets import ThreadTarget\n" "from agent_comms.comms import wire\n" + "from agent_comms.repo import Checkout\n" "comms = wire(sys.argv[1])\n" - "page = comms.views.dm_display_page(ThreadTarget('alice'), worktree=sys.argv[1])\n" - "comms.views.mark_page_read(page, worktree=sys.argv[1])\n" + "project = Checkout(sys.argv[1])\n" + "page = comms.views.dm_display_view(ThreadTarget('alice'), worktree=project).page(comms.views.presentation)\n" + "comms.views.mark_page_read(page, worktree=project)\n" "os._exit(9)\n", str(tmp_path), ], @@ -243,12 +260,12 @@ def test_shown_only_property_across_pages_modes_and_reopen(tmp_path, seed): for i in range(12)] for _ in range(18): comms.channels.set_channel_any_mode("#team", rng.choice([True, False])) - page = comms.views.channel_display_page( - rng.choice(["#team", "#any"]), + page = comms.views.channel_display_view( + rng.choice(['#team', '#any']), viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), - bounds=PageBounds( - limit=rng.randrange(1, 8), traversal=FieldCodec.decode(PageCursor, rng.choice([None, 6, 10])).older() - ), + ).page( + comms.views.presentation, + bounds=PageBounds(limit=rng.randrange(1, 8), traversal=FieldCodec.decode(PageCursor, rng.choice([None, 6, 10])).older()), ) if page.messages: through = rng.choice(page.messages).seq @@ -324,7 +341,10 @@ def test_replaced_bus_keeps_no_sequence_read_facts(tmp_path): comms = prepared(tmp_path) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("alice", "#team", "painted").message - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) replacement = tmp_path / "replacement.sqlite3" comms.bus.bus.backup(replacement) @@ -335,7 +355,10 @@ def test_replaced_bus_keeps_no_sequence_read_facts(tmp_path): from agent_comms.coordination_errors import DMScopeChanged with pytest.raises(DMScopeChanged): reopened.views.mark_page_read(page, worktree=Checkout(tmp_path)) - fresh = reopened.views.channel_display_page("#team", viewer=NamedViewer(viewer)) + fresh = reopened.views.channel_display_view( + '#team', + viewer=NamedViewer(viewer), + ).page(reopened.views.presentation) reopened.views.mark_page_read(fresh, worktree=Checkout(tmp_path)) assert reopened.views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#team"].count == 0 @@ -354,7 +377,10 @@ def test_recorded_bus_identity_is_one_member(tmp_path): assert not ledger.read().bus_identity.matches(ObservedPath.at(comms.bus.bus.path)) assert comms.views.viewer_snapshot(Checkout(tmp_path)).unread["alice"].count == 1 - page = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) recorded = RecordedBus(FileIdentity.from_stat(comms.bus.bus.path.stat())) assert ledger.read().bus_identity == recorded == page.selection.basis.bus_identity @@ -375,7 +401,10 @@ def test_recorded_bus_identity_is_one_member(tmp_path): assert reopened.views.viewer_snapshot(Checkout(tmp_path)).unread["alice"].count == 1 with pytest.raises(DMScopeChanged): reopened.views.mark_page_read(page, worktree=Checkout(tmp_path)) - fresh = reopened.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + fresh = reopened.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(reopened.views.presentation) reopened.views.mark_page_read(fresh, worktree=Checkout(tmp_path)) assert reopened.views.viewer_snapshot(Checkout(tmp_path)).unread["alice"].count == 0 @@ -383,7 +412,10 @@ def test_recorded_bus_identity_is_one_member(tmp_path): def test_partial_paints_share_one_basis_across_read_progress(tmp_path): comms = prepared(tmp_path) messages = [comms.messaging.send("alice", "#team", f"row {i}").message for i in range(5)] - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), bounds=PageBounds(limit=3)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation, bounds=PageBounds(limit=3)) scope = page.selection.scope assert scope is not None and scope.displayed is not None # Requested sequences outside the original page cannot become read facts. @@ -448,13 +480,19 @@ def current_cursor(facts): comms.channels.set_channel_any_mode("#team", rng.choice([True, False])) elif operation == "display": if rng.choice([True, False]): - page = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path), bounds=PageBounds(limit=3)) + page = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation, bounds=PageBounds(limit=3)) basis = page.selection.basis.displayed kind, target = "dm", "alice" proof = page.selection.basis else: target = rng.choice(["#team", "#any"]) - page = comms.views.channel_display_page(target, viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), bounds=PageBounds(limit=3)) + page = comms.views.channel_display_view( + target, + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation, bounds=PageBounds(limit=3)) basis = page.selection.scope.displayed kind, proof = "channel", page.selection.scope selected = {m.seq for m in page.messages if rng.choice([True, False])} diff --git a/tests/test_s4_read_characterization.py b/tests/test_s4_read_characterization.py index 290ebc5e03..56ccfe7f74 100644 --- a/tests/test_s4_read_characterization.py +++ b/tests/test_s4_read_characterization.py @@ -17,7 +17,10 @@ def test_replaced_human_incarnation_rejects_painted_proof_and_prior_reads(tmp_pa comms.registry.declare(Thread('worker', frozenset({'team'}), Checkout(tmp_path))) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name message = comms.messaging.send("worker", "#team", "painted").message - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) assert message.seq in comms.bus.reads.cursor(viewer, comms.registry.snapshot()).for_conversation(ReadLedger.conversation(message, comms.registry.snapshot())) original = comms.registry.require(viewer) @@ -27,7 +30,10 @@ def test_replaced_human_incarnation_rejects_painted_proof_and_prior_reads(tmp_pa assert message.seq not in comms.bus.reads.cursor(viewer, comms.registry.snapshot()).for_conversation(ReadLedger.conversation(message, comms.registry.snapshot())) with pytest.raises(DMScopeChanged): comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) - refreshed = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + refreshed = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(refreshed, worktree=Checkout(tmp_path)) assert message.seq in comms.bus.reads.cursor(viewer, comms.registry.snapshot()).for_conversation(ReadLedger.conversation(message, comms.registry.snapshot())) diff --git a/tests/test_thread_identity.py b/tests/test_thread_identity.py index 7a0afa1882..4edfb0275b 100644 --- a/tests/test_thread_identity.py +++ b/tests/test_thread_identity.py @@ -257,7 +257,10 @@ def test_saved_read_ledger_survives_reopen_and_new_ack(tmp_path): comms.registry.declare(Thread('owner', frozenset(), Checkout(tmp_path))) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name message = comms.messaging.send("owner", viewer, "already painted").message - page = comms.views.dm_display_page(ThreadTarget("owner"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('owner'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) path = comms.bus.reads.path document = FieldCodec.decode(ReadDocument, json.loads(path.read_text())) @@ -271,7 +274,10 @@ def test_saved_read_ledger_survives_reopen_and_new_ack(tmp_path): conversation = reopened.bus.reads.conversation(message, snapshot) assert message.seq in reopened.bus.reads.cursor(viewer, snapshot).for_conversation(conversation) next_message = reopened.messaging.send("owner", viewer, "new paint").message - page = reopened.views.dm_display_page(ThreadTarget("owner"), worktree=Checkout(tmp_path)) + page = reopened.views.dm_display_view( + ThreadTarget('owner'), + worktree=Checkout(tmp_path), + ).page(reopened.views.presentation) reopened.views.mark_page_read(page, worktree=Checkout(tmp_path)) seen = reopened.bus.reads.cursor(viewer, reopened.registry.snapshot()).for_conversation(conversation) assert all(sequence in seen for sequence in (message.seq, next_message.seq)) diff --git a/tests/test_user_thread_lookup.py b/tests/test_user_thread_lookup.py index 4222093b6a..cbe690e53a 100644 --- a/tests/test_user_thread_lookup.py +++ b/tests/test_user_thread_lookup.py @@ -41,9 +41,14 @@ def test_channel_and_dm_history_keep_the_human_as_sender(tmp_path): human = comms.registry.user_thread().existing()[0] assert channel.sender == direct.sender == human.name assert channel.sender_role is direct.sender_role is UserThreadRole - channel_page = comms.views.channel_display_page( - '#comms', viewer=comms.views.display_viewer(Checkout(tmp_path))) - direct_page = comms.views.dm_display_page(ThreadTarget('peer'), worktree=Checkout(tmp_path)) + channel_page = comms.views.channel_display_view( + '#comms', + viewer=comms.views.display_viewer(Checkout(tmp_path)), + ).page(comms.views.presentation) + direct_page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert channel.message_id in {message.message_id for message in channel_page.messages} assert direct.message_id in {message.message_id for message in direct_page.messages} assert all(message.sender == human.name for message in (*channel_page.messages, *direct_page.messages)) diff --git a/tests/test_view_unread.py b/tests/test_view_unread.py index 834c3215fd..421673b1f9 100644 --- a/tests/test_view_unread.py +++ b/tests/test_view_unread.py @@ -98,7 +98,10 @@ def test_any_mode_rejects_stale_painted_page_after_participant_joins(tmp_path): comms.channels.set_channel_any_mode("#team", True) comms.messaging.send("carol", "dave", "hidden old DM").message comms.messaging.send("alice", "bob", "painted DM").message - painted = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + painted = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) assert [message.seq for message in painted.messages] == [2] comms.channels.update_tags("carol", add=frozenset({"team"})) @@ -131,5 +134,7 @@ def test_crashed_sender_reopen_does_not_hide_unpainted_any_mode_dm(): assert child.returncode == 9 reopened = wire(root) reopened.channels.set_channel_any_mode("#team", True) - assert [message.seq for message in list(reopened.views.channel_display_page("#team").messages)] == [1, 2] + assert [message.seq for message in list(reopened.views.channel_display_view( + '#team', + ).page(reopened.views.presentation).messages)] == [1, 2] assert reopened.views.viewer_snapshot(Checkout(root)).channel_unread["#team"].count == 2 From 33e7362e2ad3be44af52598e5e0f1a099b780603 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 09:02:39 -0400 Subject: [PATCH 50/74] Capture checkout refs and commit history once per heartbeat read --- src/agent_comms/commander/order.py | 5 +- src/agent_comms/heartbeat_frame.py | 3 +- src/agent_comms/phase_evidence.py | 76 +++++--------- src/agent_comms/repo.py | 147 +++++++++++++++++++++++++++- src/agent_comms/todos.py | 65 +++--------- src/agent_comms/work_branches.py | 77 ++++++++------- tests/test_order_progress.py | 2 +- tests/test_watch_git_preparation.py | 15 +-- tools/agent_comms_project.py | 5 +- 9 files changed, 244 insertions(+), 151 deletions(-) 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/heartbeat_frame.py b/src/agent_comms/heartbeat_frame.py index c307e1c914..b685982997 100644 --- a/src/agent_comms/heartbeat_frame.py +++ b/src/agent_comms/heartbeat_frame.py @@ -41,13 +41,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) diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index 7d42c31c2c..c25cf7140e 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -16,7 +16,7 @@ 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 pathlib import Path, PurePosixPath from threading import Lock from subprocess import TimeoutExpired @@ -25,8 +25,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,20 +64,23 @@ 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] @@ -154,37 +156,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 +235,9 @@ 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: + if main == self.main: + 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 +256,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 +275,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 +291,17 @@ 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) + retained = reading.following(reading.todos, reading.main) with self.lock: self.plans = (*(plan for plan in self.plans if plan.main.repo != retained.main.repo), retained) @@ -378,6 +353,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/repo.py b/src/agent_comms/repo.py index 6c9618a93d..e011a5df1e 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 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,10 @@ def prepared(cls, path: Path) -> Self: checkout.prepare() return checkout + 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 () @@ -590,6 +597,144 @@ def retire(self) -> None: Repo.git(common.parent, "worktree", "remove", "--force", str(self.path)) +@dataclass(frozen=True) +class GitReference: + """One ref, including HEAD's symbolic target and a local branch's upstream.""" + + name: str + commit: Annotated[str, GitCommitText] + symbolic: str + upstream: str + remote: str + remote_ref: str + + @classmethod + def listed(cls, row: str) -> GitReference: + name, commit, symbolic, upstream, remote, remote_ref = row.split("\0") + return cls(name, GitCommitText.decode(commit), symbolic, upstream, remote, remote_ref) + + +@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 + + +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() + + @cached_property + def refs(self) -> dict[str, GitReference]: + return {reference.name: reference for reference in map(GitReference.listed, Repo.git( + self.checkout.path, "for-each-ref", "--include-root-refs", + "--format=%(refname)%00%(objectname)%00%(symref)%00%(upstream)%00%(upstream:remotename)%00%(upstream:remoteref)", + "HEAD", "refs/heads/", "refs/remotes/", + ).splitlines())} + + 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())))) + if not heads: + return {} + text = Repo.git(self.checkout.path, "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 + 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)) + + def ancestors(self, *revisions: str) -> set[str]: + """Reachability over the admitted graph; shallow boundaries remain graph leaves.""" + pending = [self.resolve(revision) for revision in revisions] + found: set[str] = set() + while pending: + sha = pending.pop() + if sha not in found: + found.add(sha) + pending.extend(parent for parent in self.graph[sha].parents if parent in self.graph) + return found + + def contains(self, ancestor: str, revision: str) -> bool: + return self.resolve(ancestor) in self.ancestors(revision) + + def commits(self, refs: tuple[str, ...], *excluded: str, + pattern: str = "", first_parent: bool = False) -> Iterator[GitCommit]: + """Git's newest-first revision walk, excluding complete ancestor closures.""" + seen = self.ancestors(*excluded) + pending: list[tuple[float, int, str]] = [] + sequence = 0 + for ref in refs: + commit = self.commit(ref) + heapq.heappush(pending, (-commit.at, sequence, commit.sha)) + sequence += 1 + matching = re.compile(pattern, re.IGNORECASE | re.MULTILINE) + while pending: + _at, _sequence, sha = heapq.heappop(pending) + if sha in seen: + continue + seen.add(sha) + commit = self.graph[sha] + if matching.search(commit.message): + yield commit + parents = commit.parents[:1] if first_parent else commit.parents + for parent in parents: + if parent not in seen and parent in self.graph: + heapq.heappush(pending, (-self.graph[parent].at, sequence, parent)) + sequence += 1 + + def merge_base(self, left: str, right: str) -> tuple[str, ...]: + """Best common ancestors: a common ancestor of another candidate is not best.""" + common = self.ancestors(left) & self.ancestors(right) + redundant: set[str] = set() + for sha in common: + redundant.update(parent for parent in self.graph[sha].parents if parent in common) + best = common - redundant + return tuple(sorted(best, key=lambda sha: self.graph[sha].at, reverse=True)) + + def count(self, head: str, *excluded: str) -> int: + return len(self.ancestors(head) - self.ancestors(*excluded)) + + @dataclass(frozen=True) class SubmoduleTransfer: """An initialized module's own Git directory and its original worktree setting.""" diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index e4fe05eb7d..1570267138 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -1246,25 +1246,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 @@ -1437,20 +1418,16 @@ class PresentCheckout(ProgressCheckout): 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.history.resolve("HEAD") def require_read(self) -> None: self.head @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 +1438,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("}")))) @@ -1489,26 +1466,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 +1495,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 +1511,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) @@ -1567,6 +1533,7 @@ def __init__(self) -> None: def checkout(self, path: Path) -> PresentCheckout: """This read's one reader of the checkout at path.""" + path = path.resolve() return self._checkouts.setdefault(path, PresentCheckout(path)) def progress_checkout(self, path: Path) -> ProgressCheckout: diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index 72f7480341..bcc808e79c 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -25,7 +25,7 @@ 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 Checkout, CheckoutHistory, GitCommandError, GitReference, Repo from .repository_name import RepositoryName from .wire_models import WireModel @@ -280,13 +280,13 @@ class BranchUpstream(DeclaredFamily): branch: str @classmethod - def from_git(cls, row: str) -> BranchUpstream: - branch, tracking, remote, reference = row.split("\0") - if not tracking: + def from_reference(cls, reference: GitReference) -> BranchUpstream: + branch = reference.name.removeprefix("refs/heads/") + if not reference.upstream: return NoUpstream(branch) - if remote == Repo.local_remote: - return LocalUpstream(branch, tracking) - return PublishedUpstream(branch, tracking, remote, reference) + if reference.remote == Repo.local_remote: + return LocalUpstream(branch, reference.upstream) + return PublishedUpstream(branch, reference.upstream, reference.remote, reference.remote_ref) def publication_inputs(self) -> tuple[tuple[str, str], ...]: """Only published upstreams supply remote/reference acquisition requirements.""" @@ -301,10 +301,9 @@ def tracking_name(self) -> str: 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)) + remotes = tuple(ref for ref in reader.history.references("refs/remotes/") + if ref != tracking) + count = reader.history.count(head, *remotes) return (UnpushedHead(head, count, tracking),) if count else () @@ -337,14 +336,14 @@ 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)) + for head in reader.selected_heads(self.remote, self.reference): + reader.history.commit(head) 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}")) + count = reader.history.count(head, remote_head) return (UnpushedHead(head, count, self.tracking),) if count else () return super().unpushed(reader, head) @@ -397,10 +396,14 @@ 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 Checkout(self.checkout).read() + 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.refs except GitCommandError as error: return UnreadableCheckoutRead(self.checkout, str(error)) return ReadableCheckoutRead(self) @@ -429,9 +432,8 @@ 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.count( + "HEAD", *self.history.references("refs/remotes/"))) 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 +468,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.history.head.name), + *(BranchUpstream.from_reference(reference) for reference in self.history.refs.values() + if reference.name.startswith("refs/heads/")), )} def prepare_upstreams(self) -> None: @@ -495,6 +496,7 @@ def prepare_upstreams(self) -> None: heads = tuple(dict.fromkeys( 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 +523,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.""" @@ -564,7 +562,7 @@ def require_upstream(self, branch: str) -> BranchUpstream: 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) + self.history.commit(head) return self.require_upstream(branch).unpushed(self, head) @cached_property @@ -574,7 +572,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,9 +591,12 @@ 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.history.refs["HEAD"].symbolic.removeprefix("refs/heads/") or "HEAD" + 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, 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_watch_git_preparation.py b/tests/test_watch_git_preparation.py index 08d9e4a724..7ccf38592b 100644 --- a/tests/test_watch_git_preparation.py +++ b/tests/test_watch_git_preparation.py @@ -35,7 +35,7 @@ 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 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) @@ -325,7 +326,7 @@ def test_promised_content_is_unreadable_not_absent_and_never_lazily_acquired( 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,7 +340,7 @@ 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) 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) From f800f7ec7607da5a8a056dfe45a10577dd4f3d5d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 09:27:02 -0400 Subject: [PATCH 51/74] Memoize graph queries and release pass readers from retained caches --- src/agent_comms/phase_evidence.py | 4 +- src/agent_comms/repo.py | 140 ++++++++++++++++++++-------- src/agent_comms/todos.py | 4 +- src/agent_comms/work_branches.py | 66 ++++++------- tests/test_watch_git_preparation.py | 10 +- 5 files changed, 146 insertions(+), 78 deletions(-) diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index c25cf7140e..e12a868992 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -301,7 +301,9 @@ def observed(self, repo: Repo, todos: tuple[Todo, ...], history: CheckoutHistory return current def retain(self, reading: PlanReading) -> None: - retained = reading.following(reading.todos, reading.main) + # 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) diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index e011a5df1e..384e909584 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -624,6 +624,15 @@ class GitCommit: 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. @@ -634,6 +643,9 @@ class CheckoutHistory: def __init__(self, checkout: Checkout) -> None: self.checkout = checkout self.required: set[str] = set() + self._ancestry: dict[str, int] = {} + self._bases: dict[int, tuple[str, ...]] = {} + self._walks: dict[CommitSelection, tuple[GitCommit, ...]] = {} @cached_property def refs(self) -> dict[str, GitReference]: @@ -643,6 +655,14 @@ def refs(self) -> dict[str, GitReference]: "HEAD", "refs/heads/", "refs/remotes/", ).splitlines())} + def require_read(self) -> dict[str, GitReference]: + """Read the ref cut, propagating unreadable-checkout errors to its caller.""" + return self.refs + + @property + def head(self) -> GitReference: + return self.refs["HEAD"] + def include(self, *heads: str) -> None: """Prepare acquired objects before observation; an already read graph stays immutable.""" self.required.update(heads) @@ -672,6 +692,8 @@ def resolve(self, revision: str) -> str: 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,)) @@ -683,56 +705,98 @@ def commit(self, revision: str) -> GitCommit: def references(self, prefix: str) -> tuple[str, ...]: return tuple(name for name in self.refs if name.startswith(prefix)) - def ancestors(self, *revisions: str) -> set[str]: - """Reachability over the admitted graph; shallow boundaries remain graph leaves.""" - pending = [self.resolve(revision) for revision in revisions] - found: set[str] = set() - while pending: - sha = pending.pop() - if sha not in found: - found.add(sha) - pending.extend(parent for parent in self.graph[sha].parents if parent in self.graph) - return found + @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.""" + combined = 0 + for revision in revisions: + head = self.resolve(revision) + if head not in self._ancestry: + pending = [head] + found = 0 + while pending: + sha = pending.pop() + bit = self.bit(sha) + if found & bit: + continue + if sha in self._ancestry: + found |= self._ancestry[sha] + continue + found |= bit + pending.extend(parent for parent in self.graph[sha].parents + if parent in self.graph) + self._ancestry[head] = found + combined |= self._ancestry[head] + return combined def contains(self, ancestor: str, revision: str) -> bool: - return self.resolve(ancestor) in self.ancestors(revision) + return bool(self.ancestors(revision) & self.bit(self.resolve(ancestor))) + + 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]: - """Git's newest-first revision walk, excluding complete ancestor closures.""" - seen = self.ancestors(*excluded) - pending: list[tuple[float, int, str]] = [] - sequence = 0 - for ref in refs: - commit = self.commit(ref) - heapq.heappush(pending, (-commit.at, sequence, commit.sha)) - sequence += 1 + """Filter a captured newest-first walk without traversing the graph again.""" matching = re.compile(pattern, re.IGNORECASE | re.MULTILINE) - while pending: - _at, _sequence, sha = heapq.heappop(pending) - if sha in seen: - continue - seen.add(sha) - commit = self.graph[sha] - if matching.search(commit.message): - yield commit - parents = commit.parents[:1] if first_parent else commit.parents - for parent in parents: - if parent not in seen and parent in self.graph: - heapq.heappush(pending, (-self.graph[parent].at, sequence, parent)) - sequence += 1 + return (commit for commit in self.walk(CommitSelection(refs, excluded, first_parent)) + if matching.search(commit.message)) def merge_base(self, left: str, right: str) -> tuple[str, ...]: """Best common ancestors: a common ancestor of another candidate is not best.""" common = self.ancestors(left) & self.ancestors(right) - redundant: set[str] = set() - for sha in common: - redundant.update(parent for parent in self.graph[sha].parents if parent in common) - best = common - redundant - return tuple(sorted(best, key=lambda sha: self.graph[sha].at, reverse=True)) + if common not in self._bases: + best = common + for commit in self.selected(common): + for parent in commit.parents: + if parent in self.graph: + best &= ~self.bit(parent) + self._bases[common] = tuple(commit.sha for commit in sorted( + self.selected(best), key=lambda commit: commit.at, reverse=True)) + return self._bases[common] def count(self, head: str, *excluded: str) -> int: - return len(self.ancestors(head) - self.ancestors(*excluded)) + return (self.ancestors(head) & ~self.ancestors(*excluded)).bit_count() @dataclass(frozen=True) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 1570267138..96608b4bc0 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 @@ -2759,7 +2759,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) diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index bcc808e79c..b7373643c3 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -145,6 +145,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) @@ -403,7 +430,7 @@ def history(self) -> CheckoutHistory: def read_checkout(self) -> CheckoutRead: """Whether git can open this checkout at all; the one probe every reader asks first.""" try: - self.history.refs + self.history.require_read() except GitCommandError as error: return UnreadableCheckoutRead(self.checkout, str(error)) return ReadableCheckoutRead(self) @@ -591,7 +618,7 @@ def difference(self, main: str) -> BranchDifference: raise WorkBranchError( f"HEAD reflog in {self.checkout} has no readable movement time" ) from unread - branch = self.history.refs["HEAD"].symbolic.removeprefix("refs/heads/") or "HEAD" + branch = self.history.head.symbolic.removeprefix("refs/heads/") or "HEAD" bases = self.history.merge_base(main, head) if not bases: raise WorkBranchError(f"Unrelated main and HEAD in {self.checkout}") @@ -600,49 +627,22 @@ def difference(self, main: str) -> BranchDifference: 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, ...]] = [()] diff --git a/tests/test_watch_git_preparation.py b/tests/test_watch_git_preparation.py index 7ccf38592b..50462fc98d 100644 --- a/tests/test_watch_git_preparation.py +++ b/tests/test_watch_git_preparation.py @@ -37,7 +37,7 @@ ) 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 @@ -321,7 +321,7 @@ 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() @@ -344,7 +344,8 @@ def test_promised_content_is_unreadable_not_absent_and_never_lazily_acquired( 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 @@ -741,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() From bbc06e798ce3b680c2bc6ebf0ae8ad4c36900f30 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 09:41:42 -0400 Subject: [PATCH 52/74] Share heartbeat turn digests and edit counts across rules and cards --- src/agent_comms/commander/standing.py | 13 ++++--- src/agent_comms/commander/vibe_check.py | 5 +-- src/agent_comms/commander_hud.py | 8 ++--- src/agent_comms/heartbeat_frame.py | 39 +++++++++++++++++++- src/agent_comms/standing_rules.py | 6 ++-- src/agent_comms/turn_digests.py | 48 +++++++++++++++++-------- src/agent_comms/ui_model/unit_state.py | 3 +- tests/test_commander_forks.py | 3 +- 8 files changed, 86 insertions(+), 39 deletions(-) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index b7988aeaca..e2c26ee778 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/heartbeat_frame.py b/src/agent_comms/heartbeat_frame.py index b685982997..7a6c854e75 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, @@ -61,6 +65,39 @@ 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/standing_rules.py b/src/agent_comms/standing_rules.py index eda30abcf4..e98ec365d3 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -804,7 +804,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, @@ -814,7 +814,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: @@ -1182,7 +1182,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/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/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/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}") From 814a1c849d2250837469e63011b988268b946156 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:03:47 -0400 Subject: [PATCH 53/74] Retain plan parses by blob across main updates and prune deleted modules --- src/agent_comms/phase_evidence.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index e12a868992..489baca69f 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -17,6 +17,7 @@ from collections.abc import Iterable, Iterator, Mapping from contextlib import contextmanager from dataclasses import dataclass, field, replace +from functools import cached_property from pathlib import Path, PurePosixPath from threading import Lock from subprocess import TimeoutExpired @@ -85,10 +86,14 @@ def from_history(cls, repo: Repo, census: Census, history: CheckoutHistory) -> M 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``; @@ -145,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)) @@ -236,8 +243,7 @@ def read(self, evidence: PhaseEvidence) -> EvidenceRead: return self.reads[evidence] def following(self, todos: tuple[Todo, ...], main: MainTree) -> PlanReading: - if main == self.main: - main = replace(main, parsed=self.main.parsed) + 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) From f5570ee75b9311ed3b04f9c426357272d99343b5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:10:07 -0400 Subject: [PATCH 54/74] Share one repository graph across worktree heads and batch remote reachability --- src/agent_comms/repo.py | 191 +++++++++++++++++++++++++------ src/agent_comms/todos.py | 38 +++--- src/agent_comms/work_branches.py | 48 ++++---- 3 files changed, 196 insertions(+), 81 deletions(-) diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index 384e909584..29b88730eb 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -511,6 +511,12 @@ 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) @@ -553,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, @@ -591,9 +596,7 @@ 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)) @@ -614,6 +617,101 @@ def listed(cls, row: str) -> GitReference: return cls(name, GitCommitText.decode(commit), symbolic, upstream, remote, remote_ref) +@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: + fields = tuple(field.partition(" ") for field in record.split("\0")) + (path,) = tuple(value for name, _, value in fields if name == "worktree") + (sha,) = tuple(value for name, _, value in fields if name == cls.reference) + symbolic = next((value for name, _, value in fields if name == "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 fields: + 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.""" @@ -643,25 +741,35 @@ class CheckoutHistory: def __init__(self, checkout: Checkout) -> None: self.checkout = checkout self.required: set[str] = set() - self._ancestry: dict[str, int] = {} + 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.path, "for-each-ref", "--include-root-refs", - "--format=%(refname)%00%(objectname)%00%(symref)%00%(upstream)%00%(upstream:remotename)%00%(upstream:remoteref)", - "HEAD", "refs/heads/", "refs/remotes/", + self.checkout.common_dir, "for-each-ref", + "--format=%(refname)%00%(objectname)%00%(symref)%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 - @property - def head(self) -> GitReference: - return self.refs["HEAD"] + 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.""" @@ -669,11 +777,13 @@ def include(self, *heads: str) -> None: @cached_property def graph(self) -> dict[str, GitCommit]: - heads = tuple(dict.fromkeys((*self.required, - *(reference.commit for reference in self.refs.values())))) + 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.path, "rev-list", "--stdin", "--no-commit-header", + 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 = {} @@ -681,8 +791,10 @@ def graph(self) -> dict[str, GitCommit]: 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) + commit = GitCommit( + GitCommitText.decode(sha), + tuple(map(GitCommitText.decode, parents.split())), float(at), message, + ) commits[commit.sha] = commit return commits @@ -724,30 +836,35 @@ def selected(self, bits: int) -> Iterator[GitCommit]: def ancestors(self, *revisions: str) -> int: """Memoized compact reachability; shallow boundaries are graph leaves.""" - combined = 0 - for revision in revisions: - head = self.resolve(revision) - if head not in self._ancestry: - pending = [head] - found = 0 - while pending: - sha = pending.pop() - bit = self.bit(sha) - if found & bit: - continue - if sha in self._ancestry: - found |= self._ancestry[sha] - continue - found |= bit - pending.extend(parent for parent in self.graph[sha].parents - if parent in self.graph) - self._ancestry[head] = found - combined |= self._ancestry[head] - return combined + 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: diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 96608b4bc0..e9c4fd3244 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -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) @@ -1411,7 +1412,10 @@ 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 @@ -1420,7 +1424,7 @@ def default_refs(self) -> tuple[str, ...]: @cached_property def head(self) -> str: - return self.branches.history.resolve("HEAD") + return self.branches.head.revision() def require_read(self) -> None: self.head @@ -1447,7 +1451,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, ...]]: @@ -1521,11 +1525,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] = {} @@ -1534,7 +1538,7 @@ def __init__(self) -> None: def checkout(self, path: Path) -> PresentCheckout: """This read's one reader of the checkout at path.""" path = path.resolve() - return self._checkouts.setdefault(path, PresentCheckout(path)) + 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 @@ -1587,8 +1591,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: @@ -5913,7 +5916,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; @@ -6047,7 +6052,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/work_branches.py b/src/agent_comms/work_branches.py index b7373643c3..24bc689efa 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -25,7 +25,9 @@ from .code_lines import CodeLines from .declared_family import DeclaredFamily from .field_codec import GitCommitText, PathText, projected -from .repo import Checkout, CheckoutHistory, GitCommandError, GitReference, Repo +from .repo import ( + Checkout, CheckoutHistory, GitCommandError, GitHead, GitReference, Repo, RepositoryReads, +) from .repository_name import RepositoryName from .wire_models import WireModel @@ -328,9 +330,7 @@ def tracking_name(self) -> str: 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.history.references("refs/remotes/") - if ref != tracking) - count = reader.history.count(head, *remotes) + count = reader.history.unpublished(head, tracking) return (UnpushedHead(head, count, tracking),) if count else () @@ -380,6 +380,8 @@ 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, @@ -425,7 +427,11 @@ def _git(self, *arguments: str, input: str = "") -> str: @cached_property def history(self) -> CheckoutHistory: - return Checkout(self.checkout).read() + 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.""" @@ -459,8 +465,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.history.count( - "HEAD", *self.history.references("refs/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.""" @@ -506,7 +511,7 @@ def pull_request_base(self, base: str, head: str) -> str: @cached_property def upstreams(self) -> dict[str, BranchUpstream]: return {record.branch: record for record in ( - DetachedHead(self.history.head.name), + DetachedHead(self.head.reference), *(BranchUpstream.from_reference(reference) for reference in self.history.refs.values() if reference.name.startswith("refs/heads/")), )} @@ -521,7 +526,8 @@ 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) @@ -618,7 +624,7 @@ def difference(self, main: str) -> BranchDifference: raise WorkBranchError( f"HEAD reflog in {self.checkout} has no readable movement time" ) from unread - branch = self.history.head.symbolic.removeprefix("refs/heads/") or "HEAD" + branch = self.head.branch bases = self.history.merge_base(main, head) if not bases: raise WorkBranchError(f"Unrelated main and HEAD in {self.checkout}") @@ -657,18 +663,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, @@ -791,12 +788,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) From 316230c4295f394df354a76708ddeeea7318c670 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:35:19 -0400 Subject: [PATCH 55/74] Decode branch upstreams at the repository boundary and delete sentinel copies --- .../commander/publication_rules.py | 3 +- src/agent_comms/heartbeat_frame.py | 3 +- src/agent_comms/phase_evidence.py | 4 +- src/agent_comms/repo.py | 113 ++++++++++++++++-- src/agent_comms/work_branches.py | 87 +------------- tests/test_commander_notifications.py | 3 +- tests/test_fleet_replay.py | 4 +- 7 files changed, 118 insertions(+), 99 deletions(-) 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/heartbeat_frame.py b/src/agent_comms/heartbeat_frame.py index 7a6c854e75..26cdffee5d 100644 --- a/src/agent_comms/heartbeat_frame.py +++ b/src/agent_comms/heartbeat_frame.py @@ -94,7 +94,8 @@ def edit_counts(self, comms: Comms) -> Mapping[str, int]: 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)) + 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 diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index 489baca69f..8dc10216b2 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -297,7 +297,9 @@ def __init__(self) -> None: self.plans: tuple[PlanReading, ...] = () self.lock = Lock() - def observed(self, repo: Repo, todos: tuple[Todo, ...], history: CheckoutHistory) -> PlanReading: + def observed( + self, repo: Repo, todos: tuple[Todo, ...], history: CheckoutHistory, + ) -> PlanReading: current = super().observed(repo, todos, history) with self.lock: plans = self.plans diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index 29b88730eb..99339c0aa6 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -9,7 +9,7 @@ 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 @@ -25,6 +25,7 @@ if TYPE_CHECKING: from .todos import Todo + from .work_branches import WorkBranches class RepositorySelection(ABC): @@ -600,21 +601,115 @@ def retire(self) -> None: 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 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() + count = reader.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 require_prepared(self, reader: WorkBranches) -> None: + for head in reader.selected_heads(self.remote, self.reference): + reader.history.commit(head) + + def unpushed(self, reader: WorkBranches, head: str) -> tuple[UnpushedHead, ...]: + published = reader.selected_heads(self.remote, self.reference) + if published: + (remote_head,) = published + count = reader.history.count(head, remote_head) + return (UnpushedHead(head, count, self.tracking),) if count else () + return super().unpushed(reader, head) + + @dataclass(frozen=True) class GitReference: - """One ref, including HEAD's symbolic target and a local branch's upstream.""" + """A repository ref; local branch members additionally own their decoded upstream.""" name: str commit: Annotated[str, GitCommitText] - symbolic: str - upstream: str - remote: str - remote_ref: str @classmethod def listed(cls, row: str) -> GitReference: - name, commit, symbolic, upstream, remote, remote_ref = row.split("\0") - return cls(name, GitCommitText.decode(commit), symbolic, upstream, remote, remote_ref) + 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) @@ -750,7 +845,7 @@ def __init__(self, checkout: Checkout) -> None: 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%(symref)%00%(upstream)" + "--format=%(refname)%00%(objectname)%00%(upstream)" "%00%(upstream:remotename)%00%(upstream:remoteref)", "refs/heads/", "refs/remotes/", ).splitlines())} diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index 24bc689efa..c2d9a87c19 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -26,7 +26,8 @@ from .declared_family import DeclaredFamily from .field_codec import GitCommitText, PathText, projected from .repo import ( - Checkout, CheckoutHistory, GitCommandError, GitHead, GitReference, Repo, RepositoryReads, + BranchUpstream, Checkout, CheckoutHistory, DetachedHead, GitCommandError, GitHead, + Repo, RepositoryReads, UnpushedHead, ) from .repository_name import RepositoryName from .wire_models import WireModel @@ -288,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 @@ -302,79 +296,6 @@ 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_reference(cls, reference: GitReference) -> BranchUpstream: - branch = reference.name.removeprefix("refs/heads/") - if not reference.upstream: - return NoUpstream(branch) - if reference.remote == Repo.local_remote: - return LocalUpstream(branch, reference.upstream) - return PublishedUpstream(branch, reference.upstream, reference.remote, reference.remote_ref) - - 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() - count = reader.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 require_prepared(self, reader: WorkBranches) -> None: - for head in reader.selected_heads(self.remote, self.reference): - reader.history.commit(head) - - def unpushed(self, reader: WorkBranches, head: str) -> tuple[UnpushedHead, ...]: - published = reader.selected_heads(self.remote, self.reference) - if published: - (remote_head,) = published - count = reader.history.count(head, remote_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.""" @@ -512,8 +433,8 @@ def pull_request_base(self, base: str, head: str) -> str: def upstreams(self) -> dict[str, BranchUpstream]: return {record.branch: record for record in ( DetachedHead(self.head.reference), - *(BranchUpstream.from_reference(reference) for reference in self.history.refs.values() - if reference.name.startswith("refs/heads/")), + *(upstream for reference in self.history.refs.values() + for upstream in reference.upstreams()), )} def prepare_upstreams(self) -> None: 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_fleet_replay.py b/tests/test_fleet_replay.py index 5a3cf6cfa8..874288d21b 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 ( @@ -64,7 +64,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 From af19ffcfcd7e4bd817108027037df3a76ea36083 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 13:29:49 -0400 Subject: [PATCH 56/74] Deal /prime on demand through the rule gate, in four variants Tristan wants to tell a drifted fleet "do /prime" mid-context. A paste of the rules can be skimmed; a deal cannot, because the gate refuses every tool until each dealt file is read and answered in a fresh audit. - IndictmentFree joins RulesOnly as an MRO capability on the one DeckReader list: the indictment is now a selectable source like the papers. - Prime is a DeclaredFamily naming a fleet and commander reader per variant: full, no-papers, no-indictment, light. No copied file lists. - agent-comms-prime deals this Claude session (DeckStart.primed), or with --to deals named native threads through PiRedeal.deal_from. - skills/prime/SKILL.md gives the /prime slash command. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- pyproject.toml | 1 + src/agent_comms/skills/prime/SKILL.md | 19 +++++++ src/agent_comms_hooks/prime.py | 58 ++++++++++++++++++++++ src/agent_comms_hooks/skill.py | 71 +++++++++++++++++++++++++-- 4 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 src/agent_comms/skills/prime/SKILL.md create mode 100644 src/agent_comms_hooks/prime.py 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/skills/prime/SKILL.md b/src/agent_comms/skills/prime/SKILL.md new file mode 100644 index 0000000000..f0425bf8c9 --- /dev/null +++ b/src/agent_comms/skills/prime/SKILL.md @@ -0,0 +1,19 @@ +--- +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 and its closing rule. The same deal as a compaction. + - `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, one per call. After each rule file, answer it in the audit (whole greentext lines verbatim, what they did, why it holds, what you broke) before the next file opens. +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_hooks/prime.py b/src/agent_comms_hooks/prime.py new file mode 100644 index 0000000000..7f195bf5cf --- /dev/null +++ b/src/agent_comms_hooks/prime.py @@ -0,0 +1,58 @@ +"""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 pathlib import Path + +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], root: Path | None) -> None: + """Deal each named thread the variant's readers through its Core-held gate.""" + with client_wire(root) 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) + print(f"{name}: dealt {prime.declared_name}") + + +def main(argv: Sequence[str] | None = None) -> None: + parser = argparse.ArgumentParser(prog="agent-comms-prime", description=__doc__) + parser.add_argument("--variant", choices=Prime.names(), default=FullPrime.declared_name) + parser.add_argument("--to", nargs="+", metavar="THREAD", default=(), + help="deal these native threads instead of this session") + parser.add_argument("--root", type=Path, default=None) + arguments = parser.parse_args(argv) + prime = Prime.member_named(arguments.variant) + if arguments.to: + prime_threads(prime, arguments.to, arguments.root) + return + print("\n\n".join(session_start().primed(prime))) + + +if __name__ == "__main__": + main() diff --git a/src/agent_comms_hooks/skill.py b/src/agent_comms_hooks/skill.py index 5b7f058c60..d0a1867c82 100644 --- a/src/agent_comms_hooks/skill.py +++ b/src/agent_comms_hooks/skill.py @@ -67,6 +67,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) @@ -167,15 +172,21 @@ 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 sources(cls) -> tuple[Path, ...]: """The fleet's files, its own rules among them by audience, then the commander's.""" - return (*super().sources(), CommanderPackage.indictment(), CommanderPackage.skill(), + return (*super().sources(), *cls.indictment(), CommanderPackage.skill(), Slop.catalog() / "README.md") def header(self, start: DeckStart) -> str: @@ -205,6 +216,53 @@ 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]] + + +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.""" @@ -262,6 +320,13 @@ 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 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=tuple, fleet=lambda record: self.dealt(prime.fleet()), + commander=lambda record: self.dealt(prime.commander())) + def dealt(self, reader: DeckReader) -> tuple[str, ...]: """The order to read every dealt file, once, by its packaged path.""" prompts = self.prompts() From 38261def71eb7829f7b1cda84b87bb9f2179d666 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 14:14:27 -0400 Subject: [PATCH 57/74] Decode /prime's variant once into its family and use the client's own bus route debt-src grew FamilyFlattened +1 and NoneAbsence +3 on prime.py: the variant was carried as its declared_name string and re-read twice, and --root/argv restated a None default client_wire() and argparse already own. argparse now decodes --variant through Prime.member_named (the one boundary decoder), client_wire() resolves the published route itself, and main() parses sys.argv like every sibling hook. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms_hooks/prime.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/agent_comms_hooks/prime.py b/src/agent_comms_hooks/prime.py index 7f195bf5cf..7a1d22cff9 100644 --- a/src/agent_comms_hooks/prime.py +++ b/src/agent_comms_hooks/prime.py @@ -14,7 +14,6 @@ import argparse from collections.abc import Sequence -from pathlib import Path from agent_comms.cli_bridges.claude_transcript import RunningClaude from agent_comms.comms import client_wire @@ -30,28 +29,28 @@ def session_start() -> DeckStart: return DeckStart(session, "Prime", str(RunningClaude(session).source), SessionSource("prime")) -def prime_threads(prime: type[Prime], names: Sequence[str], root: Path | None) -> None: - """Deal each named thread the variant's readers through its Core-held gate.""" - with client_wire(root) as comms: +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) - print(f"{name}: dealt {prime.declared_name}") + print(f"{name}: dealt {prime.__name__}") -def main(argv: Sequence[str] | None = None) -> None: +def main() -> None: parser = argparse.ArgumentParser(prog="agent-comms-prime", description=__doc__) - parser.add_argument("--variant", choices=Prime.names(), default=FullPrime.declared_name) + 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") - parser.add_argument("--root", type=Path, default=None) - arguments = parser.parse_args(argv) - prime = Prime.member_named(arguments.variant) + arguments = parser.parse_args() if arguments.to: - prime_threads(prime, arguments.to, arguments.root) + prime_threads(arguments.variant, arguments.to) return - print("\n\n".join(session_start().primed(prime))) + print("\n\n".join(session_start().primed(arguments.variant))) if __name__ == "__main__": From f99aa2ae243132ede969e4bfa9f23d96f9cd9f94 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 14:23:27 -0400 Subject: [PATCH 58/74] Refuse /prime out loud in a session no bus thread holds primed() passed `tuple` as the unbound branch, so /prime in an unenrolled session printed nothing and exited 0: a silent skip on a command the owner types. DeckStart.refuse_unbound raises UnboundPrime naming the session. SKILL.md no longer implies only `full` keeps the closing rules: every variant's sources() deals them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/skills/prime/SKILL.md | 3 ++- src/agent_comms_hooks/skill.py | 14 +++++++++++++- tests/test_commander_skill_hook.py | 14 +++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/skills/prime/SKILL.md b/src/agent_comms/skills/prime/SKILL.md index f0425bf8c9..f463c2baca 100644 --- a/src/agent_comms/skills/prime/SKILL.md +++ b/src/agent_comms/skills/prime/SKILL.md @@ -9,7 +9,8 @@ Tristan calls /prime when a session has drifted back to its own frame. It is a f 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 and its closing rule. The same deal as a compaction. + - `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. diff --git a/src/agent_comms_hooks/skill.py b/src/agent_comms_hooks/skill.py index afb02774f3..4d0f1b4abe 100644 --- a/src/agent_comms_hooks/skill.py +++ b/src/agent_comms_hooks/skill.py @@ -306,6 +306,15 @@ def reason(self) -> StartReason: return StartReason.member_or_default(self, LostContext)() +class UnboundPrime(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, session_id: str) -> None: + super().__init__(f"session {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.""" @@ -331,9 +340,12 @@ 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=tuple, fleet=lambda record: self.dealt(prime.fleet()), + unbound=self.refuse_unbound, fleet=lambda record: self.dealt(prime.fleet()), commander=lambda record: self.dealt(prime.commander())) + def refuse_unbound(self) -> tuple[str, ...]: + raise UnboundPrime(self.session_id) + def dealt(self, reader: DeckReader) -> tuple[str, ...]: """The order to read every dealt file, once, by its packaged path.""" prompts = self.prompts() diff --git a/tests/test_commander_skill_hook.py b/tests/test_commander_skill_hook.py index 34c995d806..9ae6e8d854 100644 --- a/tests/test_commander_skill_hook.py +++ b/tests/test_commander_skill_hook.py @@ -48,7 +48,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, Prime +from agent_comms_hooks.skill import (CommanderReader, DeckReader, DeckStart, FleetReader, FullPrime, + Prime, UnboundPrime) from agent_comms_hooks.watch import DeafWatch, WatchClaim, WatchRecord from conftest import engaged from test_commander_notifications import beat, lead_hud @@ -453,6 +454,17 @@ def test_the_prime_variants_cover_papers_and_indictment_once_each_with_the_indic assert kept == {(True, True), (True, False), (False, True), (False, False)} +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(UnboundPrime, 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" From 3c755d63b4a8e9bc2508f5548b7a8a4f052f346c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 14:30:50 -0400 Subject: [PATCH 59/74] Hand UnboundPrime the DeckStart that owns the session id debt-src grew PlainStrIdAnnotation +1 at skill.py:313: the refusal took the session id as a bare str copied out of DeckStart. It now takes the start itself and reads session_id from its owner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms_hooks/skill.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agent_comms_hooks/skill.py b/src/agent_comms_hooks/skill.py index bd296a034d..18a243e589 100644 --- a/src/agent_comms_hooks/skill.py +++ b/src/agent_comms_hooks/skill.py @@ -310,9 +310,9 @@ class UnboundPrime(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, session_id: str) -> None: - super().__init__(f"session {session_id} is bound to no bus thread, so there is no gate " - "to deal it; bind it to the bus first") + 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) @@ -344,7 +344,7 @@ def primed(self, prime: type[Prime]) -> tuple[str, ...]: commander=lambda record: self.dealt(prime.commander())) def refuse_unbound(self) -> tuple[str, ...]: - raise UnboundPrime(self.session_id) + raise UnboundPrime(self) def dealt(self, reader: DeckReader) -> tuple[str, ...]: """The order to read every dealt file, once, by its packaged path.""" From a99716ca880ebcbe03af04afe4c098444da469eb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 14:34:12 -0400 Subject: [PATCH 60/74] Point /prime's read instructions at the gate's batch window SKILL.md said "one per call", which #3726 made false: up to Unengaged.batch read rules may stand unanswered (rule_deal.py:495) and a paper's parts open together. The skill now names that owner instead of restating a count the gate already prints in its refusal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/skills/prime/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/skills/prime/SKILL.md b/src/agent_comms/skills/prime/SKILL.md index f463c2baca..8d5f9d4528 100644 --- a/src/agent_comms/skills/prime/SKILL.md +++ b/src/agent_comms/skills/prime/SKILL.md @@ -14,7 +14,7 @@ Tristan calls /prime when a session has drifted back to its own frame. It is a f - `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, one per call. After each rule file, answer it in the audit (whole greentext lines verbatim, what they did, why it holds, what you broke) before the next file opens. +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. From fa6f016e6f2dcd0ec60e46398ae2cc20ac04e4a8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 14:42:17 -0400 Subject: [PATCH 61/74] Use FieldCodec for exact stored float JSON on the held read --- src/agent_comms/bus.py | 6 +++--- src/agent_comms/messages.py | 4 ++-- src/agent_comms/typed_table.py | 23 ++++++++++++----------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index 307261467a..70d3941fec 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -264,10 +264,10 @@ def stored_message_history( *, through: int, ) -> Iterator[StoredWireHistory]: """Stream declared public wire fields from one read snapshot, without Messages.""" - query = Message.stored_wire_query(MessageIdentitySource) - query = (f'SELECT seq, timestamp, wire FROM ({query}) ' - f'WHERE seq <= ? AND ({predicate})') 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]: diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index b6ed9c7560..7c3a1aa1e9 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -1067,11 +1067,11 @@ def display_metadata(self) -> dict: return {} @classmethod - def stored_wire_query(cls, identity: type[TypedTable]) -> str: + 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(plan) + 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") + ")") diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 81a289522f..0087eb4ea0 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -143,8 +143,8 @@ def from_sql(cls, value: object) -> object: return value @classmethod - def json_text(cls, expression: str) -> str: - """The stored value as JSON text, without a Python decode/encode trip.""" + 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 @@ -187,9 +187,10 @@ def accepts(cls, annotation: object) -> bool: return annotation is float @classmethod - def json_text(cls, expression: str) -> str: - # Alternate-form-2 retains the stored double; json_quote rounds to 15 digits. - return f"CASE WHEN {expression} IS NULL THEN 'null' ELSE printf('%0!.26g', {expression}) END" + 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): @@ -208,7 +209,7 @@ def to_sql(cls, value: object) -> int | None: return None if value is None else int(value) @classmethod - def json_text(cls, expression: str) -> str: + 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 @@ -282,7 +283,7 @@ 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, expression: str) -> str: + def json_text(cls, db: sqlite3.Connection, expression: str) -> str: return f"COALESCE({expression}, 'null')" @classmethod @@ -511,9 +512,9 @@ class _Field(SqlValue): def reference(self, alias: str) -> str: return f"{alias}.{_identifier(self.wire_name)}" - def wire_record(self, plan: FieldPlan, alias: str) -> str: + def wire_record(self, db: sqlite3.Connection, plan: FieldPlan, alias: str) -> str: expression = self.reference(alias) - projected = plan.project_encoded(self.storage.json_text(expression), self.json_record) + 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" @@ -2460,10 +2461,10 @@ def stream( return cls._query(db, where, parameters, order_by, descending, key, row_type, limit) @classmethod - def wire_record(cls, plan: FieldPlan) -> str: + 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(plan, cls.declared_name) + return item.wire_record(db, plan, cls.declared_name) @classmethod def qualified(cls, *names: str) -> str: From c82b40a0f54de30b2602d4c985ebf1e65c5ad4fd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 15:42:57 -0400 Subject: [PATCH 62/74] Name the install's own failure when restarting its owners fails too Install 040380a8 (2026-10-30, main d977b8dc) stopped every owner, then something raised; the give-back start refused with "Stopped owner session has not retired yet" and that refusal was the only line printed. The real failure survived only as __context__, which nothing reports, so the install's cause was lost. OwnersStopped.given_back_on registers the restart as an exit hook that sees the in-flight failure: a refused restart during a failing install raises OwnersNotRestarted naming both, caused by the install's failure. Both owners_stopped declarations use it; the copied callback is gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/installer/refusals.py | 12 ++++++++++++ src/agent_comms/installer/upgrade.py | 21 ++++++++++++++++++--- tests/test_runtime_source.py | 11 ++++++----- 3 files changed, 36 insertions(+), 8 deletions(-) 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..9bf9e725d3 100644 --- a/src/agent_comms/installer/upgrade.py +++ b/src/agent_comms/installer/upgrade.py @@ -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,19 @@ def stop(self, stack: Stack, root: Path) -> None: for outcome in stack.owners(root, StopCliCommand, running): outcome.require_success(UpgradeRefused) + def given_back_on(self, stack: Stack, root: Path) -> None: + """Every exit starts the stopped owners on ``stack`` again. When the install itself + is failing, a refused restart carries that failure instead of replacing it.""" + def start_again(_type: object, failure: BaseException | None, _trace: object) -> bool: + try: + self.start_on(stack, root) + except UpgradeRefused as restart: + if failure is None: + raise + raise OwnersNotRestarted(failure, restart) from failure + return False + self.give_back.push(start_again) + def start_on(self, stack: Stack, root: Path) -> tuple[OwnerStart, ...]: self.started = stack.owners(root, StartCliCommand, self.stopped) for outcome in self.started: @@ -955,7 +970,7 @@ 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) + owners.given_back_on(installed, root) owners.stop(installed, root) yield owners @@ -982,7 +997,7 @@ def owners_stopped(cls, root: Path, installed: Stack, maintenance = MaintenanceBarrier(RegistryStore.at(root).path) owners = HeldOwnersStopped() with owners.give_back: - owners.give_back.callback(owners.start_on, installed, root) + owners.given_back_on(installed, root) with owners.hold: with BeginMaintenancePhase().running(clock): held = maintenance.begin(MaintenanceOperator.current()) diff --git a/tests/test_runtime_source.py b/tests/test_runtime_source.py index 58fa6e9944..6382b29b72 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,14 @@ 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 pytest.raises(OwnersNotRestarted) as failed: with owners.give_back: - owners.give_back.callback(owners.start_on, stack, comms.root) + 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) From 2072c0d4bffd146da78f9c1e91af14d4a927655e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:02:59 -0400 Subject: [PATCH 63/74] Derive a pending binding's command from its context's declared command PendingCommandBinding carried a command field and a __post_init__ that refused a command not inheriting the context (IMPL-10): the context's own class already declares which command inherits it. The binding now holds only the context, and CommandContext.command() reads the one CliCommand family member inheriting it. new gets NewThreadContext so each context has exactly one declared command. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/cli_command.py | 15 ++++++++------- src/agent_comms/cli_verbs/fork.py | 2 +- src/agent_comms/cli_verbs/new.py | 9 +++++++-- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/agent_comms/cli_command.py b/src/agent_comms/cli_command.py index 43e8059b27..f4f7d11c07 100644 --- a/src/agent_comms/cli_command.py +++ b/src/agent_comms/cli_command.py @@ -147,23 +147,24 @@ async def prepare_field(self, item: TargetField, catalogs: ConfigOptions) -> Tar @dataclass(frozen=True) class CommandContext(CatalogSelection): - """A declaration of supplied fields, not an alternate command editor.""" + """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.""" - command: type[CliCommand] context: CommandContext - def __post_init__(self) -> None: - if not issubclass(self.command, type(self.context)): - raise TypeError('A pending context must be a declared parent of its command') - @property def declaration(self) -> type[CliCommand]: - return self.command + return type(self.context).command() @property def catalog_context(self) -> CatalogSelection: diff --git a/src/agent_comms/cli_verbs/fork.py b/src/agent_comms/cli_verbs/fork.py index 14bdedb8db..16f4991ab3 100644 --- a/src/agent_comms/cli_verbs/fork.py +++ b/src/agent_comms/cli_verbs/fork.py @@ -27,6 +27,6 @@ class ForkCliCommand(NativeOwnerCommand, ThreadBoundCliCommand, ForkContext, Thr @classmethod def thread_binding(cls, thread: Thread) -> PendingCommandBinding: - return PendingCommandBinding(cls, ForkContext( + 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 aad00c4ff7..2661ab09fe 100644 --- a/src/agent_comms/cli_verbs/new.py +++ b/src/agent_comms/cli_verbs/new.py @@ -13,7 +13,12 @@ @dataclass(frozen=True, kw_only=True) -class NewThreadCliCommand(ThreadCreationCliCommand, 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 tags: Annotated[GivenTags, Argument()] @@ -21,6 +26,6 @@ class NewThreadCliCommand(ThreadCreationCliCommand, declared_name="new"): @classmethod def project_bindings(cls, comms: Comms, project: Checkout) -> tuple[PendingCommandBinding, ...]: defaults = NativeThreadDefaults() - return (PendingCommandBinding(cls, ThreadLaunchSettings( + return (PendingCommandBinding(NewThreadContext( project=project, model=defaults.model, thinking_level=defaults.thinking_level)),) From 461cbb7abd4af5e6e55aa17b4fb397b8b2b1e7a6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:06:26 -0400 Subject: [PATCH 64/74] Export: each format reads the stored history it renders; delete the wire parse-back Text export crashed on every claim-notice row: WireExportFormat.stored_row parsed the SQL-built public wire back through Message.from_wire, and ClaimNoticeKind decodes that as a ClaimMessage needing claim_transition, which the public envelope never carries. On a copy of the live bus 19,410 claim-notice rows failed this way (first at seq 1561). The format now owns how it reads the stored history. JSONL's row is the stored wire text, so the limit still selects it in SQL. Every other format renders Messages decoded once from their capability tables (BusCapability.at_sequences, the same decode main's export used), through the limit's ordinary selection. Deleted: StoredWireMessage.decode and decode_wire, and the base format's stored_row, encoded_size and stored_size, which registered a SQLite function that parsed every row back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/exporting.py | 34 +++++++++++++++++++++------------- src/agent_comms/messages.py | 17 ++++++++++------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/agent_comms/exporting.py b/src/agent_comms/exporting.py index 6eb30d2e19..9f3a1645d6 100644 --- a/src/agent_comms/exporting.py +++ b/src/agent_comms/exporting.py @@ -48,16 +48,17 @@ def row(self, message: Message) -> bytes: def json_record(record: Mapping[str, object]) -> str: return FieldCodec.compact(record, ensure_ascii=False) - def stored_row(self, message: StoredWireMessage) -> bytes: - return self.row(message.decode()) - - def encoded_size(self, wire: str) -> int: - return len(self.row(StoredWireMessage.decode_wire(wire))) + 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)) - def stored_size(self, source: StoredWireHistory) -> str: - function = self.declared_name + "_wire_export_size" - source.db.create_function(function, 1, self.encoded_size, deterministic=True) - return f'"{function}"(wire)' + return limit.select(rendered(), header_size, started_at, stats) class SelectableWireExportFormat(WireExportFormat): @@ -76,6 +77,13 @@ def header(self, metadata: Mapping[str, object]) -> bytes: def row(self, message: Message) -> bytes: 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 @@ -238,7 +246,7 @@ def time_cutoff(self) -> float | None: return None def stored_rows( - self, source: StoredWireHistory, format: WireExportFormat, + self, source: StoredWireHistory, format: JsonlFormat, header_size: int, started_at: float, stats: ExportSelectionStats, ) -> Iterable[WireExportRow]: stats.source_messages = source.count() @@ -306,7 +314,7 @@ def byte_ceiling(self) -> int: return self.value def stored_rows( - self, source: StoredWireHistory, format: WireExportFormat, + self, source: StoredWireHistory, format: JsonlFormat, header_size: int, started_at: float, stats: ExportSelectionStats, ) -> Iterable[WireExportRow]: stats.source_messages = source.count() @@ -575,8 +583,8 @@ def export_stored( ) -> WireExportProvenance: """Select the stored suffix, then write ascending rows without Message construction.""" with observer.reading(source): - return self._export(destination, lambda stats, header: self.limit.stored_rows( - source, self.format, header, self.boundary.export_started_at, stats), + 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( diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 7c3a1aa1e9..6c9fa8e572 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -1235,13 +1235,6 @@ class StoredWireMessage: timestamp: float wire: str - @staticmethod - def decode_wire(wire: str) -> Message: - return Message.from_wire(FieldCodec.parse(wire)) - - def decode(self) -> Message: - return self.decode_wire(self.wire) - @dataclass(frozen=True) class StoredWireHistory: @@ -1260,6 +1253,16 @@ def rows(self, *, after: int = 0, descending: bool = False) -> Iterator[StoredWi 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") From 469ae4dadf7fac7906f898e06abdb6e25ed1a02e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:07:39 -0400 Subject: [PATCH 65/74] Find best merge bases from the branch-only commits, not the whole shared history CheckoutHistory.merge_base scanned every common ancestor (about 25,000 commits) and cleared each one's parents from a 26,700-bit set, once per head. Over the 726 worktree heads on the live checkout that took 179 s. The common set is ancestor-closed, so a best base is a tip or a parent of a commit only one side reaches, and it is best exactly when no child is common. merge_base now tests only those candidates against a children map inverted once from the captured graph: 4.8 s for the same 726 heads, and identical to git merge-base --all on 100 sampled pairs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/repo.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index 99339c0aa6..eb5e337c39 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -994,17 +994,35 @@ def commits(self, refs: tuple[str, ...], *excluded: str, 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: a common ancestor of another candidate is not best.""" - common = self.ancestors(left) & self.ancestors(right) + """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: - best = common - for commit in self.selected(common): - for parent in commit.parents: - if parent in self.graph: - best &= ~self.bit(parent) + 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( - self.selected(best), key=lambda commit: commit.at, reverse=True)) + best, key=lambda commit: commit.at, reverse=True)) return self._bases[common] def count(self, head: str, *excluded: str) -> int: From fe002492dc8bdf8e6230272e27d9ec064b9a6244 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:10:58 -0400 Subject: [PATCH 66/74] Pin the held bus's stores instead of truncating their WAL before the carry check Install of d977b8dc aborted before its backup: "Another connection holds the WAL of bus.sqlite3". SQLiteFile.settle ran wal_checkpoint(TRUNCATE), which must wait out every open reader, and the bus always has readers (session bridges, `sqlite3 -readonly` queries). The settle existed only for carried(): it compares stat (size, mtime, inode), and the last close of a database checkpoints its WAL into the file, moving bytes without changing a row (install 33, 5c2e5bae72). Only the last close checkpoints. Holding one connection open (read-only, no transaction) keeps every other close from being last, and its own read-only close never checkpoints, so the bytes stay where they are from the before snapshot to the after snapshot with no checkpoint and no wait on any reader. Measured on SQLite 3.53.1: with the pin held, a writer's close and a reader's close leave the file and WAL stats unchanged; without it, the last open/close re-touches the file even when every frame is already backfilled, so FULL instead of TRUNCATE could not work. - RootFile.settle -> pinned (a context): no-op for plain files; SQLiteFile holds one read-only connection; PrivateSnapshotFile keeps its no-op (immutable inodes). - settle_bus_files -> pinned_bus_files returns the pins; PreparedMigration.capture takes them with the before snapshot and carry releases them after carried(). live() pushes them onto its holder so a failure before the carry releases them before any restore replaces a store. - Tests: the settle test now holds a reader of the latest snapshot while the pin is taken and through reconcile; a store test shows a pinned store's bytes survive the writer's and a reader's close. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/installer/backup.py | 14 ++++++---- src/agent_comms/installer/upgrade.py | 41 ++++++++++++++++------------ src/agent_comms/locked_store.py | 23 +++++++++------- src/agent_comms/private_sidecar.py | 7 +++-- tests/test_reconcile_stores.py | 17 ++++++------ tests/test_store_backup.py | 24 ++++++++++++++-- 6 files changed, 80 insertions(+), 46 deletions(-) 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/upgrade.py b/src/agent_comms/installer/upgrade.py index 9bf9e725d3..d41d4655c6 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 @@ -1058,6 +1058,7 @@ class PreparedMigration(Extendable): before: dict[DeclaredFile | UndeclaredFile, dict[Path, SavedFile]] read: BusReadBack files: FilesRequest + pins: ExitStack writer_stores: tuple[type[ExternalStore], ...] = () @property @@ -1067,31 +1068,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 @@ -1163,6 +1166,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..0de9f84d8e 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: @@ -1013,14 +1015,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/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/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_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() From 7a344c660a0f9d16d45df5950fcb0f9f30ea7796 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:16:41 -0400 Subject: [PATCH 67/74] Test: stored Text and JSONL exports render a claim-notice row Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- tests/test_exporting.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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" From 044b0d3764c639410fbbf6377c03501e711e1e56 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:21:19 -0400 Subject: [PATCH 68/74] Exclude remote references by name, not a rev-list flag the graph cannot resolve The progress read moved from git rev-list --not --remotes onto CheckoutHistory, which resolves each exclusion as a revision; the flag string '--remotes' raised UnavailableCommitError, so every unit with a present checkout read as Unreadable (test_order_closed_after_the_frame_read_does_not_fail_the_merge_read). ProgressCheckout now owns its remote references; a retired checkout has none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/todos.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index d69fafe29d..0bfcbfec46 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -1354,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.""" @@ -1429,6 +1437,9 @@ def head(self) -> str: 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 self.branches.history.references(f"refs/remotes/{Repo.remote}/") @@ -1701,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)) From eab4c4f10cdef5b355185471675c76ddfebc41cd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:24:01 -0400 Subject: [PATCH 69/74] Give owners back through a context instead of probing the failure for None debt-src grew NoneAbsence +2 at upgrade.py:488/492: the ExitStack callback decided whether the install failed by testing its exception argument for None (IDEN-3). given_back_on is now a context: the except arm is the failing install, the normal exit is the succeeding one. It still restarts through give_back, so a published runtime's pop_all keeps cancelling the restart. - Both owners_stopped declarations wrap their body in it; the pushed callback and the None test are deleted. - New test: a succeeding install's refused restart raises UpgradeRefused itself, and a taken-over (retired) give-back starts nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/installer/upgrade.py | 27 ++++++++++++++------------- tests/test_runtime_source.py | 19 +++++++++++++++++-- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/agent_comms/installer/upgrade.py b/src/agent_comms/installer/upgrade.py index d41d4655c6..401f21523d 100644 --- a/src/agent_comms/installer/upgrade.py +++ b/src/agent_comms/installer/upgrade.py @@ -482,18 +482,21 @@ def stop(self, stack: Stack, root: Path) -> None: for outcome in stack.owners(root, StopCliCommand, running): outcome.require_success(UpgradeRefused) - def given_back_on(self, stack: Stack, root: Path) -> None: - """Every exit starts the stopped owners on ``stack`` again. When the install itself - is failing, a refused restart carries that failure instead of replacing it.""" - def start_again(_type: object, failure: BaseException | None, _trace: object) -> bool: + @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.start_on(stack, root) + self.give_back.close() except UpgradeRefused as restart: - if failure is None: - raise raise OwnersNotRestarted(failure, restart) from failure - return False - self.give_back.push(start_again) + raise + self.give_back.close() def start_on(self, stack: Stack, root: Path) -> tuple[OwnerStart, ...]: self.started = stack.owners(root, StartCliCommand, self.stopped) @@ -969,8 +972,7 @@ class StopsOwners: def owners_stopped(cls, root: Path, installed: Stack, staged: TargetStack) -> Iterator[OwnersStopped]: owners = OwnersStopped() - with owners.give_back: - owners.given_back_on(installed, root) + with owners.given_back_on(installed, root): owners.stop(installed, root) yield owners @@ -996,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.given_back_on(installed, root) + with owners.given_back_on(installed, root): with owners.hold: with BeginMaintenancePhase().running(clock): held = maintenance.begin(MaintenanceOperator.current()) diff --git a/tests/test_runtime_source.py b/tests/test_runtime_source.py index 6382b29b72..3fa7c06ff1 100644 --- a/tests/test_runtime_source.py +++ b/tests/test_runtime_source.py @@ -55,8 +55,7 @@ def test_restart_refusal_is_retained_and_chained(comms, tmp_path): stack = Stack(Path(__file__).resolve().parents[1], commands, home=tmp_path, runtime=LaunchRuntime.running()) with pytest.raises(OwnersNotRestarted) as failed: - with owners.give_back: - owners.given_back_on(stack, comms.root) + with owners.given_back_on(stack, comms.root): raise UpgradeRefused("original stop refusal") assert owners.started assert owners.started[0].name == owners.stopped[0] @@ -65,3 +64,19 @@ def test_restart_refusal_is_retained_and_chained(comms, tmp_path): owners.started[0].require_success(UpgradeRefused) 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 == () From f14659dfd0757beb9a106f25534401a52b21637f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:27:51 -0400 Subject: [PATCH 70/74] Let each /prime variant build its own deal trigger instead of handing its name out The gate counted three call sites passing prime.declared_name into PrimedDeal (FamilyFlattened +3): the family's identity name carried out as a string. Prime now owns trigger(), so callers ask the variant for its deal and the name stays on the declaration. UnboundPrime takes the Error suffix (N818) and prime.py is black-formatted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms_hooks/prime.py | 18 +++++++++++------- src/agent_comms_hooks/skill.py | 13 +++++++++---- tests/test_commander_skill_hook.py | 8 ++++---- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/agent_comms_hooks/prime.py b/src/agent_comms_hooks/prime.py index 1b1d3c76ad..57ab1989a6 100644 --- a/src/agent_comms_hooks/prime.py +++ b/src/agent_comms_hooks/prime.py @@ -18,7 +18,6 @@ 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.rule_deal import PrimedDeal from agent_comms_hooks.skill import DeckStart, FullPrime, Prime, SessionSource from agent_comms_hooks.watch import WatchClaim @@ -37,17 +36,22 @@ def prime_threads(prime: type[Prime], names: Sequence[str]) -> None: for name in names: thread = comms.registry.require(name) thread.execution.require_native() - PiRedeal.of(comms, thread).deal_from(prime.commander, prime.fleet, - PrimedDeal(prime.declared_name)) + 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") + 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) diff --git a/src/agent_comms_hooks/skill.py b/src/agent_comms_hooks/skill.py index d12af55783..4b0707ddfc 100644 --- a/src/agent_comms_hooks/skill.py +++ b/src/agent_comms_hooks/skill.py @@ -258,6 +258,11 @@ class Prime(DeclaredFamily): 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.""" @@ -337,7 +342,7 @@ def reason(self) -> StartReason: return StartReason.member_or_default(self, LostContext)() -class UnboundPrime(Exception): +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.""" @@ -372,11 +377,11 @@ def primed(self, prime: type[Prime]) -> tuple[str, ...]: 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(), PrimedDeal(prime.declared_name)), - commander=lambda record: self.dealt(prime.commander(), PrimedDeal(prime.declared_name))) + 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 UnboundPrime(self) + 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.""" diff --git a/tests/test_commander_skill_hook.py b/tests/test_commander_skill_hook.py index 30038a885c..203b233a3b 100644 --- a/tests/test_commander_skill_hook.py +++ b/tests/test_commander_skill_hook.py @@ -17,7 +17,7 @@ import pytest -from agent_comms.rule_deal import (CompactedDeal, PrimedDeal, StartedDeal, Unengaged, +from agent_comms.rule_deal import (CompactedDeal, StartedDeal, Unengaged, UnstampedCompactionDeal) from agent_comms.cli_bridges.monitor import BoardReply, ReadBoard, SessionRefused, SessionRequest @@ -53,7 +53,7 @@ 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, FullPrime, - Prime, UnboundPrime) + Prime, UnboundPrimeError) from agent_comms_hooks.watch import DeafWatch, LiveWatch, WatchClaim, WatchRecord from conftest import engaged from test_commander_notifications import beat, lead_hud @@ -472,7 +472,7 @@ def test_the_prime_variants_cover_papers_and_indictment_once_each_with_the_indic def test_every_prime_variant_says_it_was_primed_never_that_the_session_started(): for prime in Prime.members_with(Prime): - reason = PrimedDeal(prime.declared_name).reason() + reason = prime.trigger().reason() assert f"/prime ({prime.declared_name})" in reason assert reason != StartedDeal().reason() @@ -483,7 +483,7 @@ def test_prime_in_a_session_no_bus_thread_holds_is_refused_out_loud(tmp_path, mo transcript.write_text("") start = DeckStart.decode({"hook_event_name": "SessionStart", "session_id": "loose", "transcript_path": str(transcript)}) - with pytest.raises(UnboundPrime, match="loose"): + with pytest.raises(UnboundPrimeError, match="loose"): start.primed(FullPrime) assert not transcript.with_suffix(".deal.json").exists() From 3c84bb9f85ff91f74d669dbca0fc5445be887925 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:31:39 -0400 Subject: [PATCH 71/74] Hand upstreams the history and published heads, not the reader that imports them BranchUpstream took WorkBranches only to reach its history and selected heads, which made repo.py import work_branches under TYPE_CHECKING (debt-src TypeCheckingImport +1). The reader now passes both. GitHead decodes git's porcelain record into one mapping instead of three spelled name compares (StringIdentity/FamilyFlattened +2). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/repo.py | 31 ++++++++++++------------------- src/agent_comms/work_branches.py | 11 +++++++++-- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index eb5e337c39..096e3fe937 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -25,7 +25,6 @@ if TYPE_CHECKING: from .todos import Todo - from .work_branches import WorkBranches class RepositorySelection(ABC): @@ -629,16 +628,14 @@ 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, ...]: + 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 = reader.history.unpublished(head, tracking) + count = history.unpublished(head, tracking) return (UnpushedHead(head, count, tracking),) if count else () @@ -670,17 +667,13 @@ class PublishedUpstream(TrackedUpstream): def publication_inputs(self) -> tuple[tuple[str, str], ...]: return ((self.remote, self.reference),) - def require_prepared(self, reader: WorkBranches) -> None: - for head in reader.selected_heads(self.remote, self.reference): - reader.history.commit(head) - - def unpushed(self, reader: WorkBranches, head: str) -> tuple[UnpushedHead, ...]: - published = reader.selected_heads(self.remote, self.reference) + def unpushed(self, history: CheckoutHistory, head: str, + published: tuple[str, ...]) -> tuple[UnpushedHead, ...]: if published: (remote_head,) = published - count = reader.history.count(head, remote_head) + count = history.count(head, remote_head) return (UnpushedHead(head, count, self.tracking),) if count else () - return super().unpushed(reader, head) + return super().unpushed(history, head, published) @dataclass(frozen=True) @@ -721,17 +714,17 @@ class GitHead(ABC): @classmethod def listed(cls, record: str) -> GitHead: - fields = tuple(field.partition(" ") for field in record.split("\0")) - (path,) = tuple(value for name, _, value in fields if name == "worktree") - (sha,) = tuple(value for name, _, value in fields if name == cls.reference) - symbolic = next((value for name, _, value in fields if name == "branch"), "") + # Git's porcelain record is one "name value" line per attribute, decoded once here. + fields = dict(field.partition(" ")[::2] for field in record.split("\0")) + path, sha = fields["worktree"], fields[cls.reference] + symbolic = fields.get("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 fields: + if "detached" not in fields: raise ValueError(f"Git HEAD in {path} has neither a branch nor a detached declaration") return DetachedGitHead(checkout, commit) diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index c2d9a87c19..daf26363f4 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -511,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.""" self.history.commit(head) - return self.require_upstream(branch).unpushed(self, head) + upstream = self.require_upstream(branch) + return upstream.unpushed(self.history, head, self.published(upstream)) @cached_property def repository(self) -> tuple[str, str]: From c194d566a4601b5403789838be0fec2ae7bcc63b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:35:01 -0400 Subject: [PATCH 72/74] Assert the unsupplied tags field by its type, not a missing default test_pending_new_binding_offers_missing_tags_without_constructing_the_command read tags.editor_default, which UnsuppliedTargetField does not declare (only SuppliedTargetField owns a default); it failed on ce332013 before this branch's binding change. The field's type is the fact; json_schema still omits the default. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- tests/test_thread_creation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_thread_creation.py b/tests/test_thread_creation.py index cedf3eb331..3dbb64f2b7 100644 --- a/tests/test_thread_creation.py +++ b/tests/test_thread_creation.py @@ -52,7 +52,7 @@ def test_pending_new_binding_offers_missing_tags_without_constructing_the_comman binding.edited(NewThreadCliCommand.editor_arguments({'tags': ''})) tags = next(item for item in binding.editable_fields if item.name == 'tags') assert tags.required - assert tags.editor_default is None + assert type(tags) is UnsuppliedTargetField assert 'editor_default' not in tags.json_schema() assert tags.argument(None) == {} assert tags.argument('') == {'tags': ''} From 1f68dcd888a1086d52760e51dd786552621b886a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:39:00 -0400 Subject: [PATCH 73/74] Decode git's worktree record by its declared line order, not a name-keyed dict debt-src grew StringSubscript +1 from fields["worktree"]. Git's porcelain record opens with worktree and HEAD in that order; the decode reads them positionally. Checked against all 784 live agent-comms worktrees (550 attached, 234 detached). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/repo.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index 096e3fe937..969b38b68f 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -714,17 +714,20 @@ class GitHead(ABC): @classmethod def listed(cls, record: str) -> GitHead: - # Git's porcelain record is one "name value" line per attribute, decoded once here. - fields = dict(field.partition(" ")[::2] for field in record.split("\0")) - path, sha = fields["worktree"], fields[cls.reference] - symbolic = fields.get("branch", "") + # 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 fields: + if "detached" not in attributes: raise ValueError(f"Git HEAD in {path} has neither a branch nor a detached declaration") return DetachedGitHead(checkout, commit) From efb40c5f7143ec1061d0d250e3834aa6bde7b2f8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:39:08 -0400 Subject: [PATCH 74/74] Let CliCommand's declared default answer type stand in channel_targets contexts() spelled type[CliCommand[CliResult]] twice (channel_targets.py:180, :396); Answer already declares default=CliResult (cli_command.py:64), and the restated default was the only reason channel_targets imported CliResult under TYPE_CHECKING. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/agent_comms/channel_targets.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index fddd6a3585..f5307a50f9 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -24,7 +24,6 @@ from .catalog_document import CatalogAccess, CatalogDocument from .channels import Channel from .cli_command import CliCommand, CommandBinding - from .cli_result import CliResult from .comms import Comms from .goal_management import Goals from .historical_views import ChannelHistory, DMHistory, HistoryView @@ -177,7 +176,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 +392,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)