From cfad6769e20a137b93fe1e30c73ccb05c57ec40e Mon Sep 17 00:00:00 2001 From: Tristan Simas <56880052+trissim@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:43:55 -0400 Subject: [PATCH 01/31] Plan: one declaration per bus read (#3537) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA From c57a7476b4295f041c221a70465df870b2a80354 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 27 Oct 2026 13:26:38 -0400 Subject: [PATCH 02/31] Declare bus reads once: BusRead family, notifications migrated ReadNotifications carried a caller-choosable viewer field so the MCP outcomes tool could reuse it under the caller's authority. That made the reader's authority an argument any caller could set, and the UI and MCP sides each restated who reads. Now a BusRead member owns its arguments and its read once, as read_as(comms, reader); the boundary supplies the BusReader. UiRead derives the UI's read() as the operator, and the MCP tool reads under its own ToolCaller, which is now a BusReader. OperatorOutcomes moves to bus_read beside the reader it serves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/bus_read.py | 58 +++++++++++++++++++++++++ src/agent_comms/connector_caller.py | 3 +- src/agent_comms/tool_caller.py | 20 ++------- src/agent_comms/tools.py | 5 +-- src/agent_comms/ui_model/observation.py | 20 +++++---- 5 files changed, 77 insertions(+), 29 deletions(-) create mode 100644 src/agent_comms/bus_read.py diff --git a/src/agent_comms/bus_read.py b/src/agent_comms/bus_read.py new file mode 100644 index 0000000000..9a83bceb2d --- /dev/null +++ b/src/agent_comms/bus_read.py @@ -0,0 +1,58 @@ +"""One declaration per bus read: the operation owns its arguments and its read once. + +A ``BusRead`` member is the operation. Each boundary that serves it keeps only what is +its own: the UI its root, request id and refusals (``ReadRequest``), MCP its caller, +exposure and reply (``ToolRequest``). Neither boundary restates the read, and the +reader's authority reaches the operation as a ``BusReader`` the boundary supplies, +never as an argument the caller may choose. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from .declared_family import DeclaredFamily +from .presentation import AllOutcomes + +if TYPE_CHECKING: + from .comms import Comms + from .presentation import OutcomeViewer + + +class BusReader(ABC): + """Whose authority a read runs under; each boundary supplies its own reader.""" + + @abstractmethod + def outcome_viewer(self, comms: Comms) -> OutcomeViewer: + """Whose recipient outcomes this reader may read.""" + + +class OperatorOutcomes: + """A reader speaking for the owner reads every recipient outcome, as the owner's UI does.""" + + __slots__ = () + + def outcome_viewer(self, comms: Comms) -> OutcomeViewer: + return AllOutcomes() + + +class OperatorReader(OperatorOutcomes, BusReader): + """The owner's UI: it observes the whole bus as the operator.""" + + __slots__ = () + + +class BusRead[Result](DeclaredFamily, ABC, affix=""): + """A read of Core's stores, declared once with its arguments; boundaries derive it.""" + + @abstractmethod + def read_as(self, comms: Comms, reader: BusReader) -> Result: + """The read under the reader's authority.""" + + +class UiRead[Result](BusRead[Result], ABC): + """The UI serves this read as the operator; its envelope (root, request id, refusal) + stays on ``ReadRequest``.""" + + def read(self, comms: Comms) -> Result: + return self.read_as(comms, OperatorReader()) diff --git a/src/agent_comms/connector_caller.py b/src/agent_comms/connector_caller.py index 69b4caf582..43646991ba 100644 --- a/src/agent_comms/connector_caller.py +++ b/src/agent_comms/connector_caller.py @@ -13,7 +13,8 @@ from .head_review import HeadReviewOrder, PullRequestHead from .thread_identity import ThreadIncarnation from .todos import DirectiveText -from .tool_caller import CommanderForkCaller, NoThreadCaller, OperatorOutcomes, ThreadCaller, ToolCaller +from .bus_read import OperatorOutcomes +from .tool_caller import CommanderForkCaller, NoThreadCaller, ThreadCaller, ToolCaller from .tool_request import ToolArgumentsRefusedError, ToolRequest diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index f9bcaaa7c8..3295f4abf5 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -1,7 +1,7 @@ """The caller bound at tool entry owns caller-relative tool behavior.""" from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod from dataclasses import dataclass from typing import TYPE_CHECKING, Annotated, NoReturn @@ -11,7 +11,8 @@ from .field_codec import WireForm from .launch_root import LaunchRoot from .launch_runtime import CallingThreadUnavailable -from .presentation import AllOutcomes, OutcomeViewer, OwnerOutcomes, SentMessageSummary, ThreadRecord +from .bus_read import BusReader, OperatorOutcomes +from .presentation import OutcomeViewer, OwnerOutcomes, SentMessageSummary, ThreadRecord from .thread_identity import ThreadIncarnation from .tool_result import ToolResult @@ -29,7 +30,7 @@ class ThreadListing(ToolResult): sent_messages: tuple[SentMessageSummary, ...] -class ToolCaller(ABC): +class ToolCaller(BusReader): def acting_caller(self, grant: ToolCaller) -> ToolCaller: return self @@ -47,10 +48,6 @@ def listing(self, comms: Comms, threads: tuple[ThreadRecord, ...]) -> ThreadList @abstractmethod def catalog(self, comms: Comms, tools: tuple[dict[str, object], ...]) -> ToolCatalogResult: ... - @abstractmethod - def outcome_viewer(self, comms: Comms) -> OutcomeViewer: - """Whose recipient outcomes this caller may read.""" - @abstractmethod def acting_name(self, default: str) -> str: ... @@ -90,15 +87,6 @@ def origin(self) -> AgentOrigin: return AgentOrigin() -class OperatorOutcomes: - """A caller speaking for the owner reads every recipient outcome, as the owner's UI does.""" - - __slots__ = () - - def outcome_viewer(self, comms: Comms) -> OutcomeViewer: - return AllOutcomes() - - @dataclass(frozen=True) class NoThreadCaller(OperatorOutcomes, ToolCaller): def acting_caller(self, grant: ToolCaller) -> ToolCaller: diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index c0cc5727b7..c5ef006809 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -716,10 +716,9 @@ def apply(self, comms: Comms, caller: ToolCaller) -> MessageOutcomes: if self.limit < 1: raise ToolRefusedError("limit must be positive.") source, = comms.bus.bus.deliveries_for_references((self.reference,)) - viewer = caller.outcome_viewer(comms) - if not viewer.sees(source): + if not caller.outcome_viewer(comms).sees(source): raise ToolRefusedError(f"Message {source.message.seq} is not readable by this caller.") - window = ReadNotifications(str(comms.root), (self.reference,), viewer=viewer).read(comms) + window = ReadNotifications(str(comms.root), (self.reference,)).read_as(comms, caller) following = self.after.following( window.outcomes.get((source.message.seq, source.message.message_id), ())) page = following[:self.limit] diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index b517aac113..f68b6047f4 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -41,6 +41,7 @@ ) from agent_comms.active_route import CommsRoute from agent_comms.activity import ObservedActivity +from agent_comms.bus_read import BusReader, UiRead from agent_comms.child_process import join_retirement from agent_comms.commander_hud import CommanderFrame, CommanderHud, CommanderUnitDetail from agent_comms.comms import Comms @@ -60,7 +61,7 @@ from agent_comms.message_reference import MessageReference from agent_comms.notification_evidence import NoNotificationWindow, NotificationWindow from agent_comms.presentation import ( - AllOutcomes, CoordinationSnapshot, MessageNotification, NoRevision, OutcomeViewer, ReadState, + CoordinationSnapshot, MessageNotification, NoRevision, ReadState, WireRevision, ) from agent_comms.registry_document import RosterVisibility @@ -615,15 +616,15 @@ def read(self, comms: Comms) -> TranscriptSnapshotUpdate | None: @dataclass(frozen=True) -class ReadNotifications(ReadRequest): - """Recipient handling of the referenced original messages, as its viewer may see them. - The UI reads as the operator, who sees every outcome.""" +class ReadNotifications(UiRead[NotificationWindow], ReadRequest): + """Recipient handling of the referenced original messages, as its reader may see them. + The UI reads as the operator; an MCP caller reads under its own viewer.""" references: tuple[MessageReference, ...] - viewer: OutcomeViewer = field(default_factory=AllOutcomes, kw_only=True) - def read(self, comms: Comms) -> NotificationWindow: - return comms.views.message_notifications_for_references(self.viewer, self.references) + def read_as(self, comms: Comms, reader: BusReader) -> NotificationWindow: + return comms.views.message_notifications_for_references( + reader.outcome_viewer(comms), self.references) def changed_since(self, comms: Comms, revision: WireRevision, previous: NotificationsRead) -> bool: @@ -636,10 +637,11 @@ class ReadRecordedNotifications(ReadNotifications): source: str - def read(self, comms: Comms) -> NotificationWindow: + def read_as(self, comms: Comms, reader: BusReader) -> NotificationWindow: with comms.bus.history.read_sources() as sources: source = HistorySource.require(sources, self.source) - return source.notification_references(sources, self.references, self.viewer) + return source.notification_references( + sources, self.references, reader.outcome_viewer(comms)) def changed_since(self, comms: Comms, revision: WireRevision, previous: NotificationsRead) -> bool: From 3d3a357bb3b06f905108d03bd595df5bbdab35a9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 05:06:19 -0400 Subject: [PATCH 03/31] Type counts are measured each comparison, never stored by content (#3538) ContentCache stored mypy counts under a key of committed content and checker versions, but mypy also reads the installed packages, which no key names. Hosted #3538 restored debt-ratchet-src-71453f18 (saved by main cache job 40654054315 under a Core-only install, Pydantic 2.14.1) and compared that stored base count with a head count measured under the full stack (Pydantic 2.13.5). - CheckedRevision.findings runs the checker. TypeErrors inherits it and keeps mypy's own --cache-dir, which tracks dependency changes itself. - LintFindings.findings keeps the content-keyed memo unchanged: Ruff reads only the tree and the head's configuration. - Unformatted keeps its per-file blob cache unchanged. - ClientTypeErrors.findings calls checked directly. The client commit and src tree it computed only for the key are gone. Regression: one parameterized real-mypy test switches an installed PEP 561 dependency between two sys.path environments, with committed source, client and configuration unchanged and the same count-cache directory. The count must follow the dependency. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/guards/test_debt_ratchet.py | 48 +++++++++++++++++++++++++++++++ tools/debt_ratchet.py | 23 ++++++++++----- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/tests/guards/test_debt_ratchet.py b/tests/guards/test_debt_ratchet.py index 49d540e4f0..673b1e5f4d 100644 --- a/tests/guards/test_debt_ratchet.py +++ b/tests/guards/test_debt_ratchet.py @@ -45,10 +45,12 @@ AnyName, BooleanChainTerms, BuiltinHandlerTypeSwitch, + ClientTypeErrors, CodecSubclass, FamilyFlattened, ForeignAbsenceProbe, GodClassExcess, + HeadSide, LintFindings, LongBooleanChain, Measure, @@ -65,6 +67,7 @@ Unformatted, compare, ) +from module_layers import StackClient pytestmark = pytest.mark.refactor_guard @@ -825,6 +828,51 @@ def test_both_revisions_are_checked_under_the_head_configuration(repo: Repositor assert report["delta"][measure.at(f"{repo.root}/kept.py").text] == 0 +USES_EXTERNAL_VALUE = "import extdep\n\nvalue: int = extdep.VALUE\n" + + +def typed_dependency(directory: Path, annotation: str, value: str) -> Path: + """An installed PEP 561 package outside every committed root, found through sys.path.""" + package = directory / "extdep" + package.mkdir(parents=True) + (package / "py.typed").write_text("") + (package / "__init__.py").write_text(f"VALUE: {annotation} = {value}\n") + return directory + + +def source_type_errors(repo: Repository, client: StackClient) -> int: + revision = repo.commit({"uses_external.py": USES_EXTERNAL_VALUE}) + selected = [f"{repo.root}/uses_external.py"] + _, counted = TypeErrors.findings(repo.path, revision, revision, repo.root, selected, HeadSide) + return counted[selected[0]] + + +def client_type_errors(repo: Repository, client: StackClient) -> int: + revision = repo.commit({"__init__.py": ""}) + _, counted = ClientTypeErrors.findings(repo.path, revision, client, HeadSide) + return counted[f"{client.name}/src/uses_external.py"] + + +@pytest.mark.parametrize("type_errors", [source_type_errors, client_type_errors]) +def test_type_counts_follow_the_installed_dependency_not_a_stored_count( + repo: Repository, tmp_path: Path, monkeypatch, type_errors +) -> None: + """Mypy reads installed packages no content key names. With committed source, client and + configuration unchanged, switching the installed dependency must change the count, under + the same count-cache directory and mypy's own cache: hosted #3538 compared a base count + stored under a Core-only environment with a head count from the full stack.""" + accepting = typed_dependency(tmp_path / "accepting", "int", "1") + rejecting = typed_dependency(tmp_path / "rejecting", "str", "'one'") + (tmp_path / "client").mkdir() + checkout = Repository(tmp_path / "client", "src") + checkout.commit({"uses_external.py": "import agent_comms\n" + USES_EXTERNAL_VALUE}) + client = StackClient("probe-client", checkout.path) + monkeypatch.setenv("PYTHONPATH", search_path(accepting)) + assert type_errors(repo, client) == 0 + monkeypatch.setenv("PYTHONPATH", search_path(rejecting)) + assert type_errors(repo, client) == 1 + + def test_foreign_probes_and_codec_bases_use_ast_not_text(repo: Repository) -> None: base = repo.commit({}) head = repo.commit( diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index ce3878c3b2..b9c7470d8e 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -789,9 +789,10 @@ def inputs(cls, repo: Path, ref: str, head: str, root: str, selected: list[str]) @classmethod def findings(cls, repo: Path, ref: str, head: str, root: str, selected: list[str], side: type[Side]) -> tuple[type[Side], Counter[str]]: - return side, ContentCache.memo( - partial(cls.checked, repo, ref, head, root, selected, side), - cls.__name__, root, cls.inputs(repo, ref, head, root, selected), *selected) + """Each comparison runs the checker. A checker that reads installed packages (mypy) + depends on more than the committed tree, so its count is never stored by content; + the checker's own cache tracks those inputs.""" + return side, cls.checked(repo, ref, head, root, selected, side) @classmethod def checked(cls, repo: Path, ref: str, head: str, root: str, selected: list[str], @@ -891,6 +892,15 @@ class LintFindings(Advisory, CheckedRevision): def command(tree: Path, selected: list[str], side: type[Side]) -> list[str]: return ["ruff", "check", "--no-cache", "--output-format", "concise", *selected] + @classmethod + def findings(cls, repo: Path, ref: str, head: str, root: str, selected: list[str], + side: type[Side]) -> tuple[type[Side], Counter[str]]: + """Ruff reads only the revision's tree and the head's configuration, so its count is + stored under them.""" + return side, ContentCache.memo( + partial(cls.checked, repo, ref, head, root, selected, side), + cls.__name__, root, cls.inputs(repo, ref, head, root, selected), *selected) + class TypeErrors(CheckedRevision): """Mypy errors per changed file. Mypy checks the changed files and every module they import, @@ -949,10 +959,9 @@ def selected(client: StackClient) -> list[str]: @classmethod def findings(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> tuple[type[Side], Counter[str]]: - commit = git(client.checkout, "rev-parse", "HEAD").decode().strip() - tree = git(repo, "rev-parse", f"{ref}:src").decode().strip() - return side, ContentCache.memo(partial(cls.checked, repo, ref, client, side), - cls.__name__, client.name, commit, tree) + """Mypy resolves the client's other imports from the installed environment, which no + content key names, so each comparison checks afresh under mypy's own cache.""" + return side, cls.checked(repo, ref, client, side) @classmethod def checked(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> Counter[str]: From 732605601f86f332928e457f4856f6cd1f98f237 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 05:33:37 -0400 Subject: [PATCH 04/31] One goal-history record answers every boundary (#3538) GoalHistory (tools.py) and GoalHistoryReply (runtime_requests.py) were empty subclasses of RecordedGoalHistory, and recorded_history took the result class as an argument so each caller picked its own empty wrapper. Neither added a field or a behavior. - RecordedGoalHistory now extends ToolResult (itself a DeclaredReply), so the MCP tool and the owner socket return the same record. - GoalHistory and GoalHistoryReply are deleted. - GoalManagement.recorded_history(name, scope) returns RecordedGoalHistory; the reply argument, its HistoryReply TypeVar and the TypeVar import are gone. - CommsGoalHistoryTool and GoalHistoryRuntimeRequest return it directly; tool name, declared runtime name, subject resolution, scope decoding and the worker-thread execution are unchanged. - tests/test_goal_execution.py compares against RecordedGoalHistory.empty(). The encoded record is unchanged: one populated history/failure payload decodes and re-encodes byte-identically through GoalHistory, GoalHistoryReply and RecordedGoalHistory before this commit and through RecordedGoalHistory after it; empty() encodes as {"failures": [], "history": []} on both sides. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/goal_history.py | 4 ++-- src/agent_comms/goal_management.py | 9 +++------ src/agent_comms/runtime_requests.py | 10 ++-------- src/agent_comms/tools.py | 19 ++----------------- tests/test_goal_execution.py | 5 +++-- 5 files changed, 12 insertions(+), 35 deletions(-) diff --git a/src/agent_comms/goal_history.py b/src/agent_comms/goal_history.py index 27e3590fde..48698d16bf 100644 --- a/src/agent_comms/goal_history.py +++ b/src/agent_comms/goal_history.py @@ -29,7 +29,7 @@ from .locked_store import DurableFile from .private_registry_guard import PrivateRegistryGuard from .store_files import AtomicFile -from .tool_result import DeclaredReply +from .tool_result import ToolResult from .reported_turn import ReportedTurnState from .typed_table import ( Column, @@ -170,7 +170,7 @@ def reconcile( @dataclass(frozen=True) -class RecordedGoalHistory(DeclaredReply): +class RecordedGoalHistory(ToolResult): """A thread's goal revisions beside the attempt ledger's failures, which only the ledger writes.""" history: tuple[RecordedGoalRevision, ...] diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index 554679d7c9..7fbf96d655 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -14,7 +14,7 @@ from dataclasses import replace from functools import cached_property from pathlib import Path -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING from agent_comms.field_codec import GoalId from .agent_activity import AgentActivity @@ -72,7 +72,6 @@ from .todos import SendBackVerdict, StrikeReason, Todo, JudgedCall, TodoBatch, TodoCommand, TodoStore _LOG = logging.getLogger(__name__) -HistoryReply = TypeVar("HistoryReply", bound=RecordedGoalHistory) class Goals(GoalWaitRecovery): @@ -294,12 +293,10 @@ def goal_history( """Read durable revisions for one thread incarnation within ``scope``.""" return self.registry.goal_history(name, scope) - def recorded_history( - self, reply: type[HistoryReply], name: str, scope: GoalScope - ) -> HistoryReply: + def recorded_history(self, name: str, scope: GoalScope) -> RecordedGoalHistory: """Revisions from goal history, execution failures from the attempt ledger.""" owner = self.registry.require(name).incarnation.wire_birth - return reply( + return RecordedGoalHistory( tuple(entry.recorded() for entry in self.goal_history(name, scope)), self.open_goal_store().failures(owner, scope), ) diff --git a/src/agent_comms/runtime_requests.py b/src/agent_comms/runtime_requests.py index e27e56237e..45da46d737 100644 --- a/src/agent_comms/runtime_requests.py +++ b/src/agent_comms/runtime_requests.py @@ -82,11 +82,6 @@ class GoalReply(DeclaredReply): goal: Goal -@dataclass(frozen=True) -class GoalHistoryReply(RecordedGoalHistory): - pass - - @dataclass(frozen=True) class AcpResponseReply(DeclaredReply, WireValue): """An ACP schema response, sent exactly as the schema writes it.""" @@ -298,10 +293,9 @@ async def result(self, ctx: RuntimeRequestContext) -> DeliveryOverview: class GoalHistoryRuntimeRequest(ResultRuntimeRequest): goal_id: OneGoal | AllGoals = AllGoals() - async def result(self, ctx: RuntimeRequestContext) -> GoalHistoryReply: + async def result(self, ctx: RuntimeRequestContext) -> RecordedGoalHistory: return await Coordination.run_worker(partial( - ctx.server.agent._comms.goals.recorded_history, - GoalHistoryReply, ctx.name, self.goal_id, + ctx.server.agent._comms.goals.recorded_history, ctx.name, self.goal_id, )) diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 2ba7e69cee..4baa5bb43c 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -163,21 +163,6 @@ class GoalUpdated(OwnGoalPublication): goal: GoalDeclaration -@dataclass(frozen=True) -class GoalHistory(ToolResult, RecordedGoalHistory): - pass - - - - - - - - - - - - @dataclass(frozen=True) class WorkBranchList(ToolResult): branches: tuple[WorkBranch, ...] @@ -622,8 +607,8 @@ class CommsGoalHistoryTool(ThreadReadTool): OneGoal | AllGoals, *tool_field("Optional goal ID; omit for this thread's full history") ] = AllGoals() - def apply(self, comms: Comms, caller: ToolCaller) -> GoalHistory: - return comms.goals.recorded_history(GoalHistory, caller.subject(self.thread), self.goal_id) + def apply(self, comms: Comms, caller: ToolCaller) -> RecordedGoalHistory: + return comms.goals.recorded_history(caller.subject(self.thread), self.goal_id) diff --git a/tests/test_goal_execution.py b/tests/test_goal_execution.py index 75ce9cfb1a..ada9864a55 100644 --- a/tests/test_goal_execution.py +++ b/tests/test_goal_execution.py @@ -23,6 +23,7 @@ SetGoalAction, ) from agent_comms.goal_failure_observation import FailedTurnEvidence +from agent_comms.goal_history import RecordedGoalHistory from agent_comms.goal_presentation import ( AttemptFailedExecution, GoalExecution, @@ -36,7 +37,7 @@ from agent_comms.owned_turn import OwnedTurn from agent_comms.threads import Thread from agent_comms.tool_caller import ToolCaller -from agent_comms.tools import CommsGoalHistoryTool, GoalHistory +from agent_comms.tools import CommsGoalHistoryTool from agent_comms.turn_failure import NativeChildSignaled from agent_comms.turn_input_source import NoTurnAcceptance @@ -125,7 +126,7 @@ async def test_goal_history_shows_the_ledgers_failed_attempt_beside_active_revis assert recorded.failures[0].turn_id == turn.turn_id other = CommsGoalHistoryTool(thread="worker", goal_id=OneGoal(GoalId.mint())).apply( comms, ToolCaller.from_actor("worker")) - assert other == GoalHistory.empty() + assert other == RecordedGoalHistory.empty() @pytest.mark.asyncio From 36611e2b62a86dbebc293496ab3477818bd3cc03 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 06:24:31 -0400 Subject: [PATCH 05/31] Input delivery is one owner-live read; MCP no longer answers without the owner (#3538) CommsInputDeliveryTool read goals.input_delivery in the connector's own process under UNOBSERVED_DELIVERY_SCOPE: it could not see the owner's live queue, yet returned an ordinary-looking delivery view. The UI and the owner socket each restated the same read again (ReadDeliveryOverview, InputDispositionsRuntimeRequest, with their own include_history). - BusRead is now an abstract capability of the existing ToolDeclaration family (no DeclaredFamily root of its own); UiRead still derives from it. - BusReader owns subject/own_thread/refuse_thread once; the copies on ToolCaller and NoThreadCaller are gone, refusal text and type unchanged. - OwnerReading: one exchange with one held owner incarnation; admit() defaults to any owner, OwnerLiveRead admits only native owners, so an external bridge (live PID, no socket) is refused instead of retried forever. - OwnerReplyConnection.held/answer: the one connection factory (exact incarnation, root, process; 3 s reply timeout, close in finally, no retry after dispatch) for OwnerRead and OwnerLiveRead. - OwnerLiveRead: MCP apply/read_as resolves caller.subject(thread) and asks the owner via asyncio.run on the tool's synchronous boundary; the owner answers in owned(ctx) with ctx.name and its own session. - InputDeliveryRead (comms_input_delivery, thread, include_history=True): the one declaration, in the ToolRequest/native catalogs by capability. owned() takes scope only from the owner's delivery_scope. - OwnerReadRuntimeRequest (owner_read) and ReadOwnerOperation: one generic runtime envelope and one generic UI envelope carrying the operation. - Deleted: CommsInputDeliveryTool, ReadDeliveryOverview, InputDispositionsRuntimeRequest, the input_dispositions request. - The connector encodes Refused, ConnectionError and TimeoutError from an owner read as a failed tool call instead of dropping the HTTP call. - tests/test_command_families.py: owner_read round trips for both history settings; a non-owner-live operation and a bad include_history refuse. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/bus_read.py | 71 +++++++++++++++++++++++-- src/agent_comms/connector.py | 5 +- src/agent_comms/order_tools.py | 30 +++++++++-- src/agent_comms/runtime.py | 23 ++++++++ src/agent_comms/runtime_requests.py | 16 +++--- src/agent_comms/tool_caller.py | 16 +----- src/agent_comms/ui_model/observation.py | 41 +++++--------- tests/test_command_families.py | 9 +++- 8 files changed, 149 insertions(+), 62 deletions(-) diff --git a/src/agent_comms/bus_read.py b/src/agent_comms/bus_read.py index 9a83bceb2d..363e497d43 100644 --- a/src/agent_comms/bus_read.py +++ b/src/agent_comms/bus_read.py @@ -8,15 +8,22 @@ """ from __future__ import annotations +import asyncio from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NoReturn -from .declared_family import DeclaredFamily +from .launch_runtime import CallingThreadUnavailable from .presentation import AllOutcomes +from .tool_declaration import ToolDeclaration +from .tool_result import ToolResult if TYPE_CHECKING: from .comms import Comms + from .owner_connection import RuntimeRequestContext from .presentation import OutcomeViewer + from .runtime import RuntimeConnection + from .thread_identity import ThreadIncarnation + from .threads import Thread class BusReader(ABC): @@ -26,6 +33,16 @@ class BusReader(ABC): def outcome_viewer(self, comms: Comms) -> OutcomeViewer: """Whose recipient outcomes this reader may read.""" + def subject(self, named: str) -> str: + return named or self.own_thread() + + def own_thread(self) -> str: + """The thread a read targets when its call names none; a reader with no thread has none.""" + return self.refuse_thread() + + def refuse_thread(self) -> NoReturn: + raise CallingThreadUnavailable("This tool requires a thread caller.") + class OperatorOutcomes: """A reader speaking for the owner reads every recipient outcome, as the owner's UI does.""" @@ -42,7 +59,7 @@ class OperatorReader(OperatorOutcomes, BusReader): __slots__ = () -class BusRead[Result](DeclaredFamily, ABC, affix=""): +class BusRead[Result](ToolDeclaration, ABC): """A read of Core's stores, declared once with its arguments; boundaries derive it.""" @abstractmethod @@ -56,3 +73,51 @@ class UiRead[Result](BusRead[Result], ABC): def read(self, comms: Comms) -> Result: return self.read_as(comms, OperatorReader()) + + +class OwnerReading[Result](ABC): + """One exchange with exactly one live owner incarnation over its socket.""" + + def admit(self, owner: Thread) -> None: + """An ordinary owner reading accepts any registered owner of the held incarnation.""" + + @abstractmethod + async def read(self, owner: RuntimeConnection) -> Result: ... + + +class OwnerLiveRead[Result: ToolResult](OwnerReading[Result], BusRead[Result], ABC): + """A read only the selected thread's live native owner can answer, from its own queue. + + MCP and the UI both reach the owner; neither answers from its own process, and owner + loss reaches the caller as the original refusal. The owner runs ``owned`` against its + own registration and session, never against the public thread selector. + """ + + if TYPE_CHECKING: + # The composed ThreadReadTool declares the field; an annotation here would shadow it. + thread: str + + def apply(self, comms: Comms, caller: BusReader) -> Result: + return self.read_as(comms, caller) + + def read_as(self, comms: Comms, reader: BusReader) -> Result: + from .runtime import OwnerReplyConnection + + owner = comms.registry.require(reader.subject(self.thread)).incarnation + return asyncio.run(OwnerReplyConnection.answer(comms, owner, self)) + + def admit(self, owner: Thread) -> None: + owner.execution.require_native() + + async def read(self, owner: RuntimeConnection) -> Result: + from .runtime_requests import OwnerReadRuntimeRequest + + return self.decode_reply( + await owner.request(OwnerReadRuntimeRequest.declared_name, operation=self)) + + @abstractmethod + async def owned(self, ctx: RuntimeRequestContext) -> Result: + """The answer computed inside the owner, under its own context.""" + + @abstractmethod + def decode_reply(self, raw: object) -> Result: ... diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index c74c654155..453b04611c 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -458,7 +458,10 @@ async def result(self, connector: Connector, caller: ConnectorCaller) -> ToolsCa try: reply = await asyncio.to_thread(connector.invoke, tool, self.params.arguments, caller) return ToolsCallResult((TextContent(json.dumps(reply)),), False, reply) - except (ToolRefusedError, RelationViolationError, CoordinationError) as error: + except ( + ToolRefusedError, RelationViolationError, CoordinationError, + Refused, ConnectionError, TimeoutError, + ) as error: logging.exception("Connector tool %s failed", self.params.name) return ToolsCallResult.failed(error) diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index 95deece7ca..fd06075b04 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -5,7 +5,8 @@ from abc import abstractmethod from collections.abc import Mapping from dataclasses import dataclass, field, replace -from typing import Annotated, Literal +from functools import partial +from typing import TYPE_CHECKING, Annotated, Literal from pydantic import JsonValue @@ -31,9 +32,11 @@ JudgedCall, ToolCallVerdict, ) +from .bus_read import OwnerLiveRead from .commander.vibe_check import OverloadDegradation from .commander_hud import CommanderHud from .comms import Comms +from .coordinator import Coordination from .errors import ToolRefusedError from .field_codec import FieldCodec, MessageId, OrderId, RecordProblems, WireName from .goal_actions import CreateProblemGoalAction, OrderGoalAction @@ -69,6 +72,9 @@ UnjudgedEdits, ) +if TYPE_CHECKING: + from .owner_connection import RuntimeRequestContext + @dataclass(frozen=True) class TodoChanged(ToolResult): @@ -499,13 +505,27 @@ def apply(self, comms: Comms, caller: ToolCaller) -> ThreadTurns: @dataclass(frozen=True, kw_only=True) -class CommsInputDeliveryTool(ThreadReadTool): +class InputDeliveryRead( + OwnerLiveRead[DeliveryOverview], ThreadReadTool, declared_name="comms_input_delivery", +): + """The one declaration of a unit's input delivery, answered by its live native owner.""" + label = "Input delivery" description = ("Read a unit's inputs that failed or are UNKNOWN, and its earlier delivery " "notices, each with its bus sequence and input id; none is ever replayed.") - - def apply(self, comms: Comms, caller: ToolCaller) -> DeliveryOverview: - return comms.goals.input_delivery(caller.subject(self.thread), include_history=True) + include_history: Annotated[ + bool, *tool_field("Include earlier delivery notices; current inputs are always read") + ] = True + + async def owned(self, ctx: RuntimeRequestContext) -> DeliveryOverview: + scope = await ctx.server.agent.inputs.delivery_scope(ctx.session_id) + return await Coordination.run_worker(partial( + ctx.server.agent._comms.goals.input_delivery, + ctx.name, include_history=self.include_history, scope=scope, + )) + + def decode_reply(self, raw: object) -> DeliveryOverview: + return FieldCodec.decode(DeliveryOverview, raw) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/runtime.py b/src/agent_comms/runtime.py index 89dfdde624..31cee7f5e5 100644 --- a/src/agent_comms/runtime.py +++ b/src/agent_comms/runtime.py @@ -62,6 +62,9 @@ if TYPE_CHECKING: from .acp import CommsAgent + from .bus_read import OwnerReading + from .comms import Comms + from .thread_identity import ThreadIncarnation from .selected_write_authority import AcpSelectedWriteAuthority from .turn_runner import TurnRunner @@ -428,6 +431,26 @@ class OwnerReplyConnection(RuntimeConnection): REPLY_TIMEOUT: ClassVar[float] = 3 + @classmethod + def held(cls, comms: Comms, owner: ThreadIncarnation, reading: OwnerReading) -> Self: + """A connection to exactly this registered owner incarnation, which the reading admits.""" + thread = comms.registry.require(owner.name) + if thread.incarnation != owner: + raise OwnerIdentityChangedError("The owner incarnation changed while preparing the request.") + reading.admit(thread) + return cls(comms, thread.name, socket_path(comms.root, thread.require_process().pid)) + + @classmethod + async def answer[Result]( + cls, comms: Comms, owner: ThreadIncarnation, reading: OwnerReading[Result], + ) -> Result: + """One reading from the held owner; the connection closes whatever the outcome.""" + connection = await asyncio.to_thread(cls.held, comms, owner, reading) + try: + return await reading.read(connection) + finally: + await connection.close() + async def _read_response(self, reader: asyncio.StreamReader) -> bytes: async with asyncio.timeout(self.REPLY_TIMEOUT): return await super()._read_response(reader) diff --git a/src/agent_comms/runtime_requests.py b/src/agent_comms/runtime_requests.py index 45da46d737..9ea7d4b9e9 100644 --- a/src/agent_comms/runtime_requests.py +++ b/src/agent_comms/runtime_requests.py @@ -38,6 +38,7 @@ from .field_codec import AcpSessionId, FieldCodec, WireName, WireValue, GoalId from .goal_actions import GoalAction from .goal_history import RecordedGoalHistory +from .bus_read import OwnerLiveRead from .goal_scope import AllGoals, OneGoal from .goals import Goal from .manual_compaction_bridge import compact_context @@ -45,7 +46,7 @@ from .owner_protocol import ClientLine, OwnerAnnouncement, OwnerLine, OwnerReady, OwnerResult from .thread_identity import ThreadIncarnation from .thread_presentation import LiveThreadOwnerBinding -from .tool_result import DeclaredReply +from .tool_result import DeclaredReply, ToolResult from .turn_context import ( ContextManifest, ContextSourceText, @@ -267,16 +268,13 @@ async def result(self, ctx: RuntimeRequestContext) -> CompactionOutcome: @dataclass(frozen=True, kw_only=True) -class InputDispositionsRuntimeRequest(ResultRuntimeRequest): - include_history: bool = False +class OwnerReadRuntimeRequest(ResultRuntimeRequest): + """One envelope for every owner-live read; the operation answers under this owner's context.""" - async def result(self, ctx: RuntimeRequestContext) -> DeliveryOverview: - return await Coordination.run_worker(partial(ctx.server.agent._comms.goals.input_delivery, - ctx.name, - include_history=self.include_history, - scope=await ctx.server.agent.inputs.delivery_scope(ctx.session_id), + operation: OwnerLiveRead - )) + async def result(self, ctx: RuntimeRequestContext) -> ToolResult: + return await self.operation.owned(ctx) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index 3295f4abf5..29cf6841e6 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -3,14 +3,13 @@ from abc import abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Annotated, NoReturn +from typing import TYPE_CHECKING, Annotated from .bus_publication import AgentOrigin from .cli_result import ThreadToolCatalogResult, ToolCatalogResult from .errors import ToolRefusedError from .field_codec import WireForm from .launch_root import LaunchRoot -from .launch_runtime import CallingThreadUnavailable from .bus_read import BusReader, OperatorOutcomes from .presentation import OutcomeViewer, OwnerOutcomes, SentMessageSummary, ThreadRecord from .thread_identity import ThreadIncarnation @@ -55,13 +54,6 @@ def acting_name(self, default: str) -> str: ... def from_flag(self) -> tuple[str, ...]: """The command line's --from argument naming this caller, if it names a thread.""" - @abstractmethod - def own_thread(self) -> str: - """The thread a tool acts on when its call names none.""" - - def subject(self, named: str) -> str: - return named or self.own_thread() - def commanding(self, comms: Comms, commander: str) -> None: """An ordinary caller needs no commander-child registration.""" return None @@ -104,18 +96,12 @@ def listing(self, comms: Comms, threads: tuple[ThreadRecord, ...]) -> ThreadList def catalog(self, comms: Comms, tools: tuple[dict[str, object], ...]) -> ToolCatalogResult: return ToolCatalogResult(tools) - def refuse_thread(self) -> NoReturn: - raise CallingThreadUnavailable("This tool requires a thread caller.") - def acting_name(self, default: str) -> str: return default def from_flag(self) -> tuple[str, ...]: return () - def own_thread(self) -> NoReturn: - return self.refuse_thread() - def require_directive(self, comms: Comms, body: str) -> None: """A caller with no thread gives no directive.""" diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index f68b6047f4..2df3194430 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -41,7 +41,7 @@ ) from agent_comms.active_route import CommsRoute from agent_comms.activity import ObservedActivity -from agent_comms.bus_read import BusReader, UiRead +from agent_comms.bus_read import BusReader, OwnerLiveRead, OwnerReading, UiRead from agent_comms.child_process import join_retirement from agent_comms.commander_hud import CommanderFrame, CommanderHud, CommanderUnitDetail from agent_comms.comms import Comms @@ -79,7 +79,6 @@ from agent_comms.thread_identity import ThreadIncarnation from agent_comms.thread_presentation import ThreadPresentation from agent_comms.transcripts import TranscriptCursor, TranscriptReadIdentity -from agent_comms.ui_model.delivery import ReadDeliveryView from agent_comms.ui_model.sidebar import ( ChannelRowModel, SidebarModel, @@ -96,6 +95,8 @@ from multiprocessing.connection import Connection from agent_comms.runtime import RuntimeConnection + from agent_comms.threads import Thread + from agent_comms.tool_result import ToolResult from agent_comms.transcripts import TranscriptRead @@ -453,7 +454,7 @@ def read(self, comms: Comms) -> CommanderUnitDetail: @dataclass(frozen=True) -class OwnerRead(AwaitedRead): +class OwnerRead(OwnerReading[object], AwaitedRead): """A read the live owner of thread incarnation ``owner`` answers over its socket. Served as it arrives, beside store reads; the owner's reply is decoded @@ -487,25 +488,10 @@ async def answer(self, comms: Comms) -> ReadOutcome: except self.refusal_errors as error: return self.refused(error) - def connect(self, comms: Comms) -> RuntimeConnection: - from agent_comms.runtime import OwnerIdentityChangedError, OwnerReplyConnection, socket_path - - owner = comms.registry.require(self.owner.name) - if owner.incarnation != self.owner: - raise OwnerIdentityChangedError("The owner incarnation changed while preparing the request.") - return OwnerReplyConnection( - comms, owner.name, socket_path(comms.root, owner.require_process().pid), - ) - async def ask(self, comms: Comms) -> object: - connection = await asyncio.to_thread(self.connect, comms) - try: - return await self.read(connection) - finally: - await connection.close() + from agent_comms.runtime import OwnerReplyConnection - @abstractmethod - async def read(self, owner: RuntimeConnection) -> object: ... + return await OwnerReplyConnection.answer(comms, self.owner, self) @dataclass(frozen=True) @@ -692,15 +678,16 @@ async def read(self, owner: RuntimeConnection) -> GoalSnapshotReply: @dataclass(frozen=True) -class ReadDeliveryOverview(OwnerRead): - """The owner's delivery notices, checked against its live queue.""" +class ReadOwnerOperation(OwnerRead): + """The UI's envelope for an owner-live read: the operation's own reply, from the held owner.""" - include_history: bool = False + operation: OwnerLiveRead - async def read(self, owner: RuntimeConnection) -> ReadDeliveryView: - return ReadDeliveryView.from_wire( - await owner.request("input_dispositions", include_history=self.include_history) - ) + def admit(self, owner: Thread) -> None: + self.operation.admit(owner) + + async def read(self, owner: RuntimeConnection) -> ToolResult: + return await self.operation.read(owner) @dataclass(frozen=True) diff --git a/tests/test_command_families.py b/tests/test_command_families.py index 99ce697d4b..cd58a49969 100644 --- a/tests/test_command_families.py +++ b/tests/test_command_families.py @@ -86,7 +86,10 @@ def parse(args): {"action": "cancel"}, {"action": "set_config_option", "config_id": "model", "value": "test/model"}, {"action": "compact", "instructions": "focus"}, - {"action": "input_dispositions", "include_history": True}, + {"action": "owner_read", "operation": { + "tool": "comms_input_delivery", "thread": "", "include_history": True}}, + {"action": "owner_read", "operation": { + "tool": "comms_input_delivery", "thread": "", "include_history": False}}, {"action": "dismiss_historical_inputs"}, {"action": "goal_history", "goal_id": None}, {"action": "goal_snapshot"}, @@ -117,7 +120,9 @@ def test_runtime_wire_format_round_trip(parameters): @pytest.mark.parametrize( "parameters", [ - {"action": "input_dispositions", "include_history": "yes"}, + {"action": "owner_read", "operation": { + "tool": "comms_input_delivery", "include_history": "yes"}}, + {"action": "owner_read", "operation": {"tool": "comms_goal_history"}}, {"action": "goal_history", "goal_id": 2}, {"action": "edit_goal", "goal_id": None, "expected_revision": True, "text": "x"}, {"action": "retry_goal"}, From c0821f5719bea546229dd57a80ba546226303837 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 06:32:27 -0400 Subject: [PATCH 06/31] Owner-read tests derive their cases and run the delivery read against a live owner (#3538) - test_observer_death: unavailable/stale owner reads are parametrized from ReadOwnerGoal plus ReadOwnerOperation over BusRead.members_with(OwnerLiveRead), not OwnerRead.__subclasses__(); refusal and observer-alive assertions kept. - test_input_delivery_history: the CLI test that asserted a declared owner with no process answers delivery state is deleted (that answer was the defect). The replacement starts the canonical owner socket, queues a prompt, records an off-queue notice, and asserts MCP invoke and the UI envelope both equal the owner's own projection for include_history False and True, that the off-queue notice is current only under the old unobserved projection, and that a stopped owner refuses the MCP read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/test_input_delivery_history.py | 70 +++++++++++++++++++++------- tests/test_observer_death.py | 15 ++++-- 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/tests/test_input_delivery_history.py b/tests/test_input_delivery_history.py index 19c94ada35..9e9acc74dd 100644 --- a/tests/test_input_delivery_history.py +++ b/tests/test_input_delivery_history.py @@ -1,23 +1,31 @@ """Current owner queues scope notices without altering durable input evidence.""" from agent_comms.repo import Checkout -import io -import json -from contextlib import redirect_stdout +import asyncio from dataclasses import replace -from agent_comms.cli import main +import pytest +from acp.schema import TextContentBlock + +from agent_comms.acp_extension import QueuePromptRequest, encode_request +from agent_comms.child_process import NoProcess +from agent_comms.errors import RelationViolationError from agent_comms.input_key import AcpInputKey, BusInputKey from agent_comms.comms import wire -from agent_comms.field_codec import GoalId, InputId, TurnId +from agent_comms.field_codec import FieldCodec, GoalId, InputId, TurnId from agent_comms.input_attempt import ( OwnerQueueInputDeliveryScope, UnobservedInputDeliveryScope, UnresolvedInput, ) from agent_comms.input_disposition import InputDispositions +from agent_comms.order_tools import InputDeliveryRead from agent_comms.reported_turn import ReportedTurn +from agent_comms.thread_status import RunningThreadStatus, StoppedThreadStatus from agent_comms.threads import Thread +from agent_comms.tool_caller import ToolCaller +from agent_comms.ui_model.observation import ReadOwnerOperation +from delivery_owner_fixture import canonical_delivery_owner from stored_inputs import stored_inputs @@ -103,18 +111,46 @@ def test_history_clear_is_notice_only_and_survives_rename_and_reopen(tmp_path): assert stored_inputs(ledger.root).rows[BusInputKey(7)].native_id == "b" * 32 -def test_commander_reads_a_units_failed_inputs_through_the_tool(tmp_path): - """2026-10-24: the commander hand-wrote SQL against the bus to list pi-mi's lost inputs.""" - comms = wire(tmp_path) - comms.registry.declare(Thread('owner', frozenset(), Checkout(tmp_path))) - seed(comms, 'owner') - output = io.StringIO() - with redirect_stdout(output): - code = main(['--root', str(comms.root), 'tool', '--tool', 'comms_input_delivery', - '--arguments', json.dumps({'thread': 'owner'})]) - assert code == 0, output.getvalue() - overview = json.loads(output.getvalue()) - assert {row['sequence'] for row in overview['inputs']} >= {2, 8} +async def test_mcp_and_the_ui_read_input_delivery_from_the_live_owner_queue(tmp_path): + """2026-10-24: the commander hand-wrote SQL to list pi-mi's lost inputs; the tool then + answered from the connector's own process, where no owner queue exists. Both boundaries + now carry one InputDeliveryRead to the owner and receive the owner's own answer.""" + async with canonical_delivery_owner(tmp_path) as (comms, agent, _, _): + comms.registry.register(comms.registry.require("alpha"), RunningThreadStatus()) + comms.agents.begin_turn("alpha", TurnId.mint(), "Held native fixture") + agent.inputs.state("alpha").open_inbox() + await agent.prompt("alpha", [TextContentBlock(type="text", text="queued")], + field_meta=encode_request(QueuePromptRequest("queued"))) + InputDispositions(comms.root).record( + BusInputKey(91), seq=91, owner="alpha", claim_scope=1, target="alpha", text="Off queue") + owner = comms.registry.require("alpha") + scope = await agent.inputs.delivery_scope("alpha") + assert type(scope) is OwnerQueueInputDeliveryScope + unobserved = comms.goals.input_delivery("alpha") + assert BusInputKey(91) in [row.input_id for row in unobserved.inputs] + answers = {} + for include_history in (False, True): + owned = comms.goals.input_delivery("alpha", include_history=include_history, scope=scope) + async with asyncio.timeout(30): + mcp = await asyncio.to_thread( + InputDeliveryRead.invoke, comms, + {"thread": "alpha", "include_history": include_history}, + caller=ToolCaller.from_actor("sender")) + ui = await ReadOwnerOperation( + str(comms.root), owner.incarnation, InputDeliveryRead(include_history=include_history), + ).answer(comms) + assert mcp == FieldCodec.encode(owned) + assert ui.value == owned + answers[include_history] = owned + assert BusInputKey(91) not in [row.input_id for row in answers[False].inputs] + assert answers[False].historical_inputs == () + assert BusInputKey(91) in [row.input_id for row in answers[True].historical_inputs] + assert replace(answers[True], historical_inputs=()) == answers[False] + comms.registry.register(replace(owner, process_identity=NoProcess()), StoppedThreadStatus()) + with pytest.raises(RelationViolationError): + async with asyncio.timeout(30): + await asyncio.to_thread(InputDeliveryRead.invoke, comms, {"thread": "alpha"}, + caller=ToolCaller.from_actor("sender")) def test_no_owner_queue_observation_never_dismisses_inputs(tmp_path): diff --git a/tests/test_observer_death.py b/tests/test_observer_death.py index 78379f87c3..f4a1c2b16e 100644 --- a/tests/test_observer_death.py +++ b/tests/test_observer_death.py @@ -4,6 +4,7 @@ from agent_comms.repo import Checkout import asyncio import os +from functools import partial import signal import pytest @@ -15,12 +16,20 @@ from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.owner_connection import OwnerIdentityChangedError from agent_comms.message_reference import MessageReference +from agent_comms.bus_read import BusRead, OwnerLiveRead from agent_comms.ui_model.observation import ( - ObservationProcess, ObserverDied, OwnerRead, ReadNotifications, StopSidebar, + ObservationProcess, ObserverDied, ReadNotifications, ReadOwnerGoal, ReadOwnerOperation, StopSidebar, ) from agent_comms.threads import Thread +def owner_reads(): + """Every owner read the UI can send: the goal read, and each owner-live operation in its envelope.""" + yield pytest.param(ReadOwnerGoal, id="goal") + for operation in BusRead.members_with(OwnerLiveRead): + yield pytest.param(partial(ReadOwnerOperation, operation=operation()), id=operation.declared_name) + + async def readable(process: ObservationProcess) -> None: """Wait until the observer's pipe has something to read, as the UI's event loop does.""" ready = asyncio.Event() @@ -70,7 +79,7 @@ async def test_an_invalid_notification_reference_does_not_kill_the_observer(comm assert await process.read(ReadNotifications(str(comms.root), ())) == {} -@pytest.mark.parametrize("read_type", OwnerRead.__subclasses__()) +@pytest.mark.parametrize("read_type", owner_reads()) @pytest.mark.parametrize("declared, refusal", [ (True, RelationViolationError), (False, UnregisteredThreadError), ]) @@ -88,7 +97,7 @@ async def test_unavailable_owner_reads_preserve_the_observer( process.status.raise_if_failed() -@pytest.mark.parametrize("read_type", OwnerRead.__subclasses__()) +@pytest.mark.parametrize("read_type", owner_reads()) @pytest.mark.parametrize("other_root", [False, True]) async def test_stale_owner_reads_preserve_the_observer(comms, durable_home, read_type, other_root): owner = Thread("read-subject", frozenset(), Checkout(comms.root.parent)) From a4702b1f7640b8e30272d611d77db93505aa333c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 06:51:33 -0400 Subject: [PATCH 07/31] Owner reads admit only native owners and keep the incarnation they were handed (#3538) - OwnerReading.admit is the shared native-transport admission (owner.execution.require_native()). OwnerLiveRead.admit and ReadOwnerOperation.admit were identical restatements and are deleted. ReadOwnerGoal now reaches it too: an external bridge has a live PID and no RuntimeServer socket, and _connect_current waited for that socket forever before dispatch. It is refused before the wait. - OwnerReplyConnection.held compared the registry's incarnation with the supplied one, then OwnerConnection.__init__ read the registry again into _identity, so a replacement incarnation could be recaptured unchecked. held now constructs the connection and OwnerConnection.require_identity compares the captured _identity with the supplied witness before returning; the earlier separate comparison is deleted. No second identity field. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/bus_read.py | 7 +++---- src/agent_comms/owner_connection.py | 7 ++++++- src/agent_comms/runtime.py | 6 +++--- src/agent_comms/ui_model/observation.py | 4 ---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/agent_comms/bus_read.py b/src/agent_comms/bus_read.py index 363e497d43..eda1ffffa1 100644 --- a/src/agent_comms/bus_read.py +++ b/src/agent_comms/bus_read.py @@ -79,7 +79,9 @@ class OwnerReading[Result](ABC): """One exchange with exactly one live owner incarnation over its socket.""" def admit(self, owner: Thread) -> None: - """An ordinary owner reading accepts any registered owner of the held incarnation.""" + """Only a native owner serves a runtime socket; an external bridge with a live PID + has none, so it is refused here instead of waited on before dispatch.""" + owner.execution.require_native() @abstractmethod async def read(self, owner: RuntimeConnection) -> Result: ... @@ -106,9 +108,6 @@ def read_as(self, comms: Comms, reader: BusReader) -> Result: owner = comms.registry.require(reader.subject(self.thread)).incarnation return asyncio.run(OwnerReplyConnection.answer(comms, owner, self)) - def admit(self, owner: Thread) -> None: - owner.execution.require_native() - async def read(self, owner: RuntimeConnection) -> Result: from .runtime_requests import OwnerReadRuntimeRequest diff --git a/src/agent_comms/owner_connection.py b/src/agent_comms/owner_connection.py index 205b51650f..071ded4c4f 100644 --- a/src/agent_comms/owner_connection.py +++ b/src/agent_comms/owner_connection.py @@ -28,7 +28,7 @@ from .comms import Comms from .child_process import ProcessIdentity from .registry_document import RegistryEntry - from .thread_identity import OwnerIdentity + from .thread_identity import OwnerIdentity, ThreadIncarnation from .runtime import RuntimeServer, SocketClient from .threads import Thread @@ -150,6 +150,11 @@ def __init__(self, comms: Comms, session_id: str, path: Path): self._identity = comms.registry.entry(session_id).entry.thread.incarnation self._root_identity = comms.root.stat() + def require_identity(self, owner: ThreadIncarnation) -> None: + """The incarnation this connection captured is the one the caller holds.""" + if self._identity != owner: + raise OwnerIdentityChangedError("The owner incarnation changed while preparing the request.") + def _owner_entry(self) -> RegistryEntry: if self._closed: raise ConnectionError("Owner attachment was closed before request dispatch") diff --git a/src/agent_comms/runtime.py b/src/agent_comms/runtime.py index 31cee7f5e5..e52290502b 100644 --- a/src/agent_comms/runtime.py +++ b/src/agent_comms/runtime.py @@ -435,10 +435,10 @@ class OwnerReplyConnection(RuntimeConnection): def held(cls, comms: Comms, owner: ThreadIncarnation, reading: OwnerReading) -> Self: """A connection to exactly this registered owner incarnation, which the reading admits.""" thread = comms.registry.require(owner.name) - if thread.incarnation != owner: - raise OwnerIdentityChangedError("The owner incarnation changed while preparing the request.") + connection = cls(comms, thread.name, socket_path(comms.root, thread.require_process().pid)) + connection.require_identity(owner) reading.admit(thread) - return cls(comms, thread.name, socket_path(comms.root, thread.require_process().pid)) + return connection @classmethod async def answer[Result]( diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index 2df3194430..dba817b3d2 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -95,7 +95,6 @@ from multiprocessing.connection import Connection from agent_comms.runtime import RuntimeConnection - from agent_comms.threads import Thread from agent_comms.tool_result import ToolResult from agent_comms.transcripts import TranscriptRead @@ -683,9 +682,6 @@ class ReadOwnerOperation(OwnerRead): operation: OwnerLiveRead - def admit(self, owner: Thread) -> None: - self.operation.admit(owner) - async def read(self, owner: RuntimeConnection) -> ToolResult: return await self.operation.read(owner) From 08263a4c08ee7a8d95c76f961d566158c657a93c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 06:57:08 -0400 Subject: [PATCH 08/31] Owner reads are held to their owner's lifetime: real EOF, root, recreation, death, external controls (#3538) tests/owner_socket_fixture.py (registered in pytest_plugins) is the one fixture owner: runtime_processes (moved from test_runtime.py; it now yields the spawned children so a test can stop one through its own owner), declare_owner (native or external registration) and silent_socket (the real Unix-socket peer from test_dispatched_request_without_a_reply..., which receives one request and closes without replying). The existing reply-loss test is refactored onto it. New controls, nothing of ours patched, no fabricated reply: - MCP InputDeliveryRead without a reply, for include_history False and True: OwnerReplyLostError, exactly one received request decoding to OwnerReadRuntimeRequest(thread, InputDeliveryRead(thread, include_history)), durable inputs unchanged. The peer runs no domain body. - Root replaced under a held connection (rename to a sibling, new directory at the old path): OwnerIdentityChangedError, zero requests; root restored. - Same-name recreation (unregister, begin_delete, remove, declare): the held read and a new held() with the old witness both refuse; incarnations differ. - Held process death (runtime_processes child stopped through stop_sync): the read refuses with ConnectionError before any request; inputs unchanged. - External owner with a live child and no socket: MCP delivery refuses with RelationViolationError; through the observer, ReadOwnerGoal and the input-delivery envelope (derived from OwnerLiveRead membership) both refuse and the observer stays alive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/conftest.py | 2 +- tests/owner_socket_fixture.py | 69 +++++++++++++++ tests/test_observer_death.py | 18 ++++ tests/test_runtime.py | 159 ++++++++++++++++++++++++++-------- 4 files changed, 210 insertions(+), 38 deletions(-) create mode 100644 tests/owner_socket_fixture.py diff --git a/tests/conftest.py b/tests/conftest.py index 267bdec096..cf7d9fc0d9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,7 +55,7 @@ from agent_comms.registry_document import RegisteredCommanderProject from agent_comms_hooks.watch import WatchClaim, WatchRecord -pytest_plugins = (goal_owner_fixture.__name__, "github_fixture", "pytest_session") +pytest_plugins = (goal_owner_fixture.__name__, "github_fixture", "owner_socket_fixture", "pytest_session") class TranscriptRecords: diff --git a/tests/owner_socket_fixture.py b/tests/owner_socket_fixture.py new file mode 100644 index 0000000000..c328086c64 --- /dev/null +++ b/tests/owner_socket_fixture.py @@ -0,0 +1,69 @@ +"""Registered owner threads, their runtime sockets and their child processes for owner-read tests. + +A native owner's socket here is a real Unix-socket peer that receives exactly one request +and closes without replying: it proves dispatch and then reply loss, never the domain +answer. An external owner has a live process and no socket. Child processes are real +spawned processes that a test may stop through their own owner. +""" +from __future__ import annotations + +import asyncio +import json +import sys +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager + +import pytest + +from agent_comms.child_process import ProcessIdentity, SpawnedProcess +from agent_comms.comms import Comms +from agent_comms.private_path import PrivateSocketRole +from agent_comms.repo import Checkout +from agent_comms.runtime import socket_path +from agent_comms.thread_execution import NativeThreadExecution, ThreadExecution +from agent_comms.threads import Thread + + +@pytest.fixture +def runtime_processes() -> Iterator[tuple[SpawnedProcess, SpawnedProcess]]: + children = [] + try: + for _ in range(2): + children.append( + SpawnedProcess.launch((sys.executable, "-c", "import time; time.sleep(60)")) + ) + yield tuple(children) + finally: + for child in children: + child.stop_sync() + + +def declare_owner( + comms: Comms, name: str, process: ProcessIdentity, + execution: type[ThreadExecution] = NativeThreadExecution, +) -> Thread: + return comms.registry.declare(Thread( + name, frozenset(), Checkout(comms.root.parent), process_identity=process, execution=execution, + )) + + +@asynccontextmanager +async def silent_socket(comms: Comms, process: ProcessIdentity) -> AsyncIterator[list[dict[str, object]]]: + """The owner's socket path served by a peer that reads one request and hangs up.""" + path = socket_path(comms.root, process.pid) + path.parent.mkdir(parents=True, exist_ok=True) + received: list[dict[str, object]] = [] + + async def disconnect_after_request(reader, writer): + received.append(json.loads(await reader.readline())) + writer.close() + await writer.wait_closed() + + with PrivateSocketRole.address(path) as address: + server = await asyncio.start_unix_server(disconnect_after_request, path=address) + try: + yield received + finally: + server.close() + await server.wait_closed() + path.unlink(missing_ok=True) diff --git a/tests/test_observer_death.py b/tests/test_observer_death.py index f4a1c2b16e..28dc7d1543 100644 --- a/tests/test_observer_death.py +++ b/tests/test_observer_death.py @@ -20,7 +20,9 @@ from agent_comms.ui_model.observation import ( ObservationProcess, ObserverDied, ReadNotifications, ReadOwnerGoal, ReadOwnerOperation, StopSidebar, ) +from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.threads import Thread +from owner_socket_fixture import declare_owner def owner_reads(): @@ -112,6 +114,22 @@ async def test_stale_owner_reads_preserve_the_observer(comms, durable_home, read process.status.raise_if_failed() +@pytest.mark.parametrize("read_type", owner_reads()) +async def test_external_owner_reads_refuse_before_waiting_for_a_socket( + comms, durable_home, runtime_processes, read_type, +): + """An external bridge has a live process and no runtime socket: the read refuses with the + original error instead of waiting for a socket that will never be served.""" + owner = declare_owner(comms, "external-owner", runtime_processes[0].identity, ExternalThreadExecution) + with ObservationProcess.awaited(comms.route) as process: + with pytest.raises(RelationViolationError): + async with asyncio.timeout(30): + await process.read(read_type(str(comms.root), owner.incarnation)) + assert await process.read(ReadNotifications(str(comms.root), ())) == {} + assert process.process.is_alive() + process.status.raise_if_failed() + + async def test_an_observer_that_failed_is_reported_with_its_own_traceback_and_exit_status(tmp_path, durable_home): not_a_wire = tmp_path / "not-a-wire" not_a_wire.write_text("") diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 0a609af0cb..24d977f05b 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -20,54 +20,37 @@ decode_updates, encode_updates, ) -from agent_comms.child_process import ProcessIdentity, SpawnedProcess +from agent_comms.child_process import ProcessIdentity +from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import FieldCodec +from agent_comms.input_disposition import InputDispositions, InputDocument +from agent_comms.input_key import BusInputKey +from agent_comms.order_tools import InputDeliveryRead +from agent_comms.owner_connection import OwnerIdentityChangedError, RuntimeRequest +from agent_comms.runtime_requests import OwnerReadRuntimeRequest +from agent_comms.thread_execution import ExternalThreadExecution +from agent_comms.tool_caller import ToolCaller from agent_comms.comms import wire from agent_comms.field_codec import RootId from agent_comms.owner_protocol import OwnerAnnouncement, OwnerReady, OwnerResult, OwnerUpdate from agent_comms.runtime import ( - RuntimeConnection, RuntimeProxy, present_session, socket_path, + OwnerReplyConnection, RuntimeConnection, RuntimeProxy, present_session, socket_path, ) from agent_comms.private_path import PrivateSocketRole from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.session_file import RegistrySessionLocation from agent_comms.native_arguments import GivenBinary from delivery_owner_fixture import canonical_agent +from owner_socket_fixture import declare_owner, silent_socket +from stored_inputs import stored_inputs from agent_comms.threads import Thread -@pytest.fixture -def runtime_processes(): - import sys - - children = [] - try: - for _ in range(2): - children.append( - SpawnedProcess.launch((sys.executable, "-c", "import time; time.sleep(60)")) - ) - yield tuple(child.identity for child in children) - finally: - for child in children: - child.stop_sync() - - async def test_dispatched_request_without_a_reply_remains_unknown_and_is_not_replayed(comms): process = ProcessIdentity.capture(os.getpid()) - comms.registry.declare(Thread( - "reply-loss", frozenset(), Checkout(comms.root.parent), process_identity=process, - )) - path = socket_path(comms.root, process.pid) - path.parent.mkdir(parents=True, exist_ok=True) - received = [] - - async def disconnect_after_request(reader, writer): - received.append(json.loads(await reader.readline())) - writer.close() - await writer.wait_closed() - - with PrivateSocketRole.address(path) as address: - server = await asyncio.start_unix_server(disconnect_after_request, path=address) - connection = RuntimeConnection(comms, "reply-loss", path) + declare_owner(comms, "reply-loss", process) + async with silent_socket(comms, process) as received: + connection = RuntimeConnection(comms, "reply-loss", socket_path(comms.root, process.pid)) try: with pytest.raises(OwnerReplyLostError) as lost: await connection.request("cancel") @@ -76,9 +59,111 @@ async def disconnect_after_request(reader, writer): assert [request["action"] for request in received] == ["cancel"] finally: await connection.close() - server.close() - await server.wait_closed() - path.unlink(missing_ok=True) + + +def recorded_input(comms) -> InputDocument: + """One durable input of the read's subject; a refused or lost read leaves it unchanged.""" + ledger = InputDispositions(comms.root) + ledger.record(BusInputKey(5), seq=5, owner="held", claim_scope=1, target="held", text="Kept") + return stored_inputs(ledger.root) + + +async def mcp_input_delivery(comms, include_history: bool = True): + async with asyncio.timeout(30): + return await asyncio.to_thread( + InputDeliveryRead.invoke, comms, {"thread": "held", "include_history": include_history}, + caller=ToolCaller.from_actor(None), + ) + + +@pytest.mark.parametrize("include_history", [False, True]) +async def test_an_input_delivery_read_without_a_reply_is_lost_after_one_dispatch(comms, include_history): + """The owner received exactly the declared operation once, then hung up: the caller + gets the original reply loss, and no durable input moved. The peer runs no domain body.""" + process = ProcessIdentity.capture(os.getpid()) + declare_owner(comms, "held", process) + before = recorded_input(comms) + async with silent_socket(comms, process) as received: + with pytest.raises(OwnerReplyLostError): + await mcp_input_delivery(comms, include_history) + assert [FieldCodec.decode(RuntimeRequest, request) for request in received] == [ + OwnerReadRuntimeRequest( + thread="held", operation=InputDeliveryRead(thread="held", include_history=include_history), + ), + ] + assert stored_inputs(comms.root) == before + + +async def test_a_held_owner_read_refuses_a_replaced_root_before_dispatch(comms): + process = ProcessIdentity.capture(os.getpid()) + owner = declare_owner(comms, "held", process) + operation = InputDeliveryRead(thread="held") + async with silent_socket(comms, process) as received: + connection = OwnerReplyConnection.held(comms, owner.incarnation, operation) + moved = comms.root.with_name(comms.root.name + "-replaced") + comms.root.rename(moved) + comms.root.mkdir() + try: + with pytest.raises(OwnerIdentityChangedError): + async with asyncio.timeout(30): + await operation.read(connection) + finally: + comms.root.rmdir() + moved.rename(comms.root) + await connection.close() + assert received == [] + + +async def test_a_same_name_recreation_refuses_the_held_read_and_the_old_witness(comms): + process = ProcessIdentity.capture(os.getpid()) + old = declare_owner(comms, "held", process) + operation = InputDeliveryRead(thread="held") + async with silent_socket(comms, process) as received: + connection = OwnerReplyConnection.held(comms, old.incarnation, operation) + try: + comms.registry.unregister("held") + comms.registry.begin_delete("held") + comms.registry.remove("held") + new = declare_owner(comms, "held", process) + assert new.name == old.name and new.incarnation != old.incarnation + assert comms.registry.require("held").incarnation == new.incarnation + with pytest.raises(OwnerIdentityChangedError): + async with asyncio.timeout(30): + await operation.read(connection) + with pytest.raises(OwnerIdentityChangedError): + OwnerReplyConnection.held(comms, old.incarnation, operation) + finally: + await connection.close() + assert received == [] + + +async def test_a_held_owner_read_refuses_after_its_process_dies(comms, runtime_processes): + child = runtime_processes[0] + owner = declare_owner(comms, "held", child.identity) + operation = InputDeliveryRead(thread="held") + before = recorded_input(comms) + async with silent_socket(comms, child.identity) as received: + connection = OwnerReplyConnection.held(comms, owner.incarnation, operation) + try: + child.stop_sync() + assert not child.identity.alive() + with pytest.raises(ConnectionError): + async with asyncio.timeout(30): + await operation.read(connection) + finally: + await connection.close() + assert received == [] + assert stored_inputs(comms.root) == before + + +async def test_an_external_owner_refuses_the_input_delivery_read_before_any_socket_wait( + comms, runtime_processes, +): + declare_owner(comms, "held", runtime_processes[0].identity, ExternalThreadExecution) + before = recorded_input(comms) + with pytest.raises(RelationViolationError): + await mcp_input_delivery(comms) + assert stored_inputs(comms.root) == before def test_owner_cursor_scope_rebases_only_attachment_session_alias(): @@ -185,7 +270,7 @@ async def test_existing_proxy_follows_renamed_owner_restart_and_resubscribes( tmp_path, runtime_processes ): comms = wire(tmp_path / "wire") - old, new = runtime_processes + old, new = (child.identity for child in runtime_processes) old_pid, new_pid = old.pid, new.pid comms.registry.declare(Thread('worker', frozenset(), Checkout(tmp_path), process_identity=old)) client = canonical_agent(comms) From 5c262628fa2eaf7c82493e9461a6e23434736e82 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 07:10:57 -0400 Subject: [PATCH 09/31] The runtime envelope owns the owner exchange; the read capability stops importing its transport (#3538) OwnerLiveRead extracted OwnerReadRuntimeRequest.declared_name and reached runtime/runtime_requests through two function-local imports to escape the cycle that extraction created, and OwnerReadRuntimeRequest.result returned Any because OwnerLiveRead had no Result default (hosted debt-src: FunctionLocalImport +2, FamilyFlattened +1, TypeErrors runtime_requests +1, BannedVocabulary +3). - OwnerLiveRead[Result: ToolResult = ToolResult]; operation fields stay the bare family class. OwnerLiveRead no longer inherits OwnerReading and has no socket read: owned/decode_reply are its answer and decoder. read_as builds OwnerReadRuntimeRequest(thread=owner.name, operation=self), sends it through OwnerReplyConnection.answer and decodes the reply. asyncio.run stays at the synchronous tool boundary. - OwnerReadRuntimeRequest moves from runtime_requests.py to bus_read.py as OwnerReading[object] + ResultRuntimeRequest; same owner_read action and operation field. Its read(owner) is the one socket exchange: owner.request(self.declared_name, operation=self.operation). - OwnerReading and OwnerReplyConnection move into owner_connection.py beside OwnerConnection. OwnerReading.read takes OwnerConnection. OwnerReplyConnection derives OwnerConnection directly (owner_read and goal_snapshot use the base proxy_payload; only PromptRuntimeRequest needs the ACP controller token). REPLY_TIMEOUT, held/answer/close and the require_identity check are unchanged. - The native transport check is OwnerReading.require_native_owner, called from held; RuntimeRequest.require_owner(entry) is untouched. - ReadOwnerOperation.read builds the envelope with owner.session_id and decodes through the operation; ReadOwnerGoal.read takes OwnerConnection; OwnerRead.ask loses its local import. All imports are module scope. - Old definitions deleted, no aliases. tests/test_runtime.py imports the moved names from bus_read/owner_connection, and the held root/recreation/death controls hand held() the real OwnerReadRuntimeRequest and await its read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/bus_read.py | 54 ++++++++++++------------- src/agent_comms/owner_connection.py | 45 ++++++++++++++++++++- src/agent_comms/runtime.py | 35 +--------------- src/agent_comms/runtime_requests.py | 13 +----- src/agent_comms/ui_model/observation.py | 13 +++--- tests/test_runtime.py | 26 ++++++------ 6 files changed, 89 insertions(+), 97 deletions(-) diff --git a/src/agent_comms/bus_read.py b/src/agent_comms/bus_read.py index eda1ffffa1..a62f2a0494 100644 --- a/src/agent_comms/bus_read.py +++ b/src/agent_comms/bus_read.py @@ -10,20 +10,20 @@ import asyncio from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import TYPE_CHECKING, NoReturn from .launch_runtime import CallingThreadUnavailable +from .owner_connection import ( + OwnerConnection, OwnerReading, OwnerReplyConnection, ResultRuntimeRequest, RuntimeRequestContext, +) from .presentation import AllOutcomes from .tool_declaration import ToolDeclaration from .tool_result import ToolResult if TYPE_CHECKING: from .comms import Comms - from .owner_connection import RuntimeRequestContext from .presentation import OutcomeViewer - from .runtime import RuntimeConnection - from .thread_identity import ThreadIncarnation - from .threads import Thread class BusReader(ABC): @@ -75,24 +75,13 @@ def read(self, comms: Comms) -> Result: return self.read_as(comms, OperatorReader()) -class OwnerReading[Result](ABC): - """One exchange with exactly one live owner incarnation over its socket.""" - - def admit(self, owner: Thread) -> None: - """Only a native owner serves a runtime socket; an external bridge with a live PID - has none, so it is refused here instead of waited on before dispatch.""" - owner.execution.require_native() - - @abstractmethod - async def read(self, owner: RuntimeConnection) -> Result: ... - - -class OwnerLiveRead[Result: ToolResult](OwnerReading[Result], BusRead[Result], ABC): +class OwnerLiveRead[Result: ToolResult = ToolResult](BusRead[Result], ABC): """A read only the selected thread's live native owner can answer, from its own queue. - MCP and the UI both reach the owner; neither answers from its own process, and owner - loss reaches the caller as the original refusal. The owner runs ``owned`` against its - own registration and session, never against the public thread selector. + MCP and the UI both reach the owner through ``OwnerReadRuntimeRequest``; neither answers + from its own process, and owner loss reaches the caller as the original refusal. The + owner runs ``owned`` against its own registration and session, never against the public + thread selector. """ if TYPE_CHECKING: @@ -103,16 +92,9 @@ def apply(self, comms: Comms, caller: BusReader) -> Result: return self.read_as(comms, caller) def read_as(self, comms: Comms, reader: BusReader) -> Result: - from .runtime import OwnerReplyConnection - owner = comms.registry.require(reader.subject(self.thread)).incarnation - return asyncio.run(OwnerReplyConnection.answer(comms, owner, self)) - - async def read(self, owner: RuntimeConnection) -> Result: - from .runtime_requests import OwnerReadRuntimeRequest - - return self.decode_reply( - await owner.request(OwnerReadRuntimeRequest.declared_name, operation=self)) + request = OwnerReadRuntimeRequest(thread=owner.name, operation=self) + return self.decode_reply(asyncio.run(OwnerReplyConnection.answer(comms, owner, request))) @abstractmethod async def owned(self, ctx: RuntimeRequestContext) -> Result: @@ -120,3 +102,17 @@ async def owned(self, ctx: RuntimeRequestContext) -> Result: @abstractmethod def decode_reply(self, raw: object) -> Result: ... + + +@dataclass(frozen=True, kw_only=True) +class OwnerReadRuntimeRequest(OwnerReading[object], ResultRuntimeRequest): + """The one runtime envelope for every owner-live read: sent once to the held owner, and + answered there by the operation under the owner's context.""" + + operation: OwnerLiveRead + + async def read(self, owner: OwnerConnection) -> object: + return await owner.request(self.declared_name, operation=self.operation) + + async def result(self, ctx: RuntimeRequestContext) -> ToolResult: + return await self.operation.owned(ctx) diff --git a/src/agent_comms/owner_connection.py b/src/agent_comms/owner_connection.py index 071ded4c4f..306a01b2a8 100644 --- a/src/agent_comms/owner_connection.py +++ b/src/agent_comms/owner_connection.py @@ -8,8 +8,8 @@ import socket from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, Annotated, ClassVar, cast -from abc import abstractmethod +from typing import TYPE_CHECKING, Annotated, ClassVar, Self, cast +from abc import ABC, abstractmethod from collections.abc import Awaitable from dataclasses import dataclass, replace from .command import Command @@ -256,3 +256,44 @@ async def close(self) -> None: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) + +class OwnerReading[Result](ABC): + """One exchange with exactly one live native owner incarnation over its socket.""" + + def require_native_owner(self, owner: Thread) -> None: + """Only a native owner serves a runtime socket; an external bridge with a live PID + has none, so it is refused here instead of waited on before dispatch.""" + owner.execution.require_native() + + @abstractmethod + async def read(self, owner: OwnerConnection) -> Result: ... + + +class OwnerReplyConnection(OwnerConnection): + """Bound only the live owner's response after a request has been dispatched.""" + + REPLY_TIMEOUT: ClassVar[float] = 3 + + @classmethod + def held(cls, comms: Comms, owner: ThreadIncarnation, reading: OwnerReading) -> Self: + """A connection to exactly this registered native owner incarnation.""" + thread = comms.registry.require(owner.name) + connection = cls(comms, thread.name, socket_path(comms.root, thread.require_process().pid)) + connection.require_identity(owner) + reading.require_native_owner(thread) + return connection + + @classmethod + async def answer[Result]( + cls, comms: Comms, owner: ThreadIncarnation, reading: OwnerReading[Result], + ) -> Result: + """One reading from the held owner; the connection closes whatever the outcome.""" + connection = await asyncio.to_thread(cls.held, comms, owner, reading) + try: + return await reading.read(connection) + finally: + await connection.close() + + async def _read_response(self, reader: asyncio.StreamReader) -> bytes: + async with asyncio.timeout(self.REPLY_TIMEOUT): + return await super()._read_response(reader) diff --git a/src/agent_comms/runtime.py b/src/agent_comms/runtime.py index e52290502b..f42d7143e4 100644 --- a/src/agent_comms/runtime.py +++ b/src/agent_comms/runtime.py @@ -25,7 +25,7 @@ from contextvars import ContextVar from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Self +from typing import TYPE_CHECKING, Any, Self from pydantic import BaseModel @@ -62,9 +62,6 @@ if TYPE_CHECKING: from .acp import CommsAgent - from .bus_read import OwnerReading - from .comms import Comms - from .thread_identity import ThreadIncarnation from .selected_write_authority import AcpSelectedWriteAuthority from .turn_runner import TurnRunner @@ -426,36 +423,6 @@ def request_payload(self, action: str, parameters: dict[str, object]) -> dict[st ) -class OwnerReplyConnection(RuntimeConnection): - """Bound only the live owner's response after a request has been dispatched.""" - - REPLY_TIMEOUT: ClassVar[float] = 3 - - @classmethod - def held(cls, comms: Comms, owner: ThreadIncarnation, reading: OwnerReading) -> Self: - """A connection to exactly this registered owner incarnation, which the reading admits.""" - thread = comms.registry.require(owner.name) - connection = cls(comms, thread.name, socket_path(comms.root, thread.require_process().pid)) - connection.require_identity(owner) - reading.admit(thread) - return connection - - @classmethod - async def answer[Result]( - cls, comms: Comms, owner: ThreadIncarnation, reading: OwnerReading[Result], - ) -> Result: - """One reading from the held owner; the connection closes whatever the outcome.""" - connection = await asyncio.to_thread(cls.held, comms, owner, reading) - try: - return await reading.read(connection) - finally: - await connection.close() - - async def _read_response(self, reader: asyncio.StreamReader) -> bytes: - async with asyncio.timeout(self.REPLY_TIMEOUT): - return await super()._read_response(reader) - - @dataclass(frozen=True) class OwnerAttachment: """A completed subscription: its stream, the owner's ready line and presented metadata.""" diff --git a/src/agent_comms/runtime_requests.py b/src/agent_comms/runtime_requests.py index 9ea7d4b9e9..5bc74ffe5e 100644 --- a/src/agent_comms/runtime_requests.py +++ b/src/agent_comms/runtime_requests.py @@ -38,7 +38,6 @@ from .field_codec import AcpSessionId, FieldCodec, WireName, WireValue, GoalId from .goal_actions import GoalAction from .goal_history import RecordedGoalHistory -from .bus_read import OwnerLiveRead from .goal_scope import AllGoals, OneGoal from .goals import Goal from .manual_compaction_bridge import compact_context @@ -46,7 +45,7 @@ from .owner_protocol import ClientLine, OwnerAnnouncement, OwnerLine, OwnerReady, OwnerResult from .thread_identity import ThreadIncarnation from .thread_presentation import LiveThreadOwnerBinding -from .tool_result import DeclaredReply, ToolResult +from .tool_result import DeclaredReply from .turn_context import ( ContextManifest, ContextSourceText, @@ -267,16 +266,6 @@ async def result(self, ctx: RuntimeRequestContext) -> CompactionOutcome: return await compact_context(ctx.server.agent.turns, ctx.session_id, self.instructions) -@dataclass(frozen=True, kw_only=True) -class OwnerReadRuntimeRequest(ResultRuntimeRequest): - """One envelope for every owner-live read; the operation answers under this owner's context.""" - - operation: OwnerLiveRead - - async def result(self, ctx: RuntimeRequestContext) -> ToolResult: - return await self.operation.owned(ctx) - - @dataclass(frozen=True, kw_only=True) class DismissHistoricalInputsRuntimeRequest(ResultRuntimeRequest): async def result(self, ctx: RuntimeRequestContext) -> DeliveryOverview: diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index dba817b3d2..d653c91e58 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -41,11 +41,12 @@ ) from agent_comms.active_route import CommsRoute from agent_comms.activity import ObservedActivity -from agent_comms.bus_read import BusReader, OwnerLiveRead, OwnerReading, UiRead +from agent_comms.bus_read import BusReader, OwnerLiveRead, OwnerReadRuntimeRequest, UiRead from agent_comms.child_process import join_retirement from agent_comms.commander_hud import CommanderFrame, CommanderHud, CommanderUnitDetail from agent_comms.comms import Comms from agent_comms.coordinator import Coordination +from agent_comms.owner_connection import OwnerConnection, OwnerReading, OwnerReplyConnection from agent_comms.coordination_errors import ( CoordinationError, CoordinationReadUnavailable, @@ -94,7 +95,6 @@ from concurrent.futures import Future from multiprocessing.connection import Connection - from agent_comms.runtime import RuntimeConnection from agent_comms.tool_result import ToolResult from agent_comms.transcripts import TranscriptRead @@ -488,8 +488,6 @@ async def answer(self, comms: Comms) -> ReadOutcome: return self.refused(error) async def ask(self, comms: Comms) -> object: - from agent_comms.runtime import OwnerReplyConnection - return await OwnerReplyConnection.answer(comms, self.owner, self) @@ -672,7 +670,7 @@ def live_changed(self, comms: Comms, revision: WireRevision) -> bool: class ReadOwnerGoal(OwnerRead): """The owner's goal snapshot and the turn it read with it.""" - async def read(self, owner: RuntimeConnection) -> GoalSnapshotReply: + async def read(self, owner: OwnerConnection) -> GoalSnapshotReply: return FieldCodec.decode(GoalSnapshotReply, await owner.request("goal_snapshot")) @@ -682,8 +680,9 @@ class ReadOwnerOperation(OwnerRead): operation: OwnerLiveRead - async def read(self, owner: RuntimeConnection) -> ToolResult: - return await self.operation.read(owner) + async def read(self, owner: OwnerConnection) -> ToolResult: + request = OwnerReadRuntimeRequest(thread=owner.session_id, operation=self.operation) + return self.operation.decode_reply(await request.read(owner)) @dataclass(frozen=True) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 24d977f05b..a347ef6c42 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -25,16 +25,16 @@ from agent_comms.field_codec import FieldCodec from agent_comms.input_disposition import InputDispositions, InputDocument from agent_comms.input_key import BusInputKey +from agent_comms.bus_read import OwnerReadRuntimeRequest from agent_comms.order_tools import InputDeliveryRead -from agent_comms.owner_connection import OwnerIdentityChangedError, RuntimeRequest -from agent_comms.runtime_requests import OwnerReadRuntimeRequest +from agent_comms.owner_connection import OwnerIdentityChangedError, OwnerReplyConnection, RuntimeRequest from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.tool_caller import ToolCaller from agent_comms.comms import wire from agent_comms.field_codec import RootId from agent_comms.owner_protocol import OwnerAnnouncement, OwnerReady, OwnerResult, OwnerUpdate from agent_comms.runtime import ( - OwnerReplyConnection, RuntimeConnection, RuntimeProxy, present_session, socket_path, + RuntimeConnection, RuntimeProxy, present_session, socket_path, ) from agent_comms.private_path import PrivateSocketRole from agent_comms.thread_identity import RecordedThreadIncarnation @@ -97,16 +97,16 @@ async def test_an_input_delivery_read_without_a_reply_is_lost_after_one_dispatch async def test_a_held_owner_read_refuses_a_replaced_root_before_dispatch(comms): process = ProcessIdentity.capture(os.getpid()) owner = declare_owner(comms, "held", process) - operation = InputDeliveryRead(thread="held") + reading = OwnerReadRuntimeRequest(thread="held", operation=InputDeliveryRead(thread="held")) async with silent_socket(comms, process) as received: - connection = OwnerReplyConnection.held(comms, owner.incarnation, operation) + connection = OwnerReplyConnection.held(comms, owner.incarnation, reading) moved = comms.root.with_name(comms.root.name + "-replaced") comms.root.rename(moved) comms.root.mkdir() try: with pytest.raises(OwnerIdentityChangedError): async with asyncio.timeout(30): - await operation.read(connection) + await reading.read(connection) finally: comms.root.rmdir() moved.rename(comms.root) @@ -117,9 +117,9 @@ async def test_a_held_owner_read_refuses_a_replaced_root_before_dispatch(comms): async def test_a_same_name_recreation_refuses_the_held_read_and_the_old_witness(comms): process = ProcessIdentity.capture(os.getpid()) old = declare_owner(comms, "held", process) - operation = InputDeliveryRead(thread="held") + reading = OwnerReadRuntimeRequest(thread="held", operation=InputDeliveryRead(thread="held")) async with silent_socket(comms, process) as received: - connection = OwnerReplyConnection.held(comms, old.incarnation, operation) + connection = OwnerReplyConnection.held(comms, old.incarnation, reading) try: comms.registry.unregister("held") comms.registry.begin_delete("held") @@ -129,9 +129,9 @@ async def test_a_same_name_recreation_refuses_the_held_read_and_the_old_witness( assert comms.registry.require("held").incarnation == new.incarnation with pytest.raises(OwnerIdentityChangedError): async with asyncio.timeout(30): - await operation.read(connection) + await reading.read(connection) with pytest.raises(OwnerIdentityChangedError): - OwnerReplyConnection.held(comms, old.incarnation, operation) + OwnerReplyConnection.held(comms, old.incarnation, reading) finally: await connection.close() assert received == [] @@ -140,16 +140,16 @@ async def test_a_same_name_recreation_refuses_the_held_read_and_the_old_witness( async def test_a_held_owner_read_refuses_after_its_process_dies(comms, runtime_processes): child = runtime_processes[0] owner = declare_owner(comms, "held", child.identity) - operation = InputDeliveryRead(thread="held") + reading = OwnerReadRuntimeRequest(thread="held", operation=InputDeliveryRead(thread="held")) before = recorded_input(comms) async with silent_socket(comms, child.identity) as received: - connection = OwnerReplyConnection.held(comms, owner.incarnation, operation) + connection = OwnerReplyConnection.held(comms, owner.incarnation, reading) try: child.stop_sync() assert not child.identity.alive() with pytest.raises(ConnectionError): async with asyncio.timeout(30): - await operation.read(connection) + await reading.read(connection) finally: await connection.close() assert received == [] From 9bb8b2e3864a464412ca689eda98a968a3b0a696 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 07:30:03 -0400 Subject: [PATCH 10/31] Owner-read fixtures drive the real async observer and the real deletion lifecycle (#3538) Root's selection on 5c262628f ran 60 cases: 49 passed, 11 failed. One same-name control used remove() after begin_delete, which correctly refuses Deleting status; ten observer fixtures used the removed synchronous observer surface (`with`, readable()/results()/fileno(), sync close, NotificationWindow == {}). - test_runtime same-name control: deletion completes through edit.document.remove_originals((old,)) in registry.store.editing(), the path production uses (thread_management.py:537); the captured connection, old witness, zero-dispatch peer and both OwnerIdentityChangedError refusals stay. - test_observer_death: every observer is `async with`; empty-window checks compare .outcomes. The killed-observer test uses the real receiver: one read proves startup, SIGSTOP is confirmed with waitpid(WUNTRACED), the next read is shown pending, then SIGKILL; ObserverDied carries the child pid, -SIGKILL and signal text, and StopSidebar and a later read raise that same object. A stopped child is killed and a pending task cancelled in finally. The failed-observer test asks one real read on the not-a-wire route and requires exit status 1 and the original traceback. The readable() helper is deleted. - test_busy_history_loading: three `async with` sites, the close-once test's `async with process` and awaited repeated close, .outcomes comparisons; the Connection.close endpoint observation is unchanged. - test_deleted_thread_observation and test_unreadable_history_observation (the two readable() importers) start the receiver once with start_receiving and wait on tests/observed_queue.py, a test-local queue of delivered results that raises a receiver failure as its original exception. No manual pumping. No production change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/observed_queue.py | 36 ++++++++ tests/test_busy_history_loading.py | 16 ++-- tests/test_deleted_thread_observation.py | 44 ++++------ tests/test_observer_death.py | 88 +++++++++----------- tests/test_runtime.py | 4 +- tests/test_unreadable_history_observation.py | 51 ++---------- 6 files changed, 111 insertions(+), 128 deletions(-) create mode 100644 tests/observed_queue.py diff --git a/tests/observed_queue.py b/tests/observed_queue.py new file mode 100644 index 0000000000..1596afb1b8 --- /dev/null +++ b/tests/observed_queue.py @@ -0,0 +1,36 @@ +"""What the real observer receiver delivers, in receive order, for tests that watch it. + +``ObservationProcess.start_receiving`` owns reading the pipe; a test passes this queue's +callbacks to it once and waits here. A receiver failure is raised as its original exception +when the waiting test reaches it, never swallowed or replaced. +""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +from agent_comms.ui_model.observation import Observed + + +class ObservedQueue: + def __init__(self) -> None: + self._delivered: asyncio.Queue[Callable[[], Observed]] = asyncio.Queue() + + def observed(self, result: Observed) -> None: + self._delivered.put_nowait(lambda: result) + + def failed(self, error: Exception) -> None: + def raise_original() -> Observed: + raise error + + self._delivered.put_nowait(raise_original) + + async def next(self) -> Observed: + async with asyncio.timeout(30): + delivered = await self._delivered.get() + return delivered() + + async def until(self, accepted: Callable[[Observed], bool]) -> Observed: + while not accepted(result := await self.next()): + pass + return result diff --git a/tests/test_busy_history_loading.py b/tests/test_busy_history_loading.py index db4643f23f..7589134c71 100644 --- a/tests/test_busy_history_loading.py +++ b/tests/test_busy_history_loading.py @@ -57,7 +57,7 @@ def held_source(comms): async def test_busy_source_keeps_original_waiter_and_other_reads_move(tmp_path): comms, session = saved_history(tmp_path) original = session.read_bytes() - with ObservationProcess.awaited(comms.route) as process: + async with ObservationProcess.awaited(comms.route) as process: with held_source(comms): answer = ReadTranscript(str(comms.root), "saved", request_id=19).answer(comms) assert isinstance(answer, ReadBusy) @@ -65,7 +65,7 @@ async def test_busy_source_keeps_original_waiter_and_other_reads_move(tmp_path): waiting = asyncio.create_task(process.read(ReadTranscript(str(comms.root), "saved"))) independent = asyncio.create_task(process.read(ReadNotifications(str(comms.root), ()))) async with asyncio.timeout(15): - assert await independent == {} + assert (await independent).outcomes == {} assert not waiting.done() assert len(process.reads) == 1 (request_id,) = process.reads @@ -84,7 +84,7 @@ async def test_busy_source_keeps_original_waiter_and_other_reads_move(tmp_path): async def test_cancelled_busy_read_is_removed_without_resubmission(tmp_path): comms, _session = saved_history(tmp_path) - with ObservationProcess.awaited(comms.route) as process: + async with ObservationProcess.awaited(comms.route) as process: with held_source(comms): waiting = asyncio.create_task(process.read(ReadTranscript(str(comms.root), "saved"))) await asyncio.sleep(0.15) @@ -93,7 +93,7 @@ async def test_cancelled_busy_read_is_removed_without_resubmission(tmp_path): await waiting assert process.reads == {} async with asyncio.timeout(5): - assert await process.read(ReadNotifications(str(comms.root), ())) == {} + assert (await process.read(ReadNotifications(str(comms.root), ()))).outcomes == {} assert process.reads == {} @@ -112,10 +112,10 @@ def closed(connection): monkeypatch.setattr(Connection, "close", closed) with pytest.raises(type(error)) as raised: - with process: + async with process: raise error assert raised.value is error - process.close() + await process.close() assert closes == [process.connection] assert process.connection.closed and process.process.exitcode == 0 @@ -206,8 +206,8 @@ async def test_owner_reply_uses_the_same_peer_retirement(comms): async def test_awaited_notifications_then_immediate_close_retires_successfully(comms): - with ObservationProcess.awaited(comms.route) as process: - assert await process.read(ReadNotifications(str(comms.root), ())) == {} + async with ObservationProcess.awaited(comms.route) as process: + assert (await process.read(ReadNotifications(str(comms.root), ()))).outcomes == {} assert process.connection.closed and process.process.exitcode == 0 assert not process.reads diff --git a/tests/test_deleted_thread_observation.py b/tests/test_deleted_thread_observation.py index 490fa29e19..d0f53f441a 100644 --- a/tests/test_deleted_thread_observation.py +++ b/tests/test_deleted_thread_observation.py @@ -16,7 +16,7 @@ ThreadPresented, ThreadsObserved, ViewsRetired, ) from agent_comms.ui_model.status import OpenThreadStatus -from test_observer_death import readable +from observed_queue import ObservedQueue from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.thread_text import GivenThreadText from agent_comms.threads import Thread @@ -80,47 +80,33 @@ async def test_observer_process_survives_deleted_interest_and_keeps_serving(comm declare(comms, 'surviving', 'deleted') original = comms.registry.require('deleted').incarnation names = frozenset({'surviving', 'deleted'}) - with ObservationProcess(comms.route) as process: + delivered = ObservedQueue() + async with ObservationProcess(comms.route) as process: + process.start_receiving(delivered.observed, delivered.failed) process.request(ObserveThreads(names)) process.request(ObserveViews(frozenset({original}), frozenset())) - - async def until(accepted): - async with asyncio.timeout(30): - while True: - await readable(process) - for result in process.results(): - if accepted(result): - return result - - await until(lambda result: isinstance(result, ThreadsObserved) and result.changed == names) + await delivered.until(lambda result: isinstance(result, ThreadsObserved) and result.changed == names) comms.registry.remove('deleted') missing = None retired = None - async with asyncio.timeout(30): - while missing is None or retired is None: - await readable(process) - for result in process.results(): - if isinstance(result, ThreadsObserved) and 'deleted' in result.changed: - missing = result - elif isinstance(result, ViewsRetired): - retired = result + while missing is None or retired is None: + result = await delivered.next() + if isinstance(result, ThreadsObserved) and 'deleted' in result.changed: + missing = result + elif isinstance(result, ViewsRetired): + retired = result assert missing.activity['deleted'] == DeletedThread('deleted') assert isinstance(missing.results['deleted'], ThreadPresented) assert missing.results['deleted'].presentation == NoThreadPresentation() assert retired.retired.threads == frozenset({original}) surviving = comms.registry.require('surviving') comms.registry.declare(replace(surviving, title=GivenThreadText('Still updating after deletion'))) - updated = await until(lambda result: isinstance(result, ThreadsObserved) - and 'surviving' in result.changed) + updated = await delivered.until(lambda result: isinstance(result, ThreadsObserved) + and 'surviving' in result.changed) assert isinstance(updated.activity['surviving'], ObservedActivity) assert process.process.is_alive() - loop = asyncio.get_running_loop() - loop.add_reader(process.fileno(), process.results) - try: - async with asyncio.timeout(30): - assert await process.read(ReadNotifications(str(comms.root), ())) == {} - finally: - loop.remove_reader(process.fileno()) + async with asyncio.timeout(30): + assert (await process.read(ReadNotifications(str(comms.root), ()))).outcomes == {} def test_one_revision_captures_each_thread_activity_once(comms): diff --git a/tests/test_observer_death.py b/tests/test_observer_death.py index 28dc7d1543..1c685b20e6 100644 --- a/tests/test_observer_death.py +++ b/tests/test_observer_death.py @@ -32,53 +32,49 @@ def owner_reads(): yield pytest.param(partial(ReadOwnerOperation, operation=operation()), id=operation.declared_name) -async def readable(process: ObservationProcess) -> None: - """Wait until the observer's pipe has something to read, as the UI's event loop does.""" - ready = asyncio.Event() - loop = asyncio.get_running_loop() - loop.add_reader(process.fileno(), ready.set) - try: - async with asyncio.timeout(30): - await ready.wait() - finally: - loop.remove_reader(process.fileno()) +def empty_window(root) -> ReadNotifications: + return ReadNotifications(str(root), ()) async def test_a_killed_observer_is_reported_once_with_its_signal_and_fails_awaited_reads(comms, durable_home): - process = ObservationProcess(comms.route) - try: - await readable(process) - assert process.results() # the first observation: the service is running - os.kill(process.process.pid, signal.SIGSTOP) # the read below is still unanswered when it dies - waiting = asyncio.ensure_future(process.read(ReadNotifications(str(comms.root), ()))) - await asyncio.sleep(0) - os.kill(process.process.pid, signal.SIGKILL) - with pytest.raises(ObserverDied) as died: - while True: - await readable(process) - process.results() - assert died.value.pid == process.process.pid and died.value.exit_status == -signal.SIGKILL - assert f"pid {process.process.pid}" in str(died.value) and "signal 9" in str(died.value) - with pytest.raises(ObserverDied) as awaited: - await waiting - assert awaited.value is died.value + async with ObservationProcess.awaited(comms.route) as process: + async with asyncio.timeout(30): + assert (await process.read(empty_window(comms.root))).outcomes == {} # the service runs + child = process.process.pid + os.kill(child, signal.SIGSTOP) # the read below stays unanswered until the child dies + waiting = asyncio.ensure_future(process.read(empty_window(comms.root))) + try: + async with asyncio.timeout(30): + _, stopped = await asyncio.to_thread(os.waitpid, child, os.WUNTRACED) + assert os.WIFSTOPPED(stopped) + await asyncio.sleep(0) + assert len(process.reads) == 1 and not waiting.done() + os.kill(child, signal.SIGKILL) + with pytest.raises(ObserverDied) as died: + async with asyncio.timeout(30): + await waiting + finally: + if process.process.exitcode is None: + os.kill(child, signal.SIGKILL) + waiting.cancel() + await asyncio.gather(waiting, return_exceptions=True) + assert died.value.pid == child and died.value.exit_status == -signal.SIGKILL + assert f"pid {child}" in str(died.value) and "signal 9" in str(died.value) with pytest.raises(ObserverDied) as stopping: process.request(StopSidebar()) # what a closing view sends assert stopping.value is died.value with pytest.raises(ObserverDied) as later: - await process.read(ReadNotifications(str(comms.root), ())) + await process.read(empty_window(comms.root)) assert later.value is died.value - finally: - process.close() async def test_an_invalid_notification_reference_does_not_kill_the_observer(comms, durable_home): - with ObservationProcess.awaited(comms.route) as process: + async with ObservationProcess.awaited(comms.route) as process: with pytest.raises(RelationViolationError): await process.read(ReadNotifications( str(comms.root), (MessageReference(1, MessageId.mint("not-the-original")),) )) - assert await process.read(ReadNotifications(str(comms.root), ())) == {} + assert (await process.read(empty_window(comms.root))).outcomes == {} @pytest.mark.parametrize("read_type", owner_reads()) @@ -91,10 +87,10 @@ async def test_unavailable_owner_reads_preserve_the_observer( owner = Thread("read-subject", frozenset(), Checkout(comms.root.parent)) if declared: comms.registry.declare(owner) - with ObservationProcess.awaited(comms.route) as process: + async with ObservationProcess.awaited(comms.route) as process: with pytest.raises(refusal): await process.read(read_type(str(comms.root), owner.incarnation)) - assert await process.read(ReadNotifications(str(comms.root), ())) == {} + assert (await process.read(empty_window(comms.root))).outcomes == {} assert process.process.is_alive() process.status.raise_if_failed() @@ -106,10 +102,10 @@ async def test_stale_owner_reads_preserve_the_observer(comms, durable_home, read comms.registry.declare(owner) root = str(comms.root.parent / "another-wire") if other_root else str(comms.root) identity = owner.incarnation if other_root else RecordedThreadIncarnation(owner.name, 1.0) - with ObservationProcess.awaited(comms.route) as process: + async with ObservationProcess.awaited(comms.route) as process: with pytest.raises(StaleRevision if other_root else OwnerIdentityChangedError): await process.read(read_type(root, identity)) - assert await process.read(ReadNotifications(str(comms.root), ())) == {} + assert (await process.read(empty_window(comms.root))).outcomes == {} assert process.process.is_alive() process.status.raise_if_failed() @@ -121,11 +117,11 @@ async def test_external_owner_reads_refuse_before_waiting_for_a_socket( """An external bridge has a live process and no runtime socket: the read refuses with the original error instead of waiting for a socket that will never be served.""" owner = declare_owner(comms, "external-owner", runtime_processes[0].identity, ExternalThreadExecution) - with ObservationProcess.awaited(comms.route) as process: + async with ObservationProcess.awaited(comms.route) as process: with pytest.raises(RelationViolationError): async with asyncio.timeout(30): await process.read(read_type(str(comms.root), owner.incarnation)) - assert await process.read(ReadNotifications(str(comms.root), ())) == {} + assert (await process.read(empty_window(comms.root))).outcomes == {} assert process.process.is_alive() process.status.raise_if_failed() @@ -133,14 +129,10 @@ async def test_external_owner_reads_refuse_before_waiting_for_a_socket( async def test_an_observer_that_failed_is_reported_with_its_own_traceback_and_exit_status(tmp_path, durable_home): not_a_wire = tmp_path / "not-a-wire" not_a_wire.write_text("") - process = ObservationProcess(LocalRoute(not_a_wire)) - try: + async with ObservationProcess.awaited(LocalRoute(not_a_wire)) as process: with pytest.raises(ObserverDied) as died: - while True: - await readable(process) - process.results() - assert died.value.exit_status == 1 - assert "Traceback" in died.value.error and "serve" in died.value.error - assert "exited with status 1" in str(died.value) and died.value.error in str(died.value) - finally: - process.close() + async with asyncio.timeout(30): + await process.read(empty_window(not_a_wire)) + assert died.value.exit_status == 1 + assert "Traceback" in died.value.error and "serve" in died.value.error + assert "exited with status 1" in str(died.value) and died.value.error in str(died.value) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index a347ef6c42..ec43cb2299 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -123,7 +123,9 @@ async def test_a_same_name_recreation_refuses_the_held_read_and_the_old_witness( try: comms.registry.unregister("held") comms.registry.begin_delete("held") - comms.registry.remove("held") + with comms.registry.store.editing() as edit: + edit.document.remove_originals((old,)) + edit.commit() new = declare_owner(comms, "held", process) assert new.name == old.name and new.incarnation != old.incarnation assert comms.registry.require("held").incarnation == new.incarnation diff --git a/tests/test_unreadable_history_observation.py b/tests/test_unreadable_history_observation.py index 3791deea7a..8898f509ac 100644 --- a/tests/test_unreadable_history_observation.py +++ b/tests/test_unreadable_history_observation.py @@ -45,7 +45,7 @@ ) from agent_comms.ui_model.status import OpenThreadStatus from agent_comms.turn_context import RecordedContextManifest -from test_observer_death import readable +from observed_queue import ObservedQueue from test_turn_context_wire import corrupt_context_kind, manifest from agent_comms.threads import Thread @@ -222,56 +222,23 @@ async def test_unknown_saved_member_does_not_kill_the_real_observation_process(c corrupt_context_kind(comms, "saved", "unavailable_future_segment") original = comms.contexts.path.read_bytes() original_bus = comms.bus.bus.path.read_bytes() - process = ObservationProcess(comms.route) - try: - await readable(process) - process.results() + delivered = ObservedQueue() + async with ObservationProcess(comms.route) as process: + process.start_receiving(delivered.observed, delivered.failed) process.request(ObserveSidebar(Checkout(comms.root.parent), True, True)) - async with asyncio.timeout(30): - while True: - await readable(process) - results = process.results() - sidebar = next( - (result for result in results if isinstance(result, SidebarObserved)), None - ) - if sidebar is not None: - break + await delivered.until(lambda result: isinstance(result, SidebarObserved)) assert process.process.is_alive() process.request(ObserveThreads(frozenset({"saved"}))) - async with asyncio.timeout(30): - while True: - await readable(process) - results = process.results() - threads = next( - (result for result in results if isinstance(result, ThreadsObserved)), None - ) - if threads is not None: - break + threads = await delivered.until(lambda result: isinstance(result, ThreadsObserved)) assert not isinstance(threads.results["saved"], ThreadUnavailable) # Only the request needing the damaged payload is refused, over the # same actual child/pipe that served the sidebar and thread reads. - damaged = asyncio.create_task(process.read(ReadRecordedContext(str(comms.root), "saved"))) - await asyncio.sleep(0) - async with asyncio.timeout(30): - while not damaged.done(): - await readable(process) - process.results() - await asyncio.sleep(0) with pytest.raises(StoredHistoryUnreadable): - await damaged + async with asyncio.timeout(30): + await process.read(ReadRecordedContext(str(comms.root), "saved")) # Serve a separate, readable request using the same actual pipe/child. - waiting = asyncio.create_task(process.read(ReadNotifications(str(comms.root), ()))) - await asyncio.sleep(0) async with asyncio.timeout(30): - while not waiting.done(): - await readable(process) - process.results() - # The pipe resolved the Future; let its awaiting Task resume. - # An unchanged error no longer emits an unrelated extra wake. - await asyncio.sleep(0) - assert await waiting == {} + assert (await process.read(ReadNotifications(str(comms.root), ()))).outcomes == {} assert process.process.is_alive() assert comms.contexts.path.read_bytes() == original assert comms.bus.bus.path.read_bytes() == original_bus - finally: - process.close() From 8d21db48cfad9020d4136a24d55e56871cf744fc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 07:47:05 -0400 Subject: [PATCH 11/31] A stale owner is refused as identity-changed before its process is asked for (#3538) Root's rerun on 9bb8b2e38: 22 cases in 70.37 s, 20 passed, 2 failed, both the other_root=False stale-identity cases (goal and input delivery). held looked up the selected thread's process before any identity comparison, so a stale incarnation was refused as "no owner process" (RelationViolationError) instead of OwnerIdentityChangedError. a4702b1f7 had dropped the initial comparison when it added the constructor check. - OwnerReplyConnection.held: registry.require(owner.name), then thread.incarnation != owner -> OwnerIdentityChangedError, then reading.require_native_owner(thread), then require_process/socket path and construction, then connection.require_identity(owner). The two comparisons check two different registry reads (the selected thread and the constructor's capture); no identity field or cache is added. - ObservedQueue.until holds one 30 s bound over the whole match; next() keeps its own bound for direct callers. Before, each unrelated result restarted the bound, so a steady stream could keep a wait running indefinitely. - test_deleted_thread_observation restores one 30 s bound around waiting for both the missing-thread result and the retirement. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/owner_connection.py | 11 +++++++++-- tests/observed_queue.py | 6 ++++-- tests/test_deleted_thread_observation.py | 13 +++++++------ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/agent_comms/owner_connection.py b/src/agent_comms/owner_connection.py index 306a01b2a8..54f2d386f7 100644 --- a/src/agent_comms/owner_connection.py +++ b/src/agent_comms/owner_connection.py @@ -276,11 +276,18 @@ class OwnerReplyConnection(OwnerConnection): @classmethod def held(cls, comms: Comms, owner: ThreadIncarnation, reading: OwnerReading) -> Self: - """A connection to exactly this registered native owner incarnation.""" + """A connection to exactly this registered native owner incarnation. + + The selected registration must be the held incarnation before its process is + asked for; the connection's own capture is then checked again, since the + registry may change between the two reads. + """ thread = comms.registry.require(owner.name) + if thread.incarnation != owner: + raise OwnerIdentityChangedError("The owner incarnation changed while preparing the request.") + reading.require_native_owner(thread) connection = cls(comms, thread.name, socket_path(comms.root, thread.require_process().pid)) connection.require_identity(owner) - reading.require_native_owner(thread) return connection @classmethod diff --git a/tests/observed_queue.py b/tests/observed_queue.py index 1596afb1b8..e65a850d6d 100644 --- a/tests/observed_queue.py +++ b/tests/observed_queue.py @@ -31,6 +31,8 @@ async def next(self) -> Observed: return delivered() async def until(self, accepted: Callable[[Observed], bool]) -> Observed: - while not accepted(result := await self.next()): - pass + """One 30 s bound for the whole match, however many other results arrive first.""" + async with asyncio.timeout(30): + while not accepted(result := await self.next()): + pass return result diff --git a/tests/test_deleted_thread_observation.py b/tests/test_deleted_thread_observation.py index d0f53f441a..d13b6e2277 100644 --- a/tests/test_deleted_thread_observation.py +++ b/tests/test_deleted_thread_observation.py @@ -89,12 +89,13 @@ async def test_observer_process_survives_deleted_interest_and_keeps_serving(comm comms.registry.remove('deleted') missing = None retired = None - while missing is None or retired is None: - result = await delivered.next() - if isinstance(result, ThreadsObserved) and 'deleted' in result.changed: - missing = result - elif isinstance(result, ViewsRetired): - retired = result + async with asyncio.timeout(30): + while missing is None or retired is None: + result = await delivered.next() + if isinstance(result, ThreadsObserved) and 'deleted' in result.changed: + missing = result + elif isinstance(result, ViewsRetired): + retired = result assert missing.activity['deleted'] == DeletedThread('deleted') assert isinstance(missing.results['deleted'], ThreadPresented) assert missing.results['deleted'].presentation == NoThreadPresentation() From 21b07552a468a36cdde16638a9ff6318f328d16e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 08:25:21 -0400 Subject: [PATCH 12/31] Notification reads are root-free operations; the UI envelope carries one (#3538) The notification read lived on the UI request: ReadNotifications held root and request_id beside its references, ReadRecordedNotifications subclassed it for the archive, and comms_message_outcomes built that UI envelope with str(comms.root) only to reach read_as. - notification_reads.py (new): NotificationRead(UiRead[NotificationWindow]) with changed_since; frozen LiveNotificationRead(references) and RecordedNotificationRead(references, source). Two members: archive identity and the change rule differ (live activity/evidence vs history revision). The module sits outside notification_read.py because presentation imports that module and bus_read imports presentation. - NotificationsRead and UnreadableNotificationsRead move unchanged into notification_read.py beside CapturedNotificationWindow. - ReadNotifications(ReadRequest) keeps root and request_id and carries operation: NotificationRead; it is no longer a UiRead. read() delegates to the operation (OperatorReader). ReadRecordedNotifications is deleted. NotificationReading asks request.operation.changed_since; read states stay keyed by the whole request. - CommsMessageOutcomesTool reads LiveNotificationRead((reference,)) under its caller; refusal, visibility, paging and its public schema are unchanged. - Callers migrated: observer fixtures carry LiveNotificationRead(()), test_thread_read_results reads the operation instead of a fake UI root, read-state tests call the operations. New controls: family/codec/pickle and request identity, archive change drops state, root validation per envelope, capability discovery, a foreign operation refused; a 125-message live batch across two window chunks; the recorded operation reads the archive (not live evidence) and a detached key refuses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/notification_read.py | 47 +++++++- src/agent_comms/notification_reads.py | 62 +++++++++++ src/agent_comms/tools.py | 4 +- src/agent_comms/ui_model/observation.py | 79 ++------------ tests/test_busy_history_loading.py | 7 +- tests/test_deleted_thread_observation.py | 3 +- tests/test_historical_notifications.py | 11 ++ tests/test_message_notifications.py | 18 ++++ tests/test_notification_read_state.py | 102 ++++++++++++++++-- tests/test_observer_death.py | 9 +- tests/test_thread_read_results.py | 5 +- tests/test_transcript_observation_lifetime.py | 3 +- tests/test_unreadable_history_observation.py | 3 +- 13 files changed, 257 insertions(+), 96 deletions(-) create mode 100644 src/agent_comms/notification_reads.py diff --git a/src/agent_comms/notification_read.py b/src/agent_comms/notification_read.py index 22467ba40f..0f71c6b45e 100644 --- a/src/agent_comms/notification_read.py +++ b/src/agent_comms/notification_read.py @@ -1,17 +1,21 @@ -"""A captured notification window answers whether its own evidence changed.""" +"""A captured notification window, and where a notification read settled, answer whether +their own evidence changed.""" from __future__ import annotations -from dataclasses import dataclass +from collections.abc import Mapping +from dataclasses import dataclass, field from typing import TYPE_CHECKING from .notification_assignment import NotificationAssignment -from .notification_evidence import NotificationWindow -from .revision_views import NotificationEvidenceView +from .notification_evidence import NoNotificationWindow, NotificationWindow +from .revision_views import ActivityView, NotificationEvidenceView if TYPE_CHECKING: + from .activity import ObservedActivity from .comms import Comms from .presentation import WireRevision + from .registry_provenance import DeletedThread @dataclass(frozen=True) @@ -24,3 +28,38 @@ def changed(self, comms: Comms, revision: WireRevision, earlier: WireRevision) - self.read_marks, comms.registry.snapshot(), )) or NotificationAssignment.changed(self.reads, comms.root) ) + + +@dataclass(frozen=True) +class NotificationsRead: + """Where one notification read settled: its revision and the activity of every participant + its notifications name, captured before the read for participants already known and right + after it for new ones.""" + + revision: WireRevision + activity: Mapping[str, ObservedActivity | DeletedThread | None] = field(default_factory=dict) + notification_window: NotificationWindow = field(default_factory=NoNotificationWindow) + + @classmethod + def of(cls, revision: WireRevision, comms: Comms, + known: Mapping[str, ObservedActivity | DeletedThread], + window: NotificationWindow, + ) -> NotificationsRead: + names = window.activity_names() + known = {**comms.views.observed_activity(revision, names - known.keys()), **known} + return cls(revision, {name: known.get(name) for name in names}, window) + + def live_changed(self, comms: Comms, revision: WireRevision) -> bool: + """Read marks, participant activity or retained assignment evidence changed.""" + current = (comms.views.observed_activity(revision, self.activity) + if revision.changed_since(ActivityView(), self.revision) + else self.activity) + return (any(current.get(name) != seen for name, seen in self.activity.items()) + or self.notification_window.changed(comms, revision, self.revision)) + + +class UnreadableNotificationsRead(NotificationsRead): + """An unreadable read names no participants, so any later revision reads again.""" + + def live_changed(self, comms: Comms, revision: WireRevision) -> bool: + return revision != self.revision diff --git a/src/agent_comms/notification_reads.py b/src/agent_comms/notification_reads.py new file mode 100644 index 0000000000..a3b0986a31 --- /dev/null +++ b/src/agent_comms/notification_reads.py @@ -0,0 +1,62 @@ +"""Root-free notification window operations; UI transport and MCP paging stay at their boundaries. + +A notification read owns its references (and, when recorded, the archive it reads) and how +its settled evidence goes stale. The UI carries one in ``ReadNotifications`` with its own root +and request id; MCP's single-message page calls the live read directly under its caller. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from .bus_read import BusReader, UiRead +from .historical_views import HistorySource +from .message_reference import MessageReference +from .notification_evidence import NotificationWindow +from .notification_read import NotificationsRead + +if TYPE_CHECKING: + from .comms import Comms + from .presentation import WireRevision + + +class NotificationRead(UiRead[NotificationWindow], ABC): + """A batched notification window read; each member owns when its settled read is stale.""" + + @abstractmethod + def changed_since(self, comms: Comms, revision: WireRevision, + previous: NotificationsRead) -> bool: ... + + +@dataclass(frozen=True) +class LiveNotificationRead(NotificationRead): + """Recipient handling of the referenced original messages, as its reader may see them. + The UI reads as the operator; an MCP caller reads under its own viewer.""" + + references: tuple[MessageReference, ...] + + def read_as(self, comms: Comms, reader: BusReader) -> NotificationWindow: + return comms.views.message_notifications_for_references( + reader.outcome_viewer(comms), self.references) + + def changed_since(self, comms: Comms, revision: WireRevision, + previous: NotificationsRead) -> bool: + return previous.live_changed(comms, revision) + + +@dataclass(frozen=True) +class RecordedNotificationRead(LiveNotificationRead): + """The selected archive's recorded handling, independent of today's recipients.""" + + source: str + + def read_as(self, comms: Comms, reader: BusReader) -> NotificationWindow: + with comms.bus.history.read_sources() as sources: + source = HistorySource.require(sources, self.source) + return source.notification_references( + sources, self.references, reader.outcome_viewer(comms)) + + def changed_since(self, comms: Comms, revision: WireRevision, + previous: NotificationsRead) -> bool: + return revision.history != previous.revision.history diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 4baa5bb43c..6613b5e5df 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -87,8 +87,8 @@ from .thread_model import ThreadModel from .threads import NextTurnConfiguration from .tool_caller import ThreadCaller, ThreadListing, ToolCaller -from .ui_model.observation import ReadNotifications from .tool_result import ToolResult +from .notification_reads import LiveNotificationRead from .work_branches import WorkBranch, WorkBranches if TYPE_CHECKING: @@ -703,7 +703,7 @@ def apply(self, comms: Comms, caller: ToolCaller) -> MessageOutcomes: source, = comms.bus.bus.deliveries_for_references((self.reference,)) if not caller.outcome_viewer(comms).sees(source): raise ToolRefusedError(f"Message {source.message.seq} is not readable by this caller.") - window = ReadNotifications(str(comms.root), (self.reference,)).read_as(comms, caller) + window = LiveNotificationRead((self.reference,)).read_as(comms, caller) following = self.after.following( window.outcomes.get((source.message.seq, source.message.message_id), ())) page = following[:self.limit] diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index d653c91e58..43481ac23e 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -41,7 +41,7 @@ ) from agent_comms.active_route import CommsRoute from agent_comms.activity import ObservedActivity -from agent_comms.bus_read import BusReader, OwnerLiveRead, OwnerReadRuntimeRequest, UiRead +from agent_comms.bus_read import OwnerLiveRead, OwnerReadRuntimeRequest from agent_comms.child_process import join_retirement from agent_comms.commander_hud import CommanderFrame, CommanderHud, CommanderUnitDetail from agent_comms.comms import Comms @@ -57,10 +57,10 @@ ) from agent_comms.errors import RelationViolationError, ToolRefusedError from agent_comms.field_codec import FieldCodec, OrderId -from agent_comms.historical_views import HistorySource from agent_comms.history_views import RetiredViews -from agent_comms.message_reference import MessageReference -from agent_comms.notification_evidence import NoNotificationWindow, NotificationWindow +from agent_comms.notification_evidence import NotificationWindow +from agent_comms.notification_read import NotificationsRead, UnreadableNotificationsRead +from agent_comms.notification_reads import NotificationRead from agent_comms.presentation import ( CoordinationSnapshot, MessageNotification, NoRevision, ReadState, WireRevision, @@ -68,7 +68,6 @@ from agent_comms.registry_document import RosterVisibility from agent_comms.registry_provenance import DeletedThread from agent_comms.revision_views import ( - ActivityView, CommanderFramesView, NotificationsView, RegistrationsView, @@ -599,71 +598,13 @@ def read(self, comms: Comms) -> TranscriptSnapshotUpdate | None: @dataclass(frozen=True) -class ReadNotifications(UiRead[NotificationWindow], ReadRequest): - """Recipient handling of the referenced original messages, as its reader may see them. - The UI reads as the operator; an MCP caller reads under its own viewer.""" +class ReadNotifications(ReadRequest): + """The UI's root and request witness around one root-free notification operation.""" - references: tuple[MessageReference, ...] + operation: NotificationRead - def read_as(self, comms: Comms, reader: BusReader) -> NotificationWindow: - return comms.views.message_notifications_for_references( - reader.outcome_viewer(comms), self.references) - - def changed_since(self, comms: Comms, revision: WireRevision, - previous: NotificationsRead) -> bool: - return previous.live_changed(comms, revision) - - -@dataclass(frozen=True) -class ReadRecordedNotifications(ReadNotifications): - """The selected archive's recorded handling, independent of today's recipients.""" - - source: str - - def read_as(self, comms: Comms, reader: BusReader) -> NotificationWindow: - with comms.bus.history.read_sources() as sources: - source = HistorySource.require(sources, self.source) - return source.notification_references( - sources, self.references, reader.outcome_viewer(comms)) - - def changed_since(self, comms: Comms, revision: WireRevision, - previous: NotificationsRead) -> bool: - return revision.history != previous.revision.history - - -@dataclass(frozen=True) -class NotificationsRead: - """Where one notification read settled: its revision and the activity of every participant - its notifications name, captured before the read for participants already known and right - after it for new ones.""" - - revision: WireRevision - activity: Mapping[str, ObservedActivity | DeletedThread | None] = field(default_factory=dict) - notification_window: NotificationWindow = field(default_factory=NoNotificationWindow) - - @classmethod - def of(cls, revision: WireRevision, comms: Comms, - known: Mapping[str, ObservedActivity | DeletedThread], - window: NotificationWindow, - ) -> NotificationsRead: - names = window.activity_names() - known = {**comms.views.observed_activity(revision, names - known.keys()), **known} - return cls(revision, {name: known.get(name) for name in names}, window) - - def live_changed(self, comms: Comms, revision: WireRevision) -> bool: - """Read marks, participant activity or retained assignment evidence changed.""" - current = (comms.views.observed_activity(revision, self.activity) - if revision.changed_since(ActivityView(), self.revision) - else self.activity) - return (any(current.get(name) != seen for name, seen in self.activity.items()) - or self.notification_window.changed(comms, revision, self.revision)) - - -class UnreadableNotificationsRead(NotificationsRead): - """An unreadable read names no participants, so any later revision reads again.""" - - def live_changed(self, comms: Comms, revision: WireRevision) -> bool: - return revision != self.revision + def read(self, comms: Comms) -> NotificationWindow: + return self.operation.read(comms) @dataclass(frozen=True) @@ -844,7 +785,7 @@ def continue_from(self, previous: Self) -> Self: def read(self, comms: Comms, revision: WireRevision) -> Generator[ObservationStep]: for request in self.request.reads: previous = self.read_states.get(request) - if previous is not None and not request.changed_since(comms, revision, previous): + if previous is not None and not request.operation.changed_since(comms, revision, previous): continue yield self.notifications(request, comms, revision, previous) diff --git a/tests/test_busy_history_loading.py b/tests/test_busy_history_loading.py index 7589134c71..d303eeb04e 100644 --- a/tests/test_busy_history_loading.py +++ b/tests/test_busy_history_loading.py @@ -11,6 +11,7 @@ from agent_comms.acp_extension import TranscriptSnapshotUpdate from agent_comms.comms import wire +from agent_comms.notification_reads import LiveNotificationRead from agent_comms.ui_model.observation import ( ObservationProcess, ReadBusy, ReadNotifications, ReadTranscript, ) @@ -63,7 +64,7 @@ async def test_busy_source_keeps_original_waiter_and_other_reads_move(tmp_path): assert isinstance(answer, ReadBusy) assert answer.title == "History loading" waiting = asyncio.create_task(process.read(ReadTranscript(str(comms.root), "saved"))) - independent = asyncio.create_task(process.read(ReadNotifications(str(comms.root), ()))) + independent = asyncio.create_task(process.read(ReadNotifications(str(comms.root), LiveNotificationRead(())))) async with asyncio.timeout(15): assert (await independent).outcomes == {} assert not waiting.done() @@ -93,7 +94,7 @@ async def test_cancelled_busy_read_is_removed_without_resubmission(tmp_path): await waiting assert process.reads == {} async with asyncio.timeout(5): - assert (await process.read(ReadNotifications(str(comms.root), ()))).outcomes == {} + assert (await process.read(ReadNotifications(str(comms.root), LiveNotificationRead(())))).outcomes == {} assert process.reads == {} @@ -207,7 +208,7 @@ async def test_owner_reply_uses_the_same_peer_retirement(comms): async def test_awaited_notifications_then_immediate_close_retires_successfully(comms): async with ObservationProcess.awaited(comms.route) as process: - assert (await process.read(ReadNotifications(str(comms.root), ()))).outcomes == {} + assert (await process.read(ReadNotifications(str(comms.root), LiveNotificationRead(())))).outcomes == {} assert process.connection.closed and process.process.exitcode == 0 assert not process.reads diff --git a/tests/test_deleted_thread_observation.py b/tests/test_deleted_thread_observation.py index d13b6e2277..0abcc884ad 100644 --- a/tests/test_deleted_thread_observation.py +++ b/tests/test_deleted_thread_observation.py @@ -11,6 +11,7 @@ from agent_comms.registry_document import DEFAULT_ROSTER from agent_comms.registry_provenance import DeletedThread from agent_comms.thread_presentation import NoThreadPresentation, ThreadRetired +from agent_comms.notification_reads import LiveNotificationRead from agent_comms.ui_model.observation import ( ObservationProcess, ObserveThreads, ObserveViews, ReadNotifications, ThreadPresented, ThreadsObserved, ViewsRetired, @@ -107,7 +108,7 @@ async def test_observer_process_survives_deleted_interest_and_keeps_serving(comm assert isinstance(updated.activity['surviving'], ObservedActivity) assert process.process.is_alive() async with asyncio.timeout(30): - assert (await process.read(ReadNotifications(str(comms.root), ()))).outcomes == {} + assert (await process.read(ReadNotifications(str(comms.root), LiveNotificationRead(())))).outcomes == {} def test_one_revision_captures_each_thread_activity_once(comms): diff --git a/tests/test_historical_notifications.py b/tests/test_historical_notifications.py index 6c18bb27d0..09252c0d2d 100644 --- a/tests/test_historical_notifications.py +++ b/tests/test_historical_notifications.py @@ -490,4 +490,15 @@ def test_live_cli_ack_does_not_become_archived_ack(archived_notification): messages[0].seq, messages[0].message_id ] assert archived.label == "Sent" and not archived.displayed_to + # The root-free recorded operation reads the same archive, not today's live evidence, + # and a key that is no longer attached refuses instead of falling back to live reads. + from agent_comms.coordination_errors import HistoricalSourceChanged + from agent_comms.notification_reads import RecordedNotificationRead + + recorded, = RecordedNotificationRead((messages[0].reference,), source.key).read(live).outcomes[ + messages[0].seq, messages[0].message_id + ] + assert recorded == archived + with pytest.raises(HistoricalSourceChanged): + RecordedNotificationRead((messages[0].reference,), source.key + "-detached").read(live) assert FieldCodec.decode(RecipientActivity, FieldCodec.encode(RecordedRecipientActivity())) == RecordedRecipientActivity() diff --git a/tests/test_message_notifications.py b/tests/test_message_notifications.py index 23d86badf8..2ea2ed8c64 100644 --- a/tests/test_message_notifications.py +++ b/tests/test_message_notifications.py @@ -70,3 +70,21 @@ def test_individual_view_keeps_alias_goal_and_active_owner_semantics(tmp_path): assert state.active.phase.summary in comms.views.thread_presentations(('beta',)).require('beta').summary comms.agents.finish_turn(lease) assert not comms.views.thread_presentations(('beta',)).require('beta').status.working + + +def test_a_live_batch_beyond_one_window_chunk_keeps_every_message(tmp_path): # noqa: F811 + """The live operation reads every 120-reference chunk into one window; no batch cap.""" + from agent_comms.notification_evidence import NotificationWindow + from agent_comms.notification_reads import LiveNotificationRead + + _path, _root_id, comms, initial, _people = _root(tmp_path) + messages = [initial.message, *( + comms.messaging.send('sender', '#team', f'Batch {number}').message + for number in range(NotificationWindow.window_limit + 5) + )] + window = LiveNotificationRead(tuple(message.reference for message in messages)).read(comms) + assert set(window.outcomes) == {(message.seq, message.message_id) for message in messages} + for message in (messages[0], messages[-1]): + alone = LiveNotificationRead((message.reference,)).read(comms) + key = (message.seq, message.message_id) + assert window.outcomes[key] == alone.outcomes[key] and window.outcomes[key] diff --git a/tests/test_notification_read_state.py b/tests/test_notification_read_state.py index f9f1aa477e..969edc1c47 100644 --- a/tests/test_notification_read_state.py +++ b/tests/test_notification_read_state.py @@ -1,26 +1,38 @@ +import pickle import sys from dataclasses import replace from pathlib import Path +import pytest + +from agent_comms.bus_read import BusRead, UiRead from agent_comms.comms import Comms +from agent_comms.coordination_errors import StaleRevision +from agent_comms.field_codec import FieldCodec, MessageId from agent_comms.history_views import HistoryViews +from agent_comms.message_reference import MessageReference +from agent_comms.notification_read import NotificationsRead, UnreadableNotificationsRead +from agent_comms.notification_reads import ( + LiveNotificationRead, NotificationRead, RecordedNotificationRead, +) +from agent_comms.order_tools import InputDeliveryRead from agent_comms.private_path import FileRevision from agent_comms.presentation import NoRevision, WireRevision from agent_comms.revision_views import ( ActivityView, NotificationEvidenceView, NotificationsView, RegistrationsView, SidebarView, StatusView, StoresView, ) -from agent_comms.ui_model.observation import ( - NotificationsRead, ObserveNotifications, ReadNotifications, ReadRecordedNotifications, - UnreadableNotificationsRead, -) +from agent_comms.tool_declaration import NativeRequestTools +from agent_comms.tool_request import ToolRequest +from agent_comms.tools import CommsMessageOutcomesTool +from agent_comms.ui_model.observation import ObserveNotifications, ReadNotifications def test_notification_read_states_use_the_request_revision_contract(comms): original = WireRevision.empty() later = replace(original, expiry_tick=original.expiry_tick + 60) - live = ReadNotifications("root", ()) - recorded = ReadRecordedNotifications("root", (), "archive") + live = LiveNotificationRead(()) + recorded = RecordedNotificationRead((), "archive") settled = NotificationsRead(original, {}) assert not live.changed_since(comms, original, settled) assert not live.changed_since(comms, later, settled) @@ -66,9 +78,9 @@ def test_unrelated_registry_change_keeps_the_notification_window(comms: Comms, t def test_changed_notification_interest_keeps_only_retained_read_states(): - kept = ReadNotifications('kept', ()) - removed = ReadNotifications('removed', ()) - added = ReadNotifications('added', ()) + kept = ReadNotifications('kept', LiveNotificationRead(())) + removed = ReadNotifications('removed', LiveNotificationRead(())) + added = ReadNotifications('added', LiveNotificationRead(())) previous = ObserveNotifications(frozenset((kept, removed))).observation() state = NotificationsRead(WireRevision.empty()) previous.read_states = {kept: state, removed: state} @@ -80,6 +92,78 @@ def test_changed_notification_interest_keeps_only_retained_read_states(): assert requested.read_states == {kept: state} +REFERENCES = (MessageReference(1, MessageId.mint("original")),) + + +@pytest.mark.parametrize("operation", [ + LiveNotificationRead(REFERENCES), RecordedNotificationRead(REFERENCES, "archive"), +], ids=["live", "recorded"]) +def test_notification_operations_are_root_free_family_records(operation): + encoded = FieldCodec.encode(operation) + assert "root" not in encoded and "request_id" not in encoded + assert FieldCodec.decode(NotificationRead, encoded) == operation + assert FieldCodec.decode(UiRead, encoded) == operation + request = ReadNotifications("root", operation, request_id=4) + assert FieldCodec.decode(ReadNotifications, FieldCodec.encode(request)) == request + transported = pickle.loads(pickle.dumps(request)) + assert transported == request and hash(transported) == hash(request) + assert {transported: "held"}[request] == "held" and len({request, transported}) == 1 + + +def test_root_request_references_and_archive_each_distinguish_a_notification_request(): + other_reference = (MessageReference(2, MessageId.mint("other")),) + request = ReadNotifications("root", RecordedNotificationRead(REFERENCES, "archive"), request_id=4) + distinct = ( + replace(request, root="other-root"), + replace(request, request_id=5), + replace(request, operation=RecordedNotificationRead(other_reference, "archive")), + replace(request, operation=RecordedNotificationRead(REFERENCES, "other-archive")), + replace(request, operation=LiveNotificationRead(REFERENCES)), + ) + assert len({request, *distinct}) == 1 + len(distinct) + + +def test_equal_requests_keep_their_read_state_and_a_new_archive_drops_it(): + kept = ReadNotifications("root", RecordedNotificationRead(REFERENCES, "archive")) + replaced = ReadNotifications("root", RecordedNotificationRead(REFERENCES, "detached")) + reattached = ReadNotifications("root", RecordedNotificationRead(REFERENCES, "attached")) + previous = ObserveNotifications(frozenset((kept, replaced))).observation() + state = NotificationsRead(WireRevision.empty()) + previous.read_states = {kept: state, replaced: state} + requested = ObserveNotifications(frozenset(( + ReadNotifications("root", RecordedNotificationRead(REFERENCES, "archive")), reattached, + ))).observation() + + assert previous.for_interest(requested) is requested + assert requested.read_states == {kept: state} + assert requested.read_states[kept] is state + + +def test_one_operation_in_two_root_envelopes_keeps_root_validation(comms): + operation = LiveNotificationRead(REFERENCES) + ours = ReadNotifications(str(comms.root), operation) + foreign = ReadNotifications(str(comms.root.parent / "another-wire"), operation) + assert ours.operation is foreign.operation and ours != foreign + ours.require_root(comms) + with pytest.raises(StaleRevision): + foreign.require_root(comms) + + +def test_notification_operations_are_ui_reads_and_not_tools(): + assert BusRead.members_with(NotificationRead) == (LiveNotificationRead, RecordedNotificationRead) + assert set(BusRead.members_with(NotificationRead)) <= set(BusRead.members_with(UiRead)) + tools = (*ToolRequest.members_with(ToolRequest), *NativeRequestTools.members_with(NativeRequestTools)) + assert not any(issubclass(tool, NotificationRead) for tool in tools) + assert CommsMessageOutcomesTool in ToolRequest.members_with(ToolRequest) + assert CommsMessageOutcomesTool in NativeRequestTools.members_with(NativeRequestTools) + + +def test_a_notification_request_refuses_an_operation_outside_the_notification_family(): + encoded = FieldCodec.encode(ReadNotifications("root", LiveNotificationRead(REFERENCES))) + with pytest.raises(ValueError, match="is not a member of NotificationRead"): + FieldCodec.decode(ReadNotifications, {**encoded, "operation": FieldCodec.encode(InputDeliveryRead())}) + + def test_activity_views_receive_expiry_without_expiring_store_or_evidence_views(): original = WireRevision.empty() later = replace(original, expiry_tick=60) diff --git a/tests/test_observer_death.py b/tests/test_observer_death.py index 1c685b20e6..5ea1dc10dd 100644 --- a/tests/test_observer_death.py +++ b/tests/test_observer_death.py @@ -17,6 +17,7 @@ from agent_comms.owner_connection import OwnerIdentityChangedError from agent_comms.message_reference import MessageReference from agent_comms.bus_read import BusRead, OwnerLiveRead +from agent_comms.notification_reads import LiveNotificationRead from agent_comms.ui_model.observation import ( ObservationProcess, ObserverDied, ReadNotifications, ReadOwnerGoal, ReadOwnerOperation, StopSidebar, ) @@ -33,7 +34,7 @@ def owner_reads(): def empty_window(root) -> ReadNotifications: - return ReadNotifications(str(root), ()) + return ReadNotifications(str(root), LiveNotificationRead(())) async def test_a_killed_observer_is_reported_once_with_its_signal_and_fails_awaited_reads(comms, durable_home): @@ -71,9 +72,9 @@ async def test_a_killed_observer_is_reported_once_with_its_signal_and_fails_awai async def test_an_invalid_notification_reference_does_not_kill_the_observer(comms, durable_home): async with ObservationProcess.awaited(comms.route) as process: with pytest.raises(RelationViolationError): - await process.read(ReadNotifications( - str(comms.root), (MessageReference(1, MessageId.mint("not-the-original")),) - )) + await process.read(ReadNotifications(str(comms.root), LiveNotificationRead( + (MessageReference(1, MessageId.mint("not-the-original")),) + ))) assert (await process.read(empty_window(comms.root))).outcomes == {} diff --git a/tests/test_thread_read_results.py b/tests/test_thread_read_results.py index 14356cfcbd..3b2c9ed970 100644 --- a/tests/test_thread_read_results.py +++ b/tests/test_thread_read_results.py @@ -77,7 +77,8 @@ def test_assignment_row_changes_attribute_only_their_view_without_projection( from agent_comms.coordinator import Coordination from agent_comms.presentation import MessageNotification from agent_comms.threads import Thread - from agent_comms.ui_model.observation import NotificationsRead, ReadNotifications + from agent_comms.notification_read import NotificationsRead + from agent_comms.notification_reads import LiveNotificationRead root, root_id, comms, initial, _people = _root(private_directory, direct=True) for name in ('gamma', 'outside'): @@ -100,7 +101,7 @@ def test_assignment_row_changes_attribute_only_their_view_without_projection( revision = comms.views.revision() activity = comms.views.observed_activity(revision, names) previous = ThreadsObserved(revision, results, names, activity) - notification_window = ReadNotifications(root_id, (notification_source.reference,)).read(comms) + notification_window = LiveNotificationRead((notification_source.reference,)).read(comms) notification_read = NotificationsRead.of(revision, comms, activity, notification_window) if changed_name in ('read', 'unrelated_read'): ledger = comms.bus.reads diff --git a/tests/test_transcript_observation_lifetime.py b/tests/test_transcript_observation_lifetime.py index 3d04b673f7..e5a37f215c 100644 --- a/tests/test_transcript_observation_lifetime.py +++ b/tests/test_transcript_observation_lifetime.py @@ -12,6 +12,7 @@ from agent_comms.coordinator import Coordination from agent_comms.native_arguments import GivenBinary from agent_comms.runtime import RuntimeProxy, socket_path +from agent_comms.notification_reads import LiveNotificationRead from agent_comms.ui_model.observation import ( ObservationProcess, ObserverDied, @@ -90,7 +91,7 @@ async def test_subscriptions_share_process_and_last_close_joins_it(headless_owne async def test_broken_pipe_ends_subscription_with_observer_died(headless_owner): process = ObservationProcess(headless_owner._comms.route) pending = asyncio.create_task(process.read( - ReadNotifications(str(headless_owner._comms.root), ()))) + ReadNotifications(str(headless_owner._comms.root), LiveNotificationRead(())))) try: await asyncio.sleep(0) process.process.terminate() diff --git a/tests/test_unreadable_history_observation.py b/tests/test_unreadable_history_observation.py index 8898f509ac..2320995c3d 100644 --- a/tests/test_unreadable_history_observation.py +++ b/tests/test_unreadable_history_observation.py @@ -26,6 +26,7 @@ from agent_comms.owner_connection import OwnerIdentityChangedError, OwnerReplyLostError from agent_comms.context_store import ContextRequest from agent_comms.presentation import WireRevision +from agent_comms.notification_reads import LiveNotificationRead from agent_comms.ui_model.observation import ( ObservationProcess, ObserveSidebar, @@ -238,7 +239,7 @@ async def test_unknown_saved_member_does_not_kill_the_real_observation_process(c await process.read(ReadRecordedContext(str(comms.root), "saved")) # Serve a separate, readable request using the same actual pipe/child. async with asyncio.timeout(30): - assert (await process.read(ReadNotifications(str(comms.root), ()))).outcomes == {} + assert (await process.read(ReadNotifications(str(comms.root), LiveNotificationRead(())))).outcomes == {} assert process.process.is_alive() assert comms.contexts.path.read_bytes() == original assert comms.bus.bus.path.read_bytes() == original_bus From 7bcb1ca4300a57827139d05ed1e1ea7fa4e45eac Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 08:51:28 -0400 Subject: [PATCH 13/31] Notification activity holds only projected participants; HistorySource says what it returns (#3538) NotificationsRead.activity spelled "this participant has no projection" twice: absence from the window's watched names, and a None value. observed_activity already omits registered humans and archived rows and answers DeletedThread for a deleted name, so the None was a second encoding of absence. - activity is Mapping[str, ObservedActivity | DeletedThread], keeping only the window's names that the projection actually returned. - live_changed re-projects the window's full activity_names() when ActivityView moved and compares the sparse maps, so omitted->observed, observed->omitted and observed->DeletedThread still invalidate. With no ActivityView change it does not project at all. - HistorySource.notification_references annotates references as Sequence[MessageReference] and returns NotificationWindow, the type MessageNotification.read_references already returns. That closes the Any that reached RecordedNotificationRead.read_as. - The read-state fixture that built {absent-participant: None} now settles a real #team read whose participants are an agent, a human, an archived agent and a deleted agent. New controls cover: omitted humans and archived agents, an archived agent declared again, a deleted participant, and zero or one projection by revision kind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/historical_views.py | 9 ++-- src/agent_comms/notification_read.py | 16 +++---- tests/test_notification_read_state.py | 66 +++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index 19e2e8a795..9e0842adca 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -7,7 +7,7 @@ import tempfile from abc import ABC, abstractmethod from collections import deque -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from dataclasses import dataclass, replace from functools import partial @@ -31,7 +31,9 @@ MessagePageRequest, PageBounds, ) +from .message_reference import MessageReference from .messages import CarriesEnvelope, Message +from .notification_evidence import NotificationWindow from .page_traversal import HistoryCursor, OlderTraversal from .presentation import MessageNotification, OutcomeViewer from .private_path import FileObservation, FileRevision, ObservedPath, PrivateDirectoryRole @@ -352,8 +354,9 @@ def require(cls, sources: tuple[HistorySource, ...], key: str) -> HistorySource: source.validate() return source - def notification_references(self, sources: tuple[HistorySource, ...], references, - viewer: OutcomeViewer): + def notification_references(self, sources: tuple[HistorySource, ...], + references: Sequence[MessageReference], + viewer: OutcomeViewer) -> NotificationWindow: """Read this namespace within its archive's current catalogue read, as this viewer may see it.""" diff --git a/src/agent_comms/notification_read.py b/src/agent_comms/notification_read.py index 0f71c6b45e..9820c7b10a 100644 --- a/src/agent_comms/notification_read.py +++ b/src/agent_comms/notification_read.py @@ -32,12 +32,13 @@ def changed(self, comms: Comms, revision: WireRevision, earlier: WireRevision) - @dataclass(frozen=True) class NotificationsRead: - """Where one notification read settled: its revision and the activity of every participant + """Where one notification read settled: its revision and the activity of the participants its notifications name, captured before the read for participants already known and right - after it for new ones.""" + after it for new ones. The window names every watched participant; activity holds only + the ones the registry projects, so a human or archived participant is absent from it.""" revision: WireRevision - activity: Mapping[str, ObservedActivity | DeletedThread | None] = field(default_factory=dict) + activity: Mapping[str, ObservedActivity | DeletedThread] = field(default_factory=dict) notification_window: NotificationWindow = field(default_factory=NoNotificationWindow) @classmethod @@ -47,14 +48,13 @@ def of(cls, revision: WireRevision, comms: Comms, ) -> NotificationsRead: names = window.activity_names() known = {**comms.views.observed_activity(revision, names - known.keys()), **known} - return cls(revision, {name: known.get(name) for name in names}, window) + return cls(revision, {name: known[name] for name in names if name in known}, window) def live_changed(self, comms: Comms, revision: WireRevision) -> bool: """Read marks, participant activity or retained assignment evidence changed.""" - current = (comms.views.observed_activity(revision, self.activity) - if revision.changed_since(ActivityView(), self.revision) - else self.activity) - return (any(current.get(name) != seen for name, seen in self.activity.items()) + return ((revision.changed_since(ActivityView(), self.revision) + and comms.views.observed_activity( + revision, self.notification_window.activity_names()) != self.activity) or self.notification_window.changed(comms, revision, self.revision)) diff --git a/tests/test_notification_read_state.py b/tests/test_notification_read_state.py index 969edc1c47..5f3091e545 100644 --- a/tests/test_notification_read_state.py +++ b/tests/test_notification_read_state.py @@ -17,11 +17,15 @@ ) from agent_comms.order_tools import InputDeliveryRead from agent_comms.private_path import FileRevision +from agent_comms.registry_provenance import DeletedThread +from agent_comms.repo import Checkout from agent_comms.presentation import NoRevision, WireRevision from agent_comms.revision_views import ( ActivityView, NotificationEvidenceView, NotificationsView, RegistrationsView, SidebarView, StatusView, StoresView, ) +from agent_comms.thread_identity import AgentThreadRole, UserThreadRole +from agent_comms.threads import Thread from agent_comms.tool_declaration import NativeRequestTools from agent_comms.tool_request import ToolRequest from agent_comms.tools import CommsMessageOutcomesTool @@ -41,13 +45,69 @@ def test_notification_read_states_use_the_request_revision_contract(comms): assert not live.changed_since(comms, original, UnreadableNotificationsRead(original)) +TEAM = (("sender", AgentThreadRole), ("worker", AgentThreadRole), ("human", UserThreadRole), + ("archived", AgentThreadRole), ("gone", AgentThreadRole)) + + +def declare_team(comms: Comms, tmp_path: Path) -> MessageReference: + """One real #team message whose notifications name an agent, a human and two agents + that are archived or deleted afterwards.""" + comms.channels.create_tag("team") + for name, role in TEAM: + comms.registry.declare(Thread(name, frozenset({"team"}), Checkout(tmp_path), role=role)) + return comms.messaging.send("sender", "#team", "Who is still here?").message.reference + + +def settled_read(comms: Comms, reference: MessageReference) -> tuple[WireRevision, NotificationsRead]: + revision = comms.views.revision() + window = LiveNotificationRead((reference,)).read(comms) + return revision, NotificationsRead.of(revision, comms, {}, window) + + +def test_the_window_names_every_participant_and_activity_holds_only_projected_ones(comms, tmp_path): + reference = declare_team(comms, tmp_path) + comms.registry.archive("archived") + comms.registry.remove("gone") + revision, settled = settled_read(comms, reference) + + names = settled.notification_window.activity_names() + assert {name for name, _role in TEAM} <= names + assert settled.activity.keys() == names - {"human", "archived"} + assert settled.activity["gone"] == DeletedThread("gone") + assert settled.activity["worker"] == comms.views.observed_activity(revision, ("worker",))["worker"] + assert not settled.live_changed(comms, revision) + + +def test_an_archived_participant_becoming_visible_changes_the_read(comms, tmp_path): + reference = declare_team(comms, tmp_path) + comms.registry.archive("archived") + revision, settled = settled_read(comms, reference) + assert "archived" not in settled.activity + + comms.registry.declare(Thread("archived", frozenset({"team"}), Checkout(tmp_path))) + later = comms.views.revision() + assert later.changed_since(ActivityView(), revision) + assert settled.live_changed(comms, later) + + +def test_a_participant_deleted_after_the_read_changes_it(comms, tmp_path): + reference = declare_team(comms, tmp_path) + revision, settled = settled_read(comms, reference) + assert "gone" in settled.activity and settled.activity["gone"] != DeletedThread("gone") + + comms.registry.remove("gone") + assert settled.live_changed(comms, comms.views.revision()) + + def test_notification_read_only_acquires_activity_when_its_source_changed(comms, tmp_path): + reference = declare_team(comms, tmp_path) + comms.registry.archive("archived") + original, settled = settled_read(comms, reference) + assert {"human", "archived"} <= settled.notification_window.activity_names() - settled.activity.keys() marker = tmp_path / 'changed-source' marker.write_text('changed') source = FileRevision.capture(marker).revisions() - original = WireRevision.empty() assert replace(original, history=source).changed_since(NotificationsView(), original) - settled = NotificationsRead(original, {'absent-participant': None}) calls = 0 def count_reads(frame, event, argument): @@ -60,7 +120,7 @@ def count_reads(frame, event, argument): try: assert not settled.live_changed(comms, replace(original, reads=source)) assert calls == 0 - assert settled.live_changed(comms, replace(original, activity=source)) + assert not settled.live_changed(comms, replace(original, activity=source)) assert calls == 1 finally: sys.setprofile(previous) From 5bcc78d9b0098b862ff122bf71f13762a81133d3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 09:21:08 -0400 Subject: [PATCH 14/31] Read-state fixture watches native recipients, not the whole team (#3538) NotificationWindow.activity_names() comes from native-recipient outcomes, so the #team fixture watches worker, archived and gone, never the agent sender or the human. Two assertions expected sender and human, and failed in the root run (29 passed, 2 failed). The window-names test now requires exactly {worker, archived, gone} and excludes sender and human; only archived is omitted from the sparse activity. The projection-count test requires archived as the one watched-but-omitted name. No production change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/test_notification_read_state.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_notification_read_state.py b/tests/test_notification_read_state.py index 5f3091e545..b9d4869435 100644 --- a/tests/test_notification_read_state.py +++ b/tests/test_notification_read_state.py @@ -50,8 +50,9 @@ def test_notification_read_states_use_the_request_revision_contract(comms): def declare_team(comms: Comms, tmp_path: Path) -> MessageReference: - """One real #team message whose notifications name an agent, a human and two agents - that are archived or deleted afterwards.""" + """One real #team message from an agent sender, seen by a human and by three native + agent recipients; one of those is archived or deleted afterwards. The window names only + native recipients, so the sender and the human are never watched.""" comms.channels.create_tag("team") for name, role in TEAM: comms.registry.declare(Thread(name, frozenset({"team"}), Checkout(tmp_path), role=role)) @@ -64,15 +65,16 @@ def settled_read(comms: Comms, reference: MessageReference) -> tuple[WireRevisio return revision, NotificationsRead.of(revision, comms, {}, window) -def test_the_window_names_every_participant_and_activity_holds_only_projected_ones(comms, tmp_path): +def test_the_window_names_native_recipients_and_activity_holds_only_projected_ones(comms, tmp_path): reference = declare_team(comms, tmp_path) comms.registry.archive("archived") comms.registry.remove("gone") revision, settled = settled_read(comms, reference) names = settled.notification_window.activity_names() - assert {name for name, _role in TEAM} <= names - assert settled.activity.keys() == names - {"human", "archived"} + assert names == {"worker", "archived", "gone"} + assert not names & {"sender", "human"} + assert settled.activity.keys() == names - {"archived"} assert settled.activity["gone"] == DeletedThread("gone") assert settled.activity["worker"] == comms.views.observed_activity(revision, ("worker",))["worker"] assert not settled.live_changed(comms, revision) @@ -103,7 +105,7 @@ def test_notification_read_only_acquires_activity_when_its_source_changed(comms, reference = declare_team(comms, tmp_path) comms.registry.archive("archived") original, settled = settled_read(comms, reference) - assert {"human", "archived"} <= settled.notification_window.activity_names() - settled.activity.keys() + assert settled.notification_window.activity_names() - settled.activity.keys() == {"archived"} marker = tmp_path / 'changed-source' marker.write_text('changed') source = FileRevision.capture(marker).revisions() From 7b5c78a0ebd833471b5f93549010e6faafe65cba Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 10:19:00 -0400 Subject: [PATCH 15/31] Notification reads acquire only their viewer's claims; viewers receive no acquired evidence (#3538) A recipient-scoped thread read selected every claim of each broadcast by wire_seq alone, decoded them, and filtered recipients only afterwards: the saved six-tab desktop held 17,681 claims to show 15 rows. The same acquired graph (frozen audiences, decisions, WakeAssignment and activity rows) then travelled inside ThreadPresentation to the UI, twice over, through notification_window and through each notification's message, which was the backend record itself. Viewer captured once: - NotificationAssignmentRead carries its required OutcomeViewer. read, capture, missing_store and invalid_store take it, and the public missing store returns an empty read with that viewer; the private one still refuses. - capture builds the visible (recipient_lookup, wire_seq, message_id) keys through the viewer before any SQL. Claims are selected through the recipient/seq index by those keys, using TypedTable.requested_keys (factored out of select_keys, which now consumes it). HarnessDelivery.for_recipients gets the same keys. Rows keep the original wire_seq DESC, recipient order across batches. - delivery_window, project_delivery_window and for_sources read the captured viewer instead of taking a second one. for_deliveries skips invisible recipients before allocating an UnrecordedNotificationSource, and recorded_delivery_window allocates no placeholder activity for them. - NotificationAssignment.changed groups retained reads by their own viewer and re-reads each group's sources as that viewer. - SelectedParticipant captures its sent read as OwnerOutcomes of the owner, so the sender still sees every outcome. Public envelopes: - ThreadPresentation.with_sources and MessageNotification.for_sources attach source.envelope, the declared public Message, instead of the Delivered record. Acquired versus published: - ThreadPresentation keeps only the UI fields: status, binding, notifications and read_identity. - AcquiredThreadPresentation adds incoming_references, sent_references and notification_window. It is built by with_sources, supplies revision inputs and sent_summaries (replacing SentMessageSummary.recent), and published() derives the base from the declared fields. Pickling it raises a TypeError naming published(). - ThreadReadResult.published() defaults to itself and ThreadPresented projects its presentation. ThreadReading keeps the acquired results for its next change check and publishes only the changed results' projections. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/notification_assignment.py | 110 ++++++++++++--------- src/agent_comms/notification_evidence.py | 11 +-- src/agent_comms/presentation.py | 51 ++++------ src/agent_comms/private_registry_guard.py | 9 +- src/agent_comms/selected_participant.py | 12 +-- src/agent_comms/thread_presentation.py | 88 ++++++++++++----- src/agent_comms/tool_caller.py | 2 +- src/agent_comms/typed_table.py | 33 +++++-- src/agent_comms/ui_model/observation.py | 27 +++-- tests/test_outbound_transcript_source.py | 14 +-- tests/test_recipient_activity_index.py | 2 +- tests/test_tools.py | 4 +- 12 files changed, 222 insertions(+), 141 deletions(-) diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index e60c62df8c..00077a2e73 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -38,7 +38,7 @@ from .agent_activity import RecipientActivity from .bus_publication import Delivered from .coordination_database import InvalidStore, MissingStore - from .presentation import MessageNotification + from .presentation import MessageNotification, OutcomeViewer from .private_registry_guard import RegistryPrivacy from .wake_policy import RecipientDecision @@ -232,14 +232,20 @@ class NotificationAssignment(NotificationSource): @classmethod def changed(cls, reads: Iterable[NotificationAssignmentRead], root: Path) -> tuple[NotificationAssignmentRead, ...]: - """Acquire fresh bounded rows once; retained reads own their equality.""" - reads = tuple(reads) - sources = tuple({source.message.reference: source - for read in reads for source in read.sources}.values()) - fresh: dict[MessageReference, SourceEvidence] = {} - for sources_chunk in NotificationWindow.chunks(sources): - fresh.update(cls.read(root, sources_chunk).evidence) - return tuple(read for read in reads if read.differs_from(fresh)) + """Acquire fresh bounded rows once per captured viewer; retained reads own their + equality, each against evidence its own viewer may see.""" + by_viewer: dict[OutcomeViewer, list[NotificationAssignmentRead]] = {} + for read in reads: + by_viewer.setdefault(read.viewer, []).append(read) + changed = [] + for viewer, viewed in by_viewer.items(): + sources = tuple({source.message.reference: source + for read in viewed for source in read.sources}.values()) + fresh: dict[MessageReference, SourceEvidence] = {} + for sources_chunk in NotificationWindow.chunks(sources): + fresh.update(cls.read(root, sources_chunk, viewer=viewer).evidence) + changed.extend(read for read in viewed if read.differs_from(fresh)) + return tuple(changed) @classmethod def resolve(cls, root: Path, read: NotificationAssignmentRead) -> tuple[NotificationAssignment, ...]: @@ -250,8 +256,10 @@ def resolve(cls, root: Path, read: NotificationAssignmentRead) -> tuple[Notifica @classmethod def for_deliveries( cls, originals: Iterable[Delivered], records: Iterable[NotificationAssignment], + viewer: OutcomeViewer, ) -> Iterator[tuple[Delivered, tuple[NotificationSource, ...]]]: - """Match notification assignments to messages and their recorded recipients.""" + """Match notification assignments to messages and the recorded recipients this + viewer may see; an invisible recipient allocates no placeholder.""" by_recipient = {} for record in records: key = (record.assignment.source, record.assignment.recipient_lookup) @@ -260,6 +268,8 @@ def for_deliveries( reference = original.message.reference sources = [] for recipient, decision in zip(original.recipients, original.decisions, strict=True): + if not viewer.includes(original.sender_lookup, recipient): + continue matching = by_recipient.get((reference, recipient.recipient_lookup), ()) if len(matching) > 1: raise ValueError("Original notification recipient has multiple handling rows") @@ -301,65 +311,77 @@ def _recorded_conversation_responses( @classmethod def capture( cls, connection: sqlite3.Connection, sources: Sequence[Delivered], - privacy: RegistryPrivacy, + privacy: RegistryPrivacy, viewer: OutcomeViewer, ) -> NotificationAssignmentRead: - """Capture detached assignment evidence for one bounded window of originals - from the caller's SQLite snapshot.""" + """Capture detached assignment evidence for one bounded window of originals from the + caller's SQLite snapshot: only the claims and attempts of recipients this viewer + may see are selected, by their exact original keys, before any row is decoded.""" sources = tuple(sources) if len(sources) > NotificationWindow.window_limit: raise ValueError("Notification reads require a bounded visible message window") if not sources: - return NotificationAssignmentRead((), ()) + return NotificationAssignmentRead((), (), viewer=viewer) privacy.coordinator_schema().require_schema(connection) + visible = tuple((source, recipient) for source in sources for recipient in source.recipients + if viewer.includes(source.sender_lookup, recipient)) columns = ",".join(f"w.{name}" for name in WakeAssignment.columns()) - # The window's assignments and their native-input membership are each - # selected once, membership by its assignment index, not once per row - # and per column. - rows = WakeAssignment.joined( - connection.execute( - f"WITH w AS MATERIALIZED (SELECT * FROM {WakeAssignment.declared_name} " - f"WHERE wire_seq IN ({','.join('?' for _ in sources)})), " - f"context AS MATERIALIZED ({privacy.notification_context_sql(connection)}) " - f"SELECT {columns},context.triage_inflight," - "pointer.execution_id AS current_execution_id,context.context_committed_at_ms," - "p.seq AS response_seq, p.message_id AS response_message_id, " - "p.received_at_ms AS response_received_at_ms " - "FROM w " - "JOIN context ON context.assignment_id=w.assignment_id " - f"LEFT JOIN {CurrentExecutions.declared_name} pointer " - "ON pointer.owner_lookup=w.recipient_lookup " - f"LEFT JOIN {PublicationEvidence.declared_name} p " - "ON p.execution_id=w.execution_id AND p.exact_target=w.exact_target " - "ORDER BY w.wire_seq DESC,w.recipient", - tuple(source.message.seq for source in sources), - ), - AssignmentActivityEvidence, - ) + context = privacy.notification_context_sql(connection) + rows = [] + # Each batch's requested keys select their assignments through the + # recipient/seq index; their native-input membership is selected once per + # batch by its assignment index, not once per row and per column. + for requested in WakeAssignment.requested_keys(connection, tuple( + {"recipient_lookup": recipient.recipient_lookup, + "wire_seq": source.message.seq, "message_id": source.message.message_id} + for source, recipient in visible)): + rows.extend(WakeAssignment.joined( + connection.execute( + f"WITH {requested.values}, " + f"w AS MATERIALIZED (SELECT * {requested.join}), " + f"context AS MATERIALIZED ({context}) " + f"SELECT {columns},context.triage_inflight," + "pointer.execution_id AS current_execution_id,context.context_committed_at_ms," + "p.seq AS response_seq, p.message_id AS response_message_id, " + "p.received_at_ms AS response_received_at_ms " + "FROM w " + "JOIN context ON context.assignment_id=w.assignment_id " + f"LEFT JOIN {CurrentExecutions.declared_name} pointer " + "ON pointer.owner_lookup=w.recipient_lookup " + f"LEFT JOIN {PublicationEvidence.declared_name} p " + "ON p.execution_id=w.execution_id AND p.exact_target=w.exact_target", + requested.bindings, + ), + AssignmentActivityEvidence, + )) attempts = HarnessDelivery.for_recipients(connection, tuple( - (recipient.recipient_lookup, source.message.reference) - for source in sources for recipient in source.recipients)) - return NotificationAssignmentRead(sources, tuple(rows), attempts) + (recipient.recipient_lookup, source.message.reference) for source, recipient in visible)) + return NotificationAssignmentRead( + sources, tuple(sorted(rows, key=lambda row: (-row[0].wire_seq, row[0].recipient))), + attempts, viewer=viewer) @classmethod def read( - cls, root: Path, sources: Sequence[Delivered] + cls, root: Path, sources: Sequence[Delivered], *, viewer: OutcomeViewer, ) -> NotificationAssignmentRead: - """Capture assignment evidence through the single observing connection owner.""" + """Capture this viewer's assignment evidence through the single observing + connection owner.""" privacy = RegistryStore.at(root).privacy() with CoordinationStore.observed(CoordinationStore.path_in(root)).observing( - cls, sources, privacy, + cls, sources, privacy, viewer, ) as result: return result @classmethod def missing_store( cls, store: MissingStore, sources: Sequence[Delivered], privacy: RegistryPrivacy, + viewer: OutcomeViewer, ) -> NotificationAssignmentRead: - return privacy.missing_notification_assignments(sources) + return privacy.missing_notification_assignments(sources, viewer) @classmethod def invalid_store( cls, store: InvalidStore, sources: Sequence[Delivered], privacy: RegistryPrivacy, + viewer: OutcomeViewer, ) -> NoReturn: raise ValueError(f"Channel notification status unavailable: {store.failure_reason}") diff --git a/src/agent_comms/notification_evidence.py b/src/agent_comms/notification_evidence.py index 8920b89c1d..baa0345914 100644 --- a/src/agent_comms/notification_evidence.py +++ b/src/agent_comms/notification_evidence.py @@ -19,7 +19,7 @@ from .message_reference import MessageReference from .notification_assignment import AssignmentActivityEvidence from .notification_progress import DeliveryProgress - from .presentation import MessageNotification, WireRevision + from .presentation import MessageNotification, OutcomeViewer, WireRevision from .thread_identity import ThreadIncarnation @@ -41,7 +41,9 @@ def consumed_progress(self, recipient: ThreadLookup, @dataclass(frozen=True) class NotificationAssignmentRead: - """A window's claims and its harness attempts, both keyed by the exact original. + """A window's claims and its harness attempts, both keyed by the exact original, as the + viewer captured with them may see them: every later projection and change check of this + read answers for that same viewer. A harness attempt is its own consumption evidence: a no-wake or passive recipient has no claim for the attempt to move, and its message still entered the session.""" @@ -49,10 +51,7 @@ class NotificationAssignmentRead: sources: tuple[Delivered, ...] rows: tuple[tuple[WakeAssignment, AssignmentActivityEvidence], ...] attempts: tuple[HarnessDelivery, ...] = () - - @classmethod - def empty(cls) -> NotificationAssignmentRead: - return cls((), ()) + viewer: OutcomeViewer = field(kw_only=True) @cached_property def evidence(self) -> Mapping[MessageReference, SourceEvidence]: diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 3a21c7715a..6760fe66ac 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -251,8 +251,8 @@ def references( """Borrow mounted references in the original bounded delivery windows, as this viewer may see them.""" def project(sources: Sequence[Delivered]) -> NotificationWindow: - read = NotificationAssignment.read(root, sources) - return cls.delivery_window(root, registry, sources, read, viewer=viewer) + read = NotificationAssignment.read(root, sources, viewer=viewer) + return cls.delivery_window(root, registry, sources, read) return cls.read_references(log, references, project) @@ -276,20 +276,21 @@ def read_references( @classmethod def delivery_window( cls, root: Path, registry: Registration, sources: Sequence[Delivered], - assignment_read: NotificationAssignmentRead, *, viewer: OutcomeViewer, + assignment_read: NotificationAssignmentRead, ) -> NotificationWindow: - """Project the original frozen audience even before handling is recorded.""" + """Project the original frozen audience even before handling is recorded, as the + read's captured viewer may see it.""" if not sources: return CapturedNotificationWindow({}, (assignment_read,)) snapshot = registry.snapshot() + viewer = assignment_read.viewer observations = AgentActivity(root, registry).observe_recipients( ((source, recipient) for source in sources for recipient in source.recipients if viewer.includes(source.sender_lookup, recipient)), snapshot=snapshot, ) return cls.project_delivery_window( - root, snapshot, sources, observations, - viewer=viewer, assignment_read=assignment_read, + root, snapshot, sources, observations, assignment_read=assignment_read, ) @classmethod @@ -299,37 +300,37 @@ def recorded_delivery_window(cls, root: Path, namespace: RegistryProvenance, observations = { recipient.recipient_lookup: RecordedRecipientActivity() for source in sources for recipient in source.recipients + if viewer.includes(source.sender_lookup, recipient) } - read = NotificationAssignment.read(root, sources) + read = NotificationAssignment.read(root, sources, viewer=viewer) return cls.project_delivery_window( - root, namespace, sources, observations, - assignment_read=read, viewer=viewer, + root, namespace, sources, observations, assignment_read=read, ) @classmethod def project_delivery_window( cls, root: Path, snapshot: RegistryProvenance, sources: Sequence[Delivered], observations: Mapping[ThreadLookup, RecipientActivity], - *, assignment_read: NotificationAssignmentRead, viewer: OutcomeViewer, + *, assignment_read: NotificationAssignmentRead, ) -> NotificationWindow: - """Original assignment and read authorities own both display projections.""" + """Original assignment and read authorities own both display projections; the read's + captured viewer decides which recipients are projected at all.""" if len(sources) > NotificationWindow.window_limit: raise ValueError("Notification reads require a bounded visible message window") keys = {(source.message.seq, source.message.message_id) for source in sources} result: dict[tuple[int, str], list[MessageNotification]] = {key: [] for key in keys} if not keys: return CapturedNotificationWindow({}, (assignment_read,)) + viewer = assignment_read.viewer rows = NotificationAssignment.resolve(root, assignment_read) evidence = assignment_read.evidence read_marks = [] reads = ReadLedger.at(root) document = reads.read() - for source, outcomes in NotificationAssignment.for_deliveries(sources, rows): + for source, outcomes in NotificationAssignment.for_deliveries(sources, rows, viewer): key = (source.message.seq, source.message.message_id) for outcome in outcomes: recipient = outcome.recipient() - if not viewer.includes(source.sender_lookup, recipient): - continue observation = observations[recipient.recipient_lookup] notification = outcome.project(observation, source.sender_lookup) state = evidence[source.message.reference].consumed_progress( @@ -391,16 +392,14 @@ def sent_sources( @classmethod def for_sources( - cls, root: Path, registry: Registration, viewer: OutcomeViewer, - sources: Sequence[Delivered], *, + cls, root: Path, registry: Registration, sources: Sequence[Delivered], *, assignment_read: NotificationAssignmentRead, ) -> tuple[MessageNotification, ...]: - """Attach notification outcomes to the messages the caller already checked.""" - projected = cls.delivery_window( - root, registry, sources, assignment_read, viewer=viewer, - ) + """Attach notification outcomes, as the read's viewer sees them, to the public + envelopes of the messages the caller already checked.""" + projected = cls.delivery_window(root, registry, sources, assignment_read) return tuple( - replace(notification, message=source.message) + replace(notification, message=source.envelope) for source in sources for notification in projected.outcomes[source.message.seq, source.message.message_id] ) @@ -459,16 +458,6 @@ class SentMessageSummary: reference: MessageReference summary: str - @classmethod - def recent(cls, presentation: ThreadPresentation) -> tuple[SentMessageSummary, ...]: - """One summary per recent sent source, from the presentation's captured window.""" - window = presentation.notification_window - return tuple( - cls(reference, MessageNotification.summary( - window.outcomes.get((reference.seq, reference.message_id), ()))) - for reference in presentation.sent_references - ) - class ChannelReadMark(ABC): """A read request carries its own source cut and displayed-message selection.""" diff --git a/src/agent_comms/private_registry_guard.py b/src/agent_comms/private_registry_guard.py index 045ab785da..c5f9b1e884 100644 --- a/src/agent_comms/private_registry_guard.py +++ b/src/agent_comms/private_registry_guard.py @@ -53,6 +53,7 @@ from .acp_extension import CursorAdvancedUpdate, CursorScope from .bus_publication import Delivered from .cursor_publication import CursorPublication + from .presentation import OutcomeViewer from .private_nk_entrypoint import RootLaunch from .registry_document import RegistrySnapshot from .typed_table import TableSchema @@ -162,7 +163,7 @@ def context_entered_sql(self, assignment: str) -> str: @abstractmethod def missing_notification_assignments( - self, sources: Sequence[Delivered], + self, sources: Sequence[Delivered], viewer: OutcomeViewer, ) -> NotificationAssignmentRead: ... @abstractmethod @@ -202,9 +203,9 @@ def context_entered_sql(self, assignment: str) -> str: return "0" def missing_notification_assignments( - self, sources: Sequence[Delivered], + self, sources: Sequence[Delivered], viewer: OutcomeViewer, ) -> NotificationAssignmentRead: - return NotificationAssignmentRead(tuple(sources), ()) + return NotificationAssignmentRead(tuple(sources), (), viewer=viewer) def cursor_scope( self, snapshot: RegistrySnapshot, thread_name: str, session_id: str, @@ -283,7 +284,7 @@ def context_entered_sql(self, assignment: str) -> str: return f"EXISTS (SELECT 1 {NativeRuntimeInput.context_entered_from(members, assignment)})" def missing_notification_assignments( - self, sources: Sequence[Delivered], + self, sources: Sequence[Delivered], viewer: OutcomeViewer, ) -> NotificationAssignmentRead: raise PublicationActivationBlocked("Private notification coordinator is missing") diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index cdf17be4a9..f2985f37eb 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -41,7 +41,6 @@ from .presentation import MessageNotification, OwnerOutcomes, SentMessageOutcome from .publisher import ParticipantPublisher from .selected_source_batch import SelectedSource, SelectedSourceBatch -from .threads import Thread from .turn_phase import PreparingPhase, TurnPhase from .turn_goal_permission import OwnerGoalPermission, TurnGoalPermission @@ -203,13 +202,12 @@ async def select( @staticmethod def capture_sent_outcomes( - comms: Comms, owner: Thread, sources: Sequence[Delivered], + comms: Comms, sources: Sequence[Delivered], assignment_read: NotificationAssignmentRead, ) -> tuple[SentMessageOutcome, ...]: """Project optional prompt context from the drain's captured source and SQL cuts.""" notifications = MessageNotification.for_sources( - comms.root, comms.registry, OwnerOutcomes.of(owner), sources, - assignment_read=assignment_read, + comms.root, comms.registry, sources, assignment_read=assignment_read, ) return tuple( outcome @@ -253,13 +251,11 @@ def prepare( store, lookup, after_seq=after_seq ) sent_read = NotificationAssignment.capture( - store.session._connection, sent_sources, privacy, + store.session._connection, sent_sources, privacy, OwnerOutcomes.of(sent_owner), ) if pending: participant.pointer.require_idle() - sent_outcomes = SelectedParticipant.capture_sent_outcomes( - comms, sent_owner, sent_sources, sent_read - ) + sent_outcomes = SelectedParticipant.capture_sent_outcomes(comms, sent_sources, sent_read) return owner, participant, pending, sent_outcomes @staticmethod diff --git a/src/agent_comms/thread_presentation.py b/src/agent_comms/thread_presentation.py index 975703cae8..b64d2b1f5f 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass, field, fields, replace -from typing import TYPE_CHECKING, ClassVar, Self +from typing import TYPE_CHECKING, ClassVar, NoReturn, Self from agent_comms.coordination_errors import StaleRevision from agent_comms.errors import RelationViolationError, RouteChangedError, UnregisteredThreadError @@ -12,11 +12,11 @@ from .declared_family import DeclaredFamily from .thread_identity import OwnerIdentity from .child_process import ProcessIdentity -from .notification_evidence import NoNotificationWindow, NotificationWindow +from .notification_evidence import NotificationWindow if TYPE_CHECKING: from .message_reference import MessageReference - from .presentation import MessageNotification + from .presentation import MessageNotification, SentMessageSummary from .transcripts import TranscriptReadIdentity, TranscriptSourceWindow from agent_comms.registry_document import RegistrySnapshot from agent_comms.transcripts import Transcripts @@ -209,13 +209,22 @@ def same_status(self, other: ThreadStatusDisplay) -> bool: @dataclass(frozen=True, slots=True) class ThreadPresentation(ThreadStatusDisplay): - """A thread's acquired presentation, or its declared absence.""" + """What a viewer shows for a thread, or its declared absence: no acquired evidence.""" notifications: tuple[MessageNotification, ...] = () read_identity: TranscriptReadIdentity | None = None - incoming_references: tuple[MessageReference, ...] = () - sent_references: tuple[MessageReference, ...] = () - notification_window: NotificationWindow = field(default_factory=NoNotificationWindow) + + def presentation_fields(self) -> dict[str, object]: + return {declared.name: getattr(self, declared.name) + for declared in fields(ThreadPresentation)} + + def published(self) -> ThreadPresentation: + """The value a viewer may receive: this presentation itself.""" + return self + + def sent_summaries(self) -> tuple[SentMessageSummary, ...]: + """A presentation without acquired sources summarises no sent message.""" + return () def require(self, name: str) -> ThreadPresentation: """Require a presentation for a caller already bound to this thread.""" @@ -230,8 +239,9 @@ def with_paint(self, shown: ThreadPresentation) -> Self: return replace(self, title=shown.title, marker=shown.marker, summary=shown.summary, status=shown.status) - def collect_revision_input(self, name: str, readable: dict[str, ThreadPresentation]) -> None: - readable[name] = self + def collect_revision_input(self, name: str, + readable: dict[str, AcquiredThreadPresentation]) -> None: + """Only acquired evidence can say whether a later revision changed it.""" def read_sources(self, transcripts: Transcripts, snapshot: RegistrySnapshot, name: str, sent_source_limit: int) -> ThreadPresentation: @@ -257,36 +267,69 @@ def same_observation(self, other: ThreadPresentation) -> bool: return original.thread == current.thread and original.same_snapshot(current) def with_sources(self, window: TranscriptSourceWindow) -> ThreadPresentation: - """Incoming and sent outcomes are views of one captured notification window.""" + """Incoming and sent outcomes are views of one captured notification window, read + as the thread's owner may see them; the incoming outcomes carry public envelopes.""" from .notification_assignment import NotificationAssignment from .presentation import MessageNotification, OwnerOutcomes transcripts = window.read.owner sources = tuple({source.message.reference: source for source in (*window.sources, *window.sent_sources)}.values()) - notification_read = NotificationAssignment.read(transcripts.root, sources) + notification_read = NotificationAssignment.read( + transcripts.root, sources, viewer=OwnerOutcomes.of(window.read.identity.thread)) projected = MessageNotification.delivery_window( - transcripts.root, transcripts.registry, sources, notification_read, - viewer=OwnerOutcomes.of(window.read.identity.thread), - ) + transcripts.root, transcripts.registry, sources, notification_read) incoming = tuple( - replace(notification, message=source.message) + replace(notification, message=source.envelope) for source in window.sources for notification in projected.outcomes[source.message.seq, source.message.message_id] ) - return replace( - self, notifications=incoming, - notification_window=projected, - read_identity=window.read.identity, + return AcquiredThreadPresentation(**(self.presentation_fields() | { + "notifications": incoming, + "read_identity": window.read.identity, + }), notification_window=projected, incoming_references=tuple(source.message.reference for source in window.sources), - sent_references=tuple(source.message.reference for source in window.sent_sources), - ) + sent_references=tuple(source.message.reference for source in window.sent_sources)) @property def label(self) -> str: return f"{self.marker} {self.title}".strip() +@dataclass(frozen=True, slots=True) +class AcquiredThreadPresentation(ThreadPresentation): + """A presentation together with the notification evidence it was acquired from. + + The evidence stays with the reader that acquired it, for its change checks and sent + summaries; a viewer receives only the published ThreadPresentation.""" + + incoming_references: tuple[MessageReference, ...] = field(kw_only=True) + sent_references: tuple[MessageReference, ...] = field(kw_only=True) + notification_window: NotificationWindow = field(kw_only=True) + + def published(self) -> ThreadPresentation: + return ThreadPresentation(**self.presentation_fields()) + + def sent_summaries(self) -> tuple[SentMessageSummary, ...]: + """One summary per recent sent source, from this presentation's captured window.""" + from .presentation import MessageNotification, SentMessageSummary + + return tuple( + SentMessageSummary(reference, MessageNotification.summary( + self.notification_window.outcomes.get((reference.seq, reference.message_id), ()))) + for reference in self.sent_references + ) + + def collect_revision_input(self, name: str, + readable: dict[str, AcquiredThreadPresentation]) -> None: + readable[name] = self + + def __reduce_ex__(self, protocol: object) -> NoReturn: + raise TypeError( + "AcquiredThreadPresentation keeps private delivery evidence with its reader; " + "send its published() ThreadPresentation") + + @dataclass(frozen=True, slots=True) class NoThreadPresentation(ThreadPresentation): """No acquired thread: no sources, notifications or readable revision.""" @@ -301,9 +344,6 @@ def require(self, name: str) -> ThreadPresentation: def for_status(self, name: str) -> ThreadPresentation: return self.with_paint(ThreadRetired().presentation(name)) - def collect_revision_input(self, name: str, readable: dict[str, ThreadPresentation]) -> None: - pass - def read_sources(self, transcripts: Transcripts, snapshot: RegistrySnapshot, name: str, sent_source_limit: int) -> ThreadPresentation: return self diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index 29cf6841e6..5fb70fe29b 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -130,7 +130,7 @@ def invoke(self, request: ToolRequest[ThreadCaller], comms: Comms) -> ToolResult def listing(self, comms: Comms, threads: tuple[ThreadRecord, ...]) -> ThreadListing: presentation = comms.views.thread_presentations((self.name,)).require(self.name) - return ThreadListing(threads, SentMessageSummary.recent(presentation)) + return ThreadListing(threads, presentation.sent_summaries()) def outcome_viewer(self, comms: Comms) -> OutcomeViewer: return OwnerOutcomes.of(comms.registry.require(self.name)) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index bf78b95c9d..4d30af53b0 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -298,6 +298,16 @@ def reference_sql(self) -> str: ) +@dataclass(frozen=True, slots=True) +class RequestedKeys: + """One batch of distinct declared keys: the ``requested`` VALUES table they bind, and + the null-safe join that drives the table's index from it.""" + + values: str + join: str + bindings: tuple[object, ...] + + @dataclass(frozen=True) class Column(FieldDirective): """Constraints on a field; its name and type remain on the dataclass field.""" @@ -2299,9 +2309,20 @@ def select_keys(cls, db: sqlite3.Connection, """Rows equal to any of these named keys under the same null-safe IS equality select() uses: the requested keys drive an indexed join, one batch per the connection's own variable limit, each distinct key probed once.""" + return [row for requested in cls.requested_keys(db, keys) for row in cls.iterate(db.execute( + f"WITH {requested.values} " + f"SELECT {cls.qualified(*cls.column_projection())} {requested.join}", + requested.bindings))] + + @classmethod + def requested_keys(cls, db: sqlite3.Connection, + keys: Sequence[Mapping[str, object]]) -> Iterator[RequestedKeys]: + """The distinct named keys, encoded by their declared columns, in batches that fit the + connection's own variable limit; each batch joins this table as ``FROM requested + JOIN ON ...`` under select()'s null-safe IS equality.""" bound = tuple(dict.fromkeys(cls._bound(key) for key in keys)) if not bound: - return [] + return field_sets = {tuple(item.name for item in selected) for selected, _ in bound} if len(field_sets) != 1: raise ValueError(f"{cls.__name__} keys name different fields: {sorted(field_sets)}") @@ -2312,11 +2333,11 @@ def select_keys(cls, db: sqlite3.Connection, for item, alias in zip(selected, aliases, strict=True)) probe = "(" + ",".join("?" for _ in selected) + ")" batch = db.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER) // len(selected) - return [row for chunk in batched(bound, batch) for row in cls.iterate(db.execute( - f"WITH requested({','.join(aliases)}) AS (VALUES {','.join(probe for _ in chunk)}) " - f"SELECT {cls.qualified(*cls.column_projection())} FROM requested " - f"JOIN {table} ON {joined}", - tuple(value for _, values in chunk for value in values)))] + for chunk in batched(bound, batch): + yield RequestedKeys( + f"requested({','.join(aliases)}) AS (VALUES {','.join(probe for _ in chunk)})", + f"FROM requested JOIN {table} ON {joined}", + tuple(value for _, values in chunk for value in values)) @classmethod def _query( diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index 43481ac23e..75359401f7 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -77,7 +77,7 @@ StoresView, ) from agent_comms.thread_identity import ThreadIncarnation -from agent_comms.thread_presentation import ThreadPresentation +from agent_comms.thread_presentation import AcquiredThreadPresentation, ThreadPresentation from agent_comms.transcripts import TranscriptCursor, TranscriptReadIdentity from agent_comms.ui_model.sidebar import ( ChannelRowModel, @@ -845,8 +845,11 @@ def read(self, comms: Comms, revision: WireRevision) -> Generator[ObservationSte changed = observed.changed_since(previous) | self.initial self.initial = frozenset() if changed: + # The reader keeps its acquired results for the next change check; a viewer + # receives only each changed result's published projection. yield replace(observed, changed=changed, - results={name: value for name, value in results.items() if name in changed}) + results={name: value.published() for name, value in results.items() + if name in changed}) def located(self, comms: Comms, revision: WireRevision, previous: ThreadsObserved) -> LocatedThreads: # Keep each completed stage when a later stored read fails, exactly as @@ -1033,10 +1036,15 @@ class ThreadReadResult(PublishState, ABC): def same_as_presented(self, presented: ThreadPresented) -> bool: return False + def published(self) -> ThreadReadResult: + """What a viewer receives of this read: the read itself.""" + return self + @abstractmethod def status_row(self, name: str, owner: OpenThreadStatus) -> ThreadStatusModel: ... - def collect_revision_inputs(self, name: str, readable: dict[str, ThreadPresentation], retry: set[str]) -> None: + def collect_revision_inputs(self, name: str, readable: dict[str, AcquiredThreadPresentation], + retry: set[str]) -> None: """Supply readable values and reads that must be tried again.""" def same_status(self, presentation: ThreadPresentation) -> bool: @@ -1054,10 +1062,14 @@ def same_as_presented(self, presented: ThreadPresented) -> bool: return (presented.presentation is self.presentation or presented.presentation.same_observation(self.presentation)) + def published(self) -> ThreadPresented: + return ThreadPresented(self.presentation.published()) + def status_row(self, name: str, owner: OpenThreadStatus) -> ThreadStatusModel: return ThreadStatusModel.known(name, self.presentation) - def collect_revision_inputs(self, name: str, readable: dict[str, ThreadPresentation], retry: set[str]) -> None: + def collect_revision_inputs(self, name: str, readable: dict[str, AcquiredThreadPresentation], + retry: set[str]) -> None: self.presentation.collect_revision_input(name, readable) def same_status(self, presentation: ThreadPresentation) -> bool: @@ -1077,7 +1089,8 @@ class ThreadBusy(ThreadReadResult): def status_row(self, name: str, owner: OpenThreadStatus) -> ThreadStatusModel: return owner.open(name) - def collect_revision_inputs(self, name: str, readable: dict[str, ThreadPresentation], retry: set[str]) -> None: + def collect_revision_inputs(self, name: str, readable: dict[str, AcquiredThreadPresentation], + retry: set[str]) -> None: retry.add(name) @@ -1089,8 +1102,8 @@ class ThreadsObserved(Observed): changed: frozenset[str] activity: Mapping[str, ObservedActivity | DeletedThread] - def revision_inputs(self) -> tuple[dict[str, ThreadPresentation], set[str]]: - readable: dict[str, ThreadPresentation] = {} + def revision_inputs(self) -> tuple[dict[str, AcquiredThreadPresentation], set[str]]: + readable: dict[str, AcquiredThreadPresentation] = {} retry: set[str] = set() for name, result in self.results.items(): result.collect_revision_inputs(name, readable, retry) diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index 83dec2c377..c4ba92a9e6 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -33,21 +33,21 @@ def test_original_read_contention_is_unavailable_then_same_receipts_return(tmp_p root, _root_id, comms, initial, _people = _root(tmp_path) database = root / "coordination.sqlite3" - captured = NotificationAssignment.read(root, (initial,)) + captured = NotificationAssignment.read(root, (initial,), viewer=AllOutcomes()) original = NotificationAssignment.resolve(root, captured) wire_before = comms.bus.bus.revision() assert original with Coordination(str(database)) as writer: with writer.session.writing(): with pytest.raises(CoordinationReadUnavailable) as unavailable: - NotificationAssignment.read(root, (initial,)) + NotificationAssignment.read(root, (initial,), viewer=AllOutcomes()) assert unavailable.value.__cause__.sqlite_errorcode & 0xFF in ( sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED, ) with pytest.raises(CoordinationReadUnavailable): with TranscriptRead.publication_source(root): pytest.fail("Busy acquisition reached the native schema decoder") - reopened = NotificationAssignment.read(root, (initial,)) + reopened = NotificationAssignment.read(root, (initial,), viewer=AllOutcomes()) assert reopened == captured assert NotificationAssignment.resolve(root, reopened) == original with TranscriptRead.publication_source(root) as native_read: @@ -128,15 +128,15 @@ def test_capture_page_window_names_zero_and_nonzero_sent_windows(tmp_path): # n def test_notification_window_preserves_exact_receipts_and_rejects_duplicates(tmp_path): # noqa: F811 root, _root_id, comms, initial, _people = _root(tmp_path) - records = NotificationAssignment.resolve(root, NotificationAssignment.read(root, (initial,))) + records = NotificationAssignment.resolve(root, NotificationAssignment.read(root, (initial,), viewer=AllOutcomes())) assert records - projected = tuple(NotificationAssignment.for_deliveries((initial,), records)) + projected = tuple(NotificationAssignment.for_deliveries((initial,), records, AllOutcomes())) assert projected[0][0] is initial assert any(outcome is records[0] for outcome in projected[0][1]) with pytest.raises(ValueError): - tuple(NotificationAssignment.for_deliveries((initial,), (*records, records[0]))) + tuple(NotificationAssignment.for_deliveries((initial,), (*records, records[0]), AllOutcomes())) # Duplicate evidences outside the acquired deliveries do not change their result. - assert tuple(NotificationAssignment.for_deliveries((), (*records, records[0]))) == () + assert tuple(NotificationAssignment.for_deliveries((), (*records, records[0]), AllOutcomes())) == () def test_publication_intent_joins_original_sender_and_target_only(tmp_path): # noqa: F811 diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 6d35c16edb..a24ddaad1a 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -88,6 +88,6 @@ def test_viewer_selects_the_outcomes_of_a_delivery_window(tmp_path, viewer_of, s for name, thread in comms.registry.snapshot().threads.items()} window = MessageNotification.delivery_window( comms.root, comms.registry, (original,), - NotificationAssignment.read(comms.root, (original,)), viewer=viewer_of(lookups), + NotificationAssignment.read(comms.root, (original,), viewer=viewer_of(lookups)), ) assert {item.recipient for item in window.outcomes[(sent.seq, sent.message_id)]} == seen diff --git a/tests/test_tools.py b/tests/test_tools.py index aed648ac23..51a891a86f 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -8,7 +8,7 @@ from agent_comms.channels import AnyChannel from agent_comms.connector_caller import DirectorCaller from agent_comms.errors import ToolRefusedError -from agent_comms.presentation import MessageNotification, SentMessageSummary +from agent_comms.presentation import MessageNotification from agent_comms.threads import Thread from agent_comms.tool_caller import NoThreadCaller, ThreadCaller from agent_comms.tool_declaration import NativeRequestTools @@ -139,7 +139,7 @@ def test_threads_tool_summarizes_each_recent_sent_message_once(self, comms, monk presentation = comms.views.thread_presentations(("sender",)).require("sender") assert all(row.message.reference != sent.reference for row in presentation.notifications) outcomes = presentation.notification_window.outcomes[sent.seq, sent.message_id] - assert [summary.reference for summary in SentMessageSummary.recent(presentation)] == [sent.reference] + assert [summary.reference for summary in presentation.sent_summaries()] == [sent.reference] result = invoke_tool(comms, "comms_threads", {}, caller=ThreadCaller("sender")) # One row per sent message, never one per recipient, and never the message body. From 2e8db82293dab6d846ace142b89a5383937b8b45 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 10:24:16 -0400 Subject: [PATCH 16/31] Controls for viewer-scoped evidence and published-only thread presentations (#3538) - test_outbound_transcript_source: - each recipient's own read decodes exactly one claim row (profiled AssignmentActivityEvidence._decode_row), equal to that recipient's rows and attempts in the operator read; the sender read equals the operator read; - invisible recipients allocate no UnrecordedNotificationSource, and the sender keeps all of them; - one claim transition refreshes only the retained reads whose viewer sees it: the recipient, sender and operator reads, not the other recipient's. - test_recipient_activity_index: - a viewer follows frozen lookups through rename and never into a rebirth; - a public root without a coordinator returns an empty read with the same viewer and projects only visible recipients; - a private root without one still refuses. - test_thread_read_results: - ThreadReading retains the AcquiredThreadPresentation and publishes its ThreadPresentation projection, with the same status, read_identity and notifications, whose messages are public envelopes; - the pickled observation carries no acquired evidence type, and sending the acquired result is refused; - after a second change, the UI's current and last-published rows reach no acquired evidence. - test_thread_presentation: absence publishes itself and summarises no sent message; its notification_window assertion read a field absence no longer has. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/test_outbound_transcript_source.py | 82 ++++++++++++++++++++- tests/test_recipient_activity_index.py | 53 ++++++++++++++ tests/test_thread_presentation.py | 3 +- tests/test_thread_read_results.py | 91 ++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 2 deletions(-) diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index c4ba92a9e6..aa97ae3d6c 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -1,10 +1,11 @@ """Whole conversations derive their original frozen wire sources, in both directions.""" -from agent_comms.presentation import AllOutcomes +from agent_comms.presentation import AllOutcomes, OwnerOutcomes from agent_comms.repo import Checkout from agent_comms.channel_targets import CommandTarget import sqlite3 +import sys from dataclasses import replace import pytest @@ -55,6 +56,85 @@ def test_original_read_contention_is_unavailable_then_same_receipts_return(tmp_p assert comms.bus.bus.revision() == wire_before +def test_a_read_selects_only_its_viewers_claims_and_attempts_before_decoding(tmp_path): # noqa: F811 + from agent_comms.notification_assignment import AssignmentActivityEvidence + + root, _root_id, comms, initial, people = _root(tmp_path) + lookups = {person.name: person.incarnation.lookup for person in people} + reference = initial.message.reference + everyone = NotificationAssignment.read(root, (initial,), viewer=AllOutcomes()) + recipients = {recipient.canonical_thread: recipient.recipient_lookup + for recipient in initial.recipients} + assert len(recipients) >= 2 and "sender" not in recipients + assert {claim.recipient for claim, _evidence in everyone.rows} == recipients.keys() + decoded = 0 + decode = AssignmentActivityEvidence._decode_row.__func__.__code__ + + def count_decodes(frame, event, argument): + nonlocal decoded + if event == "call" and frame.f_code is decode: + decoded += 1 + + for name, lookup in recipients.items(): + viewer = OwnerOutcomes(lookup) + decoded = 0 + profiling = sys.getprofile() + sys.setprofile(count_decodes) + try: + own = NotificationAssignment.read(root, (initial,), viewer=viewer) + finally: + sys.setprofile(profiling) + assert decoded == 1 + assert own.viewer == viewer and own.sources == (initial,) + assert own.rows == tuple(row for row in everyone.rows if row[0].recipient == name) + assert own.evidence[reference].claims == own.rows + assert own.evidence[reference].attempts == { + recipient: attempts for recipient, attempts in everyone.evidence[reference].attempts.items() + if recipient == lookup} + sender = NotificationAssignment.read(root, (initial,), viewer=OwnerOutcomes(lookups["sender"])) + assert sender.rows == everyone.rows + assert sender.evidence == everyone.evidence + + +def test_invisible_recipients_allocate_no_placeholder_source(tmp_path): # noqa: F811 + root, _root_id, _comms, initial, people = _root(tmp_path) + lookups = {person.name: person.incarnation.lookup for person in people} + recipient = initial.recipients[0] + (source, outcomes), = NotificationAssignment.for_deliveries( + (initial,), (), OwnerOutcomes(recipient.recipient_lookup)) + assert source is initial + assert [outcome.recipient() for outcome in outcomes] == [recipient] + (_source, all_outcomes), = NotificationAssignment.for_deliveries( + (initial,), (), OwnerOutcomes(lookups["sender"])) + assert [outcome.recipient() for outcome in all_outcomes] == list(initial.recipients) + + +def test_retained_reads_refresh_only_for_changes_their_viewer_sees(tmp_path): # noqa: F811 + from agent_comms.assignment_states import DeferredAssignment + from agent_comms.coordination_cohort import sealed_cohort_assignments + + root, _root_id, _comms, initial, people = _root(tmp_path) + lookups = {person.name: person.incarnation.lookup for person in people} + changed, unchanged = initial.recipients[:2] + reads = { + "changed": NotificationAssignment.read( + root, (initial,), viewer=OwnerOutcomes(changed.recipient_lookup)), + "foreign": NotificationAssignment.read( + root, (initial,), viewer=OwnerOutcomes(unchanged.recipient_lookup)), + "sender": NotificationAssignment.read( + root, (initial,), viewer=OwnerOutcomes(lookups["sender"])), + "operator": NotificationAssignment.read(root, (initial,), viewer=AllOutcomes()), + } + assert NotificationAssignment.changed(reads.values(), root) == () + with Coordination(str(root / "coordination.sqlite3")) as store: + assignment, = sealed_cohort_assignments(store, changed.recipient_lookup) + store.assignments.transition_preengagement( + assignment.assignment_id, DeferredAssignment, expected_revision=assignment.revision) + refreshed = NotificationAssignment.changed(reads.values(), root) + assert {name for name, read in reads.items() if read in refreshed} == { + "changed", "sender", "operator"} + + @pytest.mark.parametrize('direct', [False, True]) def test_saved_notification_does_not_mix_owner_drain_readiness(tmp_path, direct): # noqa: F811 from agent_comms.activity import StoppedDrainDiagnostic diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index a24ddaad1a..f187780c34 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -91,3 +91,56 @@ def test_viewer_selects_the_outcomes_of_a_delivery_window(tmp_path, viewer_of, s NotificationAssignment.read(comms.root, (original,), viewer=viewer_of(lookups)), ) assert {item.recipient for item in window.outcomes[(sent.seq, sent.message_id)]} == seen + + +def test_a_viewer_follows_frozen_lookups_through_rename_and_never_into_a_rebirth(tmp_path): + comms = canonical_goal_wire(tmp_path / "bus") + for name in ("sender", "peer-a", "peer-b"): + comms.registry.declare(Thread(name, frozenset({"ci"}), Checkout(tmp_path))) + sent = comms.messaging.send("sender", "#ci", "channel outcome").message + original = comms.bus.bus.delivery(sent.seq) + key = (sent.seq, sent.message_id) + + def seen_by(name: str) -> set[str]: + viewer = OwnerOutcomes.of(comms.registry.require(name)) + read = NotificationAssignment.read(comms.root, (original,), viewer=viewer) + assert read.viewer == viewer + window = MessageNotification.delivery_window(comms.root, comms.registry, (original,), read) + return {item.recipient for item in window.outcomes[key]} + + assert seen_by("peer-a") == {"peer-a"} + comms.registry.rename("peer-a", "renamed-a") + assert seen_by("renamed-a") == {"peer-a"} + comms.registry.remove("peer-b") + comms.registry.declare(Thread("peer-b", frozenset({"ci"}), Checkout(tmp_path))) + assert seen_by("peer-b") == set() + assert seen_by("sender") == {"peer-a", "peer-b"} + + +def test_a_public_root_without_a_coordinator_keeps_its_viewer_and_projects_visible_recipients(tmp_path): + from agent_comms.coordination_database import CoordinationStore + + comms = wire(tmp_path / "bus") + for name in ("sender", "peer-a", "peer-b"): + comms.registry.declare(Thread(name, frozenset({"ci"}), Checkout(tmp_path))) + sent = comms.messaging.send("sender", "#ci", "channel outcome").message + original = comms.bus.bus.delivery(sent.seq) + CoordinationStore.path_in(comms.root).unlink(missing_ok=True) + viewer = OwnerOutcomes.of(comms.registry.require("peer-a")) + read = NotificationAssignment.read(comms.root, (original,), viewer=viewer) + assert (read.sources, read.rows, read.attempts, read.viewer) == ((original,), (), (), viewer) + window = MessageNotification.delivery_window(comms.root, comms.registry, (original,), read) + assert {item.recipient for item in window.outcomes[(sent.seq, sent.message_id)]} == {"peer-a"} + + +def test_a_private_root_without_a_coordinator_still_refuses_notification_reads(private_comms): + from agent_comms.coordination_database import CoordinationStore + from agent_comms.coordination_errors import PublicationActivationBlocked + + for name in ("sender", "peer-a"): + private_comms.registry.declare(Thread(name, frozenset({"ci"}), Checkout(private_comms.root))) + sent = private_comms.messaging.send("sender", "#ci", "channel outcome").message + original = private_comms.bus.bus.delivery(sent.seq) + CoordinationStore.path_in(private_comms.root).unlink() + with pytest.raises(PublicationActivationBlocked): + NotificationAssignment.read(private_comms.root, (original,), viewer=AllOutcomes()) diff --git a/tests/test_thread_presentation.py b/tests/test_thread_presentation.py index 5679155b19..b4957db116 100644 --- a/tests/test_thread_presentation.py +++ b/tests/test_thread_presentation.py @@ -43,7 +43,8 @@ def test_missing_presentation_owns_source_absence_and_retired_status(comms): presentations = comms.views.thread_presentations(("missing",)) absent = presentations.read("missing") assert absent == presentations.view("missing") == NoThreadPresentation() - assert not absent.notification_window.activity_names() + assert absent.published() is absent + assert absent.sent_summaries() == () assert absent.label == "" painted = ThreadStatusModel.known("missing", absent) retired = ThreadStatusModel.retired("missing") diff --git a/tests/test_thread_read_results.py b/tests/test_thread_read_results.py index 3b2c9ed970..04c44f17b4 100644 --- a/tests/test_thread_read_results.py +++ b/tests/test_thread_read_results.py @@ -146,3 +146,94 @@ def count_projection(frame, event, argument): assert calls == 0 if changed_name == 'expiry': assert evidence_calls == 0 + + +def reachable_types(root: object) -> set[str]: + """Class names of every instance reachable from root, not following classes, + modules or functions.""" + import gc + import types + + seen, names, frontier = set(), set(), [root] + skipped = (type, types.ModuleType, types.FunctionType, types.BuiltinFunctionType, + types.MethodType) + while frontier: + item = frontier.pop() + if id(item) in seen or isinstance(item, skipped): + continue + seen.add(id(item)) + names.add(type(item).__name__) + frontier.extend(gc.get_referents(item)) + return names + + +def pickled_types(value: object) -> set[str]: + """Class names of every object the transport pickles for value.""" + import io + import pickle + + names: set[str] = set() + + class Recording(pickle.Pickler): + def reducer_override(self, obj): + names.add(type(obj).__name__) + return NotImplemented + + Recording(io.BytesIO(), protocol=pickle.HIGHEST_PROTOCOL).dump(value) + return names + + +ACQUIRED_EVIDENCE = { + "AcquiredThreadPresentation", "CapturedNotificationWindow", "NotificationAssignmentRead", + "SourceEvidence", "WakeAssignment", "AssignmentActivityEvidence", "HarnessDelivery", + "Delivered", "NotificationReadMark", +} + + +def test_thread_reading_keeps_acquired_evidence_and_publishes_its_projection(private_directory): + import pickle + + from conftest import publish_initial + from test_coordinated_runtime import _root + from agent_comms.thread_presentation import AcquiredThreadPresentation, ThreadPresentation + + _path, _root_id, comms, initial, _people = _root(private_directory, direct=True) + reading = ObserveThreads(frozenset({'beta'})).observation() + status = OpenThreadStatus(lambda flush: None) + status.open('beta') + + def observe() -> ThreadsObserved: + published = [step for step in reading.read(comms, comms.views.revision()) + if type(step) is ThreadsObserved] + assert len(published) == 1 + status.observed(published[0]) + status.rows.flush() + return published[0] + + first = observe() + retained = reading.observed.results['beta'].presentation + assert type(retained) is AcquiredThreadPresentation + assert retained.incoming_references + outgoing = first.results['beta'].presentation + assert type(outgoing) is ThreadPresentation + assert outgoing == retained.published() + assert outgoing.status_display() == retained.status_display() + assert outgoing.read_identity is retained.read_identity + assert outgoing.notifications == retained.notifications + incoming = [item for item in outgoing.notifications if item.message.seq == initial.message.seq] + assert incoming and all(item.message == initial.envelope for item in incoming) + assert not ACQUIRED_EVIDENCE & pickled_types(first) + with pytest.raises(TypeError, match="published"): + pickle.dumps(replace(first, results={'beta': ThreadPresented(retained)})) + assert (ThreadStatusModel.known('beta', outgoing).lines + == ThreadStatusModel.known('beta', retained).lines) + + publish_initial(comms, 'sender', 'beta', 'A later message for beta') + second = observe() + assert second.results['beta'].presentation != outgoing + assert reading.observed.results['beta'].presentation is not retained + # The UI's current and last-published rows hold projections only; the old acquired + # value is reachable from neither. + assert status.rows._published == status.rows.rows + assert not ACQUIRED_EVIDENCE & reachable_types(status.rows) + assert type(status.rows.rows['beta'].presentation) is ThreadPresentation From 5126bc6c695443e2b678873769a31d5cbea7f115 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 10:45:20 -0400 Subject: [PATCH 17/31] Presentations construct from their declared fields inline; forced-batch and null-key controls (#3538) presentation_fields() returned dict[str, object], so expanding it into ThreadPresentation(...) and AcquiredThreadPresentation(...) erased every field's type: 10 new arg-type errors at thread_presentation.py:287/311. It is deleted. - with_sources builds shown = replace(self, notifications=..., read_identity=...) once and constructs AcquiredThreadPresentation from {declared.name: getattr(shown, declared.name) for declared in fields(ThreadPresentation)} plus its three acquisition fields. - AcquiredThreadPresentation.published constructs ThreadPresentation from the same inline mapping over itself, the ThreadStatusDisplay.status_display shape. There is no cast, Any, ignore or copied field roster. - test_outbound_transcript_source::test_a_forced_key_batch_captures_the_same_evidence_through_the_recipient_index: - setup: a second real #team original in the private _root fixture gives at least 4 distinct claim keys, plus a repeated original; - batching: on the fixture's own coordination connection, SQLITE_LIMIT_VARIABLE_NUMBER=6 (restored in finally) forces several key batches, with deduplicated bindings equal to the unlimited batch; - result: the whole capture equals the unlimited read in rows, attempts, evidence, order and viewer, with no duplicated assignment; - index: the traced claim statements' EXPLAIN QUERY PLAN SEARCHes wake_claims through the unique recipient_lookup/wire_seq index found by PRAGMA, never a SCAN of it. - round2 test_declared_table_family: select_keys selects the weight=None child through a None key once, even when the key is repeated, and stops selecting it once the weight is 3.25. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/thread_presentation.py | 16 +++---- tests/round2/test_typed_table.py | 7 +++ tests/test_outbound_transcript_source.py | 60 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/agent_comms/thread_presentation.py b/src/agent_comms/thread_presentation.py index b64d2b1f5f..6f574db035 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -214,10 +214,6 @@ class ThreadPresentation(ThreadStatusDisplay): notifications: tuple[MessageNotification, ...] = () read_identity: TranscriptReadIdentity | None = None - def presentation_fields(self) -> dict[str, object]: - return {declared.name: getattr(self, declared.name) - for declared in fields(ThreadPresentation)} - def published(self) -> ThreadPresentation: """The value a viewer may receive: this presentation itself.""" return self @@ -284,10 +280,11 @@ def with_sources(self, window: TranscriptSourceWindow) -> ThreadPresentation: for source in window.sources for notification in projected.outcomes[source.message.seq, source.message.message_id] ) - return AcquiredThreadPresentation(**(self.presentation_fields() | { - "notifications": incoming, - "read_identity": window.read.identity, - }), notification_window=projected, + shown = replace(self, notifications=incoming, read_identity=window.read.identity) + return AcquiredThreadPresentation( + **{declared.name: getattr(shown, declared.name) + for declared in fields(ThreadPresentation)}, + notification_window=projected, incoming_references=tuple(source.message.reference for source in window.sources), sent_references=tuple(source.message.reference for source in window.sent_sources)) @@ -308,7 +305,8 @@ class AcquiredThreadPresentation(ThreadPresentation): notification_window: NotificationWindow = field(kw_only=True) def published(self) -> ThreadPresentation: - return ThreadPresentation(**self.presentation_fields()) + return ThreadPresentation(**{declared.name: getattr(self, declared.name) + for declared in fields(ThreadPresentation)}) def sent_summaries(self) -> tuple[SentMessageSummary, ...]: """One summary per recent sent source, from this presentation's captured window.""" diff --git a/tests/round2/test_typed_table.py b/tests/round2/test_typed_table.py index e8c6dc5093..ac23d0d4b9 100644 --- a/tests/round2/test_typed_table.py +++ b/tests/round2/test_typed_table.py @@ -179,12 +179,19 @@ def test_declared_table_family(tmp_path): for row in (parent, child): row.insert(db) assert type(row).select(db) == [row] + # Requested keys join under IS, so a null key selects the null row, once. + null_key = {"key": "c", "weight": None} + assert TableChildRow.select_keys(db, (null_key, null_key)) == [child] + assert TableChildRow.select_keys(db, ({"key": "c", "weight": 3.25},)) == [] with pytest.raises(sqlite3.IntegrityError): replace(child, key="bad", parent="missing").insert(db) with pytest.raises(TypeError): replace(parent, key="bool", count=True).insert(db) replace(child, weight=3.25).upsert(db) assert TableChildRow.one(db, key="c").weight == 3.25 + assert TableChildRow.select_keys(db, (null_key,)) == [] + assert TableChildRow.select_keys(db, ({"key": "c", "weight": 3.25},)) == [ + replace(child, weight=3.25)] child.upsert(db) assert TableChildRow.one(db, key="c") == child TableChildRow.update(db, where="key=?", parameters=("c",), enabled=False) diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index aa97ae3d6c..5a285eb9f9 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -337,3 +337,63 @@ def test_missing_bus_cannot_be_an_empty_presentation(tmp_path): # noqa: F811 with pytest.raises(sqlite3.OperationalError) as missing: comms.views.thread_presentations(("beta",)).require("beta") assert missing.value.sqlite_errorcode == sqlite3.SQLITE_CANTOPEN + + +def test_a_forced_key_batch_captures_the_same_evidence_through_the_recipient_index(tmp_path): # noqa: F811 + from conftest import accept_initial, publish_initial + from agent_comms.coordination_tables.assignments import WakeAssignment + from agent_comms.registry_store import RegistryStore + + root, root_id, comms, initial, _people = _root(tmp_path) + database = root / "coordination.sqlite3" + later = publish_initial(comms, "sender", "#team", "A second broadcast for the window") + with Coordination(str(database)) as store: + accept_initial(comms.bus, root_id, later.seq, store) + second = comms.bus.bus.delivery(later.seq) + sources = (initial, second, initial) + keys = tuple({"recipient_lookup": recipient.recipient_lookup, "wire_seq": source.message.seq, + "message_id": source.message.message_id} + for source in sources for recipient in source.recipients) + distinct = tuple(dict.fromkeys(tuple(key.values()) for key in keys)) + assert len(distinct) >= 4 and len(keys) > len(distinct) + ordinary = NotificationAssignment.read(root, sources, viewer=AllOutcomes()) + assert ordinary.rows + privacy = RegistryStore.at(root).privacy() + statements: list[str] = [] + with Coordination(str(database)) as store, store.session.read(): + connection = store.session._connection + unlimited, = WakeAssignment.requested_keys(connection, keys) + previous = connection.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, 6) + connection.set_trace_callback(statements.append) + try: + bound = connection.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER) + batches = tuple(WakeAssignment.requested_keys(connection, keys)) + limited = NotificationAssignment.capture(connection, sources, privacy, AllOutcomes()) + finally: + connection.set_trace_callback(None) + connection.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, previous) + index, = (name for _seq, name, unique, *_rest in connection.execute( + f'PRAGMA index_list("{WakeAssignment.declared_name}")') + if unique and [column for _rank, _cid, column in connection.execute( + f'PRAGMA index_info("{name}")')] == ["recipient_lookup", "wire_seq"]) + claim_statements = [statement for statement in statements + if statement.startswith("WITH requested") and "w AS MATERIALIZED" in statement] + plans = [[row[3] for row in connection.execute(f"EXPLAIN QUERY PLAN {statement}")] + for statement in claim_statements] + + assert len(batches) > 1 + assert all(len(batch.bindings) <= bound for batch in batches) + assert sum(len(batch.bindings) for batch in batches) == 3 * len(distinct) + assert tuple(value for batch in batches for value in batch.bindings) == unlimited.bindings + assert limited.viewer == AllOutcomes() and limited.sources == sources + assert limited.rows == ordinary.rows + assert limited.attempts == ordinary.attempts + assert limited.evidence == ordinary.evidence + assert len({claim.assignment_id for claim, _evidence in limited.rows}) == len(limited.rows) + order = [(-claim.wire_seq, claim.recipient) for claim, _evidence in limited.rows] + assert order == sorted(order) + assert len(claim_statements) >= 2 + for plan in plans: + assert any(detail.startswith(f"SEARCH {WakeAssignment.declared_name} USING INDEX {index}") + for detail in plan), plan + assert not any(detail.startswith(f"SCAN {WakeAssignment.declared_name}") for detail in plan), plan From ef1ae76d42b8d9f1757dc2eb054139f695ce74f1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 10:53:17 -0400 Subject: [PATCH 18/31] Forced-batch control lowers the limit only as far as schema validation allows (#3538) The fixed limit of 6 refused NativeRuntimeTable's schema catalog read inside capture before any claim query ran (root: 1 passed, 1 failed in 3.30 s), so it proved nothing about claim batching. - The limit is the larger of 6 and each schema capture validates (privacy.coordinator_schema() and NativeRuntimeTable), counted as declared object names plus table names, the bindings of objects_of's catalog read. It must still be below the connection's own limit. - Enough real #team originals are published and accepted in the same private fixture for more than limit / 3 distinct claim keys. The count is derived from the recipient count and must fit NotificationWindow.window_limit, and the repeated original stays as the duplicate control. - The batching, equality, ordering, trace and EXPLAIN index assertions are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/test_outbound_transcript_source.py | 32 +++++++++++++++++------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index 5a285eb9f9..4dba1a4890 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -342,28 +342,42 @@ def test_missing_bus_cannot_be_an_empty_presentation(tmp_path): # noqa: F811 def test_a_forced_key_batch_captures_the_same_evidence_through_the_recipient_index(tmp_path): # noqa: F811 from conftest import accept_initial, publish_initial from agent_comms.coordination_tables.assignments import WakeAssignment + from agent_comms.native_runtime_input import NativeRuntimeTable + from agent_comms.notification_evidence import NotificationWindow from agent_comms.registry_store import RegistryStore root, root_id, comms, initial, _people = _root(tmp_path) database = root / "coordination.sqlite3" - later = publish_initial(comms, "sender", "#team", "A second broadcast for the window") - with Coordination(str(database)) as store: - accept_initial(comms.bus, root_id, later.seq, store) - second = comms.bus.bus.delivery(later.seq) - sources = (initial, second, initial) + privacy = RegistryStore.at(root).privacy() + # The lowered limit still admits capture's own schema validation: each catalog read + # binds every declared object name and every table name of its schema. + limit = max(6, *( + len({name for row in schema.schema_rows() for name in row.schema_names}) + + len(schema.schema_rows()) + for schema in (privacy.coordinator_schema(), NativeRuntimeTable))) + key_fields = 3 + originals = limit // (key_fields * len(initial.recipients)) + 1 + assert originals + 1 <= NotificationWindow.window_limit + sources = [initial] + for index in range(originals - 1): + later = publish_initial(comms, "sender", "#team", f"Broadcast {index} for the window") + with Coordination(str(database)) as store: + accept_initial(comms.bus, root_id, later.seq, store) + sources.append(comms.bus.bus.delivery(later.seq)) + sources = (*sources, initial) keys = tuple({"recipient_lookup": recipient.recipient_lookup, "wire_seq": source.message.seq, "message_id": source.message.message_id} for source in sources for recipient in source.recipients) distinct = tuple(dict.fromkeys(tuple(key.values()) for key in keys)) - assert len(distinct) >= 4 and len(keys) > len(distinct) + assert key_fields * len(distinct) > limit and len(keys) > len(distinct) ordinary = NotificationAssignment.read(root, sources, viewer=AllOutcomes()) assert ordinary.rows - privacy = RegistryStore.at(root).privacy() statements: list[str] = [] with Coordination(str(database)) as store, store.session.read(): connection = store.session._connection unlimited, = WakeAssignment.requested_keys(connection, keys) - previous = connection.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, 6) + previous = connection.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, limit) + assert limit < previous connection.set_trace_callback(statements.append) try: bound = connection.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER) @@ -383,7 +397,7 @@ def test_a_forced_key_batch_captures_the_same_evidence_through_the_recipient_ind assert len(batches) > 1 assert all(len(batch.bindings) <= bound for batch in batches) - assert sum(len(batch.bindings) for batch in batches) == 3 * len(distinct) + assert sum(len(batch.bindings) for batch in batches) == key_fields * len(distinct) assert tuple(value for batch in batches for value in batch.bindings) == unlimited.bindings assert limited.viewer == AllOutcomes() and limited.sources == sources assert limited.rows == ordinary.rows From ed6ff38d2c11be72ff9129c16d70637c3fd2f5fd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:25:55 -0400 Subject: [PATCH 19/31] Sent summaries aggregate at their owner; the changed accumulator keeps its type (#3538) The GC stage made two new source-gate regressions: - NotificationAssignment.changed built an untyped `changed = []`. - AcquiredThreadPresentation.sent_summaries imported SentMessageSummary and MessageNotification.summary back from presentation inside the method, a hidden runtime cycle. Now: - `changed: list[NotificationAssignmentRead] = []`. - ThreadPresentation/AcquiredThreadPresentation.sent_summaries are deleted. sent_notifications() returns (reference, outcomes) pairs: () on the public presentation, the captured window's outcomes per sent reference on the acquired one. - SentMessageSummary.for_sources aggregates those pairs with the same-module MessageNotification.summary. ThreadCaller.listing calls it. - thread_presentation keeps only TYPE_CHECKING imports for these types; function-local imports 5 -> 4 (the four remaining predate this stage). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/notification_assignment.py | 2 +- src/agent_comms/presentation.py | 11 ++++++++++- src/agent_comms/thread_presentation.py | 20 +++++++++++--------- src/agent_comms/tool_caller.py | 2 +- tests/test_thread_presentation.py | 2 +- tests/test_tools.py | 5 +++-- 6 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index 00077a2e73..3126e9c8cd 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -237,7 +237,7 @@ def changed(cls, reads: Iterable[NotificationAssignmentRead], root: Path) -> tup by_viewer: dict[OutcomeViewer, list[NotificationAssignmentRead]] = {} for read in reads: by_viewer.setdefault(read.viewer, []).append(read) - changed = [] + changed: list[NotificationAssignmentRead] = [] for viewer, viewed in by_viewer.items(): sources = tuple({source.message.reference: source for read in viewed for source in read.sources}.values()) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 6760fe66ac..c34358c34b 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, field, replace from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Annotated, ClassVar +from typing import TYPE_CHECKING, Annotated, ClassVar, Self from . import relationships from .activity import Activity, ActivityData, ActivityState, ObservedActivity @@ -458,6 +458,15 @@ class SentMessageSummary: reference: MessageReference summary: str + @classmethod + def for_sources( + cls, + sources: Iterable[tuple[MessageReference, Iterable[MessageNotification]]], + ) -> tuple[Self, ...]: + """One summary per sent source, aggregated by MessageNotification.summary.""" + return tuple(cls(reference, MessageNotification.summary(outcomes)) + for reference, outcomes in sources) + class ChannelReadMark(ABC): """A read request carries its own source cut and displayed-message selection.""" diff --git a/src/agent_comms/thread_presentation.py b/src/agent_comms/thread_presentation.py index 6f574db035..37772366ec 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from .message_reference import MessageReference - from .presentation import MessageNotification, SentMessageSummary + from .presentation import MessageNotification from .transcripts import TranscriptReadIdentity, TranscriptSourceWindow from agent_comms.registry_document import RegistrySnapshot from agent_comms.transcripts import Transcripts @@ -218,8 +218,10 @@ def published(self) -> ThreadPresentation: """The value a viewer may receive: this presentation itself.""" return self - def sent_summaries(self) -> tuple[SentMessageSummary, ...]: - """A presentation without acquired sources summarises no sent message.""" + def sent_notifications( + self, + ) -> tuple[tuple[MessageReference, tuple[MessageNotification, ...]], ...]: + """A presentation without acquired sources holds no sent message.""" return () def require(self, name: str) -> ThreadPresentation: @@ -308,13 +310,13 @@ def published(self) -> ThreadPresentation: return ThreadPresentation(**{declared.name: getattr(self, declared.name) for declared in fields(ThreadPresentation)}) - def sent_summaries(self) -> tuple[SentMessageSummary, ...]: - """One summary per recent sent source, from this presentation's captured window.""" - from .presentation import MessageNotification, SentMessageSummary - + def sent_notifications( + self, + ) -> tuple[tuple[MessageReference, tuple[MessageNotification, ...]], ...]: + """Each recent sent source with its outcomes from this presentation's captured window.""" return tuple( - SentMessageSummary(reference, MessageNotification.summary( - self.notification_window.outcomes.get((reference.seq, reference.message_id), ()))) + (reference, + self.notification_window.outcomes.get((reference.seq, reference.message_id), ())) for reference in self.sent_references ) diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index 5fb70fe29b..7ac47bd92e 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -130,7 +130,7 @@ def invoke(self, request: ToolRequest[ThreadCaller], comms: Comms) -> ToolResult def listing(self, comms: Comms, threads: tuple[ThreadRecord, ...]) -> ThreadListing: presentation = comms.views.thread_presentations((self.name,)).require(self.name) - return ThreadListing(threads, presentation.sent_summaries()) + return ThreadListing(threads, SentMessageSummary.for_sources(presentation.sent_notifications())) def outcome_viewer(self, comms: Comms) -> OutcomeViewer: return OwnerOutcomes.of(comms.registry.require(self.name)) diff --git a/tests/test_thread_presentation.py b/tests/test_thread_presentation.py index b4957db116..402e1a9b9c 100644 --- a/tests/test_thread_presentation.py +++ b/tests/test_thread_presentation.py @@ -44,7 +44,7 @@ def test_missing_presentation_owns_source_absence_and_retired_status(comms): absent = presentations.read("missing") assert absent == presentations.view("missing") == NoThreadPresentation() assert absent.published() is absent - assert absent.sent_summaries() == () + assert absent.sent_notifications() == () assert absent.label == "" painted = ThreadStatusModel.known("missing", absent) retired = ThreadStatusModel.retired("missing") diff --git a/tests/test_tools.py b/tests/test_tools.py index 51a891a86f..d752df53d1 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -8,7 +8,7 @@ from agent_comms.channels import AnyChannel from agent_comms.connector_caller import DirectorCaller from agent_comms.errors import ToolRefusedError -from agent_comms.presentation import MessageNotification +from agent_comms.presentation import MessageNotification, SentMessageSummary from agent_comms.threads import Thread from agent_comms.tool_caller import NoThreadCaller, ThreadCaller from agent_comms.tool_declaration import NativeRequestTools @@ -139,7 +139,8 @@ def test_threads_tool_summarizes_each_recent_sent_message_once(self, comms, monk presentation = comms.views.thread_presentations(("sender",)).require("sender") assert all(row.message.reference != sent.reference for row in presentation.notifications) outcomes = presentation.notification_window.outcomes[sent.seq, sent.message_id] - assert [summary.reference for summary in presentation.sent_summaries()] == [sent.reference] + assert [summary.reference for summary in SentMessageSummary.for_sources( + presentation.sent_notifications())] == [sent.reference] result = invoke_tool(comms, "comms_threads", {}, caller=ThreadCaller("sender")) # One row per sent message, never one per recipient, and never the message body. From ffe8bd27c8f70932bce2da1d2b19c85392fe46db Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:00:00 -0400 Subject: [PATCH 20/31] BusRead owns apply once; the reply test reads a read's result from its class header (#3538) OwnerLiveRead carried the only `apply(comms, caller) -> Result` forwarding to read_as; every other BusRead would have needed a copy just to repeat a return annotation for tests/test_tool_replies.py. The method moves to BusRead and the copy is deleted. The test's result_type now resolves a BusRead's result from the first concrete specialisation of a BusRead capability along its MRO (types.get_original_bases, the CliCommand.result_type pattern). Family headers still carrying their type parameter declare nothing; none or two different answers are refused. Other tools keep the apply-annotation path. Controls: RecordedNotificationRead inherits UiRead[NotificationWindow]; bare BusRead/UiRead/OwnerLiveRead refuse; an ordinary tool keeps its annotation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/bus_read.py | 7 +++--- tests/test_tool_replies.py | 49 ++++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/agent_comms/bus_read.py b/src/agent_comms/bus_read.py index a62f2a0494..a1fc6ae2c3 100644 --- a/src/agent_comms/bus_read.py +++ b/src/agent_comms/bus_read.py @@ -62,6 +62,10 @@ class OperatorReader(OperatorOutcomes, BusReader): class BusRead[Result](ToolDeclaration, ABC): """A read of Core's stores, declared once with its arguments; boundaries derive it.""" + def apply(self, comms: Comms, caller: BusReader) -> Result: + """The MCP boundary runs the read under its calling thread's authority.""" + return self.read_as(comms, caller) + @abstractmethod def read_as(self, comms: Comms, reader: BusReader) -> Result: """The read under the reader's authority.""" @@ -88,9 +92,6 @@ class OwnerLiveRead[Result: ToolResult = ToolResult](BusRead[Result], ABC): # The composed ThreadReadTool declares the field; an annotation here would shadow it. thread: str - def apply(self, comms: Comms, caller: BusReader) -> Result: - return self.read_as(comms, caller) - def read_as(self, comms: Comms, reader: BusReader) -> Result: owner = comms.registry.require(reader.subject(self.thread)).incarnation request = OwnerReadRuntimeRequest(thread=owner.name, operation=self) diff --git a/tests/test_tool_replies.py b/tests/test_tool_replies.py index 7e8f779ab1..0a21f3cdf1 100644 --- a/tests/test_tool_replies.py +++ b/tests/test_tool_replies.py @@ -1,18 +1,62 @@ """Every declared tool's reply is the encoding of its declared result record.""" -from typing import get_type_hints +from types import get_original_bases +from typing import TypeVar, get_args, get_origin, get_type_hints +import pytest + +from agent_comms.bus_read import BusRead from agent_comms.comms import Comms from agent_comms.tool_result import ToolResult from agent_comms.tool_request import ToolRequest -def result_type(tool: type[ToolRequest]) -> type: +def bus_read_result(tool: type[BusRead]) -> type: + """The result a bus read declares once: the first concrete specialisation of a BusRead + capability along its MRO. A family header still carrying its type parameter declares + nothing; two different concrete answers contradict each other.""" + declared = tuple(dict.fromkeys( + argument + for declaration in tool.__mro__ + for base in get_original_bases(declaration) + if isinstance(get_origin(base), type) and issubclass(get_origin(base), BusRead) + for argument in get_args(base) + if not isinstance(argument, TypeVar) + )) + if len(declared) != 1: + raise TypeError(f"{tool.__qualname__} declares results {declared!r}, not exactly one") + return declared[0] + + +def apply_result(tool: type[ToolRequest]) -> type: return get_type_hints(tool.apply, localns={ "Comms": Comms, **{param.__name__: param for param in ToolRequest.__type_params__}, })["return"] +def result_type(tool: type[ToolRequest]) -> type: + if issubclass(tool, BusRead): + return bus_read_result(tool) + return apply_result(tool) + + +def test_bus_read_result_follows_an_inherited_specialisation_and_refuses_an_unspecialised_family(): + from agent_comms.bus_read import OwnerLiveRead, UiRead + from agent_comms.notification_evidence import NotificationWindow + from agent_comms.notification_reads import RecordedNotificationRead + + # RecordedNotificationRead restates no header: it inherits NotificationRead's UiRead[...]. + assert bus_read_result(RecordedNotificationRead) is NotificationWindow + for family in (BusRead, UiRead, OwnerLiveRead): + with pytest.raises(TypeError, match="not exactly one"): + bus_read_result(family) + + +def test_an_ordinary_tool_keeps_its_apply_annotation(): + ordinary = next(tool for tool in ToolRequest.members_with(ToolRequest) if not issubclass(tool, BusRead)) + assert result_type(ordinary) is apply_result(ordinary) + + def stores(comms: Comms) -> dict: """registry.json, the todo store and the goal store, byte for byte.""" paths = (comms.root / "registry.json", comms.goals.todos.path, @@ -22,6 +66,5 @@ def stores(comms: Comms) -> dict: def test_every_tool_declares_a_tool_result(): for tool in ToolRequest.members_with(ToolRequest): - from typing import get_args result = result_type(tool) assert all(issubclass(member, ToolResult) for member in (get_args(result) or (result,))), tool.declared_name From 780b9957eadb7bfada312533da7f882f28cc0740 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:05:56 -0400 Subject: [PATCH 21/31] Commander detail is one root-free shared read, comms_commander_unit (#3538) ReadCommanderUnit restated the commander-detail read as a UI-only request: the domain witnesses (commander, unit, orders) and the business body lived on the root-bearing envelope, so MCP had no declaration to serve. - CommanderUnitRead (order_tools.py) is UiRead[CommanderUnitDetail] + ReadTool + ToolRequest, declared_name="comms_commander_unit", with only the held commander/unit incarnations and the card's orders. read_as is the old body verbatim: both incarnations checked against one registry snapshot, the current published frame, the exact card, card.detail with the exact orders, every ToolRefusedError kept. - CommanderUnitDetail is a ToolResult directly; fields, codec schema and pickle bytes are identical to ed6ff38d (probe: pickle 4fc68ac7, schema 74234e98). - ReadOperation(ReadRequest) is the one generic UI envelope: root, request id, the operation as a bare UiRead decoded through its family, and the old ToolRefusedError/WorkBranchError refusals. deferred_reads stays (CoordinationReadUnavailable,). - ReadCommanderUnit is deleted. The real detail test compares the direct operation, the root-bound envelope and invoke_tool on the same card, and its stale-selection controls now vary the operation's witnesses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/commander_hud.py | 2 +- src/agent_comms/order_tools.py | 31 +++++++++++++++++++++++-- src/agent_comms/ui_model/observation.py | 25 +++++++------------- tests/test_commander_notifications.py | 23 +++++++++++++----- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index fdc159ec8b..8c3c041f38 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -142,7 +142,7 @@ class CommanderOrderDetail: @dataclass(frozen=True) -class CommanderUnitDetail: +class CommanderUnitDetail(ToolResult): """A lazy disclosure; activity uses the existing bounded turn wire projection.""" unit: CommanderUnit diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index fd06075b04..a03a7c699b 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -32,9 +32,9 @@ JudgedCall, ToolCallVerdict, ) -from .bus_read import OwnerLiveRead +from .bus_read import BusReader, OwnerLiveRead, UiRead from .commander.vibe_check import OverloadDegradation -from .commander_hud import CommanderHud +from .commander_hud import CommanderFrame, CommanderHud, CommanderUnitDetail from .comms import Comms from .coordinator import Coordination from .errors import ToolRefusedError @@ -528,6 +528,33 @@ def decode_reply(self, raw: object) -> DeliveryOverview: return FieldCodec.decode(DeliveryOverview, raw) +@dataclass(frozen=True, kw_only=True) +class CommanderUnitRead( + UiRead[CommanderUnitDetail], ReadTool, ToolRequest, declared_name="comms_commander_unit", +): + """One unit's card on a commander's published frame, expanded with its orders' bounded + instructions, stories, commits and activity. The held incarnations and orders select the + exact card and witness it; they are never whose authority the read runs under.""" + + label = "Commander unit" + description = ("Expand one unit's card on a commander's published frame: its orders' " + "bounded instructions, stories and commits, and its recent activity. " + "Refuses when either incarnation or the unit's assignment changed.") + commander: Annotated[ThreadIncarnation, *tool_field("Commander incarnation whose frame shows the card")] + unit: Annotated[ThreadIncarnation, *tool_field("Unit incarnation on that card")] + orders: Annotated[tuple[OrderId, ...], *tool_field("The card's orders, exactly as published")] + + def read_as(self, comms: Comms, reader: BusReader) -> CommanderUnitDetail: + snapshot = comms.registry.snapshot() + if not self.commander.current(snapshot) or not self.unit.current(snapshot): + raise ToolRefusedError("The selected commander or unit incarnation is no longer current") + hud = CommanderFrame.current(comms, self.commander.name) + for card in hud.units: + if card.unit == self.unit: + return card.detail(comms, self.commander, self.orders) + raise ToolRefusedError("The selected unit is no longer in this commander's published frame") + + @dataclass(frozen=True, kw_only=True) class CommsOrderStoryTool(ReadTool, ToolRequest): label = "Order story" diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index 75359401f7..3d83de64cf 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -41,9 +41,9 @@ ) from agent_comms.active_route import CommsRoute from agent_comms.activity import ObservedActivity -from agent_comms.bus_read import OwnerLiveRead, OwnerReadRuntimeRequest +from agent_comms.bus_read import OwnerLiveRead, OwnerReadRuntimeRequest, UiRead from agent_comms.child_process import join_retirement -from agent_comms.commander_hud import CommanderFrame, CommanderHud, CommanderUnitDetail +from agent_comms.commander_hud import CommanderFrame, CommanderHud from agent_comms.comms import Comms from agent_comms.coordinator import Coordination from agent_comms.owner_connection import OwnerConnection, OwnerReading, OwnerReplyConnection @@ -56,7 +56,7 @@ StaleRevision, ) from agent_comms.errors import RelationViolationError, ToolRefusedError -from agent_comms.field_codec import FieldCodec, OrderId +from agent_comms.field_codec import FieldCodec from agent_comms.history_views import RetiredViews from agent_comms.notification_evidence import NotificationWindow from agent_comms.notification_read import NotificationsRead, UnreadableNotificationsRead @@ -429,26 +429,17 @@ def answer(self, comms: Comms) -> ReadOutcome: @dataclass(frozen=True) -class ReadCommanderUnit(ReadRequest): - """Expand an exact published card, refusing a reused name or changed assignment.""" +class ReadOperation(ReadRequest): + """The UI's root and request witness around one root-free shared read.""" - commander: ThreadIncarnation - unit: ThreadIncarnation - orders: tuple[OrderId, ...] + operation: UiRead @property def refusal_errors(self) -> tuple[type[Exception], ...]: return (*super().refusal_errors, ToolRefusedError, WorkBranchError) - def read(self, comms: Comms) -> CommanderUnitDetail: - snapshot = comms.registry.snapshot() - if not self.commander.current(snapshot) or not self.unit.current(snapshot): - raise ToolRefusedError("The selected commander or unit incarnation is no longer current") - hud = CommanderFrame.current(comms, self.commander.name) - for card in hud.units: - if card.unit == self.unit: - return card.detail(comms, self.commander, self.orders) - raise ToolRefusedError("The selected unit is no longer in this commander's published frame") + def read(self, comms: Comms) -> object: + return self.operation.read(comms) @dataclass(frozen=True) diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index 99f6925300..8e9cc68126 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -28,7 +28,11 @@ from agent_comms.todos import ProgressRead from agent_comms.ui_model import observation from agent_comms.ui_model.observation import CommanderHudObserved, ObservationService, ObserveCommanderHud -from agent_comms.ui_model.observation import ReadCommanderUnit, ReadRefused +from agent_comms.field_codec import FieldCodec +from agent_comms.order_tools import CommanderUnitRead +from agent_comms.tool_caller import ThreadCaller +from agent_comms.tools import invoke_tool +from agent_comms.ui_model.observation import ReadOperation, ReadRefused from agent_comms.cli_bridges.codex_connection import CodexConnection from agent_comms.standing_rules import ServicePassFailing, StandingRule, UndatedStandingRule from agent_comms.ui_model.unit_state import ( @@ -83,9 +87,16 @@ def test_expanded_unit_reads_original_orders_and_bounded_activity_then_refuses_s hud = beat(comms, states=state_cards(comms, UnitState)) ServicePassStore.at(comms.root).heartbeat_passed((hud.commander,), (hud.commander,)) card = next(card for card in hud.units if card.unit.name == 'worker') - request = ReadCommanderUnit(str(comms.root), hud.commander, card.unit, - tuple(dict.fromkeys(order.order for order in card.orders))) + operation = CommanderUnitRead(commander=hud.commander, unit=card.unit, + orders=tuple(dict.fromkeys(order.order for order in card.orders))) + request = ReadOperation(str(comms.root), operation) + direct = operation.read(comms) detail = pickle.loads(pickle.dumps(request.read(comms))) + # The shared operation, the UI's root-bound envelope and the MCP boundary answer the same + # selected card with the same bounded records. + assert detail == direct + assert invoke_tool(comms, CommanderUnitRead.declared_name, FieldCodec.encode(operation), + caller=ThreadCaller("lead")) == FieldCodec.encode(direct) assert detail.unit == card assert {order.card.problem for order in detail.orders} == {'P1', 'P2'} for order in detail.orders: @@ -97,10 +108,10 @@ def test_expanded_unit_reads_original_orders_and_bounded_activity_then_refuses_s assert 'contents' not in json.dumps(detail.activity) assert (comms.root / 'state-source.jsonl').read_bytes() == original for stale in ( - replace(request, orders=()), + replace(request, operation=replace(operation, orders=())), replace(request, root=str(comms.root / 'other')), - replace(request, unit=replace(card.unit, created_at=card.unit.wire_birth + 1)), - replace(request, commander=card.unit), + replace(request, operation=replace(operation, unit=replace(card.unit, created_at=card.unit.wire_birth + 1))), + replace(request, operation=replace(operation, commander=card.unit)), ): assert isinstance(stale.answer(comms), ReadRefused) checkout = comms.goals.todos.get('P1').order.checked().check.directory From 1c8b7e8eb1b09cb9e6cbd1002dc2defaead859b6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:09:54 -0400 Subject: [PATCH 22/31] The owner's goal is one owner-live read, comms_owner_goal (#3538) The goal snapshot had two owner paths: the runtime command goal_snapshot (a GoalSnapshotResultRuntimeRequest whose change() did nothing) that UI ReadOwnerGoal sent by string, and a second copy of the same snapshot/turn projection inside every goal write. MCP had neither. - OwnerGoalRead (order_tools.py) is OwnerLiveRead[GoalSnapshotReply] + ThreadReadTool, declared_name="comms_owner_goal", with only the inherited thread selector. owned(ctx) is the old projection verbatim: goals.goal_snapshot(ctx.name), then turns.turn_state(ctx.session_id), then GoalSnapshotReply.from_snapshot(snapshot, UpdateMetadata.of(TurnChangedUpdate(turn))). - GoalSnapshotReply is a ToolResult directly (it was a DeclaredReply); pickle and wire bytes are unchanged (probe: 92ead7d5 / 648ee867). - GoalSnapshotResultRuntimeRequest.result awaits change(ctx) and then returns OwnerGoalRead().owned(ctx): the writes keep one copy of the read. - Deleted: GoalSnapshotRuntimeRequest and UI ReadOwnerGoal. Decoding action=goal_snapshot, with or without an operation, is now MalformedRecord. - Tests: the observer-death owner reads derive solely from members_with(OwnerLiveRead); the missing-owner busy test and the runtime family roundtrip use owner_read(comms_owner_goal); a canonical-owner test compares the owner's projection, MCP invoke and the UI envelope for an empty and a set goal, and refuses after the owner stops. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/acp_extension.py | 4 +-- src/agent_comms/order_tools.py | 23 ++++++++++++++ src/agent_comms/runtime_requests.py | 17 ++-------- src/agent_comms/ui_model/observation.py | 10 ------ tests/test_busy_history_loading.py | 6 ++-- tests/test_command_families.py | 4 ++- tests/test_input_delivery_history.py | 42 +++++++++++++++++++++++-- tests/test_observer_death.py | 5 ++- 8 files changed, 76 insertions(+), 35 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 8d953813ec..a2ff850010 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -52,7 +52,7 @@ from .reported_turn import ReportedTurn, ReportedTurnState from .routing import MessageRoute, NoMessageRoute from .thread_identity import ClaimScoped, ThreadIncarnation -from .tool_result import DeclaredReply +from .tool_result import ToolResult from .transcripts import NativeCommittedPrefix, TranscriptCursor, TranscriptPage, TranscriptReadIdentity from .turn_context import ContextManifest from .turn_lease import TurnState @@ -138,7 +138,7 @@ def of(cls, *updates: AgentCommsUpdate) -> UpdateMetadata: @dataclass(frozen=True) -class GoalSnapshotReply(DeclaredReply, GoalSnapshot): +class GoalSnapshotReply(ToolResult, GoalSnapshot): """A goal request's reply: the owner's goal snapshot and its turn state.""" execution: Annotated[GoalExecution, WireName("goalExecution")] diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index a03a7c699b..fbd6cf9a3a 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -32,6 +32,7 @@ JudgedCall, ToolCallVerdict, ) +from .acp_extension import GoalSnapshotReply, TurnChangedUpdate, UpdateMetadata from .bus_read import BusReader, OwnerLiveRead, UiRead from .commander.vibe_check import OverloadDegradation from .commander_hud import CommanderFrame, CommanderHud, CommanderUnitDetail @@ -528,6 +529,28 @@ def decode_reply(self, raw: object) -> DeliveryOverview: return FieldCodec.decode(DeliveryOverview, raw) +@dataclass(frozen=True, kw_only=True) +class OwnerGoalRead(OwnerLiveRead[GoalSnapshotReply], ThreadReadTool, declared_name="comms_owner_goal"): + """A unit's goal snapshot and the turn it was read with, answered by its live native owner + under the owner's own registration and session; the thread selector only finds the owner.""" + + label = "Owner goal" + description = ("Read a unit's current goal snapshot and the turn state its live native " + "owner read with it.") + + async def owned(self, ctx: RuntimeRequestContext) -> GoalSnapshotReply: + snapshot = await Coordination.run_worker(partial( + ctx.server.agent._comms.goals.goal_snapshot, ctx.name, + )) + turn = await Coordination.run_worker(partial( + ctx.server.agent.turns.turn_state, ctx.session_id, + )) + return GoalSnapshotReply.from_snapshot(snapshot, UpdateMetadata.of(TurnChangedUpdate(turn))) + + def decode_reply(self, raw: object) -> GoalSnapshotReply: + return FieldCodec.decode(GoalSnapshotReply, raw) + + @dataclass(frozen=True, kw_only=True) class CommanderUnitRead( UiRead[CommanderUnitDetail], ReadTool, ToolRequest, declared_name="comms_commander_unit", diff --git a/src/agent_comms/runtime_requests.py b/src/agent_comms/runtime_requests.py index 5bc74ffe5e..20565f1eb2 100644 --- a/src/agent_comms/runtime_requests.py +++ b/src/agent_comms/runtime_requests.py @@ -23,8 +23,6 @@ from .acp_extension import ( GoalSnapshotReply, - TurnChangedUpdate, - UpdateMetadata, ) from .agent_event_updates import AcpEventConsumer @@ -41,6 +39,7 @@ from .goal_scope import AllGoals, OneGoal from .goals import Goal from .manual_compaction_bridge import compact_context +from .order_tools import OwnerGoalRead from .owner_connection import NoControllerToken, OwnerConnection, ResultRuntimeRequest, RuntimeRequest, RuntimeRequestContext from .owner_protocol import ClientLine, OwnerAnnouncement, OwnerLine, OwnerReady, OwnerResult from .thread_identity import ThreadIncarnation @@ -292,19 +291,7 @@ async def change(self, ctx: RuntimeRequestContext) -> None: ... async def result(self, ctx: RuntimeRequestContext) -> GoalSnapshotReply: await self.change(ctx) - snapshot = await Coordination.run_worker(partial( - ctx.server.agent._comms.goals.goal_snapshot, ctx.name, - )) - turn = await Coordination.run_worker(partial( - ctx.server.agent.turns.turn_state, ctx.session_id, - )) - return GoalSnapshotReply.from_snapshot(snapshot, UpdateMetadata.of(TurnChangedUpdate(turn))) - - -@dataclass(frozen=True, kw_only=True) -class GoalSnapshotRuntimeRequest(GoalSnapshotResultRuntimeRequest): - async def change(self, ctx: RuntimeRequestContext) -> None: - pass + return await OwnerGoalRead().owned(ctx) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index 3d83de64cf..3b0d8c7f0d 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -35,7 +35,6 @@ from metaclass_registry.mro_dispatch import MroDispatch, handles from agent_comms.acp_extension import ( - GoalSnapshotReply, TranscriptDraftUpdate, TranscriptSnapshotUpdate, ) @@ -56,7 +55,6 @@ StaleRevision, ) from agent_comms.errors import RelationViolationError, ToolRefusedError -from agent_comms.field_codec import FieldCodec from agent_comms.history_views import RetiredViews from agent_comms.notification_evidence import NotificationWindow from agent_comms.notification_read import NotificationsRead, UnreadableNotificationsRead @@ -598,14 +596,6 @@ def read(self, comms: Comms) -> NotificationWindow: return self.operation.read(comms) -@dataclass(frozen=True) -class ReadOwnerGoal(OwnerRead): - """The owner's goal snapshot and the turn it read with it.""" - - async def read(self, owner: OwnerConnection) -> GoalSnapshotReply: - return FieldCodec.decode(GoalSnapshotReply, await owner.request("goal_snapshot")) - - @dataclass(frozen=True) class ReadOwnerOperation(OwnerRead): """The UI's envelope for an owner-live read: the operation's own reply, from the held owner.""" diff --git a/tests/test_busy_history_loading.py b/tests/test_busy_history_loading.py index d303eeb04e..5e0d62ea45 100644 --- a/tests/test_busy_history_loading.py +++ b/tests/test_busy_history_loading.py @@ -188,7 +188,8 @@ async def test_peer_write_failure_retires_once_and_cancels_owner_reads(comms): async def test_owner_reply_uses_the_same_peer_retirement(comms): import multiprocessing - from agent_comms.ui_model.observation import ObservationService, ReadOwnerGoal + from agent_comms.order_tools import OwnerGoalRead + from agent_comms.ui_model.observation import ObservationService, ReadOwnerOperation connection, viewer = multiprocessing.get_context("spawn").Pipe() with connection, viewer: @@ -196,7 +197,8 @@ async def test_owner_reply_uses_the_same_peer_retirement(comms): # Owner reads live in the service's task group, which only its run enters. async with service.tasks: # A real missing-owner refusal still has to be sent through the shared endpoint. - request = ReadOwnerGoal(str(comms.root), UnrecordedThreadIncarnation("missing"), request_id=7) + request = ReadOwnerOperation(str(comms.root), UnrecordedThreadIncarnation("missing"), + request_id=7, operation=OwnerGoalRead()) request.receive(service) asking = service.owner_reads[7] viewer.close() diff --git a/tests/test_command_families.py b/tests/test_command_families.py index cd58a49969..ebcd8eab3d 100644 --- a/tests/test_command_families.py +++ b/tests/test_command_families.py @@ -92,7 +92,7 @@ def parse(args): "tool": "comms_input_delivery", "thread": "", "include_history": False}}, {"action": "dismiss_historical_inputs"}, {"action": "goal_history", "goal_id": None}, - {"action": "goal_snapshot"}, + {"action": "owner_read", "operation": {"tool": "comms_owner_goal", "thread": ""}}, {"action": "edit_goal", "goal_id": None, "expected_revision": 3, "text": "new goal"}, {"action": "update_goal", "goal_id": None, "expected_revision": 3, "status": "paused"}, {"action": "retry_goal", "goal_id": None, "expected_revision": 3}, @@ -123,6 +123,8 @@ def test_runtime_wire_format_round_trip(parameters): {"action": "owner_read", "operation": { "tool": "comms_input_delivery", "include_history": "yes"}}, {"action": "owner_read", "operation": {"tool": "comms_goal_history"}}, + {"action": "goal_snapshot"}, + {"action": "goal_snapshot", "operation": {"tool": "comms_owner_goal", "thread": ""}}, {"action": "goal_history", "goal_id": 2}, {"action": "edit_goal", "goal_id": None, "expected_revision": True, "text": "x"}, {"action": "retry_goal"}, diff --git a/tests/test_input_delivery_history.py b/tests/test_input_delivery_history.py index 9e9acc74dd..e5172282e1 100644 --- a/tests/test_input_delivery_history.py +++ b/tests/test_input_delivery_history.py @@ -7,7 +7,9 @@ import pytest from acp.schema import TextContentBlock -from agent_comms.acp_extension import QueuePromptRequest, encode_request +from agent_comms.acp_extension import ( + GoalSnapshotReply, QueuePromptRequest, TurnChangedUpdate, UpdateMetadata, encode_request, +) from agent_comms.child_process import NoProcess from agent_comms.errors import RelationViolationError from agent_comms.input_key import AcpInputKey, BusInputKey @@ -19,7 +21,9 @@ UnresolvedInput, ) from agent_comms.input_disposition import InputDispositions -from agent_comms.order_tools import InputDeliveryRead +from agent_comms.goal_actions import SetGoalAction +from agent_comms.goals import NoGoal +from agent_comms.order_tools import InputDeliveryRead, OwnerGoalRead from agent_comms.reported_turn import ReportedTurn from agent_comms.thread_status import RunningThreadStatus, StoppedThreadStatus from agent_comms.threads import Thread @@ -153,6 +157,40 @@ async def test_mcp_and_the_ui_read_input_delivery_from_the_live_owner_queue(tmp_ caller=ToolCaller.from_actor("sender")) +async def test_mcp_and_the_ui_read_the_owner_goal_from_the_live_owner(tmp_path): + """The owner answers comms_owner_goal under its own registration and session: MCP and the + UI's owner envelope receive the owner's own projection, its turn metadata included, for an + empty goal and for a set one.""" + async with canonical_delivery_owner(tmp_path) as (comms, agent, _, _): + comms.registry.register(comms.registry.require("alpha"), RunningThreadStatus()) + comms.agents.begin_turn("alpha", TurnId.mint(), "Held native fixture") + owner = comms.registry.require("alpha") + replies = [] + for change in (lambda: None, + lambda: comms.goals.update_goal("alpha", SetGoalAction(text="Ship the read"))): + change() + owned = GoalSnapshotReply.from_snapshot( + comms.goals.goal_snapshot("alpha"), + UpdateMetadata.of(TurnChangedUpdate(agent.turns.turn_state("alpha")))) + async with asyncio.timeout(30): + mcp = await asyncio.to_thread( + OwnerGoalRead.invoke, comms, {"thread": "alpha"}, + caller=ToolCaller.from_actor("sender")) + ui = await ReadOwnerOperation( + str(comms.root), owner.incarnation, OwnerGoalRead()).answer(comms) + assert mcp == FieldCodec.encode(owned) + assert ui.value == owned + assert ui.value.meta.batch.updates == (TurnChangedUpdate(agent.turns.turn_state("alpha")),) + replies.append(owned) + assert replies[0].goal == NoGoal() + assert replies[1].goal.text == "Ship the read" + comms.registry.register(replace(owner, process_identity=NoProcess()), StoppedThreadStatus()) + with pytest.raises(RelationViolationError): + async with asyncio.timeout(30): + await asyncio.to_thread(OwnerGoalRead.invoke, comms, {"thread": "alpha"}, + caller=ToolCaller.from_actor("sender")) + + def test_no_owner_queue_observation_never_dismisses_inputs(tmp_path): comms = wire(tmp_path) comms.registry.declare(Thread('owner', frozenset(), Checkout(tmp_path))) diff --git a/tests/test_observer_death.py b/tests/test_observer_death.py index 5ea1dc10dd..2d90709887 100644 --- a/tests/test_observer_death.py +++ b/tests/test_observer_death.py @@ -19,7 +19,7 @@ from agent_comms.bus_read import BusRead, OwnerLiveRead from agent_comms.notification_reads import LiveNotificationRead from agent_comms.ui_model.observation import ( - ObservationProcess, ObserverDied, ReadNotifications, ReadOwnerGoal, ReadOwnerOperation, StopSidebar, + ObservationProcess, ObserverDied, ReadNotifications, ReadOwnerOperation, StopSidebar, ) from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.threads import Thread @@ -27,8 +27,7 @@ def owner_reads(): - """Every owner read the UI can send: the goal read, and each owner-live operation in its envelope.""" - yield pytest.param(ReadOwnerGoal, id="goal") + """Every owner read the UI can send: each owner-live operation in its one envelope.""" for operation in BusRead.members_with(OwnerLiveRead): yield pytest.param(partial(ReadOwnerOperation, operation=operation()), id=operation.declared_name) From d92c06392e68b43750deb02869d05d5b4073fc39 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:18:19 -0400 Subject: [PATCH 23/31] A transcript page is one root-free shared read, comms_transcript_page (#3538) TranscriptRequest restated five domain arguments (thread, before, after, through, historical_source) on every UI envelope, and RefreshTranscript built a whole UI request just to reach the page read. MCP had no page tool. - TranscriptPageRead (order_tools.py) is UiRead[TranscriptSnapshotUpdate] + ThreadReadTool, declared_name="comms_transcript_page": the inherited thread selector plus before/after/through cursors and historical_source with their old defaults. capture_as(comms, reader) resolves reader.subject(thread) and calls comms.transcripts.capture_page_read once; read_as returns TranscriptSnapshotUpdate.from_read of it. No new renderer, window, cap or wrapper. - TranscriptSnapshotUpdate is AgentCommsUpdate + ToolResult. The update family stays first: same 23 update names, transcript_snapshot discriminator and page/identity/draft fields as ed6ff38d. ACP encode/decode_updates unchanged. - TranscriptRequest holds only `operation: TranscriptPageRead`; page_read is operation.capture_as(comms, OperatorReader()). ReadTranscript, SubscribeTranscript (deferred_reads=(StaleRevision,)) and ReadTranscriptUpdate (previous publication state) stay UI lifecycle boundaries, unexposed. No forwarding properties. - RefreshTranscript keeps its original identity, bind_page_read and None for current content; on change it builds TranscriptPageRead from the identity's requested_name, cursors and historical_source and reads it through the UI's UnreadableHistory conversion. No UI request is constructed. - Callers migrated: transcript_updates replay/subscribe and the busy-history tests. New tests: one operation pages 45 records to the beginning and back, then an empty transcript; each page is identical through the operation, the UI envelope and MCP invoke. The attached-recorded witness test does the same with historical_source. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/acp_extension.py | 2 +- src/agent_comms/order_tools.py | 35 +++++++++++++++++++- src/agent_comms/transcript_updates.py | 5 +-- src/agent_comms/ui_model/observation.py | 29 +++++++--------- tests/test_busy_history_loading.py | 7 ++-- tests/test_historical_views.py | 6 ++++ tests/test_transcript_pages.py | 44 +++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 25 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index a2ff850010..3f730f0353 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -698,7 +698,7 @@ class TranscriptDraftUpdate(AgentCommsUpdate): @dataclass(frozen=True) -class TranscriptSnapshotUpdate(AgentCommsUpdate): +class TranscriptSnapshotUpdate(AgentCommsUpdate, ToolResult): page: TranscriptPage identity: TranscriptReadIdentity draft: DraftRow | None = None diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index fbd6cf9a3a..18fb905876 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -32,7 +32,7 @@ JudgedCall, ToolCallVerdict, ) -from .acp_extension import GoalSnapshotReply, TurnChangedUpdate, UpdateMetadata +from .acp_extension import GoalSnapshotReply, TranscriptSnapshotUpdate, TurnChangedUpdate, UpdateMetadata from .bus_read import BusReader, OwnerLiveRead, UiRead from .commander.vibe_check import OverloadDegradation from .commander_hud import CommanderFrame, CommanderHud, CommanderUnitDetail @@ -61,6 +61,7 @@ tool_field, ) from .tool_result import ToolResult +from .transcripts import TranscriptCursor, TranscriptRead from .transcript_events import SentTranscript from .turn_digests import ( ActivityTurnView, @@ -551,6 +552,38 @@ def decode_reply(self, raw: object) -> GoalSnapshotReply: return FieldCodec.decode(GoalSnapshotReply, raw) +@dataclass(frozen=True, kw_only=True) +class TranscriptPageRead( + UiRead[TranscriptSnapshotUpdate], ThreadReadTool, declared_name="comms_transcript_page", +): + """One bounded transcript page with its current draft and the source witness that read it. + The cursors and historical source select the page; they never choose whose authority the + read runs under.""" + + label = "Transcript page" + description = ("Read one bounded page of a thread's transcript with its current draft. " + "Send a returned cursor as before or after for the next page; " + "historical_source reads an archived source.") + before: Annotated[TranscriptCursor | None, *tool_field("Read the page before this cursor")] = None + after: Annotated[TranscriptCursor | None, *tool_field("Read the page after this cursor")] = None + through: Annotated[TranscriptCursor | None, *tool_field("Read through this cursor")] = None + historical_source: Annotated[ + str | None, *tool_field("Archived source to read instead of current history") + ] = None + + def capture_as(self, comms: Comms, reader: BusReader) -> TranscriptRead: + return comms.transcripts.capture_page_read( + reader.subject(self.thread), + before=self.before, + after=self.after, + through=self.through, + historical_source=self.historical_source, + ) + + def read_as(self, comms: Comms, reader: BusReader) -> TranscriptSnapshotUpdate: + return TranscriptSnapshotUpdate.from_read(self.capture_as(comms, reader)) + + @dataclass(frozen=True, kw_only=True) class CommanderUnitRead( UiRead[CommanderUnitDetail], ReadTool, ToolRequest, declared_name="comms_commander_unit", diff --git a/src/agent_comms/transcript_updates.py b/src/agent_comms/transcript_updates.py index de435ec6a1..cf65b6bf13 100644 --- a/src/agent_comms/transcript_updates.py +++ b/src/agent_comms/transcript_updates.py @@ -18,6 +18,7 @@ from .coordinator import Coordination from .declared_family import DeclaredFamily from .turn_lease import TurnState +from .order_tools import TranscriptPageRead from .ui_model.observation import ObservationProcess, ReadTranscript, SubscribeTranscript if TYPE_CHECKING: @@ -75,13 +76,13 @@ async def observing(self) -> AsyncIterator[ObservationProcess]: async def replay(self, session_id: str, client: SessionUpdateSink) -> None: async with self.observing() as reads: - snapshot = await reads.read(ReadTranscript(str(self.comms.root), session_id)) + snapshot = await reads.read(ReadTranscript(str(self.comms.root), TranscriptPageRead(thread=session_id))) await self._publish(session_id, snapshot, client) @asynccontextmanager async def subscribe(self, session_id: str, client: SessionUpdateSink) -> AsyncIterator[None]: async with self.observing() as reads: - snapshot = await reads.read(SubscribeTranscript(str(self.comms.root), session_id)) + snapshot = await reads.read(SubscribeTranscript(str(self.comms.root), TranscriptPageRead(thread=session_id))) await self._publish(session_id, snapshot, client) yield diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index 3b0d8c7f0d..891fdb49b0 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -40,7 +40,7 @@ ) from agent_comms.active_route import CommsRoute from agent_comms.activity import ObservedActivity -from agent_comms.bus_read import OwnerLiveRead, OwnerReadRuntimeRequest, UiRead +from agent_comms.bus_read import OperatorReader, OwnerLiveRead, OwnerReadRuntimeRequest, UiRead from agent_comms.child_process import join_retirement from agent_comms.commander_hud import CommanderFrame, CommanderHud from agent_comms.comms import Comms @@ -76,7 +76,8 @@ ) from agent_comms.thread_identity import ThreadIncarnation from agent_comms.thread_presentation import AcquiredThreadPresentation, ThreadPresentation -from agent_comms.transcripts import TranscriptCursor, TranscriptReadIdentity +from agent_comms.order_tools import TranscriptPageRead +from agent_comms.transcripts import TranscriptReadIdentity from agent_comms.ui_model.sidebar import ( ChannelRowModel, SidebarModel, @@ -493,25 +494,16 @@ def receive(self, service: ObservationService) -> None: @dataclass(frozen=True) class TranscriptRequest[ResultT](ReadRequest): - """Capture one bounded read; its owner keeps the page and witness together.""" + """The UI's lifecycle around one shared page read: root, request id, how contention is + answered and which result it publishes. The page arguments belong to the operation.""" - thread: str - before: TranscriptCursor | None = None - after: TranscriptCursor | None = None - through: TranscriptCursor | None = None - historical_source: str | None = None + operation: TranscriptPageRead def read(self, comms: Comms) -> ResultT: return UnreadableHistory.outcome(lambda: self.result(self.page_read(comms))).raised() def page_read(self, comms: Comms) -> TranscriptRead: - return comms.transcripts.capture_page_read( - self.thread, - before=self.before, - after=self.after, - through=self.through, - historical_source=self.historical_source, - ) + return self.operation.capture_as(comms, OperatorReader()) @abstractmethod def result(self, read: TranscriptRead) -> ResultT: ... @@ -580,10 +572,11 @@ def read(self, comms: Comms) -> TranscriptSnapshotUpdate | None: ) if read.content_current(): return None - return ReadTranscript( - self.root, identity.requested_name, before=identity.before, after=identity.after, + operation = TranscriptPageRead( + thread=identity.requested_name, before=identity.before, after=identity.after, through=identity.through, historical_source=identity.historical_source, - ).read(comms) + ) + return UnreadableHistory.outcome(lambda: operation.read(comms)).raised() @dataclass(frozen=True) diff --git a/tests/test_busy_history_loading.py b/tests/test_busy_history_loading.py index 5e0d62ea45..abf8aa9ecd 100644 --- a/tests/test_busy_history_loading.py +++ b/tests/test_busy_history_loading.py @@ -12,6 +12,7 @@ from agent_comms.acp_extension import TranscriptSnapshotUpdate from agent_comms.comms import wire from agent_comms.notification_reads import LiveNotificationRead +from agent_comms.order_tools import TranscriptPageRead from agent_comms.ui_model.observation import ( ObservationProcess, ReadBusy, ReadNotifications, ReadTranscript, ) @@ -60,10 +61,10 @@ async def test_busy_source_keeps_original_waiter_and_other_reads_move(tmp_path): original = session.read_bytes() async with ObservationProcess.awaited(comms.route) as process: with held_source(comms): - answer = ReadTranscript(str(comms.root), "saved", request_id=19).answer(comms) + answer = ReadTranscript(str(comms.root), TranscriptPageRead(thread="saved"), request_id=19).answer(comms) assert isinstance(answer, ReadBusy) assert answer.title == "History loading" - waiting = asyncio.create_task(process.read(ReadTranscript(str(comms.root), "saved"))) + waiting = asyncio.create_task(process.read(ReadTranscript(str(comms.root), TranscriptPageRead(thread="saved")))) independent = asyncio.create_task(process.read(ReadNotifications(str(comms.root), LiveNotificationRead(())))) async with asyncio.timeout(15): assert (await independent).outcomes == {} @@ -87,7 +88,7 @@ async def test_cancelled_busy_read_is_removed_without_resubmission(tmp_path): comms, _session = saved_history(tmp_path) async with ObservationProcess.awaited(comms.route) as process: with held_source(comms): - waiting = asyncio.create_task(process.read(ReadTranscript(str(comms.root), "saved"))) + waiting = asyncio.create_task(process.read(ReadTranscript(str(comms.root), TranscriptPageRead(thread="saved")))) await asyncio.sleep(0.15) waiting.cancel() with pytest.raises(asyncio.CancelledError): diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index 84f2c3bc21..9840d8e064 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -270,6 +270,12 @@ def test_recorded_transcript_witness_roundtrip_keeps_original_namespace(tmp_path assert FieldCodec.value_schema(type[TranscriptReadIdentity])["enum"] == list(TranscriptReadIdentity.names()) with pytest.raises(ValueError): FieldCodec.decode(TranscriptReadIdentity, {**FieldCodec.encode(read.identity), "kind": "unregistered"}) + from agent_comms.order_tools import TranscriptPageRead + from test_transcript_pages import shared_page + + recorded = shared_page(live, TranscriptPageRead(thread="alice", historical_source=source.key)) + assert recorded.identity == read.identity + assert any(event.text == "original recorded answer" for event in recorded.page.events) rebound = live.transcripts.bind_page_read("recorded-alice", decoded) assert rebound.current_identity() == decoded assert any(event.text == "original recorded answer" for event in rebound.read().events) diff --git a/tests/test_transcript_pages.py b/tests/test_transcript_pages.py index f9e86fbf87..10e1603f6e 100644 --- a/tests/test_transcript_pages.py +++ b/tests/test_transcript_pages.py @@ -12,6 +12,12 @@ from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.transcripts import NativeTranscriptCursor, WireTranscriptCursor from agent_comms.thread_text import ForkedParent, GivenThreadText +from agent_comms.acp_extension import TranscriptSnapshotUpdate +from agent_comms.field_codec import FieldCodec +from agent_comms.order_tools import TranscriptPageRead +from agent_comms.tool_caller import ThreadCaller +from agent_comms.tools import invoke_tool +from agent_comms.ui_model.observation import ReadTranscript def transcript(path, count): @@ -57,6 +63,44 @@ def test_pages_reach_beginning_and_return_to_tail_without_duplicates(tmp_path): assert seen == [f"Record {i}" for i in range(105)] +def shared_page(comms, operation: TranscriptPageRead) -> TranscriptSnapshotUpdate: + """One page operation served directly, inside the UI's root-bound envelope and through MCP: + all three carry the same page, draft and witness as the domain capture.""" + expected = TranscriptSnapshotUpdate.from_read(comms.transcripts.capture_page_read( + operation.thread, before=operation.before, after=operation.after, + through=operation.through, historical_source=operation.historical_source)) + direct = operation.read(comms) + assert ReadTranscript(str(comms.root), operation).read(comms) == direct == expected + assert invoke_tool(comms, operation.declared_name, FieldCodec.encode(operation), + caller=ThreadCaller(operation.thread)) == FieldCodec.encode(direct) + return direct + + +def test_one_page_operation_pages_alike_for_the_ui_and_mcp(tmp_path): + path = tmp_path / "session.jsonl" + transcript(path, 45) + comms = installed_comms(tmp_path / "wire") + comms.registry.declare(Thread('worker', frozenset(), Checkout(tmp_path), session_file=RegistrySessionLocation.from_path(str(path), comms.root))) + snapshot = shared_page(comms, TranscriptPageRead(thread="worker")) + through = snapshot.page.after + seen = [event.text for event in snapshot.page.events] + pages = 1 + while snapshot.page.has_older: + snapshot = shared_page(comms, TranscriptPageRead(thread="worker", before=snapshot.page.before)) + seen[:0] = [event.text for event in snapshot.page.events] + pages += 1 + assert pages > 1 and seen == [f"Record {i}" for i in range(45)] + seen = [event.text for event in snapshot.page.events] + while snapshot.page.has_newer: + snapshot = shared_page(comms, TranscriptPageRead( + thread="worker", after=snapshot.page.after, through=through)) + seen.extend(event.text for event in snapshot.page.events) + assert seen == [f"Record {i}" for i in range(45)] + comms.registry.declare(Thread('quiet', frozenset(), Checkout(tmp_path))) + empty = shared_page(comms, TranscriptPageRead(thread="quiet")) + assert not empty.page.events and not empty.page.has_older and not empty.page.has_newer + + def test_oversized_message_and_file_identity(tmp_path): path = tmp_path / "session.jsonl" text = "x" * 200000 From d5b7376f88b49521c8646b94fc1d05235733722d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:22:40 -0400 Subject: [PATCH 24/31] MCP answers a commander-detail branch refusal as a failed call; shared-read declaration controls (#3538) comms_commander_unit can raise WorkBranchError when an order's original Git cannot be read. The UI envelope already lists it as a refusal, but the connector's tools/call tuple did not, so over MCP it would escape as a crash instead of a failed tool result. It joins the existing tuple; the real detail test breaks the original .git and asserts invoke_tool raises exactly WorkBranchError there. No other class is added and nothing broadens to ValueError. Declaration controls beside the notification envelope controls: - commander-unit and transcript-page operations encode without root, request_id, previous, owner or identity, decode through bare UiRead, and their ReadOperation envelope round-trips codec, pickle, hash, set and dict; - the owner goal decodes through bare OwnerLiveRead and its owner envelope pickles; - root, request id, unit birth, orders, cursor and archive each make a distinct request; - one operation under two roots keeps the foreign-root refusal; - each public read is cataloged once in ToolRequest and NativeRequestTools, and its result, read off its class header, is exactly CommanderUnitDetail, GoalSnapshotReply or TranscriptSnapshotUpdate. UiRead tools are exactly {commander_unit, transcript_page}, OwnerLiveRead tools exactly {input_delivery, owner_goal}, and no UI lifecycle envelope is a tool. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/connector.py | 3 +- tests/test_commander_notifications.py | 5 ++ tests/test_notification_read_state.py | 90 ++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 453b04611c..841422eebd 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -53,6 +53,7 @@ from .declared_family import DeclaredFamily, UnknownFamilyMember from .errors import RelationViolationError, ToolRefusedError, UnregisteredThreadError from .coordination_errors import CoordinationError, Refused +from .work_branches import WorkBranchError from .field_codec import ( Argument, FieldCodec, GitCommitText, MalformedRecord, PathText, WireName, WireOmitDefault, RecordMember, boundary_decoder, projected, @@ -459,7 +460,7 @@ async def result(self, connector: Connector, caller: ConnectorCaller) -> ToolsCa reply = await asyncio.to_thread(connector.invoke, tool, self.params.arguments, caller) return ToolsCallResult((TextContent(json.dumps(reply)),), False, reply) except ( - ToolRefusedError, RelationViolationError, CoordinationError, + ToolRefusedError, RelationViolationError, CoordinationError, WorkBranchError, Refused, ConnectionError, TimeoutError, ) as error: logging.exception("Connector tool %s failed", self.params.name) diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index 8e9cc68126..dfa20356eb 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -32,6 +32,7 @@ from agent_comms.order_tools import CommanderUnitRead from agent_comms.tool_caller import ThreadCaller from agent_comms.tools import invoke_tool +from agent_comms.work_branches import WorkBranchError from agent_comms.ui_model.observation import ReadOperation, ReadRefused from agent_comms.cli_bridges.codex_connection import CodexConnection from agent_comms.standing_rules import ServicePassFailing, StandingRule, UndatedStandingRule @@ -121,6 +122,10 @@ def test_expanded_unit_reads_original_orders_and_bounded_activity_then_refuses_s try: metadata.write_text('gitdir: ./missing-original-git\n') assert isinstance(request.answer(comms), ReadRefused) + # MCP reaches the same nominal refusal, which the connector answers as a failed call. + with pytest.raises(WorkBranchError): + invoke_tool(comms, CommanderUnitRead.declared_name, FieldCodec.encode(operation), + caller=ThreadCaller("lead")) finally: metadata.unlink() saved.rename(metadata) diff --git a/tests/test_notification_read_state.py b/tests/test_notification_read_state.py index b9d4869435..8e1bca0b2b 100644 --- a/tests/test_notification_read_state.py +++ b/tests/test_notification_read_state.py @@ -15,7 +15,9 @@ from agent_comms.notification_reads import ( LiveNotificationRead, NotificationRead, RecordedNotificationRead, ) -from agent_comms.order_tools import InputDeliveryRead +from agent_comms.order_tools import ( + CommanderUnitRead, InputDeliveryRead, OwnerGoalRead, TranscriptPageRead, +) from agent_comms.private_path import FileRevision from agent_comms.registry_provenance import DeletedThread from agent_comms.repo import Checkout @@ -29,7 +31,16 @@ from agent_comms.tool_declaration import NativeRequestTools from agent_comms.tool_request import ToolRequest from agent_comms.tools import CommsMessageOutcomesTool -from agent_comms.ui_model.observation import ObserveNotifications, ReadNotifications +from agent_comms.acp_extension import GoalSnapshotReply, TranscriptSnapshotUpdate +from agent_comms.bus_read import OwnerLiveRead +from agent_comms.commander_hud import CommanderUnitDetail +from agent_comms.field_codec import OrderId +from agent_comms.thread_identity import RecordedThreadIncarnation +from agent_comms.transcripts import NativeTranscriptCursor +from agent_comms.ui_model.observation import ( + ObserveNotifications, ReadNotifications, ReadOperation, ReadOwnerOperation, + ReadTranscript, ReadTranscriptUpdate, RefreshTranscript, SubscribeTranscript, +) def test_notification_read_states_use_the_request_revision_contract(comms): @@ -259,3 +270,78 @@ def test_notification_rank_overrides_follow_the_mro_without_changing_parent_rank assert ShownToModelProgress(100).rank() == DeliveryRank.position_of(SettledDelivery) assert SendingProgress(100).rank() == DeliveryRank.position_of(ActiveDelivery) assert SelectedConsumedProgress(100).rank() == DeliveryRank.position_of(ActiveDelivery) + + +LEAD, WORKER = RecordedThreadIncarnation("lead", 1.0), RecordedThreadIncarnation("worker", 2.0) +ORDERS = (OrderId.mint(),) +UI_ONLY_FIELDS = {"root", "request_id", "previous", "owner", "identity"} +SHARED_UI_READS = [ + CommanderUnitRead(commander=LEAD, unit=WORKER, orders=ORDERS), + TranscriptPageRead(thread="worker", before=NativeTranscriptCursor("session.jsonl", 7), + historical_source="archive"), +] + + +@pytest.mark.parametrize("operation", SHARED_UI_READS, ids=["commander_unit", "transcript_page"]) +def test_shared_ui_reads_are_root_free_family_records_in_one_generic_envelope(operation): + encoded = FieldCodec.encode(operation) + assert not UI_ONLY_FIELDS & set(encoded) + assert FieldCodec.decode(UiRead, encoded) == operation + request = ReadOperation("root", operation, request_id=4) + assert FieldCodec.decode(ReadOperation, FieldCodec.encode(request)) == request + transported = pickle.loads(pickle.dumps(request)) + assert transported == request and hash(transported) == hash(request) + assert {transported: "held"}[request] == "held" and len({request, transported}) == 1 + + +def test_the_owner_goal_is_a_root_free_owner_live_record_in_its_envelope(): + operation = OwnerGoalRead(thread="worker") + encoded = FieldCodec.encode(operation) + assert not UI_ONLY_FIELDS & set(encoded) + assert FieldCodec.decode(OwnerLiveRead, encoded) == operation + request = ReadOwnerOperation("root", WORKER, operation=operation, request_id=4) + transported = pickle.loads(pickle.dumps(request)) + assert transported == request and hash(transported) == hash(request) + + +def test_witnesses_cursors_and_archive_each_distinguish_a_shared_read_request(): + commander = ReadOperation("root", SHARED_UI_READS[0], request_id=4) + page = ReadOperation("root", SHARED_UI_READS[1], request_id=4) + distinct = ( + replace(commander, root="other-root"), + replace(commander, request_id=5), + replace(commander, operation=replace(SHARED_UI_READS[0], unit=RecordedThreadIncarnation("worker", 3.0))), + replace(commander, operation=replace(SHARED_UI_READS[0], orders=(OrderId.mint(),))), + page, + replace(page, operation=replace(SHARED_UI_READS[1], before=NativeTranscriptCursor("session.jsonl", 8))), + replace(page, operation=replace(SHARED_UI_READS[1], historical_source="other-archive")), + ) + assert len({commander, *distinct}) == 1 + len(distinct) + + +def test_one_shared_read_in_two_root_envelopes_keeps_root_validation(comms): + operation = SHARED_UI_READS[1] + ours = ReadOperation(str(comms.root), operation) + foreign = ReadOperation(str(comms.root.parent / "another-wire"), operation) + assert ours.operation is foreign.operation and ours != foreign + ours.require_root(comms) + with pytest.raises(StaleRevision): + foreign.require_root(comms) + + +def test_the_three_public_reads_are_cataloged_once_with_their_original_results(): + sys.path.insert(0, str(Path(__file__).parent)) + from test_tool_replies import result_type + + tools = ToolRequest.members_with(ToolRequest) + native = NativeRequestTools.members_with(NativeRequestTools) + results = {CommanderUnitRead: CommanderUnitDetail, OwnerGoalRead: GoalSnapshotReply, + TranscriptPageRead: TranscriptSnapshotUpdate} + for operation, result in results.items(): + assert tools.count(operation) == 1 and native.count(operation) == 1 + assert result_type(operation) is result + assert set(ToolRequest.members_with(UiRead)) == {CommanderUnitRead, TranscriptPageRead} + assert set(ToolRequest.members_with(OwnerLiveRead)) == {InputDeliveryRead, OwnerGoalRead} + lifecycle = (ReadOperation, ReadOwnerOperation, ReadNotifications, ReadTranscript, + SubscribeTranscript, ReadTranscriptUpdate, RefreshTranscript) + assert not set(lifecycle) & {*tools, *native} From f675fbe0193ee3c80fc2e93204314a8668b9a259 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:49:08 -0400 Subject: [PATCH 25/31] MCP answers a corrupt transcript as a failed call; tests send tool arguments ToolsCallMessage.result caught CoordinationError but not MalformedRecord, so a newline-complete corrupt native entry escaped tools/call as an HTTP 500 with no id. MalformedRecord joins the existing failure tuple; ToolsCallResult.failed keeps the id and the nominal error. The shared-read controls sent FieldCodec.encode(operation) into invoke_tool, which carries the "tool" selector ToolRequest.invoke refuses; they now send FieldCodec.encode_fields(operation), the declared fields without the tag. Real HTTP regressions in test_connector: corrupt saved session -> failed call equal to the domain's MalformedRecord, then comms_channels still serves; stale cursor after rebinding the session -> failed call equal to TranscriptChanged. The deleted goal_snapshot command gets its own UnknownFamilyMember test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/connector.py | 2 +- tests/test_command_families.py | 18 +++++-- tests/test_commander_notifications.py | 4 +- tests/test_connector.py | 67 ++++++++++++++++++++++++++- tests/test_transcript_pages.py | 2 +- 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 841422eebd..cd724003e7 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -461,7 +461,7 @@ async def result(self, connector: Connector, caller: ConnectorCaller) -> ToolsCa return ToolsCallResult((TextContent(json.dumps(reply)),), False, reply) except ( ToolRefusedError, RelationViolationError, CoordinationError, WorkBranchError, - Refused, ConnectionError, TimeoutError, + MalformedRecord, Refused, ConnectionError, TimeoutError, ) as error: logging.exception("Connector tool %s failed", self.params.name) return ToolsCallResult.failed(error) diff --git a/tests/test_command_families.py b/tests/test_command_families.py index ebcd8eab3d..70f9f231ae 100644 --- a/tests/test_command_families.py +++ b/tests/test_command_families.py @@ -1,5 +1,6 @@ from agent_comms.repo import Checkout -from agent_comms.field_codec import FieldCodec, GoalId +from agent_comms.declared_family import UnknownFamilyMember +from agent_comms.field_codec import FieldCodec, GoalId, MalformedRecord """External contracts and adding commands without editing any consumer catalog.""" @@ -123,8 +124,6 @@ def test_runtime_wire_format_round_trip(parameters): {"action": "owner_read", "operation": { "tool": "comms_input_delivery", "include_history": "yes"}}, {"action": "owner_read", "operation": {"tool": "comms_goal_history"}}, - {"action": "goal_snapshot"}, - {"action": "goal_snapshot", "operation": {"tool": "comms_owner_goal", "thread": ""}}, {"action": "goal_history", "goal_id": 2}, {"action": "edit_goal", "goal_id": None, "expected_revision": True, "text": "x"}, {"action": "retry_goal"}, @@ -145,6 +144,19 @@ def test_invalid_runtime_parameters_use_canonical_decode_and_domain_errors(param assert str(refused.value) == str(declared.value) +@pytest.mark.parametrize("parameters", [ + {"action": "goal_snapshot"}, + {"action": "goal_snapshot", "operation": {"tool": "comms_owner_goal", "thread": ""}}, +], ids=["bare", "with_operation"]) +def test_the_deleted_goal_snapshot_command_is_an_undeclared_member(parameters): + with pytest.raises(UnknownFamilyMember) as undeclared: + RuntimeRequest.member_named("goal_snapshot") + with pytest.raises(MalformedRecord) as refused: + FieldCodec.decode(RuntimeRequest, {"thread": "owner", **parameters}) + assert type(refused.value.__cause__) is UnknownFamilyMember + assert str(refused.value) == str(refused.value.__cause__) == str(undeclared.value) + + @pytest.mark.parametrize( "arguments,result_type,per_root", [ diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index dfa20356eb..d574ad8927 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -96,7 +96,7 @@ def test_expanded_unit_reads_original_orders_and_bounded_activity_then_refuses_s # The shared operation, the UI's root-bound envelope and the MCP boundary answer the same # selected card with the same bounded records. assert detail == direct - assert invoke_tool(comms, CommanderUnitRead.declared_name, FieldCodec.encode(operation), + assert invoke_tool(comms, CommanderUnitRead.declared_name, FieldCodec.encode_fields(operation), caller=ThreadCaller("lead")) == FieldCodec.encode(direct) assert detail.unit == card assert {order.card.problem for order in detail.orders} == {'P1', 'P2'} @@ -124,7 +124,7 @@ def test_expanded_unit_reads_original_orders_and_bounded_activity_then_refuses_s assert isinstance(request.answer(comms), ReadRefused) # MCP reaches the same nominal refusal, which the connector answers as a failed call. with pytest.raises(WorkBranchError): - invoke_tool(comms, CommanderUnitRead.declared_name, FieldCodec.encode(operation), + invoke_tool(comms, CommanderUnitRead.declared_name, FieldCodec.encode_fields(operation), caller=ThreadCaller("lead")) finally: metadata.unlink() diff --git a/tests/test_connector.py b/tests/test_connector.py index 6e8e8ee011..e238f4afab 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -22,6 +22,7 @@ from aiohttp.test_utils import TestClient, TestServer from connector_readiness import ConnectorReadiness +from conftest import transcript_records from agent_comms.bus import Bus from agent_comms.comms import Comms, wire @@ -32,22 +33,27 @@ ConnectorVerb, ServeVerb, RevokeVerb, + ToolsCallResult, ) from agent_comms.connector_caller import CALLER, DirectorCaller from agent_comms.connector_store import ( CALLBACK, CODE_SECONDS, AuthorizationQuery, ConnectorGrants, ConnectorStore, IssuedCode, TrustedBrowser, digest, ) +from agent_comms.coordination_errors import TranscriptChanged from agent_comms.coordinator import Coordination from agent_comms.locked_store import DocumentChange from agent_comms.message_bus import WatchPass from agent_comms.service_passes import ServicePassStore from agent_comms.standing_rules import ServicePassFailing -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, MalformedRecord from agent_comms.host_services import ServiceNotServingError from agent_comms.host_services.connector import ConnectorService from agent_comms.installer.backup import StoreBackup from agent_comms.launch_root import LaunchRoot +from agent_comms.native_transcript import NativeTranscript +from agent_comms.order_tools import TranscriptPageRead +from agent_comms.session_file import RegistrySessionLocation from agent_comms.maintenance_barrier import ( InstallingPhase, MaintenanceBarrier, MaintenanceHeldError, MaintenanceOperator, PausedPhase, ReadyPhase, @@ -269,6 +275,65 @@ async def test_expected_tool_argument_refusal_keeps_its_mcp_reply(client, comms, assert json.loads(reply["result"]["content"][0]["text"])["error"] +async def tool_call(client: TestClient, token: str, call_id: int, name: str, arguments: dict): + response = await client.post("/mcp", headers={"Authorization": f"Bearer {token}"}, json={ + "jsonrpc": "2.0", "id": call_id, "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + }) + return response.status, await response.json() + + +def bind_worker_session(comms: Comms, path: Path) -> None: + worker = comms.registry.require("worker") + comms.registry.declare(replace( + worker, session_file=RegistrySessionLocation.from_path(str(path), comms.root))) + + +def saved_session(path: Path, *entries: bytes) -> Path: + header = json.dumps(transcript_records.session(id="session").to_wire()).encode() + path.write_bytes(b"".join((header + b"\n", *entries))) + return path + + +async def test_an_unreadable_transcript_is_a_failed_call_and_the_connector_keeps_serving( + client, comms, tmp_path, +): + """A corrupt complete native entry escaped tools/call as an HTTP 500 with no id.""" + path = saved_session(tmp_path / "corrupt.jsonl", b'{"type":"message"\n') + bind_worker_session(comms, path) + with pytest.raises(MalformedRecord) as unreadable: + tuple(NativeTranscript(path).tail()) + token = await approved_token(client, comms) + status, reply = await tool_call( + client, token, 41, TranscriptPageRead.declared_name, {"thread": "worker"}) + assert (status, reply["id"]) == (200, 41) + # The domain's own MalformedRecord, with its JSONDecodeError cause, is the failed result. + assert reply["result"] == FieldCodec.encode(ToolsCallResult.failed(unreadable.value)) + assert reply["result"]["isError"] is True and "structuredContent" not in reply["result"] + assert "JSONDecodeError" in reply["result"]["content"][0]["text"] + status, served = await tool_call(client, token, 42, CommsChannelsTool.declared_name, {}) + assert (status, served["id"], served["result"]["isError"]) == (200, 42, False) + + +async def test_a_stale_transcript_cursor_is_a_failed_call_with_its_coordination_cause( + client, comms, tmp_path, +): + message = transcript_records.message({"role": "assistant", "content": [{"type": "text", "text": "held"}]}) + bind_worker_session(comms, saved_session( + tmp_path / "session.jsonl", json.dumps(message).encode() + b"\n")) + page = comms.transcripts.capture_page_read("worker").read() + bind_worker_session(comms, saved_session(tmp_path / "other.jsonl")) + stale = TranscriptPageRead(thread="worker", before=page.before) + with pytest.raises(TranscriptChanged) as changed: + stale.read(comms) + token = await approved_token(client, comms) + status, reply = await tool_call( + client, token, 43, stale.declared_name, FieldCodec.encode_fields(stale)) + assert (status, reply["id"]) == (200, 43) + assert reply["result"] == FieldCodec.encode(ToolsCallResult.failed(changed.value)) + assert "structuredContent" not in reply["result"] + + async def test_request_log_names_the_route_and_never_its_values(client, store, root, comms, caplog): """Serve actual GET/POST approval secrets, with aiohttp's raw access log off.""" login = await ClientLogin.register(client) diff --git a/tests/test_transcript_pages.py b/tests/test_transcript_pages.py index 10e1603f6e..38fa13ed2d 100644 --- a/tests/test_transcript_pages.py +++ b/tests/test_transcript_pages.py @@ -71,7 +71,7 @@ def shared_page(comms, operation: TranscriptPageRead) -> TranscriptSnapshotUpdat through=operation.through, historical_source=operation.historical_source)) direct = operation.read(comms) assert ReadTranscript(str(comms.root), operation).read(comms) == direct == expected - assert invoke_tool(comms, operation.declared_name, FieldCodec.encode(operation), + assert invoke_tool(comms, operation.declared_name, FieldCodec.encode_fields(operation), caller=ThreadCaller(operation.thread)) == FieldCodec.encode(direct) return direct From 701b58768901b112df6501169647043c3c5764f4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 13:17:47 -0400 Subject: [PATCH 26/31] TranscriptSnapshotUpdate.from_read declares its read and result The producer took an unannotated read and returned Any, so the typed TranscriptPageRead.read_as (order_tools.py:584) failed the hosted type gate through it. from_read now takes TranscriptRead and returns Self; the body, fields and wire bytes are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/acp_extension.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 3f730f0353..666fec1122 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -12,7 +12,7 @@ from abc import abstractmethod from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, Annotated, Callable, ClassVar +from typing import TYPE_CHECKING, Annotated, Callable, ClassVar, Self from .acp_failure import ( @@ -53,7 +53,9 @@ from .routing import MessageRoute, NoMessageRoute from .thread_identity import ClaimScoped, ThreadIncarnation from .tool_result import ToolResult -from .transcripts import NativeCommittedPrefix, TranscriptCursor, TranscriptPage, TranscriptReadIdentity +from .transcripts import ( + NativeCommittedPrefix, TranscriptCursor, TranscriptPage, TranscriptRead, TranscriptReadIdentity, +) from .turn_context import ContextManifest from .turn_lease import TurnState from .working_memory_labels import ModelLabel @@ -704,7 +706,7 @@ class TranscriptSnapshotUpdate(AgentCommsUpdate, ToolResult): draft: DraftRow | None = None @classmethod - def from_read(cls, read): + def from_read(cls, read: TranscriptRead) -> Self: page, draft = read.read_snapshot() return cls(page, read.identity, draft.external_row()) From 5ed8ac75a48a5b3a935d72194d08be9b56fd158b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 13:29:49 -0400 Subject: [PATCH 27/31] debt ratchet: introduced occurrences are the one clean-lines gate; fields extracted into a held class move CleanChangedLines already decides which occurrences a change writes, but Comparison.failed also failed its raw per-file growth through Measure.blocks, so a moved occurrence that introduced nothing still failed on its destination's count. CleanChangedLines.blocks is now False; every introduced occurrence still fails through Comparison.failed, including one hidden by an unrelated deletion. Measure.grown is the growth predicate blocks and the site-locating step share, so raw counts, deltas and found sites are unchanged. FieldExtraction proves one more transfer from the before/head AST: an old class present at both revisions newly holds, in one typed field, one class absent from the base that no other old class holds, and each field it lost is declared once there with the same name, type (typing.Annotated metadata aside) and default. Only occurrences inside that field declaration are credited, each spending one departure. The holder is resolved through DecoratorBinding (now also DecoratorBinding.named / module_of), refused when the name is bound twice or the module star-imports. TranscriptRequest's four cursor/archive fields moved into TranscriptPageRead are that case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/guards/test_debt_ratchet.py | 49 ++++++++ tools/debt_ratchet.py | 199 ++++++++++++++++++++++++++++-- 2 files changed, 236 insertions(+), 12 deletions(-) diff --git a/tests/guards/test_debt_ratchet.py b/tests/guards/test_debt_ratchet.py index 673b1e5f4d..d92aa0a058 100644 --- a/tests/guards/test_debt_ratchet.py +++ b/tests/guards/test_debt_ratchet.py @@ -1163,6 +1163,7 @@ def test_an_absence_moved_with_its_class_to_another_module_is_not_written(repo): base = repo.commit({"attempts.py": holder, "executions.py": "VALUE = 1\n"}) moved = repo.commit({"attempts.py": "VALUE = 2\n", "executions.py": holder}) status, report = repo.compare(base, moved, NoneAbsence) + assert status == 0 assert report["introduced"][NoneAbsence.at(f"{repo.root}/executions.py").text] == [] deleted = repo.commit({"attempts.py": holder, "executions.py": None}) moved_out = repo.commit({"attempts.py": None, "owners.py": holder + "\n\n" + other}) @@ -1171,6 +1172,54 @@ def test_an_absence_moved_with_its_class_to_another_module_is_not_written(repo): assert report["introduced"][NoneAbsence.at(f"{repo.root}/owners.py").text] == [8] +EXTRACTED_FROM = ( + "class Request:\n thread: str\n before: int | None = None\n archive: str | None = None\n" + "\n\nclass Other:\n name: str\n" +) +HOLDER = "from .pages import Page\n\n\nclass Request:\n thread: str\n operation: Page\n" +OTHER = "\n\nclass Other:\n name: str\n" +PAGE = ( + "from typing import Annotated\n\n\nclass Page:\n" + " before: Annotated[int | None, \"Read before this cursor\"] = None\n" + " archive: str | None = None\n" +) + + +@pytest.mark.parametrize( + ("holder", "pages", "written"), + [ + (HOLDER + OTHER, PAGE, 0), + (HOLDER.replace(" operation", " before: int | None = None\n operation") + OTHER, PAGE, 1), + (HOLDER.replace("operation: Page", "operation: object") + OTHER, PAGE, 2), + (HOLDER + OTHER + " page: Page\n", PAGE, 2), + (HOLDER + "\n\nPage = dict\n" + OTHER, PAGE, 2), + (HOLDER.replace(".pages", ".elsewhere") + OTHER, PAGE, 2), + (HOLDER + OTHER, PAGE.replace("before:", "earlier:"), 1), + (HOLDER + OTHER, PAGE.replace("int | None", "float | None"), 1), + (HOLDER + OTHER, PAGE.replace("archive: str | None = None", "archive: str | None = ''"), 1), + (HOLDER + OTHER, PAGE + " limit: int | None = None\n", 1), + (HOLDER + OTHER, PAGE + "\n def empty(self):\n return self.before is None\n", 1), + (HOLDER.replace("import Page", "import Copy, Page").replace( + "operation: Page", "operation: Page\n copy: Copy") + OTHER, + PAGE + "\n\nclass Copy:\n before: int | None = None\n", 3), + ], + ids=["extracted", "retained", "unheld", "second_owner", "shadowed", "unresolved", "renamed", + "retyped", "redefaulted", "extra_field", "method", "copied_twice"], +) +def test_fields_extracted_into_the_one_new_held_class_are_moved_not_written(repo, holder, pages, written): + """An old class that now holds one new class in a typed field carries each field it lost + into that class unchanged; anything the proof does not cover is still written.""" + base = repo.commit({"holder.py": EXTRACTED_FROM, "pages.py": "VALUE = 1\n"}) + head = repo.commit({"holder.py": holder, "pages.py": pages}) + status, report = repo.compare(base, head, NoneAbsence) + source, destination = (NoneAbsence.at(f"{repo.root}/{name}").text for name in ("holder.py", "pages.py")) + assert status == int(written > 0) + assert len(report["introduced"][destination]) == written + assert report["introduced"][source] == [] + assert report["base"][source] == 2 and report["head"][destination] >= 2 + assert report["delta"][destination] == report["head"][destination] + + def test_git_context_configuration_cannot_mark_unchanged_lines_as_new(repo): repo.git("config", "diff.interHunkContext", "3") base = repo.commit({"names.py": "first = 1\nold_receipt = 1\nlast = 1\n"}) diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index b9c7470d8e..24e3d3859b 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -182,9 +182,14 @@ def at(cls, path: str) -> FileCount: return FileCount(cls, path) @classmethod - def blocks(cls, delta: Counts) -> bool: + def grown(cls, delta: Counts) -> bool: + """Whether any of this measure's counts grew.""" return any(value > 0 for key, value in delta.items() if cls.owns(key)) + @classmethod + def blocks(cls, delta: Counts) -> bool: + return cls.grown(delta) + @classmethod def grew(cls, key: MeasureCount, grown: int) -> str: """The line reporting one count's growth; the count words it.""" @@ -1077,6 +1082,151 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: self.visit_named(node) +type SitePlace = tuple[str, int, int, int, int] + + +@dataclass(frozen=True) +class DeclaredClass: + """One top-level class of one changed module at one revision, read for its fields.""" + + path: str + tree: ast.Module + node: ast.ClassDef + + @classmethod + def top(cls, path: str, tree: ast.Module) -> dict[str, DeclaredClass]: + """The module's top-level classes; a name declared twice declares neither.""" + named = Counter(node.name for node in tree.body if isinstance(node, ast.ClassDef)) + return {node.name: cls(path, tree, node) for node in tree.body + if isinstance(node, ast.ClassDef) and named[node.name] == 1} + + @cached_property + def fields(self) -> dict[str, ast.AnnAssign]: + """The class body's annotated names; a name annotated twice is no field of it.""" + written = [statement for statement in self.node.body + if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name)] + named = Counter(typing.cast(ast.Name, statement.target).id for statement in written) + return {name: statement for statement in written + if named[name := typing.cast(ast.Name, statement.target).id] == 1} + + def declared(self, field: str) -> tuple[str, str]: + """The field's type without typing.Annotated metadata, and its default, structurally.""" + statement = self.fields[field] + annotated = DecoratorBinding.named("typing", "Annotated", self.tree, self.path) + match statement.annotation: + case ast.Subscript(value=value, slice=ast.Tuple(elts=[declared, *_])) if annotated.marks(value): + annotation: ast.expr = declared + case annotation: + pass + return ast.dump(annotation), ast.dump(statement.value) if statement.value else "" + + def binds(self, name: str) -> bool: + """Whether the class body still binds ``name`` in any form.""" + for statement in self.node.body: + match statement: + case ast.AnnAssign(target=ast.Name(id=bound)) | ast.FunctionDef(name=bound) | ( + ast.AsyncFunctionDef(name=bound)) | ast.ClassDef(name=bound) if bound == name: + return True + case ast.Assign(targets=targets) if any( + isinstance(target, ast.Name) and target.id == name for target in targets): + return True + return False + + def holding(self, held: DeclaredClass, base: DeclaredClass) -> tuple[str, ...]: + """The fields this class newly declares whose type is ``held``.""" + binding = DecoratorBinding.named( + ".".join(DecoratorBinding.module_of(held.path)), held.node.name, self.tree, self.path) + return tuple(name for name, statement in self.fields.items() + if name not in base.fields and binding.marks(statement.annotation) + and self.bound_once(ast.unparse(statement.annotation).split(".")[0])) + + def bound_once(self, name: str) -> bool: + """The module binds ``name`` once and imports nothing by star, so no other binding + can shadow it.""" + bindings = 0 + for node in ast.walk(self.tree): + match node: + case ast.alias(name="*"): + return False + case ast.alias(name=imported, asname=alias): + bindings += (alias or imported.split(".")[0]) == name + case ast.ClassDef(name=declared) | ast.FunctionDef(name=declared) | ast.AsyncFunctionDef( + name=declared): + bindings += declared == name + case ast.Name(id=declared, ctx=ast.Store()): + bindings += declared == name + return bindings == 1 + + +class FieldExtraction: + """Fields a class gave up to the one new class it now holds in a typed field. + + The held field's annotation is the provenance edge: an old class present at both + revisions newly declares one field typed as one class absent from the base, and no other + old class holds that class. Each field the old class lost, declared once in the new class + under the same name with the same type (typing.Annotated metadata aside) and the same + default, carries its occurrences there. Only occurrences inside that one proved field + declaration are credited, each spending one occurrence that left the old class, so a + retained field, an extra field, a method or a second copy is still written. + """ + + @staticmethod + def place(path: str, node: ast.AST) -> SitePlace: + return (path, node.lineno, node.col_offset, + typing.cast(int, node.end_lineno), typing.cast(int, node.end_col_offset)) + + @classmethod + def credit(cls, measure: type[CleanChangedLines], sources: list[ChangedSource], + left: list[ChangedSource], departed: Counter[tuple[str, str]]) -> Counter[SitePlace]: + """Each proved destination occurrence's credit; ``departed`` spends what it pays.""" + existing = {name for source in [*sources, *left] for name in DeclaredClass.top( + source.path, source.base_tree)} + new = [held for source in sources + for name, held in DeclaredClass.top(source.path, source.tree).items() + if name not in existing] + edges = [] + for source in sources: + before = DeclaredClass.top(source.path, source.base_tree) + for name, owner in DeclaredClass.top(source.path, source.tree).items(): + if name in before: + for held in new: + edges.extend((before[name], owner, held) for _ in owner.holding(held, before[name])) + owners = Counter((base.path, base.node.name) for base, _, _ in edges) + holders = Counter((held.path, held.node.name) for _, _, held in edges) + credited: Counter[SitePlace] = Counter() + for base, owner, held in edges: + if owners[base.path, base.node.name] == holders[held.path, held.node.name] == 1: + for field in sorted(base.fields): + if (not owner.binds(field) and field in held.fields + and base.declared(field) == held.declared(field)): + cls.transfer(measure, base, held, field, departed, credited) + return credited + + @staticmethod + def transfer(measure: type[CleanChangedLines], base: DeclaredClass, held: DeclaredClass, + field: str, departed: Counter[tuple[str, str]], credited: Counter[SitePlace]) -> None: + """One field's occurrences, paid one for one by those that left the old declaration.""" + def occurrences(declared: DeclaredClass) -> Iterator[tuple[ast.AST, str, int]]: + inside = {id(node) for node in ast.walk(declared.fields[field])} + scopes = measure.scopes(declared.tree) + for node in measure.sites(declared.tree, declared.path): + if id(node) in inside and (count := measure.occurrences(node)): + yield node, scopes[node], count + + scope = f".{base.node.name}" + gave: Counter[str] = Counter() + for node, within, count in occurrences(base): + if within == scope: + gave[ast.unparse(node)] += count + for text in gave: + gave[text] = min(gave[text], departed[scope, text]) + for node, _, count in occurrences(held): + paid = min(count, gave[ast.unparse(node)]) + gave[ast.unparse(node)] -= paid + departed[scope, ast.unparse(node)] -= paid + credited[FieldExtraction.place(held.path, node)] += paid + + class CleanChangedLines(OccurrenceMeasure): """No occurrence may be written, even when another occurrence is deleted. @@ -1103,8 +1253,9 @@ def introduced( gone = measure.identities(source.base_tree, source.path) gone.subtract(measure.identities(source.tree, source.path)) departed.update(+gone) + extracted = FieldExtraction.credit(measure, sources, left, departed) for source in sources: - found[measure.at(source.path)] = measure.changed_sites(source, departed) + found[measure.at(source.path)] = measure.changed_sites(source, departed, extracted) return found @staticmethod @@ -1125,9 +1276,19 @@ def identities(cls, tree: ast.AST, filename: str) -> Counter[tuple[str, str]]: return found @classmethod - def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]]) -> list[int]: + def blocks(cls, delta: Counts) -> bool: + """Never on raw growth: Comparison.failed fails every introduced occurrence on its own, + and a moved or extracted occurrence grows its destination's raw count while writing + nothing. A new occurrence still fails through ``introduced`` even when an unrelated + deletion offsets the totals.""" + return False + + @classmethod + def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]], + extracted: Counter[SitePlace]) -> list[int]: """The first changed line of each occurrence written there that the base lacks and no - changed module gave up; a moved occurrence spends the departure it came from.""" + changed module gave up; a moved occurrence spends the departure it came from, and an + occurrence in a proved field extraction spends its credit.""" scopes = cls.scopes(source.tree) unwritten = cls.identities(source.tree, source.path) unwritten.subtract(cls.identities(source.base_tree, source.path)) @@ -1140,7 +1301,10 @@ def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]] unwritten[identity] -= arrived moved = min(arrived, departed[identity]) departed[identity] -= moved - sites.extend([min(touched)] * (arrived - moved)) + place = FieldExtraction.place(source.path, node) + carried = min(arrived - moved, extracted[place]) + extracted[place] -= carried + sites.extend([min(touched)] * (arrived - moved - carried)) return sites @@ -1241,16 +1405,18 @@ def lines(cls, source: bytes, filename: str) -> list[int]: return sorted([*super().lines(source, filename), *cls.prose_lines(module)]) @classmethod - def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]]) -> list[int]: + def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]], + extracted: Counter[SitePlace]) -> list[int]: prose = cls.prose_lines(source) & source.lines - return [*super().changed_sites(source, departed), *prose] + return [*super().changed_sites(source, departed, extracted), *prose] @dataclass(frozen=True) class DecoratorBinding: - """The spellings one module binds to a declared decorator, read from its imports alone. + """The spellings one module binds to a declared name (a decorator, a field factory, a + class), read from its imports alone. - A decorator of the same name declared or imported from anywhere else marks nothing. + A name of the same spelling declared or imported from anywhere else marks nothing. """ names: frozenset[str] @@ -1258,9 +1424,18 @@ class DecoratorBinding: @classmethod def of(cls, decorator: typing.Callable[..., object], tree: ast.AST, filename: str) -> DecoratorBinding: - owner, name = decorator.__module__, decorator.__name__ + return cls.named(decorator.__module__, decorator.__name__, tree, filename) + + @staticmethod + def module_of(filename: str) -> tuple[str, ...]: + """The dotted module a source path imports as; ``src`` is the import root.""" parts = Path(filename).with_suffix("").parts - module = parts[1:] if parts[:1] == ("src",) else parts + return parts[1:] if parts[:1] == ("src",) else parts + + @classmethod + def named(cls, owner: str, name: str, tree: ast.AST, filename: str) -> DecoratorBinding: + """The spellings binding ``owner.name`` in the module at ``filename``.""" + module = cls.module_of(filename) package = module[:-1] names = {name} if ".".join(module) == owner else set() for node in ast.walk(tree): @@ -2880,7 +3055,7 @@ def compare(repo: Path, base: str, head: str, root: str, workers: int, changed.paired(after), introduced, {}) # Only a grown measure is read for its sites, so a clean change pays for none. grown = [measure for measure in LocatedSource.members_with(LocatedSource) - if measure in measured and measure.blocks(comparison.delta)] + if measure in measured and measure.grown(comparison.delta)] if not grown: return comparison sources = GitMeasure.sources(repo, head, paths & python_paths(repo, head, root)) From 908db01d6154c318a02eb0b6640951f78b6676d6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 13:44:57 -0400 Subject: [PATCH 28/31] debt ratchet: one lexical binding owner; a missing introduced report fails DecoratorBinding.bindings(tree, scope) reads ScopeNames' lexical scopes as the one binding owner, and declarations() proves a name only when its scope binds it exactly once, by an unconditional import, class, function or annotated assignment in that body, with no star import. named()/of() (boundary_decoder, dataclasses.field), held-class exports, field uniqueness, source disappearance and the typing.Annotated strip all read it, so a nested-only import, a rebound Annotated or export, a field reassigned after its annotation, or a same-name binding hidden in unpacking no longer earns transfer credit. DeclaredClass.bound_once and its whole-tree census are gone. Measure.missing names counts whose required evidence a comparison lacks; CleanChangedLines.missing is each positive head count with no introduced report. Comparison fails on any, reports them as "missing" and prints them; an explicit empty introduced list stays the measured zero-new-sites answer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/guards/test_debt_ratchet.py | 34 +++++- tools/debt_ratchet.py | 188 ++++++++++++++++++++---------- 2 files changed, 161 insertions(+), 61 deletions(-) diff --git a/tests/guards/test_debt_ratchet.py b/tests/guards/test_debt_ratchet.py index d92aa0a058..d573e8c32d 100644 --- a/tests/guards/test_debt_ratchet.py +++ b/tests/guards/test_debt_ratchet.py @@ -74,11 +74,29 @@ def test_measure_owner_rejects_a_new_absence() -> None: name = NoneAbsence.at("tools/example.py") - result = Comparison("tools", "base", "head", (), {name: 0}, {name: 1}, {}, {}) + result = Comparison("tools", "base", "head", (), {name: 0}, {name: 1}, {name: [1]}, {}) assert result.delta[name] == 1 assert result.failed +GROWN_ABSENCE = NoneAbsence.at("tools/example.py") + + +@pytest.mark.parametrize( + ("introduced", "failed", "missing"), + [({}, True, [GROWN_ABSENCE]), ({GROWN_ABSENCE: []}, False, []), ({GROWN_ABSENCE: [1]}, True, [])], + ids=["unreported", "reported_none_new", "reported_new"], +) +def test_clean_lines_growth_fails_only_on_a_new_site_or_a_missing_report(introduced, failed, missing): + """Raw growth alone is a move; an explicit empty report says nothing is new, while an + absent report proves nothing.""" + result = Comparison("tools", "base", "head", (), {GROWN_ABSENCE: 0}, {GROWN_ABSENCE: 1}, introduced, {}) + assert result.delta[GROWN_ABSENCE] == 1 + assert result.failed is failed + assert result.missing == missing + assert FieldCodec.project(result, "report")["missing"] == [key.text for key in missing] + + @pytest.mark.parametrize( ("source", "expected"), [ @@ -1202,9 +1220,21 @@ def test_an_absence_moved_with_its_class_to_another_module_is_not_written(repo): (HOLDER.replace("import Page", "import Copy, Page").replace( "operation: Page", "operation: Page\n copy: Copy") + OTHER, PAGE + "\n\nclass Copy:\n before: int | None = None\n", 3), + (HOLDER.replace("import Page", "import Page as Held").replace("operation: Page", "operation: Held") + + OTHER, PAGE.replace("import Annotated", "import Annotated as Meta").replace("Annotated[", "Meta["), 0), + (HOLDER.replace("from .pages import Page\n", "def load():\n from .pages import Page\n") + OTHER, + PAGE, 2), + (HOLDER + "\n\ndef make():\n Page = dict\n return Page\n" + OTHER, PAGE, 0), + (HOLDER + OTHER, PAGE + "\n\nAnnotated = list\n", 1), + (HOLDER + OTHER, PAGE.replace("from typing import Annotated\n", "def typed():\n from typing import Annotated\n"), 1), + (HOLDER + OTHER, PAGE + "\n\nPage = dict\n", 2), + (HOLDER + OTHER, PAGE + " archive = ''\n", 1), + (HOLDER.replace(" operation: Page", " operation: Page\n before, extra = 1, 2") + OTHER, PAGE, 1), ], ids=["extracted", "retained", "unheld", "second_owner", "shadowed", "unresolved", "renamed", - "retyped", "redefaulted", "extra_field", "method", "copied_twice"], + "retyped", "redefaulted", "extra_field", "method", "copied_twice", "aliased_imports", + "nested_only_import", "unrelated_local_name", "annotated_rebound", "annotated_local_only", + "export_rebound", "field_rebound", "source_rebound_by_unpacking"], ) def test_fields_extracted_into_the_one_new_held_class_are_moved_not_written(repo, holder, pages, written): """An old class that now holds one new class in a typed field carries each field it lost diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 24e3d3859b..4327592a63 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -190,6 +190,12 @@ def grown(cls, delta: Counts) -> bool: def blocks(cls, delta: Counts) -> bool: return cls.grown(delta) + @classmethod + def missing(cls, head: Counts, introduced: dict[FileCount, list[int]]) -> list[MeasureCount]: + """This measure's counts the comparison has no required evidence for; a measure that + needs none beyond its counts misses nothing.""" + return [] + @classmethod def grew(cls, key: MeasureCount, grown: int) -> str: """The line reporting one count's growth; the count words it.""" @@ -1095,67 +1101,55 @@ class DeclaredClass: @classmethod def top(cls, path: str, tree: ast.Module) -> dict[str, DeclaredClass]: - """The module's top-level classes; a name declared twice declares neither.""" - named = Counter(node.name for node in tree.body if isinstance(node, ast.ClassDef)) - return {node.name: cls(path, tree, node) for node in tree.body - if isinstance(node, ast.ClassDef) and named[node.name] == 1} + """The module's classes whose module-level name is still that one declaration.""" + return {name: cls(path, tree, statement) + for name, (_, statement) in DecoratorBinding.declarations(tree, "", tree.body).items() + if isinstance(statement, ast.ClassDef)} + + @property + def scope(self) -> str: + return f".{self.node.name}" @cached_property def fields(self) -> dict[str, ast.AnnAssign]: - """The class body's annotated names; a name annotated twice is no field of it.""" - written = [statement for statement in self.node.body - if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name)] - named = Counter(typing.cast(ast.Name, statement.target).id for statement in written) - return {name: statement for statement in written - if named[name := typing.cast(ast.Name, statement.target).id] == 1} + """The class body's annotated names bound by that annotation alone.""" + return {name: statement for name, (_, statement) in DecoratorBinding.declarations( + self.tree, self.scope, self.node.body).items() + if isinstance(statement, ast.AnnAssign)} + + def binds(self, name: str) -> bool: + """Whether the class scope still binds ``name`` in any form.""" + return name in DecoratorBinding.bindings(self.tree, self.scope) + + @cached_property + def annotated(self) -> DecoratorBinding: + return DecoratorBinding.named("typing", "Annotated", self.tree, self.path) + + def proved(self, field: str) -> bool: + """Whether the field's annotation reads as written: one spelled Annotated that does + not bind typing.Annotated at module level could mean anything.""" + match self.fields[field].annotation: + case ast.Subscript(value=ast.Name(id="Annotated") | ast.Attribute(attr="Annotated") as value): + return self.annotated.marks(value) + return True def declared(self, field: str) -> tuple[str, str]: """The field's type without typing.Annotated metadata, and its default, structurally.""" statement = self.fields[field] - annotated = DecoratorBinding.named("typing", "Annotated", self.tree, self.path) match statement.annotation: - case ast.Subscript(value=value, slice=ast.Tuple(elts=[declared, *_])) if annotated.marks(value): + case ast.Subscript(value=value, slice=ast.Tuple(elts=[declared, *_])) if self.annotated.marks(value): annotation: ast.expr = declared case annotation: pass return ast.dump(annotation), ast.dump(statement.value) if statement.value else "" - def binds(self, name: str) -> bool: - """Whether the class body still binds ``name`` in any form.""" - for statement in self.node.body: - match statement: - case ast.AnnAssign(target=ast.Name(id=bound)) | ast.FunctionDef(name=bound) | ( - ast.AsyncFunctionDef(name=bound)) | ast.ClassDef(name=bound) if bound == name: - return True - case ast.Assign(targets=targets) if any( - isinstance(target, ast.Name) and target.id == name for target in targets): - return True - return False - def holding(self, held: DeclaredClass, base: DeclaredClass) -> tuple[str, ...]: - """The fields this class newly declares whose type is ``held``.""" + """The fields this class newly declares whose type is the module-level binding of + ``held``.""" binding = DecoratorBinding.named( ".".join(DecoratorBinding.module_of(held.path)), held.node.name, self.tree, self.path) return tuple(name for name, statement in self.fields.items() - if name not in base.fields and binding.marks(statement.annotation) - and self.bound_once(ast.unparse(statement.annotation).split(".")[0])) - - def bound_once(self, name: str) -> bool: - """The module binds ``name`` once and imports nothing by star, so no other binding - can shadow it.""" - bindings = 0 - for node in ast.walk(self.tree): - match node: - case ast.alias(name="*"): - return False - case ast.alias(name=imported, asname=alias): - bindings += (alias or imported.split(".")[0]) == name - case ast.ClassDef(name=declared) | ast.FunctionDef(name=declared) | ast.AsyncFunctionDef( - name=declared): - bindings += declared == name - case ast.Name(id=declared, ctx=ast.Store()): - bindings += declared == name - return bindings == 1 + if name not in base.fields and binding.marks(statement.annotation)) class FieldExtraction: @@ -1198,6 +1192,7 @@ def credit(cls, measure: type[CleanChangedLines], sources: list[ChangedSource], if owners[base.path, base.node.name] == holders[held.path, held.node.name] == 1: for field in sorted(base.fields): if (not owner.binds(field) and field in held.fields + and base.proved(field) and held.proved(field) and base.declared(field) == held.declared(field)): cls.transfer(measure, base, held, field, departed, credited) return credited @@ -1283,6 +1278,13 @@ def blocks(cls, delta: Counts) -> bool: deletion offsets the totals.""" return False + @classmethod + def missing(cls, head: Counts, introduced: dict[FileCount, list[int]]) -> list[MeasureCount]: + """A file with occurrences at the head but no introduced-site report is unproved: only + an explicit report, even an empty one, says no occurrence there is new.""" + return [key for key, value in head.items() + if cls.owns(key) and value > 0 and key not in introduced] + @classmethod def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]], extracted: Counter[SitePlace]) -> list[int]: @@ -1414,9 +1416,10 @@ def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]] @dataclass(frozen=True) class DecoratorBinding: """The spellings one module binds to a declared name (a decorator, a field factory, a - class), read from its imports alone. + class), read from its module-level imports and declarations. - A name of the same spelling declared or imported from anywhere else marks nothing. + A name of the same spelling declared or imported from anywhere else marks nothing, and + neither does a spelling the module binds twice, conditionally or by star import. """ names: frozenset[str] @@ -1437,22 +1440,77 @@ def named(cls, owner: str, name: str, tree: ast.AST, filename: str) -> Decorator """The spellings binding ``owner.name`` in the module at ``filename``.""" module = cls.module_of(filename) package = module[:-1] - names = {name} if ".".join(module) == owner else set() - for node in ast.walk(tree): - match node: - case ast.Import(names=aliases): - names.update(f"{alias.asname or alias.name}.{name}" - for alias in aliases if alias.name == owner) - case ast.ImportFrom(module=source, names=aliases, level=level): + names: set[str] = set() + body = typing.cast(ast.Module, tree).body + for bound, (declaration, statement) in cls.declarations(tree, "", body).items(): + match statement, declaration: + case ast.ClassDef() | ast.FunctionDef() | ast.AsyncFunctionDef(), _ if ( + ".".join(module) == owner and bound == name): + names.add(name) + case ast.Import(), ast.alias(name=imported, asname=alias) if imported == owner: + names.add(f"{alias or imported}.{name}") + case ast.ImportFrom(module=source, level=level), ast.alias(name=imported): base = package[:len(package) - level + 1] if level else () resolved = ".".join((*base, source) if source else base) - for alias in aliases: - if f"{resolved}.{alias.name}" == owner: - names.add(f"{alias.asname or alias.name}.{name}") - if resolved == owner and alias.name == name: - names.add(alias.asname or name) + if f"{resolved}.{imported}" == owner: + names.add(f"{bound}.{name}") + if resolved == owner and imported == name: + names.add(bound) return cls(frozenset(names)) + @staticmethod + def bound(node: ast.AST) -> tuple[str, ...]: + """The names one node binds where it stands; a star import binds ``*``.""" + match node: + case ast.ClassDef(name=name) | ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name): + return (name,) + case ast.alias(name="*"): + return ("*",) + case ast.alias(name=imported, asname=alias): + return (alias or imported.split(".")[0],) + case ast.Name(id=name, ctx=ast.Store() | ast.Del()): + return (name,) + case ast.ExceptHandler(name=str() as name) | ast.MatchAs(name=str() as name) | ( + ast.MatchStar(name=str() as name)) | ast.MatchMapping(rest=str() as name): + return (name,) + case ast.TypeVar(name=name) | ast.ParamSpec(name=name) | ast.TypeVarTuple(name=name): + return (name,) + case ast.Global(names=names) | ast.Nonlocal(names=names): + return tuple(names) + return () + + @classmethod + def bindings(cls, tree: ast.AST, scope: str) -> dict[str, list[ast.AST]]: + """Every binding of each name in one lexical module or class scope, as ScopeNames + places it; bindings inside nested functions and classes are theirs, not this scope's.""" + found: dict[str, list[ast.AST]] = {} + for node, within in CleanChangedLines.scopes(tree).items(): + if within == scope: + for name in cls.bound(node): + found.setdefault(name, []).append(node) + return found + + @classmethod + def declarations(cls, tree: ast.AST, scope: str, + body: list[ast.stmt]) -> dict[str, tuple[ast.AST, ast.stmt]]: + """Each name the scope binds exactly once, by an unconditional import, class, function + or annotated assignment written directly in its body, with that statement. A star + import leaves no name proved.""" + bindings = cls.bindings(tree, scope) + if "*" in bindings: + return {} + direct: dict[int, ast.stmt] = {} + for statement in body: + match statement: + case ast.Import(names=aliases) | ast.ImportFrom(names=aliases): + direct.update((id(alias), statement) for alias in aliases) + case ast.ClassDef() | ast.FunctionDef() | ast.AsyncFunctionDef(): + direct[id(statement)] = statement + case ast.AnnAssign(target=ast.Name() as target): + direct[id(target)] = statement + return {name: (node, direct[id(node)]) for name, (node, *others) in bindings.items() + if not others and id(node) in direct} + def marks(self, decorator: ast.expr) -> bool: return ast.unparse(decorator) in self.names @@ -2996,10 +3054,20 @@ def found_report(self) -> dict[str, list[Site]]: def delta_report(self) -> dict[str, int]: return self.keyed(self.delta) + @property + def missing(self) -> list[MeasureCount]: + """Counts whose measure's required evidence the comparison lacks.""" + return [key for measure in Measure.members_with(Measure) + for key in measure.missing(self.head, self.introduced)] + + @projected(view="report", name="missing") + def missing_report(self) -> list[str]: + return sorted(key.text for key in self.missing) + @property def failed(self) -> bool: return (any(measure.blocks(self.delta) for measure in Measure.members_with(Measure)) - or any(self.introduced.values())) + or any(self.introduced.values()) or bool(self.missing)) def compare(repo: Path, base: str, head: str, root: str, workers: int, @@ -3087,6 +3155,8 @@ def main() -> int: for identity, lines in sorted(result.introduced_report.items()): if lines: print(f"introduced {identity} at lines {lines}", file=sys.stderr) + for identity in result.missing_report: + print(f"missing introduced-site report for {identity}", file=sys.stderr) return int(result.failed) From 1372658f77f5329ed3b3e9ab26aa4181b72143dc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 13:53:54 -0400 Subject: [PATCH 29/31] debt ratchet: extracted field types compare by the owner they are bound to The field proof compared printed annotations, so the same spelling T imported from alpha.Cursor and beta.Cursor counted as one type. DecoratorBinding.identity rewrites every name and attribute reference in a field's type and default to its owner from the existing lexical declarations: an import's source module and export (relative imports and aliases via module_of), a declared class's own module, a builtin only when neither the module nor the class scope binds the name. Anything else - a class-scope binding, type variable, rebinding, star import or unresolved name - is UnprovedIdentity, equal to nothing; the proof and its key are one NominalProof result, and DeclaredClass.proved is gone. Occurrences pair node for node across the proved declarations, so an alias of the same owner carries its departure while a changed owner does not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/guards/test_debt_ratchet.py | 37 +++++++++ tools/debt_ratchet.py | 128 ++++++++++++++++++++++-------- 2 files changed, 133 insertions(+), 32 deletions(-) diff --git a/tests/guards/test_debt_ratchet.py b/tests/guards/test_debt_ratchet.py index d573e8c32d..b29cbbe3d9 100644 --- a/tests/guards/test_debt_ratchet.py +++ b/tests/guards/test_debt_ratchet.py @@ -1250,6 +1250,43 @@ def test_fields_extracted_into_the_one_new_held_class_are_moved_not_written(repo assert report["delta"][destination] == report["head"][destination] +TYPED_FROM = ( + "from .alpha import Cursor\n\n\nclass Request:\n thread: str\n" + " before: Cursor | None = None\n archive: str | None = None\n" +) +TYPED_PAGE = ( + "from .alpha import Cursor\n\n\nclass Page:\n" + " before: Cursor | None = None\n archive: str | None = None\n" +) + + +@pytest.mark.parametrize( + ("source", "pages", "written"), + [ + (TYPED_FROM, TYPED_PAGE, 0), + (TYPED_FROM, TYPED_PAGE.replace("import Cursor", "import Cursor as Mark").replace( + "before: Cursor", "before: Mark"), 0), + (TYPED_FROM, TYPED_PAGE.replace(".alpha", ".beta"), 1), + (TYPED_FROM, TYPED_PAGE + "\n\nstr = bytes\n", 1), + (TYPED_FROM, TYPED_PAGE.replace(" archive", " str = bytes\n archive"), 1), + (TYPED_FROM.replace("from .alpha import Cursor", "from typing import TypeVar\n\nCursor = TypeVar('Cursor')"), + TYPED_PAGE.replace("from .alpha import Cursor", "from typing import TypeVar\n\nCursor = TypeVar('Cursor')"), 1), + ], + ids=["same_owner", "same_owner_aliased", "changed_owner", "builtin_shadowed", + "builtin_shadowed_in_class", "type_variable"], +) +def test_extracted_field_types_are_compared_by_the_owner_they_import(repo, source, pages, written): + """A field type is the module and name its spelling is bound to, not the spelling: the same + Cursor imported from another module is another type, and an alias of the same is not.""" + base = repo.commit({"holder.py": source + OTHER, "pages.py": "VALUE = 1\n"}) + head = repo.commit({"holder.py": HOLDER + OTHER, "pages.py": pages}) + status, report = repo.compare(base, head, NoneAbsence) + source_count, destination = (NoneAbsence.at(f"{repo.root}/{name}").text for name in ("holder.py", "pages.py")) + assert status == int(written > 0) + assert len(report["introduced"][destination]) == written + assert report["introduced"][source_count] == [] + + def test_git_context_configuration_cannot_mark_unchanged_lines_as_new(repo): repo.git("config", "diff.interHunkContext", "3") base = repo.commit({"names.py": "first = 1\nold_receipt = 1\nlast = 1\n"}) diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 4327592a63..9e4153e897 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -5,6 +5,8 @@ import argparse import ast +import builtins +import copy import atexit import dataclasses import enum @@ -1091,6 +1093,32 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: type SitePlace = tuple[str, int, int, int, int] +class NominalProof: + """What an expression's resolved owners prove it is; only two proved identities with + the same owners and structure are the same.""" + + def same(self, other: NominalProof) -> bool: + return False + + def matches(self, key: str) -> bool: + return False + + +class UnprovedIdentity(NominalProof): + """A reference the binding facts could not resolve: equal to nothing, itself included.""" + + +@dataclass(frozen=True) +class ProvedIdentity(NominalProof): + key: str + + def same(self, other: NominalProof) -> bool: + return other.matches(self.key) + + def matches(self, key: str) -> bool: + return key == self.key + + @dataclass(frozen=True) class DeclaredClass: """One top-level class of one changed module at one revision, read for its fields.""" @@ -1125,23 +1153,21 @@ def binds(self, name: str) -> bool: def annotated(self) -> DecoratorBinding: return DecoratorBinding.named("typing", "Annotated", self.tree, self.path) - def proved(self, field: str) -> bool: - """Whether the field's annotation reads as written: one spelled Annotated that does - not bind typing.Annotated at module level could mean anything.""" - match self.fields[field].annotation: - case ast.Subscript(value=ast.Name(id="Annotated") | ast.Attribute(attr="Annotated") as value): - return self.annotated.marks(value) - return True - - def declared(self, field: str) -> tuple[str, str]: - """The field's type without typing.Annotated metadata, and its default, structurally.""" + def compared(self, field: str) -> list[ast.expr]: + """The field's name, its type without typing.Annotated metadata, and its default.""" statement = self.fields[field] match statement.annotation: case ast.Subscript(value=value, slice=ast.Tuple(elts=[declared, *_])) if self.annotated.marks(value): annotation: ast.expr = declared case annotation: pass - return ast.dump(annotation), ast.dump(statement.value) if statement.value else "" + return [statement.target, annotation, *([statement.value] if statement.value else [])] + + def declared(self, field: str) -> NominalProof: + """The field's type and default, each reference resolved to its owner in this class's + scope; the name is the field's own and needs no owner.""" + _, *written = self.compared(field) + return DecoratorBinding.identity(self.tree, self.path, self.scope, ast.Tuple(elts=written)) def holding(self, held: DeclaredClass, base: DeclaredClass) -> tuple[str, ...]: """The fields this class newly declares whose type is the module-level binding of @@ -1192,34 +1218,31 @@ def credit(cls, measure: type[CleanChangedLines], sources: list[ChangedSource], if owners[base.path, base.node.name] == holders[held.path, held.node.name] == 1: for field in sorted(base.fields): if (not owner.binds(field) and field in held.fields - and base.proved(field) and held.proved(field) - and base.declared(field) == held.declared(field)): + and base.declared(field).same(held.declared(field))): cls.transfer(measure, base, held, field, departed, credited) return credited @staticmethod def transfer(measure: type[CleanChangedLines], base: DeclaredClass, held: DeclaredClass, field: str, departed: Counter[tuple[str, str]], credited: Counter[SitePlace]) -> None: - """One field's occurrences, paid one for one by those that left the old declaration.""" - def occurrences(declared: DeclaredClass) -> Iterator[tuple[ast.AST, str, int]]: - inside = {id(node) for node in ast.walk(declared.fields[field])} - scopes = measure.scopes(declared.tree) - for node in measure.sites(declared.tree, declared.path): - if id(node) in inside and (count := measure.occurrences(node)): - yield node, scopes[node], count - + """One field's occurrences, paid one for one by those that left the old declaration. + The declarations are proved the same, so their parts pair node for node in walk order; + parts whose raw shapes differ pair nothing.""" scope = f".{base.node.name}" - gave: Counter[str] = Counter() - for node, within, count in occurrences(base): - if within == scope: - gave[ast.unparse(node)] += count - for text in gave: - gave[text] = min(gave[text], departed[scope, text]) - for node, _, count in occurrences(held): - paid = min(count, gave[ast.unparse(node)]) - gave[ast.unparse(node)] -= paid - departed[scope, ast.unparse(node)] -= paid - credited[FieldExtraction.place(held.path, node)] += paid + counted = {declared: ({id(node) for node in measure.sites(declared.tree, declared.path)}, + measure.scopes(declared.tree)) for declared in (base, held)} + for gave, took in zip(base.compared(field), held.compared(field), strict=True): + left, arrived = list(ast.walk(gave)), list(ast.walk(took)) + if [type(node) for node in left] != [type(node) for node in arrived]: + continue + for old, new in zip(left, arrived, strict=True): + if ((carried := min(measure.occurrences(old), measure.occurrences(new))) + and id(old) in counted[base][0] and id(new) in counted[held][0]): + identity = counted[base][1][old], ast.unparse(old) + if identity[0] == scope: + paid = min(carried, departed[identity]) + departed[identity] -= paid + credited[FieldExtraction.place(held.path, new)] += paid class CleanChangedLines(OccurrenceMeasure): @@ -1511,6 +1534,47 @@ def declarations(cls, tree: ast.AST, scope: str, return {name: (node, direct[id(node)]) for name, (node, *others) in bindings.items() if not others and id(node) in direct} + @classmethod + def identity(cls, tree: ast.Module, filename: str, scope: str, expression: ast.expr) -> NominalProof: + """The expression with every name and attribute reference replaced by the module and + name that own it, as evaluated in ``scope``: an imported name is its source module's + export, a declared class its own module's, a builtin one the module never binds. A name + the scope itself binds, or any reference these declarations cannot resolve, proves + nothing.""" + module = cls.module_of(filename) + package = module[:-1] + declared = cls.declarations(tree, "", tree.body) + bound = cls.bindings(tree, "") + enclosing = cls.bindings(tree, scope) + unresolved: list[str] = [] + + def owner(name: str) -> str: + match declared.get(name) if name not in enclosing else (): + case ast.alias(name=imported, asname=alias), ast.Import(): + return imported if alias else imported.split(".")[0] + case ast.alias(name=imported), ast.ImportFrom(module=source, level=level): + base = package[:len(package) - level + 1] if level else () + return ".".join((*base, *(source.split(".") if source else ()), imported)) + case _, ast.ClassDef(): + return ".".join((*module, name)) + if name not in bound and name not in enclosing and name in vars(builtins): + return f"builtins.{name}" + unresolved.append(name) + return name + + class Owned(ast.NodeTransformer): + def visit_Name(self, node: ast.Name) -> ast.Name: + return ast.Name(id=owner(node.id)) + + def visit_Attribute(self, node: ast.Attribute) -> ast.AST: + match ast.unparse(node).split("."): + case [root, *attributes] if root.isidentifier() and all(map(str.isidentifier, attributes)): + return ast.Name(id=".".join((owner(root), *attributes))) + return self.generic_visit(node) + + owned = Owned().visit(copy.deepcopy(expression)) + return UnprovedIdentity() if unresolved else ProvedIdentity(ast.dump(owned)) + def marks(self, decorator: ast.expr) -> bool: return ast.unparse(decorator) in self.names From 1ea8ab3b6784b0d8b8f9996677a4b9c72e9d9b4f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 14:41:39 -0400 Subject: [PATCH 30/31] debt ratchet: the held-class edge and Annotated strip read in class scope DecoratorBinding.named takes the scope it is read from (module by default) and drops a spelling whose root that scope rebinds. DeclaredClass.annotated and DeclaredClass.holding pass the class's own scope, so a class body that rebinds Annotated or the held class's name proves neither the stripped inner type nor the held-class edge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/guards/test_debt_ratchet.py | 5 ++++- tools/debt_ratchet.py | 14 +++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/guards/test_debt_ratchet.py b/tests/guards/test_debt_ratchet.py index b29cbbe3d9..52f3ea0c45 100644 --- a/tests/guards/test_debt_ratchet.py +++ b/tests/guards/test_debt_ratchet.py @@ -1230,11 +1230,14 @@ def test_an_absence_moved_with_its_class_to_another_module_is_not_written(repo): (HOLDER + OTHER, PAGE + "\n\nPage = dict\n", 2), (HOLDER + OTHER, PAGE + " archive = ''\n", 1), (HOLDER.replace(" operation: Page", " operation: Page\n before, extra = 1, 2") + OTHER, PAGE, 1), + (HOLDER + OTHER, PAGE.replace("class Page:\n", "class Page:\n Annotated = dict\n"), 1), + (HOLDER.replace(" operation: Page", " Page = dict\n operation: Page") + OTHER, PAGE, 2), ], ids=["extracted", "retained", "unheld", "second_owner", "shadowed", "unresolved", "renamed", "retyped", "redefaulted", "extra_field", "method", "copied_twice", "aliased_imports", "nested_only_import", "unrelated_local_name", "annotated_rebound", "annotated_local_only", - "export_rebound", "field_rebound", "source_rebound_by_unpacking"], + "export_rebound", "field_rebound", "source_rebound_by_unpacking", + "annotated_shadowed_in_class", "held_class_shadowed_in_owner"], ) def test_fields_extracted_into_the_one_new_held_class_are_moved_not_written(repo, holder, pages, written): """An old class that now holds one new class in a typed field carries each field it lost diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 9e4153e897..ee41129e93 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -1151,7 +1151,7 @@ def binds(self, name: str) -> bool: @cached_property def annotated(self) -> DecoratorBinding: - return DecoratorBinding.named("typing", "Annotated", self.tree, self.path) + return DecoratorBinding.named("typing", "Annotated", self.tree, self.path, self.scope) def compared(self, field: str) -> list[ast.expr]: """The field's name, its type without typing.Annotated metadata, and its default.""" @@ -1173,7 +1173,8 @@ def holding(self, held: DeclaredClass, base: DeclaredClass) -> tuple[str, ...]: """The fields this class newly declares whose type is the module-level binding of ``held``.""" binding = DecoratorBinding.named( - ".".join(DecoratorBinding.module_of(held.path)), held.node.name, self.tree, self.path) + ".".join(DecoratorBinding.module_of(held.path)), held.node.name, self.tree, self.path, + self.scope) return tuple(name for name, statement in self.fields.items() if name not in base.fields and binding.marks(statement.annotation)) @@ -1459,8 +1460,10 @@ def module_of(filename: str) -> tuple[str, ...]: return parts[1:] if parts[:1] == ("src",) else parts @classmethod - def named(cls, owner: str, name: str, tree: ast.AST, filename: str) -> DecoratorBinding: - """The spellings binding ``owner.name`` in the module at ``filename``.""" + def named(cls, owner: str, name: str, tree: ast.AST, filename: str, + scope: str = "") -> DecoratorBinding: + """The spellings binding ``owner.name`` in the module at ``filename``, as read from + ``scope``: a spelling whose root that scope rebinds names something else there.""" module = cls.module_of(filename) package = module[:-1] names: set[str] = set() @@ -1479,7 +1482,8 @@ def named(cls, owner: str, name: str, tree: ast.AST, filename: str) -> Decorator names.add(f"{bound}.{name}") if resolved == owner and imported == name: names.add(bound) - return cls(frozenset(names)) + hidden = cls.bindings(tree, scope).keys() if scope else frozenset() + return cls(frozenset(spelling for spelling in names if spelling.split(".")[0] not in hidden)) @staticmethod def bound(node: ast.AST) -> tuple[str, ...]: From 75773856d9fa346a9db6d5644c6a6d4401743f69 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 15:00:22 -0400 Subject: [PATCH 31/31] debt ratchet: ScopeNames decodes bindings; credit is keyed by the node itself The hosted tools gate counted the measurement repair's own AST case switches (DecoratorBinding.bound and the switch inside declarations: TypeSwitch +2, TypeSwitchArms +20) and its reconstructed coordinates (TypeErrors). ScopeNames is the one foreign-AST visitor: its visit_ hooks record each node's bound names and the declaring nodes during the walk that assigns scopes, and DecoratorBinding.bindings/declarations group those facts; a statement owns a binding when it is that node or its direct child. SitePlace and FieldExtraction.place are gone: credit is a Counter keyed by the destination node the cached tree already owns. The edge list is typed and introduced is a classmethod reading cls.members_with(cls). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tools/debt_ratchet.py | 170 +++++++++++++++++++++++++----------------- 1 file changed, 102 insertions(+), 68 deletions(-) diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index ee41129e93..02e26b3fec 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -1064,11 +1064,30 @@ def departed(cls, repo: Path, base: str, head: str, path: str) -> ChangedSource: class ScopeNames(ast.NodeVisitor): - """Each node's enclosing class and function names, dotted, through the AST's own dispatch.""" + """Each node's enclosing class and function names, dotted, through the AST's own dispatch, + with the names each node binds where it stands and the nodes that declare them: a class or + function, an import alias, an annotated name. A star import binds ``*``.""" def __init__(self) -> None: self.found: dict[ast.AST, str] = {} self.scope = "" + self.binds: dict[ast.AST, tuple[str, ...]] = {} + self.declares: set[ast.AST] = set() + + @classmethod + def of(cls, tree: ast.AST) -> ScopeNames: + names = cls() + names.visit(tree) + return names + + def bindings(self, scope: str) -> dict[str, list[ast.AST]]: + """Every binding of each name in one lexical scope, in walk order.""" + found: dict[str, list[ast.AST]] = {} + for node, names in self.binds.items(): + if self.found[node] == scope: + for name in names: + found.setdefault(name, []).append(node) + return found def generic_visit(self, node: ast.AST) -> None: self.found[node] = self.scope @@ -1076,10 +1095,65 @@ def generic_visit(self, node: ast.AST) -> None: def visit_named(self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> None: self.found[node] = outer = self.scope + self.binds[node] = (node.name,) + self.declares.add(node) self.scope = f"{outer}.{node.name}" super().generic_visit(node) self.scope = outer + def visit_alias(self, node: ast.alias) -> None: + self.binds[node] = (node.asname or node.name.split(".")[0],) + self.declares.add(node) + self.generic_visit(node) + + def visit_Name(self, node: ast.Name) -> None: + match node.ctx: + case ast.Store() | ast.Del(): + self.binds[node] = (node.id,) + self.generic_visit(node) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + match node.target: + case ast.Name(): + self.declares.add(node.target) + self.generic_visit(node) + + def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: + self.binds[node] = (node.name,) if node.name else () + self.generic_visit(node) + + def visit_MatchAs(self, node: ast.MatchAs) -> None: + self.binds[node] = (node.name,) if node.name else () + self.generic_visit(node) + + def visit_MatchStar(self, node: ast.MatchStar) -> None: + self.binds[node] = (node.name,) if node.name else () + self.generic_visit(node) + + def visit_MatchMapping(self, node: ast.MatchMapping) -> None: + self.binds[node] = (node.rest,) if node.rest else () + self.generic_visit(node) + + def visit_TypeVar(self, node: ast.TypeVar) -> None: + self.binds[node] = (node.name,) + self.generic_visit(node) + + def visit_ParamSpec(self, node: ast.ParamSpec) -> None: + self.binds[node] = (node.name,) + self.generic_visit(node) + + def visit_TypeVarTuple(self, node: ast.TypeVarTuple) -> None: + self.binds[node] = (node.name,) + self.generic_visit(node) + + def visit_Global(self, node: ast.Global) -> None: + self.binds[node] = tuple(node.names) + self.generic_visit(node) + + def visit_Nonlocal(self, node: ast.Nonlocal) -> None: + self.binds[node] = tuple(node.names) + self.generic_visit(node) + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: self.visit_named(node) @@ -1090,9 +1164,6 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: self.visit_named(node) -type SitePlace = tuple[str, int, int, int, int] - - class NominalProof: """What an expression's resolved owners prove it is; only two proved identities with the same owners and structure are the same.""" @@ -1191,21 +1262,16 @@ class FieldExtraction: retained field, an extra field, a method or a second copy is still written. """ - @staticmethod - def place(path: str, node: ast.AST) -> SitePlace: - return (path, node.lineno, node.col_offset, - typing.cast(int, node.end_lineno), typing.cast(int, node.end_col_offset)) - @classmethod def credit(cls, measure: type[CleanChangedLines], sources: list[ChangedSource], - left: list[ChangedSource], departed: Counter[tuple[str, str]]) -> Counter[SitePlace]: + left: list[ChangedSource], departed: Counter[tuple[str, str]]) -> Counter[ast.AST]: """Each proved destination occurrence's credit; ``departed`` spends what it pays.""" existing = {name for source in [*sources, *left] for name in DeclaredClass.top( source.path, source.base_tree)} new = [held for source in sources for name, held in DeclaredClass.top(source.path, source.tree).items() if name not in existing] - edges = [] + edges: list[tuple[DeclaredClass, DeclaredClass, DeclaredClass]] = [] for source in sources: before = DeclaredClass.top(source.path, source.base_tree) for name, owner in DeclaredClass.top(source.path, source.tree).items(): @@ -1214,7 +1280,7 @@ def credit(cls, measure: type[CleanChangedLines], sources: list[ChangedSource], edges.extend((before[name], owner, held) for _ in owner.holding(held, before[name])) owners = Counter((base.path, base.node.name) for base, _, _ in edges) holders = Counter((held.path, held.node.name) for _, _, held in edges) - credited: Counter[SitePlace] = Counter() + credited: Counter[ast.AST] = Counter() for base, owner, held in edges: if owners[base.path, base.node.name] == holders[held.path, held.node.name] == 1: for field in sorted(base.fields): @@ -1225,7 +1291,7 @@ def credit(cls, measure: type[CleanChangedLines], sources: list[ChangedSource], @staticmethod def transfer(measure: type[CleanChangedLines], base: DeclaredClass, held: DeclaredClass, - field: str, departed: Counter[tuple[str, str]], credited: Counter[SitePlace]) -> None: + field: str, departed: Counter[tuple[str, str]], credited: Counter[ast.AST]) -> None: """One field's occurrences, paid one for one by those that left the old declaration. The declarations are proved the same, so their parts pair node for node in walk order; parts whose raw shapes differ pair nothing.""" @@ -1243,7 +1309,7 @@ def transfer(measure: type[CleanChangedLines], base: DeclaredClass, held: Declar if identity[0] == scope: paid = min(carried, departed[identity]) departed[identity] -= paid - credited[FieldExtraction.place(held.path, new)] += paid + credited[new] += paid class CleanChangedLines(OccurrenceMeasure): @@ -1256,9 +1322,10 @@ class CleanChangedLines(OccurrenceMeasure): class moved between files, is the same occurrence moved, not a new one written. """ - @staticmethod + @classmethod def introduced( - repo: Path, base: str, head: str, paths: tuple[str, ...], departed_from: tuple[str, ...], + cls: type[typing.Self], repo: Path, base: str, head: str, paths: tuple[str, ...], + departed_from: tuple[str, ...], ) -> dict[FileCount, list[int]]: """Each strict measure's sites on each path's changed lines. Occurrences that left the changed modules in ``departed_from`` (those present at the base) pay for the same @@ -1266,7 +1333,7 @@ def introduced( sources = [ChangedSource.read(repo, base, head, path) for path in paths] left = [ChangedSource.departed(repo, base, head, path) for path in departed_from] found: dict[FileCount, list[int]] = {} - for measure in Measure.members_with(CleanChangedLines): + for measure in cls.members_with(cls): departed: Counter[tuple[str, str]] = Counter() for source in left: gone = measure.identities(source.base_tree, source.path) @@ -1311,7 +1378,7 @@ def missing(cls, head: Counts, introduced: dict[FileCount, list[int]]) -> list[M @classmethod def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]], - extracted: Counter[SitePlace]) -> list[int]: + extracted: Counter[ast.AST]) -> list[int]: """The first changed line of each occurrence written there that the base lacks and no changed module gave up; a moved occurrence spends the departure it came from, and an occurrence in a proved field extraction spends its credit.""" @@ -1327,9 +1394,8 @@ def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]] unwritten[identity] -= arrived moved = min(arrived, departed[identity]) departed[identity] -= moved - place = FieldExtraction.place(source.path, node) - carried = min(arrived - moved, extracted[place]) - extracted[place] -= carried + carried = min(arrived - moved, extracted[node]) + extracted[node] -= carried sites.extend([min(touched)] * (arrived - moved - carried)) return sites @@ -1432,7 +1498,7 @@ def lines(cls, source: bytes, filename: str) -> list[int]: @classmethod def changed_sites(cls, source: ChangedSource, departed: Counter[tuple[str, str]], - extracted: Counter[SitePlace]) -> list[int]: + extracted: Counter[ast.AST]) -> list[int]: prose = cls.prose_lines(source) & source.lines return [*super().changed_sites(source, departed, extracted), *prose] @@ -1486,57 +1552,25 @@ def named(cls, owner: str, name: str, tree: ast.AST, filename: str, return cls(frozenset(spelling for spelling in names if spelling.split(".")[0] not in hidden)) @staticmethod - def bound(node: ast.AST) -> tuple[str, ...]: - """The names one node binds where it stands; a star import binds ``*``.""" - match node: - case ast.ClassDef(name=name) | ast.FunctionDef(name=name) | ast.AsyncFunctionDef(name=name): - return (name,) - case ast.alias(name="*"): - return ("*",) - case ast.alias(name=imported, asname=alias): - return (alias or imported.split(".")[0],) - case ast.Name(id=name, ctx=ast.Store() | ast.Del()): - return (name,) - case ast.ExceptHandler(name=str() as name) | ast.MatchAs(name=str() as name) | ( - ast.MatchStar(name=str() as name)) | ast.MatchMapping(rest=str() as name): - return (name,) - case ast.TypeVar(name=name) | ast.ParamSpec(name=name) | ast.TypeVarTuple(name=name): - return (name,) - case ast.Global(names=names) | ast.Nonlocal(names=names): - return tuple(names) - return () - - @classmethod - def bindings(cls, tree: ast.AST, scope: str) -> dict[str, list[ast.AST]]: + def bindings(tree: ast.AST, scope: str) -> dict[str, list[ast.AST]]: """Every binding of each name in one lexical module or class scope, as ScopeNames - places it; bindings inside nested functions and classes are theirs, not this scope's.""" - found: dict[str, list[ast.AST]] = {} - for node, within in CleanChangedLines.scopes(tree).items(): - if within == scope: - for name in cls.bound(node): - found.setdefault(name, []).append(node) - return found + decodes it; bindings inside nested functions and classes are theirs, not this scope's.""" + return ScopeNames.of(tree).bindings(scope) - @classmethod - def declarations(cls, tree: ast.AST, scope: str, + @staticmethod + def declarations(tree: ast.AST, scope: str, body: list[ast.stmt]) -> dict[str, tuple[ast.AST, ast.stmt]]: - """Each name the scope binds exactly once, by an unconditional import, class, function - or annotated assignment written directly in its body, with that statement. A star - import leaves no name proved.""" - bindings = cls.bindings(tree, scope) + """Each name the scope binds exactly once, by a declaring node that is one of its body's + statements or such a statement's direct child, with that statement. A star import + leaves no name proved.""" + names = ScopeNames.of(tree) + bindings = names.bindings(scope) if "*" in bindings: return {} - direct: dict[int, ast.stmt] = {} - for statement in body: - match statement: - case ast.Import(names=aliases) | ast.ImportFrom(names=aliases): - direct.update((id(alias), statement) for alias in aliases) - case ast.ClassDef() | ast.FunctionDef() | ast.AsyncFunctionDef(): - direct[id(statement)] = statement - case ast.AnnAssign(target=ast.Name() as target): - direct[id(target)] = statement - return {name: (node, direct[id(node)]) for name, (node, *others) in bindings.items() - if not others and id(node) in direct} + owners = {child: statement for statement in body + for child in (statement, *ast.iter_child_nodes(statement))} + return {name: (node, owners[node]) for name, (node, *others) in bindings.items() + if not others and node in names.declares and node in owners} @classmethod def identity(cls, tree: ast.Module, filename: str, scope: str, expression: ast.expr) -> NominalProof: