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 001/109] 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 002/109] 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 003/109] 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 004/109] 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 005/109] 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 006/109] 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 007/109] 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 008/109] 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 009/109] 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 010/109] 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 011/109] 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 012/109] 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 975e4c91fdc8f7820a0d713c86047911ec70cc95 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 08:44:31 -0400 Subject: [PATCH 013/109] Separate source qualification from head promotion --- src/agent_comms/commander/judge.py | 14 +++++++++++++- src/agent_comms/todos.py | 10 +++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 3c85a70277..14e5df2460 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -57,6 +57,7 @@ GrownHeadVerdict, GrowthReason, ReviewedHeadVerdict, + QualifiedHeadVerdict, RejectedHeadVerdict, HeadVerdict, OrderHeadReview, @@ -554,7 +555,7 @@ def head_operation(self) -> HeadVerdict: def edit_problems(self) -> dict[str, str]: return super().edit_problems() | MalformedRecord( - 'judge reviewing takes --pr and --head, never edit ids.').located( + f'judge {self.declared_name} takes --pr and --head, never edit ids.').located( 'ids', 'supply --pr and --head instead of edit IDs') def operation(self, ctx: order.Commander, todo: Todo, edit: Edit) -> ToolCallVerdict: @@ -565,6 +566,17 @@ def check_edits(self, edits: list[Edit]) -> None: pass +@dataclass(frozen=True, kw_only=True) +class QualifyVerb(ReasonedVerb, QuotingVerb, ReviewingVerb): + """Record a reasoned source decision using the existing head-only review boundary.""" + + help = "the exact PR head's source stage is qualified, without permission to merge" + verdict = QualifiedHeadVerdict + + def head_operation(self) -> HeadVerdict: + return self.verdict(VerdictReason(self.why)) + + @dataclass(frozen=True, kw_only=True) class SupersededVerb(ReasonedVerb, HeadVerdictVerb): clears = (ClosedUnmergedPullRequest,) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 7e070fae8a..00d6cfedad 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -2466,7 +2466,7 @@ def claimed(self, claim: OrderClaim) -> ClaimedProblemOrder: raise TodoError("Only a pending order can be claimed by its holder.") def review_verdicts(self) -> tuple[type[HeadVerdict], ...]: - return (ReviewedHeadVerdict, RejectedHeadVerdict) + return (ReviewedHeadVerdict, QualifiedHeadVerdict, RejectedHeadVerdict) def sent_back(self, reason: StrikeReason) -> CheckedProblemOrder: return self @@ -4109,6 +4109,14 @@ def description(self, reviewer: str, subject: str) -> str: return f"{self.verb} by {reviewer} on {subject}: {self.reason.text}" +@dataclass(frozen=True) +class QualifiedHeadVerdict(DecidedHeadVerdict): + """A reviewed source stage, without permission to promote the whole change.""" + + state = "pending" + verb = "Qualified" + + @dataclass(frozen=True) class ReviewedHeadVerdict(DecidedHeadVerdict): state = "success" From 7bcb1ca4300a57827139d05ed1e1ea7fa4e45eac Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 08:51:28 -0400 Subject: [PATCH 014/109] 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 c1d46c7d319fb3f55e0d087165bc3cf6fc00d15e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 08:56:34 -0400 Subject: [PATCH 015/109] Cover qualification without promotion through real review fixtures --- tests/test_head_review.py | 141 +++++++++++++++++++++++++++- tests/test_holder_standing_rules.py | 4 +- tests/test_integration.py | 16 +++- tests/test_queue_bypass_hook.py | 6 +- 4 files changed, 157 insertions(+), 10 deletions(-) diff --git a/tests/test_head_review.py b/tests/test_head_review.py index a6d2d9ebd9..7a19b041f4 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -22,15 +22,15 @@ ) from agent_comms.repo import Checkout from agent_comms.threads import Thread -from agent_comms.errors import ToolRefusedError -from agent_comms.field_codec import RecordProblems +from agent_comms.errors import TodoError, ToolRefusedError +from agent_comms.field_codec import FieldCodec, MalformedRecord, RecordProblems from agent_comms.tool_caller import ThreadCaller from agent_comms.work_branches import SourceGrowth, WorkBranches from agent_comms.order_tools import CommsHeadVerdictTool from agent_comms.todos import ( CarriedHeadVerdict, ConflictMergeBlock, DraftListedPullRequest, FailedCheckMergeBlock, GrownHeadVerdict, GrowthReason, OrderHeadReview, LinkedHeadReview, NoHeadOrder, ProgressRead, PullRequestLink, RecordPullRequest, RejectedHeadVerdict, - ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, + HeadVerdict, QualifiedHeadVerdict, ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, StrikeReason, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, ) from source_revision_fixture import SourceRepository @@ -39,6 +39,7 @@ REASON = StrikeReason("This head repeats the same decision in two unrelated places instead of using the existing class.") ACCEPTED = VerdictReason("The existing class owns this behavior without another copy") +QUALIFIED = VerdictReason("The source stage is reviewed while paired implementation remains incomplete") @pytest.mark.parametrize('roast,fields', ( @@ -78,11 +79,140 @@ def head_bus(held_bus, github_cli, reviewed_source): return held_bus, github_cli, opened(github_cli, source, 7, source.branch_commit) +@pytest.fixture +def linked_head_bus(head_bus): + comms, _, request = head_bus + todo = comms.goals.todos.get("P2") + held = todo.held() + RecordPullRequest(todo.revision, held.owner_identity, + PullRequestLink(request.repo, request.pr), held.generation).record(comms.goals, todo.id) + return head_bus + + def invoke(comms, request, verdict, order=NoHeadOrder()): tool = CommsHeadVerdictTool(request=request, operation=verdict, order=order) return tool.invoke(comms, tool.arguments, caller=ThreadCaller("lead")) +def test_qualification_records_each_open_order_cut_without_promoting_or_finishing_work( + linked_head_bus, claim_order): + """A ready companion's source stage is a decision, not permission to ship its incomplete pair.""" + comms, github, request = linked_head_bus + context = order.Commander.read_root(comms.root) + + def qualify_unchanged(): + before = comms.goals.todos.get("P2") + goal = comms.registry.require("worker").goal.require() + quote = before.order.checked().edits[0] + judge.main(["qualify", "--repo", request.repo.to_wire(), "--pr", str(request.pr), + "--head", request.head, "--why", QUALIFIED.text, "--serves", quote], context) + assert comms.goals.todos.get("P2") == replace(before, revision=before.revision + 1) + assert comms.registry.require("worker").goal.require() == replace(goal, revision=goal.revision + 1) + record = StoredHeadReview.head_reviews(comms.goals.todos, request)[0] + assert record.verdict == QualifiedHeadVerdict(QUALIFIED) + assert record.order == LinkedHeadReview.of(before, quote) + assert record.verdict.decides() + assert record.verdict.settles_edits(record, 0) == record.at.recorded_at("qualified source cut") + assert not record.verdict.allows_integration() + assert not HeadReview.accepted(request.repo, request.head) + with pytest.raises(TodoError, match="no recorded ok verdict"): + record.verdict.require_merge(request) + + qualify_unchanged() # Pending: the order still requires every unfinished clause. + claim_order("P2", pull_request=request.pr, head=request.head) + qualify_unchanged() # Claimed: the delivery is neither accepted nor completed. + todo = comms.goals.todos.get("P2") + invoke(comms, request, UnderReviewHeadVerdict(), OrderHeadReview.for_request( + (todo,), request, UnquotedHeadReview.of)) + qualify_unchanged() # Reviewing: the recorded source cut changes no held work. + posts = tuple(call for call in github.calls() if f"statuses/{request.head}" in call[1]) + assert len(posts) == 4 + assert all("state=pending" in call for call in posts) + assert not any(call[:2] == ("pr", "merge") for call in github.calls()) + + +def test_qualification_withdraws_acceptance_without_claiming_to_disarm_the_request(head_bus): + comms, github, request = head_bus + invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) + arms = tuple(call for call in github.calls() if call[:2] == ("pr", "merge")) + assert len(arms) == 1 and HeadReview.accepted(request.repo, request.head) + invoke(comms, request, QualifiedHeadVerdict(QUALIFIED)) + assert StoredHeadReview.current_verdict(comms.goals.todos, request) is QualifiedHeadVerdict + assert not HeadReview.accepted(request.repo, request.head) + assert tuple(call for call in github.calls() if call[:2] == ("pr", "merge")) == arms + posts = tuple(call for call in github.calls() if f"statuses/{request.head}" in call[1]) + assert "state=pending" in posts[-1] + # Only a later full acceptance can permit promotion and request an arm again. + invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) + assert HeadReview.accepted(request.repo, request.head) + assert len(tuple(call for call in github.calls() if call[:2] == ("pr", "merge"))) == 2 + + +def test_qualification_codec_and_head_only_cli_require_a_reason(): + verdict = QualifiedHeadVerdict(QUALIFIED) + assert FieldCodec.decode(HeadVerdict, FieldCodec.encode(verdict, HeadVerdict)) == verdict + with pytest.raises(MalformedRecord): + FieldCodec.decode(HeadVerdict, {"kind": FieldCodec.encode(QualifiedHeadVerdict)}) + common = ["qualify", "--pr", "7", "--head", "a" * 40] + assert judge.JudgeVerb.parse([*common, "--why", QUALIFIED.text]).head_operation() == verdict + with pytest.raises(RecordProblems) as refused: + judge.JudgeVerb.parse(common) + assert "why" in refused.value.problems + with pytest.raises(RecordProblems) as refused: + judge.JudgeVerb.parse(["qualify", "E1:abcd", "--why", QUALIFIED.text, + "--serves", "Repair the existing owner"]) + assert "ids" in refused.value.problems + + +@pytest.mark.parametrize("quote", ("", "These words never occur in the held order")) +def test_linked_qualification_requires_the_original_order_quote(linked_head_bus, quote): + comms, github, request = linked_head_bus + before = comms.goals.todos.get("P2") + with pytest.raises(ToolRefusedError): + judge.main(["qualify", "--repo", request.repo.to_wire(), "--pr", str(request.pr), + "--head", request.head, "--why", QUALIFIED.text, "--serves", quote], + order.Commander.read_root(comms.root)) + assert comms.goals.todos.get(before.id) == before + assert not StoredHeadReview.head_reviews(comms.goals.todos, request) + assert not any("statuses/" in call[1] for call in github.calls()) + + +def test_qualification_refuses_a_noncommander_before_any_record_or_public_status(linked_head_bus): + comms, github, request = linked_head_bus + before = comms.goals.todos.get("P2") + tool = CommsHeadVerdictTool( + request=request, operation=QualifiedHeadVerdict(QUALIFIED), + order=OrderHeadReview.for_request((before,), request, + lambda linked: LinkedHeadReview.of(linked, linked.order.edits[0]))) + with pytest.raises(ToolRefusedError): + tool.invoke(comms, tool.arguments, caller=ThreadCaller("worker")) + assert comms.goals.todos.get(before.id) == before + assert not StoredHeadReview.head_reviews(comms.goals.todos, request) + assert not any("statuses/" in call[1] for call in github.calls()) + + +def test_qualification_does_not_carry_acceptance_to_an_unchanged_later_head(head_bus, reviewed_source): + comms, github, request = head_bus + source = reviewed_source + invoke(comms, request, QualifiedHeadVerdict(QUALIFIED)) + (source.checkout / "base_only.py").write_text("advanced = True\n") + source.git(source.checkout, "add", "base_only.py") + source.git(source.checkout, "commit", "-m", "Advance the base independently") + source.git(source.checkout, "push", "origin", "main") + source.git(source.checkout, "checkout", "feature") + source.git(source.checkout, "merge", "--no-edit", "main") + head = source.git(source.checkout, "rev-parse", "HEAD").strip() + source.git(source.checkout, "push", "origin", "HEAD:refs/pull/7/head") + moved = opened(github, source, request.pr, head) + assert request.change_identity(WorkBranches(source.checkout), "main") == moved.change_identity( + WorkBranches(source.checkout), "main") + pulls = ProgressRead() + pulls.carry_reviews(comms.goals, request.repo) + assert not StoredHeadReview.head_reviews(comms.goals.todos, moved) + assert not pulls.open(request.repo)[0].has_head_review + assert not any(f"statuses/{head}" in call[1] for call in github.calls()) + + def test_integration_reads_latest_review_in_the_declared_context(github_cli, tmp_path): source = SourceRepository.create(tmp_path / 'status-source') github_cli.configure(source.github) @@ -209,11 +339,12 @@ def test_judge_reviewing_after_ok_withdraws_the_accepted_status_on_that_head( "state=success", "state=pending"] -def test_moved_head_posts_and_records_nothing(head_bus): +@pytest.mark.parametrize("verdict", (ReviewedHeadVerdict(ACCEPTED), QualifiedHeadVerdict(QUALIFIED))) +def test_moved_head_posts_and_records_nothing(head_bus, verdict): comms, github, request = head_bus moved = replace(request, head="a" * 40) with pytest.raises(ToolRefusedError): - invoke(comms, moved, ReviewedHeadVerdict(ACCEPTED)) + invoke(comms, moved, verdict) assert not StoredHeadReview.head_reviews(comms.goals.todos, moved) assert not any("statuses/" in call[1] for call in github.calls()) diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index f2eb296609..9661d30491 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -152,14 +152,14 @@ def test_stop_rejects_a_human_before_any_registry_change(comms, tmp_path): @pytest.fixture def claim_order(held_bus): """Publish the real order and claim messages, using their retained identities.""" - def claim(problem): + def claim(problem, *, pull_request=1, head="a" * 40): todo = held_bus.goals.todos.get(problem) lead = held_bus.registry.require("lead").incarnation worker = held_bus.registry.require("worker").incarnation original = held_bus.messaging.send("lead", "worker", todo.order.instructions(todo)).message todo = held_bus.goals.todos.apply( todo.id, OrderMessage(todo.revision, lead, original.message_id, at=RecordedEventTime(time.time()))) - claimed = OrderClaim.for_order(todo, worker, 1, "a" * 40, + claimed = OrderClaim.for_order(todo, worker, pull_request, head, "Review the published repair", original.reference, store=held_bus.goals.todos) claimed = replace(claimed, message=replace( diff --git a/tests/test_integration.py b/tests/test_integration.py index 2e91c7e33c..51bd8e66b0 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -11,7 +11,7 @@ from agent_comms.repository_name import RepositoryName from agent_comms.repo import Repo from agent_comms.todos import ( - DraftListedPullRequest, ReviewedHeadVerdict, UnmergedListedPullRequest, VerdictReason, + DraftListedPullRequest, QualifiedHeadVerdict, ReviewedHeadVerdict, UnmergedListedPullRequest, VerdictReason, ) from source_revision_fixture import SourceRepository from fixture_git import ENVIRONMENT, SETTINGS @@ -50,6 +50,20 @@ def test_only_accepted_ready_members_are_selected(github_cli, tmp_path: Path): github_cli.put(source.github, DraftListedPullRequest( 8, "draft", source.branch_commit, stamp, "main", reviewable_times=(stamp,), )) + (source.checkout / "stage.py").write_text("source_stage = True\n") + source.git(source.checkout, "add", "stage.py") + source.git(source.checkout, "commit", "-m", "Prepare an incomplete source stage") + qualified = source.git(source.checkout, "rev-parse", "HEAD").strip() + source.git(source.checkout, "push", "origin", "HEAD:refs/pull/9/head") + github_cli.put(source.github, UnmergedListedPullRequest( + 9, "qualified", qualified, stamp, "main", reviewable_times=(stamp,), + )) + github_cli.put(source.github, UnmergedListedPullRequest( + 10, "unaccepted", source.main_commit, stamp, "main", reviewable_times=(stamp,), + )) + QualifiedHeadVerdict(VerdictReason("The source stage is reviewed while paired implementation remains incomplete")).post( + source.github, qualified, "lead", "source stage", + ) run = integration.IntegrationRun(integration.IntegrationCheckout(source.checkout), tmp_path / "output", "ready-head selection") assert run.accepted_heads() == () diff --git a/tests/test_queue_bypass_hook.py b/tests/test_queue_bypass_hook.py index 0bb049f910..9f4cd2f823 100644 --- a/tests/test_queue_bypass_hook.py +++ b/tests/test_queue_bypass_hook.py @@ -7,11 +7,11 @@ import pytest -from agent_comms.todos import ReviewedHeadVerdict, RejectedHeadVerdict +from agent_comms.todos import QualifiedHeadVerdict, ReviewedHeadVerdict, RejectedHeadVerdict from agent_comms.shell_commands import ShellCommand from agent_comms_hooks.queue_bypass import MergeTarget, MergeTargetUnreadable, QueueBypass from agent_comms_hooks.watch import WatchClaim, WatchRecord -from test_head_review import ACCEPTED, REASON, invoke, opened +from test_head_review import ACCEPTED, QUALIFIED, REASON, invoke, opened pytest_plugins = ("test_head_review",) @@ -137,3 +137,5 @@ def refused(head): opened(github, reviewed_source, request.pr, request.head) invoke(comms, request, RejectedHeadVerdict(REASON)) refused(request.head) + invoke(comms, request, QualifiedHeadVerdict(QUALIFIED)) + refused(request.head) From 5bcc78d9b0098b862ff122bf71f13762a81133d3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 09:21:08 -0400 Subject: [PATCH 016/109] 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 3c9d1278f461c53f3de819f7a029ebd030237b80 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 09:23:58 -0400 Subject: [PATCH 017/109] Factor head-only review behavior into an abstract capability --- src/agent_comms/commander/judge.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 14e5df2460..f8cd72fad5 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -546,12 +546,8 @@ def check_edits(self, edits: list[Edit]) -> None: AcceptedVerdict.require_branches(edit.branches_added(), self.no_owner) @dataclass(frozen=True, kw_only=True) -class ReviewingVerb(HeadVerdictVerb): - help = "a fork is reading this exact PR head; its delivery is not owed until ok or back" - verdict = UnderReviewHeadVerdict - - def head_operation(self) -> HeadVerdict: - return self.verdict() +class HeadOnlyVerb(HeadVerdictVerb, declared_name=None): + """Shared argument refusal for verdicts that only select an exact PR head.""" def edit_problems(self) -> dict[str, str]: return super().edit_problems() | MalformedRecord( @@ -567,7 +563,16 @@ def check_edits(self, edits: list[Edit]) -> None: @dataclass(frozen=True, kw_only=True) -class QualifyVerb(ReasonedVerb, QuotingVerb, ReviewingVerb): +class ReviewingVerb(HeadOnlyVerb): + help = "a fork is reading this exact PR head; its delivery is not owed until ok or back" + verdict = UnderReviewHeadVerdict + + def head_operation(self) -> HeadVerdict: + return self.verdict() + + +@dataclass(frozen=True, kw_only=True) +class QualifyVerb(ReasonedVerb, QuotingVerb, HeadOnlyVerb): """Record a reasoned source decision using the existing head-only review boundary.""" help = "the exact PR head's source stage is qualified, without permission to merge" From 7b5c78a0ebd833471b5f93549010e6faafe65cba Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 10:19:00 -0400 Subject: [PATCH 018/109] 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 019/109] 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 020/109] 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 021/109] 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 5a8d979b8432e529c266b5257345c23fff140349 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:19:34 -0400 Subject: [PATCH 022/109] Bind review patch identity to immutable integration source metadata --- src/agent_comms/head_review.py | 95 +++++++++++++++++++++++++++- src/agent_comms/work_branches.py | 5 ++ tests/test_head_review.py | 104 ++++++++++++++++++++++++++++++- 3 files changed, 202 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index 5e30777908..16cb4dc4b7 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -5,12 +5,14 @@ from __future__ import annotations +import hashlib import json from abc import abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING, Annotated, ClassVar from collections.abc import Sequence +from .build_identity import RepositoryCommit from .declared_family import DeclaredFamily from .errors import ToolRefusedError from .field_codec import FieldCodec, GitCommitText, MalformedRecord, RecordProblems @@ -40,6 +42,7 @@ class PullRequestChange: """Git's whitespace-preserving identity of a pull request's complete patch.""" patch: str + integration_digest: str = "" @dataclass(frozen=True) @@ -94,7 +97,10 @@ def change_identity(self, reader: WorkBranches, base: str) -> PullRequestChange: Verbatim patch IDs preserve Python indentation and whitespace in literals. The existing Git reader shares one base-head read and fetches missing objects. """ - return PullRequestChange(reader.pull_request_patch_id(base, self.head)) + return PullRequestChange( + reader.pull_request_patch_id(base, self.head), + IntegrationInput.digest_from_message(reader.commit_message(self.head)), + ) def holds(self, commit: str) -> bool: return self.head.startswith(commit) @@ -113,6 +119,93 @@ def current(self) -> OpenPullRequestHead: return current +@dataclass(frozen=True) +class IntegrationSource: + """One repository's captured main, exact PR overlays and resulting Git tree.""" + + baseline: RepositoryCommit + heads: tuple[PullRequestHead, ...] + candidate_tree: Annotated[str, GitCommitText] + + def __post_init__(self) -> None: + for commit in (self.baseline.commit, self.candidate_tree, *(head.head for head in self.heads)): + GitCommitText.decode(commit) + if len(commit) != 40 or commit != commit.lower(): + raise MalformedRecord("Integration inputs require full lowercase 40-hex Git identities.") + if any(head.repo != self.repository for head in self.heads): + raise MalformedRecord("Every integration member must belong to its source repository.") + if len({head.pr for head in self.heads}) != len(self.heads): + raise MalformedRecord("An integration source cannot name a PR twice.") + if self.heads != tuple(sorted(self.heads, key=lambda head: head.pr)): + raise MalformedRecord("Integration members must be in PR-number order.") + + @property + def repository(self) -> RepositoryName: + return self.baseline.repository + + +@dataclass(frozen=True) +class IntegrationInput: + """Immutable compared source cuts, bound by their carrier's reviewed Git commit. + + This is not an authorization record. Exact-head review and draft state still decide + promotion. The canonical payload deliberately cannot contain its own carrier SHA. + """ + + sources: tuple[IntegrationSource, ...] + trailer_name: ClassVar[str] = "Agent-Comms-Integration" + + def __post_init__(self) -> None: + repositories = tuple(source.repository for source in self.sources) + if not repositories or len(set(repositories)) != len(repositories): + raise MalformedRecord("Integration inputs require unique, nonempty repository sources.") + if repositories != tuple(sorted(repositories, key=lambda repo: repo.key)): + raise MalformedRecord("Integration sources must be in repository-name order.") + + def require_repositories(self, repositories: tuple[RepositoryName, ...]) -> None: + """The actual stack declaration supplies coverage, never a caller's package roster.""" + if {source.repository for source in self.sources} != set(repositories): + raise ToolRefusedError("Integration inputs omit a declared stack source or name an unknown one.") + + def source(self, repository: RepositoryName) -> IntegrationSource: + selected = tuple(source for source in self.sources if source.repository == repository) + if len(selected) != 1: + raise ToolRefusedError(f"Integration inputs do not uniquely select {repository.to_wire()}.") + return selected[0] + + @property + def canonical(self) -> str: + return FieldCodec.compact(FieldCodec.encode(self), sort_keys=True, allow_nan=False) + + @property + def digest(self) -> str: + return hashlib.sha256(self.canonical.encode("utf-8")).hexdigest() + + @property + def trailer(self) -> str: + return f"{self.trailer_name}: {self.canonical}" + + @classmethod + def from_message(cls, message: str) -> tuple[IntegrationInput, ...]: + """Absent is ordinary; any attempted malformed/duplicate trailer refuses.""" + lines = message.rstrip("\n").split("\n") + marked = tuple(line for line in lines if line.lstrip().startswith(cls.trailer_name)) + if not marked: + return () + prefix = f"{cls.trailer_name}: " + if len(marked) != 1 or not marked[0].startswith(prefix) or marked[0] != lines[-1]: + raise MalformedRecord("Integration metadata requires one final canonical trailer.") + payload = marked[0].removeprefix(prefix) + decoded = FieldCodec.decode(cls, FieldCodec.parse(payload)) + if decoded.canonical != payload: + raise MalformedRecord("Integration metadata is not canonical.") + return (decoded,) + + @classmethod + def digest_from_message(cls, message: str) -> str: + return "".join(inputs.digest for inputs in cls.from_message(message)) + + @dataclass(frozen=True) class ReadPullRequestHead(PullRequestHead): """The head as GitHub reported it, with the base branch the same read named.""" diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index 96b04c8e91..fe80aa439d 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -365,6 +365,11 @@ def pull_request_base(self, base: str, head: str) -> str: self.require_commits("origin", base_head, head) return base_head + def commit_message(self, head: str) -> str: + """Read an immutable commit's message; its domain owner decodes any metadata.""" + self.require_commits("origin", head) + return self._git("show", "--no-patch", "--format=%B", head) + def pull_request_patch_id(self, base: str, head: str) -> str: base_head = self.pull_request_base(base, head) patch = self._git( diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 7a19b041f4..99e6ea27cd 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -3,6 +3,7 @@ import subprocess import sys import time +from collections.abc import Callable from dataclasses import replace from datetime import UTC, datetime from pathlib import Path @@ -13,9 +14,13 @@ from agent_comms.commander import judge, order from agent_comms.goal_actions import CreateProblemGoalAction from agent_comms.heartbeat_frame import HeartbeatFrame -from agent_comms.head_review import HeadReview, PullRequestHead, ReadPullRequestHead +from agent_comms.build_identity import RepositoryCommit +from agent_comms.head_review import ( + HeadReview, IntegrationInput, IntegrationSource, PullRequestHead, ReadPullRequestHead, +) from agent_comms.heartbeat_subjects import CommanderHistory, HeadSubject from agent_comms.recorded_time import RecordedEventTime +from agent_comms.repository_name import RepositoryName from agent_comms.standing_rules import ( HeadReviewTimerUp, IdlePullRequest, OrderlessPullRequest, OrderlessUnreviewedHead, OrderRefusingStandingRule, ReviewTimerUp, StuckMerge, @@ -33,6 +38,7 @@ HeadVerdict, QualifiedHeadVerdict, ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, StrikeReason, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, ) +from github_fixture import GitHubFixture from source_revision_fixture import SourceRepository pytest_plugins = ("test_holder_standing_rules", "github_fixture") @@ -213,6 +219,102 @@ def test_qualification_does_not_carry_acceptance_to_an_unchanged_later_head(head assert not any(f"statuses/{head}" in call[1] for call in github.calls()) +@pytest.fixture +def captured_input(reviewed_source: SourceRepository) -> IntegrationInput: + source = reviewed_source + return IntegrationInput((IntegrationSource( + RepositoryCommit(source.github, source.main_commit), + (PullRequestHead(source.github, source.number, source.branch_commit),), + source.git(source.checkout, "rev-parse", f"{source.branch_commit}^{{tree}}").strip(), + ),)) + + +def test_integration_metadata_is_canonical_and_ordinary_messages_have_no_digest( + captured_input: IntegrationInput) -> None: + inputs = captured_input + assert FieldCodec.decode(IntegrationInput, FieldCodec.parse(inputs.canonical)) == inputs + message = f"Combine the real source changes\n\n{inputs.trailer}\n" + assert IntegrationInput.from_message(message) == (inputs,) + assert IntegrationInput.digest_from_message(message) == inputs.digest + assert IntegrationInput.from_message("An ordinary substantive change\n") == () + assert IntegrationInput.digest_from_message("An ordinary substantive change\n") == "" + + +@pytest.mark.parametrize("damage", ( + lambda trailer: trailer + "\n" + trailer, + lambda trailer: trailer.replace(": ", ":\t", 1), + lambda trailer: trailer.replace('"sources":', '"sources": ', 1), + lambda trailer: trailer + "\nUnrelated text after the trailer", + lambda trailer: trailer + "garbage", +)) +def test_attempted_integration_metadata_never_falls_back_to_ordinary_mode( + captured_input: IntegrationInput, damage: Callable[[str], str]) -> None: + with pytest.raises(MalformedRecord): + IntegrationInput.from_message(damage(captured_input.trailer)) + + +def test_integration_metadata_refuses_duplicate_json_fields_and_unknown_shapes( + captured_input: IntegrationInput) -> None: + canonical = captured_input.canonical + duplicate = canonical[:-1] + "," + canonical[1:] + for payload in (duplicate, '{"sources":[],"unsupported_version":2}'): + with pytest.raises(MalformedRecord): + IntegrationInput.from_message(f"{IntegrationInput.trailer_name}: {payload}") + + +def test_integration_source_refuses_duplicates_abbreviations_and_foreign_members( + captured_input: IntegrationInput) -> None: + source, = captured_input.sources + head, = source.heads + with pytest.raises(MalformedRecord): + replace(source, heads=(head, head)) + with pytest.raises(MalformedRecord): + replace(source, baseline=replace(source.baseline, commit=source.baseline.commit[:9])) + with pytest.raises(MalformedRecord): + replace(source, heads=(replace(head, head=head.head[:9]),)) + with pytest.raises(MalformedRecord): + replace(source, heads=(replace(head, repo=RepositoryName.from_wire("other/repository")),)) + with pytest.raises(MalformedRecord): + IntegrationInput((source, source)) + with pytest.raises(ToolRefusedError): + captured_input.require_repositories((source.repository, RepositoryName.from_wire("other/repository"))) + + +def test_companion_input_changes_the_identity_of_an_unchanged_local_patch( + captured_input: IntegrationInput, reviewed_source: SourceRepository, tmp_path: Path, + github_cli: GitHubFixture) -> None: + source = reviewed_source + companion = SourceRepository.create(tmp_path / "companion", RepositoryName.from_wire("example/client")) + client = IntegrationSource( + RepositoryCommit(companion.github, companion.main_commit), + (PullRequestHead(companion.github, companion.number, companion.branch_commit),), + companion.git(companion.checkout, "rev-parse", f"{companion.branch_commit}^{{tree}}").strip(), + ) + inputs = IntegrationInput(tuple(sorted((*captured_input.sources, client), key=lambda row: row.repository.key))) + companion.git(companion.checkout, "checkout", "feature") + owner = companion.checkout / "owner.py" + owner.write_text(owner.read_text() + "new_export = True\n") + moved = companion.publish_change("Change the paired client", "owner.py") + changed = replace(client, heads=(replace(client.heads[0], head=moved),), + candidate_tree=companion.git(companion.checkout, "rev-parse", f"{moved}^{{tree}}").strip()) + updated = replace(inputs, sources=tuple(changed if row == client else row for row in inputs.sources)) + core, = captured_input.sources + heads = tuple(PullRequestHead(source.github, source.number, source.git( + source.checkout, "commit-tree", core.candidate_tree, + "-p", source.main_commit, "-p", source.branch_commit, + "-m", f"Combine the substantive Core source\n\n{snapshot.trailer}", + ).strip()) for snapshot in (inputs, updated)) + reader = WorkBranches(source.checkout) + before, after = (head.change_identity(reader, "main") for head in heads) + assert before.patch == after.patch + assert before.integration_digest == inputs.digest + assert after.integration_digest == updated.digest + assert before != after + # An author's canonical self-hash is source identity, never an accepted review. + github_cli.configure(source.github) + assert not any(HeadReview.accepted(head.repo, head.head) for head in heads) + + def test_integration_reads_latest_review_in_the_declared_context(github_cli, tmp_path): source = SourceRepository.create(tmp_path / 'status-source') github_cli.configure(source.github) From ed6ff38d2c11be72ff9129c16d70637c3fd2f5fd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:25:55 -0400 Subject: [PATCH 023/109] 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 024/109] 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 025/109] 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 026/109] 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 027/109] 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 028/109] 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 029/109] 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 be79ae6e7ce4fa1155a4873906779d7e4ef7ebae Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:52:15 -0400 Subject: [PATCH 030/109] Capture matched source trees and guard final carrier review at their owners --- src/agent_comms/head_review.py | 110 ++++++++++- .../installer/runtime_resolution.py | 66 ++++++- src/agent_comms/integration.py | 172 ++++++++++++++++-- src/agent_comms/order_tools.py | 3 + src/agent_comms/repo.py | 7 +- src/agent_comms/todos.py | 33 ++++ tests/github_cli_stub.py | 5 +- tests/integration_source_fixture.py | 100 ++++++++++ tests/source_revision_fixture.py | 9 + tests/test_head_review.py | 52 +++++- tests/test_integration.py | 87 ++++++++- tools/debt_ratchet.py | 26 +-- tools/module_layers.py | 16 +- 13 files changed, 623 insertions(+), 63 deletions(-) create mode 100644 tests/integration_source_fixture.py diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index 16cb4dc4b7..385cedf87a 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -9,6 +9,7 @@ import json from abc import abstractmethod from dataclasses import dataclass, field +from pathlib import Path from typing import TYPE_CHECKING, Annotated, ClassVar from collections.abc import Sequence @@ -16,6 +17,7 @@ from .declared_family import DeclaredFamily from .errors import ToolRefusedError from .field_codec import FieldCodec, GitCommitText, MalformedRecord, RecordProblems +from .installer.runtime_resolution import StackSources from .repo import Repo from .repository_name import RepositoryName from .work_branches import WorkBranches @@ -85,10 +87,12 @@ def among(cls, repositories: tuple[RepositoryName, ...], pr: int, head: str) -> def at(cls, repo: RepositoryName, pr: int) -> OpenPullRequestHead: """Read GitHub's head, its base branch and draft state in one request.""" read = repo.rest(f"pulls/{pr}", "--jq", - '.head.sha + " " + .base.ref + " " + (.draft | tostring)') + '.head.sha + " " + .base.ref + " " + (.draft | tostring) + " " + .state') if read.returncode: raise ToolRefusedError(read.stderr.strip() or f"#{pr} could not be read.") - head, base, draft = read.stdout.split() + head, base, draft, state = read.stdout.split() + if state != "open": + raise ToolRefusedError(f"#{pr} is no longer an open source.") return OpenPullRequestHead.drafted(draft)(repo, pr, head, base=base) def change_identity(self, reader: WorkBranches, base: str) -> PullRequestChange: @@ -143,6 +147,46 @@ def __post_init__(self) -> None: def repository(self) -> RepositoryName: return self.baseline.repository + @staticmethod + def main_head(repository: Repo) -> str: + rows = Repo.git(repository.checkout, "ls-remote", "--exit-code", "origin", "refs/heads/main").splitlines() + if len(rows) != 1: + raise ToolRefusedError(f"No unique main in {repository.github.to_wire()}") + commit, reference = rows[0].split() + if reference != "refs/heads/main": + raise ToolRefusedError("The remote answered with a different main reference") + GitCommitText.decode(commit) + if len(commit) != 40: + raise ToolRefusedError("Remote main did not resolve to a full Git identity") + return commit + + def expected_tree(self, repository: Repo) -> str: + """Reproduce the ordered Git merge result without moving a registered checkout.""" + reader = WorkBranches(repository.checkout) + reader.require_commits("origin", self.baseline.commit, *(head.head for head in self.heads)) + commit = self.baseline.commit + for head in self.heads: + tree = Repo.git(repository.checkout, "merge-tree", "--write-tree", commit, head.head).strip() + commit = Repo.git(repository.checkout, "commit-tree", tree, + "-p", commit, "-p", head.head, + "-m", "Validate captured source combination").strip() + return Repo.git(repository.checkout, "rev-parse", f"{commit}^{{tree}}").strip() + + def validate(self, repository: Repo, carrier: PullRequestHead) -> None: + if Repo.of(repository.checkout).github != self.repository: + raise ToolRefusedError("A registered source checkout has a different Git origin") + if self.main_head(repository) != self.baseline.commit: + raise ToolRefusedError(f"{self.repository.to_wire()} main moved; recapture inputs") + if self.expected_tree(repository) != self.candidate_tree: + raise ToolRefusedError(f"{self.repository.to_wire()} candidate tree is not its captured combination") + for head in self.heads: + current = PullRequestHead.at(head.repo, head.pr) + if current.base != "main": + raise ToolRefusedError(f"#{head.pr} no longer targets captured main") + expected = carrier.head if head.same_pull_request(carrier) else head.head + if current.head != expected: + raise ToolRefusedError(f"{head.repo.to_wire()}#{head.pr} moved; recapture inputs") + @dataclass(frozen=True) class IntegrationInput: @@ -173,6 +217,39 @@ def source(self, repository: RepositoryName) -> IntegrationSource: raise ToolRefusedError(f"Integration inputs do not uniquely select {repository.to_wire()}.") return selected[0] + @staticmethod + def declared_repositories(core: Repo, baseline: str, candidate: str) -> tuple[RepositoryName, ...]: + """Actual declarations on both sides own the universe, including added/removed clients.""" + declarations = tuple(StackSources.declared_sources( + Repo.git(core.checkout, "show", f"{commit}:stack/pyproject.toml")) + for commit in (baseline, candidate)) + return tuple(sorted({core.github, *(Repo.repository_url(url) + for side in declarations for url, branch in side.values())}, key=lambda name: name.key)) + + def validate(self, repositories: tuple[Repo, ...], carrier: PullRequestHead) -> None: + """Git and forge prove this frozen input; Todo and verdict owners decide permission.""" + core = Repo.selected(repositories, carrier.repo.to_wire()) + WorkBranches(core.checkout).require_commits("origin", carrier.head) + source = self.source(carrier.repo) + self.require_repositories(self.declared_repositories(core, source.baseline.commit, carrier.head)) + members = tuple(head for head in source.heads if head.same_pull_request(carrier)) + if len(members) != 1: + raise ToolRefusedError("The integration carrier is not a unique captured local PR") + if members[0].head == carrier.head: + raise ToolRefusedError("An input captures the carrier's earlier source, never its own commit SHA") + for entry in self.sources: + entry.validate(Repo.selected(repositories, entry.repository.to_wire()), carrier) + for head in source.heads: + if not Repo.descends(core.checkout, head.head, carrier.head): + raise ToolRefusedError(f"The carrier dropped original source #{head.pr}") + if Repo.git(core.checkout, "rev-parse", f"{carrier.head}^{{tree}}").strip() != source.candidate_tree: + raise ToolRefusedError("The carrier tree differs from its captured candidate") + if self.from_message(WorkBranches(core.checkout).commit_message(carrier.head)) != (self,): + raise ToolRefusedError("The current carrier does not contain this exact canonical input") + StackSources.require_compatible( + Repo.git(core.checkout, "show", f"{source.baseline.commit}:stack/pyproject.toml"), + Repo.git(core.checkout, "show", f"{carrier.head}:stack/pyproject.toml")) + @property def canonical(self) -> str: return FieldCodec.compact(FieldCodec.encode(self), sort_keys=True, allow_nan=False) @@ -206,6 +283,35 @@ def digest_from_message(cls, message: str) -> str: return "".join(inputs.digest for inputs in cls.from_message(message)) +class Side(DeclaredFamily, affix="Side"): + """One compared source cut; selection and materialized path belong to this member.""" + + @staticmethod + @abstractmethod + def ref(base: str, head: str) -> str: + """This side's revision of the compared pair.""" + + @classmethod + def sides(cls) -> tuple[type[Side], ...]: + return cls.members_with(cls) + + @classmethod + def checkout(cls, directory: Path, repository: RepositoryName) -> Path: + return directory / cls.declared_name / Path(*repository.key) + + +class BaseSide(Side): + @staticmethod + def ref(base: str, head: str) -> str: + return base + + +class HeadSide(Side): + @staticmethod + def ref(base: str, head: str) -> str: + return head + + @dataclass(frozen=True) class ReadPullRequestHead(PullRequestHead): """The head as GitHub reported it, with the base branch the same read named.""" diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index bf6271ad73..5a0bec3d3c 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -10,10 +10,13 @@ from urllib.parse import parse_qs, urlsplit from packaging.markers import Marker +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet from packaging.utils import canonicalize_name from pydantic import BaseModel, Field from agent_comms.declared_family import DeclaredFamily +from agent_comms.field_codec import FieldCodec from agent_comms.installer.probes.packages import ArchivePin, GitPin, PackagePin, PackageRecord from agent_comms.installer.probes.packages import DistributionVersionsProbe, InstalledPackagesProbe from agent_comms.launch_runtime import LaunchRuntime @@ -23,6 +26,59 @@ from agent_comms.installer.upgrade import Stack +class StackSources: + """Branch-tracking source declarations shared by installation and source checking.""" + + @classmethod + def sources(cls, stack: Path) -> dict[str, tuple[str, str]]: + return cls.declared_sources((stack / "pyproject.toml").read_text()) + + @staticmethod + def require_compatible(baseline: str, candidate: str) -> None: + """Local-source membership may change; external runtime requirements must agree. + + This supported path compares the same external contract, not arbitrary requirement + migrations. Candidate metadata/settings are copied intact by the private project writer. + """ + documents = tuple(tomllib.loads(text) for text in (baseline, candidate)) + local_names: set[str] = set() + for document in documents: + match document: + case {"tool": {"uv": {"sources": dict() as sources}}}: + for name, source in sources.items(): + match source: + case {"git": str(), "branch": str()} | {"path": str()}: + local_names.add(canonicalize_name(FieldCodec.decode(str, name))) + contracts = [] + for document in documents: + match document: + case {"project": {"dependencies": list() as dependencies, **project}}: + requirements = tuple(Requirement(text) for text in FieldCodec.decode(tuple[str, ...], dependencies)) + contracts.append(frozenset(str(requirement) for requirement in requirements + if canonicalize_name(requirement.name) not in local_names)) + version = ".".join(map(str, sys.version_info[:3])) + python = FieldCodec.decode(str, project.get("requires-python", "")) + if not SpecifierSet(python).contains(version, prereleases=True): + raise ValueError(f"The common interpreter {version} does not satisfy {python}") + case _: + raise ValueError("A compared source requires explicit project runtime dependencies") + if contracts[0] != contracts[1]: + raise ValueError("External runtime dependency migration is unsupported by this common environment") + + @staticmethod + def declared_sources(document: str) -> dict[str, tuple[str, str]]: + match tomllib.loads(document): + case {"tool": {"uv": {"sources": dict() as sources}}}: + found: dict[str, tuple[str, str]] = {} + for name, source in sources.items(): + match source: + case {"git": str(url), "branch": str(branch)}: + GitSource.require_branches(name, (branch,)) + found[FieldCodec.decode(str, name)] = (url, branch) + return found + raise ValueError("The stack declares no [tool.uv.sources] table") + + class RuntimePackage(DeclaredFamily, affix="Package"): distribution: ClassVar[str] repository: ClassVar[RepositoryName] @@ -78,11 +134,15 @@ class GitSource(LockSource): git: str def tracked(self, name: str) -> tuple[str, ...]: - branches = parse_qs(urlsplit(self.git).query).get("branch", []) - if branches not in ([], ["main"]): - raise ValueError(f"{name} tracks {branches}, not main: {self.git}") + branches = tuple(parse_qs(urlsplit(self.git).query).get("branch", [])) + self.require_branches(name, branches) return (name,) if branches else () + @staticmethod + def require_branches(name: str, branches: tuple[str, ...]) -> None: + if branches not in ((), ("main",)): + raise ValueError(f"{name} tracks {branches}, not main") + def pin(self, version: str, core: str) -> PackagePin: commit = urlsplit(self.git).fragment if not commit: diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index fa50ffd6d7..1d4e93f10c 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -12,17 +12,23 @@ from pathlib import Path from typing import Annotated, ClassVar +from .active_route import resolve_comms_route +from .build_identity import RepositoryCommit +from .commander.order import CommanderContext from .child_process import STOP_GRACE_SECONDS, ResidentManager, SpawnedProcess +from .errors import ToolRefusedError from .field_codec import FieldCodec, WireName -from .head_review import HeadReview, PullRequestHead +from .head_review import BaseSide, HeadReview, HeadSide, IntegrationInput, IntegrationSource, PullRequestHead +from .installer.runtime_resolution import StackSources from .launch_runtime import CheckoutSource, RestoredRuntime from .repo import Checkout, GitCommandError, Repo from .systemd_unit import AgentsSlice +from .repository_name import RepositoryName from .test_lease import LeaseStart, TestLease from .todos import PullRequests +from .work_branches import WorkBranches - -class IntegrationFailed(RuntimeError): +class IntegrationFailed(ToolRefusedError): pass @@ -62,6 +68,9 @@ def files(self, checkout: Path, changed: tuple[Path, ...]) -> tuple[str, ...]: class FailureAction(ABC): + def require_qualification(self) -> None: + """Checking source capture does not run or retry a test selection.""" + def require_heads(self, heads: tuple[PullRequestHead, ...]) -> None: pass @@ -94,6 +103,9 @@ def argument(cls, value: str) -> ExcludeAfterFailure: raise argparse.ArgumentTypeError("A positive pull request number is required") return cls(pr) + def require_qualification(self) -> None: + raise IntegrationFailed("Qualification cannot exclude a captured source after failure") + def require_heads(self, heads: tuple[PullRequestHead, ...]) -> None: if not any(head.pr == self.pr for head in heads): raise IntegrationFailed(f"#{self.pr} is not in the reviewed integration heads") @@ -128,6 +140,38 @@ def fresh_main(self) -> str: Repo.git(self.path, "fetch", "origin", timeout=300) return Repo.git(self.path, "rev-parse", "origin/main").strip() + @classmethod + def create(cls, repository: Repo, path: Path, commit: str) -> IntegrationCheckout: + if path.exists(): + raise IntegrationFailed(f"Preserve existing qualification checkout: {path}") + WorkBranches(repository.checkout).require_commits("origin", commit) + path.parent.mkdir(parents=True, exist_ok=True) + Repo.git(repository.checkout, "worktree", "add", "--detach", str(path), commit, timeout=300) + created = cls(path) + created.require_editable() + return created + + def include(self, head: PullRequestHead) -> None: + """Fetch exactly the captured member; Git, not a patch heuristic, combines it.""" + Repo.git(self.path, "fetch", "origin", f"refs/pull/{head.pr}/head", timeout=300) + fetched = Repo.git(self.path, "rev-parse", "FETCH_HEAD").strip() + if fetched != head.head: + raise IntegrationFailed(f"#{head.pr} moved while fetching; capture its new head") + try: + Repo.git(self.path, "merge", "--no-ff", "--no-edit", head.head, timeout=300) + except GitCommandError: + if Repo.answers(self.path, "rev-parse", "-q", "--verify", "MERGE_HEAD"): + Repo.git(self.path, "merge", "--abort") + raise + + def combine_sources(self, base: str, heads: tuple[PullRequestHead, ...]) -> str: + """Checking admits incomplete sources, but never drops a conflicting member.""" + self.require_editable() + Repo.git(self.path, "switch", "--detach", base) + for head in heads: + self.include(head) + return Repo.git(self.path, "rev-parse", "HEAD^{tree}").strip() + def combine(self, base: str, heads: tuple[PullRequestHead, ...]) -> tuple[PullRequestHead, ...]: self.require_editable() Repo.git(self.path, "switch", "--detach", base) @@ -136,15 +180,9 @@ def combine(self, base: str, heads: tuple[PullRequestHead, ...]) -> tuple[PullRe current = head.current() if not HeadReview.accepted(current.repo, current.head): raise IntegrationFailed(f"#{head.pr} no longer has an accepted head review") - Repo.git(self.path, "fetch", "origin", f"refs/pull/{head.pr}/head", timeout=300) - fetched = Repo.git(self.path, "rev-parse", "FETCH_HEAD").strip() - if fetched != current.head: - raise IntegrationFailed(f"#{head.pr} moved while fetching; review its new head") try: - Repo.git(self.path, "merge", "--no-ff", "--no-edit", current.head, timeout=300) + self.include(current) except GitCommandError as error: - if Repo.answers(self.path, "rev-parse", "-q", "--verify", "MERGE_HEAD"): - Repo.git(self.path, "merge", "--abort") print(f"MERGE REFUSED #{head.pr} {current.head}: {error}", flush=True) else: combined.append(current) @@ -241,6 +279,101 @@ def accepted_heads(self) -> tuple[PullRequestHead, ...]: if HeadReview.accepted(repo.github, request.head) ) + def qualify(self, context: CommanderContext, problems: tuple[str, ...]) -> IntegrationInput: + """Prepare one checking carrier from stored orders; never test, post, ready or arm it.""" + self.checkout.require_editable() + self.failure.require_qualification() + if not problems or len(set(problems)) != len(problems): + raise IntegrationFailed("Qualification requires unique actual problem selectors") + if context.actor_identity.commanding_identity != context.incarnation: + raise IntegrationFailed("Qualification requires this root's existing commander authority") + todos = tuple(context.comms.goals.todos.get(problem) for problem in problems) + for todo in todos: + todo.require_commander(context.incarnation) + core = Repo.of(self.checkout.path) + registered_core = Repo.selected(context.repos, core.github.to_wire()) + base = IntegrationSource.main_head(registered_core) + WorkBranches(registered_core.checkout).require_commits("origin", base) + linked_repositories = {link.repo for todo in todos for order in todo.order.declarations + for link in order.pull_requests} + initial = {*IntegrationInput.declared_repositories(registered_core, base, base), *linked_repositories} + repositories = {name: Repo.selected(context.repos, name.to_wire()) for name in initial} + mains = {core.github: base, **{name: IntegrationSource.main_head(repo) + for name, repo in repositories.items() if name != core.github}} + pulls = PullRequests(todos) + heads: dict[RepositoryName, tuple[PullRequestHead, ...]] = {} + for repo in repositories.values(): + WorkBranches(repo.checkout).require_commits("origin", mains[repo.github]) + linked = PullRequests.linked_among(todos, repo.github) + selected = [] + for row in pulls.rows(repo.github, tuple(sorted(linked))): + if row.as_merged(): + merged, = row.as_merged() + if not repo.contains(merged.merge.oid, mains[repo.github]): + raise IntegrationFailed("A merged source is absent from captured main") + continue + if not row.as_unmerged() or len(linked[row.number]) != 1 or row.base != "main": + raise IntegrationFailed("A linked source is closed, ambiguous or targets another base") + selected.append(PullRequestHead(repo.github, row.number, row.head)) + heads[repo.github] = tuple(selected) + primary = PullRequests.linked_among((todos[0],), core.github) + carriers = tuple(head for head in heads[core.github] if head.pr in primary) + if len(carriers) != 1: + raise IntegrationFailed("The primary problem has no unique existing local carrier") + self.output.mkdir(parents=True, exist_ok=False) + IntegrationCheckout.create(registered_core, BaseSide.checkout(self.output, core.github), base) + tree = self.checkout.combine_sources(base, heads[core.github]) + candidate_commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() + required = IntegrationInput.declared_repositories(registered_core, base, candidate_commit) + if linked_repositories - set(required): + raise IntegrationFailed("A selected order links an unknown stack repository") + sources = [IntegrationSource(RepositoryCommit(core.github, base), heads[core.github], tree)] + for name in required: + if name == core.github: + continue + if name not in repositories: + repositories[name] = Repo.selected(context.repos, name.to_wire()) + mains[name] = IntegrationSource.main_head(repositories[name]) + heads[name] = () + repo = repositories[name] + if len(heads[name]) > 1: + raise IntegrationFailed("A companion repository has no unique existing linked carrier") + baseline = mains[name] + IntegrationCheckout.create(repo, BaseSide.checkout(self.output, name), baseline) + candidate = IntegrationCheckout.create(repo, HeadSide.checkout(self.output, name), baseline) + tree = candidate.combine_sources(baseline, heads[name]) + sources.append(IntegrationSource(RepositoryCommit(name, baseline), heads[name], tree)) + # Revalidate original sources before freezing the shared value or attaching its payload. + for source in sources: + repo = repositories[source.repository] + if IntegrationSource.main_head(repo) != source.baseline.commit: + raise IntegrationFailed("A captured main moved; preserve this failed attempt and recapture") + for head in source.heads: + head.current() + if source.expected_tree(repo) != source.candidate_tree: + raise IntegrationFailed("Actual and reproduced candidate trees differ") + current_todos = tuple(context.comms.goals.todos.get(todo.id) for todo in todos) + for todo in current_todos: + todo.require_commander(context.incarnation) + for source in sources: + linked = PullRequests.linked_among(current_todos, source.repository) + if any(head.pr not in linked for head in source.heads): + raise IntegrationFailed("A selected problem no longer links its captured source") + inputs = IntegrationInput(tuple(sorted(sources, key=lambda source: source.repository.key))) + context.comms.goals.todos.require_input_links(inputs) + if Repo.git(self.checkout.path, "rev-parse", f"{base}^{{tree}}").strip() == inputs.source(core.github).candidate_tree: + raise IntegrationFailed("The Core carrier has no substantive source change") + original_message = WorkBranches(self.checkout.path).commit_message(candidate_commit) + if IntegrationInput.from_message(original_message): + raise IntegrationFailed("Capture requires a fresh substantive integration commit, not nested metadata") + Repo.git(self.checkout.path, "commit", "--amend", "-m", + f"{original_message.rstrip()}\n\n{inputs.trailer}") + commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() + IntegrationCheckout.create(registered_core, HeadSide.checkout(self.output, core.github), commit) + (self.output / "input.json").write_text(inputs.canonical + "\n") + print(f"QUALIFICATION ONLY {commit}; inputs {inputs.digest}; {self.output}; no review or merge permission", flush=True) + return inputs + def test( self, base: str, heads: tuple[PullRequestHead, ...], start: LeaseStart, attempt: int ) -> TestAttempt: @@ -346,6 +479,11 @@ def run() -> int: help="new persistent directory for this run's two bounded logs", ) parser.add_argument("--risk", required=True) + parser.add_argument("--qualify", default="", metavar="PROBLEM", + help="prepare sources from this actual primary order, without running tests") + parser.add_argument("--with-problem", action="append", default=[], metavar="PROBLEM") + parser.add_argument("--comms-root", type=Path, + help="the existing authorized root for stored-problem qualification") parser.add_argument( ExcludeAfterFailure.option, type=ExcludeAfterFailure.argument, default=StopAfterFailure() ) @@ -358,13 +496,23 @@ def run() -> int: pytest_arguments = arguments.pytest_arguments if pytest_arguments[:1] == ["--"]: pytest_arguments = pytest_arguments[1:] - return IntegrationRun( + run = IntegrationRun( IntegrationCheckout(arguments.checkout.resolve()), arguments.output.resolve(), arguments.risk, arguments.drop_on_failure, tuple(pytest_arguments), - ).execute() + ) + # Decode the foreign CLI operation once; qualification never enters execute/test/lease. + if arguments.qualify: + if pytest_arguments: + parser.error("Qualification cannot run tests") + context = CommanderContext.read_root(resolve_comms_route(arguments.comms_root).root) + run.qualify(context, (arguments.qualify, *arguments.with_problem)) + return 0 + if arguments.with_problem: + parser.error("--with-problem requires --qualify") + return run.execute() if __name__ == "__main__": diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index 95deece7ca..fea81e6f1a 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -204,6 +204,9 @@ def apply(self, comms: Comms, caller: ThreadCaller) -> HeadReviewed: heads.append(self.request.current()) preparations = [] for current in heads: + with RecordProblems.collecting(problems, '--head', + 'recapture integration inputs from their actual linked sources'): + self.operation.prepare_sources(comms.goals, current) with RecordProblems.collecting(problems, self.operation.preparation_field, self.operation.preparation_fix): preparations.append(PreparedHeadReview(current, self.operation.subject(comms.goals, current))) diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index ca1d5e1c42..cd0215becd 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -216,8 +216,13 @@ def grep(cls, checkout: Path | str, *args: str) -> tuple[str, ...]: def of(cls, checkout: Path) -> Repo: """Its GitHub name comes from the checkout's own origin remote.""" url = cls.git(checkout, "remote", "get-url", "origin").strip() + return cls(checkout=checkout, github=cls.repository_url(url)) + + @staticmethod + def repository_url(url: str) -> RepositoryName: + """The same Git-origin identity for a checkout and a declared stack source.""" github = url.removesuffix(".git").replace(":", "/").split("/")[-2:] - return cls(checkout=checkout, github=RepositoryName.from_wire("/".join(github))) + return RepositoryName.from_wire("/".join(github)) class CheckoutHoldsWorkError(ValueError): diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 00d6cfedad..0a15746124 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -100,6 +100,7 @@ HeadReviewOrder, HeadReviewRelation, HeadReviewStatus, + IntegrationInput, MergeArm, OpenPullRequestHead, PreparedHeadReview, @@ -4046,6 +4047,9 @@ def require_merge(cls, request: PullRequestHead) -> None: raise TodoError( f"{request.repo.to_wire()}#{request.pr} at {request.head} has no recorded ok verdict.") + def prepare_sources(self, goals: Goals, request: ReadPullRequestHead) -> None: + """Marking review pending or rejecting it grants no source promotion permission.""" + def reviewed(self, goals: Goals, request: PullRequestHead, prepared: PreparedHeadReview, reviewer: ThreadIncarnation, order: HeadReviewOrder) -> HeadReviewed: request = replace(request, head=prepared.current.head) @@ -4134,6 +4138,10 @@ def carry(self, goals: Goals, record: HeadReviewRecord, current: ReadPullRequest return False if reviewed_change != current_change: return False + try: + self.prepare_sources(goals, current) + except (GitCommandError, WorkBranchError, subprocess.TimeoutExpired, ToolRefusedError, MalformedRecord): + return False verdict = CarriedHeadVerdict(self.reason, record.request) carried = replace(record, request=replace(record.request, head=current.head), verdict=verdict, at=RecordedEventTime(time.time())) @@ -4145,6 +4153,21 @@ def carry(self, goals: Goals, record: HeadReviewRecord, current: ReadPullRequest def allows_integration(cls) -> bool: return True + def prepare_sources(self, goals: Goals, request: ReadPullRequestHead) -> None: + """Final acceptance checks the actual source owners, never an author's self-hash.""" + project = goals.registry.snapshot().commander_project.require() + repository = Repo.selected(project.repos, request.repo.to_wire()) + message = WorkBranches(repository.checkout).commit_message(request.head) + for inputs in IntegrationInput.from_message(message): + goals.todos.require_input_links(inputs) + inputs.validate(project.repos, request) + for source in inputs.sources: + for head in source.heads: + if not head.same_pull_request(request) and not HeadReview.accepted(head.repo, head.head): + raise TodoError( + f"{head.repo.to_wire()}#{head.pr} at {head.head} has no accepted exact-head " + "source review; pending qualification cannot promote the carrier.") + def landing(self, current: OpenPullRequestHead) -> MergeArm: """Accepting the current head means landing it: arm it, pinned to that head.""" return current.arm() @@ -5971,6 +5994,16 @@ def for_goal(self, owner: ThreadIncarnation, goal_id: GoalId) -> tuple[Todo, ... return self._select(Assignment.sql_field('generation') + '=?', (goal_id,), reader=partial(self.held_in, unit=owner)) + def require_input_links(self, inputs: IntegrationInput) -> None: + """Only current stored orders, never metadata supplied by an author, link these sources.""" + todos = self.open_orders() + for source in inputs.sources: + linked = PullRequests.linked_among(todos, source.repository) + for head in source.heads: + if len(linked.get(head.pr, ())) != 1: + raise TodoError( + f"{head.repo.to_wire()}#{head.pr} has no unique actual Todo source authority") + def open_orders(self) -> tuple[Todo, ...]: """Every open order, whichever commander created it: their links say which requests any commander's board already owns.""" diff --git a/tests/github_cli_stub.py b/tests/github_cli_stub.py index 47b8903b90..183fb539c4 100755 --- a/tests/github_cli_stub.py +++ b/tests/github_cli_stub.py @@ -37,9 +37,10 @@ def main(): print(entry["listed"]["number"]) elif parts[:1] == ["pulls"] and len(parts) == 2: assert arguments[arguments.index("--jq") + 1] == ( - '.head.sha + " " + .base.ref + " " + (.draft | tostring)') + '.head.sha + " " + .base.ref + " " + (.draft | tostring) + " " + .state') listed = data["requests"][parts[1]]["listed"] - print(listed["head"], listed["base"], str(listed["kind"] == "draft").lower()) + state = "open" if listed["kind"] in data["open_kinds"] else "closed" + print(listed["head"], listed["base"], str(listed["kind"] == "draft").lower(), state) elif parts[:1] == ["statuses"] and len(parts) == 2: assert arguments[arguments.index("--method") + 1] == "POST" print("{}") diff --git a/tests/integration_source_fixture.py b/tests/integration_source_fixture.py new file mode 100644 index 0000000000..8669222059 --- /dev/null +++ b/tests/integration_source_fixture.py @@ -0,0 +1,100 @@ +"""One real Git/forge/Todo setup for matched source capture and promotion controls.""" + +from collections.abc import Callable +from dataclasses import dataclass, replace +from pathlib import Path +import time + +import pytest + +from agent_comms.commander.order import Commander, CommanderContext +from agent_comms.comms import Comms +from agent_comms.goal_actions import CreateProblemGoalAction +from agent_comms.head_review import PullRequestHead +from agent_comms.integration import IntegrationCheckout +from agent_comms.recorded_time import RecordedEventTime +from agent_comms.repo import Repo +from agent_comms.repository_name import RepositoryName +from agent_comms.todos import DraftListedPullRequest, RecordPullRequest +from fixture_git import ENVIRONMENT +from github_fixture import GitHubFixture +from source_revision_fixture import SourceRepository + + +@dataclass(frozen=True) +class MutualSources: + context: CommanderContext + core: SourceRepository + client: SourceRepository + checkout: IntegrationCheckout + output: Path + + def publish_carrier(self, github: GitHubFixture) -> PullRequestHead: + """Only tests emulate the root's explicit publication to the existing primary PR.""" + commit = self.core.git(self.checkout.path, "rev-parse", "HEAD").strip() + self.core.git(self.checkout.path, "push", "origin", f"{commit}:refs/pull/{self.core.number}/head") + stamp = "2026-10-24T00:00:00Z" + github.put(self.core.github, DraftListedPullRequest( + self.core.number, "feature", commit, stamp, "main", reviewable_times=(stamp,), + )) + return PullRequestHead(self.core.github, self.core.number, commit) + + +@pytest.fixture +def mutual_sources( + held_bus: Comms, + configured_repository: Callable[[Comms, *tuple[Repo, ...]], Commander], + github_cli: GitHubFixture, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> MutualSources: + for name, value in ENVIRONMENT.items(): + monkeypatch.setenv(name, value) + core = SourceRepository.create(tmp_path / "matched-core", held_bus.goals.todos.get("P2").repo) + client = SourceRepository.create(tmp_path / "matched-client", RepositoryName.from_wire("example/client")) + core_files = core.write_files({ + Path("pyproject.toml"): '[project]\nname = "agent-comms"\nversion = "0.0.1"\ndependencies = []\n', + Path("stack/pyproject.toml"): ( + '[project]\nname = "fixture-stack"\nversion = "0.0.1"\ndependencies = ["agent-comms", "source-client"]\n' + '[tool.uv.sources]\nagent-comms = {path = "..", editable = true}\n' + f'source-client = {{git = "{client.remote}", branch = "main"}}\n' + ), + Path("src/agent_comms/__init__.py"): "", + Path("src/agent_comms/api.py"): 'def old_api() -> str:\n return "baseline"\n', + }) + core.publish_change("Declare the baseline Core API and actual stack source", *core_files) + core.git(core.checkout, "push", "origin", "main") + client_files = client.write_files({ + Path("pyproject.toml"): '[project]\nname = "source-client"\nversion = "0.0.1"\ndependencies = []\n', + Path("src/workflow.py"): 'from agent_comms.api import old_api\n\ndef run() -> str:\n return old_api()\n', + }) + client.publish_change("Declare the baseline client import", *client_files) + client.git(client.checkout, "push", "origin", "main") + for source in (core, client): + source.git(source.checkout, "checkout", "-B", "feature", "main") + core.publish_change("Replace the Core export without an alias", *core.write_files({ + Path("src/agent_comms/api.py"): 'def new_api() -> str:\n return "candidate"\n', + })) + client.publish_change("Migrate the client to the new Core export", *client.write_files({ + Path("src/workflow.py"): 'from agent_comms.api import new_api\n\ndef run() -> str:\n return new_api()\n', + })) + context = configured_repository(held_bus, core, client) + primary = held_bus.goals.todos.get("P2") + held = primary.held() + RecordPullRequest(primary.revision, held.owner_identity, core.link, held.generation).record( + held_bus.goals, primary.id) + held_bus.goals.update_goal("worker", CreateProblemGoalAction( + problem="P3", commander="lead", request=primary.request, + assigner=held_bus.registry.require("lead").incarnation, + repo=client.github, text="Migrate the paired source client", + order=replace(primary.order.checked(), pull_requests=(client.link,), + check=replace(primary.order.check, directory=client.checkout)), + review_at=RecordedEventTime(time.time() + 3600), + )) + stamp = "2026-10-24T00:00:00Z" + for source in (core, client): + github_cli.put(source.github, DraftListedPullRequest( + source.number, "feature", source.branch_commit, stamp, "main", reviewable_times=(stamp,), + )) + checkout = IntegrationCheckout.create(core, tmp_path / "qualification", core.main_commit) + return MutualSources(context, core, client, checkout, tmp_path / "qualified-inputs") diff --git a/tests/source_revision_fixture.py b/tests/source_revision_fixture.py index 637bfa88ea..365c9878d4 100644 --- a/tests/source_revision_fixture.py +++ b/tests/source_revision_fixture.py @@ -1,5 +1,6 @@ """One real Git source/remote owner for citation tests; no source checkout is required.""" import ast +from collections.abc import Mapping from pathlib import Path import subprocess from typing import ClassVar, Self @@ -60,6 +61,14 @@ def git_input(repository: Path, text: str, *arguments: str) -> str: capture_output=True, check=True, timeout=30, ).stdout.strip() + def write_files(self, files: Mapping[Path, str]) -> tuple[str, ...]: + """Real source fixtures share file preparation, not a second source-selection mechanism.""" + for relative, text in files.items(): + destination = self.checkout / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(text) + return tuple(str(path) for path in files) + def publish_change(self, message: str, *files: str) -> str: """Commit fixture files and publish this PR's new head through real Git.""" self.git(self.checkout, 'add', '--', *files) diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 99e6ea27cd..3dfc6910f2 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -16,8 +16,9 @@ from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.build_identity import RepositoryCommit from agent_comms.head_review import ( - HeadReview, IntegrationInput, IntegrationSource, PullRequestHead, ReadPullRequestHead, + DraftMergeArm, HeadReview, IntegrationInput, IntegrationSource, PullRequestHead, ReadPullRequestHead, ) +from agent_comms.integration import IntegrationRun from agent_comms.heartbeat_subjects import CommanderHistory, HeadSubject from agent_comms.recorded_time import RecordedEventTime from agent_comms.repository_name import RepositoryName @@ -39,9 +40,10 @@ StrikeReason, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, ) from github_fixture import GitHubFixture +from integration_source_fixture import MutualSources from source_revision_fixture import SourceRepository -pytest_plugins = ("test_holder_standing_rules", "github_fixture") +pytest_plugins = ("test_holder_standing_rules", "github_fixture", "integration_source_fixture") REASON = StrikeReason("This head repeats the same decision in two unrelated places instead of using the existing class.") ACCEPTED = VerdictReason("The existing class owns this behavior without another copy") @@ -315,6 +317,52 @@ def test_companion_input_changes_the_identity_of_an_unchanged_local_patch( assert not any(HeadReview.accepted(head.repo, head.head) for head in heads) +def test_pending_sources_can_compute_but_only_reviewed_other_heads_allow_carrier_promotion( + mutual_sources: MutualSources, github_cli: GitHubFixture) -> None: + pair = mutual_sources + comms = pair.context.comms + client = PullRequestHead(pair.client.github, pair.client.number, pair.client.branch_commit) + invoke(comms, client, UnderReviewHeadVerdict()) + run = IntegrationRun(pair.checkout, pair.output, "pending inputs compute without promotion") + inputs = run.qualify(pair.context, ("P2", "P3")) + carrier = pair.publish_carrier(github_cli) + invoke(comms, carrier, UnderReviewHeadVerdict()) + for verdict in (UnderReviewHeadVerdict(), QualifiedHeadVerdict(QUALIFIED)): + invoke(comms, client, verdict) + with pytest.raises(TodoError, match="no accepted exact-head source review"): + invoke(comms, carrier, ReviewedHeadVerdict(ACCEPTED)) + assert not HeadReview.accepted(carrier.repo, carrier.head) + invoke(comms, client, ReviewedHeadVerdict(ACCEPTED)) + assert HeadReview.accepted(client.repo, client.head) + original, = inputs.source(pair.core.github).heads + assert not HeadReview.accepted(original.repo, original.head) + invoke(comms, carrier, ReviewedHeadVerdict(ACCEPTED)) + assert HeadReview.accepted(carrier.repo, carrier.head) + assert StoredHeadReview.current_verdict(comms.goals.todos, carrier) is ReviewedHeadVerdict + assert carrier.current().arm() == DraftMergeArm() + assert run.accepted_heads() == () + assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) + + +def test_a_moved_companion_refuses_final_review_without_writing_acceptance( + mutual_sources: MutualSources, github_cli: GitHubFixture) -> None: + pair = mutual_sources + run = IntegrationRun(pair.checkout, pair.output, "a changed actual source invalidates the input") + run.qualify(pair.context, ("P2", "P3")) + carrier = pair.publish_carrier(github_cli) + pair.client.publish_change("Move the companion", *pair.client.write_files({ + Path("src/new_file.py"): "changed = True\n", + })) + stamp = "2026-10-24T00:00:00Z" + github_cli.put(pair.client.github, DraftListedPullRequest( + pair.client.number, "feature", pair.client.branch_commit, stamp, "main", reviewable_times=(stamp,), + )) + with pytest.raises(TodoError, match="moved"): + invoke(pair.context.comms, carrier, ReviewedHeadVerdict(ACCEPTED)) + assert not HeadReview.accepted(carrier.repo, carrier.head) + assert not StoredHeadReview.head_reviews(pair.context.comms.goals.todos, carrier) + + def test_integration_reads_latest_review_in_the_declared_context(github_cli, tmp_path): source = SourceRepository.create(tmp_path / 'status-source') github_cli.configure(source.github) diff --git a/tests/test_integration.py b/tests/test_integration.py index 51bd8e66b0..9b65ffa2f9 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,16 +7,21 @@ import pytest from agent_comms import integration -from agent_comms.head_review import PullRequestHead +from agent_comms.errors import ToolRefusedError +from agent_comms.head_review import BaseSide, HeadReview, HeadSide, IntegrationInput, PullRequestHead from agent_comms.repository_name import RepositoryName from agent_comms.repo import Repo from agent_comms.todos import ( DraftListedPullRequest, QualifiedHeadVerdict, ReviewedHeadVerdict, UnmergedListedPullRequest, VerdictReason, ) +from agent_comms.field_codec import FieldCodec +from agent_comms.installer.runtime_resolution import StackSources +from github_fixture import GitHubFixture +from integration_source_fixture import MutualSources from source_revision_fixture import SourceRepository from fixture_git import ENVIRONMENT, SETTINGS -pytest_plugins = ("github_fixture",) +pytest_plugins = ("github_fixture", "test_holder_standing_rules", "integration_source_fixture") @pytest.fixture @@ -73,6 +78,84 @@ def test_only_accepted_ready_members_are_selected(github_cli, tmp_path: Path): assert run.accepted_heads() == (PullRequestHead(source.github, 7, source.branch_commit),) +def test_qualification_captures_actual_orders_without_reviewing_or_arming_sources( + mutual_sources: MutualSources, github_cli: GitHubFixture) -> None: + pair = mutual_sources + run = integration.IntegrationRun(pair.checkout, pair.output, "mutually migrated API/import source") + inputs = run.qualify(pair.context, ("P2", "P3")) + assert FieldCodec.decode(IntegrationInput, FieldCodec.parse((pair.output / "input.json").read_bytes())) == inputs + carrier = Repo.git(pair.checkout.path, "rev-parse", "HEAD").strip() + assert IntegrationInput.from_message(Repo.git(pair.checkout.path, "show", "--format=%B", "--no-patch", carrier)) == (inputs,) + for source in inputs.sources: + assert len(source.heads) == 1 + assert Repo.descends(HeadSide.checkout(pair.output, source.repository), source.heads[0].head, "HEAD") + assert Repo.git(BaseSide.checkout(pair.output, source.repository), "rev-parse", "HEAD").strip() == source.baseline.commit + assert Repo.git(HeadSide.checkout(pair.output, source.repository), "rev-parse", "HEAD^{tree}").strip() == source.candidate_tree + assert not HeadReview.accepted(source.repository, source.heads[0].head) + assert run.accepted_heads() == () + assert not any(call[:2] == ("pr", "merge") or "POST" in call for call in github_cli.calls()) + + +def test_real_mutual_imports_work_for_both_matched_cuts_and_fail_for_both_mixed_cuts( + mutual_sources: MutualSources) -> None: + import sys + + pair = mutual_sources + integration.IntegrationRun(pair.checkout, pair.output, "both mixed imports must fail").qualify( + pair.context, ("P2", "P3")) + results = {} + for core_side in (BaseSide, HeadSide): + for client_side in (BaseSide, HeadSide): + environment = {**os.environ, "PYTHONPATH": os.pathsep.join(( + str(core_side.checkout(pair.output, pair.core.github) / "src"), + str(client_side.checkout(pair.output, pair.client.github) / "src"), + ))} + result = subprocess.run( + (sys.executable, "-c", "from workflow import run; print(run())"), + cwd=pair.output, env=environment, text=True, capture_output=True, timeout=30, + ) + results[core_side, client_side] = result + assert results[BaseSide, BaseSide].returncode == 0 + assert results[BaseSide, BaseSide].stdout.strip() == "baseline" + assert results[HeadSide, HeadSide].returncode == 0 + assert results[HeadSide, HeadSide].stdout.strip() == "candidate" + assert results[BaseSide, HeadSide].returncode != 0 + assert results[HeadSide, BaseSide].returncode != 0 + + +def test_changed_main_during_source_review_refuses_the_frozen_input( + mutual_sources: MutualSources, github_cli: GitHubFixture) -> None: + pair = mutual_sources + run = integration.IntegrationRun(pair.checkout, pair.output, "stale baseline must refuse") + inputs = run.qualify(pair.context, ("P2", "P3")) + carrier = pair.publish_carrier(github_cli) + pair.client.git(pair.client.checkout, "checkout", "main") + pair.client.publish_change("Advance client main", *pair.client.write_files({Path("main-only.py"): "advanced = True\n"})) + pair.client.git(pair.client.checkout, "push", "origin", "main") + with pytest.raises(ToolRefusedError, match="main moved"): + inputs.validate(pair.context.repos, carrier) + + +def test_common_runtime_contract_allows_local_source_and_metadata_changes() -> None: + baseline = ('[project]\nname="old-label"\nversion="1"\n' + 'dependencies=["agent-comms", "old-client", "packaging>=25"]\n' + '[tool.uv.sources]\nagent-comms={path="..",editable=true}\n' + 'old-client={git="https://github.com/example/old.git",branch="main"}\n') + candidate = ('[project]\nname="new-label"\nversion="2"\n' + 'dependencies=["agent-comms", "new-client", "packaging>=25"]\n' + '[tool.uv.sources]\nagent-comms={path="..",editable=true}\n' + 'new-client={git="https://github.com/example/new.git",branch="main"}\n') + StackSources.require_compatible(baseline, candidate) + StackSources.require_compatible(candidate, baseline) + + +def test_common_runtime_contract_refuses_unsupported_external_dependency_migration() -> None: + baseline = '[project]\ndependencies=["packaging>=25"]\n' + candidate = '[project]\ndependencies=["packaging>=26"]\n' + with pytest.raises(ValueError): + StackSources.require_compatible(baseline, candidate) + + def test_main_checkout_is_refused_without_changes(tmp_path: Path): source = SourceRepository.create(tmp_path / "main-source") before = source.main_commit diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index ce3878c3b2..79356ca4bc 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -60,6 +60,7 @@ from stored_formats import StoredForm from typing_names import TypingImports, TypingReference from agent_comms.field_codec import FieldCodec, ViewExclusion, boundary_decoder, projected +from agent_comms.head_review import BaseSide, HeadSide, Side from agent_comms.phase_evidence import MainTree, PlanReading from agent_comms.plan_phases import PlanPhase from agent_comms.repo import Repo @@ -142,31 +143,6 @@ def memo[Value](cls, compute: Callable[[], Value], *parts: str) -> Value: -class Side(DeclaredFamily, affix="Side"): - """One of the two revisions a comparison measures; its declared name names a checker's cache.""" - - @staticmethod - @abstractmethod - def ref(base: str, head: str) -> str: - """This side's revision of the compared pair.""" - - @classmethod - def sides(cls) -> tuple[type[Side], ...]: - return cls.members_with(cls) - - -class BaseSide(Side): - @staticmethod - def ref(base: str, head: str) -> str: - return base - - -class HeadSide(Side): - @staticmethod - def ref(base: str, head: str) -> str: - return head - - class Measure(DeclaredFamily, affix="Measure"): @classmethod def owns(cls, key: MeasureCount) -> bool: diff --git a/tools/module_layers.py b/tools/module_layers.py index d64dbc316b..7975de06e8 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -37,6 +37,7 @@ from functools import cached_property from pathlib import Path +from agent_comms.installer.runtime_resolution import StackSources from agent_comms.installer.runtime_wire import ClientImport, CrossesRuntimes from typing_names import TypingImports, TypingReference @@ -298,25 +299,12 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: @dataclass(frozen=True) -class StackClient: +class StackClient(StackSources): """A package the stack installs from a git source, read from a checkout of it.""" name: str checkout: Path - @staticmethod - def sources(stack: Path) -> dict[str, tuple[str, str]]: - """Each git source stack/pyproject.toml declares: name to (url, branch).""" - match tomllib.loads((stack / "pyproject.toml").read_text()): - case {"tool": {"uv": {"sources": dict() as sources}}}: - found = {} - for name, source in sources.items(): - match source: - case {"git": str(url), "branch": str(branch)}: - found[name] = (url, branch) - return found - raise ValueError(f"{stack / 'pyproject.toml'} declares no [tool.uv.sources] table") - @classmethod def fetched(cls, stack: Path, into: Path) -> tuple[StackClient, ...]: """Shallow clones of every git source at its declared branch head.""" From c3c7912d8cf89107bcb499820352916bd6ab251a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 13:07:13 -0400 Subject: [PATCH 031/109] Use the checkout lifecycle for matched source materialization --- src/agent_comms/integration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index 1d4e93f10c..e98c63cc9d 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -21,7 +21,7 @@ from .head_review import BaseSide, HeadReview, HeadSide, IntegrationInput, IntegrationSource, PullRequestHead from .installer.runtime_resolution import StackSources from .launch_runtime import CheckoutSource, RestoredRuntime -from .repo import Checkout, GitCommandError, Repo +from .repo import Checkout, DetachedStart, GitCommandError, Repo from .systemd_unit import AgentsSlice from .repository_name import RepositoryName from .test_lease import LeaseStart, TestLease @@ -146,8 +146,7 @@ def create(cls, repository: Repo, path: Path, commit: str) -> IntegrationCheckou raise IntegrationFailed(f"Preserve existing qualification checkout: {path}") WorkBranches(repository.checkout).require_commits("origin", commit) path.parent.mkdir(parents=True, exist_ok=True) - Repo.git(repository.checkout, "worktree", "add", "--detach", str(path), commit, timeout=300) - created = cls(path) + created = cls.add(repository.checkout, path, DetachedStart(commit)) created.require_editable() return created @@ -170,6 +169,7 @@ def combine_sources(self, base: str, heads: tuple[PullRequestHead, ...]) -> str: Repo.git(self.path, "switch", "--detach", base) for head in heads: self.include(head) + self.prepare() return Repo.git(self.path, "rev-parse", "HEAD^{tree}").strip() def combine(self, base: str, heads: tuple[PullRequestHead, ...]) -> tuple[PullRequestHead, ...]: From 701b58768901b112df6501169647043c3c5764f4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 13:17:47 -0400 Subject: [PATCH 032/109] 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 033/109] 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 034/109] 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 13c7b055654364b303791622b634bb9787ccd327 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 13:47:36 -0400 Subject: [PATCH 035/109] Resolve actual CI source graphs and retain registered companion PR links --- src/agent_comms/commander/order.py | 8 +- src/agent_comms/head_review.py | 98 +++++-- .../installer/runtime_resolution.py | 8 + src/agent_comms/integration.py | 250 +++++++++++++++++- src/agent_comms/todos.py | 16 +- tests/github_cli_stub.py | 5 + tests/test_head_review.py | 137 +++++++++- tests/test_integration.py | 65 +++++ tests/test_module_layers_clients.py | 45 ++++ tools/module_layers.py | 35 ++- 10 files changed, 628 insertions(+), 39 deletions(-) diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index f7ec75d3e6..c5fd4deb94 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -1133,6 +1133,8 @@ def run(self, ctx: Commander, todo: Todo, source: Repo, class NewPullRequestSelection(PullRequestSelection): def run(self, ctx: Commander, todo: Todo, source: Repo, title: str, body_file: str) -> str: + if not source.holds(todo): + raise ValueError(f"Create this order's PR in {todo.repo.to_wire()}, not {source.github.to_wire()}.") if not title or not body_file: raise ValueError("Creating a PR requires --title and --body-file.") return todo.order.checked().open_pull_request( @@ -1152,7 +1154,7 @@ def run(self, ctx: Commander, todo: Todo, source: Repo, title: str, body_file: str) -> str: if title or body_file: raise ValueError("--existing does not create or edit the PR; omit title and body.") - link = PullRequestLink.existing(todo.repo, self.number) + link = PullRequestLink.existing(source.github, self.number) ctx.comms.goals.todos.apply(todo.id, RecordPullRequest( todo.revision, ctx.incarnation, link, todo.held().generation)) return link.url @@ -1184,9 +1186,7 @@ def run(self, ctx: Commander) -> None: storage = ctx.comms.registry.storage_rule if not storage.owns_checkout(holder, source.checkout, source.github): raise ValueError(f"Run order pr from a source checkout owned by {holder.name}.") - if not source.holds(todo): - sys.exit(f'Refused: current checkout belongs to {source.github.to_wire()}, ' - f'not {todo.repo.to_wire()}.') + Repo.selected(ctx.repos, source.github.to_wire()) print(self.existing.run(ctx, todo, source, self.title, self.body_file)) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index 385cedf87a..efb2de18f9 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -172,20 +172,26 @@ def expected_tree(self, repository: Repo) -> str: "-m", "Validate captured source combination").strip() return Repo.git(repository.checkout, "rev-parse", f"{commit}^{{tree}}").strip() - def validate(self, repository: Repo, carrier: PullRequestHead) -> None: + def require_origin(self, repository: Repo) -> None: if Repo.of(repository.checkout).github != self.repository: - raise ToolRefusedError("A registered source checkout has a different Git origin") + raise ToolRefusedError("A source checkout has a different Git origin") + + def validate(self, repository: Repo, carrier: PullRequestHead) -> None: + self.require_origin(repository) if self.main_head(repository) != self.baseline.commit: raise ToolRefusedError(f"{self.repository.to_wire()} main moved; recapture inputs") + self.require_candidate(repository, carrier) + + def require_candidate(self, repository: Repo, carrier: PullRequestHead) -> None: + """The event/review owner verifies the current carrier; all other heads stay exact.""" + self.require_origin(repository) if self.expected_tree(repository) != self.candidate_tree: raise ToolRefusedError(f"{self.repository.to_wire()} candidate tree is not its captured combination") for head in self.heads: - current = PullRequestHead.at(head.repo, head.pr) - if current.base != "main": - raise ToolRefusedError(f"#{head.pr} no longer targets captured main") - expected = carrier.head if head.same_pull_request(carrier) else head.head - if current.head != expected: - raise ToolRefusedError(f"{head.repo.to_wire()}#{head.pr} moved; recapture inputs") + if not head.same_pull_request(carrier): + current = head.current() + if current.base != "main": + raise ToolRefusedError(f"#{head.pr} no longer targets captured main") @dataclass(frozen=True) @@ -198,6 +204,7 @@ class IntegrationInput: sources: tuple[IntegrationSource, ...] trailer_name: ClassVar[str] = "Agent-Comms-Integration" + filename: ClassVar[str] = "input.json" def __post_init__(self) -> None: repositories = tuple(source.repository for source in self.sources) @@ -220,15 +227,34 @@ def source(self, repository: RepositoryName) -> IntegrationSource: @staticmethod def declared_repositories(core: Repo, baseline: str, candidate: str) -> tuple[RepositoryName, ...]: """Actual declarations on both sides own the universe, including added/removed clients.""" - declarations = tuple(StackSources.declared_sources( - Repo.git(core.checkout, "show", f"{commit}:stack/pyproject.toml")) - for commit in (baseline, candidate)) - return tuple(sorted({core.github, *(Repo.repository_url(url) - for side in declarations for url, branch in side.values())}, key=lambda name: name.key)) + return tuple(sorted({core.github, *IntegrationInput.source_urls(core, baseline, candidate)}, + key=lambda name: name.key)) + + @staticmethod + def source_urls(core: Repo, baseline: str, candidate: str) -> dict[RepositoryName, str]: + urls: dict[RepositoryName, str] = {} + for commit in (baseline, candidate): + declared = StackSources.declared_sources( + Repo.git(core.checkout, "show", f"{commit}:stack/pyproject.toml")) + identities = tuple(Repo.repository_url(url) for url, branch in declared.values()) + if len(set(identities)) != len(identities): + raise ToolRefusedError("One stack cut declares a Git repository more than once") + for url, branch in declared.values(): + urls[Repo.repository_url(url)] = url + return urls def validate(self, repositories: tuple[Repo, ...], carrier: PullRequestHead) -> None: """Git and forge prove this frozen input; Todo and verdict owners decide permission.""" core = Repo.selected(repositories, carrier.repo.to_wire()) + current = carrier.current() + if current.base != "main": + raise ToolRefusedError("The integration carrier no longer targets main") + self.require_carrier(core, carrier) + for entry in self.sources: + entry.validate(Repo.selected(repositories, entry.repository.to_wire()), carrier) + + def require_carrier(self, core: Repo, carrier: PullRequestHead) -> None: + """The same immutable carrier proof is used by final review and tested Git events.""" WorkBranches(core.checkout).require_commits("origin", carrier.head) source = self.source(carrier.repo) self.require_repositories(self.declared_repositories(core, source.baseline.commit, carrier.head)) @@ -237,8 +263,6 @@ def validate(self, repositories: tuple[Repo, ...], carrier: PullRequestHead) -> raise ToolRefusedError("The integration carrier is not a unique captured local PR") if members[0].head == carrier.head: raise ToolRefusedError("An input captures the carrier's earlier source, never its own commit SHA") - for entry in self.sources: - entry.validate(Repo.selected(repositories, entry.repository.to_wire()), carrier) for head in source.heads: if not Repo.descends(core.checkout, head.head, carrier.head): raise ToolRefusedError(f"The carrier dropped original source #{head.pr}") @@ -273,10 +297,22 @@ def from_message(cls, message: str) -> tuple[IntegrationInput, ...]: if len(marked) != 1 or not marked[0].startswith(prefix) or marked[0] != lines[-1]: raise MalformedRecord("Integration metadata requires one final canonical trailer.") payload = marked[0].removeprefix(prefix) + return (cls.from_canonical(payload),) + + @classmethod + def from_canonical(cls, payload: str) -> IntegrationInput: decoded = FieldCodec.decode(cls, FieldCodec.parse(payload)) if decoded.canonical != payload: raise MalformedRecord("Integration metadata is not canonical.") - return (decoded,) + return decoded + + @classmethod + def at(cls, directory: Path) -> IntegrationInput: + """A derived execution artifact; Git/event validation remains mandatory in CI.""" + return cls.from_canonical((directory / cls.filename).read_text().removesuffix("\n")) + + def write(self, directory: Path) -> None: + (directory / self.filename).write_text(self.canonical + "\n") @classmethod def digest_from_message(cls, message: str) -> str: @@ -299,18 +335,45 @@ def sides(cls) -> tuple[type[Side], ...]: def checkout(cls, directory: Path, repository: RepositoryName) -> Path: return directory / cls.declared_name / Path(*repository.key) + @classmethod + def require_materialized(cls, directory: Path, source: IntegrationSource) -> Path: + checkout = cls.checkout(directory, source.repository) + source.require_origin(Repo.of(checkout)) + if Repo.git(checkout, "status", "--porcelain"): + raise ToolRefusedError(f"Preserve dirty materialized source: {checkout}") + cls.require_revision(checkout, source) + return checkout + + @staticmethod + @abstractmethod + def require_revision(checkout: Path, source: IntegrationSource) -> None: + """Each side proves its own exact captured cut.""" + class BaseSide(Side): @staticmethod def ref(base: str, head: str) -> str: return base + @staticmethod + def require_revision(checkout: Path, source: IntegrationSource) -> None: + if Repo.git(checkout, "rev-parse", "HEAD").strip() != source.baseline.commit: + raise ToolRefusedError("Materialized baseline is not the captured main commit") + class HeadSide(Side): @staticmethod def ref(base: str, head: str) -> str: return head + @staticmethod + def require_revision(checkout: Path, source: IntegrationSource) -> None: + if Repo.git(checkout, "rev-parse", "HEAD^{tree}").strip() != source.candidate_tree: + raise ToolRefusedError("Materialized candidate is not the captured source tree") + if not Repo.descends(checkout, source.baseline.commit, "HEAD") or any( + not Repo.descends(checkout, head.head, "HEAD") for head in source.heads): + raise ToolRefusedError("Materialized candidate dropped a captured source parent") + @dataclass(frozen=True) class ReadPullRequestHead(PullRequestHead): @@ -402,8 +465,7 @@ def prepared(self, goals: Goals, problems: dict[str, str]) -> tuple[HeadReviewOr @staticmethod def holding(todos: Sequence[Todo], request: PullRequestHead) -> tuple[Todo, ...]: """The open orders whose current declaration holds this head, held or released.""" - return tuple(todo for todo in todos if todo.repo == request.repo - and todo.order.holds_head(request)) + return tuple(todo for todo in todos if todo.order.holds_head(request)) @abstractmethod def require_reviewer(self, goals: Goals, request: PullRequestHead, diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index 5a0bec3d3c..c75a6bc524 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -33,6 +33,14 @@ class StackSources: def sources(cls, stack: Path) -> dict[str, tuple[str, str]]: return cls.declared_sources((stack / "pyproject.toml").read_text()) + @staticmethod + def require_package(checkout: Path, name: str) -> None: + """A stack key names the actual source project, not a package-to-PR roster.""" + match tomllib.loads((checkout / "pyproject.toml").read_text()): + case {"project": {"name": str(actual)}} if canonicalize_name(actual) == canonicalize_name(name): + return + raise ValueError(f"{checkout} does not declare stack package {name}") + @staticmethod def require_compatible(baseline: str, candidate: str) -> None: """Local-source membership may change; external runtime requirements must agree. diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index e98c63cc9d..846a2ba45b 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -4,6 +4,7 @@ import argparse import os +import re import subprocess import sys import tomllib @@ -16,9 +17,10 @@ from .build_identity import RepositoryCommit from .commander.order import CommanderContext from .child_process import STOP_GRACE_SECONDS, ResidentManager, SpawnedProcess +from .declared_family import DeclaredFamily from .errors import ToolRefusedError -from .field_codec import FieldCodec, WireName -from .head_review import BaseSide, HeadReview, HeadSide, IntegrationInput, IntegrationSource, PullRequestHead +from .field_codec import FieldCodec, GitCommitText, WireName +from .head_review import BaseSide, HeadReview, HeadSide, IntegrationInput, IntegrationSource, PullRequestHead, Side from .installer.runtime_resolution import StackSources from .launch_runtime import CheckoutSource, RestoredRuntime from .repo import Checkout, DetachedStart, GitCommandError, Repo @@ -254,6 +256,220 @@ def finish( raise IntegrationFailed(f"Timed out: {self.log}; no automatic replay") +@dataclass(frozen=True) +class TestedSources(DeclaredFamily, affix="Sources"): + """The actual tested Git graph and forge carrier, not a supplied source roster. + + Event-specific ancestry and current-main rules live on their event declarations. + These transient values never enter Core's live records or authorize a review. + """ + + repository: Repo + tested: str + carrier: PullRequestHead + resolve_option: ClassVar[str] = "--resolve-ci-inputs" + revalidate_option: ClassVar[str] = "--revalidate-ci-inputs" + + @classmethod + def options(cls) -> tuple[str, ...]: + return cls.resolve_option, cls.revalidate_option + + @property + def parents(self) -> tuple[str, ...]: + return tuple(Repo.git(self.repository.checkout, "rev-list", "--parents", "-n", "1", self.tested).split()[1:]) + + @property + def baseline(self) -> str: + if len(self.parents) != 2: + raise IntegrationFailed("The tested comparison requires an actual two-parent merge") + return self.parents[0] + + @property + def tree(self) -> str: + return Repo.git(self.repository.checkout, "rev-parse", f"{self.tested}^{{tree}}").strip() + + @classmethod + def read(cls, checkout: Path) -> TestedSources: + repository = Repo.of(checkout) + if RepositoryName.from_wire(os.environ["GITHUB_REPOSITORY"]) != repository.github: + raise IntegrationFailed("The runner checkout origin is not the actual event repository") + tested = Repo.git(checkout, "rev-parse", "HEAD^{commit}").strip() + if os.environ["GITHUB_SHA"] != tested: + raise IntegrationFailed("The runner checkout is not the event's actual tested SHA") + GitCommitText.decode(tested) + document = FieldCodec.parse(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + return cls.member_named(os.environ["GITHUB_EVENT_NAME"]).resolve(repository, tested, document) + + @classmethod + @abstractmethod + def resolve(cls, repository: Repo, tested: str, document: object) -> TestedSources: + """Decode this foreign event once and prove its relationship to the tested Git graph.""" + + def require_carrier(self) -> None: + current = self.carrier.current() + if current.base != "main": + raise IntegrationFailed("The tested carrier no longer targets main") + + def main_commit(self, source: IntegrationSource) -> str: + return source.baseline.commit + + def materialize(self, directory: Path) -> IntegrationInput: + """Capture once from Git/forge, then use these same exact checkouts for every consumer.""" + if not directory.is_relative_to(Path(os.environ["RUNNER_TEMP"]).resolve()): + raise IntegrationFailed("CI source artifacts belong under the runner's private temporary root") + directory.mkdir(parents=True, exist_ok=False) + payload = IntegrationInput.from_message( + WorkBranches(self.repository.checkout).commit_message(self.carrier.head)) + urls = IntegrationInput.source_urls(self.repository, self.baseline, self.tested) + repositories = [self.repository] + for name, url in sorted(urls.items(), key=lambda item: item[0].key): + if name == self.repository.github: + raise IntegrationFailed("The stack repeats Core as a Git companion source") + checkout = directory / "repositories" / Path(*name.key) + checkout.parent.mkdir(parents=True, exist_ok=True) + Repo.git(self.repository.checkout, "clone", "--no-checkout", url, str(checkout), timeout=300) + repository = Repo.of(checkout) + if repository.github != name: + raise IntegrationFailed("A declared source clone has a different actual Git origin") + repositories.append(repository) + if payload: + inputs, = payload + else: + sources = [IntegrationSource(RepositoryCommit(self.repository.github, self.baseline), + (self.carrier,), self.tree)] + for repository in repositories[1:]: + commit = IntegrationSource.main_head(repository) + WorkBranches(repository.checkout).require_commits("origin", commit) + tree = Repo.git(repository.checkout, "rev-parse", f"{commit}^{{tree}}").strip() + sources.append(IntegrationSource(RepositoryCommit(repository.github, commit), (), tree)) + inputs = IntegrationInput(tuple(sorted(sources, key=lambda source: source.repository.key))) + self.validate(inputs, tuple(repositories)) + for source in inputs.sources: + repository = Repo.selected(tuple(repositories), source.repository.to_wire()) + IntegrationCheckout.create(repository, BaseSide.checkout(directory, source.repository), source.baseline.commit) + # The actual tested Core merge already carries all local parents. Fetching its + # earlier own PR head through refs/pull would instead fetch the new carrier. + if source.repository == self.repository.github: + IntegrationCheckout.create(repository, HeadSide.checkout(directory, source.repository), self.tested) + else: + head = IntegrationCheckout.create(repository, HeadSide.checkout(directory, source.repository), source.baseline.commit) + head.combine_sources(source.baseline.commit, source.heads) + for side in Side.sides(): + side.require_materialized(directory, source) + checkout = side.checkout(directory, source.repository) + print(f"SOURCE {side.declared_name} {source.repository.to_wire()} " + f"{Repo.git(checkout, 'rev-parse', 'HEAD').strip()} {checkout}", flush=True) + self.validate(inputs, tuple(repositories)) + inputs.write(directory) + print(f"CI INPUT {inputs.digest}; source computation only, no review or merge permission", flush=True) + return inputs + + def revalidate(self, directory: Path) -> IntegrationInput: + inputs = IntegrationInput.at(directory) + repositories = [] + for source in inputs.sources: + for side in Side.sides(): + side.require_materialized(directory, source) + repositories.append(Repo.of(BaseSide.checkout(directory, source.repository))) + self.validate(inputs, tuple(repositories)) + print(f"CI INPUT REVALIDATED {inputs.digest}; no verdict posted", flush=True) + return inputs + + def validate(self, inputs: IntegrationInput, repositories: tuple[Repo, ...]) -> None: + """Re-run at job exit: source checks compute, but never publish a verdict.""" + self.require_carrier() + local = inputs.source(self.repository.github) + if local.baseline.commit != self.baseline or local.candidate_tree != self.tree: + raise IntegrationFailed("Tested base/tree differs from the frozen source input; recapture") + inputs.require_repositories(IntegrationInput.declared_repositories( + self.repository, self.baseline, self.tested)) + payload = IntegrationInput.from_message( + WorkBranches(self.repository.checkout).commit_message(self.carrier.head)) + if payload: + if payload != (inputs,): + raise IntegrationFailed("The tested carrier and materialized input disagree") + inputs.require_carrier(self.repository, self.carrier) + else: + # Ordinary mode is precisely one actual carrier plus unchanged captured mains. + if local.heads != (self.carrier,) or any(source.heads for source in inputs.sources + if source.repository != self.repository.github): + raise IntegrationFailed("An ordinary comparison cannot supply companion overlays") + for source in inputs.sources: + repository = Repo.selected(repositories, source.repository.to_wire()) + if IntegrationSource.main_head(repository) != self.main_commit(source): + raise IntegrationFailed(f"{source.repository.to_wire()} main moved; recapture") + source.require_candidate(repository, self.carrier) + + +class PullRequestSources(TestedSources, declared_name="pull_request"): + @classmethod + def resolve(cls, repository: Repo, tested: str, document: object) -> TestedSources: + match document: + case {"number": int(number), "pull_request": {"head": {"sha": str(head)}}}: + graph = cls(repository, tested, PullRequestHead(repository.github, number, head)) + case _: + raise IntegrationFailed("Malformed pull_request source event") + if graph.parents != (graph.baseline, graph.carrier.head): + raise IntegrationFailed("The tested second parent is not the event's exact PR head") + graph.require_carrier() + return graph + + +class MergeGroupSources(TestedSources, declared_name="merge_group"): + @classmethod + def resolve(cls, repository: Repo, tested: str, document: object) -> TestedSources: + match document: + case {"merge_group": {"head_sha": str(head), "base_sha": str(base), "head_ref": str(reference)}}: + # This is GitHub's queue protocol (also used by review-verdict), not a + # branch-name guess about which independent repositories belong together. + queued = re.search(r"/pr-([0-9]+)-", reference) + if queued is None or head != tested: + raise IntegrationFailed("No actual queued carrier in this merge-group event") + carrier = PullRequestHead.at(repository.github, int(queued[1])) + graph = cls(repository, tested, PullRequestHead(carrier.repo, carrier.pr, carrier.head)) + case _: + raise IntegrationFailed("Malformed merge_group source event") + if graph.baseline != base or not Repo.descends(repository.checkout, graph.carrier.head, tested): + raise IntegrationFailed("The tested queue graph does not contain its exact current carrier") + graph.require_carrier() + return graph + + +class MainSources(TestedSources, declared_name="push"): + @staticmethod + def merged_carrier(repository: Repo, tested: str) -> PullRequestHead: + response = repository.github.rest(f"commits/{tested}/pulls", "--jq", "map(.number)") + if response.returncode: + raise IntegrationFailed(response.stderr.strip() or "Cannot read main's merged PR provenance") + numbers = FieldCodec.decode(tuple[int, ...], FieldCodec.parse(response.stdout)) + rows = tuple(row for row in PullRequests().rows(repository.github, numbers) + if row.base == "main" and any(merged.merge.oid == tested for merged in row.as_merged())) + if len(rows) != 1: + raise IntegrationFailed("Main cache requires one actual merged PR; direct/ambiguous commits refuse") + row, = rows + return PullRequestHead(repository.github, row.number, row.head) + + @classmethod + def resolve(cls, repository: Repo, tested: str, document: object) -> TestedSources: + match document: + case {"ref": "refs/heads/main", "after": str(after)} if after == tested: + graph = cls(repository, tested, cls.merged_carrier(repository, tested)) + case _: + raise IntegrationFailed("Main cache requires the actual main push event") + if graph.parents != (graph.baseline, graph.carrier.head): + raise IntegrationFailed("Main cache supports the actual two-parent merged PR, not a guessed cut") + return graph + + def require_carrier(self) -> None: + if self.merged_carrier(self.repository, self.tested) != self.carrier: + raise IntegrationFailed("Main's uniquely identified merged carrier changed") + + def main_commit(self, source: IntegrationSource) -> str: + if source.repository == self.repository.github: + return self.tested + return super().main_commit(source) + + @dataclass(frozen=True) class IntegrationRun: checkout: IntegrationCheckout @@ -370,7 +586,7 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...]) -> Integ f"{original_message.rstrip()}\n\n{inputs.trailer}") commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() IntegrationCheckout.create(registered_core, HeadSide.checkout(self.output, core.github), commit) - (self.output / "input.json").write_text(inputs.canonical + "\n") + inputs.write(self.output) print(f"QUALIFICATION ONLY {commit}; inputs {inputs.digest}; {self.output}; no review or merge permission", flush=True) return inputs @@ -465,22 +681,28 @@ def execute(self) -> int: def main() -> None: """Enter the existing CPU-slice owner before acquiring a transferable lease.""" + if any(argument.partition("=")[0] in TestedSources.options() for argument in sys.argv[1:]): + raise SystemExit(run()) command = ResidentManager.current().command((sys.executable, "-m", __name__, *sys.argv[1:])) os.execvpe(command[0], command, dict(os.environ)) def run() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--checkout", type=Path, required=True) + parser.add_argument("--checkout", type=Path) parser.add_argument( "--output", type=Path, - required=True, help="new persistent directory for this run's two bounded logs", ) - parser.add_argument("--risk", required=True) - parser.add_argument("--qualify", default="", metavar="PROBLEM", - help="prepare sources from this actual primary order, without running tests") + parser.add_argument("--risk", default="") + operation = parser.add_mutually_exclusive_group() + operation.add_argument(TestedSources.resolve_option, type=Path, metavar="OUTPUT", + help="materialize the actual GitHub event's exact compared sources; no bus") + operation.add_argument(TestedSources.revalidate_option, type=Path, metavar="OUTPUT", + help="revalidate the same event/input and source heads before CI success") + operation.add_argument("--qualify", default="", metavar="PROBLEM", + help="prepare sources from this actual primary order, without running tests") parser.add_argument("--with-problem", action="append", default=[], metavar="PROBLEM") parser.add_argument("--comms-root", type=Path, help="the existing authorized root for stored-problem qualification") @@ -496,6 +718,18 @@ def run() -> int: pytest_arguments = arguments.pytest_arguments if pytest_arguments[:1] == ["--"]: pytest_arguments = pytest_arguments[1:] + if arguments.resolve_ci_inputs or arguments.revalidate_ci_inputs: + if pytest_arguments or arguments.with_problem or arguments.comms_root or arguments.output or arguments.risk: + parser.error("CI source resolution does not accept tests, a bus root or local qualification options") + arguments.drop_on_failure.require_qualification() + graph = TestedSources.read((arguments.checkout or Path.cwd()).resolve()) + if arguments.resolve_ci_inputs: + graph.materialize(arguments.resolve_ci_inputs.resolve()) + else: + graph.revalidate(arguments.revalidate_ci_inputs.resolve()) + return 0 + if arguments.checkout is None or arguments.output is None or not arguments.risk: + parser.error("Local integration requires --checkout, --output and --risk") run = IntegrationRun( IntegrationCheckout(arguments.checkout.resolve()), arguments.output.resolve(), diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 0a15746124..752b1a1b3c 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -990,13 +990,13 @@ def __init__(self, todos: Sequence[Todo] = ()) -> None: self._read: dict[tuple[RepositoryName, int], ListedPullRequest] = {} self._open: dict[RepositoryName, tuple[int, ...]] = {} self._worktrees: dict[RepositoryName, dict[str, tuple[str, ...]]] = {} - self._todos: dict[RepositoryName, dict[str, Todo]] = {} + self._todos: dict[str, Todo] = {} self.register(todos) def register(self, todos: Sequence[Todo]) -> Self: """Register a frame's orders while preserving this read's existing GitHub facts.""" for todo in todos: - self._todos.setdefault(todo.repo, {})[todo.id] = todo + self._todos[todo.id] = todo return self def retained_assignments(self, todo: Todo, assignment: Assignment) -> tuple[Assignment, ...]: @@ -1055,7 +1055,7 @@ def claimed_heads(self, todo: Todo) -> Iterator[tuple[OrderClaim, PullRequest]]: yield claim, request def linked(self, repo: RepositoryName) -> dict[int, list[Todo]]: - return self.linked_among(self._todos.get(repo, {}).values(), repo) + return self.linked_among(self._todos.values(), repo) @staticmethod def linked_among(todos: Iterable[Todo], repo: RepositoryName) -> dict[int, list[Todo]]: @@ -4986,13 +4986,19 @@ class RecordPullRequest(TodoCommand): link: PullRequestLink generation: GoalId + def validate(self, db: sqlite3.Connection, current: Todo, store: TodoStore) -> None: + if self.link.repo != current.repo: + # TodoStore.writing retains this registry cut through the actual SQL write. + project = RegistryStore.at(store.root).snapshot().commander_project.require() + Repo.selected(project.repos, self.link.repo.to_wire()) + def changed(self, current: Todo) -> Todo: current.require_actor(self.actor, self.generation) - if self.link.repo != current.repo: - raise TodoError("A recorded pull request must belong to the todo's repository.") order = current.order.checked() if self.link in order.pull_requests: return current + order.require_linkable() + current.state.require_open(current) return replace(current, revision=current.revision + 1, order=order.linked_to(self.link)) diff --git a/tests/github_cli_stub.py b/tests/github_cli_stub.py index 183fb539c4..2a07cc5f5c 100755 --- a/tests/github_cli_stub.py +++ b/tests/github_cli_stub.py @@ -41,6 +41,11 @@ def main(): listed = data["requests"][parts[1]]["listed"] state = "open" if listed["kind"] in data["open_kinds"] else "closed" print(listed["head"], listed["base"], str(listed["kind"] == "draft").lower(), state) + elif len(parts) == 3 and parts[0] == "commits" and parts[2] == "pulls": + assert arguments[arguments.index("--jq") + 1] == "map(.number)" + print(json.dumps([entry["listed"]["number"] for entry in data["requests"].values() + if entry["listed"].get("merge", {}).get("oid") == parts[1] + or entry["listed"]["head"] == parts[1]])) elif parts[:1] == ["statuses"] and len(parts) == 2: assert arguments[arguments.index("--method") + 1] == "POST" print("{}") diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 3dfc6910f2..78088b079a 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -4,6 +4,7 @@ import sys import time from collections.abc import Callable +from contextlib import chdir from dataclasses import replace from datetime import UTC, datetime from pathlib import Path @@ -12,6 +13,7 @@ from agent_comms.commander import judge, order +from agent_comms.comms import Comms from agent_comms.goal_actions import CreateProblemGoalAction from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.build_identity import RepositoryCommit @@ -29,13 +31,14 @@ from agent_comms.repo import Checkout from agent_comms.threads import Thread from agent_comms.errors import TodoError, ToolRefusedError -from agent_comms.field_codec import FieldCodec, MalformedRecord, RecordProblems +from agent_comms.field_codec import FieldCodec, GoalId, MalformedRecord, RecordProblems from agent_comms.tool_caller import ThreadCaller +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.work_branches import SourceGrowth, WorkBranches from agent_comms.order_tools import CommsHeadVerdictTool from agent_comms.todos import ( CarriedHeadVerdict, ConflictMergeBlock, DraftListedPullRequest, FailedCheckMergeBlock, GrownHeadVerdict, GrowthReason, OrderHeadReview, LinkedHeadReview, - NoHeadOrder, ProgressRead, PullRequestLink, RecordPullRequest, RejectedHeadVerdict, + NoHeadOrder, OrderClaim, ProgressRead, PullRequestLink, PullRequests, RecordPullRequest, RejectedHeadVerdict, HeadVerdict, QualifiedHeadVerdict, ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, StrikeReason, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, ) @@ -139,6 +142,136 @@ def qualify_unchanged(): assert not any(call[:2] == ("pr", "merge") for call in github.calls()) +@pytest.fixture +def companion_checkout( + linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], + reviewed_source: SourceRepository, + configured_repository: Callable[[Comms, *tuple[SourceRepository, ...]], order.Commander], + tmp_path: Path, +) -> Path: + comms, github, primary = linked_head_bus + companion = SourceRepository.create(tmp_path / "companion", RepositoryName("example", "companion")) + configured_repository(comms, reviewed_source, companion) + stamp = "2026-10-24T00:00:00Z" + # The same PR number exists in both repositories: substituting todo.repo cannot pass. + github.put(companion.github, DraftListedPullRequest( + primary.pr, "feature", companion.branch_commit, stamp, "main", reviewable_times=(stamp,))) + owner = comms.registry.require(comms.goals.todos.get("P2").held().owner) + rule = comms.registry.storage_rule + checkout = rule.repository_checkout(rule.roots_of(owner), "P2", companion.github) + checkout.parent.mkdir(parents=True, exist_ok=True) + companion.git(companion.checkout, "worktree", "add", "--detach", str(checkout), companion.branch_commit) + return checkout + + +def test_native_companion_link_preserves_claim_review_goal_and_is_idempotent( + linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], + companion_checkout: Path, + claim_order: Callable[..., OrderClaim], +) -> None: + comms, github, primary = linked_head_bus + claim_order("P2", pull_request=primary.pr, head=primary.head) + todo = comms.goals.todos.get("P2") + invoke(comms, primary, UnderReviewHeadVerdict(), OrderHeadReview.for_request( + (todo,), primary, UnquotedHeadReview.of)) + before = comms.goals.todos.get("P2") + goal = comms.registry.require(before.held().owner).goal + reviews = StoredHeadReview.head_reviews(comms.goals.todos, primary) + link = PullRequestLink(SourceRepository.of(companion_checkout).github, primary.pr) + context = order.Commander.read_root(comms.root) + with chdir(companion_checkout): + order.main(["pr", before.id, "--existing", str(primary.pr)], context) + after = comms.goals.todos.get(before.id) + assert after == replace(before, revision=before.revision + 1, order=before.order.checked().linked_to(link)) + assert type(after.order) is type(before.order) + assert comms.registry.require(after.held().owner).goal == goal + assert StoredHeadReview.head_reviews(comms.goals.todos, primary) == reviews + companion = PullRequestHead(link.repo, link.number, SourceRepository.git(companion_checkout, "rev-parse", "HEAD").strip()) + assert PullRequests.linked_among((after,), link.repo) == {link.number: [after]} + assert PullRequests((after,)).linked(link.repo) == {link.number: [after]} + assert OrderHeadReview.holding((after,), companion) == (after,) + # Qualification reads these exact stored links, not a caller-provided pair roster. + assert {item.repo for item in after.order.checked().pull_requests} == {primary.repo, link.repo} + with chdir(companion_checkout): + order.main(["pr", before.id, "--existing", str(primary.pr)], context) + assert comms.goals.todos.get(before.id) == after + assert comms.registry.require(after.held().owner).goal == goal + assert StoredHeadReview.head_reviews(comms.goals.todos, primary) == reviews + assert not any(call[:2] in (("pr", "create"), ("pr", "merge")) for call in github.calls()) + assert any(link.repo.to_wire() in argument for call in github.calls() for argument in call) + + +def test_companion_metadata_cannot_create_edit_or_adopt_missing_pr( + linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], companion_checkout: Path, +) -> None: + comms, github, primary = linked_head_bus + before = comms.goals.todos.get("P2") + context = order.Commander.read_root(comms.root) + with chdir(companion_checkout): + for arguments in ( + ["--title", "Do not create a companion", "--body-file", "not-read.md"], + ["--existing", str(primary.pr), "--title", "Do not edit"], + ["--existing", "999999"], + ): + with pytest.raises((ValueError, TodoError, SystemExit, subprocess.CalledProcessError)): + order.main(["pr", before.id, *arguments], context) + assert comms.goals.todos.get(before.id) == before + assert not any(call[:2] in (("pr", "create"), ("pr", "edit"), ("pr", "merge")) for call in github.calls()) + + +def test_companion_metadata_refuses_another_owners_checkout( + linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], companion_checkout: Path, +) -> None: + comms, github, primary = linked_head_bus + before = comms.goals.todos.get("P2") + companion = SourceRepository.of(companion_checkout) + other = comms.registry.require("lead") + rule = comms.registry.storage_rule + checkout = rule.repository_checkout(rule.roots_of(other), "P2", companion.github) + checkout.parent.mkdir(parents=True, exist_ok=True) + companion.git(companion_checkout, "worktree", "add", "--detach", str(checkout), "HEAD") + calls = github.calls() + with chdir(checkout), pytest.raises(ValueError, match="owned by"): + order.main(["pr", before.id, "--existing", str(primary.pr)], order.Commander.read_root(comms.root)) + assert comms.goals.todos.get(before.id) == before + assert github.calls() == calls + + +def test_companion_metadata_refuses_an_unregistered_origin_before_forge_read( + linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], companion_checkout: Path, + reviewed_source: SourceRepository, + configured_repository: Callable[[Comms, *tuple[SourceRepository, ...]], order.Commander], +) -> None: + comms, github, primary = linked_head_bus + before = comms.goals.todos.get("P2") + context = configured_repository(comms, reviewed_source) + calls = github.calls() + with chdir(companion_checkout), pytest.raises(ValueError, match="Unknown repository"): + order.main(["pr", before.id, "--existing", str(primary.pr)], context) + assert comms.goals.todos.get(before.id) == before + assert github.calls() == calls + + +def test_companion_metadata_write_enforces_registration_revision_and_holder( + linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], companion_checkout: Path, +) -> None: + comms, _, primary = linked_head_bus + before = comms.goals.todos.get("P2") + held = before.held() + worker = held.owner_identity + registered = PullRequestLink(SourceRepository.of(companion_checkout).github, primary.pr) + for command in ( + RecordPullRequest(before.revision, worker, PullRequestLink(RepositoryName("unknown", "source"), 7), held.generation), + RecordPullRequest(before.revision + 1, worker, registered, held.generation), + RecordPullRequest(before.revision, worker, registered, GoalId.mint()), + RecordPullRequest(before.revision, RecordedThreadIncarnation("not-holder", 1.0), + registered, held.generation), + ): + with pytest.raises((ValueError, TodoError)): + comms.goals.todos.apply(before.id, command) + assert comms.goals.todos.get(before.id) == before + + def test_qualification_withdraws_acceptance_without_claiming_to_disarm_the_request(head_bus): comms, github, request = head_bus invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) diff --git a/tests/test_integration.py b/tests/test_integration.py index 9b65ffa2f9..9a97152c69 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,8 +1,10 @@ """Integration policy at real TOML/Git/gh boundaries; no patched Core methods.""" +import json import os from pathlib import Path import subprocess +import sys import pytest @@ -30,6 +32,69 @@ def heads() -> tuple[PullRequestHead, ...]: return (PullRequestHead(repo, 7, "a" * 40), PullRequestHead(repo, 8, "b" * 40)) +@pytest.mark.parametrize("event", ("pull_request", "merge_group")) +def test_actual_ci_cli_uses_verified_carrier_not_synthetic_message_and_revalidates( + mutual_sources: MutualSources, github_cli: GitHubFixture, + tmp_path: Path, event: str, +) -> None: + pair = mutual_sources + inputs = integration.IntegrationRun(pair.checkout, pair.output, "CI source graph").qualify(pair.context, ("P2", "P3")) + carrier = pair.publish_carrier(github_cli) + local = inputs.source(pair.core.github) + # Actual synthetic merge: its own message deliberately has no source metadata. + tested = pair.core.git(pair.core.checkout, "commit-tree", local.candidate_tree, + "-p", local.baseline.commit, "-p", carrier.head, + "-m", "Synthetic PR/queue merge; no input metadata").strip() + checkout = integration.IntegrationCheckout.create(pair.core, tmp_path / "ci-checkout", tested) + assert IntegrationInput.from_message(Repo.git(checkout.path, "show", "--no-patch", "--format=%B", "HEAD")) == () + documents = { + "pull_request": {"number": carrier.pr, "pull_request": {"head": {"sha": carrier.head}}}, + "merge_group": {"merge_group": {"head_sha": tested, "base_sha": local.baseline.commit, + "head_ref": f"refs/heads/gh-readonly-queue/main/pr-{carrier.pr}-{carrier.head}"}}, + } + document = tmp_path / "event.json" + document.write_text(json.dumps(documents[event])) + output = tmp_path / "resolved" + environment = {**os.environ, **ENVIRONMENT, "GITHUB_EVENT_NAME": event, + "GITHUB_EVENT_PATH": str(document), "GITHUB_SHA": tested, + "GITHUB_REPOSITORY": pair.core.github.to_wire(), "RUNNER_TEMP": str(tmp_path), + "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")} + command = (sys.executable, "-m", "agent_comms.integration") + result = subprocess.run((*command, "--resolve-ci-inputs", str(output)), cwd=checkout.path, + env=environment, capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stdout + result.stderr + assert IntegrationInput.at(output) == inputs + for source in inputs.sources: + for side in (BaseSide, HeadSide): + side.require_materialized(output, source) + original = pair.core.git(pair.core.checkout, "rev-parse", "HEAD").strip() + result = subprocess.run((*command, "--revalidate-ci-inputs", str(output)), cwd=checkout.path, + env=environment, capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stdout + result.stderr + pair.client.git(pair.client.checkout, "push", "origin", "HEAD:main") + stale = subprocess.run((*command, "--revalidate-ci-inputs", str(output)), cwd=checkout.path, + env=environment, capture_output=True, text=True, timeout=120) + assert stale.returncode != 0 and "main moved" in stale.stderr + assert pair.core.git(pair.core.checkout, "rev-parse", "HEAD").strip() == original + assert not any(call[:2] == ("pr", "merge") or "state=success" in call for call in github_cli.calls()) + + +def test_ci_event_refuses_unrelated_graph_and_carrier_without_materialization( + mutual_sources: MutualSources, github_cli: GitHubFixture, +) -> None: + pair = mutual_sources + carrier = PullRequestHead(pair.core.github, pair.core.number, pair.core.branch_commit) + # main is not a two-parent PR test graph, regardless of plausible event text. + with pytest.raises(integration.IntegrationFailed, match="two-parent"): + integration.PullRequestSources.resolve(pair.core, pair.core.main_commit, + {"number": carrier.pr, "pull_request": {"head": {"sha": carrier.head}}}) + with pytest.raises(integration.IntegrationFailed, match="queued carrier"): + integration.MergeGroupSources.resolve(pair.core, pair.core.main_commit, + {"merge_group": {"head_sha": pair.core.main_commit, "base_sha": pair.core.main_commit, + "head_ref": "refs/heads/not-a-GitHub-queue-reference"}}) + assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) + + def test_selection_uses_the_project_declaration_and_unions_existing_files(tmp_path: Path): (tmp_path / "pyproject.toml").write_text( '[tool.pytest.ini_options]\ntestpaths = ["checks"]\n' diff --git a/tests/test_module_layers_clients.py b/tests/test_module_layers_clients.py index 64b113fb92..2aabf87fdc 100644 --- a/tests/test_module_layers_clients.py +++ b/tests/test_module_layers_clients.py @@ -8,6 +8,13 @@ from pathlib import Path from module_layers import OutgoingWire, Package, StackClient +from agent_comms.head_review import BaseSide, HeadSide, Side +from agent_comms.integration import IntegrationRun +from agent_comms.todos import DraftListedPullRequest +from github_fixture import GitHubFixture +from integration_source_fixture import MutualSources + +pytest_plugins = ("github_fixture", "test_holder_standing_rules", "integration_source_fixture") def write(path: Path, text: str = "") -> None: @@ -15,6 +22,44 @@ def write(path: Path, text: str = "") -> None: path.write_text(text) +def test_materialized_clients_check_matched_sources_and_both_mixed_sources_fail( + mutual_sources: MutualSources, github_cli: GitHubFixture, +) -> None: + pair = mutual_sources + inputs = IntegrationRun(pair.checkout, pair.output, "materialized module clients").qualify(pair.context, ("P2", "P3")) + core = inputs.source(pair.core.github) + base = BaseSide.require_materialized(pair.output, core) + head = HeadSide.require_materialized(pair.output, core) + files = tuple(sorted(pair.output.rglob(".git"))) + base_clients = StackClient.materialized(base / "stack", pair.output, BaseSide, inputs) + head_clients = StackClient.materialized(head / "stack", pair.output, HeadSide, inputs) + assert tuple(sorted(pair.output.rglob(".git"))) == files # No later fresh main clone. + assert Package(base, base_clients).missing_names == () + assert Package(head, head_clients).missing_names == () + assert {name for entry, name in Package(base, head_clients).missing_names} == {"new_api"} + assert {name for entry, name in Package(head, base_clients).missing_names} == {"old_api"} + assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) + + +def test_materialized_client_files_come_from_each_actual_cut( + mutual_sources: MutualSources, github_cli: GitHubFixture, +) -> None: + pair = mutual_sources + pair.client.git(pair.client.checkout, "mv", "src/workflow.py", "src/replacement.py") + commit = pair.client.publish_change("Move the candidate client file", "src") + stamp = "2026-10-24T00:00:00Z" + github_cli.put(pair.client.github, DraftListedPullRequest( + pair.client.number, "feature", commit, stamp, "main", reviewable_times=(stamp,))) + inputs = IntegrationRun(pair.checkout, pair.output, "side-specific client files").qualify(pair.context, ("P2", "P3")) + cuts: dict[type[Side], set[str]] = {} + for side in (BaseSide, HeadSide): + core = side.require_materialized(pair.output, inputs.source(pair.core.github)) + client, = StackClient.materialized(core / "stack", pair.output, side, inputs) + cuts[side] = {entry.path for entry in client.imports} + assert cuts[BaseSide] == {"src/workflow.py"} + assert cuts[HeadSide] == {"src/replacement.py"} + + def test_client_imports_root_reachability_and_name_missing_modules(tmp_path): core = tmp_path / "core" write( diff --git a/tools/module_layers.py b/tools/module_layers.py index 7975de06e8..cda48f19ba 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -37,7 +37,9 @@ from functools import cached_property from pathlib import Path +from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side from agent_comms.installer.runtime_resolution import StackSources +from agent_comms.repo import Repo from agent_comms.installer.runtime_wire import ClientImport, CrossesRuntimes from typing_names import TypingImports, TypingReference @@ -318,6 +320,22 @@ def fetched(cls, stack: Path, into: Path) -> tuple[StackClient, ...]: clients.append(cls(name, checkout)) return tuple(clients) + @classmethod + def materialized(cls, stack: Path, directory: Path, side: type[Side], + inputs: IntegrationInput) -> tuple[StackClient, ...]: + """Only this actual cut's declarations, from the already-frozen Git input. + + A package absent on this side is absent here; the comparison owner forms the + selected-file union across the two returned sets, not a phantom main clone. + """ + clients = [] + for name, (url, branch) in cls.sources(stack).items(): + source = inputs.source(Repo.repository_url(url)) + checkout = side.require_materialized(directory, source) + cls.require_package(checkout, name) + clients.append(cls(name, checkout)) + return tuple(clients) + @cached_property def imports(self) -> tuple[ClientImport, ...]: found: list[ClientImport] = [] @@ -682,9 +700,21 @@ def main() -> int: parser.add_argument( "--report", type=Path, help="with --check, also write the layer report to this file" ) + parser.add_argument("--ci-inputs", type=Path, + help="use the integration entrypoint's already-materialized exact source input") arguments = parser.parse_args() with tempfile.TemporaryDirectory() as clones: - package = Package(ROOT, StackClient.fetched(ROOT / "stack", Path(clones))) + if arguments.ci_inputs: + inputs = IntegrationInput.at(arguments.ci_inputs) + local = inputs.source(Repo.of(ROOT).github) + core = HeadSide.require_materialized(arguments.ci_inputs, local) + package = Package(core, StackClient.materialized(core / "stack", arguments.ci_inputs, HeadSide, inputs)) + base = BaseSide.require_materialized(arguments.ci_inputs, local) + if arguments.base and Repo.git(ROOT, "rev-parse", arguments.base).strip() != local.baseline.commit: + raise ValueError("The outgoing wire must check this input's actual captured base") + else: + package = Package(ROOT, StackClient.fetched(ROOT / "stack", Path(clones))) + base = Path(clones) / "base" if not arguments.check: sys.stdout.write(package.markdown()) return 0 @@ -692,7 +722,8 @@ def main() -> int: arguments.report.write_text(package.markdown()) lacking = report(package) if arguments.base: - base = base_tree(arguments.base, Path(clones) / "base") + if not arguments.ci_inputs: + base = base_tree(arguments.base, base) print(f"outgoing wire against Core at {arguments.base}:") lacking = report(Package(base, (OutgoingWire.at(),))) or lacking return 1 if lacking else 0 From 1372658f77f5329ed3b3e9ab26aa4181b72143dc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 13:53:54 -0400 Subject: [PATCH 036/109] 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 ebe43ce4d7ccde66be1e47d1ec3a2466a75bf5a0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 14:04:50 -0400 Subject: [PATCH 037/109] Compare clients from their matched source cuts through the measure family --- .../installer/runtime_resolution.py | 5 +- tests/test_integration.py | 26 ++++++ tests/test_module_layers_clients.py | 23 ++++- tools/debt_ratchet.py | 86 ++++++++----------- tools/module_layers.py | 73 +++++++++++++++- 5 files changed, 159 insertions(+), 54 deletions(-) diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index c75a6bc524..5deed593e4 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -82,7 +82,10 @@ def declared_sources(document: str) -> dict[str, tuple[str, str]]: match source: case {"git": str(url), "branch": str(branch)}: GitSource.require_branches(name, (branch,)) - found[FieldCodec.decode(str, name)] = (url, branch) + identity = canonicalize_name(FieldCodec.decode(str, name)) + if identity in found: + raise ValueError(f"The stack declares package {identity} twice") + found[identity] = (url, branch) return found raise ValueError("The stack declares no [tool.uv.sources] table") diff --git a/tests/test_integration.py b/tests/test_integration.py index 9a97152c69..dd9f11b8e3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -22,6 +22,8 @@ from integration_source_fixture import MutualSources from source_revision_fixture import SourceRepository from fixture_git import ENVIRONMENT, SETTINGS +from debt_ratchet import ClientTypeErrors, compare +from module_layers import MaterializedClients pytest_plugins = ("github_fixture", "test_holder_standing_rules", "integration_source_fixture") @@ -79,6 +81,22 @@ def test_actual_ci_cli_uses_verified_carrier_not_synthetic_message_and_revalidat assert not any(call[:2] == ("pr", "merge") or "state=success" in call for call in github_cli.calls()) +def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( + mutual_sources: MutualSources, +) -> None: + pair = mutual_sources + inputs = integration.IntegrationRun(pair.checkout, pair.output, "real client checker tuple").qualify(pair.context, ("P2", "P3")) + source = inputs.source(pair.core.github) + head = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() + clients = MaterializedClients(pair.output, inputs) + assert len(ClientTypeErrors.parts(pair.core.checkout, source.baseline.commit, head, + {"src/agent_comms/api.py"}, "src/", clients)) == 2 + result = compare(pair.core.checkout, source.baseline.commit, head, "src", 1, + measured=(ClientTypeErrors,), clients=clients) + assert not result.failed + assert all(value == 0 for value in result.delta.values()) + + def test_ci_event_refuses_unrelated_graph_and_carrier_without_materialization( mutual_sources: MutualSources, github_cli: GitHubFixture, ) -> None: @@ -95,6 +113,14 @@ def test_ci_event_refuses_unrelated_graph_and_carrier_without_materialization( assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) +def test_stack_source_identity_normalizes_package_spelling_and_refuses_duplicate_aliases() -> None: + source = '{git = "https://github.com/example/client.git", branch = "main"}' + assert StackSources.declared_sources(f'[tool.uv.sources]\nSource_Client = {source}\n') == { + "source-client": ("https://github.com/example/client.git", "main")} + with pytest.raises(ValueError, match="declares package source-client twice"): + StackSources.declared_sources(f'[tool.uv.sources]\nSource_Client = {source}\nsource-client = {source}\n') + + def test_selection_uses_the_project_declaration_and_unions_existing_files(tmp_path: Path): (tmp_path / "pyproject.toml").write_text( '[tool.pytest.ini_options]\ntestpaths = ["checks"]\n' diff --git a/tests/test_module_layers_clients.py b/tests/test_module_layers_clients.py index 2aabf87fdc..8965447409 100644 --- a/tests/test_module_layers_clients.py +++ b/tests/test_module_layers_clients.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -from module_layers import OutgoingWire, Package, StackClient +from module_layers import MaterializedClients, OutgoingWire, Package, StackClient from agent_comms.head_review import BaseSide, HeadSide, Side from agent_comms.integration import IntegrationRun from agent_comms.todos import DraftListedPullRequest @@ -60,6 +60,27 @@ def test_materialized_client_files_come_from_each_actual_cut( assert cuts[HeadSide] == {"src/replacement.py"} +def test_materialized_selection_includes_peer_imports_but_not_absent_paths( + mutual_sources: MutualSources, github_cli: GitHubFixture, +) -> None: + pair = mutual_sources + files = pair.client.write_files({Path("src/workflow.py"): 'def run() -> str:\n return "local"\n'}) + commit = pair.client.publish_change("Keep the file while removing its candidate Core import", *files) + stamp = "2026-10-24T00:00:00Z" + github_cli.put(pair.client.github, DraftListedPullRequest( + pair.client.number, "feature", commit, stamp, "main", reviewable_times=(stamp,))) + inputs = IntegrationRun(pair.checkout, pair.output, "selected file union").qualify(pair.context, ("P2", "P3")) + resource = MaterializedClients(pair.output, inputs) + core = inputs.source(pair.core.github) + head = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() + for side in (BaseSide, HeadSide): + client, = resource.at(pair.core.checkout, core.baseline.commit, head, side) + assert client.files_importing("agent_comms") == ["src/workflow.py"] + assert client.checkout == side.checkout(pair.output, pair.client.github) + candidate, = resource.at(pair.core.checkout, core.baseline.commit, head, HeadSide) + assert candidate.imports == () + + def test_client_imports_root_reachability_and_name_missing_modules(tmp_path): core = tmp_path / "core" write( diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 79356ca4bc..08346d4c57 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -5,7 +5,6 @@ import argparse import ast -import atexit import dataclasses import enum import fcntl @@ -21,7 +20,6 @@ import os import pickle import re -import shutil import subprocess import sys import tempfile @@ -56,7 +54,7 @@ MroDispatch, handles, ) -from module_layers import PACKAGE, StackClient +from module_layers import PACKAGE, ClientSources, MaterializedClients, StackClient from stored_formats import StoredForm from typing_names import TypingImports, TypingReference from agent_comms.field_codec import FieldCodec, ViewExclusion, boundary_decoder, projected @@ -195,7 +193,7 @@ def source_paths(root: str, tree: Path) -> tuple[str, ...]: @classmethod @abstractmethod def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: + root: str, clients: ClientSources = ClientSources()) -> tuple[Callable[[], object], ...]: """Independent pieces of the comparison; compare() runs each in a separate process.""" @classmethod @@ -209,9 +207,10 @@ def align(cls, base: Counts, head: Counts) -> tuple[Counts, Counts]: return base, head @classmethod - def compare(cls, repo: Path, base: str, head: str, changed: set[str], root: str): + def compare(cls, repo: Path, base: str, head: str, changed: set[str], root: str, + clients: ClientSources = ClientSources()) -> tuple[Counts, Counts]: """Return independent before/after measurements for this declaration.""" - parts = cls.parts(repo, base, head, changed, root) + parts = cls.parts(repo, base, head, changed, root, clients) return cls.combine([part() for part in parts], repo, base, head, changed, root) @@ -311,7 +310,7 @@ def counted(cls, repo: Path, base: str, head: str, changed: set[str], @classmethod def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: + root: str, clients: ClientSources = ClientSources()) -> tuple[Callable[[], object], ...]: return (partial(cls.counted, repo, base, head, changed, root),) @classmethod @@ -366,7 +365,7 @@ def read_fresh(cls, repo: Path, ref: str, paths: list[str]) -> dict[str, Reading @classmethod def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: + root: str, clients: ClientSources = ClientSources()) -> tuple[Callable[[], object], ...]: before, after = cls.inventory(repo, base, root), cls.inventory(repo, head, root) shared = sorted((before & after) - ChangedPaths.of(repo, base, head).touched) shards = os.process_cpu_count() or 1 @@ -459,8 +458,8 @@ def run_by(cls) -> type[Measure]: @classmethod def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: - return SyntaxPasses.parts(repo, base, head, changed, root) + root: str, clients: ClientSources = ClientSources()) -> tuple[Callable[[], object], ...]: + return SyntaxPasses.parts(repo, base, head, changed, root, clients) @classmethod def combine(cls, results: list, repo: Path, base: str, head: str, changed: set[str], @@ -611,7 +610,7 @@ def side(cls, side: type[Side], repo: Path, ref: str, @classmethod def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: + root: str, clients: ClientSources = ClientSources()) -> tuple[Callable[[], object], ...]: shards = os.process_cpu_count() or 1 return tuple(partial(cls.side, side, repo, ref, paths[shard::shards]) for side in Side.sides() for ref in (side.ref(base, head),) @@ -794,7 +793,7 @@ def checked(cls, repo: Path, ref: str, head: str, root: str, selected: list[str] @classmethod def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: + root: str, clients: ClientSources = ClientSources()) -> tuple[Callable[[], object], ...]: return tuple(partial(cls.findings, repo, ref, head, root, batch, side) for side in Side.sides() for ref in (side.ref(base, head),) for batch in cls.batches(sorted(changed & python_paths(repo, ref, root))) @@ -897,31 +896,14 @@ def environment(root: str, tree: Path) -> dict[str, str]: class ClientTypeErrors(SideCounts): - """Mypy errors in each stack client file that imports agent_comms, checked against each - revision's package. Both sides read the same client commit, so the client's own errors cancel - and an error the head adds is a name or signature the client uses that the change took away: - #3110 deleted TurnState.settles as unused while Toad's conversation_turn.py:262 called it, and - only the running app found it.""" + """Mypy growth across coherent Core/client source tuples in one checker environment. - finding = TypeErrors.finding - - @staticmethod - @cache - def clients(repo: Path, head: str) -> tuple[StackClient, ...]: - """The head's stack clients, cloned once per run and read by both sides; a revision - declaring no stack has none.""" - if not git(repo, "ls-tree", "--name-only", head, "--", "stack/pyproject.toml"): - return () - into = Path(tempfile.mkdtemp(prefix="debt-ratchet-clients-")) - atexit.register(shutil.rmtree, into, True) - (into / "stack").mkdir() - (into / "stack/pyproject.toml").write_bytes(git(repo, "show", f"{head}:stack/pyproject.toml")) - return StackClient.fetched(into / "stack", into) + Matched migrations intentionally compare different client code. Selection is the union + of both real cuts, with absent files absent and each cut's own configuration preserved. + Ordinary local comparisons retain the existing one-main client acquisition. + """ - @staticmethod - def selected(client: StackClient) -> list[str]: - return sorted({entry.path for entry in client.imports - if entry.module.partition(".")[0] == PACKAGE}) + finding = TypeErrors.finding @classmethod def findings(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> tuple[type[Side], Counter[str]]: @@ -932,7 +914,7 @@ def findings(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) - @classmethod def checked(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> Counter[str]: - selected = cls.selected(client) + selected = client.files_importing(PACKAGE) cache = ContentCache.directory.with_name("debt-ratchet-mypy-clients") / client.name / side.declared_name with tempfile.TemporaryDirectory(prefix="debt-ratchet-client-") as scratch: subprocess.run(["tar", "-x", "-C", scratch], input=git(repo, "archive", ref, "src"), @@ -943,7 +925,7 @@ def checked(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> cache, "--follow-imports=silent", "--ignore-missing-imports", *selected)), cwd=client.checkout, timeout=600, env={**os.environ, "MYPYPATH": os.pathsep.join( - (str(Path(scratch, "src")), str(client.checkout / "src")))}, + (str(Path(scratch, "src")), *client.import_paths))}, ) output = cls.checker_output(result, f"{client.name} at {ref}") return Counter(f"{client.name}/{match[1]}" for match in cls.finding.finditer(output) @@ -951,14 +933,14 @@ def checked(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> @classmethod def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: - # Only a change to the package the clients import can change what they see. - if not any(path.startswith(f"src/{PACKAGE}/") for path in changed): + root: str, clients: ClientSources = ClientSources()) -> tuple[Callable[[], object], ...]: + # A matched resource also owns client-only changes, even with an unchanged Core patch. + if not any(path.startswith(f"src/{PACKAGE}/") for path in changed) and not clients.requires_clients(changed): return () return tuple(partial(cls.findings, repo, side.ref(base, head), client, side) - for client in cls.clients(repo, head) - if cls.selected(client) - for side in Side.sides()) + for side in Side.sides() + for client in clients.at(repo, base, head, side) + if client.files_importing(PACKAGE)) @classmethod def measured(cls, found: dict[type[Side], Counter[str]], changed: set[str]) -> list[str]: @@ -2652,7 +2634,7 @@ class DonePhaseBroken(Measure): @classmethod def parts(cls, repo: Path, base: str, head: str, changed: set[str], - root: str) -> tuple[Callable[[], object], ...]: + root: str, clients: ClientSources = ClientSources()) -> tuple[Callable[[], object], ...]: declared = Path(inspect.getfile(PlanPhase)).resolve() if root != f"{declared.relative_to(REPOSITORY).parts[0]}/": return () @@ -2795,7 +2777,8 @@ def failed(self) -> bool: def compare(repo: Path, base: str, head: str, root: str, workers: int, - measured: tuple[type[Measure], ...] = Measure.members_with(Measure)) -> Comparison: + measured: tuple[type[Measure], ...] = Measure.members_with(Measure), + clients: ClientSources = ClientSources()) -> Comparison: source_root = Path(root) if source_root.is_absolute() or ".." in source_root.parts or not source_root.parts: raise ValueError("--root must be a repository-relative source directory") @@ -2807,9 +2790,9 @@ def compare(repo: Path, base: str, head: str, root: str, workers: int, before: Counts = {} after: Counts = {} introduced: dict[FileCount, list[int]] = {} - # Every measure reads production Python paths only, so with none touched each would - # report no change. - if not touched & (production_paths(repo, base) | production_paths(repo, head)): + # Ordinary comparisons need a touched production path. Matched inputs can change + # an external client while leaving the local Core patch unchanged. + if not touched & (production_paths(repo, base) | production_paths(repo, head)) and not clients.requires_clients(touched): return Comparison(root, base, head, (), before, after, introduced, {}) # A pass of a shared runner is measured once, by that runner. @@ -2817,7 +2800,7 @@ def compare(repo: Path, base: str, head: str, root: str, workers: int, # Forked workers start from this already-imported module instead of importing it again. with ForkedProcessPool(workers) as pool: parts = {measure: [pool.submit(part) - for part in measure.parts(repo, base, head, paths, root)] + for part in measure.parts(repo, base, head, paths, root, clients)] for measure in measures} sites = [pool.submit( CleanChangedLines.introduced, repo, base, head, @@ -2864,8 +2847,11 @@ def main() -> int: parser.add_argument("--head", required=True) parser.add_argument("--workers", type=int, default=os.process_cpu_count() or 1, help="Pool width; a caller running ratchets in parallel narrows it") + parser.add_argument("--ci-inputs", type=Path, + help="the integration entrypoint's exact materialized Core/client cuts") args = parser.parse_args() - result = compare(Path.cwd(), args.base, args.head, args.root, args.workers) + clients = MaterializedClients.read(args.ci_inputs) if args.ci_inputs else ClientSources() + result = compare(Path.cwd(), args.base, args.head, args.root, args.workers, clients=clients) # Flushed first: stdout is block-buffered into a pipe, and the growth lines must end the log. print(json.dumps(FieldCodec.project(result, "report"), indent=2), flush=True) # The failing step's last lines become check annotations (.github/run-step.sh merges diff --git a/tools/module_layers.py b/tools/module_layers.py index cda48f19ba..626ba2205e 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -25,7 +25,9 @@ import argparse import ast +import atexit import inspect +import shutil import subprocess import sys import tempfile @@ -33,8 +35,8 @@ import typing from abc import ABC, abstractmethod from collections.abc import Iterable, Sequence -from dataclasses import dataclass -from functools import cached_property +from dataclasses import dataclass, replace +from functools import cache, cached_property from pathlib import Path from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side @@ -306,6 +308,32 @@ class StackClient(StackSources): name: str checkout: Path + compared: tuple[Path, ...] = () + dependencies: tuple[Path, ...] = () + + @property + def import_paths(self) -> tuple[str, ...]: + return tuple(str(root / path) for root in (self.checkout, *self.dependencies) + for path in ("src", ".")) + + def files_importing(self, module: str) -> list[str]: + """A selected path comes from either cut, but absence on this cut stays absent.""" + clients = (self, *(StackClient(self.name, path) for path in self.compared)) + return sorted({entry.path for client in clients for entry in client.imports + if entry.module.partition(".")[0] == module + and (self.checkout / entry.path).is_file()}) + + @classmethod + @cache + def revision(cls, repo: Path, head: str) -> tuple[StackClient, ...]: + """Ordinary local comparisons retain one head-declared main capture for both sides.""" + if not Repo.git(repo, "ls-tree", "--name-only", head, "--", "stack/pyproject.toml"): + return () + into = Path(tempfile.mkdtemp(prefix="debt-ratchet-clients-")) + atexit.register(shutil.rmtree, into, True) + (into / "stack").mkdir() + (into / "stack/pyproject.toml").write_text(Repo.git(repo, "show", f"{head}:stack/pyproject.toml")) + return cls.fetched(into / "stack", into) @classmethod def fetched(cls, stack: Path, into: Path) -> tuple[StackClient, ...]: @@ -347,6 +375,47 @@ def imports(self) -> tuple[ClientImport, ...]: return tuple(found) +@dataclass(frozen=True) +class ClientSources: + """The comparison's acquisition policy, passed to every measure before workers fork.""" + + def requires_clients(self, changed: set[str]) -> bool: + return False + + def at(self, repo: Path, base: str, head: str, side: type[Side]) -> tuple[StackClient, ...]: + return StackClient.revision(repo, head) + + +@dataclass(frozen=True) +class MaterializedClients(ClientSources): + directory: Path + inputs: IntegrationInput + + @classmethod + def read(cls, directory: Path) -> MaterializedClients: + return cls(directory, IntegrationInput.at(directory)) + + def requires_clients(self, changed: set[str]) -> bool: + # A companion can change while the local Core patch is unchanged. + return True + + @cache + def at(self, repo: Path, base: str, head: str, side: type[Side]) -> tuple[StackClient, ...]: + source = self.inputs.source(Repo.of(repo).github) + if Repo.git(repo, "rev-parse", base).strip() != source.baseline.commit or ( + Repo.git(repo, "rev-parse", f"{head}^{{tree}}").strip() != source.candidate_tree): + raise ValueError("Checker Core cuts do not match the materialized source input") + cuts = {cut: StackClient.materialized( + cut.require_materialized(self.directory, source) / "stack", + self.directory, cut, self.inputs) for cut in Side.sides()} + clients = tuple(client for found in cuts.values() for client in found) + return tuple(replace(client, + compared=tuple(peer.checkout for peer in clients + if peer.name == client.name and peer.checkout != client.checkout), + dependencies=tuple(peer.checkout for peer in cuts[side] if peer.checkout != client.checkout)) + for client in cuts[side]) + + class OutgoingWire(StackClient): """Every declaration carrying the cross-runtime capability, from its owning registry.""" From 1ea8ab3b6784b0d8b8f9996677a4b9c72e9d9b4f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 14:41:39 -0400 Subject: [PATCH 038/109] 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 039/109] 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: From 037d6bed8a1738ba5b1d219d6ffa2317dd650613 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 15:08:15 -0400 Subject: [PATCH 040/109] Build one checker environment for the captured source tuple in hosted CI --- .github/workflows/debt-ratchet-cache.yml | 33 ++- .github/workflows/debt-ratchet.yml | 54 ++++- pyproject.toml | 1 + src/agent_comms/head_review.py | 7 +- .../installer/runtime_resolution.py | 37 ++-- src/agent_comms/integration.py | 39 +++- src/agent_comms/work_branches.py | 4 + tests/github_cli_stub.py | 3 + tests/github_fixture.py | 6 + tests/integration_source_fixture.py | 31 ++- tests/test_checker_stack.py | 62 ++++++ tests/test_head_review.py | 65 +++++- tests/test_integration.py | 119 ++++++++++- tests/test_module_layers_clients.py | 57 +++++- tools/checker_stack.py | 190 ++++++++++++++++++ tools/debt_ratchet.py | 8 +- tools/module_layers.py | 5 +- 17 files changed, 661 insertions(+), 60 deletions(-) create mode 100644 tests/test_checker_stack.py create mode 100644 tools/checker_stack.py diff --git a/.github/workflows/debt-ratchet-cache.yml b/.github/workflows/debt-ratchet-cache.yml index 10ef3b6ef5..802bebca3a 100644 --- a/.github/workflows/debt-ratchet-cache.yml +++ b/.github/workflows/debt-ratchet-cache.yml @@ -9,6 +9,18 @@ on: permissions: contents: read + pull-requests: read + +# GitHub's actor supplies only Git object attribution, never merge permission. +# Process-scoped configuration reaches merge/commit-tree without changing any checkout. +env: + GIT_AUTHOR_NAME: ${{ github.actor }} + GIT_AUTHOR_EMAIL: ${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com + GIT_COMMITTER_NAME: ${{ github.actor }} + GIT_COMMITTER_EMAIL: ${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: commit.gpgsign + GIT_CONFIG_VALUE_0: "false" concurrency: group: debt-ratchet-cache @@ -33,12 +45,20 @@ jobs: with: python-version-file: pyproject.toml - - name: Install agent-comms + - name: Bootstrap only Core and locked checker dependencies run: | python -m pip install packaging python -m pip install "$(python -I src/agent_comms/installer/declared_uv.py pyproject.toml)" - uv sync --locked --all-extras - echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + UV_PROJECT_ENVIRONMENT="$RUNNER_TEMP/checker-venv" uv sync --locked --all-extras + echo "$RUNNER_TEMP/checker-venv/bin" >> "$GITHUB_PATH" + + - name: Resolve main's actual merged source tuple once + env: + GH_TOKEN: ${{ github.token }} + run: PYTHONPATH=src python -m agent_comms.integration --resolve-ci-inputs "$RUNNER_TEMP/paired-sources" + + - name: Build the one common checker environment + run: PYTHONPATH=src:tools python tools/checker_stack.py --ci-inputs "$RUNNER_TEMP/paired-sources" - uses: actions/cache/restore@v4 with: @@ -50,7 +70,12 @@ jobs: - name: Measure main against its parent run: | PYTHONPATH=src:tools python tools/debt_ratchet.py \ - --root ${{ matrix.kind }} --base HEAD^1 --head HEAD || true + --root ${{ matrix.kind }} --base HEAD^1 --head HEAD --ci-inputs "$RUNNER_TEMP/paired-sources" || true + + - name: The same sources are still current before saving cache + env: + GH_TOKEN: ${{ github.token }} + run: PYTHONPATH=src python -m agent_comms.integration --revalidate-ci-inputs "$RUNNER_TEMP/paired-sources" - uses: actions/cache/save@v4 with: diff --git a/.github/workflows/debt-ratchet.yml b/.github/workflows/debt-ratchet.yml index 1ac9927be1..b060efef82 100644 --- a/.github/workflows/debt-ratchet.yml +++ b/.github/workflows/debt-ratchet.yml @@ -10,6 +10,17 @@ on: permissions: contents: read +# Materialization merges and tree proofs create Git objects; every child inherits this +# process-only identity/signing policy. No global or shared repository config is written. +env: + GIT_AUTHOR_NAME: ${{ github.actor }} + GIT_AUTHOR_EMAIL: ${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com + GIT_COMMITTER_NAME: ${{ github.actor }} + GIT_COMMITTER_EMAIL: ${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: commit.gpgsign + GIT_CONFIG_VALUE_0: "false" + # A superseded push to a pull request stops its old run; queue groups never share a group. # The draft state is part of the key: a draft run skips every job but the ratchets, so one that # starts in the same second as ready_for_review must not cancel the ready run (#3164 and #3154 lost @@ -65,8 +76,18 @@ jobs: - uses: actions/setup-python@v5 with: python-version-file: pyproject.toml - - name: Install agent-comms and test dependencies - run: python -m pip install -e src/agent_comms/skills/refactor-audit -e '.[dev]' + - name: Bootstrap only Core and locked checker dependencies + run: | + python -m pip install packaging + python -m pip install "$(python -I src/agent_comms/installer/declared_uv.py pyproject.toml)" + UV_PROJECT_ENVIRONMENT="$RUNNER_TEMP/checker-venv" uv sync --locked --all-extras + echo "$RUNNER_TEMP/checker-venv/bin" >> "$GITHUB_PATH" + - name: Resolve the actual tested source tuple once + env: + GH_TOKEN: ${{ github.token }} + run: PYTHONPATH=src python -m agent_comms.integration --resolve-ci-inputs "$RUNNER_TEMP/paired-sources" + - name: Build the one common checker environment + run: PYTHONPATH=src:tools python tools/checker_stack.py --ci-inputs "$RUNNER_TEMP/paired-sources" - name: Compile production and tools run: python -m compileall -q src tools - name: Import every Core and hook module @@ -90,7 +111,11 @@ jobs: - name: Stack clients import only names Core has; the outgoing wire matches the base env: BASE: ${{ needs.base.outputs.base }} - run: python tools/module_layers.py --check --base "$BASE" --report "$GITHUB_STEP_SUMMARY" + run: PYTHONPATH=src:tools python tools/module_layers.py --check --base "$BASE" --ci-inputs "$RUNNER_TEMP/paired-sources" --report "$GITHUB_STEP_SUMMARY" + - name: The same sources are still current before success + env: + GH_TOKEN: ${{ github.token }} + run: PYTHONPATH=src python -m agent_comms.integration --revalidate-ci-inputs "$RUNNER_TEMP/paired-sources" native-scripts: name: native-scripts @@ -205,14 +230,20 @@ jobs: with: python-version-file: pyproject.toml - - name: Install agent-comms + - name: Bootstrap only Core and locked checker dependencies run: | python -m pip install packaging python -m pip install "$(python -I src/agent_comms/installer/declared_uv.py pyproject.toml)" - uv lock --project stack --upgrade - UV_PROJECT_ENVIRONMENT="$GITHUB_WORKSPACE/.venv" uv sync --project stack --locked - uv pip install --python "$GITHUB_WORKSPACE/.venv/bin/python" -e '.[dev]' - echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + UV_PROJECT_ENVIRONMENT="$RUNNER_TEMP/checker-venv" uv sync --locked --all-extras + echo "$RUNNER_TEMP/checker-venv/bin" >> "$GITHUB_PATH" + + - name: Resolve the actual tested source tuple once + env: + GH_TOKEN: ${{ github.token }} + run: PYTHONPATH=src python -m agent_comms.integration --resolve-ci-inputs "$RUNNER_TEMP/paired-sources" + + - name: Build the one common checker environment + run: PYTHONPATH=src:tools python tools/checker_stack.py --ci-inputs "$RUNNER_TEMP/paired-sources" # Saved by debt-ratchet-cache.yml on main; entries are keyed by content, so any # older main's cache only misses where the code differs. @@ -229,10 +260,15 @@ jobs: set -o pipefail ratchet_status=0 PYTHONPATH=src:tools python tools/debt_ratchet.py \ - --root ${{ matrix.kind }} --base "$BASE" --head HEAD 2>ratchet-findings.txt | tee ratchet.txt || ratchet_status=$? + --root ${{ matrix.kind }} --base "$BASE" --head HEAD --ci-inputs "$RUNNER_TEMP/paired-sources" 2>ratchet-findings.txt | tee ratchet.txt || ratchet_status=$? cat ratchet-findings.txt >&2 exit "$ratchet_status" + - name: The same sources are still current before success + env: + GH_TOKEN: ${{ github.token }} + run: PYTHONPATH=src python -m agent_comms.integration --revalidate-ci-inputs "$RUNNER_TEMP/paired-sources" + # Hosted annotations stop at ten per step, so a unit never saw a long list (88 findings # on the bus batch, 2026-10-21). The whole list goes on the pull request, one comment per # kind, replaced on every run. diff --git a/pyproject.toml b/pyproject.toml index fc53e7492c..899a5142ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dev = [ "ruff==0.16.0", "mypy==2.3.0", "uv==0.13.1", + "tomli-w==1.2.0", "nominal-refactor-advisor @ git+https://github.com/OpenHCSDev/NominalRefactorAdvisor@main", ] diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index efb2de18f9..d57aeeaabe 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -182,11 +182,14 @@ def validate(self, repository: Repo, carrier: PullRequestHead) -> None: raise ToolRefusedError(f"{self.repository.to_wire()} main moved; recapture inputs") self.require_candidate(repository, carrier) - def require_candidate(self, repository: Repo, carrier: PullRequestHead) -> None: - """The event/review owner verifies the current carrier; all other heads stay exact.""" + def require_tree(self, repository: Repo) -> None: self.require_origin(repository) if self.expected_tree(repository) != self.candidate_tree: raise ToolRefusedError(f"{self.repository.to_wire()} candidate tree is not its captured combination") + + def require_candidate(self, repository: Repo, carrier: PullRequestHead) -> None: + """The event/review owner verifies the current carrier; all other heads stay exact.""" + self.require_tree(repository) for head in self.heads: if not head.same_pull_request(carrier): current = head.current() diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index 5deed593e4..0e4b99ebda 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -41,8 +41,8 @@ def require_package(checkout: Path, name: str) -> None: return raise ValueError(f"{checkout} does not declare stack package {name}") - @staticmethod - def require_compatible(baseline: str, candidate: str) -> None: + @classmethod + def require_compatible(cls, baseline: str, candidate: str) -> None: """Local-source membership may change; external runtime requirements must agree. This supported path compares the same external contract, not arbitrary requirement @@ -57,22 +57,29 @@ def require_compatible(baseline: str, candidate: str) -> None: match source: case {"git": str(), "branch": str()} | {"path": str()}: local_names.add(canonicalize_name(FieldCodec.decode(str, name))) - contracts = [] - for document in documents: - match document: - case {"project": {"dependencies": list() as dependencies, **project}}: - requirements = tuple(Requirement(text) for text in FieldCodec.decode(tuple[str, ...], dependencies)) - contracts.append(frozenset(str(requirement) for requirement in requirements - if canonicalize_name(requirement.name) not in local_names)) - version = ".".join(map(str, sys.version_info[:3])) - python = FieldCodec.decode(str, project.get("requires-python", "")) - if not SpecifierSet(python).contains(version, prereleases=True): - raise ValueError(f"The common interpreter {version} does not satisfy {python}") - case _: - raise ValueError("A compared source requires explicit project runtime dependencies") + contracts = tuple(frozenset(map(str, cls.runtime_requirements(document, frozenset(local_names)))) + for document in documents) if contracts[0] != contracts[1]: raise ValueError("External runtime dependency migration is unsupported by this common environment") + @staticmethod + def runtime_requirements(document: dict[str, object], local_names: frozenset[str]) -> tuple[Requirement, ...]: + """Decode the supported external project contract once, for every package consumer.""" + match document: + case {"project": {"dependencies": list() as dependencies, **project}}: + version = ".".join(map(str, sys.version_info[:3])) + python = FieldCodec.decode(str, project.get("requires-python", "")) + if not SpecifierSet(python).contains(version, prereleases=True): + raise ValueError(f"The common interpreter {version} does not satisfy {python}") + requirements = [] + for text in FieldCodec.decode(tuple[str, ...], dependencies): + requirement = Requirement(text) + requirement.name = canonicalize_name(requirement.name) + if requirement.name not in local_names: + requirements.append(requirement) + return tuple(requirements) + raise ValueError("A compared source requires explicit project runtime dependencies") + @staticmethod def declared_sources(document: str) -> dict[str, tuple[str, str]]: match tomllib.loads(document): diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index 846a2ba45b..d1a4291761 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -276,7 +276,7 @@ def options(cls) -> tuple[str, ...]: @property def parents(self) -> tuple[str, ...]: - return tuple(Repo.git(self.repository.checkout, "rev-list", "--parents", "-n", "1", self.tested).split()[1:]) + return WorkBranches(self.repository.checkout).commit_parents(self.tested) @property def baseline(self) -> str: @@ -310,8 +310,11 @@ def require_carrier(self) -> None: if current.base != "main": raise IntegrationFailed("The tested carrier no longer targets main") - def main_commit(self, source: IntegrationSource) -> str: - return source.baseline.commit + def main_commits(self, inputs: IntegrationInput) -> dict[RepositoryName, str]: + return {source.repository: source.baseline.commit for source in inputs.sources} + + def require_local_candidate(self, source: IntegrationSource) -> None: + source.require_candidate(self.repository, self.carrier) def materialize(self, directory: Path) -> IntegrationInput: """Capture once from Git/forge, then use these same exact checkouts for every consumer.""" @@ -394,11 +397,14 @@ def validate(self, inputs: IntegrationInput, repositories: tuple[Repo, ...]) -> if local.heads != (self.carrier,) or any(source.heads for source in inputs.sources if source.repository != self.repository.github): raise IntegrationFailed("An ordinary comparison cannot supply companion overlays") + mains = self.main_commits(inputs) for source in inputs.sources: repository = Repo.selected(repositories, source.repository.to_wire()) - if IntegrationSource.main_head(repository) != self.main_commit(source): + if IntegrationSource.main_head(repository) != mains[source.repository]: raise IntegrationFailed(f"{source.repository.to_wire()} main moved; recapture") - source.require_candidate(repository, self.carrier) + self.require_local_candidate(local) + for source in (source for source in inputs.sources if source.repository != self.repository.github): + source.require_candidate(Repo.selected(repositories, source.repository.to_wire()), self.carrier) class PullRequestSources(TestedSources, declared_name="pull_request"): @@ -442,8 +448,12 @@ def merged_carrier(repository: Repo, tested: str) -> PullRequestHead: if response.returncode: raise IntegrationFailed(response.stderr.strip() or "Cannot read main's merged PR provenance") numbers = FieldCodec.decode(tuple[int, ...], FieldCodec.parse(response.stdout)) + parents = WorkBranches(repository.checkout).commit_parents(tested) + if len(parents) != 2: + raise IntegrationFailed("Main cache requires the actual two-parent merged PR") rows = tuple(row for row in PullRequests().rows(repository.github, numbers) - if row.base == "main" and any(merged.merge.oid == tested for merged in row.as_merged())) + if row.base == "main" and row.head == parents[1] + and any(merged.merge.oid == tested for merged in row.as_merged())) if len(rows) != 1: raise IntegrationFailed("Main cache requires one actual merged PR; direct/ambiguous commits refuse") row, = rows @@ -464,10 +474,19 @@ def require_carrier(self) -> None: if self.merged_carrier(self.repository, self.tested) != self.carrier: raise IntegrationFailed("Main's uniquely identified merged carrier changed") - def main_commit(self, source: IntegrationSource) -> str: - if source.repository == self.repository.github: - return self.tested - return super().main_commit(source) + def main_commits(self, inputs: IntegrationInput) -> dict[RepositoryName, str]: + return {**super().main_commits(inputs), self.repository.github: self.tested} + + def require_local_candidate(self, source: IntegrationSource) -> None: + """Main owns the transition from exact open sources to exact merged ancestry.""" + source.require_tree(self.repository) + for head in (head for head in source.heads if not head.same_pull_request(self.carrier)): + rows = tuple(row for row in PullRequests().rows(head.repo, (head.pr,)) + if row.head == head.head and row.base == "main") + # Inclusion belongs to Git. An open/closed badge cannot undo a commit that + # the actual merged carrier contains, nor can it substitute for that proof. + if len(rows) != 1 or not Repo.descends(self.repository.checkout, head.head, self.tested): + raise IntegrationFailed(f"#{head.pr} is not the exact captured source merged into this main") @dataclass(frozen=True) diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index fe80aa439d..3bd0943681 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -365,6 +365,10 @@ def pull_request_base(self, base: str, head: str) -> str: self.require_commits("origin", base_head, head) return base_head + def commit_parents(self, head: str) -> tuple[str, ...]: + self.require_commits("origin", head) + return tuple(self._git("rev-list", "--parents", "-n", "1", head).split()[1:]) + def commit_message(self, head: str) -> str: """Read an immutable commit's message; its domain owner decodes any metadata.""" self.require_commits("origin", head) diff --git a/tests/github_cli_stub.py b/tests/github_cli_stub.py index 2a07cc5f5c..bb79f5031c 100755 --- a/tests/github_cli_stub.py +++ b/tests/github_cli_stub.py @@ -25,6 +25,9 @@ def main(): if arguments[:2] == ["pr", "view"]: data = repositories[arguments[arguments.index("-R") + 1]] request = data["requests"][arguments[2]] + if refusal := request.get("read_refusal"): + print(refusal, file=sys.stderr) + raise SystemExit(1) field = arguments[arguments.index("--json") + 1] assert arguments[arguments.index("--jq") + 1] == "." + field print({"number": request["listed"]["number"], "body": request["body"]}[field]) diff --git a/tests/github_fixture.py b/tests/github_fixture.py index 53ead17486..ab1e952e30 100644 --- a/tests/github_fixture.py +++ b/tests/github_fixture.py @@ -36,6 +36,12 @@ def put(self, repository: RepositoryName, row: ListedPullRequest, body: str = "" data[repository.to_wire()]["requests"][str(row.number)] = {"body": body, "listed": FieldCodec.encode(row)} self.path.write_text(json.dumps(data)) + def refuse_read(self, repository: RepositoryName, number: int, refusal: str) -> None: + """An existing remote PR is inaccessible at GitHub's actual process boundary.""" + data = json.loads(self.path.read_text()) + data[repository.to_wire()]["requests"][str(number)]["read_refusal"] = refusal + self.path.write_text(json.dumps(data)) + def refuse_arm(self, repository: RepositoryName, number: int, refusal: str) -> None: """GitHub's answer when enabling auto-merge on this pull request fails.""" data = json.loads(self.path.read_text()) diff --git a/tests/integration_source_fixture.py b/tests/integration_source_fixture.py index 8669222059..82ac2758ea 100644 --- a/tests/integration_source_fixture.py +++ b/tests/integration_source_fixture.py @@ -41,7 +41,7 @@ def publish_carrier(self, github: GitHubFixture) -> PullRequestHead: @pytest.fixture -def mutual_sources( +def source_pair( held_bus: Comms, configured_repository: Callable[[Comms, *tuple[Repo, ...]], Commander], github_cli: GitHubFixture, @@ -56,9 +56,14 @@ def mutual_sources( Path("pyproject.toml"): '[project]\nname = "agent-comms"\nversion = "0.0.1"\ndependencies = []\n', Path("stack/pyproject.toml"): ( '[project]\nname = "fixture-stack"\nversion = "0.0.1"\ndependencies = ["agent-comms", "source-client"]\n' + '[tool.uv]\noverride-dependencies = ["packaging==26.0"]\n' + 'constraint-dependencies = ["typing-extensions>=4.15"]\n' + '[tool.uv.pip]\nstrict = true\n' '[tool.uv.sources]\nagent-comms = {path = "..", editable = true}\n' f'source-client = {{git = "{client.remote}", branch = "main"}}\n' ), + Path("stack/uv.lock"): 'version = 1\nrevision = 3\nrequires-python = ">=3.14"\npackage = []\n', + Path("uv.lock"): 'version = 1\nrevision = 3\nrequires-python = ">=3.14"\npackage = []\n', Path("src/agent_comms/__init__.py"): "", Path("src/agent_comms/api.py"): 'def old_api() -> str:\n return "baseline"\n', }) @@ -83,14 +88,6 @@ def mutual_sources( held = primary.held() RecordPullRequest(primary.revision, held.owner_identity, core.link, held.generation).record( held_bus.goals, primary.id) - held_bus.goals.update_goal("worker", CreateProblemGoalAction( - problem="P3", commander="lead", request=primary.request, - assigner=held_bus.registry.require("lead").incarnation, - repo=client.github, text="Migrate the paired source client", - order=replace(primary.order.checked(), pull_requests=(client.link,), - check=replace(primary.order.check, directory=client.checkout)), - review_at=RecordedEventTime(time.time() + 3600), - )) stamp = "2026-10-24T00:00:00Z" for source in (core, client): github_cli.put(source.github, DraftListedPullRequest( @@ -98,3 +95,19 @@ def mutual_sources( )) checkout = IntegrationCheckout.create(core, tmp_path / "qualification", core.main_commit) return MutualSources(context, core, client, checkout, tmp_path / "qualified-inputs") + + +@pytest.fixture +def mutual_sources(source_pair: MutualSources) -> MutualSources: + """The same source owner, with its companion linked by a separate real checked order.""" + comms = source_pair.context.comms + primary = comms.goals.todos.get("P2") + comms.goals.update_goal("worker", CreateProblemGoalAction( + problem="P3", commander="lead", request=primary.request, + assigner=comms.registry.require("lead").incarnation, + repo=source_pair.client.github, text="Migrate the paired source client", + order=replace(primary.order.checked(), pull_requests=(source_pair.client.link,), + check=replace(primary.order.check, directory=source_pair.client.checkout)), + review_at=RecordedEventTime(time.time() + 3600), + )) + return source_pair diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py new file mode 100644 index 0000000000..d7a6783673 --- /dev/null +++ b/tests/test_checker_stack.py @@ -0,0 +1,62 @@ +"""Private full-project projection against real captured Git/forge inputs; no fake resolver.""" + +from pathlib import Path +import tomllib + +import pytest +import tomli_w + +from agent_comms.head_review import BaseSide, HeadSide +from agent_comms.installer.commands import InstallCommands +from agent_comms.integration import IntegrationRun +from agent_comms.launch_runtime import LaunchRuntime +from agent_comms.todos import DraftListedPullRequest +from checker_stack import CheckerStack +from github_fixture import GitHubFixture +from integration_source_fixture import MutualSources + +pytest_plugins = ("test_holder_standing_rules", "integration_source_fixture") + + +@pytest.mark.parametrize("dependencies,allowed", [((), True), (("idna==3.10",), False)]) +def test_private_project_preserves_settings_pins_and_refuses_external_migration( + mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, + dependencies: tuple[str, ...], allowed: bool, monkeypatch: pytest.MonkeyPatch, +) -> None: + pair = mutual_sources + # Edit the real source before capture, never rewrite the captured artifact or selectors. + core_project = pair.core.checkout / "pyproject.toml" + document = tomllib.loads(core_project.read_text()) + document["project"]["dependencies"] = list(dependencies) + head = pair.core.publish_change("Declare this candidate runtime contract", *pair.core.write_files({ + Path("pyproject.toml"): tomli_w.dumps(document)})) + stamp = "2026-10-24T00:00:00Z" + github_cli.put(pair.core.github, DraftListedPullRequest( + pair.core.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) + inputs = IntegrationRun(pair.checkout, pair.output, "full private checker project").qualify(pair.context, ("P2", "P3")) + with InstallCommands() as commands: + checker = CheckerStack(checkout=tmp_path / "checker", commands=commands, home=tmp_path, + runtime=LaunchRuntime.running(), source_directory=pair.output, + inputs=inputs, uv_program=Path("uv")) + original = checker.core / "stack" + before = (original / "pyproject.toml").read_bytes(), (original / "uv.lock").read_bytes() + if allowed: + checker.write_project() + expected = tomllib.loads(before[0].decode()) + expected["tool"]["uv"]["sources"] = { + "agent-comms": {"path": str(checker.core), "editable": True}, + "source-client": {"path": str(HeadSide.checkout(pair.output, pair.client.github)), "editable": True}, + } + assert tomllib.loads((checker.checkout / "stack/pyproject.toml").read_text()) == expected + assert checker.lock.read_bytes() == before[1] + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) + with pytest.raises(ValueError, match="isolated runner bootstrap prefix"): + checker.prepare() + else: + with pytest.raises(ValueError, match="External runtime dependency migration"): + checker.write_project() + assert not checker.checkout.exists() + assert ((original / "pyproject.toml").read_bytes(), (original / "uv.lock").read_bytes()) == before + for side in (BaseSide, HeadSide): + for source in inputs.sources: + side.require_materialized(pair.output, source) diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 78088b079a..83f2665929 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -40,7 +40,7 @@ CarriedHeadVerdict, ConflictMergeBlock, DraftListedPullRequest, FailedCheckMergeBlock, GrownHeadVerdict, GrowthReason, OrderHeadReview, LinkedHeadReview, NoHeadOrder, OrderClaim, ProgressRead, PullRequestLink, PullRequests, RecordPullRequest, RejectedHeadVerdict, HeadVerdict, QualifiedHeadVerdict, ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, - StrikeReason, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, + StrikeReason, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, WithdrawTodo, ) from github_fixture import GitHubFixture from integration_source_fixture import MutualSources @@ -177,6 +177,7 @@ def test_native_companion_link_preserves_claim_review_goal_and_is_idempotent( before = comms.goals.todos.get("P2") goal = comms.registry.require(before.held().owner).goal reviews = StoredHeadReview.head_reviews(comms.goals.todos, primary) + history = tuple(comms.registry.goal_history(before.held().owner)) link = PullRequestLink(SourceRepository.of(companion_checkout).github, primary.pr) context = order.Commander.read_root(comms.root) with chdir(companion_checkout): @@ -192,15 +193,48 @@ def test_native_companion_link_preserves_claim_review_goal_and_is_idempotent( assert OrderHeadReview.holding((after,), companion) == (after,) # Qualification reads these exact stored links, not a caller-provided pair roster. assert {item.repo for item in after.order.checked().pull_requests} == {primary.repo, link.repo} + story = after.story() with chdir(companion_checkout): order.main(["pr", before.id, "--existing", str(primary.pr)], context) assert comms.goals.todos.get(before.id) == after + assert comms.goals.todos.get(before.id).story() == story assert comms.registry.require(after.held().owner).goal == goal assert StoredHeadReview.head_reviews(comms.goals.todos, primary) == reviews + assert tuple(comms.registry.goal_history(after.held().owner)) == history assert not any(call[:2] in (("pr", "create"), ("pr", "merge")) for call in github.calls()) assert any(link.repo.to_wire() in argument for call in github.calls() for argument in call) +def test_native_companion_adoption_is_the_same_reviewed_orders_actual_qualification_source( + source_pair: MutualSources, claim_order: Callable[..., OrderClaim], + github_cli: GitHubFixture, +) -> None: + pair = source_pair + comms = pair.context.comms + primary = PullRequestHead(pair.core.github, pair.core.number, pair.core.branch_commit) + claim_order("P2", pull_request=primary.pr, head=primary.head) + todo = comms.goals.todos.get("P2") + invoke(comms, primary, UnderReviewHeadVerdict(), OrderHeadReview.for_request( + (todo,), primary, UnquotedHeadReview.of)) + before = comms.goals.todos.get("P2") + owner = comms.registry.require(before.held().owner) + rule = comms.registry.storage_rule + checkout = rule.repository_checkout(rule.roots_of(owner), before.id, pair.client.github) + checkout.parent.mkdir(parents=True, exist_ok=True) + pair.client.git(pair.client.checkout, "worktree", "add", "--detach", str(checkout), pair.client.branch_commit) + with chdir(checkout): + order.main(["pr", before.id, "--existing", str(pair.client.number)], order.Commander.read_root(comms.root)) + linked = comms.goals.todos.get(before.id) + assert linked == replace(before, revision=before.revision + 1, + order=before.order.checked().linked_to(pair.client.link)) + inputs = IntegrationRun(pair.checkout, pair.output, "native qualified companion discovery").qualify(pair.context, ("P2",)) + assert inputs.source(pair.client.github).heads == ( + PullRequestHead(pair.client.github, pair.client.number, pair.client.branch_commit),) + assert comms.goals.todos.get(before.id) == linked + assert not HeadReview.accepted(primary.repo, primary.head) + assert not any(call[:2] in (("pr", "create"), ("pr", "merge")) for call in github_cli.calls()) + + def test_companion_metadata_cannot_create_edit_or_adopt_missing_pr( linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], companion_checkout: Path, ) -> None: @@ -219,6 +253,19 @@ def test_companion_metadata_cannot_create_edit_or_adopt_missing_pr( assert not any(call[:2] in (("pr", "create"), ("pr", "edit"), ("pr", "merge")) for call in github.calls()) +def test_companion_metadata_refuses_an_existing_but_inaccessible_remote_pr( + linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], companion_checkout: Path, +) -> None: + comms, github, primary = linked_head_bus + before = comms.goals.todos.get("P2") + companion = SourceRepository.of(companion_checkout) + github.refuse_read(companion.github, primary.pr, "GraphQL: Resource not accessible by integration") + with chdir(companion_checkout), pytest.raises(subprocess.CalledProcessError): + order.main(["pr", before.id, "--existing", str(primary.pr)], order.Commander.read_root(comms.root)) + assert comms.goals.todos.get(before.id) == before + assert not any(call[:2] in (("pr", "create"), ("pr", "edit")) for call in github.calls()) + + def test_companion_metadata_refuses_another_owners_checkout( linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], companion_checkout: Path, ) -> None: @@ -252,6 +299,22 @@ def test_companion_metadata_refuses_an_unregistered_origin_before_forge_read( assert github.calls() == calls +def test_companion_metadata_refuses_a_closed_order_without_forge_or_link_changes( + linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], companion_checkout: Path, +) -> None: + comms, github, primary = linked_head_bus + before = comms.goals.todos.get("P2") + comms.goals.todos.apply(before.id, WithdrawTodo( + before.revision, comms.registry.require("lead").incarnation, + before.held(), "Close this fixture order before attempting metadata")) + closed = comms.goals.todos.get(before.id) + calls = github.calls() + with chdir(companion_checkout), pytest.raises((ValueError, TodoError)): + order.main(["pr", before.id, "--existing", str(primary.pr)], order.Commander.read_root(comms.root)) + assert comms.goals.todos.get(before.id) == closed + assert github.calls() == calls + + def test_companion_metadata_write_enforces_registration_revision_and_holder( linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], companion_checkout: Path, ) -> None: diff --git a/tests/test_integration.py b/tests/test_integration.py index dd9f11b8e3..413a7e2bc2 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,12 +1,15 @@ """Integration policy at real TOML/Git/gh boundaries; no patched Core methods.""" +from dataclasses import replace import json import os from pathlib import Path import subprocess import sys +import tomllib import pytest +import tomli_w from agent_comms import integration from agent_comms.errors import ToolRefusedError @@ -14,16 +17,19 @@ from agent_comms.repository_name import RepositoryName from agent_comms.repo import Repo from agent_comms.todos import ( - DraftListedPullRequest, QualifiedHeadVerdict, ReviewedHeadVerdict, UnmergedListedPullRequest, VerdictReason, + DraftListedPullRequest, MergedListedPullRequest, PullRequestCommit, QualifiedHeadVerdict, + ReviewedHeadVerdict, UnmergedListedPullRequest, VerdictReason, ) from agent_comms.field_codec import FieldCodec +from agent_comms.goal_actions import CreateProblemGoalAction +from agent_comms.recorded_time import RecordedEventTime from agent_comms.installer.runtime_resolution import StackSources from github_fixture import GitHubFixture from integration_source_fixture import MutualSources from source_revision_fixture import SourceRepository from fixture_git import ENVIRONMENT, SETTINGS from debt_ratchet import ClientTypeErrors, compare -from module_layers import MaterializedClients +from module_layers import MaterializedClients, Package pytest_plugins = ("github_fixture", "test_holder_standing_rules", "integration_source_fixture") @@ -81,11 +87,116 @@ def test_actual_ci_cli_uses_verified_carrier_not_synthetic_message_and_revalidat assert not any(call[:2] == ("pr", "merge") or "state=success" in call for call in github_cli.calls()) +def test_actual_ordinary_ci_cli_captures_main_once_and_never_steals_an_unlinked_overlay( + mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, +) -> None: + pair = mutual_sources + head = pair.core.branch_commit + baseline = pair.core.main_commit + tree = pair.core.git(pair.core.checkout, "rev-parse", f"{head}^{{tree}}").strip() + tested = pair.core.git(pair.core.checkout, "commit-tree", tree, "-p", baseline, + "-p", head, "-m", "Actual ordinary synthetic merge").strip() + checkout = integration.IntegrationCheckout.create(pair.core, tmp_path / "ordinary-ci", tested) + document = tmp_path / "ordinary-event.json" + document.write_text(json.dumps({"number": pair.core.number, "pull_request": {"head": {"sha": head}}})) + output = tmp_path / "ordinary-inputs" + environment = {**os.environ, **ENVIRONMENT, "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_EVENT_PATH": str(document), "GITHUB_SHA": tested, + "GITHUB_REPOSITORY": pair.core.github.to_wire(), "RUNNER_TEMP": str(tmp_path), + "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")} + result = subprocess.run((sys.executable, "-m", "agent_comms.integration", "--resolve-ci-inputs", str(output)), + cwd=checkout.path, env=environment, capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stdout + result.stderr + inputs = IntegrationInput.at(output) + core = inputs.source(pair.core.github) + client = inputs.source(pair.client.github) + assert core.baseline.commit == baseline + assert core.heads == (PullRequestHead(pair.core.github, pair.core.number, head),) + assert client.heads == () and client.baseline.commit == pair.client.main_commit + resource = MaterializedClients(output, inputs) + base = BaseSide.require_materialized(output, core) + candidate = HeadSide.require_materialized(output, core) + assert Package(base, resource.at(checkout.path, baseline, tested, BaseSide)).missing_names == () + assert {name for _, name in Package(candidate, resource.at(checkout.path, baseline, tested, HeadSide)).missing_names} == {"old_api"} + assert not any("state=success" in call or call[:2] == ("pr", "merge") for call in github_cli.calls()) + + +def test_actual_main_cli_keeps_merged_local_members_exact_and_refuses_later_head_changes( + mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, +) -> None: + pair = mutual_sources + peer = SourceRepository(checkout=pair.core.checkout, github=pair.core.github, number=8) + peer.git(peer.checkout, "switch", "-c", "second-local-source", "main") + peer_head = peer.publish_change("Independent local source", *peer.write_files({ + Path("src/agent_comms/extra.py"): "extra = 1\n"})) + peer.git(peer.checkout, "switch", "feature") + stamp = "2026-10-24T00:00:00Z" + github_cli.put(peer.github, DraftListedPullRequest( + peer.number, "second-local-source", peer_head, stamp, "main", reviewable_times=(stamp,))) + primary = pair.context.comms.goals.todos.get("P2") + pair.context.comms.goals.update_goal("worker", CreateProblemGoalAction( + problem="P4", commander="lead", request=primary.request, + assigner=pair.context.comms.registry.require("lead").incarnation, + repo=peer.github, text="Include the existing local source", + order=replace(primary.order.checked(), pull_requests=(peer.link,)), + review_at=RecordedEventTime(9999999999.0))) + inputs = integration.IntegrationRun(pair.checkout, pair.output, "actual main tuple").qualify( + pair.context, ("P2", "P3", "P4")) + carrier = pair.publish_carrier(github_cli) + pair.core.git(pair.core.checkout, "switch", "main") + pair.core.git(pair.core.checkout, "merge", "--no-ff", "--no-edit", carrier.head) + tested = pair.core.git(pair.core.checkout, "rev-parse", "HEAD").strip() + pair.core.git(pair.core.checkout, "push", "origin", "main") + for number, head in ((carrier.pr, carrier.head), (peer.number, peer_head)): + github_cli.put(pair.core.github, MergedListedPullRequest( + number, "feature", head, stamp, "main", reviewable_times=(stamp,), + merge=PullRequestCommit(tested), merged=stamp)) + document = tmp_path / "main-event.json" + document.write_text(json.dumps({"ref": "refs/heads/main", "after": tested})) + output = tmp_path / "main-inputs" + environment = {**os.environ, **ENVIRONMENT, "GITHUB_EVENT_NAME": "push", + "GITHUB_EVENT_PATH": str(document), "GITHUB_SHA": tested, + "GITHUB_REPOSITORY": pair.core.github.to_wire(), "RUNNER_TEMP": str(tmp_path), + "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")} + command = (sys.executable, "-m", "agent_comms.integration") + result = subprocess.run((*command, "--resolve-ci-inputs", str(output)), cwd=pair.core.checkout, + env=environment, capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stdout + result.stderr + assert IntegrationInput.at(output) == inputs + result = subprocess.run((*command, "--revalidate-ci-inputs", str(output)), cwd=pair.core.checkout, + env=environment, capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stdout + result.stderr + github_cli.put(peer.github, MergedListedPullRequest( + peer.number, "second-local-source", "f" * 40, stamp, "main", reviewable_times=(stamp,), + merge=PullRequestCommit(tested), merged=stamp)) + stale = subprocess.run((*command, "--revalidate-ci-inputs", str(output)), cwd=pair.core.checkout, + env=environment, capture_output=True, text=True, timeout=120) + assert stale.returncode != 0 and "exact captured source" in stale.stderr + assert not any("state=success" in call or call[:2] == ("pr", "merge") for call in github_cli.calls()) + + def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( - mutual_sources: MutualSources, + mutual_sources: MutualSources, github_cli: GitHubFixture, ) -> None: pair = mutual_sources - inputs = integration.IntegrationRun(pair.checkout, pair.output, "real client checker tuple").qualify(pair.context, ("P2", "P3")) + plugin = ('from mypy.plugin import Plugin\nfrom agent_comms.api import old_api\n' + 'def plugin(version: str) -> type[Plugin]:\n' + ' assert old_api() == "baseline"\n return Plugin\n') + # The native config and its plugin are real baseline source, then migrated on feature. + pair.client.git(pair.client.checkout, "switch", "main") + project = tomllib.loads((pair.client.checkout / "pyproject.toml").read_text()) + project["tool"] = {"mypy": {"plugins": ["mypy_source_plugin.py"]}} + pair.client.publish_change("Declare the baseline native mypy plugin", *pair.client.write_files({ + Path("pyproject.toml"): tomli_w.dumps(project), Path("mypy_source_plugin.py"): plugin})) + pair.client.git(pair.client.checkout, "push", "origin", "main") + pair.client.git(pair.client.checkout, "switch", "feature") + pair.client.git(pair.client.checkout, "rebase", "main") + head = pair.client.publish_change("Migrate the real plugin alongside the client", *pair.client.write_files({ + Path("mypy_source_plugin.py"): plugin.replace("old_api", "new_api").replace("baseline", "candidate")})) + stamp = "2026-10-24T00:00:00Z" + github_cli.put(pair.client.github, DraftListedPullRequest( + pair.client.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) + inputs = integration.IntegrationRun(pair.checkout, pair.output, "real client checker tuple and native plugins").qualify(pair.context, ("P2", "P3")) source = inputs.source(pair.core.github) head = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() clients = MaterializedClients(pair.output, inputs) diff --git a/tests/test_module_layers_clients.py b/tests/test_module_layers_clients.py index 8965447409..1736380014 100644 --- a/tests/test_module_layers_clients.py +++ b/tests/test_module_layers_clients.py @@ -5,9 +5,14 @@ import shutil import subprocess import sys +import tomllib from pathlib import Path +import pytest +import tomli_w + from module_layers import MaterializedClients, OutgoingWire, Package, StackClient +from debt_ratchet import ClientTypeErrors from agent_comms.head_review import BaseSide, HeadSide, Side from agent_comms.integration import IntegrationRun from agent_comms.todos import DraftListedPullRequest @@ -41,6 +46,41 @@ def test_materialized_clients_check_matched_sources_and_both_mixed_sources_fail( assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) +@pytest.mark.parametrize("baseline_has_client,candidate_has_client", ((False, True), (True, False))) +def test_actual_client_membership_stays_absent_on_its_own_cut( + mutual_sources: MutualSources, github_cli: GitHubFixture, + baseline_has_client: bool, candidate_has_client: bool, +) -> None: + pair = mutual_sources + full = (pair.core.checkout / "stack/pyproject.toml").read_text() + absent = tomllib.loads(full) + absent["project"]["dependencies"].remove("source-client") + del absent["tool"]["uv"]["sources"]["source-client"] + absent_text = tomli_w.dumps(absent) + if not baseline_has_client: + pair.core.git(pair.core.checkout, "switch", "main") + pair.core.publish_change("Baseline has no client declaration", *pair.core.write_files({ + Path("stack/pyproject.toml"): absent_text})) + pair.core.git(pair.core.checkout, "push", "origin", "main") + pair.core.git(pair.core.checkout, "switch", "feature") + pair.core.git(pair.core.checkout, "rebase", "main") + head = pair.core.publish_change("Declare only the candidate's actual client membership", *pair.core.write_files({ + Path("stack/pyproject.toml"): full if candidate_has_client else absent_text})) + stamp = "2026-10-24T00:00:00Z" + github_cli.put(pair.core.github, DraftListedPullRequest( + pair.core.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) + inputs = IntegrationRun(pair.checkout, pair.output, "actual added and removed client").qualify(pair.context, ("P2", "P3")) + resource = MaterializedClients(pair.output, inputs) + core = inputs.source(pair.core.github) + tested = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() + for side, present in ((BaseSide, baseline_has_client), (HeadSide, candidate_has_client)): + clients = resource.at(pair.core.checkout, core.baseline.commit, tested, side) + assert len(clients) == int(present) + assert all(client.checkout == side.checkout(pair.output, pair.client.github) for client in clients) + assert len(ClientTypeErrors.parts(pair.core.checkout, core.baseline.commit, tested, + {"src/agent_comms/api.py"}, "src/", resource)) == 1 + + def test_materialized_client_files_come_from_each_actual_cut( mutual_sources: MutualSources, github_cli: GitHubFixture, ) -> None: @@ -60,13 +100,26 @@ def test_materialized_client_files_come_from_each_actual_cut( assert cuts[HeadSide] == {"src/replacement.py"} +@pytest.mark.parametrize("name", ("source-client", "source-renamed")) def test_materialized_selection_includes_peer_imports_but_not_absent_paths( - mutual_sources: MutualSources, github_cli: GitHubFixture, + mutual_sources: MutualSources, github_cli: GitHubFixture, name: str, ) -> None: pair = mutual_sources - files = pair.client.write_files({Path("src/workflow.py"): 'def run() -> str:\n return "local"\n'}) + client_project = tomllib.loads((pair.client.checkout / "pyproject.toml").read_text()) + client_project["project"]["name"] = name + files = pair.client.write_files({ + Path("src/workflow.py"): 'def run() -> str:\n return "local"\n', + Path("pyproject.toml"): tomli_w.dumps(client_project)}) commit = pair.client.publish_change("Keep the file while removing its candidate Core import", *files) + stack = tomllib.loads((pair.core.checkout / "stack/pyproject.toml").read_text()) + stack["project"]["dependencies"] = [name if item == "source-client" else item + for item in stack["project"]["dependencies"]] + stack["tool"]["uv"]["sources"][name] = stack["tool"]["uv"]["sources"].pop("source-client") + core_head = pair.core.publish_change("Declare the actual candidate client name", *pair.core.write_files({ + Path("stack/pyproject.toml"): tomli_w.dumps(stack)})) stamp = "2026-10-24T00:00:00Z" + github_cli.put(pair.core.github, DraftListedPullRequest( + pair.core.number, "feature", core_head, stamp, "main", reviewable_times=(stamp,))) github_cli.put(pair.client.github, DraftListedPullRequest( pair.client.number, "feature", commit, stamp, "main", reviewable_times=(stamp,))) inputs = IntegrationRun(pair.checkout, pair.output, "selected file union").qualify(pair.context, ("P2", "P3")) diff --git a/tools/checker_stack.py b/tools/checker_stack.py new file mode 100644 index 0000000000..5956109b80 --- /dev/null +++ b/tools/checker_stack.py @@ -0,0 +1,190 @@ +"""Build the one checker environment from materialized CI sources, never resolve sources here.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import shutil +import sys +import tomllib +from dataclasses import dataclass +from functools import cached_property +from pathlib import Path + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +import tomli_w + +from agent_comms.field_codec import FieldCodec +from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side +from agent_comms.installer.commands import InstallCommands +from agent_comms.installer.declared_uv import DeclaredUv +from agent_comms.installer.runtime_resolution import CorePackage, ResolvedLock, RuntimeResolution, StackSources +from agent_comms.installer.upgrade import Stack +from agent_comms.launch_runtime import LaunchRuntime +from agent_comms.repo import Repo +from module_layers import StackClient + + +@dataclass(frozen=True, kw_only=True) +class CheckerStack(Stack, StackSources): + """Stack's command/probe/runtime lifetime, with a private unchanged-settings CI project. + + This consumes IntegrationInput. It cannot choose a new source, read the live bus, + post a verdict, or replace a production project/lock/interpreter. + """ + + source_directory: Path + inputs: IntegrationInput + uv_program: Path + + @property + def uv(self) -> Path: + return self.uv_program + + @cached_property + def core(self) -> Path: + return HeadSide.require_materialized(self.source_directory, self.inputs.source(CorePackage.repository)) + + @cached_property + def local_names(self) -> frozenset[str]: + source = self.inputs.source(CorePackage.repository) + return frozenset({CorePackage.distribution, *(name for side in Side.sides() + for name in self.sources(side.require_materialized(self.source_directory, source) / "stack"))}) + + def require_uv(self) -> None: + declared = DeclaredUv.of(self.core / "pyproject.toml", self.uv) + if not declared.accepts(self.commands.run(self.uv, "--version")): + raise ValueError("The bootstrap checker must use Core's declared uv") + + def projects(self, side: type[Side]) -> tuple[Path, ...]: + core = side.require_materialized(self.source_directory, self.inputs.source(CorePackage.repository)) + clients = StackClient.materialized(core / "stack", self.source_directory, side, self.inputs) + return (core, *(client.checkout for client in clients)) + + def requirements(self, side: type[Side]) -> tuple[Requirement, ...]: + core = side.checkout(self.source_directory, CorePackage.repository) + return tuple(requirement for project in (*self.projects(side), core / "stack") + for requirement in self.runtime_requirements( + tomllib.loads((project / "pyproject.toml").read_text()), self.local_names)) + + def require_runtime_compatibility(self) -> None: + # Local package presence differs legitimately; only the real external contract agrees. + before = frozenset(map(str, self.requirements(BaseSide))) + after = frozenset(map(str, self.requirements(HeadSide))) + if before != after: + raise ValueError("External runtime dependency migration requires an explicit compatible environment") + + def write_project(self) -> None: + """Preserve the full candidate document and pins; replace only its source entries.""" + self.require_runtime_compatibility() + original = self.core / "stack" + project = self.checkout / "stack" + project.mkdir(parents=True, exist_ok=False) + document: dict[str, object] = tomllib.loads((original / "pyproject.toml").read_text()) + match document: + case {"tool": {"uv": {"sources": dict() as sources}}}: + for raw_name, value in tuple(sources.items()): + name = canonicalize_name(FieldCodec.decode(str, raw_name)) + match value: + case {"git": str(url), "branch": str(), **options}: + source = self.inputs.source(Repo.repository_url(url)) + checkout = HeadSide.require_materialized(self.source_directory, source) + self.require_package(checkout, name) + sources[raw_name] = {**options, "path": str(checkout), "editable": True} + case {"path": str(relative), **options}: + checkout = (original / relative).resolve() + if checkout != self.core or name != CorePackage.distribution: + raise ValueError(f"Uncaptured local source {name}: {checkout}") + sources[raw_name] = {**options, "path": str(checkout)} + case _: + # Pinned/index/archive sources remain exactly as declared. + pass + case _: + raise ValueError("The candidate stack requires its actual source declarations") + (project / "pyproject.toml").write_text(tomli_w.dumps(document)) + shutil.copyfile(original / "uv.lock", self.lock) + + def resolve(self) -> None: + # No --upgrade: unrelated release and revision pins survive the local-path projection. + self.commands.run(self.uv, "lock", "--project", self.checkout / "stack", "--python", self.python) + + def require_resolution(self, resolution: RuntimeResolution, original: ResolvedLock) -> None: + locked = ResolvedLock.read(self.lock) + names = {entry.name for entry in locked.package} - self.local_names + selected = {name: record.version for name, record in resolution.packages.items() if name in names} + wanted = locked.pins(selected, resolution.core_commit) + actual = {name: resolution.packages[name].pin() for name in selected} + if actual != wanted: + raise ValueError("The one actual checker environment differs from its private lock") + retained = {entry.name for entry in original.package} & selected.keys() + versions = {name: selected[name] for name in retained} + if original.pins(versions, resolution.core_commit) != locked.pins(versions, resolution.core_commit): + raise ValueError("Resolving local sources changed a retained external release/revision pin") + baseline = ResolvedLock.read(BaseSide.checkout(self.source_directory, CorePackage.repository) / "stack/uv.lock") + shared = {entry.name for entry in baseline.package} & selected.keys() + baseline_versions = {name: selected[name] for name in shared} + if baseline.pins(baseline_versions, resolution.core_commit) != locked.pins(baseline_versions, resolution.core_commit): + raise ValueError("Captured sides require different external release/revision pins") + # The existing bootstrap lock owns checker-only packages; the stack lock owns + # runtime packages. Their disjoint selection avoids inventing a merged pin roster. + bootstrap = ResolvedLock.read(self.core / "uv.lock") + checker_names = {entry.name for entry in bootstrap.package} - names - self.local_names + checker_versions = {name: record.version for name, record in resolution.packages.items() + if name in checker_names} + if bootstrap.pins(checker_versions, resolution.core_commit) != { + name: resolution.packages[name].pin() for name in checker_versions}: + raise ValueError("Preparing runtime sources changed a locked checker-only package") + for side in Side.sides(): + for requirement in self.requirements(side): + if requirement.marker is None or requirement.marker.evaluate(): + record = resolution.packages[canonicalize_name(requirement.name)] + if not requirement.specifier.contains(record.version, prereleases=True): + raise ValueError(f"The common environment does not satisfy {requirement}") + + def prepare(self) -> RuntimeResolution: + prefix = self.runtime.prefix.resolve() + runner = Path(os.environ["RUNNER_TEMP"]).resolve() + if (not self.source_directory.resolve().is_relative_to(runner) or not prefix.is_relative_to(runner) + or not self.checkout.resolve().is_relative_to(self.source_directory.resolve()) + or not (prefix / "pyvenv.cfg").is_file()): + raise ValueError("The common checker requires its isolated runner bootstrap prefix and input directory") + self.require_uv() + self.write_project() + original = ResolvedLock.read(self.lock) + self.resolve() + # Retain the bootstrap's Core dev/checker packages in this SAME isolated prefix. + self.commands.run(self.uv, "sync", "--project", self.checkout / "stack", "--locked", + "--inexact", "--no-editable", "--python", self.python, + env={**os.environ, "UV_PROJECT_ENVIRONMENT": str(self.runtime.prefix)}) + self.commands.run(self.uv, "pip", "check", "--python", self.python) + commit = Repo.git(self.core, "rev-parse", "HEAD").strip() + resolution = RuntimeResolution.capture(self, commit) + self.require_resolution(resolution, original) + resolution.save(self.runtime.prefix) + digest = hashlib.sha256(self.lock.read_bytes()).hexdigest() + bootstrap_digest = hashlib.sha256((self.core / "uv.lock").read_bytes()).hexdigest() + print(f"CHECKER INTERPRETER {self.python}\nCHECKER LOCK {digest}\n" + f"CHECKER BOOTSTRAP LOCK {bootstrap_digest}\n{resolution.model_dump_json(indent=2)}", flush=True) + return resolution + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ci-inputs", type=Path, required=True) + args = parser.parse_args() + directory = args.ci_inputs.resolve() + executable = shutil.which("uv") + if executable is None: + raise ValueError("Bootstrap Core's declared uv before preparing the checker stack") + inputs = IntegrationInput.at(directory) + with InstallCommands() as commands: + CheckerStack(checkout=directory / "checker", commands=commands, home=directory, + runtime=LaunchRuntime(Path(sys.executable)), source_directory=directory, + inputs=inputs, uv_program=Path(executable)).prepare() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 08346d4c57..ece3800bdd 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -919,13 +919,15 @@ def checked(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> with tempfile.TemporaryDirectory(prefix="debt-ratchet-client-") as scratch: subprocess.run(["tar", "-x", "-C", scratch], input=git(repo, "archive", ref, "src"), check=True, timeout=60) - # The client checks under its own configuration, from its checkout. + # Native config plugins import Python too: both readers need the same cut, + # not a baseline static path alongside candidate runtime imports. + imports = (str(Path(scratch, "src")), *client.import_paths) result = SpawnedProcess.run( (sys.executable, "-m", *TypeErrors.mypy( cache, "--follow-imports=silent", "--ignore-missing-imports", *selected)), cwd=client.checkout, timeout=600, - env={**os.environ, "MYPYPATH": os.pathsep.join( - (str(Path(scratch, "src")), *client.import_paths))}, + env={**os.environ, "MYPYPATH": os.pathsep.join(imports), + "PYTHONPATH": os.pathsep.join((*imports, os.environ.get("PYTHONPATH", "")))}, ) output = cls.checker_output(result, f"{client.name} at {ref}") return Counter(f"{client.name}/{match[1]}" for match in cls.finding.finditer(output) diff --git a/tools/module_layers.py b/tools/module_layers.py index 626ba2205e..ff60a7b4f9 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -409,9 +409,12 @@ def at(self, repo: Path, base: str, head: str, side: type[Side]) -> tuple[StackC cut.require_materialized(self.directory, source) / "stack", self.directory, cut, self.inputs) for cut in Side.sides()} clients = tuple(client for found in cuts.values() for client in found) + origins = {member.checkout(self.directory, source.repository): source.repository + for member in Side.sides() for source in self.inputs.sources} return tuple(replace(client, compared=tuple(peer.checkout for peer in clients - if peer.name == client.name and peer.checkout != client.checkout), + if origins[peer.checkout] == origins[client.checkout] + and peer.checkout != client.checkout), dependencies=tuple(peer.checkout for peer in cuts[side] if peer.checkout != client.checkout)) for client in cuts[side]) From 1467ea144d419c0ba91751cc1af5a7e45015b187 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 15:15:42 -0400 Subject: [PATCH 041/109] Lock the TOML writer used by isolated checker preparation --- uv.lock | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/uv.lock b/uv.lock index 14240be34f..faa005c9f7 100644 --- a/uv.lock +++ b/uv.lock @@ -36,6 +36,7 @@ dev = [ { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "ruff" }, + { name = "tomli-w" }, { name = "uv" }, ] @@ -55,6 +56,7 @@ requires-dist = [ { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.3" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.0" }, + { name = "tomli-w", marker = "extra == 'dev'", specifier = "==1.2.0" }, { name = "uv", marker = "extra == 'dev'", specifier = "==0.13.1" }, ] provides-extras = ["dev"] @@ -773,6 +775,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "tree-sitter" version = "0.25.2" From 9a477db8813de34f8827b4cdc3935d2f9e26913f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 16:01:51 -0400 Subject: [PATCH 042/109] Derive checker package contracts from standard backend wheel metadata --- .../installer/runtime_resolution.py | 186 +++++++++++++++--- tests/integration_source_fixture.py | 22 ++- tests/source_revision_fixture.py | 13 ++ tests/test_checker_stack.py | 43 +++- tests/test_integration.py | 15 +- tests/test_module_layers_clients.py | 20 +- tests/test_project_metadata.py | 184 +++++++++++++++++ tools/checker_stack.py | 38 ++-- tools/debt_ratchet.py | 7 +- tools/module_layers.py | 91 +++++++-- 10 files changed, 536 insertions(+), 83 deletions(-) create mode 100644 tests/test_project_metadata.py diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index 0e4b99ebda..674de6b126 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -2,17 +2,24 @@ from __future__ import annotations +import shutil import sys import tomllib +from collections.abc import Iterable from dataclasses import dataclass +from email import policy +from email.parser import BytesParser from pathlib import Path +from tempfile import TemporaryDirectory +from zipfile import ZipFile from typing import TYPE_CHECKING, ClassVar from urllib.parse import parse_qs, urlsplit -from packaging.markers import Marker +from packaging.markers import Marker, default_environment from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet -from packaging.utils import canonicalize_name +from packaging.utils import canonicalize_name, parse_wheel_filename +from packaging.version import Version from pydantic import BaseModel, Field from agent_comms.declared_family import DeclaredFamily @@ -20,12 +27,96 @@ from agent_comms.installer.probes.packages import ArchivePin, GitPin, PackagePin, PackageRecord from agent_comms.installer.probes.packages import DistributionVersionsProbe, InstalledPackagesProbe from agent_comms.launch_runtime import LaunchRuntime +from agent_comms.repo import Repo from agent_comms.repository_name import RepositoryName if TYPE_CHECKING: + from agent_comms.installer.commands import InstallCommands from agent_comms.installer.upgrade import Stack +@dataclass(frozen=True) +class ProjectMetadata: + """An installable project's backend-produced wheel metadata, not its manifest dialect.""" + + name: str + version: Version + python: SpecifierSet + requirements: tuple[Requirement, ...] + extras: frozenset[str] + + @classmethod + def from_wheel(cls, wheel: Path) -> ProjectMetadata: + name, version, _, _ = parse_wheel_filename(wheel.name) + with ZipFile(wheel) as archive: + entries = tuple(entry for entry in archive.infolist() + if entry.filename.endswith(".dist-info/METADATA")) + if len(entries) != 1: + raise ValueError(f"{wheel} requires exactly one dist-info/METADATA") + entry, = entries + parent, separator, filename = entry.filename.partition("/") + if separator != "/" or filename != "METADATA": + raise ValueError(f"Malformed wheel metadata path: {entry.filename}") + identity, recorded_version = parent.removesuffix(".dist-info").rsplit("-", 1) + if canonicalize_name(identity) != name or Version(recorded_version) != version: + raise ValueError(f"Wheel metadata directory differs from {wheel.name}") + message = BytesParser(policy=policy.default).parsebytes(archive.read(entry)) + if message.defects: + raise ValueError(f"Malformed wheel metadata: {message.defects}") + def required(header: str) -> str: + values = message.get_all(header, []) + if len(values) != 1 or not str(values[0]).strip(): + raise ValueError(f"Wheel metadata requires exactly one {header}") + return str(values[0]).strip() + actual = canonicalize_name(required("Name"), validate=True) + actual_version = Version(required("Version")) + Version(required("Metadata-Version")) + if actual != name or actual_version != version: + raise ValueError(f"Wheel metadata identity differs from {wheel.name}") + python = message.get_all("Requires-Python", []) + if len(python) > 1: + raise ValueError("Duplicate Requires-Python metadata") + extra_values = tuple(canonicalize_name(str(value), validate=True) + for value in message.get_all("Provides-Extra", [])) + if len(set(extra_values)) != len(extra_values): + raise ValueError("Duplicate Provides-Extra metadata") + return cls(actual, actual_version, SpecifierSet(str(python[0]) if python else ""), + tuple(Requirement(str(value)) for value in message.get_all("Requires-Dist", [])), + frozenset(extra_values)) + + @classmethod + def acquire(cls, checkout: Path, *, uv: Path, python: Path, + commands: InstallCommands, output: Path) -> ProjectMetadata: + """Build once through the selected frontend; output never lives in a source cut.""" + output = output.resolve() + checkout = checkout.resolve() + if output.is_relative_to(checkout): + raise ValueError("Wheel metadata output must be outside the captured source") + before = Repo.git(checkout, "rev-parse", "HEAD").strip() + if Repo.git(checkout, "status", "--porcelain"): + raise ValueError(f"Preserve dirty metadata source: {checkout}") + output.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory(prefix="metadata-", dir=output) as temporary: + try: + commands.run(uv, "build", "--wheel", "--out-dir", temporary, + "--python", python, checkout) + wheels = tuple(Path(temporary).glob("*.whl")) + if len(wheels) != 1: + raise ValueError(f"Backend produced {len(wheels)} wheels for {checkout}, expected one") + wheel, = wheels + return cls.from_wheel(wheel) + finally: + if Repo.git(checkout, "rev-parse", "HEAD").strip() != before or Repo.git( + checkout, "status", "--porcelain"): + raise ValueError(f"Backend moved or dirtied captured source: {checkout}") + + @staticmethod + def require_python(specifier: SpecifierSet, environment: dict[str, str]) -> None: + version = environment["python_full_version"] + if not specifier.contains(version, prereleases=True): + raise ValueError(f"The common interpreter {version} does not satisfy {specifier}") + + class StackSources: """Branch-tracking source declarations shared by installation and source checking.""" @@ -34,12 +125,25 @@ def sources(cls, stack: Path) -> dict[str, tuple[str, str]]: return cls.declared_sources((stack / "pyproject.toml").read_text()) @staticmethod - def require_package(checkout: Path, name: str) -> None: - """A stack key names the actual source project, not a package-to-PR roster.""" - match tomllib.loads((checkout / "pyproject.toml").read_text()): - case {"project": {"name": str(actual)}} if canonicalize_name(actual) == canonicalize_name(name): - return - raise ValueError(f"{checkout} does not declare stack package {name}") + def require_package(metadata: ProjectMetadata, name: str) -> None: + """The actual backend, never a manifest spelling, owns the distribution identity.""" + if metadata.name != canonicalize_name(name): + raise ValueError(f"Backend builds {metadata.name}, not stack package {name}") + + @staticmethod + def frontend() -> Path: + executable = shutil.which("uv") + if executable is None: + raise ValueError("Bootstrap Core's declared uv before acquiring source metadata") + return Path(executable).absolute() + + @staticmethod + def marker_environment(python: Path) -> dict[str, str]: + # Marker evaluation must describe the interpreter actually running both checkers. + # A different environment cannot borrow this process's platform or Python facts. + if not LaunchRuntime.from_interpreter(python).runs_this_process: + raise ValueError("Run metadata preparation in the selected common interpreter") + return dict(default_environment()) @classmethod def require_compatible(cls, baseline: str, candidate: str) -> None: @@ -57,28 +161,62 @@ def require_compatible(cls, baseline: str, candidate: str) -> None: match source: case {"git": str(), "branch": str()} | {"path": str()}: local_names.add(canonicalize_name(FieldCodec.decode(str, name))) - contracts = tuple(frozenset(map(str, cls.runtime_requirements(document, frozenset(local_names)))) + environment = cls.marker_environment(Path(sys.executable)) + contracts = tuple(frozenset(str(requirement) for requirement in cls.virtual_requirements(document) + if canonicalize_name(requirement.name) not in local_names + and cls.requirement_active(requirement, environment, ("",))) for document in documents) if contracts[0] != contracts[1]: raise ValueError("External runtime dependency migration is unsupported by this common environment") @staticmethod - def runtime_requirements(document: dict[str, object], local_names: frozenset[str]) -> tuple[Requirement, ...]: - """Decode the supported external project contract once, for every package consumer.""" + def virtual_requirements(document: dict[str, object]) -> tuple[Requirement, ...]: + """Only package=false roots have a declarative (non-wheel) runtime contract.""" match document: - case {"project": {"dependencies": list() as dependencies, **project}}: - version = ".".join(map(str, sys.version_info[:3])) - python = FieldCodec.decode(str, project.get("requires-python", "")) - if not SpecifierSet(python).contains(version, prereleases=True): - raise ValueError(f"The common interpreter {version} does not satisfy {python}") - requirements = [] - for text in FieldCodec.decode(tuple[str, ...], dependencies): - requirement = Requirement(text) - requirement.name = canonicalize_name(requirement.name) - if requirement.name not in local_names: - requirements.append(requirement) - return tuple(requirements) - raise ValueError("A compared source requires explicit project runtime dependencies") + case {"project": {"dependencies": list() as dependencies, **project}, + "tool": {"uv": {"package": False}}}: + python = SpecifierSet(FieldCodec.decode(str, project.get("requires-python", ""))) + ProjectMetadata.require_python(python, dict(default_environment())) + return tuple(map(Requirement, FieldCodec.decode(tuple[str, ...], dependencies))) + raise ValueError("A virtual runtime root requires tool.uv.package=false and project dependencies") + + @staticmethod + def requirement_active(requirement: Requirement, environment: dict[str, str], extras: Iterable[str]) -> bool: + return requirement.marker is None or any( + requirement.marker.evaluate({**environment, "extra": extra}) for extra in extras) + + @classmethod + def active_requirements(cls, document: dict[str, object], projects: tuple[ProjectMetadata, ...], + environment: dict[str, str]) -> tuple[Requirement, ...]: + """Monotone local-extra closure, retaining original marked/extras-bearing edges.""" + local = {project.name: project for project in projects} + if len(local) != len(projects): + raise ValueError("Captured package metadata contains duplicate distribution identities") + for project in projects: + ProjectMetadata.require_python(project.python, environment) + selected: dict[str, set[str]] = {} + pending = [requirement for requirement in cls.virtual_requirements(document) + if cls.requirement_active(requirement, environment, ("",))] + active: set[Requirement] = set() + while pending: + requirement = pending.pop() + # Pending edges were admitted by their declaring package's active extras; + # virtual roots have only the base context. + active.add(requirement) + name = canonicalize_name(requirement.name) + if name not in local: + continue + project = local[name] + requested = {"", *(canonicalize_name(extra) for extra in requirement.extras)} + if not requested.difference({""}).issubset(project.extras): + raise ValueError(f"{name} does not provide requested extras {requirement.extras}") + previous = selected.setdefault(name, set()) + added = requested - previous + if not added: + continue + previous.update(added) + pending.extend(edge for edge in project.requirements if cls.requirement_active(edge, environment, added)) + return tuple(sorted(active, key=str)) @staticmethod def declared_sources(document: str) -> dict[str, tuple[str, str]]: diff --git a/tests/integration_source_fixture.py b/tests/integration_source_fixture.py index 82ac2758ea..a155b95a70 100644 --- a/tests/integration_source_fixture.py +++ b/tests/integration_source_fixture.py @@ -1,8 +1,9 @@ """One real Git/forge/Todo setup for matched source capture and promotion controls.""" -from collections.abc import Callable +from collections.abc import Callable, Iterator from dataclasses import dataclass, replace from pathlib import Path +import sys import time import pytest @@ -10,7 +11,8 @@ from agent_comms.commander.order import Commander, CommanderContext from agent_comms.comms import Comms from agent_comms.goal_actions import CreateProblemGoalAction -from agent_comms.head_review import PullRequestHead +from agent_comms.head_review import IntegrationInput, PullRequestHead +from agent_comms.installer.commands import InstallCommands from agent_comms.integration import IntegrationCheckout from agent_comms.recorded_time import RecordedEventTime from agent_comms.repo import Repo @@ -19,6 +21,7 @@ from fixture_git import ENVIRONMENT from github_fixture import GitHubFixture from source_revision_fixture import SourceRepository +from module_layers import MaterializedClients @dataclass(frozen=True) @@ -53,10 +56,10 @@ def source_pair( core = SourceRepository.create(tmp_path / "matched-core", held_bus.goals.todos.get("P2").repo) client = SourceRepository.create(tmp_path / "matched-client", RepositoryName.from_wire("example/client")) core_files = core.write_files({ - Path("pyproject.toml"): '[project]\nname = "agent-comms"\nversion = "0.0.1"\ndependencies = []\n', + Path("pyproject.toml"): core.package_project("agent-comms"), Path("stack/pyproject.toml"): ( '[project]\nname = "fixture-stack"\nversion = "0.0.1"\ndependencies = ["agent-comms", "source-client"]\n' - '[tool.uv]\noverride-dependencies = ["packaging==26.0"]\n' + '[tool.uv]\npackage = false\noverride-dependencies = ["packaging==26.0"]\n' 'constraint-dependencies = ["typing-extensions>=4.15"]\n' '[tool.uv.pip]\nstrict = true\n' '[tool.uv.sources]\nagent-comms = {path = "..", editable = true}\n' @@ -70,7 +73,7 @@ def source_pair( core.publish_change("Declare the baseline Core API and actual stack source", *core_files) core.git(core.checkout, "push", "origin", "main") client_files = client.write_files({ - Path("pyproject.toml"): '[project]\nname = "source-client"\nversion = "0.0.1"\ndependencies = []\n', + Path("pyproject.toml"): client.package_project("source-client"), Path("src/workflow.py"): 'from agent_comms.api import old_api\n\ndef run() -> str:\n return old_api()\n', }) client.publish_change("Declare the baseline client import", *client_files) @@ -97,6 +100,15 @@ def source_pair( return MutualSources(context, core, client, checkout, tmp_path / "qualified-inputs") +@pytest.fixture +def materialized_clients(test_uv: Path) -> Iterator[Callable[[Path, IntegrationInput], MaterializedClients]]: + """The root test run supplies the actual frontend; every test owns one command lifetime.""" + with InstallCommands() as commands: + def acquire(directory: Path, inputs: IntegrationInput) -> MaterializedClients: + return MaterializedClients(directory, inputs, test_uv, Path(sys.executable), commands) + yield acquire + + @pytest.fixture def mutual_sources(source_pair: MutualSources) -> MutualSources: """The same source owner, with its companion linked by a separate real checked order.""" diff --git a/tests/source_revision_fixture.py b/tests/source_revision_fixture.py index 365c9878d4..f5ac533ef9 100644 --- a/tests/source_revision_fixture.py +++ b/tests/source_revision_fixture.py @@ -6,6 +6,8 @@ from typing import ClassVar, Self from tempfile import mkdtemp +import tomli_w + from agent_comms.registry_document import RegisteredCommanderProject from agent_comms.repo import Repo from agent_comms.thread_identity import RecordedThreadIncarnation @@ -61,6 +63,17 @@ def git_input(repository: Path, text: str, *arguments: str) -> str: capture_output=True, check=True, timeout=30, ).stdout.strip() + @staticmethod + def package_project(name: str, *, dependencies: tuple[str, ...] = (), + python: str = ">=3.10", extras: dict[str, list[str]] | None = None) -> str: + """One legitimate tiny source project, built by its standard backend in controls.""" + return tomli_w.dumps({ + "build-system": {"requires": ["hatchling"], "build-backend": "hatchling.build"}, + "project": {"name": name, "version": "0.0.1", "requires-python": python, + "dependencies": list(dependencies), "optional-dependencies": extras or {}}, + "tool": {"hatch": {"build": {"targets": {"wheel": {"packages": ["src"]}}}}}, + }) + def write_files(self, files: Mapping[Path, str]) -> tuple[str, ...]: """Real source fixtures share file preparation, not a second source-selection mechanism.""" for relative, text in files.items(): diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py index d7a6783673..c8b4d8c575 100644 --- a/tests/test_checker_stack.py +++ b/tests/test_checker_stack.py @@ -21,7 +21,7 @@ @pytest.mark.parametrize("dependencies,allowed", [((), True), (("idna==3.10",), False)]) def test_private_project_preserves_settings_pins_and_refuses_external_migration( mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, - dependencies: tuple[str, ...], allowed: bool, monkeypatch: pytest.MonkeyPatch, + dependencies: tuple[str, ...], allowed: bool, monkeypatch: pytest.MonkeyPatch, test_uv: Path, ) -> None: pair = mutual_sources # Edit the real source before capture, never rewrite the captured artifact or selectors. @@ -37,7 +37,7 @@ def test_private_project_preserves_settings_pins_and_refuses_external_migration( with InstallCommands() as commands: checker = CheckerStack(checkout=tmp_path / "checker", commands=commands, home=tmp_path, runtime=LaunchRuntime.running(), source_directory=pair.output, - inputs=inputs, uv_program=Path("uv")) + inputs=inputs, uv_program=test_uv) original = checker.core / "stack" before = (original / "pyproject.toml").read_bytes(), (original / "uv.lock").read_bytes() if allowed: @@ -60,3 +60,42 @@ def test_private_project_preserves_settings_pins_and_refuses_external_migration( for side in (BaseSide, HeadSide): for source in inputs.sources: side.require_materialized(pair.output, source) + + +@pytest.mark.parametrize("requested", (False, True)) +def test_bootstrap_extra_is_runtime_only_when_the_virtual_root_requests_it( + mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, + requested: bool, test_uv: Path, +) -> None: + pair = mutual_sources + pair.core.git(pair.core.checkout, "switch", "main") + project = tomllib.loads((pair.core.checkout / "pyproject.toml").read_text()) + project["project"]["optional-dependencies"] = {"checking": ["idna>=3.9"]} + stack = tomllib.loads((pair.core.checkout / "stack/pyproject.toml").read_text()) + if requested: + stack["project"]["dependencies"] = ["agent-comms[checking]", "source-client"] + pair.core.publish_change("Declare actual baseline root and optional dependency", *pair.core.write_files({ + Path("pyproject.toml"): tomli_w.dumps(project), + Path("stack/pyproject.toml"): tomli_w.dumps(stack)})) + pair.core.git(pair.core.checkout, "push", "origin", "main") + pair.core.git(pair.core.checkout, "switch", "feature") + pair.core.git(pair.core.checkout, "rebase", "main") + project["project"]["optional-dependencies"] = {"checking": ["idna>=3.10"]} + head = pair.core.publish_change("Change only the optional checker dependency", *pair.core.write_files({ + Path("pyproject.toml"): tomli_w.dumps(project)})) + stamp = "2026-10-24T00:00:00Z" + github_cli.put(pair.core.github, DraftListedPullRequest( + pair.core.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) + inputs = IntegrationRun(pair.checkout, pair.output, "runtime root extras, not bootstrap extras").qualify( + pair.context, ("P2", "P3")) + with InstallCommands() as commands: + checker = CheckerStack(checkout=tmp_path / "checker", commands=commands, home=tmp_path, + runtime=LaunchRuntime.running(), source_directory=pair.output, inputs=inputs, uv_program=test_uv) + if requested: + with pytest.raises(ValueError, match="External runtime dependency migration"): + checker.require_runtime_compatibility() + else: + checker.require_runtime_compatibility() + before = checker.clients.projects(BaseSide)[pair.core.github] + after = checker.clients.projects(HeadSide)[pair.core.github] + assert before.requirements != after.requirements # Complete metadata was never filtered away. diff --git a/tests/test_integration.py b/tests/test_integration.py index 413a7e2bc2..cf2bc443a7 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,5 +1,6 @@ """Integration policy at real TOML/Git/gh boundaries; no patched Core methods.""" +from collections.abc import Callable from dataclasses import replace import json import os @@ -89,6 +90,7 @@ def test_actual_ci_cli_uses_verified_carrier_not_synthetic_message_and_revalidat def test_actual_ordinary_ci_cli_captures_main_once_and_never_steals_an_unlinked_overlay( mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, + materialized_clients: Callable[[Path, IntegrationInput], MaterializedClients], ) -> None: pair = mutual_sources head = pair.core.branch_commit @@ -113,7 +115,7 @@ def test_actual_ordinary_ci_cli_captures_main_once_and_never_steals_an_unlinked_ assert core.baseline.commit == baseline assert core.heads == (PullRequestHead(pair.core.github, pair.core.number, head),) assert client.heads == () and client.baseline.commit == pair.client.main_commit - resource = MaterializedClients(output, inputs) + resource = materialized_clients(output, inputs) base = BaseSide.require_materialized(output, core) candidate = HeadSide.require_materialized(output, core) assert Package(base, resource.at(checkout.path, baseline, tested, BaseSide)).missing_names == () @@ -177,6 +179,7 @@ def test_actual_main_cli_keeps_merged_local_members_exact_and_refuses_later_head def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( mutual_sources: MutualSources, github_cli: GitHubFixture, + materialized_clients: Callable[[Path, IntegrationInput], MaterializedClients], ) -> None: pair = mutual_sources plugin = ('from mypy.plugin import Plugin\nfrom agent_comms.api import old_api\n' @@ -199,7 +202,7 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( inputs = integration.IntegrationRun(pair.checkout, pair.output, "real client checker tuple and native plugins").qualify(pair.context, ("P2", "P3")) source = inputs.source(pair.core.github) head = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() - clients = MaterializedClients(pair.output, inputs) + clients = materialized_clients(pair.output, inputs) assert len(ClientTypeErrors.parts(pair.core.checkout, source.baseline.commit, head, {"src/agent_comms/api.py"}, "src/", clients)) == 2 result = compare(pair.core.checkout, source.baseline.commit, head, "src", 1, @@ -340,11 +343,11 @@ def test_changed_main_during_source_review_refuses_the_frozen_input( def test_common_runtime_contract_allows_local_source_and_metadata_changes() -> None: baseline = ('[project]\nname="old-label"\nversion="1"\n' - 'dependencies=["agent-comms", "old-client", "packaging>=25"]\n' + 'dependencies=["agent-comms", "old-client", "packaging>=25"]\n[tool.uv]\npackage=false\n' '[tool.uv.sources]\nagent-comms={path="..",editable=true}\n' 'old-client={git="https://github.com/example/old.git",branch="main"}\n') candidate = ('[project]\nname="new-label"\nversion="2"\n' - 'dependencies=["agent-comms", "new-client", "packaging>=25"]\n' + 'dependencies=["agent-comms", "new-client", "packaging>=25"]\n[tool.uv]\npackage=false\n' '[tool.uv.sources]\nagent-comms={path="..",editable=true}\n' 'new-client={git="https://github.com/example/new.git",branch="main"}\n') StackSources.require_compatible(baseline, candidate) @@ -352,8 +355,8 @@ def test_common_runtime_contract_allows_local_source_and_metadata_changes() -> N def test_common_runtime_contract_refuses_unsupported_external_dependency_migration() -> None: - baseline = '[project]\ndependencies=["packaging>=25"]\n' - candidate = '[project]\ndependencies=["packaging>=26"]\n' + baseline = '[project]\ndependencies=["packaging>=25"]\n[tool.uv]\npackage=false\n' + candidate = '[project]\ndependencies=["packaging>=26"]\n[tool.uv]\npackage=false\n' with pytest.raises(ValueError): StackSources.require_compatible(baseline, candidate) diff --git a/tests/test_module_layers_clients.py b/tests/test_module_layers_clients.py index 1736380014..21868b97be 100644 --- a/tests/test_module_layers_clients.py +++ b/tests/test_module_layers_clients.py @@ -1,6 +1,7 @@ """A stack client's imports root reachability and fail on modules Core lacks.""" import ast +from collections.abc import Callable import os import shutil import subprocess @@ -13,7 +14,7 @@ from module_layers import MaterializedClients, OutgoingWire, Package, StackClient from debt_ratchet import ClientTypeErrors -from agent_comms.head_review import BaseSide, HeadSide, Side +from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side from agent_comms.integration import IntegrationRun from agent_comms.todos import DraftListedPullRequest from github_fixture import GitHubFixture @@ -29,6 +30,7 @@ def write(path: Path, text: str = "") -> None: def test_materialized_clients_check_matched_sources_and_both_mixed_sources_fail( mutual_sources: MutualSources, github_cli: GitHubFixture, + materialized_clients: Callable[[Path, IntegrationInput], MaterializedClients], ) -> None: pair = mutual_sources inputs = IntegrationRun(pair.checkout, pair.output, "materialized module clients").qualify(pair.context, ("P2", "P3")) @@ -36,8 +38,9 @@ def test_materialized_clients_check_matched_sources_and_both_mixed_sources_fail( base = BaseSide.require_materialized(pair.output, core) head = HeadSide.require_materialized(pair.output, core) files = tuple(sorted(pair.output.rglob(".git"))) - base_clients = StackClient.materialized(base / "stack", pair.output, BaseSide, inputs) - head_clients = StackClient.materialized(head / "stack", pair.output, HeadSide, inputs) + resource = materialized_clients(pair.output, inputs) + base_clients = resource.cuts[BaseSide] + head_clients = resource.cuts[HeadSide] assert tuple(sorted(pair.output.rglob(".git"))) == files # No later fresh main clone. assert Package(base, base_clients).missing_names == () assert Package(head, head_clients).missing_names == () @@ -50,6 +53,7 @@ def test_materialized_clients_check_matched_sources_and_both_mixed_sources_fail( def test_actual_client_membership_stays_absent_on_its_own_cut( mutual_sources: MutualSources, github_cli: GitHubFixture, baseline_has_client: bool, candidate_has_client: bool, + materialized_clients: Callable[[Path, IntegrationInput], MaterializedClients], ) -> None: pair = mutual_sources full = (pair.core.checkout / "stack/pyproject.toml").read_text() @@ -70,7 +74,7 @@ def test_actual_client_membership_stays_absent_on_its_own_cut( github_cli.put(pair.core.github, DraftListedPullRequest( pair.core.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) inputs = IntegrationRun(pair.checkout, pair.output, "actual added and removed client").qualify(pair.context, ("P2", "P3")) - resource = MaterializedClients(pair.output, inputs) + resource = materialized_clients(pair.output, inputs) core = inputs.source(pair.core.github) tested = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() for side, present in ((BaseSide, baseline_has_client), (HeadSide, candidate_has_client)): @@ -83,6 +87,7 @@ def test_actual_client_membership_stays_absent_on_its_own_cut( def test_materialized_client_files_come_from_each_actual_cut( mutual_sources: MutualSources, github_cli: GitHubFixture, + materialized_clients: Callable[[Path, IntegrationInput], MaterializedClients], ) -> None: pair = mutual_sources pair.client.git(pair.client.checkout, "mv", "src/workflow.py", "src/replacement.py") @@ -92,9 +97,9 @@ def test_materialized_client_files_come_from_each_actual_cut( pair.client.number, "feature", commit, stamp, "main", reviewable_times=(stamp,))) inputs = IntegrationRun(pair.checkout, pair.output, "side-specific client files").qualify(pair.context, ("P2", "P3")) cuts: dict[type[Side], set[str]] = {} + resource = materialized_clients(pair.output, inputs) for side in (BaseSide, HeadSide): - core = side.require_materialized(pair.output, inputs.source(pair.core.github)) - client, = StackClient.materialized(core / "stack", pair.output, side, inputs) + client, = resource.cuts[side] cuts[side] = {entry.path for entry in client.imports} assert cuts[BaseSide] == {"src/workflow.py"} assert cuts[HeadSide] == {"src/replacement.py"} @@ -103,6 +108,7 @@ def test_materialized_client_files_come_from_each_actual_cut( @pytest.mark.parametrize("name", ("source-client", "source-renamed")) def test_materialized_selection_includes_peer_imports_but_not_absent_paths( mutual_sources: MutualSources, github_cli: GitHubFixture, name: str, + materialized_clients: Callable[[Path, IntegrationInput], MaterializedClients], ) -> None: pair = mutual_sources client_project = tomllib.loads((pair.client.checkout / "pyproject.toml").read_text()) @@ -123,7 +129,7 @@ def test_materialized_selection_includes_peer_imports_but_not_absent_paths( github_cli.put(pair.client.github, DraftListedPullRequest( pair.client.number, "feature", commit, stamp, "main", reviewable_times=(stamp,))) inputs = IntegrationRun(pair.checkout, pair.output, "selected file union").qualify(pair.context, ("P2", "P3")) - resource = MaterializedClients(pair.output, inputs) + resource = materialized_clients(pair.output, inputs) core = inputs.source(pair.core.github) head = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() for side in (BaseSide, HeadSide): diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py new file mode 100644 index 0000000000..aceaa38050 --- /dev/null +++ b/tests/test_project_metadata.py @@ -0,0 +1,184 @@ +"""Actual standard backend output owns package facts; no patched metadata producers.""" + +from collections.abc import Callable +from pathlib import Path +import sys +import tomllib +from zipfile import ZipFile + +from packaging.requirements import Requirement +import pytest +import tomli_w + +from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput +from agent_comms.installer.commands import InstallCommands +from agent_comms.installer.refusals import UpgradeRefused +from agent_comms.installer.runtime_resolution import ProjectMetadata, StackSources +from agent_comms.integration import IntegrationRun +from agent_comms.repository_name import RepositoryName +from github_fixture import GitHubFixture +from integration_source_fixture import MutualSources +from module_layers import MaterializedClients +from source_revision_fixture import SourceRepository + +pytest_plugins = ("github_fixture", "test_holder_standing_rules", "integration_source_fixture") + + +def package(root: Path, name: str, *, dependencies: tuple[str, ...] = (), + extras: dict[str, list[str]] | None = None, python: str = ">=3.10") -> SourceRepository: + source = SourceRepository.create(root, RepositoryName.from_wire(f"example/{name}")) + source.publish_change("Declare a real installable source", *source.write_files({ + Path("pyproject.toml"): source.package_project(name, dependencies=dependencies, extras=extras, python=python), + Path("src/module.py"): "value = 1\n", + })) + return source + + +@pytest.mark.parametrize("backend", ("hatch", "poetry")) +def test_standard_backend_metadata_preserves_identity_python_and_all_optional_edges( + tmp_path: Path, test_uv: Path, backend: str, +) -> None: + source = package(tmp_path / "source", "source-package", dependencies=("packaging>=24",), + extras={"syntax": ["idna>=3.9; python_version >= '3.10'"]}) + if backend == "poetry": + source.publish_change("Use the declared Poetry backend, without a project table", *source.write_files({ + Path("pyproject.toml"): tomli_w.dumps({ + "build-system": {"requires": ["poetry-core>=1.2.0"], "build-backend": "poetry.core.masonry.api"}, + "tool": {"poetry": {"name": "source-package", "version": "0.0.1", + "description": "Real backend control", "authors": ["Source "], + "packages": [{"include": "module.py", "from": "src"}], + "dependencies": {"python": ">=3.10", "packaging": ">=24", + "idna": {"version": ">=3.9", "optional": True, "python": ">=3.10"}}, + "extras": {"syntax": ["idna"]}}}, + })})) + with InstallCommands() as commands: + metadata = ProjectMetadata.acquire(source.checkout, uv=test_uv, python=Path(sys.executable), + commands=commands, output=tmp_path / "metadata") + assert metadata.name == "source-package" and metadata.extras == frozenset({"syntax"}) + assert len(metadata.requirements) == 2 + optional, = (edge for edge in metadata.requirements if edge.name == "idna") + assert optional.marker is not None and "extra" in str(optional.marker) + assert not optional.marker.evaluate({"extra": ""}) and optional.marker.evaluate({"extra": "syntax"}) + StackSources.require_package(metadata, "source_package") + with pytest.raises(ValueError, match="not stack package"): + StackSources.require_package(metadata, "different-package") + with pytest.raises(ValueError, match="common interpreter"): + ProjectMetadata.require_python(metadata.python, {"python_full_version": "3.8.0"}) + environment = StackSources.marker_environment(Path(sys.executable)) + with pytest.raises(ValueError, match="selected common interpreter"): + StackSources.marker_environment(tmp_path / "different-interpreter") + root = {"project": {"dependencies": ["source-package", "inactive; python_version < '1'"]}, + "tool": {"uv": {"package": False}}} + assert all(edge.name != "inactive" for edge in StackSources.active_requirements(root, (metadata,), environment)) + assert all(edge.name != "idna" for edge in StackSources.active_requirements(root, (metadata,), environment)) + root["project"]["dependencies"] = ["source-package[syntax]"] + assert optional in StackSources.active_requirements(root, (metadata,), environment) + assert len(metadata.requirements) == 2 # Inactive edges were excluded from selection, never erased. + + +def test_runtime_extra_closure_keeps_bootstrap_only_changes_outside_runtime( + tmp_path: Path, test_uv: Path, +) -> None: + front = package(tmp_path / "front", "front", extras={"image": ["peer[paint]"]}) + before = package(tmp_path / "before", "peer", extras={ + "paint": ["front[image]", "idna>=3.9"], "checking": ["packaging>=24"]}) + after = package(tmp_path / "after", "peer", extras={ + "paint": ["front[image]", "idna>=3.10"], "checking": ["packaging>=25"]}) + with InstallCommands() as commands: + front_metadata, before_metadata, after_metadata = ( + ProjectMetadata.acquire(source.checkout, uv=test_uv, python=Path(sys.executable), + commands=commands, output=tmp_path / "metadata") + for source in (front, before, after)) + environment = StackSources.marker_environment(Path(sys.executable)) + root = {"project": {"dependencies": ["front"]}, "tool": {"uv": {"package": False}}} + before_active = StackSources.active_requirements(root, (front_metadata, before_metadata), environment) + after_active = StackSources.active_requirements(root, (front_metadata, after_metadata), environment) + assert before_active == after_active == (Requirement("front"),) + root["project"]["dependencies"] = ["front[image]"] + before_active = StackSources.active_requirements(root, (front_metadata, before_metadata), environment) + after_active = StackSources.active_requirements(root, (front_metadata, after_metadata), environment) + assert Requirement('peer[paint]; extra == "image"') in before_active + assert any(edge.name == "idna" for edge in before_active) + assert not any(edge.name == "packaging" for edge in before_active) + assert before_active != after_active + root["project"]["dependencies"] = ["peer[checking]"] + assert StackSources.active_requirements(root, (before_metadata,), environment) != StackSources.active_requirements( + root, (after_metadata,), environment) + + +def test_materialized_metadata_is_built_once_and_revalidated_when_borrowed( + mutual_sources: MutualSources, github_cli: GitHubFixture, + materialized_clients: Callable[[Path, IntegrationInput], MaterializedClients], +) -> None: + pair = mutual_sources + inputs = IntegrationRun(pair.checkout, pair.output, "backend metadata lifetime").qualify(pair.context, ("P2", "P3")) + resource = materialized_clients(pair.output, inputs) + metadata = resource.metadata + built = len(resource.commands.clock.phases) + assert built == sum(len(projects) for projects in metadata.values()) + for side in (BaseSide, HeadSide): + assert resource.projects(side) is metadata[side] + resource.cuts[side] + assert resource.metadata is metadata and len(resource.commands.clock.phases) == built + checkout = HeadSide.checkout(pair.output, pair.client.github) + (checkout / "src/workflow.py").write_text("dirty = True\n") + with pytest.raises(ValueError, match="dirty materialized"): + resource.projects(HeadSide) + + +@pytest.mark.parametrize("mutation", ("dirty", "moved")) +def test_backend_cannot_mutate_the_captured_source(tmp_path: Path, test_uv: Path, mutation: str) -> None: + source = package(tmp_path / "source", "source-package") + document = tomllib.loads((source.checkout / "pyproject.toml").read_text()) + document["tool"]["hatch"]["build"]["hooks"] = {"custom": {"path": "hatch_build.py"}} + action = '(Path(self.root) / "src/module.py").write_text("changed = True\\n")' + if mutation == "moved": + action += ('\n subprocess.run(["git", "-C", self.root, "-c", "user.name=Source", ' + '"-c", "user.email=source@example.invalid", "commit", "-am", "Backend moved source"], check=True)') + hook = ('from pathlib import Path\nimport subprocess\n' + 'from hatchling.builders.hooks.plugin.interface import BuildHookInterface\n' + 'class Hook(BuildHookInterface):\n' + ' def initialize(self, version, build_data):\n ' + action + '\n') + source.publish_change("Declare a real mutating backend hook", *source.write_files({ + Path("pyproject.toml"): tomli_w.dumps(document), Path("hatch_build.py"): hook})) + with InstallCommands() as commands, pytest.raises(ValueError, match="moved or dirtied"): + ProjectMetadata.acquire(source.checkout, uv=test_uv, python=Path(sys.executable), + commands=commands, output=tmp_path / "metadata") + + +def test_backend_failure_is_not_empty_metadata(tmp_path: Path, test_uv: Path) -> None: + source = package(tmp_path / "source", "source-package") + document = tomllib.loads((source.checkout / "pyproject.toml").read_text()) + document["project"]["version"] = "not-a-version" + source.publish_change("Declare invalid backend metadata", *source.write_files({ + Path("pyproject.toml"): tomli_w.dumps(document)})) + with InstallCommands() as commands, pytest.raises(UpgradeRefused): + ProjectMetadata.acquire(source.checkout, uv=test_uv, python=Path(sys.executable), + commands=commands, output=tmp_path / "metadata") + + +@pytest.mark.parametrize("corruption", ("missing", "duplicate", "name", "header")) +def test_wheel_decoder_refuses_malformed_real_backend_output( + tmp_path: Path, test_uv: Path, corruption: str, +) -> None: + source = package(tmp_path / "source", "source-package") + output = tmp_path / "wheel" + with InstallCommands() as commands: + commands.run(test_uv, "build", "--wheel", "--out-dir", output, "--python", Path(sys.executable), source.checkout) + wheel, = output.glob("*.whl") + with ZipFile(wheel) as archive: + entries = [(entry, archive.read(entry)) for entry in archive.infolist()] + with ZipFile(wheel, "w") as archive: + for entry, data in entries: + if entry.filename.endswith(".dist-info/METADATA"): + if corruption == "missing": + continue + if corruption == "name": + data = data.replace(b"Name: source-package", b"Name: different-package") + if corruption == "header": + data = data.replace(b"Name: source-package", b"Name: source-package\nName: source-package") + if corruption == "duplicate": + archive.writestr(entry, data) + archive.writestr(entry, data) + with pytest.raises(ValueError): + ProjectMetadata.from_wheel(wheel) diff --git a/tools/checker_stack.py b/tools/checker_stack.py index 5956109b80..d7cfd60146 100644 --- a/tools/checker_stack.py +++ b/tools/checker_stack.py @@ -24,7 +24,7 @@ from agent_comms.installer.upgrade import Stack from agent_comms.launch_runtime import LaunchRuntime from agent_comms.repo import Repo -from module_layers import StackClient +from module_layers import MaterializedClients @dataclass(frozen=True, kw_only=True) @@ -58,21 +58,22 @@ def require_uv(self) -> None: if not declared.accepts(self.commands.run(self.uv, "--version")): raise ValueError("The bootstrap checker must use Core's declared uv") - def projects(self, side: type[Side]) -> tuple[Path, ...]: - core = side.require_materialized(self.source_directory, self.inputs.source(CorePackage.repository)) - clients = StackClient.materialized(core / "stack", self.source_directory, side, self.inputs) - return (core, *(client.checkout for client in clients)) + @cached_property + def clients(self) -> MaterializedClients: + return MaterializedClients(self.source_directory, self.inputs, self.uv, self.python, self.commands) def requirements(self, side: type[Side]) -> tuple[Requirement, ...]: - core = side.checkout(self.source_directory, CorePackage.repository) - return tuple(requirement for project in (*self.projects(side), core / "stack") - for requirement in self.runtime_requirements( - tomllib.loads((project / "pyproject.toml").read_text()), self.local_names)) + core = side.require_materialized(self.source_directory, self.inputs.source(CorePackage.repository)) + # Identity validation and runtime selection borrow the SAME backend metadata objects. + projects = self.clients.projects(side) + return self.active_requirements(tomllib.loads((core / "stack/pyproject.toml").read_text()), + tuple(projects.values()), self.clients.environment) def require_runtime_compatibility(self) -> None: # Local package presence differs legitimately; only the real external contract agrees. - before = frozenset(map(str, self.requirements(BaseSide))) - after = frozenset(map(str, self.requirements(HeadSide))) + before, after = (frozenset(str(requirement) for requirement in self.requirements(side) + if canonicalize_name(requirement.name) not in self.local_names) + for side in (BaseSide, HeadSide)) if before != after: raise ValueError("External runtime dependency migration requires an explicit compatible environment") @@ -91,7 +92,7 @@ def write_project(self) -> None: case {"git": str(url), "branch": str(), **options}: source = self.inputs.source(Repo.repository_url(url)) checkout = HeadSide.require_materialized(self.source_directory, source) - self.require_package(checkout, name) + self.require_package(self.clients.projects(HeadSide)[source.repository], name) sources[raw_name] = {**options, "path": str(checkout), "editable": True} case {"path": str(relative), **options}: checkout = (original / relative).resolve() @@ -138,10 +139,9 @@ def require_resolution(self, resolution: RuntimeResolution, original: ResolvedLo raise ValueError("Preparing runtime sources changed a locked checker-only package") for side in Side.sides(): for requirement in self.requirements(side): - if requirement.marker is None or requirement.marker.evaluate(): - record = resolution.packages[canonicalize_name(requirement.name)] - if not requirement.specifier.contains(record.version, prereleases=True): - raise ValueError(f"The common environment does not satisfy {requirement}") + record = resolution.packages[canonicalize_name(requirement.name)] + if not requirement.specifier.contains(record.version, prereleases=True): + raise ValueError(f"The common environment does not satisfy {requirement}") def prepare(self) -> RuntimeResolution: prefix = self.runtime.prefix.resolve() @@ -175,14 +175,12 @@ def main() -> int: parser.add_argument("--ci-inputs", type=Path, required=True) args = parser.parse_args() directory = args.ci_inputs.resolve() - executable = shutil.which("uv") - if executable is None: - raise ValueError("Bootstrap Core's declared uv before preparing the checker stack") + executable = StackSources.frontend() inputs = IntegrationInput.at(directory) with InstallCommands() as commands: CheckerStack(checkout=directory / "checker", commands=commands, home=directory, runtime=LaunchRuntime(Path(sys.executable)), source_directory=directory, - inputs=inputs, uv_program=Path(executable)).prepare() + inputs=inputs, uv_program=executable).prepare() return 0 diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index ece3800bdd..8b6a21a783 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -55,6 +55,7 @@ handles, ) from module_layers import PACKAGE, ClientSources, MaterializedClients, StackClient +from agent_comms.installer.commands import InstallCommands from stored_formats import StoredForm from typing_names import TypingImports, TypingReference from agent_comms.field_codec import FieldCodec, ViewExclusion, boundary_decoder, projected @@ -2852,8 +2853,10 @@ def main() -> int: parser.add_argument("--ci-inputs", type=Path, help="the integration entrypoint's exact materialized Core/client cuts") args = parser.parse_args() - clients = MaterializedClients.read(args.ci_inputs) if args.ci_inputs else ClientSources() - result = compare(Path.cwd(), args.base, args.head, args.root, args.workers, clients=clients) + with InstallCommands() as commands: + clients = MaterializedClients.read(args.ci_inputs, uv=MaterializedClients.frontend(), + python=Path(sys.executable), commands=commands) if args.ci_inputs else ClientSources() + result = compare(Path.cwd(), args.base, args.head, args.root, args.workers, clients=clients) # Flushed first: stdout is block-buffered into a pipe, and the growth lines must end the log. print(json.dumps(FieldCodec.project(result, "report"), indent=2), flush=True) # The failing step's last lines become check annotations (.github/run-step.sh merges diff --git a/tools/module_layers.py b/tools/module_layers.py index ff60a7b4f9..a10742aa07 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -40,7 +40,9 @@ from pathlib import Path from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side -from agent_comms.installer.runtime_resolution import StackSources +from agent_comms.installer.commands import InstallCommands +from agent_comms.installer.runtime_resolution import CorePackage, ProjectMetadata, StackSources +from agent_comms.repository_name import RepositoryName from agent_comms.repo import Repo from agent_comms.installer.runtime_wire import ClientImport, CrossesRuntimes @@ -325,7 +327,7 @@ def files_importing(self, module: str) -> list[str]: @classmethod @cache - def revision(cls, repo: Path, head: str) -> tuple[StackClient, ...]: + def revision(cls, repo: Path, head: str, uv: Path, python: Path) -> tuple[StackClient, ...]: """Ordinary local comparisons retain one head-declared main capture for both sides.""" if not Repo.git(repo, "ls-tree", "--name-only", head, "--", "stack/pyproject.toml"): return () @@ -333,10 +335,12 @@ def revision(cls, repo: Path, head: str) -> tuple[StackClient, ...]: atexit.register(shutil.rmtree, into, True) (into / "stack").mkdir() (into / "stack/pyproject.toml").write_text(Repo.git(repo, "show", f"{head}:stack/pyproject.toml")) - return cls.fetched(into / "stack", into) + with InstallCommands() as commands: + return cls.fetched(into / "stack", into, uv=uv, python=python, commands=commands) @classmethod - def fetched(cls, stack: Path, into: Path) -> tuple[StackClient, ...]: + def fetched(cls, stack: Path, into: Path, *, uv: Path, python: Path, + commands: InstallCommands) -> tuple[StackClient, ...]: """Shallow clones of every git source at its declared branch head.""" clients = [] for name, (url, branch) in StackClient.sources(stack).items(): @@ -345,12 +349,16 @@ def fetched(cls, stack: Path, into: Path) -> tuple[StackClient, ...]: ["git", "clone", "--quiet", "--depth", "1", "--branch", branch, url, str(checkout)], check=True, ) + metadata = ProjectMetadata.acquire(checkout, uv=uv, python=python, + commands=commands, output=into / "metadata") + cls.require_package(metadata, name) clients.append(cls(name, checkout)) return tuple(clients) @classmethod def materialized(cls, stack: Path, directory: Path, side: type[Side], - inputs: IntegrationInput) -> tuple[StackClient, ...]: + inputs: IntegrationInput, + metadata: dict[RepositoryName, ProjectMetadata]) -> tuple[StackClient, ...]: """Only this actual cut's declarations, from the already-frozen Git input. A package absent on this side is absent here; the comparison owner forms the @@ -360,7 +368,7 @@ def materialized(cls, stack: Path, directory: Path, side: type[Side], for name, (url, branch) in cls.sources(stack).items(): source = inputs.source(Repo.repository_url(url)) checkout = side.require_materialized(directory, source) - cls.require_package(checkout, name) + cls.require_package(metadata[source.repository], name) clients.append(cls(name, checkout)) return tuple(clients) @@ -376,38 +384,84 @@ def imports(self) -> tuple[ClientImport, ...]: @dataclass(frozen=True) -class ClientSources: +class ClientSources(StackSources): """The comparison's acquisition policy, passed to every measure before workers fork.""" def requires_clients(self, changed: set[str]) -> bool: return False def at(self, repo: Path, base: str, head: str, side: type[Side]) -> tuple[StackClient, ...]: - return StackClient.revision(repo, head) + return StackClient.revision(repo, head, self.frontend(), Path(sys.executable)) @dataclass(frozen=True) class MaterializedClients(ClientSources): directory: Path inputs: IntegrationInput + uv: Path + python: Path + commands: InstallCommands @classmethod - def read(cls, directory: Path) -> MaterializedClients: - return cls(directory, IntegrationInput.at(directory)) + def read(cls, directory: Path, *, uv: Path, python: Path, + commands: InstallCommands) -> MaterializedClients: + return cls(directory, IntegrationInput.at(directory), uv, python, commands) + + @cached_property + def environment(self) -> dict[str, str]: + return self.marker_environment(self.python) + + @cached_property + def metadata(self) -> dict[type[Side], dict[RepositoryName, ProjectMetadata]]: + """One acquisition per actual immutable project, shared by all consumers here.""" + environment = self.environment + result: dict[type[Side], dict[RepositoryName, ProjectMetadata]] = {} + core_source = self.inputs.source(CorePackage.repository) + for side in Side.sides(): + core = side.require_materialized(self.directory, core_source) + packages = {core_source.repository: CorePackage.distribution, + **{Repo.repository_url(url): name + for name, (url, branch) in self.sources(core / "stack").items()}} + projects: dict[RepositoryName, ProjectMetadata] = {} + for identity in sorted(packages, key=lambda identity: identity.key): + source = self.inputs.source(identity) + checkout = side.require_materialized(self.directory, source) + try: + metadata = ProjectMetadata.acquire(checkout, uv=self.uv, python=self.python, + commands=self.commands, output=self.directory / "metadata") + self.require_package(metadata, packages[identity]) + ProjectMetadata.require_python(metadata.python, environment) + projects[identity] = metadata + finally: + side.require_materialized(self.directory, source) + result[side] = projects + return result + + def projects(self, side: type[Side]) -> dict[RepositoryName, ProjectMetadata]: + projects = self.metadata[side] + for identity in projects: + side.require_materialized(self.directory, self.inputs.source(identity)) + return projects + + @cached_property + def cuts(self) -> dict[type[Side], tuple[StackClient, ...]]: + core_source = self.inputs.source(CorePackage.repository) + return {side: StackClient.materialized( + side.require_materialized(self.directory, core_source) / "stack", + self.directory, side, self.inputs, self.projects(side)) for side in Side.sides()} def requires_clients(self, changed: set[str]) -> bool: # A companion can change while the local Core patch is unchanged. return True - @cache def at(self, repo: Path, base: str, head: str, side: type[Side]) -> tuple[StackClient, ...]: source = self.inputs.source(Repo.of(repo).github) if Repo.git(repo, "rev-parse", base).strip() != source.baseline.commit or ( Repo.git(repo, "rev-parse", f"{head}^{{tree}}").strip() != source.candidate_tree): raise ValueError("Checker Core cuts do not match the materialized source input") - cuts = {cut: StackClient.materialized( - cut.require_materialized(self.directory, source) / "stack", - self.directory, cut, self.inputs) for cut in Side.sides()} + for cut in Side.sides(): + self.projects(cut) + cuts = self.cuts clients = tuple(client for found in cuts.values() for client in found) origins = {member.checkout(self.directory, source.repository): source.repository for member in Side.sides() for source in self.inputs.sources} @@ -775,17 +829,20 @@ def main() -> int: parser.add_argument("--ci-inputs", type=Path, help="use the integration entrypoint's already-materialized exact source input") arguments = parser.parse_args() - with tempfile.TemporaryDirectory() as clones: + with tempfile.TemporaryDirectory() as clones, InstallCommands() as commands: if arguments.ci_inputs: inputs = IntegrationInput.at(arguments.ci_inputs) local = inputs.source(Repo.of(ROOT).github) core = HeadSide.require_materialized(arguments.ci_inputs, local) - package = Package(core, StackClient.materialized(core / "stack", arguments.ci_inputs, HeadSide, inputs)) + resource = MaterializedClients(arguments.ci_inputs, inputs, StackSources.frontend(), + Path(sys.executable), commands) + package = Package(core, resource.cuts[HeadSide]) base = BaseSide.require_materialized(arguments.ci_inputs, local) if arguments.base and Repo.git(ROOT, "rev-parse", arguments.base).strip() != local.baseline.commit: raise ValueError("The outgoing wire must check this input's actual captured base") else: - package = Package(ROOT, StackClient.fetched(ROOT / "stack", Path(clones))) + package = Package(ROOT, StackClient.fetched(ROOT / "stack", Path(clones), + uv=StackSources.frontend(), python=Path(sys.executable), commands=commands)) base = Path(clones) / "base" if not arguments.check: sys.stdout.write(package.markdown()) From e3d4ab0e0675614283f96b121514587bb3076e66 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 16:42:45 -0400 Subject: [PATCH 043/109] Solve checker and runtime roots together with owned pin retention --- .../installer/runtime_resolution.py | 123 ++++++++++++++- tests/test_checker_stack.py | 147 ++++++++++++++++++ tools/checker_stack.py | 96 ++++++++---- 3 files changed, 330 insertions(+), 36 deletions(-) diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index 674de6b126..34c7744f95 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import shutil import sys import tomllib @@ -110,6 +111,14 @@ def acquire(cls, checkout: Path, *, uv: Path, python: Path, checkout, "status", "--porcelain"): raise ValueError(f"Backend moved or dirtied captured source: {checkout}") + def with_all_extras(self, requirement: str) -> str: + """Enrich only this package's existing root, preserving its complete requirement.""" + selected = Requirement(requirement) + if canonicalize_name(selected.name) != self.name: + return requirement + selected.extras.update(self.extras) + return str(selected) + @staticmethod def require_python(specifier: SpecifierSet, environment: dict[str, str]) -> None: version = environment["python_full_version"] @@ -273,6 +282,24 @@ class LockSource(BaseModel): def tracked(self, name: str) -> tuple[str, ...]: return () + def project_source(self) -> dict[str, str]: + raise ValueError("Local/virtual sources have no external retention projection") + + def retained_options(self, options: dict[str, object]) -> dict[str, object]: + return options + + def retain(self, name: str, marker: str, uv: dict[str, object]) -> None: + match uv: + case {"sources": dict() as sources}: + match sources.get(name, {}): + case dict() as options: + sources[name] = {**self.retained_options(options), **self.project_source(), + **({"marker": marker} if marker else {})} + case _: + raise ValueError(f"Ambiguous private source declarations for {name}") + case _: + raise ValueError("Private retention requires the existing UV sources table") + class RegistrySource(LockSource): registry: str @@ -280,6 +307,20 @@ class RegistrySource(LockSource): def pin(self, version: str, core: str) -> PackagePin: return PackagePin(version) + def project_source(self) -> dict[str, str]: + return {"index": "locked-" + hashlib.sha256(self.registry.encode()).hexdigest()} + + def retain(self, name: str, marker: str, uv: dict[str, object]) -> None: + index = {"name": self.project_source()["index"], "url": self.registry, "explicit": True} + match uv.get("index", []): + case list() as indexes: + if index not in indexes: + indexes.append(index) + uv["index"] = indexes + case _: + raise ValueError("Private UV indexes must retain their declared table array") + super().retain(name, marker, uv) + class GitSource(LockSource): """Our sibling packages track main, the ref InstallDue measures them against; a unit @@ -300,10 +341,45 @@ def require_branches(name: str, branches: tuple[str, ...]) -> None: raise ValueError(f"{name} tracks {branches}, not main") def pin(self, version: str, core: str) -> PackagePin: - commit = urlsplit(self.git).fragment - if not commit: + return GitPin(version, self.project_source()["rev"]) + + def project_source(self) -> dict[str, str]: + source = urlsplit(self.git) + if not source.fragment: raise ValueError("A resolved Git lock entry must name its commit") - return GitPin(version, commit) + return {"git": source._replace(query="", fragment="").geturl(), "rev": source.fragment} + + def retained_options(self, options: dict[str, object]) -> dict[str, object]: + # The locked revision replaces ONLY the old ref selector, never extra/index settings. + return {key: value for key, value in options.items() if key not in ("branch", "tag", "rev")} + + def retain(self, name: str, marker: str, uv: dict[str, object]) -> None: + super().retain(name, marker, uv) + source = self.project_source() + # A transitive direct URL must also stop naming its old moving branch. The + # override fixes SOURCE only; VersionedPackage's constraint retains its version. + locked = Requirement(f"{name} @ git+{source['git']}@{source['rev']}") + if marker: + locked.marker = Marker(marker) + overrides = FieldCodec.decode(list[str], uv.get("override-dependencies", [])) + result: list[str] = [] + for text in overrides: + requirement = Requirement(text) + if canonicalize_name(requirement.name) == name: + # Preserve existing specifier constraints rather than discard them when + # replacing a source URL. Extras and markers remain on this override. + if requirement.specifier: + constraints = FieldCodec.decode(list[str], uv.get("constraint-dependencies", [])) + constraints.append(str(requirement)) + uv["constraint-dependencies"] = constraints + requirement.specifier = SpecifierSet() + requirement.url = locked.url + result.append(str(requirement)) + else: + result.append(text) + if not any(canonicalize_name(Requirement(text).name) == name for text in overrides): + result.append(str(locked)) + uv["override-dependencies"] = result class EditableSource(LockSource): @@ -326,10 +402,14 @@ class LockedPackage(BaseModel): name: str markers: list[str] = Field(default_factory=list, alias="resolution-markers") + def active(self) -> bool: + return not self.markers or any(Marker(marker).evaluate() for marker in self.markers) + def selects(self, name: str, version: str) -> bool: - return canonicalize_name(self.name) == name and ( - not self.markers or any(Marker(marker).evaluate() for marker in self.markers) - ) + return canonicalize_name(self.name) == name and self.active() + + def external(self) -> tuple[VersionedPackage, ...]: + return () class VersionedPackage(LockedPackage): @@ -339,6 +419,24 @@ class VersionedPackage(LockedPackage): def selects(self, name: str, version: str) -> bool: return super().selects(name, version) and self.version == version + def external(self) -> tuple[VersionedPackage, ...]: + return (self,) if self.active() else () + + def require_retained(self, actual: VersionedPackage) -> None: + if (canonicalize_name(self.name) != canonicalize_name(actual.name) or self.version != actual.version + or self.source.project_source() != actual.source.project_source()): + raise ValueError(f"Private solve changed retained source/version for {self.name}") + + def constrain(self, uv: dict[str, object]) -> None: + marker = " or ".join(f"({marker})" for marker in self.markers) + requirement = Requirement(f"{canonicalize_name(self.name)}=={self.version}") + if marker: + requirement.marker = Marker(marker) + constraints = FieldCodec.decode(list[str], uv.get("constraint-dependencies", [])) + constraints.append(str(requirement)) + uv["constraint-dependencies"] = constraints + self.source.retain(canonicalize_name(self.name), marker, uv) + class LocalPackage(LockedPackage): source: EditableSource | VirtualSource @@ -356,6 +454,19 @@ def tracked(self) -> tuple[str, ...]: return tuple(dict.fromkeys( name for entry in self.package for name in entry.source.tracked(entry.name))) + def external(self, excluded: frozenset[str]) -> dict[str, VersionedPackage]: + """Current-interpreter external evidence; local/virtual entries select themselves out.""" + result: dict[str, VersionedPackage] = {} + for package in self.package: + for entry in package.external(): + name = canonicalize_name(entry.name) + if name in excluded: + continue + if name in result: + raise ValueError(f"Lock selects ambiguous external entries for {name}") + result[name] = entry + return result + def pins(self, selected: dict[str, str], core: str) -> dict[str, PackagePin]: result: dict[str, PackagePin] = {} for name, version in selected.items(): diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py index c8b4d8c575..f698dc1f4d 100644 --- a/tests/test_checker_stack.py +++ b/tests/test_checker_stack.py @@ -1,19 +1,27 @@ """Private full-project projection against real captured Git/forge inputs; no fake resolver.""" +from collections.abc import Iterator from pathlib import Path +import os +import sys import tomllib +from packaging.requirements import Requirement import pytest import tomli_w from agent_comms.head_review import BaseSide, HeadSide from agent_comms.installer.commands import InstallCommands +from agent_comms.installer.refusals import UpgradeRefused +from agent_comms.installer.runtime_resolution import CorePackage, ResolvedLock from agent_comms.integration import IntegrationRun from agent_comms.launch_runtime import LaunchRuntime +from agent_comms.repository_name import RepositoryName from agent_comms.todos import DraftListedPullRequest from checker_stack import CheckerStack from github_fixture import GitHubFixture from integration_source_fixture import MutualSources +from source_revision_fixture import SourceRepository pytest_plugins = ("test_holder_standing_rules", "integration_source_fixture") @@ -99,3 +107,142 @@ def test_bootstrap_extra_is_runtime_only_when_the_virtual_root_requests_it( before = checker.clients.projects(BaseSide)[pair.core.github] after = checker.clients.projects(HeadSide)[pair.core.github] assert before.requirements != after.requirements # Complete metadata was never filtered away. + + +@pytest.fixture +def common_checker( + mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, test_uv: Path, +) -> Iterator[CheckerStack]: + """Two genuine independently locked graphs, not hand-built lock/installed claims.""" + pair = mutual_sources + plugin = SourceRepository.create(tmp_path / "plugin", RepositoryName.from_wire("example/checker-plugin")) + plugin.publish_change("Declare checker-only Git package", *plugin.write_files({ + Path("pyproject.toml"): plugin.package_project("checker-plugin"), + Path("src/checker_plugin.py"): "value = 1\n"})) + plugin.git(plugin.checkout, "push", "origin", "main") + pair.client.git(pair.client.checkout, "switch", "main") + pair.client.publish_change("Declare runtime-only dependency", *pair.client.write_files({ + Path("pyproject.toml"): pair.client.package_project("source-client", dependencies=( + "charset-normalizer==3.4.0",))})) + pair.client.git(pair.client.checkout, "push", "origin", "main") + pair.client.git(pair.client.checkout, "switch", "feature") + pair.client.git(pair.client.checkout, "rebase", "main") + pair.core.git(pair.core.checkout, "switch", "main") + pair.core.write_files({Path("pyproject.toml"): pair.core.package_project("agent-comms", + dependencies=("idna>=3.10,<4; python_version >= '3.10'",), extras={ + "checking": ["idna<3.11", "six==1.16.0", + f"checker-plugin @ git+{plugin.remote.as_uri()}@main"]})}) + stack = tomllib.loads((pair.core.checkout / "stack/pyproject.toml").read_text()) + stack["tool"]["uv"]["sources"]["source-client"]["git"] = pair.client.remote.as_uri() + stack["project"]["dependencies"] = ["agent-comms>=0.0.1; python_version >= '3.10'", "source-client"] + stack["tool"]["uv"]["override-dependencies"].append("agent-comms>=0.0.1; python_version >= '3.10'") + pair.core.write_files({Path("stack/pyproject.toml"): tomli_w.dumps(stack)}) + with InstallCommands() as commands: + for project in (pair.core.checkout, pair.core.checkout / "stack"): + commands.run(test_uv, "lock", "--project", project, "--python", Path(sys.executable)) + pair.core.publish_change("Retain actual independent bootstrap and runtime locks", "pyproject.toml", + "stack/pyproject.toml", "uv.lock", "stack/uv.lock") + pair.core.git(pair.core.checkout, "push", "origin", "main") + pair.core.git(pair.core.checkout, "switch", "feature") + pair.core.git(pair.core.checkout, "rebase", "main") + stamp = "2026-10-24T00:00:00Z" + for source in (pair.core, pair.client): + source.git(source.checkout, "push", "origin", f"HEAD:refs/pull/{source.number}/head") + github_cli.put(source.github, DraftListedPullRequest( + source.number, "feature", source.branch_commit, stamp, "main", reviewable_times=(stamp,))) + inputs = IntegrationRun(pair.checkout, pair.output, "one complete runtime/checker solve").qualify( + pair.context, ("P2", "P3")) + checker = CheckerStack(checkout=tmp_path / "checker", commands=commands, home=tmp_path, + runtime=LaunchRuntime.running(), source_directory=pair.output, inputs=inputs, uv_program=test_uv) + checker.write_project() + yield checker + + +def test_complete_project_reconciles_shared_preferences_and_keeps_both_cuts_on_one_prefix( + common_checker: CheckerStack, +) -> None: + checker = common_checker + originals = {path: path.read_bytes() for side in (BaseSide, HeadSide) + for path in (side.checkout(checker.source_directory, CorePackage.repository) / "uv.lock", + side.checkout(checker.source_directory, CorePackage.repository) / "stack/uv.lock")} + bootstrap = checker.bootstrap_lock.external(checker.local_names) + runtime = checker.runtime_locks[HeadSide].external(checker.local_names) + assert bootstrap["idna"].version != runtime["idna"].version + metadata = checker.clients.projects(HeadSide)[CorePackage.repository] + private = tomllib.loads((checker.checkout / "stack/pyproject.toml").read_text()) + for roots in (private["project"]["dependencies"], private["tool"]["uv"]["override-dependencies"]): + core, = (Requirement(text) for text in roots if Requirement(text).name == metadata.name) + assert core.extras == metadata.extras and str(core.specifier) == ">=0.0.1" + assert core.marker is not None and "python_version" in str(core.marker) + assert not any(edge.name == "six" for edge in checker.requirements(HeadSide)) + assert any(edge.name == "six" for edge in checker.private_requirements()) + assert any(edge.marker is not None for edge in checker.private_requirements()) + checker.resolve() + locked = ResolvedLock.read(checker.lock).external(checker.local_names) + assert locked["idna"].version == bootstrap["idna"].version + for name, package in checker.retained_packages.items(): + package.require_retained(locked[name]) + prefix = checker.home / "common-prefix" + checker.commands.run(checker.uv, "venv", "--python", checker.python, prefix) + checker.commands.run(checker.uv, "sync", "--project", checker.checkout / "stack", "--locked", + "--no-editable", "--python", checker.python, + env={**os.environ, "UV_PROJECT_ENVIRONMENT": str(prefix)}) + checker.commands.run(checker.uv, "pip", "check", "--python", prefix / "bin/python") + for side, answer in ((BaseSide, "baseline"), (HeadSide, "candidate")): + roots = [str(side.checkout(checker.source_directory, source.repository) / "src") + for source in checker.inputs.sources] + # Both real API cuts execute with this same installed dependency prefix. + result = checker.commands.run(prefix / "bin/python", "-c", + "import runpy, sys, idna; sys.path[:0] = sys.argv[1:-1]; " + "print(sys.prefix); print(idna.__version__); print(runpy.run_path(sys.argv[-1])['run']())", + *roots, side.checkout(checker.source_directory, RepositoryName.from_wire("example/client")) + / "src/workflow.py") + assert result.splitlines() == [str(prefix), locked["idna"].version, answer] + assert {path: path.read_bytes() for path in originals} == originals + + +def test_incompatible_full_root_graph_refuses_during_solve(common_checker: CheckerStack) -> None: + checker = common_checker + project = checker.checkout / "stack/pyproject.toml" + document = tomllib.loads(project.read_text()) + document["project"]["dependencies"].append("idna>=3.11") + project.write_text(tomli_w.dumps(document)) + with pytest.raises(UpgradeRefused): + checker.resolve() + assert not (checker.home / "common-prefix").exists() # No sync or corrective install happened. + + +@pytest.mark.parametrize("package,change", ( + ("charset-normalizer", "3.4.1"), ("six", "1.17.0"), ("checker-plugin", "revision"), +)) +def test_retained_runtime_checker_version_and_git_identity_refuse_actual_solve_drift( + common_checker: CheckerStack, package: str, change: str, +) -> None: + checker = common_checker + expected = checker.retained_packages[package] + project = checker.checkout / "stack/pyproject.toml" + document = tomllib.loads(project.read_text()) + uv = document["tool"]["uv"] + uv["constraint-dependencies"] = [text for text in uv["constraint-dependencies"] + if Requirement(text).name != package] + if change == "revision": + # Move only the genuine external repository and solve its new exact source. The + # retained canonical lock must reject a same-version, different-commit package. + source = uv["sources"][package] + remote = Path(source["git"].removeprefix("file://")) + repository = SourceRepository.of(remote.parents[1] / "repository") + commit = repository.publish_change("Same version, different checker implementation", *repository.write_files({ + Path("src/checker_plugin.py"): "value = 2\n"})) + repository.git(repository.checkout, "push", "origin", "main") + source["rev"] = commit + uv["override-dependencies"] = [f"{package} @ git+{source['git']}@{commit}" + if Requirement(text).name == package else text for text in uv["override-dependencies"]] + else: + # Changing the explicit private override is not an accepted waiver: the retained + # source/version owner still detects the resulting genuine solver selection. + uv["override-dependencies"].append(f"{package}=={change}") + project.write_text(tomli_w.dumps(document)) + checker.resolve() + actual = ResolvedLock.read(checker.lock).external(checker.local_names)[package] + with pytest.raises(ValueError, match="changed retained source/version"): + expected.require_retained(actual) diff --git a/tools/checker_stack.py b/tools/checker_stack.py index d7cfd60146..d887661b38 100644 --- a/tools/checker_stack.py +++ b/tools/checker_stack.py @@ -20,7 +20,9 @@ from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side from agent_comms.installer.commands import InstallCommands from agent_comms.installer.declared_uv import DeclaredUv -from agent_comms.installer.runtime_resolution import CorePackage, ResolvedLock, RuntimeResolution, StackSources +from agent_comms.installer.runtime_resolution import ( + CorePackage, ResolvedLock, RuntimeResolution, StackSources, VersionedPackage, +) from agent_comms.installer.upgrade import Stack from agent_comms.launch_runtime import LaunchRuntime from agent_comms.repo import Repo @@ -77,15 +79,56 @@ def require_runtime_compatibility(self) -> None: if before != after: raise ValueError("External runtime dependency migration requires an explicit compatible environment") + @cached_property + def bootstrap_lock(self) -> ResolvedLock: + return ResolvedLock.read(self.core / "uv.lock") + + @cached_property + def runtime_locks(self) -> dict[type[Side], ResolvedLock]: + source = self.inputs.source(CorePackage.repository) + return {side: ResolvedLock.read( + side.require_materialized(self.source_directory, source) / "stack/uv.lock") for side in Side.sides()} + + @cached_property + def shared_names(self) -> frozenset[str]: + runtime_names = {name for lock in self.runtime_locks.values() for name in lock.external(self.local_names)} + return frozenset(runtime_names & self.bootstrap_lock.external(self.local_names).keys()) + + @cached_property + def retained_packages(self) -> dict[str, VersionedPackage]: + """Derived disjoint lock evidence, never another editable selection ledger.""" + excluded = self.local_names | self.shared_names + retained = self.bootstrap_lock.external(excluded) + for lock in self.runtime_locks.values(): + for name, package in lock.external(excluded).items(): + if name in retained: + retained[name].require_retained(package) + retained[name] = package + return retained + + def private_requirements(self) -> tuple[Requirement, ...]: + return self.active_requirements( + tomllib.loads((self.checkout / "stack/pyproject.toml").read_text()), + tuple(self.clients.projects(HeadSide).values()), self.clients.environment) + def write_project(self) -> None: - """Preserve the full candidate document and pins; replace only its source entries.""" + """Declare both complete root graphs and retained identities before the one solve.""" self.require_runtime_compatibility() + retained = self.retained_packages # Conflicting runtime-only locks refuse before prefix mutation. original = self.core / "stack" project = self.checkout / "stack" project.mkdir(parents=True, exist_ok=False) document: dict[str, object] = tomllib.loads((original / "pyproject.toml").read_text()) + metadata = self.clients.projects(HeadSide)[CorePackage.repository] match document: - case {"tool": {"uv": {"sources": dict() as sources}}}: + case {"project": {"dependencies": list() as dependencies} as root, + "tool": {"uv": {"sources": dict() as sources} as uv}}: + roots = FieldCodec.decode(list[str], dependencies) + if not any(canonicalize_name(Requirement(text).name) == metadata.name for text in roots): + raise ValueError("The private checker requires the declared Core runtime root") + root["dependencies"] = [metadata.with_all_extras(text) for text in roots] + uv["override-dependencies"] = [metadata.with_all_extras(text) for text in + FieldCodec.decode(list[str], uv.get("override-dependencies", []))] for raw_name, value in tuple(sources.items()): name = canonicalize_name(FieldCodec.decode(str, raw_name)) match value: @@ -102,6 +145,8 @@ def write_project(self) -> None: case _: # Pinned/index/archive sources remain exactly as declared. pass + for package in retained.values(): + package.constrain(uv) case _: raise ValueError("The candidate stack requires its actual source declarations") (project / "pyproject.toml").write_text(tomli_w.dumps(document)) @@ -111,37 +156,29 @@ def resolve(self) -> None: # No --upgrade: unrelated release and revision pins survive the local-path projection. self.commands.run(self.uv, "lock", "--project", self.checkout / "stack", "--python", self.python) - def require_resolution(self, resolution: RuntimeResolution, original: ResolvedLock) -> None: + def require_resolution(self, resolution: RuntimeResolution) -> None: locked = ResolvedLock.read(self.lock) - names = {entry.name for entry in locked.package} - self.local_names - selected = {name: record.version for name, record in resolution.packages.items() if name in names} + external = locked.external(self.local_names) + selected = {name: record.version for name, record in resolution.packages.items() + if name not in self.local_names} wanted = locked.pins(selected, resolution.core_commit) actual = {name: resolution.packages[name].pin() for name in selected} if actual != wanted: raise ValueError("The one actual checker environment differs from its private lock") - retained = {entry.name for entry in original.package} & selected.keys() - versions = {name: selected[name] for name in retained} - if original.pins(versions, resolution.core_commit) != locked.pins(versions, resolution.core_commit): - raise ValueError("Resolving local sources changed a retained external release/revision pin") - baseline = ResolvedLock.read(BaseSide.checkout(self.source_directory, CorePackage.repository) / "stack/uv.lock") - shared = {entry.name for entry in baseline.package} & selected.keys() - baseline_versions = {name: selected[name] for name in shared} - if baseline.pins(baseline_versions, resolution.core_commit) != locked.pins(baseline_versions, resolution.core_commit): - raise ValueError("Captured sides require different external release/revision pins") - # The existing bootstrap lock owns checker-only packages; the stack lock owns - # runtime packages. Their disjoint selection avoids inventing a merged pin roster. - bootstrap = ResolvedLock.read(self.core / "uv.lock") - checker_names = {entry.name for entry in bootstrap.package} - names - self.local_names - checker_versions = {name: record.version for name, record in resolution.packages.items() - if name in checker_names} - if bootstrap.pins(checker_versions, resolution.core_commit) != { - name: resolution.packages[name].pin() for name in checker_versions}: - raise ValueError("Preparing runtime sources changed a locked checker-only package") - for side in Side.sides(): - for requirement in self.requirements(side): + for name, package in self.retained_packages.items(): + # A universal lock can include an unselected optional dependency. Constraints + # preserve its identity without inventing a request to install that extra. + if name in external: + package.require_retained(external[name]) + for requirements in (*(self.requirements(side) for side in Side.sides()), self.private_requirements()): + for requirement in requirements: record = resolution.packages[canonicalize_name(requirement.name)] if not requirement.specifier.contains(record.version, prereleases=True): raise ValueError(f"The common environment does not satisfy {requirement}") + for name in sorted(self.shared_names & selected.keys()): + preferences = [package.version for lock in (*self.runtime_locks.values(), self.bootstrap_lock) + for key, package in lock.external(self.local_names).items() if key == name] + print(f"CHECKER SHARED {name}: original={preferences} solved={selected[name]}", flush=True) def prepare(self) -> RuntimeResolution: prefix = self.runtime.prefix.resolve() @@ -152,16 +189,15 @@ def prepare(self) -> RuntimeResolution: raise ValueError("The common checker requires its isolated runner bootstrap prefix and input directory") self.require_uv() self.write_project() - original = ResolvedLock.read(self.lock) self.resolve() - # Retain the bootstrap's Core dev/checker packages in this SAME isolated prefix. + # Both graphs belong to this solve; retaining stray files cannot substitute for roots. self.commands.run(self.uv, "sync", "--project", self.checkout / "stack", "--locked", - "--inexact", "--no-editable", "--python", self.python, + "--no-editable", "--python", self.python, env={**os.environ, "UV_PROJECT_ENVIRONMENT": str(self.runtime.prefix)}) self.commands.run(self.uv, "pip", "check", "--python", self.python) commit = Repo.git(self.core, "rev-parse", "HEAD").strip() resolution = RuntimeResolution.capture(self, commit) - self.require_resolution(resolution, original) + self.require_resolution(resolution) resolution.save(self.runtime.prefix) digest = hashlib.sha256(self.lock.read_bytes()).hexdigest() bootstrap_digest = hashlib.sha256((self.core / "uv.lock").read_bytes()).hexdigest() From 4fccef4e7455d6fd1962ac884fb8cc81f4051b12 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 16:47:52 -0400 Subject: [PATCH 044/109] Retain locked Git package subdirectories through private resolution --- .../installer/runtime_resolution.py | 42 +++++++++++++------ tests/test_checker_stack.py | 38 ++++++++++++----- 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index 34c7744f95..1bdefa64e4 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -14,7 +14,7 @@ from tempfile import TemporaryDirectory from zipfile import ZipFile from typing import TYPE_CHECKING, ClassVar -from urllib.parse import parse_qs, urlsplit +from urllib.parse import parse_qsl, quote, urlencode, urlsplit from packaging.markers import Marker, default_environment from packaging.requirements import Requirement @@ -288,17 +288,19 @@ def project_source(self) -> dict[str, str]: def retained_options(self, options: dict[str, object]) -> dict[str, object]: return options - def retain(self, name: str, marker: str, uv: dict[str, object]) -> None: + def retain(self, name: str, marker: str, uv: dict[str, object]) -> dict[str, str]: + source = self.project_source() match uv: case {"sources": dict() as sources}: match sources.get(name, {}): case dict() as options: - sources[name] = {**self.retained_options(options), **self.project_source(), + sources[name] = {**self.retained_options(options), **source, **({"marker": marker} if marker else {})} case _: raise ValueError(f"Ambiguous private source declarations for {name}") case _: raise ValueError("Private retention requires the existing UV sources table") + return source class RegistrySource(LockSource): @@ -310,7 +312,7 @@ def pin(self, version: str, core: str) -> PackagePin: def project_source(self) -> dict[str, str]: return {"index": "locked-" + hashlib.sha256(self.registry.encode()).hexdigest()} - def retain(self, name: str, marker: str, uv: dict[str, object]) -> None: + def retain(self, name: str, marker: str, uv: dict[str, object]) -> dict[str, str]: index = {"name": self.project_source()["index"], "url": self.registry, "explicit": True} match uv.get("index", []): case list() as indexes: @@ -319,7 +321,7 @@ def retain(self, name: str, marker: str, uv: dict[str, object]) -> None: uv["index"] = indexes case _: raise ValueError("Private UV indexes must retain their declared table array") - super().retain(name, marker, uv) + return super().retain(name, marker, uv) class GitSource(LockSource): @@ -331,7 +333,8 @@ class GitSource(LockSource): git: str def tracked(self, name: str) -> tuple[str, ...]: - branches = tuple(parse_qs(urlsplit(self.git).query).get("branch", [])) + source = self.decoded_source() + branches = (source["branch"],) if "branch" in source else () self.require_branches(name, branches) return (name,) if branches else () @@ -343,22 +346,36 @@ def require_branches(name: str, branches: tuple[str, ...]) -> None: def pin(self, version: str, core: str) -> PackagePin: return GitPin(version, self.project_source()["rev"]) - def project_source(self) -> dict[str, str]: + def decoded_source(self) -> dict[str, str]: + """Decode UV's locked URL once here, including the package's repository location.""" source = urlsplit(self.git) if not source.fragment: raise ValueError("A resolved Git lock entry must name its commit") - return {"git": source._replace(query="", fragment="").geturl(), "rev": source.fragment} + parameters: dict[str, str] = {} + for key, value in parse_qsl(source.query, keep_blank_values=True, strict_parsing=True, errors="strict"): + if (key not in ("branch", "tag", "rev", "subdirectory") or key in parameters + or not value or "\u0000" in value): + raise ValueError(f"Unsupported or duplicate locked Git query parameter: {key}") + parameters[key] = value + if len(parameters.keys() & {"branch", "tag", "rev"}) > 1: + raise ValueError("A locked Git source must have one unambiguous ref selector") + # The fragment owns the actual commit; a query rev is only the old requested ref. + return {"git": source._replace(query="", fragment="").geturl(), **parameters, "rev": source.fragment} + + def project_source(self) -> dict[str, str]: + return {key: value for key, value in self.decoded_source().items() if key not in ("branch", "tag")} def retained_options(self, options: dict[str, object]) -> dict[str, object]: # The locked revision replaces ONLY the old ref selector, never extra/index settings. return {key: value for key, value in options.items() if key not in ("branch", "tag", "rev")} - def retain(self, name: str, marker: str, uv: dict[str, object]) -> None: - super().retain(name, marker, uv) - source = self.project_source() + def retain(self, name: str, marker: str, uv: dict[str, object]) -> dict[str, str]: + source = super().retain(name, marker, uv) # A transitive direct URL must also stop naming its old moving branch. The # override fixes SOURCE only; VersionedPackage's constraint retains its version. - locked = Requirement(f"{name} @ git+{source['git']}@{source['rev']}") + fragment = urlencode({"subdirectory": source["subdirectory"]}, quote_via=quote) if "subdirectory" in source else "" + url = f"git+{source['git']}@{source['rev']}" + (f"#{fragment}" if fragment else "") + locked = Requirement(f"{name} @ {url}") if marker: locked.marker = Marker(marker) overrides = FieldCodec.decode(list[str], uv.get("override-dependencies", [])) @@ -380,6 +397,7 @@ def retain(self, name: str, marker: str, uv: dict[str, object]) -> None: if not any(canonicalize_name(Requirement(text).name) == name for text in overrides): result.append(str(locked)) uv["override-dependencies"] = result + return source class EditableSource(LockSource): diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py index f698dc1f4d..00d155d42c 100644 --- a/tests/test_checker_stack.py +++ b/tests/test_checker_stack.py @@ -13,7 +13,7 @@ from agent_comms.head_review import BaseSide, HeadSide from agent_comms.installer.commands import InstallCommands from agent_comms.installer.refusals import UpgradeRefused -from agent_comms.installer.runtime_resolution import CorePackage, ResolvedLock +from agent_comms.installer.runtime_resolution import CorePackage, GitSource, ResolvedLock from agent_comms.integration import IntegrationRun from agent_comms.launch_runtime import LaunchRuntime from agent_comms.repository_name import RepositoryName @@ -117,8 +117,10 @@ def common_checker( pair = mutual_sources plugin = SourceRepository.create(tmp_path / "plugin", RepositoryName.from_wire("example/checker-plugin")) plugin.publish_change("Declare checker-only Git package", *plugin.write_files({ - Path("pyproject.toml"): plugin.package_project("checker-plugin"), - Path("src/checker_plugin.py"): "value = 1\n"})) + Path("python package/pyproject.toml"): plugin.package_project("checker-plugin"), + Path("python package/src/checker_plugin.py"): "value = 1\n", + Path("other package/pyproject.toml"): plugin.package_project("checker-plugin"), + Path("other package/src/checker_plugin.py"): "value = 2\n"})) plugin.git(plugin.checkout, "push", "origin", "main") pair.client.git(pair.client.checkout, "switch", "main") pair.client.publish_change("Declare runtime-only dependency", *pair.client.write_files({ @@ -131,7 +133,7 @@ def common_checker( pair.core.write_files({Path("pyproject.toml"): pair.core.package_project("agent-comms", dependencies=("idna>=3.10,<4; python_version >= '3.10'",), extras={ "checking": ["idna<3.11", "six==1.16.0", - f"checker-plugin @ git+{plugin.remote.as_uri()}@main"]})}) + f"checker-plugin @ git+{plugin.remote.as_uri()}@main#subdirectory=python%20package"]})}) stack = tomllib.loads((pair.core.checkout / "stack/pyproject.toml").read_text()) stack["tool"]["uv"]["sources"]["source-client"]["git"] = pair.client.remote.as_uri() stack["project"]["dependencies"] = ["agent-comms>=0.0.1; python_version >= '3.10'", "source-client"] @@ -177,6 +179,15 @@ def test_complete_project_reconciles_shared_preferences_and_keeps_both_cuts_on_o assert not any(edge.name == "six" for edge in checker.requirements(HeadSide)) assert any(edge.name == "six" for edge in checker.private_requirements()) assert any(edge.marker is not None for edge in checker.private_requirements()) + source = private["tool"]["uv"]["sources"]["checker-plugin"] + assert source["subdirectory"] == "python package" + override, = (Requirement(text) for text in private["tool"]["uv"]["override-dependencies"] + if Requirement(text).name == "checker-plugin") + assert override.url == f"git+{source['git']}@{source['rev']}#subdirectory=python%20package" + for query in ("unknown=python", "subdirectory=python&subdirectory=other", "subdirectory=", + "branch=main&tag=release", "subdirectory=%00"): + with pytest.raises(ValueError, match="Git"): + GitSource(git=f"{source['git']}?{query}#{source['rev']}").project_source() checker.resolve() locked = ResolvedLock.read(checker.lock).external(checker.local_names) assert locked["idna"].version == bootstrap["idna"].version @@ -213,7 +224,8 @@ def test_incompatible_full_root_graph_refuses_during_solve(common_checker: Check @pytest.mark.parametrize("package,change", ( - ("charset-normalizer", "3.4.1"), ("six", "1.17.0"), ("checker-plugin", "revision"), + ("charset-normalizer", "3.4.1"), ("six", "1.17.0"), + ("checker-plugin", "revision"), ("checker-plugin", "location"), )) def test_retained_runtime_checker_version_and_git_identity_refuse_actual_solve_drift( common_checker: CheckerStack, package: str, change: str, @@ -225,17 +237,21 @@ def test_retained_runtime_checker_version_and_git_identity_refuse_actual_solve_d uv = document["tool"]["uv"] uv["constraint-dependencies"] = [text for text in uv["constraint-dependencies"] if Requirement(text).name != package] - if change == "revision": + if change in ("revision", "location"): # Move only the genuine external repository and solve its new exact source. The # retained canonical lock must reject a same-version, different-commit package. source = uv["sources"][package] remote = Path(source["git"].removeprefix("file://")) repository = SourceRepository.of(remote.parents[1] / "repository") - commit = repository.publish_change("Same version, different checker implementation", *repository.write_files({ - Path("src/checker_plugin.py"): "value = 2\n"})) - repository.git(repository.checkout, "push", "origin", "main") - source["rev"] = commit - uv["override-dependencies"] = [f"{package} @ git+{source['git']}@{commit}" + if change == "revision": + commit = repository.publish_change("Same version, different checker implementation", *repository.write_files({ + Path("python package/src/checker_plugin.py"): "value = 3\n"})) + repository.git(repository.checkout, "push", "origin", "main") + source["rev"] = commit + else: + source["subdirectory"] = "other package" # Same repo/SHA/version, another actual project. + location = source["subdirectory"].replace(" ", "%20") + uv["override-dependencies"] = [f"{package} @ git+{source['git']}@{source['rev']}#subdirectory={location}" if Requirement(text).name == package else text for text in uv["override-dependencies"]] else: # Changing the explicit private override is not an accepted waiver: the retained From 3707bf446931c8f4b6e196939ca5a5a63d36dd45 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 17:12:30 -0400 Subject: [PATCH 045/109] Seed source fixtures before publishing their main and PR history --- tests/integration_source_fixture.py | 21 ++++++-------- tests/source_revision_fixture.py | 44 +++++++++++++++-------------- tests/test_checker_stack.py | 27 +++++++++--------- tests/test_integration.py | 6 ++-- tests/test_module_layers_clients.py | 4 +-- tests/test_project_metadata.py | 9 +++--- tests/test_source_revision.py | 38 +++++++++++++++++++++++++ 7 files changed, 92 insertions(+), 57 deletions(-) diff --git a/tests/integration_source_fixture.py b/tests/integration_source_fixture.py index a155b95a70..436e58535b 100644 --- a/tests/integration_source_fixture.py +++ b/tests/integration_source_fixture.py @@ -53,10 +53,13 @@ def source_pair( ) -> MutualSources: for name, value in ENVIRONMENT.items(): monkeypatch.setenv(name, value) - core = SourceRepository.create(tmp_path / "matched-core", held_bus.goals.todos.get("P2").repo) - client = SourceRepository.create(tmp_path / "matched-client", RepositoryName.from_wire("example/client")) - core_files = core.write_files({ - Path("pyproject.toml"): core.package_project("agent-comms"), + client = SourceRepository.create( + tmp_path / "matched-client", RepositoryName.from_wire("example/client"), baseline_files={ + Path("pyproject.toml"): SourceRepository.package_project("source-client"), + Path("src/workflow.py"): 'from agent_comms.api import old_api\n\ndef run() -> str:\n return old_api()\n', + }) + core = SourceRepository.create(tmp_path / "matched-core", held_bus.goals.todos.get("P2").repo, baseline_files={ + Path("pyproject.toml"): SourceRepository.package_project("agent-comms"), Path("stack/pyproject.toml"): ( '[project]\nname = "fixture-stack"\nversion = "0.0.1"\ndependencies = ["agent-comms", "source-client"]\n' '[tool.uv]\npackage = false\noverride-dependencies = ["packaging==26.0"]\n' @@ -70,16 +73,8 @@ def source_pair( Path("src/agent_comms/__init__.py"): "", Path("src/agent_comms/api.py"): 'def old_api() -> str:\n return "baseline"\n', }) - core.publish_change("Declare the baseline Core API and actual stack source", *core_files) - core.git(core.checkout, "push", "origin", "main") - client_files = client.write_files({ - Path("pyproject.toml"): client.package_project("source-client"), - Path("src/workflow.py"): 'from agent_comms.api import old_api\n\ndef run() -> str:\n return old_api()\n', - }) - client.publish_change("Declare the baseline client import", *client_files) - client.git(client.checkout, "push", "origin", "main") for source in (core, client): - source.git(source.checkout, "checkout", "-B", "feature", "main") + source.git(source.checkout, "switch", "feature") core.publish_change("Replace the Core export without an alias", *core.write_files({ Path("src/agent_comms/api.py"): 'def new_api() -> str:\n return "candidate"\n', })) diff --git a/tests/source_revision_fixture.py b/tests/source_revision_fixture.py index f5ac533ef9..91075aa585 100644 --- a/tests/source_revision_fixture.py +++ b/tests/source_revision_fixture.py @@ -4,6 +4,7 @@ from pathlib import Path import subprocess from typing import ClassVar, Self +from types import MappingProxyType from tempfile import mkdtemp import tomli_w @@ -82,11 +83,15 @@ def write_files(self, files: Mapping[Path, str]) -> tuple[str, ...]: destination.write_text(text) return tuple(str(path) for path in files) - def publish_change(self, message: str, *files: str) -> str: - """Commit fixture files and publish this PR's new head through real Git.""" + def commit_change(self, message: str, *files: str) -> str: + """Commit the current checkout; its caller chooses what ref to publish.""" self.git(self.checkout, 'add', '--', *files) self.git(self.checkout, 'commit', '-m', message) - head = self.git(self.checkout, 'rev-parse', 'HEAD').strip() + return self.git(self.checkout, 'rev-parse', 'HEAD').strip() + + def publish_change(self, message: str, *files: str) -> str: + """Commit fixture files and publish this PR's descendant through real Git.""" + head = self.commit_change(message, *files) self.git(self.checkout, 'push', 'origin', f'HEAD:refs/pull/{self.number}/head') return head @@ -98,7 +103,10 @@ def remote_for(cls, root: Path, repository: RepositoryName) -> Path: return remote @classmethod - def create(cls, root: Path, name: RepositoryName = RepositoryName.from_wire('example/source')) -> Self: + def create( + cls, root: Path, name: RepositoryName = RepositoryName.from_wire('example/source'), *, + baseline_files: Mapping[Path, str] = MappingProxyType({}), + ) -> Self: root.mkdir(parents=True, exist_ok=True) root = Path(mkdtemp(prefix='source-', dir=root)) repository = root / 'repository' @@ -106,23 +114,17 @@ def create(cls, root: Path, name: RepositoryName = RepositoryName.from_wire('exa remote = cls.remote_for(root, name) cls.git(repository, 'init', '--initial-branch=main') cls.git(repository, 'remote', 'add', 'origin', str(remote)) - main_blob = cls.git_input(repository, cls.main_source, - 'hash-object', '-w', '--stdin') - main_tree = cls.git_input(repository, f'100644 blob {main_blob}\towner.py\n', 'mktree') - main = cls.git(repository, 'commit-tree', main_tree, '-m', 'Main declaration').strip() - cls.git(repository, 'update-ref', 'refs/heads/main', main) - branch_blob = cls.git_input(repository, - cls.main_source + 'class Member(Parent):\n pass\n', - 'hash-object', '-w', '--stdin') - new_blob = cls.git_input(repository, '@decorated\nclass BranchOnly:\n pass\n', - 'hash-object', '-w', '--stdin') - tree = cls.git_input(repository, - f'100644 blob {new_blob}\tbranch.py\n100644 blob {branch_blob}\towner.py\n', 'mktree') - branch = cls.git(repository, 'commit-tree', tree, '-p', main, '-m', 'PR declaration').strip() - cls.git(repository, 'update-ref', 'refs/heads/feature', branch) - cls.git(repository, 'push', 'origin', 'main', 'feature:refs/pull/7/head') - cls.git(repository, 'remote', 'set-head', 'origin', '--auto') - cls.git(repository, 'reset', '--hard', 'main') created = cls.of(repository) assert isinstance(created, cls) + created.commit_change('Main declaration', *created.write_files({ + Path('owner.py'): cls.main_source, **baseline_files, + })) + cls.git(repository, 'switch', '-c', 'feature') + created.commit_change('PR declaration', *created.write_files({ + Path('owner.py'): (repository / 'owner.py').read_text() + 'class Member(Parent):\n pass\n', + Path('branch.py'): '@decorated\nclass BranchOnly:\n pass\n', + })) + cls.git(repository, 'push', 'origin', 'main', f'feature:refs/pull/{created.number}/head') + cls.git(repository, 'remote', 'set-head', 'origin', '--auto') + cls.git(repository, 'switch', 'main') return created diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py index 00d155d42c..baa559def0 100644 --- a/tests/test_checker_stack.py +++ b/tests/test_checker_stack.py @@ -82,12 +82,12 @@ def test_bootstrap_extra_is_runtime_only_when_the_virtual_root_requests_it( stack = tomllib.loads((pair.core.checkout / "stack/pyproject.toml").read_text()) if requested: stack["project"]["dependencies"] = ["agent-comms[checking]", "source-client"] - pair.core.publish_change("Declare actual baseline root and optional dependency", *pair.core.write_files({ + pair.core.commit_change("Declare actual baseline root and optional dependency", *pair.core.write_files({ Path("pyproject.toml"): tomli_w.dumps(project), Path("stack/pyproject.toml"): tomli_w.dumps(stack)})) pair.core.git(pair.core.checkout, "push", "origin", "main") pair.core.git(pair.core.checkout, "switch", "feature") - pair.core.git(pair.core.checkout, "rebase", "main") + pair.core.git(pair.core.checkout, "merge", "--no-edit", "main") project["project"]["optional-dependencies"] = {"checking": ["idna>=3.10"]} head = pair.core.publish_change("Change only the optional checker dependency", *pair.core.write_files({ Path("pyproject.toml"): tomli_w.dumps(project)})) @@ -115,20 +115,19 @@ def common_checker( ) -> Iterator[CheckerStack]: """Two genuine independently locked graphs, not hand-built lock/installed claims.""" pair = mutual_sources - plugin = SourceRepository.create(tmp_path / "plugin", RepositoryName.from_wire("example/checker-plugin")) - plugin.publish_change("Declare checker-only Git package", *plugin.write_files({ - Path("python package/pyproject.toml"): plugin.package_project("checker-plugin"), - Path("python package/src/checker_plugin.py"): "value = 1\n", - Path("other package/pyproject.toml"): plugin.package_project("checker-plugin"), - Path("other package/src/checker_plugin.py"): "value = 2\n"})) - plugin.git(plugin.checkout, "push", "origin", "main") + plugin = SourceRepository.create( + tmp_path / "plugin", RepositoryName.from_wire("example/checker-plugin"), baseline_files={ + Path("python package/pyproject.toml"): SourceRepository.package_project("checker-plugin"), + Path("python package/src/checker_plugin.py"): "value = 1\n", + Path("other package/pyproject.toml"): SourceRepository.package_project("checker-plugin"), + Path("other package/src/checker_plugin.py"): "value = 2\n"}) pair.client.git(pair.client.checkout, "switch", "main") - pair.client.publish_change("Declare runtime-only dependency", *pair.client.write_files({ + pair.client.commit_change("Declare runtime-only dependency", *pair.client.write_files({ Path("pyproject.toml"): pair.client.package_project("source-client", dependencies=( "charset-normalizer==3.4.0",))})) pair.client.git(pair.client.checkout, "push", "origin", "main") pair.client.git(pair.client.checkout, "switch", "feature") - pair.client.git(pair.client.checkout, "rebase", "main") + pair.client.git(pair.client.checkout, "merge", "--no-edit", "main") pair.core.git(pair.core.checkout, "switch", "main") pair.core.write_files({Path("pyproject.toml"): pair.core.package_project("agent-comms", dependencies=("idna>=3.10,<4; python_version >= '3.10'",), extras={ @@ -142,11 +141,11 @@ def common_checker( with InstallCommands() as commands: for project in (pair.core.checkout, pair.core.checkout / "stack"): commands.run(test_uv, "lock", "--project", project, "--python", Path(sys.executable)) - pair.core.publish_change("Retain actual independent bootstrap and runtime locks", "pyproject.toml", + pair.core.commit_change("Retain actual independent bootstrap and runtime locks", "pyproject.toml", "stack/pyproject.toml", "uv.lock", "stack/uv.lock") pair.core.git(pair.core.checkout, "push", "origin", "main") pair.core.git(pair.core.checkout, "switch", "feature") - pair.core.git(pair.core.checkout, "rebase", "main") + pair.core.git(pair.core.checkout, "merge", "--no-edit", "main") stamp = "2026-10-24T00:00:00Z" for source in (pair.core, pair.client): source.git(source.checkout, "push", "origin", f"HEAD:refs/pull/{source.number}/head") @@ -244,7 +243,7 @@ def test_retained_runtime_checker_version_and_git_identity_refuse_actual_solve_d remote = Path(source["git"].removeprefix("file://")) repository = SourceRepository.of(remote.parents[1] / "repository") if change == "revision": - commit = repository.publish_change("Same version, different checker implementation", *repository.write_files({ + commit = repository.commit_change("Same version, different checker implementation", *repository.write_files({ Path("python package/src/checker_plugin.py"): "value = 3\n"})) repository.git(repository.checkout, "push", "origin", "main") source["rev"] = commit diff --git a/tests/test_integration.py b/tests/test_integration.py index cf2bc443a7..871375d1a7 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -189,11 +189,11 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( pair.client.git(pair.client.checkout, "switch", "main") project = tomllib.loads((pair.client.checkout / "pyproject.toml").read_text()) project["tool"] = {"mypy": {"plugins": ["mypy_source_plugin.py"]}} - pair.client.publish_change("Declare the baseline native mypy plugin", *pair.client.write_files({ + pair.client.commit_change("Declare the baseline native mypy plugin", *pair.client.write_files({ Path("pyproject.toml"): tomli_w.dumps(project), Path("mypy_source_plugin.py"): plugin})) pair.client.git(pair.client.checkout, "push", "origin", "main") pair.client.git(pair.client.checkout, "switch", "feature") - pair.client.git(pair.client.checkout, "rebase", "main") + pair.client.git(pair.client.checkout, "merge", "--no-edit", "main") head = pair.client.publish_change("Migrate the real plugin alongside the client", *pair.client.write_files({ Path("mypy_source_plugin.py"): plugin.replace("old_api", "new_api").replace("baseline", "candidate")})) stamp = "2026-10-24T00:00:00Z" @@ -335,7 +335,7 @@ def test_changed_main_during_source_review_refuses_the_frozen_input( inputs = run.qualify(pair.context, ("P2", "P3")) carrier = pair.publish_carrier(github_cli) pair.client.git(pair.client.checkout, "checkout", "main") - pair.client.publish_change("Advance client main", *pair.client.write_files({Path("main-only.py"): "advanced = True\n"})) + pair.client.commit_change("Advance client main", *pair.client.write_files({Path("main-only.py"): "advanced = True\n"})) pair.client.git(pair.client.checkout, "push", "origin", "main") with pytest.raises(ToolRefusedError, match="main moved"): inputs.validate(pair.context.repos, carrier) diff --git a/tests/test_module_layers_clients.py b/tests/test_module_layers_clients.py index 21868b97be..5e49450ffa 100644 --- a/tests/test_module_layers_clients.py +++ b/tests/test_module_layers_clients.py @@ -63,11 +63,11 @@ def test_actual_client_membership_stays_absent_on_its_own_cut( absent_text = tomli_w.dumps(absent) if not baseline_has_client: pair.core.git(pair.core.checkout, "switch", "main") - pair.core.publish_change("Baseline has no client declaration", *pair.core.write_files({ + pair.core.commit_change("Baseline has no client declaration", *pair.core.write_files({ Path("stack/pyproject.toml"): absent_text})) pair.core.git(pair.core.checkout, "push", "origin", "main") pair.core.git(pair.core.checkout, "switch", "feature") - pair.core.git(pair.core.checkout, "rebase", "main") + pair.core.git(pair.core.checkout, "merge", "--no-edit", "main") head = pair.core.publish_change("Declare only the candidate's actual client membership", *pair.core.write_files({ Path("stack/pyproject.toml"): full if candidate_has_client else absent_text})) stamp = "2026-10-24T00:00:00Z" diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index aceaa38050..f6c615c5b4 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -26,11 +26,12 @@ def package(root: Path, name: str, *, dependencies: tuple[str, ...] = (), extras: dict[str, list[str]] | None = None, python: str = ">=3.10") -> SourceRepository: - source = SourceRepository.create(root, RepositoryName.from_wire(f"example/{name}")) - source.publish_change("Declare a real installable source", *source.write_files({ - Path("pyproject.toml"): source.package_project(name, dependencies=dependencies, extras=extras, python=python), + source = SourceRepository.create(root, RepositoryName.from_wire(f"example/{name}"), baseline_files={ + Path("pyproject.toml"): SourceRepository.package_project( + name, dependencies=dependencies, extras=extras, python=python), Path("src/module.py"): "value = 1\n", - })) + }) + source.git(source.checkout, "switch", "feature") return source diff --git a/tests/test_source_revision.py b/tests/test_source_revision.py index a47f64e270..1f94bce4ea 100644 --- a/tests/test_source_revision.py +++ b/tests/test_source_revision.py @@ -1,5 +1,6 @@ """Citations use fetched Git objects, not whichever source is on disk.""" from dataclasses import replace +from pathlib import Path import shutil import pytest @@ -28,6 +29,43 @@ def test_source_creation_does_not_reuse_a_retired_directory(tmp_path): assert replacement.git(replacement.checkout, 'rev-parse', 'HEAD').strip() == replacement.main_commit +def test_seeded_source_publishes_descendants_and_merges_main_without_replacing_a_pr(tmp_path: Path) -> None: + files = { + Path("pyproject.toml"): SourceRepository.package_project("source-package"), + Path("src/api.py"): 'def old_api() -> str:\n return "baseline"\n', + } + source = SourceRepository.create(tmp_path, baseline_files=files) + main, feature = source.main_commit, source.branch_commit + assert source.git(source.checkout, "rev-parse", "HEAD").strip() == main + assert source.git(source.checkout, "show", f"{main}:owner.py") == source.main_source + assert source.git(source.checkout, "show", f"{feature}:owner.py") == ( + source.main_source + 'class Member(Parent):\n pass\n') + assert source.git(source.checkout, "show", f"{feature}:branch.py") == '@decorated\nclass BranchOnly:\n pass\n' + for revision in (main, feature): + for path, text in files.items(): + assert source.git(source.checkout, "show", f"{revision}:{path}") == text + assert source.descends(source.checkout, main, feature) + source.git(source.checkout, "switch", "feature") + candidate = source.publish_change("Replace the seeded API", *source.write_files({ + Path("src/api.py"): 'def new_api() -> str:\n return "candidate"\n'})) + assert source.descends(source.checkout, feature, candidate) + assert source.git(source.remote, "rev-parse", "refs/pull/7/head").strip() == candidate + source.git(source.checkout, "switch", "main") + advanced = source.commit_change("Advance main independently", *source.write_files({ + Path("main_only.py"): "advanced = True\n"})) + source.git(source.checkout, "push", "origin", "main") + assert source.git(source.remote, "rev-parse", "main").strip() == advanced + assert source.git(source.remote, "rev-parse", "refs/pull/7/head").strip() == candidate + source.git(source.checkout, "switch", "feature") + source.git(source.checkout, "merge", "--no-edit", "main") + final = source.publish_change("Publish the merged descendant", *source.write_files({ + Path("src/next.py"): "next_value = 2\n"})) + for ancestor in (candidate, advanced): + assert source.descends(source.checkout, ancestor, final) + assert source.git(source.remote, "rev-parse", "refs/pull/7/head").strip() == final + assert source.git(source.checkout, "show", f"{final}:main_only.py") == "advanced = True\n" + + def test_main_revision_refuses_branch_lines_and_ignores_dirty_disk(tmp_path): source = SourceRepository.create(tmp_path) (source.checkout / 'owner.py').write_text('disk-only\n' * 100) From 1753f480b2f24425a8b4a3b83a97912f17f30eda Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 17:37:35 -0400 Subject: [PATCH 046/109] Own checker UV, Git and CI input boundaries in their declarations --- src/agent_comms/head_review.py | 25 +- .../installer/runtime_resolution.py | 413 +++++++++++++----- src/agent_comms/integration.py | 99 +++-- tests/test_checker_stack.py | 27 +- tests/test_integration.py | 8 +- tests/test_project_metadata.py | 3 +- tests/test_runtime_resolution.py | 22 +- tools/checker_stack.py | 57 +-- tools/module_layers.py | 4 +- 9 files changed, 451 insertions(+), 207 deletions(-) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index d57aeeaabe..f1add45efa 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -90,10 +90,7 @@ def at(cls, repo: RepositoryName, pr: int) -> OpenPullRequestHead: '.head.sha + " " + .base.ref + " " + (.draft | tostring) + " " + .state') if read.returncode: raise ToolRefusedError(read.stderr.strip() or f"#{pr} could not be read.") - head, base, draft, state = read.stdout.split() - if state != "open": - raise ToolRefusedError(f"#{pr} is no longer an open source.") - return OpenPullRequestHead.drafted(draft)(repo, pr, head, base=base) + return OpenPullRequestHead.from_forge(repo, pr, read.stdout) def change_identity(self, reader: WorkBranches, base: str) -> PullRequestChange: """Compare the PR's patch, never the unrelated changes in its base tree. @@ -149,11 +146,12 @@ def repository(self) -> RepositoryName: @staticmethod def main_head(repository: Repo) -> str: - rows = Repo.git(repository.checkout, "ls-remote", "--exit-code", "origin", "refs/heads/main").splitlines() + reference_name = f"refs/heads/{WorkBranches.main}" + rows = Repo.git(repository.checkout, "ls-remote", "--exit-code", "origin", reference_name).splitlines() if len(rows) != 1: raise ToolRefusedError(f"No unique main in {repository.github.to_wire()}") commit, reference = rows[0].split() - if reference != "refs/heads/main": + if reference != reference_name: raise ToolRefusedError("The remote answered with a different main reference") GitCommitText.decode(commit) if len(commit) != 40: @@ -193,7 +191,7 @@ def require_candidate(self, repository: Repo, carrier: PullRequestHead) -> None: for head in self.heads: if not head.same_pull_request(carrier): current = head.current() - if current.base != "main": + if current.base != WorkBranches.main: raise ToolRefusedError(f"#{head.pr} no longer targets captured main") @@ -250,7 +248,7 @@ def validate(self, repositories: tuple[Repo, ...], carrier: PullRequestHead) -> """Git and forge prove this frozen input; Todo and verdict owners decide permission.""" core = Repo.selected(repositories, carrier.repo.to_wire()) current = carrier.current() - if current.base != "main": + if current.base != WorkBranches.main: raise ToolRefusedError("The integration carrier no longer targets main") self.require_carrier(core, carrier) for entry in self.sources: @@ -420,6 +418,17 @@ class OpenPullRequestHead(ReadPullRequestHead): # GitHub's REST ``draft`` field as ``tostring`` renders it. forge_draft: ClassVar[str] + forge_state: ClassVar[str] = "open" + + @classmethod + def from_forge(cls, repo: RepositoryName, pr: int, response: str) -> OpenPullRequestHead: + head, base, draft, state = response.split() + if state != cls.forge_state: + raise ToolRefusedError(f"#{pr} is no longer an open source.") + GitCommitText.decode(head) + if len(head) != 40: + raise ToolRefusedError("The forge head must be a full Git commit") + return cls.drafted(draft)(repo, pr, head, base=base) @classmethod def drafted(cls, draft: str) -> type[OpenPullRequestHead]: diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index 1bdefa64e4..d617e47adb 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -3,17 +3,20 @@ from __future__ import annotations import hashlib +import platform +import re import shutil import sys import tomllib from collections.abc import Iterable from dataclasses import dataclass +from functools import cached_property from email import policy from email.parser import BytesParser from pathlib import Path from tempfile import TemporaryDirectory from zipfile import ZipFile -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Annotated, ClassVar, Self from urllib.parse import parse_qsl, quote, urlencode, urlsplit from packaging.markers import Marker, default_environment @@ -21,15 +24,16 @@ from packaging.specifiers import SpecifierSet from packaging.utils import canonicalize_name, parse_wheel_filename from packaging.version import Version -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, RootModel from agent_comms.declared_family import DeclaredFamily -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, GitCommitText, boundary_decoder from agent_comms.installer.probes.packages import ArchivePin, GitPin, PackagePin, PackageRecord from agent_comms.installer.probes.packages import DistributionVersionsProbe, InstalledPackagesProbe from agent_comms.launch_runtime import LaunchRuntime from agent_comms.repo import Repo from agent_comms.repository_name import RepositoryName +from agent_comms.work_branches import WorkBranches if TYPE_CHECKING: from agent_comms.installer.commands import InstallCommands @@ -55,9 +59,10 @@ def from_wheel(cls, wheel: Path) -> ProjectMetadata: if len(entries) != 1: raise ValueError(f"{wheel} requires exactly one dist-info/METADATA") entry, = entries - parent, separator, filename = entry.filename.partition("/") - if separator != "/" or filename != "METADATA": + components = entry.filename.split("/") + if len(components) != 2: raise ValueError(f"Malformed wheel metadata path: {entry.filename}") + parent, _ = components identity, recorded_version = parent.removesuffix(".dist-info").rsplit("-", 1) if canonicalize_name(identity) != name or Version(recorded_version) != version: raise ValueError(f"Wheel metadata directory differs from {wheel.name}") @@ -120,12 +125,260 @@ def with_all_extras(self, requirement: str) -> str: return str(selected) @staticmethod - def require_python(specifier: SpecifierSet, environment: dict[str, str]) -> None: - version = environment["python_full_version"] + def require_python(specifier: SpecifierSet) -> None: + version = platform.python_version() if not specifier.contains(version, prereleases=True): raise ValueError(f"The common interpreter {version} does not satisfy {specifier}") +class GitSelection: + """No requested ref; only the resolved commit selects the package.""" + + def branches(self) -> tuple[str, ...]: + return () + + +class GitPackageLocation: + """The repository root has no subdirectory field or direct-URL fragment.""" + + def fields(self) -> dict[str, str]: + return {} + + def suffix(self) -> str: + return "" + + +@dataclass(frozen=True) +class GitQuery(GitSelection, DeclaredFamily, affix="GitQuery"): + value: str + + def __post_init__(self) -> None: + if not self.value or chr(0) in self.value: + raise ValueError("Git query values must be nonempty and representable") + self.value.encode("utf-8", errors="strict") + + def selectors(self) -> tuple[GitSelection, ...]: + return () + + def locations(self) -> tuple[GitPackageLocation, ...]: + return () + + +class GitRef(GitQuery, declared_name=None): + def selectors(self) -> tuple[GitSelection, ...]: + return (self,) + + +class BranchGitQuery(GitRef): + def branches(self) -> tuple[str, ...]: + return (self.value,) + + +class TagGitQuery(GitRef): + pass + + +class RevGitQuery(GitRef): + pass + + +class SubdirectoryGitQuery(GitQuery, GitPackageLocation): + def locations(self) -> tuple[GitPackageLocation, ...]: + return (self,) + + def fields(self) -> dict[str, str]: + return {self.declared_name: self.value} + + def suffix(self) -> str: + return "#" + urlencode(self.fields(), quote_via=quote) + + +@dataclass(frozen=True) +class LockedGitLocation: + repository: str + revision: Annotated[str, GitCommitText] + selector: GitSelection + package: GitPackageLocation + + @classmethod + @boundary_decoder + def decode(cls, value: str) -> LockedGitLocation: + source = urlsplit(value) + GitCommitText.decode(source.fragment) + if len(source.fragment) != 40 or source.fragment != source.fragment.lower(): + raise ValueError("A resolved Git lock entry requires its full lowercase commit") + if not source.scheme or not source.path: + raise ValueError("A resolved Git source requires its repository URL") + if re.search(r"%(?![0-9a-fA-F]{2})", source.query): + raise ValueError("Malformed Git query escaping") + parameters: list[GitQuery] = [] + seen: set[str] = set() + for key, text in parse_qsl(source.query, keep_blank_values=True, strict_parsing=True, errors="strict"): + if key in seen: + raise ValueError(f"Duplicate Git query parameter: {key}") + seen.add(key) + parameters.append(FieldCodec.decode_document(GitQuery.member_named(key), {"value": text})) + selectors = tuple(selector for parameter in parameters for selector in parameter.selectors()) + locations = tuple(location for parameter in parameters for location in parameter.locations()) + if len(selectors) > 1: + raise ValueError("A locked Git source requires one unambiguous ref selector") + selector, = selectors or (GitSelection(),) + package, = locations or (GitPackageLocation(),) + return cls(source._replace(query="", fragment="").geturl(), source.fragment, selector, package) + + @property + def direct_url(self) -> str: + return f"git+{self.repository}@{self.revision}{self.package.suffix()}" + + +class UvRecord(BaseModel): + """Decode consumed TOML fields strictly and preserve every unrelated UV/tool option.""" + + model_config = ConfigDict(extra="allow", strict=True, validate_by_alias=True, validate_by_name=False) + + def document(self) -> dict[str, object]: + # Python mode preserves TOML date/time and arrays, not just JSON-shaped options. + return self.model_dump(mode="python", by_alias=True, exclude_unset=True) + + +class UvSourceOptions(UvRecord): + """Only fields consumed here are modeled; UV retains every other source option.""" + + def materialize(self, original: Path, core: Path, + projects: dict[RepositoryName, tuple[Path, ProjectMetadata]], name: str) -> UvSourceOptions: + return self + + def retained_options(self) -> dict[str, object]: + return self.model_dump(mode="python", by_alias=True, exclude_unset=True) + + def with_options(self, options: dict[str, object], marker: str) -> Self: + fields = self.model_dump(mode="python", by_alias=True, exclude_unset=True) + if marker: + fields["marker"] = marker + return type(self)(**{**options, **fields}) + + +class UvGitSource(UvSourceOptions): + git: str + + def retained_options(self) -> dict[str, object]: + return self.model_dump(mode="python", by_alias=True, exclude=set(GitQuery.names()), exclude_unset=True) + + +class UvTrackedSource(UvGitSource): + branch: str + + def materialize(self, original: Path, core: Path, + projects: dict[RepositoryName, tuple[Path, ProjectMetadata]], name: str) -> UvSourceOptions: + GitSource.require_branches(name, (self.branch,)) + checkout, metadata = projects[Repo.repository_url(self.git)] + StackSources.require_package(metadata, name) + options = self.model_dump(mode="python", exclude=set(type(self).model_fields)) + return UvLocalSource.model_validate({**options, "path": str(checkout), "editable": True}) + + +class UvLocalSource(UvSourceOptions): + path: str + + def materialize(self, original: Path, core: Path, + projects: dict[RepositoryName, tuple[Path, ProjectMetadata]], name: str) -> UvSourceOptions: + checkout = (original / self.path).resolve() + if checkout != core or name != CorePackage.distribution: + raise ValueError(f"Uncaptured local source {name}: {checkout}") + return self.model_copy(update={"path": str(checkout)}) + + +class UvRevisionSource(UvGitSource): + rev: str + subdirectory: str = "" + + +class UvIndexSource(UvSourceOptions): + index: str + + +class UvConditionalSources(RootModel[list[UvSourceOptions]]): + """Conditional source arrays pass through intact; an exact pin cannot replace them.""" + + def materialize(self, original: Path, core: Path, + projects: dict[RepositoryName, tuple[Path, ProjectMetadata]], name: str) -> UvConditionalSources: + return self + + def retained_options(self) -> dict[str, object]: + raise ValueError("Ambiguous private conditional source declarations") + + +class UvIndex(UvRecord): + name: str = "" + url: str + explicit: bool = False + + +class UvProjectSettings(UvRecord): + package: bool = True + sources: dict[str, UvRevisionSource | UvTrackedSource | UvLocalSource | UvIndexSource + | UvGitSource | UvSourceOptions | UvConditionalSources] + overrides: list[str] = Field(default_factory=list, alias="override-dependencies") + constraints: list[str] = Field(default_factory=list, alias="constraint-dependencies") + indexes: list[UvIndex] = Field(default_factory=list, alias="index") + + def retain(self, name: str, marker: str, source: UvSourceOptions) -> None: + original = self.sources.get(name, UvSourceOptions()) + self.sources[name] = source.with_options(original.retained_options(), marker) + + def override_git(self, name: str, marker: str, location: LockedGitLocation) -> None: + locked = Requirement(f"{name} @ {location.direct_url}") + if marker: + locked.marker = Marker(marker) + result: list[str] = [] + found = False + for text in self.overrides: + requirement = Requirement(text) + if canonicalize_name(requirement.name) == name: + found = True + if requirement.specifier: + self.constraints = [*self.constraints, str(requirement)] + requirement.specifier = SpecifierSet() + requirement.url = locked.url + result.append(str(requirement)) + else: + result.append(text) + if not found: + result.append(str(locked)) + self.overrides = result + + +class UvProjectRoot(UvRecord): + dependencies: list[str] + python: str = Field(default="", alias="requires-python") + + +class UvProjectTools(UvRecord): + uv: UvProjectSettings + + +class PrivateUvProject(UvRecord): + """One transient, lossless projection of the actual captured virtual project.""" + project: UvProjectRoot + tool: UvProjectTools + + @classmethod + def read(cls, path: Path) -> PrivateUvProject: + return FieldCodec.decode(cls, tomllib.loads(path.read_text())) + + def enrich(self, metadata: ProjectMetadata) -> None: + if not any(canonicalize_name(Requirement(text).name) == metadata.name + for text in self.project.dependencies): + raise ValueError("The private checker requires the declared Core runtime root") + self.project.dependencies = [metadata.with_all_extras(text) for text in self.project.dependencies] + self.tool.uv.overrides = [metadata.with_all_extras(text) for text in self.tool.uv.overrides] + + def materialize(self, original: Path, core: Path, + projects: dict[RepositoryName, tuple[Path, ProjectMetadata]]) -> None: + for name, source in tuple(self.tool.uv.sources.items()): + self.tool.uv.sources[name] = source.materialize(original, core, projects, canonicalize_name(name)) + + class StackSources: """Branch-tracking source declarations shared by installation and source checking.""" @@ -140,6 +393,7 @@ def require_package(metadata: ProjectMetadata, name: str) -> None: raise ValueError(f"Backend builds {metadata.name}, not stack package {name}") @staticmethod + @boundary_decoder def frontend() -> Path: executable = shutil.which("uv") if executable is None: @@ -152,7 +406,7 @@ def marker_environment(python: Path) -> dict[str, str]: # A different environment cannot borrow this process's platform or Python facts. if not LaunchRuntime.from_interpreter(python).runs_this_process: raise ValueError("Run metadata preparation in the selected common interpreter") - return dict(default_environment()) + return FieldCodec.decode(dict[str, str], default_environment()) @classmethod def require_compatible(cls, baseline: str, candidate: str) -> None: @@ -171,25 +425,27 @@ def require_compatible(cls, baseline: str, candidate: str) -> None: case {"git": str(), "branch": str()} | {"path": str()}: local_names.add(canonicalize_name(FieldCodec.decode(str, name))) environment = cls.marker_environment(Path(sys.executable)) - contracts = tuple(frozenset(str(requirement) for requirement in cls.virtual_requirements(document) - if canonicalize_name(requirement.name) not in local_names - and cls.requirement_active(requirement, environment, ("",))) + contracts = tuple(frozenset(str(requirement) for requirement in cls.virtual_requirements(document, environment) + if canonicalize_name(requirement.name) not in local_names) for document in documents) if contracts[0] != contracts[1]: raise ValueError("External runtime dependency migration is unsupported by this common environment") - @staticmethod - def virtual_requirements(document: dict[str, object]) -> tuple[Requirement, ...]: + @classmethod + def virtual_requirements(cls, document: dict[str, object], environment: dict[str, str]) -> tuple[Requirement, ...]: """Only package=false roots have a declarative (non-wheel) runtime contract.""" match document: case {"project": {"dependencies": list() as dependencies, **project}, "tool": {"uv": {"package": False}}}: python = SpecifierSet(FieldCodec.decode(str, project.get("requires-python", ""))) - ProjectMetadata.require_python(python, dict(default_environment())) - return tuple(map(Requirement, FieldCodec.decode(tuple[str, ...], dependencies))) + ProjectMetadata.require_python(python) + return tuple(requirement for requirement in map( + Requirement, FieldCodec.decode(tuple[str, ...], dependencies)) + if cls.requirement_active(requirement, environment, ("",))) raise ValueError("A virtual runtime root requires tool.uv.package=false and project dependencies") @staticmethod + @boundary_decoder def requirement_active(requirement: Requirement, environment: dict[str, str], extras: Iterable[str]) -> bool: return requirement.marker is None or any( requirement.marker.evaluate({**environment, "extra": extra}) for extra in extras) @@ -202,14 +458,13 @@ def active_requirements(cls, document: dict[str, object], projects: tuple[Projec if len(local) != len(projects): raise ValueError("Captured package metadata contains duplicate distribution identities") for project in projects: - ProjectMetadata.require_python(project.python, environment) + ProjectMetadata.require_python(project.python) selected: dict[str, set[str]] = {} - pending = [requirement for requirement in cls.virtual_requirements(document) - if cls.requirement_active(requirement, environment, ("",))] + pending = list(cls.virtual_requirements(document, environment)) active: set[Requirement] = set() while pending: requirement = pending.pop() - # Pending edges were admitted by their declaring package's active extras; + # Pending edges were selected by their declaring package's active extras; # virtual roots have only the base context. active.add(requirement) name = canonicalize_name(requirement.name) @@ -282,25 +537,11 @@ class LockSource(BaseModel): def tracked(self, name: str) -> tuple[str, ...]: return () - def project_source(self) -> dict[str, str]: + def project_source(self) -> UvSourceOptions: raise ValueError("Local/virtual sources have no external retention projection") - def retained_options(self, options: dict[str, object]) -> dict[str, object]: - return options - - def retain(self, name: str, marker: str, uv: dict[str, object]) -> dict[str, str]: - source = self.project_source() - match uv: - case {"sources": dict() as sources}: - match sources.get(name, {}): - case dict() as options: - sources[name] = {**self.retained_options(options), **source, - **({"marker": marker} if marker else {})} - case _: - raise ValueError(f"Ambiguous private source declarations for {name}") - case _: - raise ValueError("Private retention requires the existing UV sources table") - return source + def retain(self, name: str, marker: str, uv: UvProjectSettings) -> None: + uv.retain(name, marker, self.project_source()) class RegistrySource(LockSource): @@ -309,19 +550,14 @@ class RegistrySource(LockSource): def pin(self, version: str, core: str) -> PackagePin: return PackagePin(version) - def project_source(self) -> dict[str, str]: - return {"index": "locked-" + hashlib.sha256(self.registry.encode()).hexdigest()} + def project_source(self) -> UvIndexSource: + return UvIndexSource(index="locked-" + hashlib.sha256(self.registry.encode()).hexdigest()) - def retain(self, name: str, marker: str, uv: dict[str, object]) -> dict[str, str]: - index = {"name": self.project_source()["index"], "url": self.registry, "explicit": True} - match uv.get("index", []): - case list() as indexes: - if index not in indexes: - indexes.append(index) - uv["index"] = indexes - case _: - raise ValueError("Private UV indexes must retain their declared table array") - return super().retain(name, marker, uv) + def retain(self, name: str, marker: str, uv: UvProjectSettings) -> None: + index = UvIndex(name=self.project_source().index, url=self.registry, explicit=True) + if index not in uv.indexes: + uv.indexes = [*uv.indexes, index] + super().retain(name, marker, uv) class GitSource(LockSource): @@ -330,74 +566,33 @@ class GitSource(LockSource): unchanged (f39412d88 held Toad on work/unit-toad--P694 after #1239 merged, 2026-10-25). A ``?rev=`` source is pinned and never relocked.""" + model_config = ConfigDict(frozen=True) git: str + @cached_property + def location(self) -> LockedGitLocation: + return LockedGitLocation.decode(self.git) + def tracked(self, name: str) -> tuple[str, ...]: - source = self.decoded_source() - branches = (source["branch"],) if "branch" in source else () + branches = self.location.selector.branches() self.require_branches(name, branches) return (name,) if branches else () @staticmethod def require_branches(name: str, branches: tuple[str, ...]) -> None: - if branches not in ((), ("main",)): - raise ValueError(f"{name} tracks {branches}, not main") + if branches not in ((), (WorkBranches.main,)): + raise ValueError(f"{name} tracks {branches}, not {WorkBranches.main}") def pin(self, version: str, core: str) -> PackagePin: - return GitPin(version, self.project_source()["rev"]) - - def decoded_source(self) -> dict[str, str]: - """Decode UV's locked URL once here, including the package's repository location.""" - source = urlsplit(self.git) - if not source.fragment: - raise ValueError("A resolved Git lock entry must name its commit") - parameters: dict[str, str] = {} - for key, value in parse_qsl(source.query, keep_blank_values=True, strict_parsing=True, errors="strict"): - if (key not in ("branch", "tag", "rev", "subdirectory") or key in parameters - or not value or "\u0000" in value): - raise ValueError(f"Unsupported or duplicate locked Git query parameter: {key}") - parameters[key] = value - if len(parameters.keys() & {"branch", "tag", "rev"}) > 1: - raise ValueError("A locked Git source must have one unambiguous ref selector") - # The fragment owns the actual commit; a query rev is only the old requested ref. - return {"git": source._replace(query="", fragment="").geturl(), **parameters, "rev": source.fragment} - - def project_source(self) -> dict[str, str]: - return {key: value for key, value in self.decoded_source().items() if key not in ("branch", "tag")} - - def retained_options(self, options: dict[str, object]) -> dict[str, object]: - # The locked revision replaces ONLY the old ref selector, never extra/index settings. - return {key: value for key, value in options.items() if key not in ("branch", "tag", "rev")} - - def retain(self, name: str, marker: str, uv: dict[str, object]) -> dict[str, str]: - source = super().retain(name, marker, uv) - # A transitive direct URL must also stop naming its old moving branch. The - # override fixes SOURCE only; VersionedPackage's constraint retains its version. - fragment = urlencode({"subdirectory": source["subdirectory"]}, quote_via=quote) if "subdirectory" in source else "" - url = f"git+{source['git']}@{source['rev']}" + (f"#{fragment}" if fragment else "") - locked = Requirement(f"{name} @ {url}") - if marker: - locked.marker = Marker(marker) - overrides = FieldCodec.decode(list[str], uv.get("override-dependencies", [])) - result: list[str] = [] - for text in overrides: - requirement = Requirement(text) - if canonicalize_name(requirement.name) == name: - # Preserve existing specifier constraints rather than discard them when - # replacing a source URL. Extras and markers remain on this override. - if requirement.specifier: - constraints = FieldCodec.decode(list[str], uv.get("constraint-dependencies", [])) - constraints.append(str(requirement)) - uv["constraint-dependencies"] = constraints - requirement.specifier = SpecifierSet() - requirement.url = locked.url - result.append(str(requirement)) - else: - result.append(text) - if not any(canonicalize_name(Requirement(text).name) == name for text in overrides): - result.append(str(locked)) - uv["override-dependencies"] = result - return source + return GitPin(version, self.location.revision) + + def project_source(self) -> UvRevisionSource: + return UvRevisionSource(git=self.location.repository, rev=self.location.revision, + **self.location.package.fields()) + + def retain(self, name: str, marker: str, uv: UvProjectSettings) -> None: + super().retain(name, marker, uv) + uv.override_git(name, marker, self.location) class EditableSource(LockSource): @@ -445,14 +640,12 @@ def require_retained(self, actual: VersionedPackage) -> None: or self.source.project_source() != actual.source.project_source()): raise ValueError(f"Private solve changed retained source/version for {self.name}") - def constrain(self, uv: dict[str, object]) -> None: + def constrain(self, uv: UvProjectSettings) -> None: marker = " or ".join(f"({marker})" for marker in self.markers) requirement = Requirement(f"{canonicalize_name(self.name)}=={self.version}") if marker: requirement.marker = Marker(marker) - constraints = FieldCodec.decode(list[str], uv.get("constraint-dependencies", [])) - constraints.append(str(requirement)) - uv["constraint-dependencies"] = constraints + uv.constraints = [*uv.constraints, str(requirement)] self.source.retain(canonicalize_name(self.name), marker, uv) diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index d1a4291761..61c44ddb11 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -11,7 +11,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from pathlib import Path -from typing import Annotated, ClassVar +from typing import Annotated, ClassVar, Self from .active_route import resolve_comms_route from .build_identity import RepositoryCommit @@ -19,7 +19,7 @@ from .child_process import STOP_GRACE_SECONDS, ResidentManager, SpawnedProcess from .declared_family import DeclaredFamily from .errors import ToolRefusedError -from .field_codec import FieldCodec, GitCommitText, WireName +from .field_codec import FieldCodec, GitCommitText, PathText, WireName, boundary_decoder from .head_review import BaseSide, HeadReview, HeadSide, IntegrationInput, IntegrationSource, PullRequestHead, Side from .installer.runtime_resolution import StackSources from .launch_runtime import CheckoutSource, RestoredRuntime @@ -140,7 +140,7 @@ def require_editable(self) -> None: def fresh_main(self) -> str: self.require_editable() Repo.git(self.path, "fetch", "origin", timeout=300) - return Repo.git(self.path, "rev-parse", "origin/main").strip() + return Repo.git(self.path, "rev-parse", f"origin/{WorkBranches.main}").strip() @classmethod def create(cls, repository: Repo, path: Path, commit: str) -> IntegrationCheckout: @@ -166,7 +166,7 @@ def include(self, head: PullRequestHead) -> None: raise def combine_sources(self, base: str, heads: tuple[PullRequestHead, ...]) -> str: - """Checking admits incomplete sources, but never drops a conflicting member.""" + """Checking preserves every source member, including when their merge conflicts.""" self.require_editable() Repo.git(self.path, "switch", "--detach", base) for head in heads: @@ -256,6 +256,24 @@ def finish( raise IntegrationFailed(f"Timed out: {self.log}; no automatic replay") +@dataclass(frozen=True, kw_only=True) +class RunnerEnvironment: + root: Annotated[Path, PathText, WireName("RUNNER_TEMP")] + + @classmethod + def read(cls) -> Self: + return FieldCodec.decode(cls, {entry.key: os.environ[entry.key] + for entry in FieldCodec.argument_fields(cls)}) + + +@dataclass(frozen=True, kw_only=True) +class GitHubEnvironment(RunnerEnvironment): + repository: Annotated[RepositoryName, WireName("GITHUB_REPOSITORY")] + tested: Annotated[str, GitCommitText, WireName("GITHUB_SHA")] + event: Annotated[str, WireName("GITHUB_EVENT_NAME")] + document: Annotated[Path, PathText, WireName("GITHUB_EVENT_PATH")] + + @dataclass(frozen=True) class TestedSources(DeclaredFamily, affix="Sources"): """The actual tested Git graph and forge carrier, not a supplied source roster. @@ -267,6 +285,7 @@ class TestedSources(DeclaredFamily, affix="Sources"): repository: Repo tested: str carrier: PullRequestHead + environment: RunnerEnvironment = field(kw_only=True, compare=False) resolve_option: ClassVar[str] = "--resolve-ci-inputs" revalidate_option: ClassVar[str] = "--revalidate-ci-inputs" @@ -290,24 +309,26 @@ def tree(self) -> str: @classmethod def read(cls, checkout: Path) -> TestedSources: + environment = GitHubEnvironment.read() repository = Repo.of(checkout) - if RepositoryName.from_wire(os.environ["GITHUB_REPOSITORY"]) != repository.github: + if environment.repository != repository.github: raise IntegrationFailed("The runner checkout origin is not the actual event repository") tested = Repo.git(checkout, "rev-parse", "HEAD^{commit}").strip() - if os.environ["GITHUB_SHA"] != tested: + if environment.tested != tested: raise IntegrationFailed("The runner checkout is not the event's actual tested SHA") GitCommitText.decode(tested) - document = FieldCodec.parse(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) - return cls.member_named(os.environ["GITHUB_EVENT_NAME"]).resolve(repository, tested, document) + document = FieldCodec.parse(environment.document.read_text()) + return cls.member_named(environment.event).resolve(repository, tested, document, environment=environment) @classmethod @abstractmethod - def resolve(cls, repository: Repo, tested: str, document: object) -> TestedSources: + def resolve(cls, repository: Repo, tested: str, document: object, *, + environment: RunnerEnvironment) -> TestedSources: """Decode this foreign event once and prove its relationship to the tested Git graph.""" def require_carrier(self) -> None: current = self.carrier.current() - if current.base != "main": + if current.base != WorkBranches.main: raise IntegrationFailed("The tested carrier no longer targets main") def main_commits(self, inputs: IntegrationInput) -> dict[RepositoryName, str]: @@ -318,7 +339,7 @@ def require_local_candidate(self, source: IntegrationSource) -> None: def materialize(self, directory: Path) -> IntegrationInput: """Capture once from Git/forge, then use these same exact checkouts for every consumer.""" - if not directory.is_relative_to(Path(os.environ["RUNNER_TEMP"]).resolve()): + if not directory.is_relative_to(self.environment.root.resolve()): raise IntegrationFailed("CI source artifacts belong under the runner's private temporary root") directory.mkdir(parents=True, exist_ok=False) payload = IntegrationInput.from_message( @@ -409,10 +430,11 @@ def validate(self, inputs: IntegrationInput, repositories: tuple[Repo, ...]) -> class PullRequestSources(TestedSources, declared_name="pull_request"): @classmethod - def resolve(cls, repository: Repo, tested: str, document: object) -> TestedSources: + def resolve(cls, repository: Repo, tested: str, document: object, *, + environment: RunnerEnvironment) -> TestedSources: match document: case {"number": int(number), "pull_request": {"head": {"sha": str(head)}}}: - graph = cls(repository, tested, PullRequestHead(repository.github, number, head)) + graph = cls(repository, tested, PullRequestHead(repository.github, number, head), environment=environment) case _: raise IntegrationFailed("Malformed pull_request source event") if graph.parents != (graph.baseline, graph.carrier.head): @@ -423,16 +445,20 @@ def resolve(cls, repository: Repo, tested: str, document: object) -> TestedSourc class MergeGroupSources(TestedSources, declared_name="merge_group"): @classmethod - def resolve(cls, repository: Repo, tested: str, document: object) -> TestedSources: + @boundary_decoder + def resolve(cls, repository: Repo, tested: str, document: object, *, + environment: RunnerEnvironment) -> TestedSources: match document: case {"merge_group": {"head_sha": str(head), "base_sha": str(base), "head_ref": str(reference)}}: # This is GitHub's queue protocol (also used by review-verdict), not a # branch-name guess about which independent repositories belong together. queued = re.search(r"/pr-([0-9]+)-", reference) - if queued is None or head != tested: + if queued is None: raise IntegrationFailed("No actual queued carrier in this merge-group event") + if head != tested: + raise IntegrationFailed("The merge-group event names another tested head") carrier = PullRequestHead.at(repository.github, int(queued[1])) - graph = cls(repository, tested, PullRequestHead(carrier.repo, carrier.pr, carrier.head)) + graph = cls(repository, tested, PullRequestHead(carrier.repo, carrier.pr, carrier.head), environment=environment) case _: raise IntegrationFailed("Malformed merge_group source event") if graph.baseline != base or not Repo.descends(repository.checkout, graph.carrier.head, tested): @@ -452,7 +478,7 @@ def merged_carrier(repository: Repo, tested: str) -> PullRequestHead: if len(parents) != 2: raise IntegrationFailed("Main cache requires the actual two-parent merged PR") rows = tuple(row for row in PullRequests().rows(repository.github, numbers) - if row.base == "main" and row.head == parents[1] + if row.base == WorkBranches.main and row.head == parents[1] and any(merged.merge.oid == tested for merged in row.as_merged())) if len(rows) != 1: raise IntegrationFailed("Main cache requires one actual merged PR; direct/ambiguous commits refuse") @@ -460,10 +486,13 @@ def merged_carrier(repository: Repo, tested: str) -> PullRequestHead: return PullRequestHead(repository.github, row.number, row.head) @classmethod - def resolve(cls, repository: Repo, tested: str, document: object) -> TestedSources: + def resolve(cls, repository: Repo, tested: str, document: object, *, + environment: RunnerEnvironment) -> TestedSources: match document: - case {"ref": "refs/heads/main", "after": str(after)} if after == tested: - graph = cls(repository, tested, cls.merged_carrier(repository, tested)) + case {"ref": str(reference), "after": str(after)} if after == tested: + if reference != f"refs/heads/{WorkBranches.main}": + raise IntegrationFailed("Main cache requires the actual main push event") + graph = cls(repository, tested, cls.merged_carrier(repository, tested), environment=environment) case _: raise IntegrationFailed("Main cache requires the actual main push event") if graph.parents != (graph.baseline, graph.carrier.head): @@ -482,7 +511,7 @@ def require_local_candidate(self, source: IntegrationSource) -> None: source.require_tree(self.repository) for head in (head for head in source.heads if not head.same_pull_request(self.carrier)): rows = tuple(row for row in PullRequests().rows(head.repo, (head.pr,)) - if row.head == head.head and row.base == "main") + if row.head == head.head and row.base == WorkBranches.main) # Inclusion belongs to Git. An open/closed badge cannot undo a commit that # the actual merged carrier contains, nor can it substitute for that proof. if len(rows) != 1 or not Repo.descends(self.repository.checkout, head.head, self.tested): @@ -547,7 +576,7 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...]) -> Integ if not repo.contains(merged.merge.oid, mains[repo.github]): raise IntegrationFailed("A merged source is absent from captured main") continue - if not row.as_unmerged() or len(linked[row.number]) != 1 or row.base != "main": + if not row.as_unmerged() or len(linked[row.number]) != 1 or row.base != WorkBranches.main: raise IntegrationFailed("A linked source is closed, ambiguous or targets another base") selected.append(PullRequestHead(repo.github, row.number, row.head)) heads[repo.github] = tuple(selected) @@ -706,15 +735,17 @@ def main() -> None: os.execvpe(command[0], command, dict(os.environ)) +@boundary_decoder def run() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--checkout", type=Path) - parser.add_argument( + checkout_option = parser.add_argument("--checkout", type=Path) + local_options = parser.add_argument_group("local integration") + output_option = local_options.add_argument( "--output", type=Path, help="new persistent directory for this run's two bounded logs", ) - parser.add_argument("--risk", default="") + risk_option = local_options.add_argument("--risk", default="") operation = parser.add_mutually_exclusive_group() operation.add_argument(TestedSources.resolve_option, type=Path, metavar="OUTPUT", help="materialize the actual GitHub event's exact compared sources; no bus") @@ -722,13 +753,13 @@ def run() -> int: help="revalidate the same event/input and source heads before CI success") operation.add_argument("--qualify", default="", metavar="PROBLEM", help="prepare sources from this actual primary order, without running tests") - parser.add_argument("--with-problem", action="append", default=[], metavar="PROBLEM") - parser.add_argument("--comms-root", type=Path, + local_options.add_argument("--with-problem", action="append", default=[], metavar="PROBLEM") + local_options.add_argument("--comms-root", type=Path, help="the existing authorized root for stored-problem qualification") parser.add_argument( ExcludeAfterFailure.option, type=ExcludeAfterFailure.argument, default=StopAfterFailure() ) - parser.add_argument( + local_options.add_argument( "pytest_arguments", nargs=argparse.REMAINDER, help="pytest's full argument surface after --; touched files are appended", @@ -737,9 +768,13 @@ def run() -> int: pytest_arguments = arguments.pytest_arguments if pytest_arguments[:1] == ["--"]: pytest_arguments = pytest_arguments[1:] + arguments.pytest_arguments = pytest_arguments + values = vars(arguments) if arguments.resolve_ci_inputs or arguments.revalidate_ci_inputs: - if pytest_arguments or arguments.with_problem or arguments.comms_root or arguments.output or arguments.risk: - parser.error("CI source resolution does not accept tests, a bus root or local qualification options") + forbidden = {option.dest for option in local_options._group_actions if values[option.dest]} + if forbidden: + parser.error("CI source resolution does not accept tests, a bus root or local qualification options: " + + ", ".join(sorted(forbidden))) arguments.drop_on_failure.require_qualification() graph = TestedSources.read((arguments.checkout or Path.cwd()).resolve()) if arguments.resolve_ci_inputs: @@ -747,7 +782,9 @@ def run() -> int: else: graph.revalidate(arguments.revalidate_ci_inputs.resolve()) return 0 - if arguments.checkout is None or arguments.output is None or not arguments.risk: + missing = {option.dest for option in (checkout_option, output_option, risk_option) + if not values[option.dest]} + if missing: parser.error("Local integration requires --checkout, --output and --risk") run = IntegrationRun( IntegrationCheckout(arguments.checkout.resolve()), diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py index baa559def0..2adc9cb7f4 100644 --- a/tests/test_checker_stack.py +++ b/tests/test_checker_stack.py @@ -1,6 +1,7 @@ """Private full-project projection against real captured Git/forge inputs; no fake resolver.""" from collections.abc import Iterator +from datetime import date from pathlib import Path import os import sys @@ -36,8 +37,19 @@ def test_private_project_preserves_settings_pins_and_refuses_external_migration( core_project = pair.core.checkout / "pyproject.toml" document = tomllib.loads(core_project.read_text()) document["project"]["dependencies"] = list(dependencies) + stack = tomllib.loads((pair.core.checkout / "stack/pyproject.toml").read_text()) + stack["project"]["python"] = "unmodeled convenience spelling" + stack["tool"]["uv"]["overrides"] = ["unmodeled overrides"] + stack["tool"]["uv"]["constraints"] = ["unmodeled constraints"] + stack["tool"]["uv"]["indexes"] = [{"unmodeled": "indexes"}] + stack["tool"]["fixture"] = {"dates": [date(2026, 10, 28)], "matrix": [{"enabled": False}]} + stack["tool"]["uv"]["index"] = [{"name": "fixture-index", "url": "https://pypi.org/simple", + "explicit": True, "authenticate": "always"}] + stack["tool"]["uv"]["sources"]["conditional-package"] = [ + {"index": "fixture-index", "marker": "sys_platform == 'linux'"}] head = pair.core.publish_change("Declare this candidate runtime contract", *pair.core.write_files({ - Path("pyproject.toml"): tomli_w.dumps(document)})) + Path("pyproject.toml"): tomli_w.dumps(document), + Path("stack/pyproject.toml"): tomli_w.dumps(stack)})) stamp = "2026-10-24T00:00:00Z" github_cli.put(pair.core.github, DraftListedPullRequest( pair.core.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) @@ -51,10 +63,10 @@ def test_private_project_preserves_settings_pins_and_refuses_external_migration( if allowed: checker.write_project() expected = tomllib.loads(before[0].decode()) - expected["tool"]["uv"]["sources"] = { - "agent-comms": {"path": str(checker.core), "editable": True}, - "source-client": {"path": str(HeadSide.checkout(pair.output, pair.client.github)), "editable": True}, - } + expected["tool"]["uv"]["sources"]["agent-comms"] = {"path": str(checker.core), "editable": True} + expected["tool"]["uv"]["sources"]["source-client"] = { + "path": str(HeadSide.checkout(pair.output, pair.client.github)), "editable": True} + assert tomllib.loads((checker.checkout / "stack/pyproject.toml").read_text()) == expected assert checker.lock.read_bytes() == before[1] monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) @@ -184,9 +196,12 @@ def test_complete_project_reconciles_shared_preferences_and_keeps_both_cuts_on_o if Requirement(text).name == "checker-plugin") assert override.url == f"git+{source['git']}@{source['rev']}#subdirectory=python%20package" for query in ("unknown=python", "subdirectory=python&subdirectory=other", "subdirectory=", - "branch=main&tag=release", "subdirectory=%00"): + "branch=main&tag=release", "subdirectory=%00", "subdirectory=%ZZ", "branch="): with pytest.raises(ValueError, match="Git"): GitSource(git=f"{source['git']}?{query}#{source['rev']}").project_source() + for revision in ("", "a" * 7, "Z" * 40): + with pytest.raises(ValueError, match="Git"): + GitSource(git=f"{source['git']}?branch=main#{revision}").project_source() checker.resolve() locked = ResolvedLock.read(checker.lock).external(checker.local_names) assert locked["idna"].version == bootstrap["idna"].version diff --git a/tests/test_integration.py b/tests/test_integration.py index 871375d1a7..4606c1cf20 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -212,18 +212,20 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( def test_ci_event_refuses_unrelated_graph_and_carrier_without_materialization( - mutual_sources: MutualSources, github_cli: GitHubFixture, + mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, ) -> None: pair = mutual_sources carrier = PullRequestHead(pair.core.github, pair.core.number, pair.core.branch_commit) # main is not a two-parent PR test graph, regardless of plausible event text. with pytest.raises(integration.IntegrationFailed, match="two-parent"): integration.PullRequestSources.resolve(pair.core, pair.core.main_commit, - {"number": carrier.pr, "pull_request": {"head": {"sha": carrier.head}}}) + {"number": carrier.pr, "pull_request": {"head": {"sha": carrier.head}}}, + environment=integration.RunnerEnvironment(root=tmp_path)) with pytest.raises(integration.IntegrationFailed, match="queued carrier"): integration.MergeGroupSources.resolve(pair.core, pair.core.main_commit, {"merge_group": {"head_sha": pair.core.main_commit, "base_sha": pair.core.main_commit, - "head_ref": "refs/heads/not-a-GitHub-queue-reference"}}) + "head_ref": "refs/heads/not-a-GitHub-queue-reference"}}, + environment=integration.RunnerEnvironment(root=tmp_path)) assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index f6c615c5b4..24547ee449 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -7,6 +7,7 @@ from zipfile import ZipFile from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet import pytest import tomli_w @@ -64,7 +65,7 @@ def test_standard_backend_metadata_preserves_identity_python_and_all_optional_ed with pytest.raises(ValueError, match="not stack package"): StackSources.require_package(metadata, "different-package") with pytest.raises(ValueError, match="common interpreter"): - ProjectMetadata.require_python(metadata.python, {"python_full_version": "3.8.0"}) + ProjectMetadata.require_python(SpecifierSet("<1")) environment = StackSources.marker_environment(Path(sys.executable)) with pytest.raises(ValueError, match="selected common interpreter"): StackSources.marker_environment(tmp_path / "different-interpreter") diff --git a/tests/test_runtime_resolution.py b/tests/test_runtime_resolution.py index 17c50d3cfb..6083cc5e88 100644 --- a/tests/test_runtime_resolution.py +++ b/tests/test_runtime_resolution.py @@ -1,4 +1,5 @@ import os +import tomllib from pathlib import Path import pytest @@ -21,7 +22,11 @@ def test_package_probe_entry_uses_one_declaration_before_refusing_build_identity ) -def manifest(core="core", toad="toad", wheel="old-wheel"): +TOAD_COMMIT = "a" * 40 +TEXTUAL_COMMIT = "b" * 40 + + +def manifest(core="core", toad=TOAD_COMMIT, wheel="old-wheel"): return RuntimeResolution.model_validate( { "core_commit": core, @@ -35,7 +40,7 @@ def manifest(core="core", toad="toad", wheel="old-wheel"): "version": "3", "source": { "url": "https://example/textual", - "vcs_info": {"commit_id": "textual"}, + "vcs_info": {"commit_id": TEXTUAL_COMMIT}, }, }, "rich": {"version": "4", "source": None}, @@ -62,12 +67,12 @@ def lock(): { "name": "batrachian-toad", "version": "2", - "source": {"git": "https://example/toad#toad"}, + "source": {"git": f"https://example/toad#{TOAD_COMMIT}"}, }, { "name": "textual", "version": "3", - "source": {"git": "https://example/textual#textual"}, + "source": {"git": f"https://example/textual#{TEXTUAL_COMMIT}"}, }, {"name": "rich", "version": "4", "source": {"registry": "https://pypi.org/simple"}}, # The universal lock contains entries not selected by this synced environment. @@ -88,7 +93,7 @@ def test_recorded_sources_match_selected_lock_not_wheel_locations(): assert built.pins() == pins assert manifest(wheel="another-wheel").pins() == pins assert manifest(core="changed").pins() != pins - assert manifest(toad="changed").pins() != pins + assert manifest(toad="d" * 40).pins() != pins assert lock().pins({**selected, "unselected": "5"}, "core") != built.pins() @@ -110,7 +115,8 @@ def test_the_stack_relocks_every_branch_tracking_package_and_refuses_a_unit_bran tracked = lock.tracked() assert {"batrachian-toad", "metaclass-registry", "textual"} <= set(tracked) assert "objectstate" not in tracked - toad = next(entry for entry in lock.package if entry.name == "batrachian-toad") - toad.source.git = toad.source.git.replace("branch=main", "branch=work/unit-toad--P694") + document = tomllib.loads(path.read_text()) + toad = next(entry for entry in document["package"] if entry["name"] == "batrachian-toad") + toad["source"]["git"] = toad["source"]["git"].replace("branch=main", "branch=work/unit-toad--P694") with pytest.raises(ValueError, match="batrachian-toad"): - lock.tracked() + ResolvedLock.model_validate(document).tracked() diff --git a/tools/checker_stack.py b/tools/checker_stack.py index d887661b38..b1f7c35bc2 100644 --- a/tools/checker_stack.py +++ b/tools/checker_stack.py @@ -16,14 +16,14 @@ from packaging.utils import canonicalize_name import tomli_w -from agent_comms.field_codec import FieldCodec from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side from agent_comms.installer.commands import InstallCommands from agent_comms.installer.declared_uv import DeclaredUv from agent_comms.installer.runtime_resolution import ( - CorePackage, ResolvedLock, RuntimeResolution, StackSources, VersionedPackage, + CorePackage, PrivateUvProject, ResolvedLock, RuntimeResolution, StackSources, VersionedPackage, ) from agent_comms.installer.upgrade import Stack +from agent_comms.integration import RunnerEnvironment from agent_comms.launch_runtime import LaunchRuntime from agent_comms.repo import Repo from module_layers import MaterializedClients @@ -118,38 +118,16 @@ def write_project(self) -> None: original = self.core / "stack" project = self.checkout / "stack" project.mkdir(parents=True, exist_ok=False) - document: dict[str, object] = tomllib.loads((original / "pyproject.toml").read_text()) - metadata = self.clients.projects(HeadSide)[CorePackage.repository] - match document: - case {"project": {"dependencies": list() as dependencies} as root, - "tool": {"uv": {"sources": dict() as sources} as uv}}: - roots = FieldCodec.decode(list[str], dependencies) - if not any(canonicalize_name(Requirement(text).name) == metadata.name for text in roots): - raise ValueError("The private checker requires the declared Core runtime root") - root["dependencies"] = [metadata.with_all_extras(text) for text in roots] - uv["override-dependencies"] = [metadata.with_all_extras(text) for text in - FieldCodec.decode(list[str], uv.get("override-dependencies", []))] - for raw_name, value in tuple(sources.items()): - name = canonicalize_name(FieldCodec.decode(str, raw_name)) - match value: - case {"git": str(url), "branch": str(), **options}: - source = self.inputs.source(Repo.repository_url(url)) - checkout = HeadSide.require_materialized(self.source_directory, source) - self.require_package(self.clients.projects(HeadSide)[source.repository], name) - sources[raw_name] = {**options, "path": str(checkout), "editable": True} - case {"path": str(relative), **options}: - checkout = (original / relative).resolve() - if checkout != self.core or name != CorePackage.distribution: - raise ValueError(f"Uncaptured local source {name}: {checkout}") - sources[raw_name] = {**options, "path": str(checkout)} - case _: - # Pinned/index/archive sources remain exactly as declared. - pass - for package in retained.values(): - package.constrain(uv) - case _: - raise ValueError("The candidate stack requires its actual source declarations") - (project / "pyproject.toml").write_text(tomli_w.dumps(document)) + document = PrivateUvProject.read(original / "pyproject.toml") + projects = self.clients.projects(HeadSide) + document.enrich(projects[CorePackage.repository]) + materialized = {identity: ( + HeadSide.require_materialized(self.source_directory, self.inputs.source(identity)), metadata) + for identity, metadata in projects.items()} + document.materialize(original, self.core, materialized) + for package in retained.values(): + package.constrain(document.tool.uv) + (project / "pyproject.toml").write_text(tomli_w.dumps(document.document())) shutil.copyfile(original / "uv.lock", self.lock) def resolve(self) -> None: @@ -182,10 +160,13 @@ def require_resolution(self, resolution: RuntimeResolution) -> None: def prepare(self) -> RuntimeResolution: prefix = self.runtime.prefix.resolve() - runner = Path(os.environ["RUNNER_TEMP"]).resolve() - if (not self.source_directory.resolve().is_relative_to(runner) or not prefix.is_relative_to(runner) - or not self.checkout.resolve().is_relative_to(self.source_directory.resolve()) - or not (prefix / "pyvenv.cfg").is_file()): + runner = RunnerEnvironment.read().root.resolve() + for path in (self.source_directory.resolve(), prefix): + if not path.is_relative_to(runner): + raise ValueError("The common checker requires its isolated runner bootstrap prefix and input directory") + if not self.checkout.resolve().is_relative_to(self.source_directory.resolve()): + raise ValueError("The private checker project must be under its captured source directory") + if not (prefix / "pyvenv.cfg").is_file(): raise ValueError("The common checker requires its isolated runner bootstrap prefix and input directory") self.require_uv() self.write_project() diff --git a/tools/module_layers.py b/tools/module_layers.py index a10742aa07..16bca49c46 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -414,7 +414,7 @@ def environment(self) -> dict[str, str]: @cached_property def metadata(self) -> dict[type[Side], dict[RepositoryName, ProjectMetadata]]: """One acquisition per actual immutable project, shared by all consumers here.""" - environment = self.environment + _ = self.environment # Prove the selected interpreter once before using its Python version. result: dict[type[Side], dict[RepositoryName, ProjectMetadata]] = {} core_source = self.inputs.source(CorePackage.repository) for side in Side.sides(): @@ -430,7 +430,7 @@ def metadata(self) -> dict[type[Side], dict[RepositoryName, ProjectMetadata]]: metadata = ProjectMetadata.acquire(checkout, uv=self.uv, python=self.python, commands=self.commands, output=self.directory / "metadata") self.require_package(metadata, packages[identity]) - ProjectMetadata.require_python(metadata.python, environment) + ProjectMetadata.require_python(metadata.python) projects[identity] = metadata finally: side.require_materialized(self.directory, source) From b89b3582ff30c21a79f68aa9b2e2b63c82d468b8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 18:14:31 -0400 Subject: [PATCH 047/109] Preserve native UV source contracts and share proved decoder traversal --- .../installer/runtime_resolution.py | 35 ++++++--------- tests/guards/test_debt_ratchet.py | 43 +++++++++++++++++++ tests/source_revision_fixture.py | 20 ++++++--- tests/test_checker_stack.py | 31 ++++++++----- tests/test_integration.py | 7 ++- tools/debt_ratchet.py | 40 +++++++++++------ 6 files changed, 125 insertions(+), 51 deletions(-) diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index d617e47adb..4577561d09 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -17,7 +17,7 @@ from tempfile import TemporaryDirectory from zipfile import ZipFile from typing import TYPE_CHECKING, Annotated, ClassVar, Self -from urllib.parse import parse_qsl, quote, urlencode, urlsplit +from urllib.parse import parse_qsl, urlsplit from packaging.markers import Marker, default_environment from packaging.requirements import Requirement @@ -139,14 +139,11 @@ def branches(self) -> tuple[str, ...]: class GitPackageLocation: - """The repository root has no subdirectory field or direct-URL fragment.""" + """The repository root has no native UV subdirectory field.""" def fields(self) -> dict[str, str]: return {} - def suffix(self) -> str: - return "" - @dataclass(frozen=True) class GitQuery(GitSelection, DeclaredFamily, affix="GitQuery"): @@ -189,9 +186,6 @@ def locations(self) -> tuple[GitPackageLocation, ...]: def fields(self) -> dict[str, str]: return {self.declared_name: self.value} - def suffix(self) -> str: - return "#" + urlencode(self.fields(), quote_via=quote) - @dataclass(frozen=True) class LockedGitLocation: @@ -226,10 +220,6 @@ def decode(cls, value: str) -> LockedGitLocation: package, = locations or (GitPackageLocation(),) return cls(source._replace(query="", fragment="").geturl(), source.fragment, selector, package) - @property - def direct_url(self) -> str: - return f"git+{self.repository}@{self.revision}{self.package.suffix()}" - class UvRecord(BaseModel): """Decode consumed TOML fields strictly and preserve every unrelated UV/tool option.""" @@ -244,6 +234,8 @@ def document(self) -> dict[str, object]: class UvSourceOptions(UvRecord): """Only fields consumed here are modeled; UV retains every other source option.""" + marker: str = "" + def materialize(self, original: Path, core: Path, projects: dict[RepositoryName, tuple[Path, ProjectMetadata]], name: str) -> UvSourceOptions: return self @@ -252,10 +244,10 @@ def retained_options(self) -> dict[str, object]: return self.model_dump(mode="python", by_alias=True, exclude_unset=True) def with_options(self, options: dict[str, object], marker: str) -> Self: - fields = self.model_dump(mode="python", by_alias=True, exclude_unset=True) + selected = type(self)(**{**options, **self.document()}) if marker: - fields["marker"] = marker - return type(self)(**{**options, **fields}) + selected.marker = marker + return selected class UvGitSource(UvSourceOptions): @@ -273,7 +265,8 @@ def materialize(self, original: Path, core: Path, GitSource.require_branches(name, (self.branch,)) checkout, metadata = projects[Repo.repository_url(self.git)] StackSources.require_package(metadata, name) - options = self.model_dump(mode="python", exclude=set(type(self).model_fields)) + options = self.model_dump(mode="python", by_alias=True, exclude_unset=True, + exclude=set(type(self).model_fields) - set(UvSourceOptions.model_fields)) return UvLocalSource.model_validate({**options, "path": str(checkout), "editable": True}) @@ -326,8 +319,9 @@ def retain(self, name: str, marker: str, source: UvSourceOptions) -> None: original = self.sources.get(name, UvSourceOptions()) self.sources[name] = source.with_options(original.retained_options(), marker) - def override_git(self, name: str, marker: str, location: LockedGitLocation) -> None: - locked = Requirement(f"{name} @ {location.direct_url}") + def override_git(self, name: str, marker: str) -> None: + """The native source owns Git location; the root override only selects its name.""" + locked = Requirement(name) if marker: locked.marker = Marker(marker) result: list[str] = [] @@ -339,7 +333,7 @@ def override_git(self, name: str, marker: str, location: LockedGitLocation) -> N if requirement.specifier: self.constraints = [*self.constraints, str(requirement)] requirement.specifier = SpecifierSet() - requirement.url = locked.url + requirement.url = None result.append(str(requirement)) else: result.append(text) @@ -350,7 +344,6 @@ def override_git(self, name: str, marker: str, location: LockedGitLocation) -> N class UvProjectRoot(UvRecord): dependencies: list[str] - python: str = Field(default="", alias="requires-python") class UvProjectTools(UvRecord): @@ -592,7 +585,7 @@ def project_source(self) -> UvRevisionSource: def retain(self, name: str, marker: str, uv: UvProjectSettings) -> None: super().retain(name, marker, uv) - uv.override_git(name, marker, self.location) + uv.override_git(name, marker) class EditableSource(LockSource): diff --git a/tests/guards/test_debt_ratchet.py b/tests/guards/test_debt_ratchet.py index 98de8fc63f..da03e89dc4 100644 --- a/tests/guards/test_debt_ratchet.py +++ b/tests/guards/test_debt_ratchet.py @@ -1170,6 +1170,49 @@ def test_none_as_absence_fails_with_its_line_except_in_a_declared_boundary_decod assert report["introduced"][plan] == [] and report["delta"][plan] == 0 +@pytest.mark.parametrize("declaration,expected", [ + pytest.param("from agent_comms.field_codec import boundary_decoder\n@boundary_decoder\n{function}", 0, + id="imported"), + pytest.param("from agent_comms.field_codec import boundary_decoder as decode\n@decode\n{function}", 0, + id="aliased"), + pytest.param("import agent_comms.field_codec as codec\n@codec.boundary_decoder\n{function}", 0, + id="qualified"), + pytest.param("import agent_comms.field_codec\n@agent_comms.field_codec.boundary_decoder\n{function}", 0, + id="module"), + pytest.param("from .field_codec import boundary_decoder\n@boundary_decoder\n{function}", 0, + id="relative"), + pytest.param("{function}", 1, id="undecorated"), + pytest.param("from elsewhere import boundary_decoder\n@boundary_decoder\n{function}", 1, + id="different-owner"), + pytest.param("from agent_comms.field_codec import boundary_decoder\n" + "from elsewhere import boundary_decoder\n@boundary_decoder\n{function}", 1, + id="duplicate"), + pytest.param("if enabled:\n from agent_comms.field_codec import boundary_decoder\n" + "@boundary_decoder\n{function}", 1, id="conditional"), + pytest.param("from agent_comms.field_codec import *\n@boundary_decoder\n{function}", 1, + id="star"), + pytest.param("from agent_comms.field_codec import boundary_decoder\nclass Decoder:\n" + " boundary_decoder = lambda function: function\n @boundary_decoder\n" + " def decode(external):\n return external.marker\n", 1, id="class-shadowed"), + pytest.param("from agent_comms.field_codec import boundary_decoder\ndef outer(boundary_decoder):\n" + " @boundary_decoder\n def decode(external):\n return external.marker\n", 1, + id="parameter-shadowed"), +]) +def test_declared_decoder_boundary_is_shared_by_both_contextual_absence_measures( + tmp_path: Path, declaration: str, expected: int, +) -> None: + repository = Repository(tmp_path, "src/agent_comms") + source = declaration.format(function="def decode(external):\n return external.marker\n") + base = repository.commit({"boundary.py": source}) + head = repository.commit({"boundary.py": source.replace("return external.marker", "return external.marker is None")}) + status, report = repository.compare(base, head, NoneAbsence, ForeignAbsenceProbe) + assert status == int(bool(expected)) + for measure in (NoneAbsence, ForeignAbsenceProbe): + identity = measure.at(f"{repository.root}/boundary.py").text + assert report["head"][identity] == expected + assert report["delta"][identity] == expected + + def test_an_unchanged_absence_on_a_rewritten_line_is_not_written_but_a_new_one_is(repo): base = repo.commit({"owner.py": "def ready(stored):\n return stored is not None and stored.done\n"}) kept = repo.commit({"owner.py": "def ready(stored):\n return stored is not None and stored.settled()\n"}) diff --git a/tests/source_revision_fixture.py b/tests/source_revision_fixture.py index 91075aa585..53fb8190f3 100644 --- a/tests/source_revision_fixture.py +++ b/tests/source_revision_fixture.py @@ -65,14 +65,22 @@ def git_input(repository: Path, text: str, *arguments: str) -> str: ).stdout.strip() @staticmethod - def package_project(name: str, *, dependencies: tuple[str, ...] = (), - python: str = ">=3.10", extras: dict[str, list[str]] | None = None) -> str: - """One legitimate tiny source project, built by its standard backend in controls.""" + def package_project( + name: str, *, dependencies: tuple[str, ...] = (), python: str = ">=3.10", + extras: dict[str, list[str]] | None = None, scripts: Mapping[str, str] = MappingProxyType({}), + build_system: Mapping[str, str | tuple[str, ...]] = MappingProxyType({ + "requires": ("hatchling",), "build-backend": "hatchling.build", + }), + ) -> str: + """Build the actual src tree, independently of distribution or module spelling.""" return tomli_w.dumps({ - "build-system": {"requires": ["hatchling"], "build-backend": "hatchling.build"}, + "build-system": dict(build_system), "project": {"name": name, "version": "0.0.1", "requires-python": python, - "dependencies": list(dependencies), "optional-dependencies": extras or {}}, - "tool": {"hatch": {"build": {"targets": {"wheel": {"packages": ["src"]}}}}}, + "dependencies": list(dependencies), "optional-dependencies": extras or {}, + "scripts": dict(scripts)}, + "tool": {"hatch": {"build": {"targets": {"wheel": { + "only-include": ["src"], "sources": ["src"], + }}}}}, }) def write_files(self, files: Mapping[Path, str]) -> tuple[str, ...]: diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py index 2adc9cb7f4..1d0499a5ac 100644 --- a/tests/test_checker_stack.py +++ b/tests/test_checker_stack.py @@ -27,10 +27,15 @@ pytest_plugins = ("test_holder_standing_rules", "integration_source_fixture") -@pytest.mark.parametrize("dependencies,allowed", [((), True), (("idna==3.10",), False)]) +@pytest.mark.parametrize("dependencies,allowed,declared_python", [ + pytest.param((), True, "", id="dependencies0-True"), + pytest.param(("idna==3.10",), False, "", id="dependencies1-False"), + pytest.param((), True, ">=3.14", id="requires-python"), +]) def test_private_project_preserves_settings_pins_and_refuses_external_migration( mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, - dependencies: tuple[str, ...], allowed: bool, monkeypatch: pytest.MonkeyPatch, test_uv: Path, + dependencies: tuple[str, ...], allowed: bool, declared_python: str, + monkeypatch: pytest.MonkeyPatch, test_uv: Path, ) -> None: pair = mutual_sources # Edit the real source before capture, never rewrite the captured artifact or selectors. @@ -39,6 +44,10 @@ def test_private_project_preserves_settings_pins_and_refuses_external_migration( document["project"]["dependencies"] = list(dependencies) stack = tomllib.loads((pair.core.checkout / "stack/pyproject.toml").read_text()) stack["project"]["python"] = "unmodeled convenience spelling" + if declared_python: + stack["project"]["requires-python"] = declared_python + stack["tool"]["uv"]["sources"]["source-client"]["marker"] = "python_version >= '3.10'" + stack["tool"]["uv"]["sources"]["source-client"]["fixture-option"] = {"preserved": True} stack["tool"]["uv"]["overrides"] = ["unmodeled overrides"] stack["tool"]["uv"]["constraints"] = ["unmodeled constraints"] stack["tool"]["uv"]["indexes"] = [{"unmodeled": "indexes"}] @@ -65,7 +74,8 @@ def test_private_project_preserves_settings_pins_and_refuses_external_migration( expected = tomllib.loads(before[0].decode()) expected["tool"]["uv"]["sources"]["agent-comms"] = {"path": str(checker.core), "editable": True} expected["tool"]["uv"]["sources"]["source-client"] = { - "path": str(HeadSide.checkout(pair.output, pair.client.github)), "editable": True} + "path": str(HeadSide.checkout(pair.output, pair.client.github)), "editable": True, + "marker": "python_version >= '3.10'", "fixture-option": {"preserved": True}} assert tomllib.loads((checker.checkout / "stack/pyproject.toml").read_text()) == expected assert checker.lock.read_bytes() == before[1] @@ -141,11 +151,14 @@ def common_checker( pair.client.git(pair.client.checkout, "switch", "feature") pair.client.git(pair.client.checkout, "merge", "--no-edit", "main") pair.core.git(pair.core.checkout, "switch", "main") - pair.core.write_files({Path("pyproject.toml"): pair.core.package_project("agent-comms", + core = tomllib.loads(pair.core.package_project("agent-comms", dependencies=("idna>=3.10,<4; python_version >= '3.10'",), extras={ - "checking": ["idna<3.11", "six==1.16.0", - f"checker-plugin @ git+{plugin.remote.as_uri()}@main#subdirectory=python%20package"]})}) + "checking": ["idna<3.11", "six==1.16.0", "checker-plugin"]})) + plugin_source = {"git": plugin.remote.as_uri(), "branch": "main", "subdirectory": "python package"} + core["tool"]["uv"] = {"sources": {"checker-plugin": plugin_source}} + pair.core.write_files({Path("pyproject.toml"): tomli_w.dumps(core)}) stack = tomllib.loads((pair.core.checkout / "stack/pyproject.toml").read_text()) + stack["tool"]["uv"]["sources"]["checker-plugin"] = plugin_source stack["tool"]["uv"]["sources"]["source-client"]["git"] = pair.client.remote.as_uri() stack["project"]["dependencies"] = ["agent-comms>=0.0.1; python_version >= '3.10'", "source-client"] stack["tool"]["uv"]["override-dependencies"].append("agent-comms>=0.0.1; python_version >= '3.10'") @@ -194,7 +207,8 @@ def test_complete_project_reconciles_shared_preferences_and_keeps_both_cuts_on_o assert source["subdirectory"] == "python package" override, = (Requirement(text) for text in private["tool"]["uv"]["override-dependencies"] if Requirement(text).name == "checker-plugin") - assert override.url == f"git+{source['git']}@{source['rev']}#subdirectory=python%20package" + assert override.url is None and not override.specifier + assert source == bootstrap["checker-plugin"].source.project_source().document() for query in ("unknown=python", "subdirectory=python&subdirectory=other", "subdirectory=", "branch=main&tag=release", "subdirectory=%00", "subdirectory=%ZZ", "branch="): with pytest.raises(ValueError, match="Git"): @@ -264,9 +278,6 @@ def test_retained_runtime_checker_version_and_git_identity_refuse_actual_solve_d source["rev"] = commit else: source["subdirectory"] = "other package" # Same repo/SHA/version, another actual project. - location = source["subdirectory"].replace(" ", "%20") - uv["override-dependencies"] = [f"{package} @ git+{source['git']}@{source['rev']}#subdirectory={location}" - if Requirement(text).name == package else text for text in uv["override-dependencies"]] else: # Changing the explicit private override is not an accepted waiver: the retained # source/version owner still detects the resulting genuine solver selection. diff --git a/tests/test_integration.py b/tests/test_integration.py index 4606c1cf20..6bd9a7b2ef 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -132,6 +132,8 @@ def test_actual_main_cli_keeps_merged_local_members_exact_and_refuses_later_head peer_head = peer.publish_change("Independent local source", *peer.write_files({ Path("src/agent_comms/extra.py"): "extra = 1\n"})) peer.git(peer.checkout, "switch", "feature") + assert peer.git(peer.checkout, "ls-remote", "origin", f"refs/pull/{peer.number}/head").split() == [ + peer_head, f"refs/pull/{peer.number}/head"] stamp = "2026-10-24T00:00:00Z" github_cli.put(peer.github, DraftListedPullRequest( peer.number, "second-local-source", peer_head, stamp, "main", reviewable_times=(stamp,))) @@ -140,7 +142,8 @@ def test_actual_main_cli_keeps_merged_local_members_exact_and_refuses_later_head problem="P4", commander="lead", request=primary.request, assigner=pair.context.comms.registry.require("lead").incarnation, repo=peer.github, text="Include the existing local source", - order=replace(primary.order.checked(), pull_requests=(peer.link,)), + order=replace(primary.order.checked(), pull_requests=(peer.link,), + check=replace(primary.order.check, directory=peer.checkout)), review_at=RecordedEventTime(9999999999.0))) inputs = integration.IntegrationRun(pair.checkout, pair.output, "actual main tuple").qualify( pair.context, ("P2", "P3", "P4")) @@ -188,7 +191,7 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( # The native config and its plugin are real baseline source, then migrated on feature. pair.client.git(pair.client.checkout, "switch", "main") project = tomllib.loads((pair.client.checkout / "pyproject.toml").read_text()) - project["tool"] = {"mypy": {"plugins": ["mypy_source_plugin.py"]}} + project["tool"]["mypy"] = {"plugins": ["mypy_source_plugin.py"]} pair.client.commit_change("Declare the baseline native mypy plugin", *pair.client.write_files({ Path("pyproject.toml"): tomli_w.dumps(project), Path("mypy_source_plugin.py"): plugin})) pair.client.git(pair.client.checkout, "push", "origin", "main") diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index eae4e2518d..765ed94cad 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -1068,6 +1068,10 @@ def visit_alias(self, node: ast.alias) -> None: self.declares.add(node) self.generic_visit(node) + def visit_arg(self, node: ast.arg) -> None: + self.binds[node] = (node.arg,) + self.generic_visit(node) + def visit_Name(self, node: ast.Name) -> None: match node.ctx: case ast.Store() | ast.Del(): @@ -1478,8 +1482,25 @@ class DecoratorBinding: @classmethod def of(cls, decorator: typing.Callable[..., object], tree: ast.AST, - filename: str) -> DecoratorBinding: - return cls.named(decorator.__module__, decorator.__name__, tree, filename) + filename: str, scope: str = "") -> DecoratorBinding: + return cls.named(decorator.__module__, decorator.__name__, tree, filename, scope) + + @classmethod + def sites(cls, decorator: typing.Callable[..., object], tree: ast.AST, + filename: str) -> Iterator[ast.AST]: + """Walk outside functions whose enclosing scope proves the declared decorator.""" + scopes = ScopeNames.of(tree) + pending = [tree] + while pending: + node = pending.pop() + match node: + case ast.FunctionDef(decorator_list=[_, *_] as decorators) | ast.AsyncFunctionDef( + decorator_list=[_, *_] as decorators): + marker = cls.of(decorator, tree, filename, scopes.found[node]) + if any(map(marker.marks, decorators)): + continue + yield node + pending.extend(ast.iter_child_nodes(node)) @staticmethod def module_of(filename: str) -> tuple[str, ...]: @@ -1631,16 +1652,7 @@ class NoneAbsence(CleanChangedLines, PerFileOccurrenceMeasure): @classmethod def sites(cls, tree: ast.AST, filename: str) -> Iterator[ast.AST]: - marker = DecoratorBinding.of(boundary_decoder, tree, filename) - pending = [tree] - while pending: - node = pending.pop() - match node: - case ast.FunctionDef(decorator_list=decorators) | ast.AsyncFunctionDef( - decorator_list=decorators) if any(map(marker.marks, decorators)): - continue - yield node - pending.extend(ast.iter_child_nodes(node)) + return DecoratorBinding.sites(boundary_decoder, tree, filename) @staticmethod def occurrences(node: ast.AST) -> int: @@ -2630,6 +2642,10 @@ class ForeignAbsenceProbe(PerFileOccurrenceMeasure): occurrences = AuditForeignAbsenceProbe.count + @classmethod + def sites(cls, tree: ast.AST, filename: str) -> Iterator[ast.AST]: + return DecoratorBinding.sites(boundary_decoder, tree, filename) + class CodecSubclass(PerFileOccurrenceMeasure): """TIME-9 census measure; the sealed FieldCodec guard also resolves aliases.""" From 19f7998f678f52fa4daf5d23e89b5b8dfb13e14a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 18:30:36 -0400 Subject: [PATCH 048/109] Validate retained UV options through their existing model boundary --- src/agent_comms/installer/runtime_resolution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index 4577561d09..9b7acad97c 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -244,7 +244,7 @@ def retained_options(self) -> dict[str, object]: return self.model_dump(mode="python", by_alias=True, exclude_unset=True) def with_options(self, options: dict[str, object], marker: str) -> Self: - selected = type(self)(**{**options, **self.document()}) + selected = type(self).model_validate({**options, **self.document()}) if marker: selected.marker = marker return selected From e2416c2094517fb8b82ab36fcc921bf09286c53b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 19:03:32 -0400 Subject: [PATCH 049/109] Preserve explicit checker runtime and decorator evaluation position --- src/agent_comms/child_process.py | 3 ++- tests/test_checker_stack.py | 2 +- tools/debt_ratchet.py | 35 +++++++++++++++++--------------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/agent_comms/child_process.py b/src/agent_comms/child_process.py index c70e2e3d79..6eadeaa676 100644 --- a/src/agent_comms/child_process.py +++ b/src/agent_comms/child_process.py @@ -2188,6 +2188,7 @@ def collect(self, *, timeout: float) -> subprocess.CompletedProcess[bytes]: def run( cls, command: tuple[str, ...], *, timeout: float, cwd: str | Path, env: dict[str, str], + runtime: LaunchRuntime = LaunchRuntime.running(), ) -> subprocess.CompletedProcess[bytes]: """A one-shot interpreter exchange, including forced descendant retirement. @@ -2197,7 +2198,7 @@ def run( if not math.isfinite(timeout) or timeout <= 0: raise ValueError("A positive finite timeout is required") deadline = time.monotonic() + timeout - with cls.launch(command, cwd=cwd, env=env, output=subprocess.PIPE, + with cls.launch(command, cwd=cwd, env=env, runtime=runtime, output=subprocess.PIPE, stderr=subprocess.PIPE) as child: try: return child.collect(timeout=max(0, deadline - time.monotonic())) diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py index 1d0499a5ac..df9b992890 100644 --- a/tests/test_checker_stack.py +++ b/tests/test_checker_stack.py @@ -154,7 +154,7 @@ def common_checker( core = tomllib.loads(pair.core.package_project("agent-comms", dependencies=("idna>=3.10,<4; python_version >= '3.10'",), extras={ "checking": ["idna<3.11", "six==1.16.0", "checker-plugin"]})) - plugin_source = {"git": plugin.remote.as_uri(), "branch": "main", "subdirectory": "python package"} + plugin_source = {"git": plugin.remote.as_uri(), "rev": plugin.main_commit, "subdirectory": "python package"} core["tool"]["uv"] = {"sources": {"checker-plugin": plugin_source}} pair.core.write_files({Path("pyproject.toml"): tomli_w.dumps(core)}) stack = tomllib.loads((pair.core.checkout / "stack/pyproject.toml").read_text()) diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 765ed94cad..0f70bccb00 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -59,6 +59,7 @@ ) from module_layers import PACKAGE, ClientSources, MaterializedClients, StackClient from agent_comms.installer.commands import InstallCommands +from agent_comms.launch_runtime import LaunchRuntime from stored_formats import StoredForm from typing_names import TypingImports, TypingReference from agent_comms.field_codec import FieldCodec, ViewExclusion, boundary_decoder, projected @@ -946,10 +947,11 @@ def checked(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> # Native config plugins import Python too: both readers need the same cut, # not a baseline static path alongside candidate runtime imports. imports = (str(Path(scratch, "src")), *client.import_paths) + runtime = LaunchRuntime.from_interpreter(sys.executable) result = SpawnedProcess.run( - (sys.executable, "-m", *TypeErrors.mypy( + (str(runtime.interpreter), "-m", *TypeErrors.mypy( cache, "--follow-imports=silent", "--ignore-missing-imports", *selected)), - cwd=client.checkout, timeout=600, + cwd=client.checkout, timeout=600, runtime=runtime, env={**os.environ, "MYPYPATH": os.pathsep.join(imports), "PYTHONPATH": os.pathsep.join((*imports, os.environ.get("PYTHONPATH", "")))}, ) @@ -1042,11 +1044,11 @@ def of(cls, tree: ast.AST) -> ScopeNames: 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.""" + def bindings(self, scope: str, before: tuple[ast.AST, ...] = ()) -> dict[str, list[ast.AST]]: + """Bindings in one scope, excluding declarations whose evaluation has not assigned them.""" found: dict[str, list[ast.AST]] = {} for node, names in self.binds.items(): - if self.found[node] == scope: + if node not in before and self.found[node] == scope: for name in names: found.setdefault(name, []).append(node) return found @@ -1482,8 +1484,8 @@ class DecoratorBinding: @classmethod def of(cls, decorator: typing.Callable[..., object], tree: ast.AST, - filename: str, scope: str = "") -> DecoratorBinding: - return cls.named(decorator.__module__, decorator.__name__, tree, filename, scope) + filename: str, scope: str = "", before: tuple[ast.AST, ...] = ()) -> DecoratorBinding: + return cls.named(decorator.__module__, decorator.__name__, tree, filename, scope, before) @classmethod def sites(cls, decorator: typing.Callable[..., object], tree: ast.AST, @@ -1496,7 +1498,7 @@ def sites(cls, decorator: typing.Callable[..., object], tree: ast.AST, match node: case ast.FunctionDef(decorator_list=[_, *_] as decorators) | ast.AsyncFunctionDef( decorator_list=[_, *_] as decorators): - marker = cls.of(decorator, tree, filename, scopes.found[node]) + marker = cls.of(decorator, tree, filename, scopes.found[node], (node,)) if any(map(marker.marks, decorators)): continue yield node @@ -1510,14 +1512,15 @@ def module_of(filename: str) -> tuple[str, ...]: @classmethod def named(cls, owner: str, name: str, tree: ast.AST, filename: str, - scope: str = "") -> DecoratorBinding: + scope: str = "", before: tuple[ast.AST, ...] = ()) -> 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.""" + ``scope``: a spelling whose root that scope rebinds names something else there. + Decorators supply their own not-yet-bound definition; every other binding remains.""" module = cls.module_of(filename) package = module[:-1] names: set[str] = set() body = typing.cast(ast.Module, tree).body - for bound, (declaration, statement) in cls.declarations(tree, "", body).items(): + for bound, (declaration, statement) in cls.declarations(tree, "", body, before).items(): match statement, declaration: case ast.ClassDef() | ast.FunctionDef() | ast.AsyncFunctionDef(), _ if ( ".".join(module) == owner and bound == name): @@ -1531,23 +1534,23 @@ def named(cls, owner: str, name: str, tree: ast.AST, filename: str, names.add(f"{bound}.{name}") if resolved == owner and imported == name: names.add(bound) - hidden = cls.bindings(tree, scope).keys() if scope else frozenset() + hidden = cls.bindings(tree, scope, before).keys() if scope else frozenset() return cls(frozenset(spelling for spelling in names if spelling.split(".")[0] not in hidden)) @staticmethod - def bindings(tree: ast.AST, scope: str) -> dict[str, list[ast.AST]]: + def bindings(tree: ast.AST, scope: str, before: tuple[ast.AST, ...] = ()) -> dict[str, list[ast.AST]]: """Every binding of each name in one lexical module or class scope, as ScopeNames decodes it; bindings inside nested functions and classes are theirs, not this scope's.""" - return ScopeNames.of(tree).bindings(scope) + return ScopeNames.of(tree).bindings(scope, before) @staticmethod def declarations(tree: ast.AST, scope: str, - body: list[ast.stmt]) -> dict[str, tuple[ast.AST, ast.stmt]]: + body: list[ast.stmt], before: tuple[ast.AST, ...] = ()) -> dict[str, tuple[ast.AST, ast.stmt]]: """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) + bindings = names.bindings(scope, before) if "*" in bindings: return {} owners = {child: statement for statement in body From 810c8c9a1e74cd1d7545e0a5d6de84757489cb39 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 19:08:16 -0400 Subject: [PATCH 050/109] Acknowledge verified selected native context during the running turn --- src/agent_comms/native_input_record.py | 8 +++++ src/agent_comms/native_pi.py | 16 +++++++++ src/agent_comms/native_prompt_send.py | 4 +++ src/agent_comms/native_runtime_input.py | 48 +++++++++++++++---------- src/agent_comms/private_send_claim.py | 18 +++++++++- src/agent_comms/private_send_stage.py | 37 ++++++++++--------- src/agent_comms/tracked_turn.py | 2 ++ 7 files changed, 96 insertions(+), 37 deletions(-) diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index 81422f126b..36d12f5c0a 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -52,6 +52,7 @@ from .message_reference import MessageReference from .native_entries import MessageEntry, RecordedEntry, TranscriptProjection from .native_runtime_input import NativeRuntimeInput + from .native_pi import NativeContextProof from .transcript_events import TranscriptEvent @@ -402,6 +403,10 @@ def require_recorded(self) -> NativeInputReference: def require_recorded_input(self, owner: CursorOwner, db: sqlite3.Connection) -> None: """An unrecorded context grants no committed-input identity.""" + def acknowledge_context(self, row: NativeRuntimeInput, db: sqlite3.Connection, context: NativeContextProof) -> None: + """The first verified entry is immutable; later settlement does not own it.""" + row.write_context(db, context) + @dataclass(frozen=True) class UnrecordedNativeInputReference(NativeContextReference): @@ -441,6 +446,9 @@ def acquire(cls, row: NativeInputContext) -> NativeInputReference: except MalformedRecord as error: raise IdentityConflict("Native recorded context reference is incomplete") from error + def acknowledge_context(self, row: NativeRuntimeInput, db: sqlite3.Connection, context: NativeContextProof) -> None: + row.require_context_proof().require_continuation(context) + def require_recorded_input(self, owner: CursorOwner, db: sqlite3.Connection) -> None: owner.require_recorded_reference(db, self) diff --git a/src/agent_comms/native_pi.py b/src/agent_comms/native_pi.py index d439846824..24f992fc18 100644 --- a/src/agent_comms/native_pi.py +++ b/src/agent_comms/native_pi.py @@ -322,6 +322,22 @@ def corroborate( class NativeContextProof(NativeContextRecord, NativeInputLocation): session_file: Path + def require_continuation(self, context: NativeContextProof) -> None: + """Repeated requests may include this input; its first receipt stays immutable. + + A different native entry or conflicting proof of the same request is not + another observation of that entry. Callers corroborate later generations + before this check; this comparison grants no native or replay authority. + """ + if (self.input_commit, self.session_file, self.session_entry_start) != ( + context.input_commit, context.session_file, context.session_entry_start + ): + raise IdentityConflict("Native context acknowledgement belongs to another entry") + if context.request_generation < self.request_generation: + raise IdentityConflict("Native context precedes its original acknowledgement") + if context.request_generation == self.request_generation and context != self: + raise IdentityConflict("Native request has contradictory context evidence") + def corroborates_input( self, input_id: InputId, *, files: NativeSessionFiles, evidence: NativeEvidenceRead, diff --git a/src/agent_comms/native_prompt_send.py b/src/agent_comms/native_prompt_send.py index 8b7bfbc73b..fc9dcb5fa5 100644 --- a/src/agent_comms/native_prompt_send.py +++ b/src/agent_comms/native_prompt_send.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: from .input_drain import InputDrain from .owned_send import OwnedSend + from .native_pi import NativeContextProof from .tracked_turn import TrackedTurnSession _MAX_SEND_SECONDS = 5.0 @@ -35,6 +36,9 @@ class PromptBoundary(ABC): @abstractmethod async def send_prompt(self, turn: TrackedTurnSession) -> None: ... + async def context_committed(self, turn: TrackedTurnSession, context: NativeContextProof) -> None: + """An unowned prompt has no durable recipient acknowledgement to advance.""" + def following_sender(self, inputs: InputDrain, session_id: AcpSessionId, prompt: str) -> OwnedSend: raise RelationViolationError("An unowned prompt cannot authorize following input") diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index d2086fc1bd..c60e7e475d 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -18,7 +18,7 @@ from .coordination_errors import IdentityConflict, PublicationActivationBlocked, StaleFence from .coordination_tables.participants import OwnerGenerations from .coordination_tables.responses import ResponseObligation -from .declared_family import DeclaredFamily +from .declared_family import ClassFact, DeclaredFamily from .field_codec import ( FieldCodec, InputId, @@ -60,7 +60,6 @@ from .tool_denial import SelectedToolDenied from .typed_table import ( Column, - FinalizedColumn, FrozenColumn, IntegerStorage, MonotoneColumns, @@ -99,7 +98,6 @@ class PublishedReplyRevision(TypedRow): @dataclass(frozen=True) class NativeRuntimeInput( WriteOnceColumns, - FinalizedColumn, RequiredUpdateColumns, NoDelete, NativeInputRecord, @@ -134,8 +132,13 @@ def notification_context_sql(cls) -> str: "member.ordinal,n.input_id LIMIT 1) AS context_committed_at_ms FROM w" ) - write_once_columns = ('sent_owner_claimed_generation', 'session_id', 'session_file') - finalized_column = 'session_entry_id' + @ClassFact + def write_once_columns(cls) -> tuple[str, ...]: + # The declared context group is immutable, not the later triage verdict. + return ('sent_owner_claimed_generation', 'session_id', 'session_file', 'verdict', + *(plan.name for plan in FieldCodec.field_plans(cls) + if NativeContext.single(plan.annotation))) + required_update_columns = ('sent_owner_claimed_generation',) @classmethod @@ -320,22 +323,20 @@ def published_replies(self, db, user, owner_lookup): "stage='triage' OR verdict IS NULL", ) - def require_unproven(self, token_digest: str) -> None: - """Only the original reserved capability can acquire its first proof.""" - if self.owner_token_digest != token_digest or self.reference.recorded: - raise StaleFence("native proof belongs to a different or already settled dispatch") - - def commit_context( - self, - db: sqlite3.Connection, - context: NativeContextProof, - *, - verdict: TriageDecisionRecord = AbsentTriageDecisionRecord(), - ) -> None: - """Commit the context acknowledgement once against the original selected session.""" + def require_dispatch(self, token_digest: str) -> None: + """Only the original dispatch may acknowledge context or settle its result.""" + if self.owner_token_digest != token_digest: + raise StaleFence("native proof belongs to a different dispatch") + + def commit_context(self, db: sqlite3.Connection, context: NativeContextProof) -> None: + """Retain the first corroborated context, independently of terminal settlement.""" if context.input_id != self.input_id: raise StaleFence("native context belongs to another reserved input") self.require_session_identity().require_context(context) + self.reference.acknowledge_context(self, db, context) + + def write_context(self, db: sqlite3.Connection, context: NativeContextProof) -> None: + """The unrecorded reference alone takes this first-write path.""" updated = self.update( db, where="input_id=? AND session_entry_id IS NULL", @@ -345,11 +346,20 @@ def commit_context( request_generation=NumberNativeColumn(context.request_generation), llm_context_digest=TextNativeColumn(context.llm_context_digest), committed_at_ms=NumberNativeColumn(context.committed_at_ms), - verdict=verdict, ) if updated.rowcount != 1: raise StaleFence("native proof was previously committed") + def settle_verdict(self, db: sqlite3.Connection, verdict: TriageDecisionRecord) -> None: + """The decision follows context entry; neither transition overwrites the other.""" + current = type(self).require(db, input_id=self.input_id) + current.reference.require_recorded() + current.verdict.require_undecided() + updated = self.update(db, where="input_id=? AND verdict IS NULL", + parameters=(self.input_id,), verdict=verdict) + if updated.rowcount != 1: + raise StaleFence("native triage verdict was previously settled") + class SelectedSlotState(DeclaredFamily, affix="Slot"): """Consumption is irreversible; only a matching live terminal can acknowledge it.""" diff --git a/src/agent_comms/private_send_claim.py b/src/agent_comms/private_send_claim.py index 1e175de8c6..b1b698e729 100644 --- a/src/agent_comms/private_send_claim.py +++ b/src/agent_comms/private_send_claim.py @@ -228,8 +228,24 @@ def settle(store: Coordination, terminal: NativePiTerminalFailure = error): ) return result + async def context_committed(self, turn: TrackedTurnSession, context: NativeContextProof) -> None: + """Publish corroborated entry while the model is still running. + + The context journal is borrowed before coordinator locking, as on the + terminal path. Neither the RPC event nor model activity is a receipt. + """ + async with self.selected.cursor_delivery.reading() as source_reads: + def acknowledge(store: Coordination) -> None: + self.verify(store, context, source_reads) + self.stage.acknowledge_context( + store, self.participant, self.input_id, self.token_digest, context, + ) + await Coordination.run_async(self.store_path, acknowledge) + def commit(self, store: Coordination, context: NativeContextProof) -> None: - self.stage.commit(store, self.participant, self.input_id, self.token_digest, context) + self.stage.acknowledge_context( + store, self.participant, self.input_id, self.token_digest, context, + ) def _start_once(self, identity: NativeSessionIdentity) -> None: """Commit the exact grant under original fences, then release them. diff --git a/src/agent_comms/private_send_stage.py b/src/agent_comms/private_send_stage.py index aced2c1a2f..8ad56e2c28 100644 --- a/src/agent_comms/private_send_stage.py +++ b/src/agent_comms/private_send_stage.py @@ -132,9 +132,21 @@ def pending_input( ).require_valid() except ReservationViolationError as error: raise StaleFence(f"native dispatch identity changed: {error}") from error - row.require_unproven(token_digest) + row.require_dispatch(token_digest) return row + def require_acknowledgement(self, store: Coordination) -> None: + self.require_claim(store) + + def acknowledge_context( + self, store: Coordination, owner: ParticipantOwner, input_id: InputId, + token_digest: str, context: NativeContextProof, + ) -> None: + with store.session.writing() as db: + row = self.pending_input(store, input_id, owner, token_digest) + self.require_acknowledgement(store) + row.commit_context(db, context) + def verify( self, store: Coordination, @@ -311,7 +323,8 @@ def commit( with store.session.writing() as db: row = self.pending_input(store, input_id, owner, token_digest) self.require_claim(store) - row.commit_context(db, context, verdict=DecidedTriageRecord(type(decision))) + row.commit_context(db, context) + row.settle_verdict(db, DecidedTriageRecord(type(decision))) for captured in self.assignments: decision.settle(store, db, store.assignments.get(captured.assignment_id)) # This is the settlement transaction's witness, not a replacement @@ -381,21 +394,11 @@ def reserve_claim(self, store: Coordination, db: sqlite3.Connection) -> None: ): raise IdentityConflict("this execution attempt already has a native input") - def commit( - self, - store: Coordination, - owner: ParticipantOwner, - input_id: InputId, - token_digest: str, - context: NativeContextProof, - ) -> None: - with store.session.writing() as db: - row = self.pending_input(store, input_id, owner, token_digest) - snapshot, _ = store.require_fence(self.fence) - for assignment in self.assignments: - engagement = assignment.lifecycle.require_engagement() - snapshot.require_wire_response(engagement.exact_target) - row.commit_context(db, context) + def require_acknowledgement(self, store: Coordination) -> None: + snapshot, _ = store.require_fence(self.fence) + for assignment in self.assignments: + engagement = assignment.lifecycle.require_engagement() + snapshot.require_wire_response(engagement.exact_target) def require_phase(self, store: Coordination, current: WakeAssignment) -> None: snapshot, attempt = store.require_fence(self.fence) diff --git a/src/agent_comms/tracked_turn.py b/src/agent_comms/tracked_turn.py index a0a117a2cd..0a2d930871 100644 --- a/src/agent_comms/tracked_turn.py +++ b/src/agent_comms/tracked_turn.py @@ -499,6 +499,8 @@ async def accept_user_message( async def committed_context(self, event: pi.ContextCommitted) -> None: if event.input_id == self.command.input_id: self.context_commit = ObservedNativeCommit(event) + context = await self.context_proof() + await self.prompt_send_boundary.context_committed(self, context) async def assistant_started(self) -> None: await super().assistant_started() From 1e2e8b562e2078c69c4329508f5cbcdc8c36cfa7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 19:15:01 -0400 Subject: [PATCH 051/109] Keep harness admission distinct from complete original context evidence --- src/agent_comms/cli_bridges/codex.py | 4 +- src/agent_comms/cli_bridges/opencode.py | 4 +- src/agent_comms/cli_bridges/shared.py | 2 +- .../coordination_tables/harness_deliveries.py | 13 ++- src/agent_comms/harness_delivery_states.py | 92 +++++++++++++++---- src/agent_comms/native_prompt_send.py | 2 +- src/agent_comms/notification_evidence.py | 2 +- src/agent_comms/notification_progress.py | 25 +++-- src/agent_comms/private_send_claim.py | 2 +- .../task_attachments/commander_pings.py | 4 +- src/agent_comms/tracked_turn.py | 2 +- tests/test_codex_earlier_pages.py | 4 +- tests/test_harness_receipt_context.py | 61 ++++++++++++ tests/test_historical_notifications.py | 16 ++-- tests/test_live_context_notifications.py | 86 +++++++++++++++++ 15 files changed, 268 insertions(+), 51 deletions(-) create mode 100644 tests/test_harness_receipt_context.py create mode 100644 tests/test_live_context_notifications.py diff --git a/src/agent_comms/cli_bridges/codex.py b/src/agent_comms/cli_bridges/codex.py index 3fee50a1fa..2a5824e405 100755 --- a/src/agent_comms/cli_bridges/codex.py +++ b/src/agent_comms/cli_bridges/codex.py @@ -17,7 +17,7 @@ from agent_comms.messages import Message from agent_comms.send_result import SendResult from agent_comms.cli_protocol import EarlierItems -from agent_comms.harness_delivery_states import Outcome, DeliveredOutcome +from agent_comms.harness_delivery_states import AcceptedOutcome, Outcome from .history import CodexHistory, HarnessAddress, OnlineHistory from .shared import SessionBinding, IncomingFrame, TranscriptBridge, Options, run_bridge from agent_comms.runtime_updates import ( @@ -133,7 +133,7 @@ async def deliver(self, message: Message) -> type[Outcome]: continue if retries: self.log(f'Codex is back; sent {message.message_id} after {retries} refused attempts') - return DeliveredOutcome + return AcceptedOutcome async def show_sent(self, result: SendResult) -> None: async with CodexConnection.open(self.socket) as codex: diff --git a/src/agent_comms/cli_bridges/opencode.py b/src/agent_comms/cli_bridges/opencode.py index fde2029b1e..3c1fa53827 100755 --- a/src/agent_comms/cli_bridges/opencode.py +++ b/src/agent_comms/cli_bridges/opencode.py @@ -19,7 +19,7 @@ from agent_comms.messages import Message from agent_comms.send_result import SendResult from agent_comms.cli_arguments import CliArguments -from agent_comms.harness_delivery_states import DeliveredOutcome, Outcome +from agent_comms.harness_delivery_states import Outcome, PersistedOutcome from .history import HarnessAddress, OnlineHistory, OpenCodeHistory from .shared import ( @@ -241,7 +241,7 @@ async def transcript(self) -> AsyncIterator[RuntimeUpdate]: async def deliver(self, message: Message) -> type[Outcome]: async with self.connection() as connection: await connection.deliver(IncomingFrame(message).text) - return DeliveredOutcome + return PersistedOutcome async def show_sent(self, result: SendResult) -> None: async with self.connection() as connection: diff --git a/src/agent_comms/cli_bridges/shared.py b/src/agent_comms/cli_bridges/shared.py index ad1b83fa99..7d99b5511c 100644 --- a/src/agent_comms/cli_bridges/shared.py +++ b/src/agent_comms/cli_bridges/shared.py @@ -1645,7 +1645,7 @@ async def read_export(self, read: ReadResultObserved) -> tuple[str, ...]: targets.extend(await part.confirm(self, read.record)) for unread in read.closing(notice): for stored in await self.coordinator_read(exact.stored): - stored.outcome.finished_with(unread) + stored.monitor.finished_with(unread) return tuple(targets) def new_attachment(self, peer: MonitorPeer, token: str) -> MonitorAttachment: diff --git a/src/agent_comms/coordination_tables/harness_deliveries.py b/src/agent_comms/coordination_tables/harness_deliveries.py index 79363a42fb..91888f8036 100644 --- a/src/agent_comms/coordination_tables/harness_deliveries.py +++ b/src/agent_comms/coordination_tables/harness_deliveries.py @@ -23,6 +23,8 @@ if TYPE_CHECKING: from agent_comms.assignment_store import AssignmentStore + from agent_comms.notification_progress import DeliveryProgress + from agent_comms.presentation import MessageNotification @dataclass(frozen=True, slots=True) @@ -72,12 +74,21 @@ def first_write(self, db: sqlite3.Connection) -> bool: self.insert(db) return True + def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: + return self.monitor.consumed_progress(progress) + + def arrival_count(self) -> int: + return self.monitor.arrival_count(self.outcome) + + def arrival_label(self, notification: MessageNotification) -> str: + return self.monitor.arrival_label(self.outcome, notification) + def settle_claims(self, assignments: AssignmentStore, db: sqlite3.Connection) -> None: """What this attempt means for the recipient's claim on its message: the claim left the native queue, then took the steps this outcome proves. Each phase reads the claim as it stands, so a claim accepted after the attempt ends where one that existed before it would; a claim already settled keeps its state.""" - for steps in (lambda state: state.harness_handoff(), self.outcome.claim_steps): + for steps in (lambda state: state.harness_handoff(), self.monitor.claim_steps): assignments.settle_harness_inputs(db, self.recipient_lookup, (self.reference,), steps) def begin(self, assignments: AssignmentStore, db: sqlite3.Connection) -> bool: diff --git a/src/agent_comms/harness_delivery_states.py b/src/agent_comms/harness_delivery_states.py index b5732647c4..35fb3ee065 100644 --- a/src/agent_comms/harness_delivery_states.py +++ b/src/agent_comms/harness_delivery_states.py @@ -20,16 +20,12 @@ from .cli_bridges.shared import LineMonitorBridge, MonitorPart, MonitorReadFile from .coordination_tables.harness_deliveries import HarnessDelivery from .notification_progress import DeliveryProgress + from .presentation import MessageNotification class Outcome(DeclaredFamily, affix="Outcome"): """Stored spellings stay the same; the stored outcome decides what may follow it.""" - @classmethod - def claim_steps(cls, state: AssignmentState) -> tuple[type[AssignmentState], ...]: - """The claim steps this outcome proves; an attempt that proves nothing moves no claim.""" - return () - @classmethod def settle(cls, successor: HarnessDelivery, assignments: AssignmentStore, db: sqlite3.Connection) -> HarnessDelivery: @@ -38,17 +34,23 @@ def settle(cls, successor: HarnessDelivery, assignments: AssignmentStore, return successor @classmethod - def consumed_progress(cls, progress: DeliveryProgress) -> DeliveryProgress: - """The recipient's progress after this attempt; an emitted, unconfirmed or filtered - attempt proves no context entry and leaves it.""" - return progress + def arrival_count(cls) -> int: + return 0 @classmethod - def finished_with(cls, export: MonitorReadFile) -> None: - """An unconsumed body keeps its export for a later Read.""" + def arrival_label(cls, notification: MessageNotification) -> str: + return "Arrival unknown" + + @classmethod + def input_detail(cls) -> str: + return "Attempt is uncertain; no replay is permitted." class FilteredOutcome(Outcome): + @classmethod + def input_detail(cls) -> str: + return "No input was submitted." + @classmethod def settle(cls, successor: HarnessDelivery, assignments: AssignmentStore, db: sqlite3.Connection) -> HarnessDelivery: @@ -62,21 +64,45 @@ class UnconfirmedOutcome(Outcome): class DeliveredOutcome(FilteredOutcome): + """An original harness receipt, not evidence that a model took the body. + + Older generic receipts retain this spelling and their actual monitor proof. + A producer name alone cannot upgrade their evidence to model context. + """ + @classmethod - def claim_steps(cls, state: AssignmentState) -> tuple[type[AssignmentState], ...]: - return state.harness_delivery() + def input_detail(cls) -> str: + return "Harness reported delivery; model context is unproved." + +class AcceptedOutcome(DeliveredOutcome): @classmethod - def consumed_progress(cls, progress: DeliveryProgress) -> DeliveryProgress: - return progress.harness_consumed() + def arrival_count(cls) -> int: + return 1 @classmethod - def finished_with(cls, export: MonitorReadFile) -> None: - export.retire() + def arrival_label(cls, notification: MessageNotification) -> str: + return "Arrived (RPC accepted; time unrecorded)" + + @classmethod + def input_detail(cls) -> str: + return "RPC accepted input; model context proof is unavailable." + + +class PersistedOutcome(AcceptedOutcome): + @classmethod + def arrival_label(cls, notification: MessageNotification) -> str: + return "Arrived (saved input; time unrecorded)" + + @classmethod + def input_detail(cls) -> str: + return "Harness saved input; model context proof is unavailable." class EmittedOutcome(UnconfirmedOutcome): - pass + @classmethod + def input_detail(cls) -> str: + return "Emitted; recipient context remains unconfirmed." @dataclass(frozen=True) @@ -92,6 +118,21 @@ def evidence(self, recorded: type[Outcome]) -> tuple[type[Outcome], MonitorEvide proves nothing leaves the recorded outcome.""" return recorded, self + def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: + return progress + + def claim_steps(self, state: AssignmentState) -> tuple[type[AssignmentState], ...]: + return () + + def arrival_count(self, outcome: type[Outcome]) -> int: + return outcome.arrival_count() + + def arrival_label(self, outcome: type[Outcome], notification: MessageNotification) -> str: + return outcome.arrival_label(notification) + + def finished_with(self, export: MonitorReadFile) -> None: + """A body without a complete original transcript proof keeps its export.""" + @dataclass(frozen=True) class UnrecordedMonitor(MonitorEvidence): @@ -131,3 +172,18 @@ class ObservedMonitor(MonitorEvidence): def evidence(self, recorded: type[Outcome]) -> tuple[type[Outcome], MonitorEvidence]: """Only a complete, matching confirmation stores this, so the message entered context.""" return DeliveredOutcome, self + + def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: + return progress.harness_consumed(round(self.timestamp * 1000)) + + def claim_steps(self, state: AssignmentState) -> tuple[type[AssignmentState], ...]: + return state.harness_delivery() + + def arrival_count(self, outcome: type[Outcome]) -> int: + return 1 + + def arrival_label(self, outcome: type[Outcome], notification: MessageNotification) -> str: + return notification.timed_state("Arrived", round(self.timestamp * 1000)) + + def finished_with(self, export: MonitorReadFile) -> None: + export.retire() diff --git a/src/agent_comms/native_prompt_send.py b/src/agent_comms/native_prompt_send.py index fc9dcb5fa5..8c2b369eb5 100644 --- a/src/agent_comms/native_prompt_send.py +++ b/src/agent_comms/native_prompt_send.py @@ -36,7 +36,7 @@ class PromptBoundary(ABC): @abstractmethod async def send_prompt(self, turn: TrackedTurnSession) -> None: ... - async def context_committed(self, turn: TrackedTurnSession, context: NativeContextProof) -> None: + async def context_committed(self, context: NativeContextProof) -> None: """An unowned prompt has no durable recipient acknowledgement to advance.""" def following_sender(self, inputs: InputDrain, session_id: AcpSessionId, prompt: str) -> OwnedSend: diff --git a/src/agent_comms/notification_evidence.py b/src/agent_comms/notification_evidence.py index 8920b89c1d..e24a2317a1 100644 --- a/src/agent_comms/notification_evidence.py +++ b/src/agent_comms/notification_evidence.py @@ -35,7 +35,7 @@ def consumed_progress(self, recipient: ThreadLookup, progress: DeliveryProgress) -> DeliveryProgress: """The recipient's progress after each of its own attempts on this original.""" for attempt in self.attempts.get(recipient, ()): - progress = attempt.outcome.consumed_progress(progress) + progress = attempt.consumed_progress(progress) return progress diff --git a/src/agent_comms/notification_progress.py b/src/agent_comms/notification_progress.py index 81925885a9..b2d4efdda6 100644 --- a/src/agent_comms/notification_progress.py +++ b/src/agent_comms/notification_progress.py @@ -72,9 +72,9 @@ def counted_summary_prefix(self, notification: MessageNotification) -> tuple[str """The lead a counted wrapper prints; a consumed proof is already named by its count.""" return (notification.label,) - def harness_consumed(self) -> DeliveryProgress: - """This progress once a harness confirmed the message entered its session.""" - return HarnessConsumedProgress() + def harness_consumed(self, observed_at_ms: int) -> DeliveryProgress: + """Retain the original complete-body transcript observation time.""" + return HarnessConsumedProgress(observed_at_ms) class ConsumedDelivery(SettledDelivery): @@ -84,7 +84,7 @@ class ConsumedDelivery(SettledDelivery): ignored_label: ClassVar[str] = "Seen, not answering" consumed: ClassVar[bool] = True - def harness_consumed(self) -> Self: + def harness_consumed(self, observed_at_ms: int) -> Self: """An earlier proof already timed the entry; a harness confirmation adds nothing.""" return self @@ -95,11 +95,6 @@ def counted_summary_prefix(self, notification: MessageNotification) -> tuple[str return () -@dataclass(frozen=True) -class HarnessConsumedProgress(ConsumedDelivery, DeliveryProgress): - """A harness confirmed context entry with no claim to time it; the attempt has no clock.""" - - @dataclass(frozen=True) class UnrecordedProgress(WaitingDelivery, DeliveryProgress): """No recipient delivery has been recorded.""" @@ -121,16 +116,16 @@ class ContextualProgress(WaitingDelivery, DeliveryProgress): def consumed(self) -> bool: return self.proof.consumed - def harness_consumed(self) -> DeliveryProgress: - return replace(self, proof=self.proof.harness_consumed()) + def harness_consumed(self, observed_at_ms: int) -> DeliveryProgress: + return replace(self, proof=self.proof.harness_consumed(observed_at_ms)) class PendingProgress(ContextualProgress): """A pending claim adds no label of its own: once a harness proves entry, the proof is the whole state.""" - def harness_consumed(self) -> DeliveryProgress: - return self.proof.harness_consumed() + def harness_consumed(self, observed_at_ms: int) -> DeliveryProgress: + return self.proof.harness_consumed(observed_at_ms) class PassiveProgress(ContextualProgress): @@ -263,6 +258,10 @@ def consumed_notification(self, notification: MessageNotification) -> MessageNot return replace(notification, state=self) +class HarnessConsumedProgress(ShownToModelProgress): + """Complete original body observed in the harness transcript, at its recorded time.""" + + class SelectedConsumedProgress(ActiveDelivery, ShownToModelProgress): pass diff --git a/src/agent_comms/private_send_claim.py b/src/agent_comms/private_send_claim.py index b1b698e729..35f77df1d2 100644 --- a/src/agent_comms/private_send_claim.py +++ b/src/agent_comms/private_send_claim.py @@ -228,7 +228,7 @@ def settle(store: Coordination, terminal: NativePiTerminalFailure = error): ) return result - async def context_committed(self, turn: TrackedTurnSession, context: NativeContextProof) -> None: + async def context_committed(self, context: NativeContextProof) -> None: """Publish corroborated entry while the model is still running. The context journal is borrowed before coordinator locking, as on the diff --git a/src/agent_comms/task_attachments/commander_pings.py b/src/agent_comms/task_attachments/commander_pings.py index e3a35ec684..c9838e7f45 100644 --- a/src/agent_comms/task_attachments/commander_pings.py +++ b/src/agent_comms/task_attachments/commander_pings.py @@ -17,7 +17,7 @@ from agent_comms.channel_targets import ThreadTarget from agent_comms.declared_family import DeclaredFamily -from agent_comms.harness_delivery_states import DeliveredOutcome, Outcome +from agent_comms.harness_delivery_states import FilteredOutcome, Outcome from agent_comms.commander_service import CommanderService from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec, OrderId @@ -326,7 +326,7 @@ class CommanderNotice(CommanderUpdate): async def wake(self, bridge: ClaudeBridge, message: Message) -> type[Outcome]: """Cleared lines leave the board; nothing new needs the session.""" - return DeliveredOutcome + return FilteredOutcome def lines(self, now: float) -> tuple[str, ...]: return (f"Cleared · {len(self.handled)} requests remain on the board.", *self.not_applied) diff --git a/src/agent_comms/tracked_turn.py b/src/agent_comms/tracked_turn.py index 0a2d930871..969f472fd4 100644 --- a/src/agent_comms/tracked_turn.py +++ b/src/agent_comms/tracked_turn.py @@ -500,7 +500,7 @@ async def committed_context(self, event: pi.ContextCommitted) -> None: if event.input_id == self.command.input_id: self.context_commit = ObservedNativeCommit(event) context = await self.context_proof() - await self.prompt_send_boundary.context_committed(self, context) + await self.prompt_send_boundary.context_committed(context) async def assistant_started(self) -> None: await super().assistant_started() diff --git a/tests/test_codex_earlier_pages.py b/tests/test_codex_earlier_pages.py index 84a0c507f8..a60e0f258e 100644 --- a/tests/test_codex_earlier_pages.py +++ b/tests/test_codex_earlier_pages.py @@ -165,7 +165,7 @@ def test_hidden_page_items_keep_import_events_but_own_no_visible_events(tmp_path async def test_draining_server_gets_the_same_turn_after_reconnecting(tmp_path): """Codex refuses a turn while draining and asks for it again; the input is delivered, not lost.""" - from agent_comms.cli_protocol import DeliveredOutcome + from agent_comms.harness_delivery_states import AcceptedOutcome from agent_comms.messages import InfoKind, Message turns = [] @@ -202,7 +202,7 @@ async def serve(request): options = CodexOptions(Watch(), StoredBinding(), tmp_path / 'bridge', tmp_path, str(socket_path)) bridge = CodexBridge(options, binding.address(), db) message = Message(sender='sender', target='cli', body='keep going', type=InfoKind) - assert await bridge.deliver(message) is DeliveredOutcome + assert await bridge.deliver(message) is AcceptedOutcome assert len(turns) == 2 and turns[0] == turns[1] assert turns[0]['clientUserMessageId'] == f'comms:{message.message_id}' finally: diff --git a/tests/test_harness_receipt_context.py b/tests/test_harness_receipt_context.py new file mode 100644 index 0000000000..c8e9b30f7d --- /dev/null +++ b/tests/test_harness_receipt_context.py @@ -0,0 +1,61 @@ +"""Interpret actual persisted receipt variants without starting a harness/model.""" +from dataclasses import replace + +import pytest + +from agent_comms.cli_bridges.claude import ClaudeBridge +from agent_comms.coordination_tables.harness_deliveries import HarnessDelivery +from agent_comms.coordinator import Coordination +from agent_comms.field_codec import TranscriptRecordId +from agent_comms.harness_delivery_states import ( + AcceptedOutcome, DeliveredOutcome, EmittedOutcome, FilteredOutcome, + ObservedMonitor, PersistedOutcome, UnconfirmedOutcome, UnrecordedMonitor, +) +from agent_comms.notification_progress import HarnessConsumedProgress +from agent_comms.presentation import AllOutcomes +from test_coordinated_runtime import _root, tmp_path # noqa: F401 + + +@pytest.mark.parametrize(('outcome', 'monitor', 'consumed', 'arrived'), ( + (UnconfirmedOutcome, UnrecordedMonitor(), False, 0), + (EmittedOutcome, UnrecordedMonitor(), False, 0), + (FilteredOutcome, UnrecordedMonitor(), False, 0), + (DeliveredOutcome, UnrecordedMonitor(), False, 0), + (AcceptedOutcome, UnrecordedMonitor(), False, 1), + (PersistedOutcome, UnrecordedMonitor(), False, 1), + (DeliveredOutcome, ObservedMonitor(TranscriptRecordId('complete-original'), 1791964800.125), True, 1), +)) +def test_only_retained_complete_body_proof_promotes_consumption( + tmp_path, outcome, monitor, consumed, arrived, +): + """This exercises projection of recorded evidence, not transcript acquisition. + + Complete body/sender/part acquisition is protected by test_monitor_delivery; + an unproved old Delivered row deliberately stays unproved here. + """ + _path, _root_id, comms, initial, _people = _root(tmp_path, direct=True) + source = initial.message + recipient, = comms.bus.bus.delivery(source.seq).recipients + attempt = HarnessDelivery(ClaudeBridge, recipient.recipient_lookup, + source.message_id, source.seq, UnconfirmedOutcome) + with Coordination(comms.root / 'coordination.sqlite3') as store: + with store.session.writing() as db: + assert attempt.begin(store.assignments, db) + recorded = attempt.apply( + lambda row: replace(row, outcome=outcome, monitor=monitor), + store.assignments, db, + ) + with store.session.writing() as db: + assert not attempt.begin(store.assignments, db) + retained, = recorded.stored(db) + assert retained == recorded + current, = comms.views.message_notifications_for_references( + AllOutcomes(), (source.reference,), + ).outcomes[source.seq, source.message_id] + assert current.state.consumed is consumed + assert current.answer is None + assert not current.displayed_to + assert recorded.arrival_count() == arrived + assert recorded.monitor == monitor + if consumed: + assert current.label == current.timed_state(HarnessConsumedProgress.label, 1791964800125) diff --git a/tests/test_historical_notifications.py b/tests/test_historical_notifications.py index 6c18bb27d0..230641afe0 100644 --- a/tests/test_historical_notifications.py +++ b/tests/test_historical_notifications.py @@ -26,9 +26,9 @@ from agent_comms.coordination_database import CoordinationStore from agent_comms.coordination_tables.harness_deliveries import HarnessDelivery from agent_comms.coordinator import Coordination -from agent_comms.harness_delivery_states import DeliveredOutcome +from agent_comms.harness_delivery_states import DeliveredOutcome, ObservedMonitor from agent_comms.coordination_errors import DMScopeChanged, SchemaVersionError -from agent_comms.field_codec import InputId, MessageId, TurnId +from agent_comms.field_codec import InputId, MessageId, TranscriptRecordId, TurnId from agent_comms.read_basis import HistoricalDisplay, ReadCursor from agent_comms.read_ledger import ReadLedger from agent_comms.thread_identity import AgentThreadRole, UserThreadRole @@ -108,7 +108,8 @@ def test_harness_consumption_survives_human_display(archived_notification): with Coordination(str(CoordinationStore.path_in(live.root))) as store, \ store.session.writing() as db: attempt = HarnessDelivery(ClaudeBridge, recipient.recipient_lookup, shown.message_id, shown.seq, - DeliveredOutcome) + DeliveredOutcome, ObservedMonitor( + TranscriptRecordId('complete-original'), shown.timestamp)) assert attempt.begin(store.assignments, db) attempt.record(store.assignments, db) @@ -122,7 +123,8 @@ def human_display(): shown, human.incarnation, live.registry.snapshot(), document=None) consumed = notification() - assert consumed.label == "Consumed" and consumed.state.consumed and not consumed.displayed_to + assert consumed.state.consumed and not consumed.displayed_to + assert consumed.label == consumed.timed_state('Consumed', round(shown.timestamp * 1000)) ledger = live.bus.reads.read() assert human_display() == () # The UI's "mark read" on the agent's thread, as the registered human viewer. @@ -478,13 +480,15 @@ def test_live_cli_ack_does_not_become_archived_ack(archived_notification): store.session.writing() as db: for message in unread: attempt = HarnessDelivery(ClaudeBridge, lookup, message.message_id, message.seq, - DeliveredOutcome) + DeliveredOutcome, ObservedMonitor( + TranscriptRecordId('complete-original'), message.timestamp)) assert attempt.begin(store.assignments, db) attempt.record(store.assignments, db) current, = old.views.message_notifications_for_references(AllOutcomes(), (messages[0].reference,)).outcomes[ messages[0].seq, messages[0].message_id ] - assert current.label == "Consumed" and current.state.consumed and not current.displayed_to + assert current.state.consumed and not current.displayed_to + assert current.label == current.timed_state('Consumed', round(messages[0].timestamp * 1000)) with live.bus.history.read_sources() as sources: archived, = source.notification_references(sources, (messages[0].reference,), AllOutcomes()).outcomes[ messages[0].seq, messages[0].message_id diff --git a/tests/test_live_context_notifications.py b/tests/test_live_context_notifications.py new file mode 100644 index 0000000000..e076838ff1 --- /dev/null +++ b/tests/test_live_context_notifications.py @@ -0,0 +1,86 @@ +"""Real reserved inputs, source files and journals; no native/provider substitute.""" +from dataclasses import replace +from pathlib import Path + +import pytest + +from agent_comms.coordination_errors import IdentityConflict +from agent_comms.coordinator import Coordination +from agent_comms.native_pi import NativeContextProof +from agent_comms.native_runtime_input import NativeRuntimeInput +from agent_comms.presentation import AllOutcomes +from agent_comms.private_send_claim import PrivateSendClaim +from agent_comms.text_digest import TextDigest +from native_proof_cases import append_input_entry, recorded_context, write_proof_rows +from test_coordinated_runtime import tmp_path # noqa: F401 +from test_native_failure_recovery import SentHistory, prepared_native_input + + +def recorded_input_context(claim: PrivateSendClaim) -> NativeContextProof: + """Record the actual reserved prompt at its selected session's byte position.""" + session, participant = claim.session, claim.selected + SentHistory.write(claim, session, participant) + identity = session.identity + entry = 'input-before-answer' + offset = append_input_entry(identity.path, { + 'type': 'message', 'id': entry, + 'timestamp': '2026-10-01T00:00:00.000Z', + 'message': {'role': 'user', 'inputId': claim.input_id, + 'inputDigest': TextDigest.of(claim.prompt.text).value, + 'content': [{'type': 'text', 'text': claim.prompt.text}]}, + }) + record = { + 'schema': 2, 'type': 'context_committed', + 'inputId': claim.input_id, 'sessionId': identity.session_id, + 'sessionEntryId': entry, 'requestGeneration': 1, + 'llmContextDigest': 'f' * 64, 'committedAtMs': 1_791_964_800_001, + } + write_proof_rows(session.files(participant.comms.root), (record,)) + return recorded_context(identity.path, record, session_entry_start=offset) + + +async def test_reserved_context_notifies_before_any_assistant_reply(tmp_path: Path): + async with prepared_native_input(tmp_path) as claim: + context = recorded_input_context(claim) + comms = claim.selected.comms + originals = tuple(row.source for row in claim.stage.assignments) + before = comms.views.message_notifications_for_references(AllOutcomes(), originals) + before_rows = tuple(row for rows in before.outcomes.values() for row in rows) + assert before_rows and all(not row.state.consumed for row in before_rows) + + await claim.context_committed(context) + + after = comms.views.message_notifications_for_references(AllOutcomes(), originals) + after_rows = tuple(row for rows in after.outcomes.values() for row in rows) + assert after_rows and all(row.state.consumed for row in after_rows) + assert all(row.answer is None for row in after_rows) + assert all(not row.displayed_to for row in after_rows) + assert claim.stage.progress.current.starting + with Coordination(claim.store_path) as store: + original = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) + assert original.require_context_proof() == context + claim.commit(store, context) + claim.commit(store, context) + retained = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) + assert retained == original + + +async def test_same_request_contradiction_cannot_replace_context_receipt(tmp_path: Path): + async with prepared_native_input(tmp_path) as claim: + context = recorded_input_context(claim) + await claim.context_committed(context) + with Coordination(claim.store_path) as store: + with pytest.raises(IdentityConflict): + claim.commit(store, replace(context, llm_context_digest='e' * 64)) + retained = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) + assert retained.require_context_proof() == context + + +async def test_uncorroborated_live_context_never_grants_consumption(tmp_path: Path): + async with prepared_native_input(tmp_path) as claim: + context = recorded_input_context(claim) + with pytest.raises(IdentityConflict): + await claim.context_committed(replace(context, llm_context_digest='e' * 64)) + with Coordination(claim.store_path) as store: + retained = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) + assert not retained.reference.recorded From d170348f8f0de2d620a4f6a6b9681d32f1dc78a7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 19:20:12 -0400 Subject: [PATCH 052/109] Import ClassFact from its declaration --- src/agent_comms/native_runtime_input.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index c60e7e475d..cb39a9193d 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -18,7 +18,8 @@ from .coordination_errors import IdentityConflict, PublicationActivationBlocked, StaleFence from .coordination_tables.participants import OwnerGenerations from .coordination_tables.responses import ResponseObligation -from .declared_family import ClassFact, DeclaredFamily +from .class_fact import ClassFact +from .declared_family import DeclaredFamily from .field_codec import ( FieldCodec, InputId, From 1fd3f86c0dc35d567011bed6e3d195e8431bf6f2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 19:25:46 -0400 Subject: [PATCH 053/109] Keep wake-only Monitor observations outside original-body proof --- src/agent_comms/cli_bridges/claude.py | 6 +++-- src/agent_comms/cli_bridges/monitor.py | 14 ++++++---- src/agent_comms/cli_bridges/shared.py | 15 +++++++---- src/agent_comms/harness_delivery_states.py | 30 +++++++++++++++++++++- tests/test_monitor_delivery.py | 30 +++++++++++++++++++++- 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/agent_comms/cli_bridges/claude.py b/src/agent_comms/cli_bridges/claude.py index f862f1c15e..2596f31573 100755 --- a/src/agent_comms/cli_bridges/claude.py +++ b/src/agent_comms/cli_bridges/claude.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, AsyncIterator, BinaryIO, ClassVar from agent_comms.bus import Bus -from agent_comms.harness_delivery_states import Outcome +from agent_comms.harness_delivery_states import Outcome, WakeOnlyMonitor from agent_comms.messages import Message from agent_comms.private_path import FileRevision from agent_comms.runtime_updates import RuntimeUpdate @@ -58,7 +58,9 @@ async def deliver_board(self, update: CommanderUpdate, message: Message) -> type return await update.wake(self, message) async def wake(self, message: Message) -> type[Outcome]: - return await self.deliver(replace(message, body=self.board_wake)) + return await self.deliver( + replace(message, body=self.board_wake), reception=WakeOnlyMonitor, + ) def history_start(self, stream: BinaryIO, boundary: int) -> int: """On restart only the last 4 MiB is published again; Core's finish is idempotent, so diff --git a/src/agent_comms/cli_bridges/monitor.py b/src/agent_comms/cli_bridges/monitor.py index d0e7c1d594..29d737f815 100644 --- a/src/agent_comms/cli_bridges/monitor.py +++ b/src/agent_comms/cli_bridges/monitor.py @@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Annotated, BinaryIO, ClassVar, NoReturn, get_args, get_origin from agent_comms.child_process import ProcessIdentity -from agent_comms.harness_delivery_states import Outcome +from agent_comms.harness_delivery_states import Outcome, PendingMonitor from agent_comms.declared_family import DeclaredFamily from agent_comms.installer.runtime_wire import CrossesRuntimes from agent_comms.field_codec import FieldCodec, MalformedRecord, MessageId, PathText @@ -124,9 +124,11 @@ async def prepare_delivery(self, bridge: LineMonitorBridge) -> AttachedReader: await bridge.reader_changed.wait_for(lambda: bridge.reader is not self) return await bridge.reader.prepare_delivery(bridge) - async def deliver(self, bridge: LineMonitorBridge, message: Message) -> type[Outcome]: + async def deliver( + self, bridge: LineMonitorBridge, message: Message, reception: type[PendingMonitor], + ) -> type[Outcome]: reader = await self.prepare_delivery(bridge) - return await reader.deliver(bridge, message) + return await reader.deliver(bridge, message, reception) async def confirm_probe(self, bridge: LineMonitorBridge, probe: MonitorProbe) -> None: """Only an unproven attachment consumes a probe.""" @@ -215,8 +217,10 @@ async def confirm_probe(self, bridge: LineMonitorBridge, probe: MonitorProbe) -> async def part_observed(self, message_id: MessageId, number: int) -> None: await self.attachment.observed(message_id, number) - async def deliver(self, bridge: LineMonitorBridge, message: Message) -> type[Outcome]: - return await bridge.deliver_to(self.attachment, message) + async def deliver( + self, bridge: LineMonitorBridge, message: Message, reception: type[PendingMonitor], + ) -> type[Outcome]: + return await bridge.deliver_to(self.attachment, message, reception) @dataclass(frozen=True) diff --git a/src/agent_comms/cli_bridges/shared.py b/src/agent_comms/cli_bridges/shared.py index 7d99b5511c..b490fadb7a 100644 --- a/src/agent_comms/cli_bridges/shared.py +++ b/src/agent_comms/cli_bridges/shared.py @@ -43,7 +43,7 @@ from agent_comms.cli_protocol import TaggedWire, WatchLine, raise_child_exit from agent_comms.harness_delivery_states import ( EmittedOutcome, FilteredOutcome, MismatchedMonitor, ObservedMonitor, - Outcome, ReceivingMonitor, UnconfirmedOutcome, + Outcome, PendingMonitor, ReceivingMonitor, UnconfirmedOutcome, ) from agent_comms.comms import Comms, wire from agent_comms.coordination_errors import ( @@ -1651,12 +1651,17 @@ async def read_export(self, read: ReadResultObserved) -> tuple[str, ...]: def new_attachment(self, peer: MonitorPeer, token: str) -> MonitorAttachment: return MonitorAttachment(peer, MonitorProbe(session=self.binding.thread, token=token)) - async def deliver(self, message: messages.Message) -> type[Outcome]: - return await self.reader.deliver(self, message) + async def deliver( + self, message: messages.Message, *, reception: type[PendingMonitor] = ReceivingMonitor, + ) -> type[Outcome]: + return await self.reader.deliver(self, message, reception) - async def deliver_to(self, attachment: MonitorAttachment, message: messages.Message) -> type[Outcome]: + async def deliver_to( + self, attachment: MonitorAttachment, message: messages.Message, + reception: type[PendingMonitor], + ) -> type[Outcome]: frame = self.incoming_frame(message) - receiving = partial(replace, monitor=ReceivingMonitor(len(frame.parts))) + receiving = partial(replace, monitor=reception.for_parts(len(frame.parts))) attempt = self.attempt(message, UnconfirmedOutcome) await self.coordinator_write(partial(attempt.apply, receiving)) await frame.emit_to(attachment, self.window_bytes, self.exports) diff --git a/src/agent_comms/harness_delivery_states.py b/src/agent_comms/harness_delivery_states.py index 35fb3ee065..92460f2acd 100644 --- a/src/agent_comms/harness_delivery_states.py +++ b/src/agent_comms/harness_delivery_states.py @@ -7,6 +7,7 @@ from __future__ import annotations import sqlite3 +from abc import abstractmethod from dataclasses import dataclass, replace from functools import partial from typing import TYPE_CHECKING @@ -139,10 +140,37 @@ class UnrecordedMonitor(MonitorEvidence): pass +class PendingMonitor(MonitorEvidence, declared_name=None): + """The emission owns whether its later observation can prove the original body.""" + + @classmethod + @abstractmethod + def for_parts(cls, total: int) -> PendingMonitor: ... + + @dataclass(frozen=True) -class ReceivingMonitor(MonitorEvidence): +class WakeOnlyMonitor(PendingMonitor): + """A board wake is not the original packet, even if its bytes happen to match.""" + + @classmethod + def for_parts(cls, total: int) -> WakeOnlyMonitor: + return cls() + + async def confirm(self, bridge: LineMonitorBridge, delivery: HarnessDelivery, + notification: MonitorPart, record: ObservedMonitor) -> tuple[str, ...]: + # Retire only the wake's transport reservation, never original-body proof. + await bridge.part_observed(delivery.message_id, notification.number) + return () + + +@dataclass(frozen=True) +class ReceivingMonitor(PendingMonitor): total: int + @classmethod + def for_parts(cls, total: int) -> ReceivingMonitor: + return cls(total) + async def confirm(self, bridge: LineMonitorBridge, delivery: HarnessDelivery, notification: MonitorPart, record: ObservedMonitor) -> tuple[str, ...]: if notification.total != self.total: diff --git a/tests/test_monitor_delivery.py b/tests/test_monitor_delivery.py index 12518c2c05..e13b3e1b0d 100644 --- a/tests/test_monitor_delivery.py +++ b/tests/test_monitor_delivery.py @@ -39,8 +39,9 @@ ) from agent_comms.harness_delivery_states import ( DeliveredOutcome, EmittedOutcome, MismatchedMonitor, ObservedMonitor, ReceivingMonitor, - UnconfirmedOutcome, UnrecordedMonitor, + UnconfirmedOutcome, UnrecordedMonitor, WakeOnlyMonitor, ) +from agent_comms.notification_progress import SentProgress from agent_comms.agent_inbox import TakenInputStart from agent_comms.errors import RelationViolationError from agent_comms.read_ledger import ReadLedger @@ -536,6 +537,33 @@ async def test_a_body_that_fits_one_event_goes_inline_and_exports_nothing(bridge assert not bridge.exports.exists() +@pytest.mark.asyncio +@pytest.mark.parametrize('original_body', ('Original board packet', ClaudeBridge.board_wake)) +async def test_board_wake_observation_cannot_confirm_original_packet(bridge, original_body): + """Exercise the actual wake transport, including an accidental exact-body collision.""" + message = bridge.comms.messaging.send('peer', 'viewer', original_body).message + human_reads = ReadLedger.at(bridge.comms.root).read() + initial = bridge.attempt(message, UnconfirmedOutcome) + assert await bridge.coordinator_write(initial.begin) + async with attached_monitor(bridge) as reader: + outcome = await bridge.wake(message) + line = await only_event(reader) + assert ClaudeMonitorFrame.decode_line(line).body == ClaudeBridge.board_wake + await bridge.finish(bridge.attempt(message, outcome), partial(replace, outcome=outcome)) + # Re-reading the wake cannot manufacture a proof of the original body. + for _ in range(2): + await observe(bridge, notification(line).updates(ClaudeSession())) + attached = await bridge.reader.prepare_delivery(bridge) + assert attached.attachment.pending_parts == {} + retained, = await bridge.attempts(message.message_id) + assert retained.outcome is EmittedOutcome + assert retained.monitor == WakeOnlyMonitor() + assert not retained.consumed_progress(SentProgress(round(message.timestamp * 1000))).consumed + assert await bridge.coordinator_read(lambda db: HarnessObservedPart.of(db, retained)) == () + assert not await bridge.coordinator_write(initial.begin) + assert ReadLedger.at(bridge.comms.root).read() == human_reads + + @pytest.mark.asyncio @pytest.mark.parametrize('parts', (2, 8, 23, 25, 46, 26)) async def test_a_larger_message_is_one_notice_and_one_whole_read_confirms_it(bridge, parts): From aa249db175877f55170bb7c3b79a3daf65a5780a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 19:29:53 -0400 Subject: [PATCH 054/109] Cover grouped context acknowledgement and foreign input refusal --- tests/test_live_context_notifications.py | 20 ++++++++++++++++---- tests/test_native_failure_recovery.py | 7 ++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/test_live_context_notifications.py b/tests/test_live_context_notifications.py index e076838ff1..bb5bedb817 100644 --- a/tests/test_live_context_notifications.py +++ b/tests/test_live_context_notifications.py @@ -1,4 +1,5 @@ """Real reserved inputs, source files and journals; no native/provider substitute.""" +from collections.abc import Callable from dataclasses import replace from pathlib import Path @@ -6,6 +7,7 @@ from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordinator import Coordination +from agent_comms.field_codec import InputId from agent_comms.native_pi import NativeContextProof from agent_comms.native_runtime_input import NativeRuntimeInput from agent_comms.presentation import AllOutcomes @@ -39,11 +41,15 @@ def recorded_input_context(claim: PrivateSendClaim) -> NativeContextProof: return recorded_context(identity.path, record, session_entry_start=offset) -async def test_reserved_context_notifies_before_any_assistant_reply(tmp_path: Path): - async with prepared_native_input(tmp_path) as claim: +@pytest.mark.parametrize('source_count', (1, 3)) +async def test_reserved_context_notifies_before_any_assistant_reply(tmp_path: Path, source_count: int): + async with prepared_native_input( + tmp_path, source_bodies=tuple(f'Grouped source {index}' for index in range(source_count - 1)), + ) as claim: context = recorded_input_context(claim) comms = claim.selected.comms originals = tuple(row.source for row in claim.stage.assignments) + assert len(originals) == source_count before = comms.views.message_notifications_for_references(AllOutcomes(), originals) before_rows = tuple(row for rows in before.outcomes.values() for row in rows) assert before_rows and all(not row.state.consumed for row in before_rows) @@ -76,11 +82,17 @@ async def test_same_request_contradiction_cannot_replace_context_receipt(tmp_pat assert retained.require_context_proof() == context -async def test_uncorroborated_live_context_never_grants_consumption(tmp_path: Path): +@pytest.mark.parametrize('corrupt', ( + lambda context: replace(context, llm_context_digest='e' * 64), + lambda context: replace(context, input_id=InputId('00000000000000000000000000000001')), +)) +async def test_uncorroborated_live_context_never_grants_consumption( + tmp_path: Path, corrupt: Callable[[NativeContextProof], NativeContextProof], +): async with prepared_native_input(tmp_path) as claim: context = recorded_input_context(claim) with pytest.raises(IdentityConflict): - await claim.context_committed(replace(context, llm_context_digest='e' * 64)) + await claim.context_committed(corrupt(context)) with Coordination(claim.store_path) as store: retained = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) assert not retained.reference.recorded diff --git a/tests/test_native_failure_recovery.py b/tests/test_native_failure_recovery.py index 30386eccb9..be836145da 100644 --- a/tests/test_native_failure_recovery.py +++ b/tests/test_native_failure_recovery.py @@ -28,6 +28,7 @@ from agent_comms.selected_write_authority import NoSelectedWritePlans from agent_comms.text_digest import TextDigest from native_proof_cases import append_input_entry, recorded_context, write_proof_rows +from conftest import accept_initial, publish_initial from test_coordinated_runtime import _root, tmp_path # noqa: F401 @@ -115,10 +116,14 @@ def record(cls, claim, session, participant): @asynccontextmanager async def prepared_native_input( - directory: Path, *, max_attempts: int = 1, + directory: Path, *, max_attempts: int = 1, source_bodies: tuple[str, ...] = (), ) -> AsyncIterator[PrivateSendClaim]: """Keep the real selected attempt and its bound input open for an owner operation.""" root, root_id, comms, _initial, _people = _root(directory, direct=True) + for body in source_bodies: + message = publish_initial(comms, 'sender', 'beta', body) + with Coordination(root / 'coordination.sqlite3') as store: + accept_initial(comms.bus, root_id, message.seq, store) with closing(CursorDelivery()) as cursor_delivery: with Coordination(root / "coordination.sqlite3") as store: async with SelectedParticipant.select( From 4ccbcf8f51ff2ab7f29b89ce516175638607e883 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 19:30:19 -0400 Subject: [PATCH 055/109] Remove unused changed-path contract from client source policy --- tools/debt_ratchet.py | 4 ++-- tools/module_layers.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 0f70bccb00..330944017c 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -963,7 +963,7 @@ def checked(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> def parts(cls, repo: Path, base: str, head: str, changed: set[str], root: str, clients: ClientSources = ClientSources()) -> tuple[Callable[[], object], ...]: # A matched resource also owns client-only changes, even with an unchanged Core patch. - if not any(path.startswith(f"src/{PACKAGE}/") for path in changed) and not clients.requires_clients(changed): + if not any(path.startswith(f"src/{PACKAGE}/") for path in changed) and not clients.requires_clients(): return () return tuple(partial(cls.findings, repo, side.ref(base, head), client, side) for side in Side.sides() @@ -3171,7 +3171,7 @@ def compare(repo: Path, base: str, head: str, root: str, workers: int, introduced: dict[FileCount, list[int]] = {} # Ordinary comparisons need a touched production path. Matched inputs can change # an external client while leaving the local Core patch unchanged. - if not touched & (production_paths(repo, base) | production_paths(repo, head)) and not clients.requires_clients(touched): + if not touched & (production_paths(repo, base) | production_paths(repo, head)) and not clients.requires_clients(): return Comparison(root, base, head, (), before, after, introduced, {}) # A pass of a shared runner is measured once, by that runner. diff --git a/tools/module_layers.py b/tools/module_layers.py index 16bca49c46..0712e7906e 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -387,7 +387,7 @@ def imports(self) -> tuple[ClientImport, ...]: class ClientSources(StackSources): """The comparison's acquisition policy, passed to every measure before workers fork.""" - def requires_clients(self, changed: set[str]) -> bool: + def requires_clients(self) -> bool: return False def at(self, repo: Path, base: str, head: str, side: type[Side]) -> tuple[StackClient, ...]: @@ -450,7 +450,7 @@ def cuts(self) -> dict[type[Side], tuple[StackClient, ...]]: side.require_materialized(self.directory, core_source) / "stack", self.directory, side, self.inputs, self.projects(side)) for side in Side.sides()} - def requires_clients(self, changed: set[str]) -> bool: + def requires_clients(self) -> bool: # A companion can change while the local Core patch is unchanged. return True From c332f12a65dd4715ce2a2865b9012d43b77ed0a5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 19:33:26 -0400 Subject: [PATCH 056/109] Cover later corroborated requests retaining the first receipt --- tests/test_live_context_notifications.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/test_live_context_notifications.py b/tests/test_live_context_notifications.py index bb5bedb817..371e2eda3b 100644 --- a/tests/test_live_context_notifications.py +++ b/tests/test_live_context_notifications.py @@ -1,14 +1,16 @@ """Real reserved inputs, source files and journals; no native/provider substitute.""" from collections.abc import Callable +from contextlib import closing from dataclasses import replace from pathlib import Path +import sqlite3 import pytest from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordinator import Coordination from agent_comms.field_codec import InputId -from agent_comms.native_pi import NativeContextProof +from agent_comms.native_pi import NativeContextJournal, NativeContextProof from agent_comms.native_runtime_input import NativeRuntimeInput from agent_comms.presentation import AllOutcomes from agent_comms.private_send_claim import PrivateSendClaim @@ -71,6 +73,26 @@ async def test_reserved_context_notifies_before_any_assistant_reply(tmp_path: Pa assert retained == original +async def test_later_corroborated_request_preserves_first_context_receipt(tmp_path: Path): + async with prepared_native_input(tmp_path) as claim: + context = recorded_input_context(claim) + await claim.context_committed(context) + files = claim.session.files(claim.selected.comms.root) + # Append through the declared journal owner, preserving its original file identity. + with closing(sqlite3.connect(files.input_proof)) as db, db: + first = NativeContextJournal.for_input(db, claim.input_id, context.request_generation) + assert first is not None + later = replace(first, request_generation=first.request_generation + 1, + llm_context_digest='d' * 64, committed_at_ms=first.committed_at_ms + 1) + later.insert(db) + later_context = later.at(context.session_file, session_entry_start=context.session_entry_start) + await claim.context_committed(later_context) + with Coordination(claim.store_path) as store: + claim.commit(store, later_context) + retained = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) + assert retained.require_context_proof() == context + + async def test_same_request_contradiction_cannot_replace_context_receipt(tmp_path: Path): async with prepared_native_input(tmp_path) as claim: context = recorded_input_context(claim) From 47e0c03ea18d6a2ddf7cf5a7bb63edb024585cb0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 20:03:37 -0400 Subject: [PATCH 057/109] Retain independently verified context on each actual following input --- src/agent_comms/backend.py | 3 + src/agent_comms/following_inputs.py | 49 +++- src/agent_comms/input_attempt.py | 35 +++ src/agent_comms/input_disposition.py | 25 ++ src/agent_comms/native_pi.py | 3 +- src/agent_comms/native_prompt_send.py | 5 +- src/agent_comms/pi_commands.py | 23 ++ src/agent_comms/private_send_claim.py | 17 +- src/agent_comms/private_sidecar.py | 17 +- src/agent_comms/tracked_turn.py | 96 +++++-- src/agent_comms/turn_inputs.py | 1 + tests/test_following_context_notifications.py | 264 ++++++++++++++++++ tests/test_live_context_notifications.py | 19 +- 13 files changed, 508 insertions(+), 49 deletions(-) create mode 100644 tests/test_following_context_notifications.py diff --git a/src/agent_comms/backend.py b/src/agent_comms/backend.py index a95cb9c7e5..1e0222b47f 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -544,6 +544,9 @@ async def initialize_rpc(self) -> None: self.active_session_file = self.launch.session.session_file self.watchdog.reading() + def following_started(self, command: commands.FollowingPrompt) -> None: + """An ordinary session has no tracked model-context proof observer.""" + async def finish_input(self) -> None: """Retire snapshot work before the input transport it borrows.""" await self.stats.close() diff --git a/src/agent_comms/following_inputs.py b/src/agent_comms/following_inputs.py index 4234cd38bc..6c01331d8f 100644 --- a/src/agent_comms/following_inputs.py +++ b/src/agent_comms/following_inputs.py @@ -10,6 +10,8 @@ from .acp_extension import LiveQueueScope from .agent_backend import TurnInbox from .coordinator import Coordination +from .errors import RelationViolationError +from .ordinary_turn_rules import RunningOrdinaryOwnerCheck from .queued_input import DeliveredInput, InputHandoffRefused from .thread_identity import ClaimedIdentity from .turn_input_source import AcceptedFollowingInput @@ -20,6 +22,8 @@ from .turn_runner import TurnRunner from .input_drain import InputDrain from .tracked_turn import TrackedTurnSession + from .owned_send import OwnedSend + from .native_pi import NativeContextProof class FollowingInputs: @@ -44,6 +48,11 @@ async def open(self, session: TrackedTurnSession) -> None: async def started(self, input_id: InputId) -> AcceptedFollowingInput: raise AssertionError("A turn without an inbox cannot start a following input") + async def context_committed( + self, input_id: InputId, sent_text: str, context: NativeContextProof, + ) -> None: + raise AssertionError("A turn without an inbox cannot acknowledge a following context") + async def retired(self, event: InputRefused) -> None: raise AssertionError("A turn without an inbox cannot retire a following input") @@ -56,7 +65,16 @@ class OwnerFollowingInputs(FollowingInputs): session_id: str owner: Thread inbox: TurnInbox - scope: LiveQueueScope = field(init=False) + sender: OwnedSend = field(init=False) + + @property + def scope(self) -> LiveQueueScope: + lease = self.sender.thread.require_turn_lease() + return LiveQueueScope( + session_id=self.session_id, + claimed_identity=ClaimedIdentity(lease.identity.incarnation, lease.claimed_generation), + owner=self.sender.thread.require_process(), + ) async def pass_on_unread(self, turns: TurnRunner) -> None: """Inputs the ended turn never read start the next turn or are refused, never replayed.""" @@ -82,12 +100,7 @@ async def open(self, session: TrackedTurnSession) -> None: session.prompt_send_boundary.following_sender, inputs, inputs.state(self.session_id).owned().identifier, session.command.message, )) - lease = sender.thread.require_turn_lease() - self.scope = LiveQueueScope( - session_id=self.session_id, - claimed_identity=ClaimedIdentity(lease.identity.incarnation, lease.claimed_generation), - owner=sender.thread.require_process(), - ) + self.sender = sender inputs.state(self.session_id).capture_first_source(sender.original_source) session.forward_inbox(self.inbox, sender) await self.ready( @@ -101,5 +114,27 @@ async def started(self, input_id: InputId) -> AcceptedFollowingInput: await self.ready(self.inputs, self.session_id) return source + async def context_committed( + self, input_id: InputId, sent_text: str, context: NativeContextProof, + ) -> None: + source = self.inputs.state(self.session_id).following[input_id] + def acknowledge() -> None: + if self.inputs.state(self.session_id).following.get(input_id) is not source: + raise RelationViolationError("Following context outlived its actual accepted input") + sender = self.sender + with self.inputs.comms.registry.store.current_snapshot() as cut: + snapshot = cut.snapshot + canonical = snapshot.canonical_name(sender.thread.name) + current = sender.require_owner(snapshot, canonical) + sender._check_owner( + current, snapshot, canonical, source, sent_text, + check_type=RunningOrdinaryOwnerCheck, + ) + self.inputs.dispositions.context_committed( + source.keys, lease=sender.thread.require_turn_lease(), + sent_text=sent_text, context=context, snapshot=snapshot, + ) + await Coordination.run_worker(acknowledge) + async def retired(self, event: InputRefused) -> None: await event.apply_to(self.inputs, self.session_id) diff --git a/src/agent_comms/input_attempt.py b/src/agent_comms/input_attempt.py index 82393903a7..e0266755df 100644 --- a/src/agent_comms/input_attempt.py +++ b/src/agent_comms/input_attempt.py @@ -29,6 +29,7 @@ ) from .input_key import InputKey, InputKeyText, PublicInputKeyText from .lifecycle_capability import LifecycleCapability, Unresolved +from .native_pi import NativeContextProof from .reported_turn import ReportedTurnState from .text_digest import TextDigest from .thread_identity import GenerationCounter, ThreadIncarnation @@ -131,6 +132,15 @@ def require_started(self, claim_scope: int) -> StartedInput: def context_provenance(self) -> InputProvenance: raise RelationViolationError("Constraint lacks its original input") + def model_contexts(self) -> tuple[NativeContextProof, ...]: + return () + + def context_committed( + self, lease: TurnLeaseFence, sent_text: str, context: NativeContextProof, + *, snapshot: RegistrySnapshot, + ) -> InputTransition: + raise RelationViolationError("Context acknowledgement lacks its started input") + def bind( self, *, claim_scope: int, turn_id: TurnId, native_id: InputId, text: str ) -> InputTransition: @@ -446,6 +456,31 @@ def started_for_native( return self.matches_lease(lease, snapshot) and self.matches_native( turn_id=lease.turn_id, native_id=native_id, text=sent_text) + def context_committed( + self, lease: TurnLeaseFence, sent_text: str, context: NativeContextProof, + *, snapshot: RegistrySnapshot, + ) -> InputTransition: + if not self.started_for_native(lease, context.input_id, sent_text, snapshot=snapshot): + raise RelationViolationError("Context acknowledgement differs from its original input binding") + return self.record_context(context) + + def record_context(self, context: NativeContextProof) -> InputTransition: + return MovedInput(self._transition(ContextConsumedInput, context=context)) + + +@dataclass(frozen=True) +class ContextConsumedInput(StartedInput): + """The original started binding plus its first independently corroborated context.""" + + context: Annotated[NativeContextProof, PublicExclude(), WireRequired()] + + def model_contexts(self) -> tuple[NativeContextProof, ...]: + return (self.context,) + + def record_context(self, context: NativeContextProof) -> InputTransition: + self.context.require_continuation(context) + return UnchangedInput(self) + class NotSentInput(Unresolved, RecoverableUnboundInput): public_status = "not_sent" diff --git a/src/agent_comms/input_disposition.py b/src/agent_comms/input_disposition.py index 72419a4c84..75ef580b46 100644 --- a/src/agent_comms/input_disposition.py +++ b/src/agent_comms/input_disposition.py @@ -47,6 +47,7 @@ from .bus import BusReader from .channel_input_batch import SingleInputBatch from .private_registry_guard import RegistryPrivacy + from .native_pi import NativeContextProof from .registry_document import RegistryEntry, RegistrySnapshot from .threads import Thread from .turn_lease import TurnLeaseFence @@ -452,6 +453,30 @@ def started(self, key: InputKey, *, turn_id: TurnId, native_id: InputId, text: s key, lambda row: row.started(turn_id=turn_id, native_id=native_id, text=text) ) + def context_committed( + self, keys: tuple[InputKey, ...], *, lease: TurnLeaseFence, + sent_text: str, context: NativeContextProof, snapshot: RegistrySnapshot, + ) -> InputDocument: + """A corroborated input proves only its exact original key batch, atomically. + + The live caller holds its registry lease and native evidence scope. Missing, + unstarted or differently bound members refuse the complete batch before writes. + """ + if not keys: + raise RelationViolationError("Context acknowledgement has no original input keys") + def acknowledge(document: InputDocument) -> DocumentChange[InputDocument, InputDocument]: + owner = snapshot.require(lease.identity.incarnation.name) + if (owner.require_turn_lease() != lease + or snapshot.claim_generation(owner.name) != lease.claimed_generation): + raise RelationViolationError("Context acknowledgement lost its original live turn lease") + document.originals(keys) + return DocumentChange.to(document.transition( + keys, lambda row: row.context_committed( + lease, sent_text, context, snapshot=snapshot, + ), + )) + return self._update(*_members("key", keys), acknowledge) + def settle_unbound(self, keys: tuple[InputKey, ...]) -> InputDocument: """Publish one complete retirement and return that exact document cut.""" return self._update( diff --git a/src/agent_comms/native_pi.py b/src/agent_comms/native_pi.py index 24f992fc18..8477e87b05 100644 --- a/src/agent_comms/native_pi.py +++ b/src/agent_comms/native_pi.py @@ -31,6 +31,7 @@ FieldCodec, InputId, MalformedRecord, + PathText, Sha256Text, WireName, boundary_decoder, @@ -320,7 +321,7 @@ def corroborate( @dataclass(frozen=True, slots=True) class NativeContextProof(NativeContextRecord, NativeInputLocation): - session_file: Path + session_file: Annotated[Path, PathText] def require_continuation(self, context: NativeContextProof) -> None: """Repeated requests may include this input; its first receipt stays immutable. diff --git a/src/agent_comms/native_prompt_send.py b/src/agent_comms/native_prompt_send.py index 8c2b369eb5..8ab9936147 100644 --- a/src/agent_comms/native_prompt_send.py +++ b/src/agent_comms/native_prompt_send.py @@ -27,6 +27,7 @@ from .input_drain import InputDrain from .owned_send import OwnedSend from .native_pi import NativeContextProof + from .native_entries import NativeEvidenceScope from .tracked_turn import TrackedTurnSession _MAX_SEND_SECONDS = 5.0 @@ -36,7 +37,9 @@ class PromptBoundary(ABC): @abstractmethod async def send_prompt(self, turn: TrackedTurnSession) -> None: ... - async def context_committed(self, context: NativeContextProof) -> None: + async def context_committed( + self, context: NativeContextProof, source_reads: NativeEvidenceScope, + ) -> None: """An unowned prompt has no durable recipient acknowledgement to advance.""" def following_sender(self, inputs: InputDrain, session_id: AcpSessionId, prompt: str) -> OwnedSend: diff --git a/src/agent_comms/pi_commands.py b/src/agent_comms/pi_commands.py index c74484d587..47d5dc4076 100644 --- a/src/agent_comms/pi_commands.py +++ b/src/agent_comms/pi_commands.py @@ -58,6 +58,10 @@ from .pi_events import Response, ResponseOutcome from .pending_requests import PendingRequest from .launch_runtime import RestartEnvironment + from .tracked_turn import TrackedTurnSession + from .native_entries import NativeEvidenceScope + from .native_pi import NativeContextProof + from .pi_events import InputCommitted class SessionSnapshot: @@ -210,6 +214,16 @@ class Prompt(PiCommand): WireName("streamingBehavior"), ] = None + async def input_committed(self, session: TrackedTurnSession, event: InputCommitted) -> None: + session.prompt_state = session.prompt_state.start(event) + await session.following.open(session) + + async def context_committed( + self, session: TrackedTurnSession, context: NativeContextProof, + source_reads: NativeEvidenceScope, + ) -> None: + await session.prompt_send_boundary.context_committed(context, source_reads) + @classmethod def response_type(cls) -> type[PiCommand]: return Prompt @@ -239,6 +253,15 @@ class FollowingPrompt(Prompt, declared_name=None): input: Annotated[InputRequest, WireTransient()] + async def input_committed(self, session: TrackedTurnSession, event: InputCommitted) -> None: + """This accepted following input cannot restart the original prompt or inbox.""" + + async def context_committed( + self, session: TrackedTurnSession, context: NativeContextProof, + source_reads: NativeEvidenceScope, + ) -> None: + await session.following.context_committed(self.input.input_id, self.message, context) + def accepted(self, response: Response, session: TurnSession) -> Iterable[events.AgentEvent]: # PendingRequest already records this acknowledgement. return () diff --git a/src/agent_comms/private_send_claim.py b/src/agent_comms/private_send_claim.py index 35f77df1d2..3ba0c46a8f 100644 --- a/src/agent_comms/private_send_claim.py +++ b/src/agent_comms/private_send_claim.py @@ -228,19 +228,20 @@ def settle(store: Coordination, terminal: NativePiTerminalFailure = error): ) return result - async def context_committed(self, context: NativeContextProof) -> None: + async def context_committed( + self, context: NativeContextProof, source_reads: NativeEvidenceScope, + ) -> None: """Publish corroborated entry while the model is still running. The context journal is borrowed before coordinator locking, as on the terminal path. Neither the RPC event nor model activity is a receipt. """ - async with self.selected.cursor_delivery.reading() as source_reads: - def acknowledge(store: Coordination) -> None: - self.verify(store, context, source_reads) - self.stage.acknowledge_context( - store, self.participant, self.input_id, self.token_digest, context, - ) - await Coordination.run_async(self.store_path, acknowledge) + def acknowledge(store: Coordination) -> None: + self.verify(store, context, source_reads) + self.stage.acknowledge_context( + store, self.participant, self.input_id, self.token_digest, context, + ) + await Coordination.run_async(self.store_path, acknowledge) def commit(self, store: Coordination, context: NativeContextProof) -> None: self.stage.acknowledge_context( diff --git a/src/agent_comms/private_sidecar.py b/src/agent_comms/private_sidecar.py index ab3321c7b4..de282f7d8d 100644 --- a/src/agent_comms/private_sidecar.py +++ b/src/agent_comms/private_sidecar.py @@ -33,6 +33,7 @@ if TYPE_CHECKING: from .agent_backend import StreamingInputPolicy + from .image_input import ImageInput from .private_path import ObservedPath, PrivateFileRole, TrustedAncestorRole from .store_files import ExclusiveLock, StoreOwner @@ -505,13 +506,16 @@ def reconcile_at(cls, path: Path, practice: Practice = NoPractice()) -> bool: connection.close() -def encode_request(text: str, *, streaming_behavior: type[StreamingInputPolicy] | None = None) -> str: +def encode_request( + text: str, *, streaming_behavior: type[StreamingInputPolicy] | None = None, + images: tuple[ImageInput, ...] = (), +) -> str: """JSON-encode like the pinned native ``_claimNativeInput`` request.""" return FieldCodec.compact( { "kind": "prompt", "text": text, - "images": None, + "images": FieldCodec.encode(images) if images else None, "streamingBehavior": FieldCodec.encode(streaming_behavior), "expandPromptTemplates": True, "source": "rpc", @@ -519,8 +523,13 @@ def encode_request(text: str, *, streaming_behavior: type[StreamingInputPolicy] ) -def native_request_digest(text: str, *, streaming_behavior: type[StreamingInputPolicy] | None = None) -> str: +def native_request_digest( + text: str, *, streaming_behavior: type[StreamingInputPolicy] | None = None, + images: tuple[ImageInput, ...] = (), +) -> str: """sha256 over the pinned native request envelope, not the bare text.""" return hashlib.sha256( - ("pi-input-request-v1\n" + encode_request(text, streaming_behavior=streaming_behavior)).encode("utf-8") + ("pi-input-request-v1\n" + encode_request( + text, streaming_behavior=streaming_behavior, images=images, + )).encode("utf-8") ).hexdigest() diff --git a/src/agent_comms/tracked_turn.py b/src/agent_comms/tracked_turn.py index 969f472fd4..e14f96e473 100644 --- a/src/agent_comms/tracked_turn.py +++ b/src/agent_comms/tracked_turn.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable, Mapping from contextlib import AsyncExitStack, ExitStack -from dataclasses import dataclass +from dataclasses import dataclass, field from functools import partial from pathlib import Path from types import MappingProxyType @@ -35,7 +35,9 @@ NativePiPromptRejected, NativeTurnResult, _verify_context, + read_tracked_input_commitment, ) +from .private_sidecar import native_request_digest from .native_prompt_send import UNGUARDED_PROMPT, PromptBoundary, PromptSendFailure from .native_session_reopen import SessionRetention from .native_startup import NativeStartup @@ -54,6 +56,10 @@ from .coordinated_runtime import NativeExecution from .cursor_publication import CursorDelivery from .owned_send import OwnedSend + from .native_entries import NativeEvidenceScope + from .native_tool_transport import UnboundToolTransport + from .native_session_files import NativeSessionFiles + from .native_session_reopen import PiSessionId from .pi_native_backend import PersistentPiSession from .pi_payloads import UserMessage from .turn_failure import TurnFailure @@ -99,6 +105,50 @@ def require(self) -> T: return self.event +@dataclass +class TrackedInputContext: + """One actually sent command owns its emitted join, bounded by this run's scope.""" + + command: commands.Prompt + input_commit: NativeCommitObservation[pi.InputCommitted] = field(default_factory=PendingNativeCommit) + context_commit: NativeCommitObservation[pi.ContextCommitted] = field(default_factory=PendingNativeCommit) + + async def committed_input(self, turn: TrackedTurnSession, event: pi.InputCommitted) -> None: + self.input_commit = self.input_commit.capture(event) + async with turn.cursor_delivery.reading() as source_reads: + await Coordination.run_worker(lambda: + source_reads.for_source(turn.active_session_file).observe(event)) + await self.command.input_committed(turn, event) + + def proof( + self, files: NativeSessionFiles, session_id: PiSessionId, + source_reads: NativeEvidenceScope, + ) -> NativeContextProof: + evidence = source_reads.for_source(files.session) + proof = _verify_context( + files, self.command.input_id, session_id, + self.input_commit.require(), self.context_commit.require(), evidence=evidence, + ) + committed = read_tracked_input_commitment( + proof.session_file, self.command.input_id, evidence=evidence, + ) + if not committed.matches(native_request_digest( + self.command.message, streaming_behavior=self.command.streaming_behavior, + images=self.command.images, + )): + raise NativePiUnavailable("Native context differs from its actual scoped input request") + return proof + + async def committed_context(self, turn: TrackedTurnSession, event: pi.ContextCommitted) -> None: + self.context_commit = ObservedNativeCommit(event) + async with turn.cursor_delivery.reading() as source_reads: + proof = await Coordination.run_worker(partial( + self.proof, turn.launch.session.files(turn.launch.root), + turn.native.attestation.require_identity().session_id, source_reads, + )) + await self.command.context_committed(turn, proof, source_reads) + + class TrackedTerminal(ABC): """Relate completed assistant responses to their streamed text.""" @@ -263,11 +313,15 @@ def __init__( self.prompt_send_boundary = prompt_send_boundary self.ingress = ingress self.observe_event = observe_event - self.input_commit: NativeCommitObservation[pi.InputCommitted] = PendingNativeCommit() - self.context_commit: NativeCommitObservation[pi.ContextCommitted] = PendingNativeCommit() + self.contexts: dict[InputId, TrackedInputContext] = {} self.draft_written_at = 0.0 self.terminal: TrackedTerminal = PendingTrackedTerminal() + async def open_transport(self, resources: AsyncExitStack) -> UnboundToolTransport: + transport = await super().open_transport(resources) + self.contexts[self.command.input_id] = TrackedInputContext(self.command) + return transport + @classmethod async def execute( cls, @@ -464,16 +518,21 @@ async def send_prompt(self) -> None: def reject_prompt(self, response): raise NativePiPromptRejected(response) + def following_started(self, command: commands.FollowingPrompt) -> None: + if command.input_id in self.contexts: + raise NativePiUnavailable("A tracked following input reused an in-scope native identity") + self.contexts[command.input_id] = TrackedInputContext(command) + + async def finish_input(self) -> None: + try: + await super().finish_input() + finally: + self.contexts.clear() + @handles(pi.InputCommitted) async def committed_input(self, event: pi.InputCommitted) -> None: - if event.input_id == self.command.input_id: - self.input_commit = self.input_commit.capture(event) - self.prompt_state = self.prompt_state.start(event) - async with self.cursor_delivery.reading() as source_reads: - await Coordination.run_worker(lambda: - source_reads.for_source(self.active_session_file).observe(event) - ) - await self.following.open(self) + if context := self.contexts.get(event.input_id): + await context.committed_input(self, event) def forward_inbox(self, inbox: TurnInbox, sender: OwnedSend) -> None: """Later inputs go to the running child under the owner's write checks.""" @@ -497,10 +556,8 @@ async def accept_user_message( @handles(pi.ContextCommitted) async def committed_context(self, event: pi.ContextCommitted) -> None: - if event.input_id == self.command.input_id: - self.context_commit = ObservedNativeCommit(event) - context = await self.context_proof() - await self.prompt_send_boundary.context_committed(context) + if context := self.contexts.get(event.input_id): + await context.committed_context(self, event) async def assistant_started(self) -> None: await super().assistant_started() @@ -539,13 +596,10 @@ async def context_proof(self) -> NativeContextProof: # The recipient serializes this read with cursor and settlement reads. # Cancellation joins the worker before its borrowed scope is released. async with self.cursor_delivery.reading() as source_reads: - return await Coordination.run_worker(lambda: _verify_context( + return await Coordination.run_worker(partial( + self.contexts[self.command.input_id].proof, self.launch.session.files(self.launch.root), - self.command.input_id, - self.native.attestation.require_identity().session_id, - self.input_commit.require(), - self.context_commit.require(), - evidence=source_reads.for_source(self.active_session_file), + self.native.attestation.require_identity().session_id, source_reads, )) async def invalidate_identity(self) -> tuple[AgentEvent, ...]: diff --git a/src/agent_comms/turn_inputs.py b/src/agent_comms/turn_inputs.py index 4a3af2e7e8..40450b10c3 100644 --- a/src/agent_comms/turn_inputs.py +++ b/src/agent_comms/turn_inputs.py @@ -244,6 +244,7 @@ def mark_started( if not session.input_start.following(public_id, native_id, entry.content.text): return () self.retire(native_id) + session.following_started(command) self.started = True return (public_id,) return () diff --git a/tests/test_following_context_notifications.py b/tests/test_following_context_notifications.py new file mode 100644 index 0000000000..6eb462f56d --- /dev/null +++ b/tests/test_following_context_notifications.py @@ -0,0 +1,264 @@ +"""Following proof uses real session/journal/input rows, without a native/provider run. + +These controls exercise the shared event verifier and durable transitions. The +actual TrackedTurnSession event-loop/transport qualification remains root-owned. +""" +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, closing +from dataclasses import dataclass, field, replace +from pathlib import Path +import sqlite3 + +import pytest + +from agent_comms.agent_backend import InputContent, InputRequest, SteerInputPolicy +from agent_comms.coordination_errors import IdentityConflict +from agent_comms.coordinator import Coordination +from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import InputId +from agent_comms.lifecycle_capability import Unresolved +from agent_comms.input_disposition import InputDispositions +from agent_comms.input_key import InputKey +from agent_comms.input_origin import WireInputOrigin +from agent_comms.native_failures import NativePiUnavailable +from agent_comms.native_pi import NativeContextJournal, NativeContextProof +from agent_comms.native_tool_call import OwnerToolConnection +from agent_comms.pi_commands import FollowingPrompt +from agent_comms.tracked_turn import ObservedNativeCommit, TrackedInputContext +from agent_comms.turn_lease import TurnLeaseFence +from agent_comms.native_runtime_input import NativeRuntimeInput +from agent_comms.pi_events import ContextCommitted, InputCommitted +from agent_comms.private_send_claim import PrivateSendClaim +from agent_comms.private_sidecar import native_request_digest +from conftest import accept_initial, publish_initial +from native_proof_cases import append_input_entry +from test_coordinated_runtime import tmp_path # noqa: F401 +from test_live_context_notifications import acknowledge_context, recorded_input_context +from test_native_failure_recovery import prepared_native_input + + +@asynccontextmanager +async def following_session(directory: Path) -> AsyncIterator[PrivateSendClaim]: + async with prepared_native_input(directory) as claim: + await acknowledge_context(claim, recorded_input_context(claim)) + yield claim + + +@dataclass(frozen=True) +class FollowingContext: + claim: PrivateSendClaim + keys: tuple[InputKey, ...] + text: str + input_event: InputCommitted + context_event: ContextCommitted + public_id: InputId = field(default_factory=InputId.mint) + + @property + def dispositions(self) -> InputDispositions: + return InputDispositions(self.claim.selected.comms.root) + + @property + def lease(self) -> TurnLeaseFence: + return self.claim.owner.thread.require_turn_lease() + + def start(self) -> None: + for key in self.keys: + assert self.dispositions.started( + key, turn_id=self.lease.turn_id, native_id=self.input_event.input_id, + text=self.text, + ) + + @property + def command(self) -> FollowingPrompt: + return FollowingPrompt( + input=InputRequest(input_id=self.public_id, content=InputContent(text=self.text), + when_busy=SteerInputPolicy), + input_id=self.input_event.input_id, message=self.text, + streaming_behavior=SteerInputPolicy, + # This command-data control never starts a tool transport or connects here. + owner_tools=OwnerToolConnection(str(self.claim.session.identity.path) + '.unused', 'unused'), + ) + + async def verify(self, event: ContextCommitted | None = None) -> NativeContextProof: + observed = TrackedInputContext( + self.command, ObservedNativeCommit(self.input_event), + ObservedNativeCommit(event or self.context_event), + ) + async with self.claim.selected.cursor_delivery.reading() as reads: + return observed.proof( + self.claim.session.files(self.claim.selected.comms.root), + self.input_event.session_id, reads, + ) + + def acknowledge(self, context: NativeContextProof) -> None: + with self.claim.selected.comms.registry.store.current_snapshot() as cut: + self.dispositions.context_committed( + self.keys, lease=self.lease, sent_text=self.text, + context=context, snapshot=cut.snapshot, + ) + + def append_generation(self, generation: int) -> FollowingContext: + files = self.claim.session.files(self.claim.selected.comms.root) + with closing(sqlite3.connect(files.input_proof)) as db, db: + original = NativeContextJournal.for_input( + db, self.input_event.input_id, self.context_event.request_generation, + ) + assert original is not None + later = replace(original, request_generation=generation, + committed_at_ms=original.committed_at_ms + 1, + llm_context_digest='b' * 64) + later.insert(db) + return replace(self, context_event=replace( + self.context_event, request_generation=later.request_generation, + llm_context_digest=later.llm_context_digest, committed_at_ms=later.committed_at_ms, + )) + + +def following_input(claim: PrivateSendClaim, *, generation: int, count: int = 1) -> FollowingContext: + """Reserve and bind originals through InputDispositions, then retain native evidence. + + No message/start/context event is inferred from another input or a reply. + """ + comms, owner = claim.selected.comms, claim.owner.thread + lease = owner.require_turn_lease() + messages = tuple(publish_initial(comms, 'sender', owner.name, + f'Following original {generation}/{index}') + for index in range(count)) + with Coordination(claim.store_path) as store: + for message in messages: + assert accept_initial(comms.bus, claim.selected.root_id, message.seq, store) + keys = tuple(InputDispositions.bus_key(message.message, owner) for message in messages) + text = '\n'.join(message.message.body for message in messages) + dispositions = InputDispositions(comms.root) + for key, message in zip(keys, messages, strict=True): + assert dispositions.record( + key, seq=message.seq, owner=owner.name, claim_scope=lease.claimed_generation, + target=owner.name, text=message.message.body, + origin=WireInputOrigin(claim.selected.root_id, message.message.reference), + ) + native_id = InputId.mint() + assert dispositions.bind_originals( + keys, claim_scope=lease.claimed_generation, turn_id=lease.turn_id, + native_id=native_id, text=text, + ) + session = claim.session.identity + entry = f'following-{native_id}' + start = append_input_entry(session.path, { + 'type': 'message', 'id': entry, 'timestamp': '2026-10-01T00:00:00.002Z', + 'message': {'role': 'user', 'inputId': native_id, + 'inputDigest': native_request_digest(text, streaming_behavior=SteerInputPolicy), + 'content': [{'type': 'text', 'text': text}]}, + }) + recorded = NativeContextJournal( + input_id=native_id, session_id=session.session_id, session_entry_id=entry, + request_generation=generation, llm_context_digest='a' * 64, + committed_at_ms=1_791_964_800_001 + generation, + schema=2, type='context_committed', + ) + with closing(sqlite3.connect(claim.session.files(comms.root).input_proof)) as db, db: + recorded.insert(db) + return FollowingContext( + claim, keys, text, + InputCommitted(input_id=native_id, session_id=session.session_id, + session_entry_id=entry, session_entry_start=start), + ContextCommitted(input_id=native_id, session_id=session.session_id, + session_entry_id=entry, request_generation=generation, + llm_context_digest=recorded.llm_context_digest, + committed_at_ms=recorded.committed_at_ms), + ) + + +async def test_two_following_ids_and_grouped_keys_keep_their_own_first_proof(tmp_path: Path): + async with following_session(tmp_path) as claim: + first = following_input(claim, generation=2, count=2) + second = following_input(claim, generation=3) + first.start() + second.start() + assert first.input_event.input_id != second.input_event.input_id != claim.input_id + assert all(not row.model_contexts() for row in first.dispositions.read( + first.keys + second.keys).rows.values()) + proof = await first.verify() + first.acknowledge(proof) + for row in first.dispositions.read(first.keys).rows.values(): + assert row.has_started and not row.items_with(Unresolved, row) + assert row.model_contexts() == (proof,) + assert all(not row.model_contexts() for row in second.dispositions.read(second.keys).rows.values()) + second_proof = await second.verify() + second.acknowledge(second_proof) + assert all(row.model_contexts() == (second_proof,) + for row in second.dispositions.read(second.keys).rows.values()) + with Coordination(claim.store_path) as store: + original = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) + assert original.require_context_proof().input_id == claim.input_id + assert len(NativeRuntimeInput.select(store.session._connection)) == 1 + assert claim.stage.progress.current.starting # No terminal answer was supplied. + + +async def test_one_emitted_event_alone_cannot_prove_context(tmp_path: Path): + async with following_session(tmp_path) as claim: + case = following_input(claim, generation=2) + case.start() + for incomplete in ( + TrackedInputContext(case.command, input_commit=ObservedNativeCommit(case.input_event)), + TrackedInputContext(case.command, context_commit=ObservedNativeCommit(case.context_event)), + ): + async with claim.selected.cursor_delivery.reading() as reads: + with pytest.raises(NativePiUnavailable): + incomplete.proof(claim.session.files(claim.selected.comms.root), + case.input_event.session_id, reads) + assert all(not row.model_contexts() for row in case.dispositions.read(case.keys).rows.values()) + + +async def test_foreign_journal_event_and_mixed_key_batch_refuse_without_partial_write(tmp_path: Path): + async with following_session(tmp_path) as claim: + first = following_input(claim, generation=2) + second = following_input(claim, generation=3) + first.start() + second.start() + with pytest.raises(NativePiUnavailable): + await first.verify(second.context_event) + with pytest.raises(NativePiUnavailable): + await first.verify(replace(first.context_event, llm_context_digest='c' * 64)) + proof = await first.verify() + with claim.selected.comms.registry.store.current_snapshot() as cut: + with pytest.raises(RelationViolationError): + first.dispositions.context_committed( + first.keys + second.keys, lease=first.lease, sent_text=first.text, + context=proof, snapshot=cut.snapshot, + ) + assert all(not row.model_contexts() for row in first.dispositions.read( + first.keys + second.keys).rows.values()) + + +async def test_duplicate_and_later_generation_keep_first_proof_and_no_replay(tmp_path: Path): + async with following_session(tmp_path) as claim: + case = following_input(claim, generation=2) + case.start() + proof = await case.verify() + case.acknowledge(proof) + case.acknowledge(proof) + later = case.append_generation(3) + later.acknowledge(await later.verify()) + assert all(row.model_contexts() == (proof,) for row in case.dispositions.read(case.keys).rows.values()) + with pytest.raises(IdentityConflict): + case.acknowledge(replace(proof, llm_context_digest='c' * 64)) + assert not case.dispositions.bind_originals( + case.keys, claim_scope=case.lease.claimed_generation, turn_id=case.lease.turn_id, + native_id=InputId.mint(), text=case.text, + ) + + +async def test_cancelled_input_or_released_lease_cannot_acquire_context(tmp_path: Path): + async with following_session(tmp_path) as claim: + cleared = following_input(claim, generation=2) + for key in cleared.keys: + cleared.dispositions.clear_native(key, cleared.input_event.input_id, cleared.text) + with pytest.raises(RelationViolationError): + cleared.acknowledge(await cleared.verify()) + live = following_input(claim, generation=3) + live.start() + proof = await live.verify() + claim.selected.comms.owners.release(claim.owner.thread.name) + with pytest.raises(RelationViolationError): + live.acknowledge(proof) + assert all(not row.model_contexts() for row in live.dispositions.read(live.keys).rows.values()) diff --git a/tests/test_live_context_notifications.py b/tests/test_live_context_notifications.py index 371e2eda3b..bfeaf8beed 100644 --- a/tests/test_live_context_notifications.py +++ b/tests/test_live_context_notifications.py @@ -14,7 +14,7 @@ from agent_comms.native_runtime_input import NativeRuntimeInput from agent_comms.presentation import AllOutcomes from agent_comms.private_send_claim import PrivateSendClaim -from agent_comms.text_digest import TextDigest +from agent_comms.private_sidecar import native_request_digest from native_proof_cases import append_input_entry, recorded_context, write_proof_rows from test_coordinated_runtime import tmp_path # noqa: F401 from test_native_failure_recovery import SentHistory, prepared_native_input @@ -30,7 +30,7 @@ def recorded_input_context(claim: PrivateSendClaim) -> NativeContextProof: 'type': 'message', 'id': entry, 'timestamp': '2026-10-01T00:00:00.000Z', 'message': {'role': 'user', 'inputId': claim.input_id, - 'inputDigest': TextDigest.of(claim.prompt.text).value, + 'inputDigest': native_request_digest(claim.prompt.text), 'content': [{'type': 'text', 'text': claim.prompt.text}]}, }) record = { @@ -43,6 +43,11 @@ def recorded_input_context(claim: PrivateSendClaim) -> NativeContextProof: return recorded_context(identity.path, record, session_entry_start=offset) +async def acknowledge_context(claim: PrivateSendClaim, context: NativeContextProof) -> None: + async with claim.selected.cursor_delivery.reading() as source_reads: + await claim.context_committed(context, source_reads) + + @pytest.mark.parametrize('source_count', (1, 3)) async def test_reserved_context_notifies_before_any_assistant_reply(tmp_path: Path, source_count: int): async with prepared_native_input( @@ -56,7 +61,7 @@ async def test_reserved_context_notifies_before_any_assistant_reply(tmp_path: Pa before_rows = tuple(row for rows in before.outcomes.values() for row in rows) assert before_rows and all(not row.state.consumed for row in before_rows) - await claim.context_committed(context) + await acknowledge_context(claim, context) after = comms.views.message_notifications_for_references(AllOutcomes(), originals) after_rows = tuple(row for rows in after.outcomes.values() for row in rows) @@ -76,7 +81,7 @@ async def test_reserved_context_notifies_before_any_assistant_reply(tmp_path: Pa async def test_later_corroborated_request_preserves_first_context_receipt(tmp_path: Path): async with prepared_native_input(tmp_path) as claim: context = recorded_input_context(claim) - await claim.context_committed(context) + await acknowledge_context(claim, context) files = claim.session.files(claim.selected.comms.root) # Append through the declared journal owner, preserving its original file identity. with closing(sqlite3.connect(files.input_proof)) as db, db: @@ -86,7 +91,7 @@ async def test_later_corroborated_request_preserves_first_context_receipt(tmp_pa llm_context_digest='d' * 64, committed_at_ms=first.committed_at_ms + 1) later.insert(db) later_context = later.at(context.session_file, session_entry_start=context.session_entry_start) - await claim.context_committed(later_context) + await acknowledge_context(claim, later_context) with Coordination(claim.store_path) as store: claim.commit(store, later_context) retained = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) @@ -96,7 +101,7 @@ async def test_later_corroborated_request_preserves_first_context_receipt(tmp_pa async def test_same_request_contradiction_cannot_replace_context_receipt(tmp_path: Path): async with prepared_native_input(tmp_path) as claim: context = recorded_input_context(claim) - await claim.context_committed(context) + await acknowledge_context(claim, context) with Coordination(claim.store_path) as store: with pytest.raises(IdentityConflict): claim.commit(store, replace(context, llm_context_digest='e' * 64)) @@ -114,7 +119,7 @@ async def test_uncorroborated_live_context_never_grants_consumption( async with prepared_native_input(tmp_path) as claim: context = recorded_input_context(claim) with pytest.raises(IdentityConflict): - await claim.context_committed(corrupt(context)) + await acknowledge_context(claim, corrupt(context)) with Coordination(claim.store_path) as store: retained = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) assert not retained.reference.recorded From 3acb8429b70850abf1a42db9c9592f9ea0961535 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 20:21:04 -0400 Subject: [PATCH 058/109] Cover live triage context before independent verdict and failure --- tests/test_live_context_notifications.py | 45 +++++++++++++- tests/test_native_failure_recovery.py | 75 ++++++++++++++++-------- 2 files changed, 96 insertions(+), 24 deletions(-) diff --git a/tests/test_live_context_notifications.py b/tests/test_live_context_notifications.py index bfeaf8beed..d7628f62f8 100644 --- a/tests/test_live_context_notifications.py +++ b/tests/test_live_context_notifications.py @@ -15,9 +15,10 @@ from agent_comms.presentation import AllOutcomes from agent_comms.private_send_claim import PrivateSendClaim from agent_comms.private_sidecar import native_request_digest +from agent_comms.selected_triage import AbsentTriageDecisionRecord, DecidedTriageRecord, IgnoreSelectedTriage from native_proof_cases import append_input_entry, recorded_context, write_proof_rows from test_coordinated_runtime import tmp_path # noqa: F401 -from test_native_failure_recovery import SentHistory, prepared_native_input +from test_native_failure_recovery import SentHistory, prepared_native_input, prepared_native_triage def recorded_input_context(claim: PrivateSendClaim) -> NativeContextProof: @@ -78,6 +79,48 @@ async def test_reserved_context_notifies_before_any_assistant_reply(tmp_path: Pa assert retained == original +async def test_triage_consumption_precedes_and_survives_later_verdict(tmp_path: Path): + async with prepared_native_triage(tmp_path) as claim: + context = recorded_input_context(claim) + originals = tuple(row.source for row in claim.stage.assignments) + before = claim.selected.comms.views.message_notifications_for_references(AllOutcomes(), originals) + assert all(not row.state.consumed for rows in before.outcomes.values() for row in rows) + await acknowledge_context(claim, context) + active = claim.selected.comms.views.message_notifications_for_references(AllOutcomes(), originals) + assert sum(row.state.consumed for rows in active.outcomes.values() for row in rows) == 1 + assert all(row.answer is None and not row.displayed_to + for rows in active.outcomes.values() for row in rows) + with Coordination(claim.store_path) as store: + original = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) + assert original.verdict == AbsentTriageDecisionRecord() + claim.commit(store, context) + claim.stage.commit(store, claim.participant, claim.input_id, claim.token_digest, + context, IgnoreSelectedTriage()) + retained = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) + assert retained.require_context_proof() == context + assert retained.verdict == DecidedTriageRecord(IgnoreSelectedTriage) + with store.session.writing() as db: + with pytest.raises(IdentityConflict): + retained.commit_context(db, replace(context, llm_context_digest='e' * 64)) + with pytest.raises(sqlite3.IntegrityError): + NativeRuntimeInput.update(db, where='input_id=?', parameters=(claim.input_id,), + verdict=AbsentTriageDecisionRecord()) + + +async def test_triage_failure_keeps_previously_acknowledged_context_and_no_retry(tmp_path: Path): + async with prepared_native_triage(tmp_path) as claim: + context = recorded_input_context(claim) + await acknowledge_context(claim, context) + with Coordination(claim.store_path) as store: + claim.stage.fail_decision(store, claim.participant, claim.input_id, claim.token_digest, context) + retained = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) + assert retained.require_context_proof() == context + assert retained.verdict == AbsentTriageDecisionRecord() + with pytest.raises(IdentityConflict): + claim.stage.reserve(store, claim.participant, claim.token_digest) + assert len(NativeRuntimeInput.select(store.session._connection)) == 1 + + async def test_later_corroborated_request_preserves_first_context_receipt(tmp_path: Path): async with prepared_native_input(tmp_path) as claim: context = recorded_input_context(claim) diff --git a/tests/test_native_failure_recovery.py b/tests/test_native_failure_recovery.py index be836145da..a4055a2526 100644 --- a/tests/test_native_failure_recovery.py +++ b/tests/test_native_failure_recovery.py @@ -21,10 +21,13 @@ from agent_comms.launch_runtime import ThreadLaunch from agent_comms.native_runtime_input import NativeRuntimeInput from agent_comms.private_send_claim import PrivateSendClaim +from agent_comms.private_send_stage import TriageNativeSend +from agent_comms.owner_fence import prepare_fence_token +from agent_comms.selected_request import SelectedRequest from agent_comms.selected_actions import CodingSelectedAction from agent_comms.selected_participant import SelectedParticipant from agent_comms.selected_session import SelectedSession -from agent_comms.selected_turn import SelectedAttempt +from agent_comms.selected_turn import SelectedAttempt, SelectedPrompt from agent_comms.selected_write_authority import NoSelectedWritePlans from agent_comms.text_digest import TextDigest from native_proof_cases import append_input_entry, recorded_context, write_proof_rows @@ -115,13 +118,13 @@ def record(cls, claim, session, participant): @asynccontextmanager -async def prepared_native_input( - directory: Path, *, max_attempts: int = 1, source_bodies: tuple[str, ...] = (), -) -> AsyncIterator[PrivateSendClaim]: - """Keep the real selected attempt and its bound input open for an owner operation.""" - root, root_id, comms, _initial, _people = _root(directory, direct=True) +async def prepared_native_participant( + directory: Path, *, direct: bool, source_bodies: tuple[str, ...] = (), +) -> AsyncIterator[SelectedParticipant]: + """One real publication/selection/store lifetime for full and triage controls.""" + root, root_id, comms, initial, _people = _root(directory, direct=direct) for body in source_bodies: - message = publish_initial(comms, 'sender', 'beta', body) + message = publish_initial(comms, 'sender', initial.message.target, body) with Coordination(root / 'coordination.sqlite3') as store: accept_initial(comms.bus, root_id, message.seq, store) with closing(CursorDelivery()) as cursor_delivery: @@ -130,22 +133,48 @@ async def prepared_native_input( comms, store, root_id, "beta", 0, cursor_delivery=cursor_delivery, ) as participant: assert participant is not None - created = store.create( - participant.batch.execution_id, WireOrigin, - participant.lookup, max_attempts, - sources=participant.batch.sources, - ).value - attempt = await SelectedAttempt.engage(participant, created) - directory = participant.owner.thread.native_session_directory(root) - identity = create_fresh_private_session(directory, worktree=Path(participant.owner.thread.worktree)) - participant.owner = comms.registry.attach_native_session(participant.owner, identity.session_file) - session = SelectedSession.for_launch( - directory, identity.path, root=root, - worktree=Path(participant.owner.thread.worktree), - owner=participant.owner.thread.incarnation.require_recorded(), - ) - request, _action = await attempt.prepare(session, CodingSelectedAction(), NoSelectedWritePlans()) - yield request.claim_scope + yield participant + + +def attach_native_session(participant: SelectedParticipant) -> SelectedSession: + root, comms = participant.comms.root, participant.comms + directory = participant.owner.thread.native_session_directory(root) + identity = create_fresh_private_session(directory, worktree=Path(participant.owner.thread.worktree)) + participant.owner = comms.registry.attach_native_session(participant.owner, identity.session_file) + return SelectedSession.for_launch( + directory, identity.path, root=root, + worktree=Path(participant.owner.thread.worktree), + owner=participant.owner.thread.incarnation.require_recorded(), + ) + + +@asynccontextmanager +async def prepared_native_input( + directory: Path, *, max_attempts: int = 1, source_bodies: tuple[str, ...] = (), +) -> AsyncIterator[PrivateSendClaim]: + """Keep the real selected attempt and its bound input open for an owner operation.""" + async with prepared_native_participant(directory, direct=True, source_bodies=source_bodies) as participant: + created = participant.store.create( + participant.batch.execution_id, WireOrigin, + participant.lookup, max_attempts, sources=participant.batch.sources, + ).value + attempt = await SelectedAttempt.engage(participant, created) + session = attach_native_session(participant) + request, _action = await attempt.prepare(session, CodingSelectedAction(), NoSelectedWritePlans()) + yield request.claim_scope + + +@asynccontextmanager +async def prepared_native_triage(directory: Path) -> AsyncIterator[PrivateSendClaim]: + """Reserve an actual triage batch; no native process or model is substituted.""" + async with prepared_native_participant(directory, direct=False) as participant: + assert participant.batch.requires_triage + session = attach_native_session(participant) + request = await SelectedRequest.reserve( + participant, session, TriageNativeSend(participant.batch.assignments), + prepare_fence_token(), SelectedPrompt(participant).triage(), + ) + yield request.claim_scope async def release_native_input(directory: Path, history: type[NativeSendHistory] = FailedHistory) -> ReleasedNativeInput: From 5f7ab48f899cb7e50c7871afb3a14444df02f032 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 20:44:26 -0400 Subject: [PATCH 059/109] Separate native input proof declarations from session execution --- src/agent_comms/attempt_recovery.py | 2 +- src/agent_comms/following_inputs.py | 2 +- src/agent_comms/historical_native_inputs.py | 2 +- src/agent_comms/input_attempt.py | 2 +- src/agent_comms/input_disposition.py | 2 +- src/agent_comms/native_entries.py | 4 +- src/agent_comms/native_input_proof.py | 297 ++++++++++++++++++ src/agent_comms/native_input_record.py | 45 +-- src/agent_comms/native_pi.py | 243 +------------- src/agent_comms/native_prompt_send.py | 2 +- src/agent_comms/native_runtime_input.py | 5 +- src/agent_comms/native_session_files.py | 2 +- src/agent_comms/native_session_reopen.py | 2 +- src/agent_comms/native_transcript.py | 4 +- src/agent_comms/pi_commands.py | 2 +- src/agent_comms/pi_events.py | 2 +- src/agent_comms/private_send_claim.py | 3 +- src/agent_comms/private_send_stage.py | 2 +- src/agent_comms/selected_triage.py | 2 +- src/agent_comms/tracked_turn.py | 2 +- tests/native_proof_cases.py | 2 +- tests/round2/test_typed_table.py | 2 +- tests/test_bus_files.py | 2 +- tests/test_following_context_notifications.py | 2 +- tests/test_live_context_notifications.py | 2 +- tests/test_native_context_reference.py | 2 +- tests/test_native_evidence_custody.py | 4 +- tests/test_native_input_bus_path.py | 2 +- tests/test_native_input_evidence_index.py | 2 +- tests/test_native_pi.py | 3 +- tools/render_native_proof_schema.py | 2 +- 31 files changed, 338 insertions(+), 314 deletions(-) create mode 100644 src/agent_comms/native_input_proof.py diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index d21945b693..10b94c588d 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -44,7 +44,7 @@ from .coordination_database import CoordinationStore from .native_input_record import FullNativeExecution, TriageNativeExecution -from .native_pi import NativeContextProof +from .native_input_proof import NativeContextProof from .native_prompt_binding import ( expected_prompt_bindings, expected_prompt_matches_journal, diff --git a/src/agent_comms/following_inputs.py b/src/agent_comms/following_inputs.py index 6c01331d8f..806a5bb2eb 100644 --- a/src/agent_comms/following_inputs.py +++ b/src/agent_comms/following_inputs.py @@ -23,7 +23,7 @@ from .input_drain import InputDrain from .tracked_turn import TrackedTurnSession from .owned_send import OwnedSend - from .native_pi import NativeContextProof + from .native_input_proof import NativeContextProof class FollowingInputs: diff --git a/src/agent_comms/historical_native_inputs.py b/src/agent_comms/historical_native_inputs.py index b010183b17..976592ea0d 100644 --- a/src/agent_comms/historical_native_inputs.py +++ b/src/agent_comms/historical_native_inputs.py @@ -27,7 +27,7 @@ NativeInputReference, TriageNativeExecution, ) -from .native_pi import NativeContextProof +from .native_input_proof import NativeContextProof from .native_prompt_binding import expected_prompt_bindings, expected_prompt_matches_journal from .native_runtime_input import NativeRuntimeInput from .selected_triage import SelectedTriage diff --git a/src/agent_comms/input_attempt.py b/src/agent_comms/input_attempt.py index e0266755df..5ed6106244 100644 --- a/src/agent_comms/input_attempt.py +++ b/src/agent_comms/input_attempt.py @@ -29,7 +29,7 @@ ) from .input_key import InputKey, InputKeyText, PublicInputKeyText from .lifecycle_capability import LifecycleCapability, Unresolved -from .native_pi import NativeContextProof +from .native_input_proof import NativeContextProof from .reported_turn import ReportedTurnState from .text_digest import TextDigest from .thread_identity import GenerationCounter, ThreadIncarnation diff --git a/src/agent_comms/input_disposition.py b/src/agent_comms/input_disposition.py index 75ef580b46..84303d1135 100644 --- a/src/agent_comms/input_disposition.py +++ b/src/agent_comms/input_disposition.py @@ -47,7 +47,7 @@ from .bus import BusReader from .channel_input_batch import SingleInputBatch from .private_registry_guard import RegistryPrivacy - from .native_pi import NativeContextProof + from .native_input_proof import NativeContextProof from .registry_document import RegistryEntry, RegistrySnapshot from .threads import Thread from .turn_lease import TurnLeaseFence diff --git a/src/agent_comms/native_entries.py b/src/agent_comms/native_entries.py index 8f390b31d3..b726c2a609 100644 --- a/src/agent_comms/native_entries.py +++ b/src/agent_comms/native_entries.py @@ -24,7 +24,7 @@ from .store_files import AtomicFile if TYPE_CHECKING: - from .native_pi import NativeContextProof + from .native_input_proof import NativeContextProof from .importing import ImportedConversation from .declared_family import DeclaredFamily, UnknownFamilyMember @@ -40,7 +40,7 @@ WireOmitDefault, ) from .importing import ImportedSessionMetadata -from .native_input_record import NativeInputLocation +from .native_input_proof import NativeInputLocation from .pi_payloads import Opaque, PiField, PiMessage, PiOmitted, PiPayload, ReportedText from .pi_vocabulary import PendingStopReason, ThinkingLevel from .recorded_time import GeneratedTimestamp diff --git a/src/agent_comms/native_input_proof.py b/src/agent_comms/native_input_proof.py new file mode 100644 index 0000000000..0399bdcd71 --- /dev/null +++ b/src/agent_comms/native_input_proof.py @@ -0,0 +1,297 @@ +"""Native input identity, byte placement and corroborated model-context proof. + +These declarations are shared by durable input state, emitted events and the +proof journal without depending on session execution or transcript routing. +""" + +from __future__ import annotations + +import os +import sqlite3 +from collections.abc import Iterator +from contextlib import ExitStack, closing, contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, ClassVar, Literal + +from pydantic import JsonValue + +from .coordination_errors import IdentityConflict +from .errors import RelationViolationError +from .field_codec import ( + FieldCodec, + InputId, + MalformedRecord, + PathText, + Sha256Text, + WireName, + boundary_decoder, +) +from .locked_store import SQLiteFile +from .native_failures import NativePiUnavailable +from .native_session_reopen import PiSessionId +from .private_path import FileIdentity, PrivateFileRole +from .typed_table import ( + Column, + DurableTables, + GuardedInserts, + ImmutableRows, + Index, + IntegerStorage, + WithoutRowid, +) + +if TYPE_CHECKING: + from . import pi_events as pi + from .native_entries import NativeEntry, NativeEvidenceRead, SessionEntry + from .native_session_files import NativeSessionFiles + + +@dataclass(frozen=True, slots=True) +class NativeInputCommit: + """Original emitted input identity shared with its assembled context.""" + + input_id: Annotated[ + InputId, + WireName("inputId"), + Column(primary_key=True), + ] + session_id: Annotated[PiSessionId, WireName("sessionId")] + session_entry_id: Annotated[str, WireName("sessionEntryId")] + + @property + def input_commit(self) -> NativeInputCommit: + return NativeInputCommit(self.input_id, self.session_id, self.session_entry_id) + + +@dataclass(frozen=True) +class NativeInputLocation(NativeInputCommit): + """Emitted byte placement; only the original runtime input row persists it.""" + + # SQL permits an uncommitted row; the emitted offset remains a required int. + position_column: ClassVar[Column] = Column( + check="session_entry_start>0", + nullable=True, + storage=IntegerStorage, + ) + session_entry_start: Annotated[ + int, WireName("sessionEntryStart"), position_column + ] = field(kw_only=True) + + +@dataclass(frozen=True, slots=True) +class NativeContextRecord(NativeInputCommit): + """Native context facts extend the original committed input declaration.""" + + request_generation: Annotated[ + int, + WireName("requestGeneration"), + Column(primary_key=True, check="request_generation BETWEEN 1 AND 9007199254740991"), + ] + llm_context_digest: Annotated[ + str, + Sha256Text, + WireName("llmContextDigest"), + ] + # The native writer stamps the moment this context was committed for a + # model request: the time the input was shown to the model. + committed_at_ms: Annotated[ + int, + WireName("committedAtMs"), + Column(check="committed_at_ms BETWEEN 1 AND 9007199254740991"), + ] + + @classmethod + def from_events( + cls, input_id: InputId, session_id: PiSessionId, + input_event: pi.InputCommitted, context_event: pi.ContextCommitted, + ) -> NativeContextRecord: + """Strictly join the two emitted records through the context declaration. + + This is an emitted acknowledgement, not proof of durable inclusion. A journal + read must independently corroborate the same complete located record. + """ + emitted = FieldCodec.encode(context_event) + record = FieldCodec.decode(cls, {key: emitted[key] + for _, key in FieldCodec.record_fields(cls)}) + if record.input_commit != NativeInputCommit(input_id, session_id, record.session_entry_id): + raise NativePiUnavailable("Native Pi context names another original input") + if record.input_commit != input_event.input_commit: + raise NativePiUnavailable("Native Pi input/context events disagree") + return record + + def __post_init__(self): + Sha256Text.decode(self.llm_context_digest) + if not 0 < self.request_generation <= 2**53 - 1: + raise ValueError("Native context generation is outside its native range") + if not 0 < self.committed_at_ms <= 2**53 - 1: + raise ValueError("Native context commit time is outside its native range") + + def at(self, session_file: Path, *, session_entry_start: int) -> NativeContextProof: + """Locate recorded facts; this does not grant acceptance or replay.""" + return NativeContextProof( + self.input_id, + self.session_id, + self.session_entry_id, + self.request_generation, + self.llm_context_digest, + self.committed_at_ms, + session_file, + session_entry_start=session_entry_start, + ) + + +@dataclass(frozen=True, slots=True) +class NativeContextJournal( + GuardedInserts, ImmutableRows, NativeContextRecord, WithoutRowid, DurableTables +): + """One proof declaration owns wire facts and the indexed durable journal.""" + + insert_condition = """NEW.request_generation < COALESCE((SELECT MAX(request_generation) + FROM native_context_journal), 0) OR EXISTS(SELECT 1 FROM native_context_journal + WHERE request_generation=NEW.request_generation + AND (llm_context_digest != NEW.llm_context_digest + OR committed_at_ms != NEW.committed_at_ms)) OR NEW.session_id + != (SELECT session_id FROM native_context_journal ORDER BY session_id LIMIT 1)""" + + + schema: Literal[2] + type: Literal["context_committed"] + + indexes = (Index(("request_generation",)), Index(("session_id",))) + + @classmethod + def native_contract(cls) -> dict[str, JsonValue]: + """Generate the native writer's SQLite contract from this declaration.""" + table = cls.declared_name + return { + "objects": cls.schema_objects(), + "columns": [ + {"name": plan.name, "wire": plan.key} for plan in FieldCodec.field_plans(cls) + ], + "insert": cls.insertion_statement(), + "head": f"SELECT COALESCE(MAX(request_generation),0) AS generation FROM {table}", + "session": f"SELECT session_id AS id FROM {table} ORDER BY session_id LIMIT 1", + "current": f"SELECT input_id,session_entry_id FROM {table} " + f"WHERE request_generation=(SELECT MAX(request_generation) FROM {table})", + } + + @classmethod + @contextmanager + def open_evidence(cls, files: NativeSessionFiles) -> Iterator[sqlite3.Connection]: + """Read an indexed snapshot after SQLite's bounded hot-journal recovery. + + A read is corroboration, never a fresh acknowledgement. Keep the private inode + pinned across SQLite's transaction and refuse redirection or schema drift. + Open existing storage read/write for SQLite rollback, then forbid SQL + writes. A cold history view can recover without a model turn or acknowledgement. + """ + path = files.input_proof + with ExitStack() as ownership: + try: + held = ownership.enter_context(closing( + os.fdopen(PrivateFileRole.read( + path, NativePiUnavailable("Native proof indexed evidence is unavailable")), "rb") + )) + before = os.fstat(held.fileno()) + db = ownership.enter_context(closing(SQLiteFile.recovering_snapshot(path))) + if cls.schema_differs(db): + raise NativePiUnavailable("Native proof requires offline durable conversion") + if FileIdentity.from_stat(before) != FileIdentity.from_stat(path.lstat()): + raise NativePiUnavailable("Native proof inode changed while opening") + except (OSError, sqlite3.Error, RelationViolationError, MalformedRecord) as error: + raise NativePiUnavailable("Native proof indexed evidence is unavailable") from error + # Consumer exceptions retain their original boundary and disposition. + # The ExitStack closes the database and descriptor without handling caller errors. + yield db + try: + if FileIdentity.from_stat(before) != FileIdentity.from_stat(path.lstat()): + raise NativePiUnavailable("Native proof inode changed during observation") + except OSError as error: + raise NativePiUnavailable("Native proof indexed evidence is unavailable") from error + + @classmethod + def for_input( + cls, db: sqlite3.Connection, input_id: InputId, generation: int | None = None + ) -> NativeContextJournal | None: + """The primary key answers one input without scanning lifetime history.""" + where = "input_id=?" + parameters: tuple[InputId | int, ...] = (input_id,) + if generation is not None: + where += " AND request_generation=?" + parameters += (generation,) + rows = cls.read( + db.execute( + f"SELECT {cls._column_list(tuple(cls.column_projection()))} FROM {cls.declared_name} " + f"WHERE {where} ORDER BY request_generation DESC LIMIT 1", + parameters, + ) + ) + return next(iter(rows), None) + + def corroborate( + self, session_file: Path, header: SessionEntry, entry: NativeEntry, *, + session_entry_start: int, + ) -> NativeContextProof: + if self.session_id != header.id or self.session_entry_id != entry.id: + raise NativePiUnavailable("Native Pi proof journal source lineage differs") + return self.at(session_file, session_entry_start=session_entry_start) + + +@dataclass(frozen=True, slots=True) +class NativeContextProof(NativeContextRecord, NativeInputLocation): + session_file: Annotated[Path, PathText] + + def require_continuation(self, context: NativeContextProof) -> None: + """Repeated requests may include this input; its first receipt stays immutable. + + A different native entry or conflicting proof of the same request is not + another observation of that entry. Callers corroborate later generations + before this check; this comparison grants no native or replay authority. + """ + if (self.input_commit, self.session_file, self.session_entry_start) != ( + context.input_commit, context.session_file, context.session_entry_start + ): + raise IdentityConflict("Native context acknowledgement belongs to another entry") + if context.request_generation < self.request_generation: + raise IdentityConflict("Native context precedes its original acknowledgement") + if context.request_generation == self.request_generation and context != self: + raise IdentityConflict("Native request has contradictory context evidence") + + def corroborates_input( + self, input_id: InputId, *, files: NativeSessionFiles, + evidence: NativeEvidenceRead, + ) -> bool: + """Check an already-observed live event against its isolated saved proof. + + This grants neither replay nor recovery authority. History IO occurs + before the coordinator read transaction, as on the native return path. + """ + if self.input_id != input_id or files.session != self.session_file: + return False + return self.read_evidence( + files, input_id, + request_generation=self.request_generation, evidence=evidence, + session_entry_start=self.session_entry_start, + ) == self + + @classmethod + @boundary_decoder + def read_evidence( + cls, files: NativeSessionFiles, input_id: InputId, *, request_generation: int | None = None, + evidence: NativeEvidenceRead | None = None, + session_entry_start: int, + ) -> NativeContextProof: + """Corroborate live recorded events; parsed bytes alone grant no authority.""" + session_file = files.session.absolute() + from .native_entries import NativeInputEvidenceRead + with NativeInputEvidenceRead.borrow(session_file, evidence) as evidence: + with NativeContextJournal.open_evidence(files) as db: + row = NativeContextJournal.for_input(db, input_id, request_generation) + if row is None: + raise NativePiUnavailable("The input has no assembled-context proof") + location = NativeInputLocation(input_id, row.session_id, row.session_entry_id, + session_entry_start=session_entry_start) + entry = evidence.read_input(location) + return row.corroborate(session_file, evidence.header(), entry, + session_entry_start=session_entry_start) diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index 36d12f5c0a..1aad5751ad 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -8,12 +8,8 @@ import sqlite3 from abc import ABC, abstractmethod -from dataclasses import ( - dataclass, - field, - replace, -) -from typing import TYPE_CHECKING, Annotated, ClassVar, Literal +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING, Literal from agent_comms.coordination_tables.executions import ExecutionRecord @@ -34,11 +30,10 @@ JsonShapeMember, MalformedRecord, NativeContext, - WireName, ) from .native_session_reopen import PiSessionId from .transcript_routes import InputDisplay, NoDisplayText -from .typed_table import Column, IntegerStorage, SqlStorage, TextStorage +from .typed_table import SqlStorage, TextStorage if TYPE_CHECKING: from sqlite3 import Connection @@ -52,42 +47,10 @@ from .message_reference import MessageReference from .native_entries import MessageEntry, RecordedEntry, TranscriptProjection from .native_runtime_input import NativeRuntimeInput - from .native_pi import NativeContextProof + from .native_input_proof import NativeContextProof from .transcript_events import TranscriptEvent -@dataclass(frozen=True, slots=True) -class NativeInputCommit: - """Original emitted input identity shared with its assembled context.""" - - input_id: Annotated[ - InputId, - WireName("inputId"), - Column(primary_key=True), - ] - session_id: Annotated[PiSessionId, WireName("sessionId")] - session_entry_id: Annotated[str, WireName("sessionEntryId")] - - @property - def input_commit(self) -> NativeInputCommit: - return NativeInputCommit(self.input_id, self.session_id, self.session_entry_id) - - -@dataclass(frozen=True) -class NativeInputLocation(NativeInputCommit): - """Emitted byte placement; only the original runtime input row persists it.""" - - # SQL permits an uncommitted row; the emitted offset remains a required int. - position_column: ClassVar[Column] = Column( - check="session_entry_start>0", - nullable=True, - storage=IntegerStorage, - ) - session_entry_start: Annotated[ - int, WireName("sessionEntryStart"), position_column - ] = field(kw_only=True) - - class NativeInputPosition(DeclaredFamily, JsonShapeFamily, affix="NativePosition"): """Decode the original SQL placement once: uncommitted NULL or a byte offset.""" diff --git a/src/agent_comms/native_pi.py b/src/agent_comms/native_pi.py index 8477e87b05..84db56ba51 100644 --- a/src/agent_comms/native_pi.py +++ b/src/agent_comms/native_pi.py @@ -9,15 +9,11 @@ import os import re import shutil -import sqlite3 import sys -from collections.abc import Iterator -from contextlib import ExitStack, closing, contextmanager +from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Literal - -from pydantic import JsonValue +from typing import TYPE_CHECKING from agent_comms.participant_identity import ParticipantIdentity @@ -31,17 +27,12 @@ FieldCodec, InputId, MalformedRecord, - PathText, - Sha256Text, - WireName, - boundary_decoder, ) from .launch_root import LaunchRoot from .launch_runtime import LaunchRuntime, RestartEnvironment, ThreadLaunch -from .locked_store import SQLiteFile from .native_arguments import NativeArguments, SelectedModel from .native_failures import NativePiUnavailable, NativeRequestFailure -from .native_input_record import NativeInputCommit, NativeInputLocation +from .native_input_proof import NativeContextProof, NativeContextRecord from .native_package import MANIFEST, UNIT_INSTRUCTIONS, VerifiedNativePackage from .native_session_files import NativeAgentSettings, NativeSessionFiles from .native_tool_call import NativeToolMode @@ -52,18 +43,10 @@ PrivateFileRole, TrustedAncestorRole, ) -from .typed_table import ( - Column, - DurableTables, - GuardedInserts, - ImmutableRows, - Index, - WithoutRowid, -) if TYPE_CHECKING: from . import pi_events as pi - from .native_entries import NativeEntry, NativeEvidenceRead, SessionEntry + from .native_entries import NativeEvidenceRead from .selected_session import SelectedSession from .turn_failure import TurnFailure from .native_session_reopen import PiSessionId @@ -160,224 +143,6 @@ def main() -> int: return 0 -@dataclass(frozen=True, slots=True) -class NativeContextRecord(NativeInputCommit): - """Native context facts extend the original committed input declaration.""" - - request_generation: Annotated[ - int, - WireName("requestGeneration"), - Column(primary_key=True, check="request_generation BETWEEN 1 AND 9007199254740991"), - ] - llm_context_digest: Annotated[ - str, - Sha256Text, - WireName("llmContextDigest"), - ] - # The native writer stamps the moment this context was committed for a - # model request: the time the input was shown to the model. - committed_at_ms: Annotated[ - int, - WireName("committedAtMs"), - Column(check="committed_at_ms BETWEEN 1 AND 9007199254740991"), - ] - - @classmethod - def from_events( - cls, input_id: InputId, session_id: PiSessionId, - input_event: pi.InputCommitted, context_event: pi.ContextCommitted, - ) -> NativeContextRecord: - """Strictly join the two emitted records through the context declaration. - - This is an emitted acknowledgement, not proof of durable inclusion. A journal - read must independently corroborate the same complete located record. - """ - emitted = FieldCodec.encode(context_event) - record = FieldCodec.decode(cls, {key: emitted[key] - for _, key in FieldCodec.record_fields(cls)}) - if record.input_commit != NativeInputCommit(input_id, session_id, record.session_entry_id): - raise NativePiUnavailable("Native Pi context names another original input") - if record.input_commit != input_event.input_commit: - raise NativePiUnavailable("Native Pi input/context events disagree") - return record - - def __post_init__(self): - Sha256Text.decode(self.llm_context_digest) - if not 0 < self.request_generation <= 2**53 - 1: - raise ValueError("Native context generation is outside its native range") - if not 0 < self.committed_at_ms <= 2**53 - 1: - raise ValueError("Native context commit time is outside its native range") - - def at(self, session_file: Path, *, session_entry_start: int) -> NativeContextProof: - """Locate recorded facts; this does not grant acceptance or replay.""" - return NativeContextProof( - self.input_id, - self.session_id, - self.session_entry_id, - self.request_generation, - self.llm_context_digest, - self.committed_at_ms, - session_file, - session_entry_start=session_entry_start, - ) - - -@dataclass(frozen=True, slots=True) -class NativeContextJournal( - GuardedInserts, ImmutableRows, NativeContextRecord, WithoutRowid, DurableTables -): - """One proof declaration owns wire facts and the indexed durable journal.""" - - insert_condition = """NEW.request_generation < COALESCE((SELECT MAX(request_generation) - FROM native_context_journal), 0) OR EXISTS(SELECT 1 FROM native_context_journal - WHERE request_generation=NEW.request_generation - AND (llm_context_digest != NEW.llm_context_digest - OR committed_at_ms != NEW.committed_at_ms)) OR NEW.session_id - != (SELECT session_id FROM native_context_journal ORDER BY session_id LIMIT 1)""" - - - schema: Literal[2] - type: Literal["context_committed"] - - indexes = (Index(("request_generation",)), Index(("session_id",))) - - @classmethod - def native_contract(cls) -> dict[str, JsonValue]: - """Generate the native writer's SQLite contract from this declaration.""" - table = cls.declared_name - return { - "objects": cls.schema_objects(), - "columns": [ - {"name": plan.name, "wire": plan.key} for plan in FieldCodec.field_plans(cls) - ], - "insert": cls.insertion_statement(), - "head": f"SELECT COALESCE(MAX(request_generation),0) AS generation FROM {table}", - "session": f"SELECT session_id AS id FROM {table} ORDER BY session_id LIMIT 1", - "current": f"SELECT input_id,session_entry_id FROM {table} " - f"WHERE request_generation=(SELECT MAX(request_generation) FROM {table})", - } - - @classmethod - @contextmanager - def open_evidence(cls, files: NativeSessionFiles) -> Iterator[sqlite3.Connection]: - """Read an indexed snapshot after SQLite's bounded hot-journal recovery. - - A read is corroboration, never a fresh acknowledgement. Keep the private inode - pinned across SQLite's transaction and refuse redirection or schema drift. - Open existing storage read/write for SQLite rollback, then forbid SQL - writes. A cold history view can recover without a model turn or acknowledgement. - """ - path = files.input_proof - with ExitStack() as ownership: - try: - held = ownership.enter_context(closing( - os.fdopen(PrivateFileRole.read( - path, NativePiUnavailable("Native proof indexed evidence is unavailable")), "rb") - )) - before = os.fstat(held.fileno()) - db = ownership.enter_context(closing(SQLiteFile.recovering_snapshot(path))) - if cls.schema_differs(db): - raise NativePiUnavailable("Native proof requires offline durable conversion") - if FileIdentity.from_stat(before) != FileIdentity.from_stat(path.lstat()): - raise NativePiUnavailable("Native proof inode changed while opening") - except (OSError, sqlite3.Error, RelationViolationError, MalformedRecord) as error: - raise NativePiUnavailable("Native proof indexed evidence is unavailable") from error - # Consumer exceptions retain their original boundary and disposition. - # The ExitStack closes the database and descriptor without handling caller errors. - yield db - try: - if FileIdentity.from_stat(before) != FileIdentity.from_stat(path.lstat()): - raise NativePiUnavailable("Native proof inode changed during observation") - except OSError as error: - raise NativePiUnavailable("Native proof indexed evidence is unavailable") from error - - @classmethod - def for_input( - cls, db: sqlite3.Connection, input_id: InputId, generation: int | None = None - ) -> NativeContextJournal | None: - """The primary key answers one input without scanning lifetime history.""" - where = "input_id=?" - parameters: tuple[InputId | int, ...] = (input_id,) - if generation is not None: - where += " AND request_generation=?" - parameters += (generation,) - rows = cls.read( - db.execute( - f"SELECT {cls._column_list(tuple(cls.column_projection()))} FROM {cls.declared_name} " - f"WHERE {where} ORDER BY request_generation DESC LIMIT 1", - parameters, - ) - ) - return next(iter(rows), None) - - def corroborate( - self, session_file: Path, header: SessionEntry, entry: NativeEntry, *, - session_entry_start: int, - ) -> NativeContextProof: - if self.session_id != header.id or self.session_entry_id != entry.id: - raise NativePiUnavailable("Native Pi proof journal source lineage differs") - return self.at(session_file, session_entry_start=session_entry_start) - - -@dataclass(frozen=True, slots=True) -class NativeContextProof(NativeContextRecord, NativeInputLocation): - session_file: Annotated[Path, PathText] - - def require_continuation(self, context: NativeContextProof) -> None: - """Repeated requests may include this input; its first receipt stays immutable. - - A different native entry or conflicting proof of the same request is not - another observation of that entry. Callers corroborate later generations - before this check; this comparison grants no native or replay authority. - """ - if (self.input_commit, self.session_file, self.session_entry_start) != ( - context.input_commit, context.session_file, context.session_entry_start - ): - raise IdentityConflict("Native context acknowledgement belongs to another entry") - if context.request_generation < self.request_generation: - raise IdentityConflict("Native context precedes its original acknowledgement") - if context.request_generation == self.request_generation and context != self: - raise IdentityConflict("Native request has contradictory context evidence") - - def corroborates_input( - self, input_id: InputId, *, files: NativeSessionFiles, - evidence: NativeEvidenceRead, - ) -> bool: - """Check an already-observed live event against its isolated saved proof. - - This grants neither replay nor recovery authority. History IO occurs - before the coordinator read transaction, as on the native return path. - """ - if self.input_id != input_id or files.session != self.session_file: - return False - return self.read_evidence( - files, input_id, - request_generation=self.request_generation, evidence=evidence, - session_entry_start=self.session_entry_start, - ) == self - - @classmethod - @boundary_decoder - def read_evidence( - cls, files: NativeSessionFiles, input_id: InputId, *, request_generation: int | None = None, - evidence: NativeEvidenceRead | None = None, - session_entry_start: int, - ) -> NativeContextProof: - """Corroborate live recorded events; parsed bytes alone grant no authority.""" - session_file = files.session.absolute() - from .native_entries import NativeInputEvidenceRead - with NativeInputEvidenceRead.borrow(session_file, evidence) as evidence: - with NativeContextJournal.open_evidence(files) as db: - row = NativeContextJournal.for_input(db, input_id, request_generation) - if row is None: - raise NativePiUnavailable("The input has no assembled-context proof") - location = NativeInputLocation(input_id, row.session_id, row.session_entry_id, - session_entry_start=session_entry_start) - entry = evidence.read_input(location) - return row.corroborate(session_file, evidence.header(), entry, - session_entry_start=session_entry_start) - - class NativePiTerminalFailure(NativePiUnavailable): """A proved input ended in a failed terminal and its owned process was reaped.""" diff --git a/src/agent_comms/native_prompt_send.py b/src/agent_comms/native_prompt_send.py index 8ab9936147..b3ea169690 100644 --- a/src/agent_comms/native_prompt_send.py +++ b/src/agent_comms/native_prompt_send.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: from .input_drain import InputDrain from .owned_send import OwnedSend - from .native_pi import NativeContextProof + from .native_input_proof import NativeContextProof from .native_entries import NativeEvidenceScope from .tracked_turn import TrackedTurnSession diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index cb39a9193d..3b0768a045 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -35,7 +35,6 @@ NativeColumn, NativeInputContext, NativeInputExecution, - NativeInputLocation, NativeInputPosition, NativeInputRecord, NativeStageStorage, @@ -46,7 +45,7 @@ UnrecordedNativeColumn, UnrecordedNativePosition, ) -from .native_pi import NativeContextRecord +from .native_input_proof import NativeContextRecord, NativeInputLocation from .native_session_reopen import ( NativeSessionIdentity, NoPiSessionId, @@ -77,7 +76,7 @@ ) if TYPE_CHECKING: - from .native_pi import NativeContextProof + from .native_input_proof import NativeContextProof from .native_session_reopen import NativeSessionIdentity diff --git a/src/agent_comms/native_session_files.py b/src/agent_comms/native_session_files.py index 465b0b5192..3202f2fe29 100644 --- a/src/agent_comms/native_session_files.py +++ b/src/agent_comms/native_session_files.py @@ -256,7 +256,7 @@ class NativeInputProof(DurableFile, ReconciledFile, PatternFile): @classmethod def table_schema(cls) -> type[TableSchema]: - from .native_pi import NativeContextJournal + from .native_input_proof import NativeContextJournal return NativeContextJournal diff --git a/src/agent_comms/native_session_reopen.py b/src/agent_comms/native_session_reopen.py index dd9cc76c2d..b840783cab 100644 --- a/src/agent_comms/native_session_reopen.py +++ b/src/agent_comms/native_session_reopen.py @@ -16,7 +16,7 @@ from .native_child import PiSessionChild from .native_runtime_input import NativeRuntimeInput from .native_entries import NativeEvidenceRead - from agent_comms.native_pi import NativeContextProof + from .native_input_proof import NativeContextProof from .pi_native_backend import PersistentPiSession diff --git a/src/agent_comms/native_transcript.py b/src/agent_comms/native_transcript.py index a6a7a106c0..8fcccdbaf4 100644 --- a/src/agent_comms/native_transcript.py +++ b/src/agent_comms/native_transcript.py @@ -12,10 +12,10 @@ from typing import TYPE_CHECKING, BinaryIO from .errors import RelationViolationError -from .native_pi import NativeContextJournal +from .native_input_proof import NativeContextJournal if TYPE_CHECKING: - from .native_pi import NativeContextProof + from .native_input_proof import NativeContextProof from .native_output import NativeRecordLocation from .read_ledger import ReadLedger from .thread_identity import RecordedThreadIncarnation diff --git a/src/agent_comms/pi_commands.py b/src/agent_comms/pi_commands.py index 47d5dc4076..5113921a93 100644 --- a/src/agent_comms/pi_commands.py +++ b/src/agent_comms/pi_commands.py @@ -60,7 +60,7 @@ from .launch_runtime import RestartEnvironment from .tracked_turn import TrackedTurnSession from .native_entries import NativeEvidenceScope - from .native_pi import NativeContextProof + from .native_input_proof import NativeContextProof from .pi_events import InputCommitted diff --git a/src/agent_comms/pi_events.py b/src/agent_comms/pi_events.py index cfe1f5a292..012b695abd 100644 --- a/src/agent_comms/pi_events.py +++ b/src/agent_comms/pi_events.py @@ -22,7 +22,7 @@ WireOmitDefault, ) from .native_failures import NativePiUnavailable -from .native_input_record import NativeInputCommit, NativeInputLocation +from .native_input_proof import NativeInputCommit, NativeInputLocation from .native_output import NativeOutputLocation, NativeRecordLocation from .native_pi import NativePiOperationFailed from .native_turn_context import NativeContextManifestData diff --git a/src/agent_comms/private_send_claim.py b/src/agent_comms/private_send_claim.py index 3ba0c46a8f..ec460a3ecc 100644 --- a/src/agent_comms/private_send_claim.py +++ b/src/agent_comms/private_send_claim.py @@ -30,7 +30,8 @@ from .native_arguments import SelectedModel from .native_input_owner import ParticipantOwner, RegistryOwner from .native_entries import NativeEvidenceScope -from .native_pi import NativeContextProof, NativePiTerminalFailure, NativeTurnResult +from .native_input_proof import NativeContextProof +from .native_pi import NativePiTerminalFailure, NativeTurnResult from .native_prompt_binding import bind_expected_prompt from .native_prompt_send import PromptBoundary from .native_session_reopen import NativeSessionIdentity diff --git a/src/agent_comms/private_send_stage.py b/src/agent_comms/private_send_stage.py index 8ad56e2c28..da48ea11fc 100644 --- a/src/agent_comms/private_send_stage.py +++ b/src/agent_comms/private_send_stage.py @@ -29,7 +29,7 @@ ) from .native_input_owner import ParticipantOwner from .native_input_record import (NativeInputIdentity, NativeInputExecution, TriageNativeExecution, FullNativeExecution) -from .native_pi import NativeContextProof +from .native_input_proof import NativeContextProof from .native_entries import NativeEvidenceScope from .native_prompt_binding import ( PromptBinding, diff --git a/src/agent_comms/selected_triage.py b/src/agent_comms/selected_triage.py index 345cf88922..b0a755859a 100644 --- a/src/agent_comms/selected_triage.py +++ b/src/agent_comms/selected_triage.py @@ -31,7 +31,7 @@ from .coordinated_runtime import SelectedExecution from .historical_native_inputs import HistoricalNativeInput, TriageHistoricalNativeInput from .native_input_record import TriageNativeExecution - from .native_pi import NativeContextProof + from .native_input_proof import NativeContextProof from .native_runtime_input import NativeRuntimeInput from .private_send_claim import PrivateSendClaim from .private_send_stage import TriageNativeSend diff --git a/src/agent_comms/tracked_turn.py b/src/agent_comms/tracked_turn.py index e14f96e473..0d8d9b5a85 100644 --- a/src/agent_comms/tracked_turn.py +++ b/src/agent_comms/tracked_turn.py @@ -29,8 +29,8 @@ from .native_attestation import AttestationError, ObservedAttestation from .native_failures import NativePiUnavailable from .native_package import VerifiedNativePackage +from .native_input_proof import NativeContextProof from .native_pi import ( - NativeContextProof, NativeEmptyAnswer, NativePiPromptRejected, NativeTurnResult, diff --git a/tests/native_proof_cases.py b/tests/native_proof_cases.py index f39d59015c..6e233201fb 100644 --- a/tests/native_proof_cases.py +++ b/tests/native_proof_cases.py @@ -8,7 +8,7 @@ from pathlib import Path from agent_comms.field_codec import FieldCodec -from agent_comms.native_pi import NativeContextJournal, NativeContextProof, NativeContextRecord +from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof, NativeContextRecord from agent_comms.native_session_files import NativeSessionFiles diff --git a/tests/round2/test_typed_table.py b/tests/round2/test_typed_table.py index e8c6dc5093..297d8dfc45 100644 --- a/tests/round2/test_typed_table.py +++ b/tests/round2/test_typed_table.py @@ -395,7 +395,7 @@ class LeanBoardLine(DurableTables, TypedTable, declared_name="lean_board_line"): def test_atomic_creation_preserves_declared_indexes_triggers_and_strict_creation(): - from agent_comms.native_pi import NativeContextJournal + from agent_comms.native_input_proof import NativeContextJournal with sqlite3.connect(':memory:') as db: NativeContextJournal.create(db, if_not_exists=True) diff --git a/tests/test_bus_files.py b/tests/test_bus_files.py index 11f16b0386..83311c00f2 100644 --- a/tests/test_bus_files.py +++ b/tests/test_bus_files.py @@ -15,7 +15,7 @@ from agent_comms.field_codec import ClaimId, GoalId, InputId, TurnId from agent_comms.installer.backup import bus_files from agent_comms.locked_store import DeclaredFile, DurableFile, LiveFilesRequest, RootFile, RuntimeFile, SQLiteFile -from agent_comms.native_pi import NativeContextJournal +from agent_comms.native_input_proof import NativeContextJournal from agent_comms.native_session_files import NativeInputProof from agent_comms.recorded_time import RecordedEventTime from agent_comms.repository_name import RepositoryName diff --git a/tests/test_following_context_notifications.py b/tests/test_following_context_notifications.py index 6eb462f56d..6156cb95e5 100644 --- a/tests/test_following_context_notifications.py +++ b/tests/test_following_context_notifications.py @@ -21,7 +21,7 @@ from agent_comms.input_key import InputKey from agent_comms.input_origin import WireInputOrigin from agent_comms.native_failures import NativePiUnavailable -from agent_comms.native_pi import NativeContextJournal, NativeContextProof +from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof from agent_comms.native_tool_call import OwnerToolConnection from agent_comms.pi_commands import FollowingPrompt from agent_comms.tracked_turn import ObservedNativeCommit, TrackedInputContext diff --git a/tests/test_live_context_notifications.py b/tests/test_live_context_notifications.py index d7628f62f8..70849d2bb8 100644 --- a/tests/test_live_context_notifications.py +++ b/tests/test_live_context_notifications.py @@ -10,7 +10,7 @@ from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordinator import Coordination from agent_comms.field_codec import InputId -from agent_comms.native_pi import NativeContextJournal, NativeContextProof +from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof from agent_comms.native_runtime_input import NativeRuntimeInput from agent_comms.presentation import AllOutcomes from agent_comms.private_send_claim import PrivateSendClaim diff --git a/tests/test_native_context_reference.py b/tests/test_native_context_reference.py index a45e53ac0d..5ad9bd5fa5 100644 --- a/tests/test_native_context_reference.py +++ b/tests/test_native_context_reference.py @@ -36,7 +36,7 @@ UnrecordedNativeColumn, UnrecordedNativeInputReference, ) -from agent_comms.native_pi import NativeContextProof +from agent_comms.native_input_proof import NativeContextProof from agent_comms.native_runtime_input import CurrentNativeCursor, NativeRuntimeInput from agent_comms.private_path import FileIdentity from agent_comms.proven_source_coverage import InjectedInput, NoInjectedInput, ProvenSourceCoverage, SourcePrefix diff --git a/tests/test_native_evidence_custody.py b/tests/test_native_evidence_custody.py index 1978f924fe..883ae20be6 100644 --- a/tests/test_native_evidence_custody.py +++ b/tests/test_native_evidence_custody.py @@ -13,8 +13,8 @@ from agent_comms.coordinator import Coordination from agent_comms.native_entries import NativeEntry, NativeEvidenceScope from agent_comms.native_failures import NativePiUnavailable -from agent_comms.native_input_record import NativeInputLocation -from agent_comms.native_pi import NativeContextJournal, NativeContextProof, PrivateEvidenceRead +from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof, NativeInputLocation +from agent_comms.native_pi import PrivateEvidenceRead from agent_comms.native_transcript import NativeTranscript from agent_comms.store_files import ( ExclusiveLock, diff --git a/tests/test_native_input_bus_path.py b/tests/test_native_input_bus_path.py index d6c39b0356..31b2398020 100644 --- a/tests/test_native_input_bus_path.py +++ b/tests/test_native_input_bus_path.py @@ -20,7 +20,7 @@ from agent_comms.native_package import VerifiedNativePackage from agent_comms.turn_watchdog import ModelWait from agent_comms.native_arguments import NativeInputProofArgument -from agent_comms.native_pi import NativeContextJournal, NativeContextProof +from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof from agent_comms.native_transcript import NativeTranscript from bus_history import read_history from delivery_owner_fixture import reply_turn_finished diff --git a/tests/test_native_input_evidence_index.py b/tests/test_native_input_evidence_index.py index 69b88a07d6..e572fd5a8d 100644 --- a/tests/test_native_input_evidence_index.py +++ b/tests/test_native_input_evidence_index.py @@ -4,7 +4,7 @@ import pytest from agent_comms.native_entries import NativeEvidenceScope -from agent_comms.native_pi import NativeContextProof +from agent_comms.native_input_proof import NativeContextProof from agent_comms.native_session_reopen import NativeSessionIdentity from native_proof_cases import write_proof_rows from test_native_pi import INPUT_ID, _evidence diff --git a/tests/test_native_pi.py b/tests/test_native_pi.py index 4b735a4679..aa2b1ba2f9 100644 --- a/tests/test_native_pi.py +++ b/tests/test_native_pi.py @@ -16,9 +16,8 @@ from agent_comms.native_arguments import SelectedModel from agent_comms.native_entries import NativeEntry from agent_comms.native_failures import NativePiUnavailable +from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof from agent_comms.native_pi import ( - NativeContextJournal, - NativeContextProof, NativePiRpcLaunch, NativePiTerminalFailure, ) diff --git a/tools/render_native_proof_schema.py b/tools/render_native_proof_schema.py index 32663c505f..d79e57cea0 100644 --- a/tools/render_native_proof_schema.py +++ b/tools/render_native_proof_schema.py @@ -4,7 +4,7 @@ import sys from pathlib import Path -from agent_comms.native_pi import NativeContextJournal +from agent_comms.native_input_proof import NativeContextJournal def main() -> None: From 3312938708bcd3db0d11e1e6930cb90b9612bfcd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 21:14:20 -0400 Subject: [PATCH 060/109] Share verified input context across ordinary and selected native turns --- src/agent_comms/agent_backend.py | 10 ++ src/agent_comms/backend.py | 127 +++++++++++++++-- src/agent_comms/following_inputs.py | 30 ---- src/agent_comms/owned_send.py | 31 +++++ src/agent_comms/owned_turn.py | 3 + src/agent_comms/pi_commands.py | 16 +-- src/agent_comms/pi_native_backend.py | 1 + src/agent_comms/tracked_turn.py | 128 ++---------------- src/agent_comms/turn_input_source.py | 18 +++ tests/native_backend_fixture.py | 4 + tests/pi_session_turn.py | 12 +- tests/test_following_context_notifications.py | 2 +- tests/test_pi_payloads.py | 2 +- tests/test_stack_native_compaction.py | 3 +- 14 files changed, 215 insertions(+), 172 deletions(-) diff --git a/src/agent_comms/agent_backend.py b/src/agent_comms/agent_backend.py index 050ac80ce4..7bac91ab2f 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -41,6 +41,7 @@ from .agent_events import AgentEvent from .launch_runtime import LaunchRuntime + from .native_input_proof import NativeContextProof from .owned_turn import TurnDelivery from .threads import Thread from .transcript_sources import TranscriptSource @@ -308,6 +309,15 @@ def original(self, native_id: InputId, text: str) -> bool: ... def following(self, public_id: InputId, native_id: InputId, text: str) -> bool: ... + async def original_context_committed(self, sent_text: str, context: NativeContextProof) -> None: + """An unowned input has no durable ordinary disposition to acknowledge.""" + + async def following_context_committed( + self, public_id: InputId, sent_text: str, context: NativeContextProof, + ) -> None: + """An unowned following input has no durable ordinary disposition.""" + + class NoInputStartListener(InputStartListener): started_events = () diff --git a/src/agent_comms/backend.py b/src/agent_comms/backend.py index 1e0222b47f..1cb4a3ed38 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -29,10 +29,10 @@ Sequence, ) from contextlib import AsyncExitStack, aclosing, contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, field from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar from agent_comms.participant_identity import ParticipantIdentity from agent_comms.pi_payloads import RequestId @@ -55,12 +55,14 @@ from .native_child import PiSessionChild from .native_failures import NativePiUnavailable from .native_package import VerifiedNativePackage -from .native_pi import NativePiRpcLaunch +from .native_input_proof import NativeContextProof +from .native_pi import NativePiRpcLaunch, _verify_context, read_tracked_input_commitment from .native_session_reopen import SessionRetention from .native_startup import NATIVE_STARTUP_POLICY, NativeStartup from .native_tool_call import NativeToolMode from .native_tool_transport import ToolTransport, UnboundToolTransport from .pi_rpc import PiRpcChannel +from .private_sidecar import native_request_digest from .prompt_state import PromptState, QueuedOperation, UnwrittenPrompt from .request_progress import NO_REQUEST_OBSERVER, RequestObserver, RequestProgress from .send_outcome import NO_SEND_BOUNDARY, SendBoundary @@ -76,6 +78,10 @@ if TYPE_CHECKING: from .acp_failure import ProviderRetryResult + from .cursor_publication import CursorDelivery + from .native_entries import NativeEvidenceScope + from .native_session_files import NativeSessionFiles + from .native_session_reopen import PiSessionId from .pi_native_backend import PersistentPiSession from .pi_payloads import AssistantMessage, UserMessage @@ -121,6 +127,7 @@ async def stream_agent_events( rpc_abort_grace: float = RPC_ABORT_GRACE_SECONDS, send_boundary: SendBoundary = NO_SEND_BOUNDARY, *, + cursor_delivery: CursorDelivery, input_start: InputStartListener, persistent_session: PersistentPiSession, package: VerifiedNativePackage, @@ -174,6 +181,7 @@ async def stream_agent_events( rpc_abort_grace=rpc_abort_grace, send_boundary=send_boundary, input_start=input_start, + cursor_delivery=cursor_delivery, native_session=persistent_session, ui_request=ui_request, request_observer=request_observer, @@ -544,9 +552,6 @@ async def initialize_rpc(self) -> None: self.active_session_file = self.launch.session.session_file self.watchdog.reading() - def following_started(self, command: commands.FollowingPrompt) -> None: - """An ordinary session has no tracked model-context proof observer.""" - async def finish_input(self) -> None: """Retire snapshot work before the input transport it borrows.""" await self.stats.close() @@ -580,6 +585,79 @@ def finish_result(self) -> Iterable[events.AgentEvent]: yield from self.output.done(self, self.error_text) +T = TypeVar("T") + + +class NativeCommitObservation(ABC, Generic[T]): + """Observed native input records, without permission to start or replay input.""" + + @abstractmethod + def require(self) -> T: ... + + def capture(self, event: T) -> NativeCommitObservation[T]: + raise NativePiUnavailable("Native Pi repeated the input commitment") + + +class PendingNativeCommit(NativeCommitObservation[T]): + def require(self) -> T: + raise NativePiUnavailable("Native Pi did not commit a tracked model context") + + def capture(self, event: T) -> NativeCommitObservation[T]: + return ObservedNativeCommit(event) + + +@dataclass(frozen=True) +class ObservedNativeCommit(NativeCommitObservation[T]): + event: T + + def require(self) -> T: + return self.event + + +@dataclass +class TrackedInputContext: + """One actually sent command owns its emitted join, bounded by this run's scope.""" + + command: commands.Prompt + input_commit: NativeCommitObservation[pi.InputCommitted] = field(default_factory=PendingNativeCommit) + context_commit: NativeCommitObservation[pi.ContextCommitted] = field(default_factory=PendingNativeCommit) + + async def committed_input(self, turn: TurnSession, event: pi.InputCommitted) -> None: + self.input_commit = self.input_commit.capture(event) + async with turn.cursor_delivery.reading() as source_reads: + await Coordination.run_worker(lambda: + source_reads.for_source(turn.launch.session.files(turn.launch.root).session).observe(event)) + await self.command.input_committed(turn, event) + + def proof( + self, files: NativeSessionFiles, session_id: PiSessionId, + source_reads: NativeEvidenceScope, + ) -> NativeContextProof: + evidence = source_reads.for_source(files.session) + proof = _verify_context( + files, self.command.input_id, session_id, + self.input_commit.require(), self.context_commit.require(), evidence=evidence, + ) + committed = read_tracked_input_commitment( + proof.session_file, self.command.input_id, evidence=evidence, + ) + if not committed.matches(native_request_digest( + self.command.message, streaming_behavior=self.command.streaming_behavior, + images=self.command.images, + )): + raise NativePiUnavailable("Native context differs from its actual scoped input request") + return proof + + async def committed_context(self, turn: TurnSession, event: pi.ContextCommitted) -> None: + self.context_commit = ObservedNativeCommit(event) + async with turn.cursor_delivery.reading() as source_reads: + proof = await Coordination.run_worker(partial( + self.proof, turn.launch.session.files(turn.launch.root), + turn.native.attestation.require_identity().session_id, source_reads, + )) + await self.command.context_committed(turn, proof, source_reads) + + class TurnSession(NativeSession): """A native input adds its declared tools and constructs its connected command.""" @@ -591,21 +669,54 @@ def provider_retry_ended(self, event: pi.AutoRetryEnd) -> tuple[events.AgentEven return updates def __init__(self, launch, make_command: Callable[..., commands.Prompt], *, - tool_mode: NativeToolMode, **options): + tool_mode: NativeToolMode, cursor_delivery: CursorDelivery, **options): super().__init__(launch, **options) self.tool_mode = tool_mode self.make_command = make_command + self.cursor_delivery = cursor_delivery + self.contexts: dict[InputId, TrackedInputContext] = {} async def open_transport(self, resources: AsyncExitStack) -> UnboundToolTransport: + resources.callback(self.contexts.clear) unbound = await self.tool_mode.open_transport(self, resources) self.command = self.make_command(owner_tools=unbound.listener.connection) del self.make_command + self.contexts[self.command.input_id] = TrackedInputContext(self.command) self.output.sensitive = bool(self.command.images) return unbound - async def context_proof(self): + def following_started(self, command: commands.FollowingPrompt) -> None: + if command.input_id in self.contexts: + raise NativePiUnavailable("A tracked following input reused an in-scope native identity") + self.contexts[command.input_id] = TrackedInputContext(command) + + async def original_committed(self, event: pi.InputCommitted) -> None: + """An ordinary original already started through its matched MessageStart.""" + + async def original_context_committed( + self, context: NativeContextProof, source_reads: NativeEvidenceScope, + ) -> None: + await self.input_start.original_context_committed(self.command.message, context) + + @handles(pi.InputCommitted) + async def committed_input(self, event: pi.InputCommitted) -> None: + if context := self.contexts.get(event.input_id): + await context.committed_input(self, event) + + @handles(pi.ContextCommitted) + async def committed_context(self, event: pi.ContextCommitted) -> None: + if context := self.contexts.get(event.input_id): + await context.committed_context(self, event) + + async def context_proof(self) -> NativeContextProof: if not self.started_input or not self.accepts_output: raise SelectedToolDenied("Native tool has no started input") + async with self.cursor_delivery.reading() as source_reads: + return await Coordination.run_worker(partial( + self.contexts[self.command.input_id].proof, + self.launch.session.files(self.launch.root), + self.native.attestation.require_identity().session_id, source_reads, + )) @handles(pi.ToolExecutionStart) async def tool_started(self, event): diff --git a/src/agent_comms/following_inputs.py b/src/agent_comms/following_inputs.py index 806a5bb2eb..2b6d31ec8e 100644 --- a/src/agent_comms/following_inputs.py +++ b/src/agent_comms/following_inputs.py @@ -10,8 +10,6 @@ from .acp_extension import LiveQueueScope from .agent_backend import TurnInbox from .coordinator import Coordination -from .errors import RelationViolationError -from .ordinary_turn_rules import RunningOrdinaryOwnerCheck from .queued_input import DeliveredInput, InputHandoffRefused from .thread_identity import ClaimedIdentity from .turn_input_source import AcceptedFollowingInput @@ -23,7 +21,6 @@ from .input_drain import InputDrain from .tracked_turn import TrackedTurnSession from .owned_send import OwnedSend - from .native_input_proof import NativeContextProof class FollowingInputs: @@ -48,11 +45,6 @@ async def open(self, session: TrackedTurnSession) -> None: async def started(self, input_id: InputId) -> AcceptedFollowingInput: raise AssertionError("A turn without an inbox cannot start a following input") - async def context_committed( - self, input_id: InputId, sent_text: str, context: NativeContextProof, - ) -> None: - raise AssertionError("A turn without an inbox cannot acknowledge a following context") - async def retired(self, event: InputRefused) -> None: raise AssertionError("A turn without an inbox cannot retire a following input") @@ -114,27 +106,5 @@ async def started(self, input_id: InputId) -> AcceptedFollowingInput: await self.ready(self.inputs, self.session_id) return source - async def context_committed( - self, input_id: InputId, sent_text: str, context: NativeContextProof, - ) -> None: - source = self.inputs.state(self.session_id).following[input_id] - def acknowledge() -> None: - if self.inputs.state(self.session_id).following.get(input_id) is not source: - raise RelationViolationError("Following context outlived its actual accepted input") - sender = self.sender - with self.inputs.comms.registry.store.current_snapshot() as cut: - snapshot = cut.snapshot - canonical = snapshot.canonical_name(sender.thread.name) - current = sender.require_owner(snapshot, canonical) - sender._check_owner( - current, snapshot, canonical, source, sent_text, - check_type=RunningOrdinaryOwnerCheck, - ) - self.inputs.dispositions.context_committed( - source.keys, lease=sender.thread.require_turn_lease(), - sent_text=sent_text, context=context, snapshot=snapshot, - ) - await Coordination.run_worker(acknowledge) - async def retired(self, event: InputRefused) -> None: await event.apply_to(self.inputs, self.session_id) diff --git a/src/agent_comms/owned_send.py b/src/agent_comms/owned_send.py index 98f9ea2b3d..f54321eed6 100644 --- a/src/agent_comms/owned_send.py +++ b/src/agent_comms/owned_send.py @@ -5,6 +5,7 @@ import logging from contextlib import AbstractContextManager from dataclasses import dataclass +from functools import partial from pathlib import Path from typing import TYPE_CHECKING @@ -39,6 +40,7 @@ from .goal_waits import GoalWait from .input_drain import InputDrain + from .native_input_proof import NativeContextProof from .registry_document import RegistrySnapshot from .threads import Thread from .turn_goal_permission import TurnGoalPermission @@ -172,6 +174,35 @@ def original(self, native_id: InputId, text: str) -> bool: def following(self, public_id: InputId, native_id: InputId, text: str) -> bool: return self._started(self.source(public_id), native_id, text) + async def original_context_committed(self, sent_text: str, context: NativeContextProof) -> None: + await Coordination.run_worker(partial( + self._record_context, self.original_source, sent_text, context, + )) + + async def following_context_committed( + self, public_id: InputId, sent_text: str, context: NativeContextProof, + ) -> None: + await Coordination.run_worker(partial( + self._record_context, self.source(public_id), sent_text, context, + )) + + def _record_context( + self, source: TurnInputSource, sent_text: str, context: NativeContextProof, + ) -> None: + with self.inputs.comms.registry.store.current_snapshot() as cut: + snapshot = cut.snapshot + source.require_context_source(self) + canonical = snapshot.canonical_name(self.thread.name) + current = self.require_owner(snapshot, canonical) + self._check_owner( + current, snapshot, canonical, source, sent_text, + check_type=RunningOrdinaryOwnerCheck, + ) + self.inputs.dispositions.context_committed( + source.keys, lease=self.thread.require_turn_lease(), + sent_text=sent_text, context=context, snapshot=snapshot, + ) + def _started(self, source: TurnInputSource, native_id: InputId, sent_text: str) -> bool: return source.valid_keys(sent_text) and all( self.inputs.dispositions.started( diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index 78f48ff91c..bc2d12e72a 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -65,6 +65,7 @@ from .turn_terminal import EndedWithoutResult, TurnTerminal if TYPE_CHECKING: + from .cursor_publication import CursorDelivery from .turn_runner import TurnRunner @@ -86,6 +87,7 @@ class TurnDelivery: finish_event: asyncio.Event send_boundary: SendBoundary input_start: InputStartListener + cursor_delivery: CursorDelivery request_progress: Callable[..., None] ui_request: Callable[[DialogUiRequest], Awaitable[ExtensionUiChoice]] tool_mode: NativeToolMode @@ -384,6 +386,7 @@ async def stream(self): finish_event=self.finish_event, send_boundary=sender, input_start=sender, + cursor_delivery=self.runner.inputs.effects.cursors.delivery(self.session_id), request_progress=self.progress.record_request_progress, ui_request=lambda request: self.runner.extension_ui_permission( self.session_id, self.turn_id, self.controller, request diff --git a/src/agent_comms/pi_commands.py b/src/agent_comms/pi_commands.py index 5113921a93..404b72246d 100644 --- a/src/agent_comms/pi_commands.py +++ b/src/agent_comms/pi_commands.py @@ -58,7 +58,6 @@ from .pi_events import Response, ResponseOutcome from .pending_requests import PendingRequest from .launch_runtime import RestartEnvironment - from .tracked_turn import TrackedTurnSession from .native_entries import NativeEvidenceScope from .native_input_proof import NativeContextProof from .pi_events import InputCommitted @@ -214,15 +213,14 @@ class Prompt(PiCommand): WireName("streamingBehavior"), ] = None - async def input_committed(self, session: TrackedTurnSession, event: InputCommitted) -> None: - session.prompt_state = session.prompt_state.start(event) - await session.following.open(session) + async def input_committed(self, session: TurnSession, event: InputCommitted) -> None: + await session.original_committed(event) async def context_committed( - self, session: TrackedTurnSession, context: NativeContextProof, + self, session: TurnSession, context: NativeContextProof, source_reads: NativeEvidenceScope, ) -> None: - await session.prompt_send_boundary.context_committed(context, source_reads) + await session.original_context_committed(context, source_reads) @classmethod def response_type(cls) -> type[PiCommand]: @@ -253,14 +251,14 @@ class FollowingPrompt(Prompt, declared_name=None): input: Annotated[InputRequest, WireTransient()] - async def input_committed(self, session: TrackedTurnSession, event: InputCommitted) -> None: + async def input_committed(self, session: TurnSession, event: InputCommitted) -> None: """This accepted following input cannot restart the original prompt or inbox.""" async def context_committed( - self, session: TrackedTurnSession, context: NativeContextProof, + self, session: TurnSession, context: NativeContextProof, source_reads: NativeEvidenceScope, ) -> None: - await session.following.context_committed(self.input.input_id, self.message, context) + await session.input_start.following_context_committed(self.input.input_id, self.message, context) def accepted(self, response: Response, session: TurnSession) -> Iterable[events.AgentEvent]: # PendingRequest already records this acknowledgement. diff --git a/src/agent_comms/pi_native_backend.py b/src/agent_comms/pi_native_backend.py index b1f7d6c191..a317305d8f 100644 --- a/src/agent_comms/pi_native_backend.py +++ b/src/agent_comms/pi_native_backend.py @@ -287,6 +287,7 @@ async def run_turn( finish_event=delivery.finish_event, send_boundary=delivery.send_boundary, input_start=delivery.input_start, + cursor_delivery=delivery.cursor_delivery, request_observer=delivery.request_progress, persistent_session=self, package=package, diff --git a/src/agent_comms/tracked_turn.py b/src/agent_comms/tracked_turn.py index 0d8d9b5a85..96dc02f3a0 100644 --- a/src/agent_comms/tracked_turn.py +++ b/src/agent_comms/tracked_turn.py @@ -7,13 +7,11 @@ from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable, Mapping from contextlib import AsyncExitStack, ExitStack -from dataclasses import dataclass, field +from dataclasses import dataclass from functools import partial from pathlib import Path from types import MappingProxyType -from typing import TYPE_CHECKING, Generic, TypeVar - -from metaclass_registry.mro_dispatch import handles +from typing import TYPE_CHECKING from . import pi_commands as commands from . import pi_events as pi @@ -34,10 +32,7 @@ NativeEmptyAnswer, NativePiPromptRejected, NativeTurnResult, - _verify_context, - read_tracked_input_commitment, ) -from .private_sidecar import native_request_digest from .native_prompt_send import UNGUARDED_PROMPT, PromptBoundary, PromptSendFailure from .native_session_reopen import SessionRetention from .native_startup import NativeStartup @@ -57,9 +52,6 @@ from .cursor_publication import CursorDelivery from .owned_send import OwnedSend from .native_entries import NativeEvidenceScope - from .native_tool_transport import UnboundToolTransport - from .native_session_files import NativeSessionFiles - from .native_session_reopen import PiSessionId from .pi_native_backend import PersistentPiSession from .pi_payloads import UserMessage from .turn_failure import TurnFailure @@ -76,79 +68,6 @@ async def __call__(self, event: pi.PiEvent | AgentEvent | ObservedAttestation) - NO_NATIVE_OBSERVER = NoObserver() NO_FOLLOWING_INPUTS = FollowingInputs() -T = TypeVar("T") - - -class NativeCommitObservation(ABC, Generic[T]): - """Observed native input records, without permission to start or replay input.""" - - @abstractmethod - def require(self) -> T: ... - - def capture(self, event: T) -> NativeCommitObservation[T]: - raise NativePiUnavailable("Native Pi repeated the input commitment") - - -class PendingNativeCommit(NativeCommitObservation[T]): - def require(self) -> T: - raise NativePiUnavailable("Native Pi did not commit a tracked model context") - - def capture(self, event: T) -> NativeCommitObservation[T]: - return ObservedNativeCommit(event) - - -@dataclass(frozen=True) -class ObservedNativeCommit(NativeCommitObservation[T]): - event: T - - def require(self) -> T: - return self.event - - -@dataclass -class TrackedInputContext: - """One actually sent command owns its emitted join, bounded by this run's scope.""" - - command: commands.Prompt - input_commit: NativeCommitObservation[pi.InputCommitted] = field(default_factory=PendingNativeCommit) - context_commit: NativeCommitObservation[pi.ContextCommitted] = field(default_factory=PendingNativeCommit) - - async def committed_input(self, turn: TrackedTurnSession, event: pi.InputCommitted) -> None: - self.input_commit = self.input_commit.capture(event) - async with turn.cursor_delivery.reading() as source_reads: - await Coordination.run_worker(lambda: - source_reads.for_source(turn.active_session_file).observe(event)) - await self.command.input_committed(turn, event) - - def proof( - self, files: NativeSessionFiles, session_id: PiSessionId, - source_reads: NativeEvidenceScope, - ) -> NativeContextProof: - evidence = source_reads.for_source(files.session) - proof = _verify_context( - files, self.command.input_id, session_id, - self.input_commit.require(), self.context_commit.require(), evidence=evidence, - ) - committed = read_tracked_input_commitment( - proof.session_file, self.command.input_id, evidence=evidence, - ) - if not committed.matches(native_request_digest( - self.command.message, streaming_behavior=self.command.streaming_behavior, - images=self.command.images, - )): - raise NativePiUnavailable("Native context differs from its actual scoped input request") - return proof - - async def committed_context(self, turn: TrackedTurnSession, event: pi.ContextCommitted) -> None: - self.context_commit = ObservedNativeCommit(event) - async with turn.cursor_delivery.reading() as source_reads: - proof = await Coordination.run_worker(partial( - self.proof, turn.launch.session.files(turn.launch.root), - turn.native.attestation.require_identity().session_id, source_reads, - )) - await self.command.context_committed(turn, proof, source_reads) - - class TrackedTerminal(ABC): """Relate completed assistant responses to their streamed text.""" @@ -301,27 +220,21 @@ def __init__( launch, command, tool_mode=selected_tool_mode, + cursor_delivery=cursor_delivery, model_wait=model_wait, startup=startup, request_observer=request_observer, native_session=pi_session, ) self.retainable = False - self.cursor_delivery = cursor_delivery self.following = following self.model = model self.prompt_send_boundary = prompt_send_boundary self.ingress = ingress self.observe_event = observe_event - self.contexts: dict[InputId, TrackedInputContext] = {} self.draft_written_at = 0.0 self.terminal: TrackedTerminal = PendingTrackedTerminal() - async def open_transport(self, resources: AsyncExitStack) -> UnboundToolTransport: - transport = await super().open_transport(resources) - self.contexts[self.command.input_id] = TrackedInputContext(self.command) - return transport - @classmethod async def execute( cls, @@ -518,21 +431,14 @@ async def send_prompt(self) -> None: def reject_prompt(self, response): raise NativePiPromptRejected(response) - def following_started(self, command: commands.FollowingPrompt) -> None: - if command.input_id in self.contexts: - raise NativePiUnavailable("A tracked following input reused an in-scope native identity") - self.contexts[command.input_id] = TrackedInputContext(command) - - async def finish_input(self) -> None: - try: - await super().finish_input() - finally: - self.contexts.clear() + async def original_committed(self, event: pi.InputCommitted) -> None: + self.prompt_state = self.prompt_state.start(event) + await self.following.open(self) - @handles(pi.InputCommitted) - async def committed_input(self, event: pi.InputCommitted) -> None: - if context := self.contexts.get(event.input_id): - await context.committed_input(self, event) + async def original_context_committed( + self, context: NativeContextProof, source_reads: NativeEvidenceScope, + ) -> None: + await self.prompt_send_boundary.context_committed(context, source_reads) def forward_inbox(self, inbox: TurnInbox, sender: OwnedSend) -> None: """Later inputs go to the running child under the owner's write checks.""" @@ -554,11 +460,6 @@ async def accept_user_message( return () raise NativePiUnavailable("Native Pi started a user message this turn did not send") - @handles(pi.ContextCommitted) - async def committed_context(self, event: pi.ContextCommitted) -> None: - if context := self.contexts.get(event.input_id): - await context.committed_context(self, event) - async def assistant_started(self) -> None: await super().assistant_started() self.draft_written_at = 0.0 @@ -593,14 +494,7 @@ def accept_final_message(self, message): async def context_proof(self) -> NativeContextProof: if not self.prompt_state.acknowledged: raise NativePiUnavailable("Native Pi did not commit a tracked model context") - # The recipient serializes this read with cursor and settlement reads. - # Cancellation joins the worker before its borrowed scope is released. - async with self.cursor_delivery.reading() as source_reads: - return await Coordination.run_worker(partial( - self.contexts[self.command.input_id].proof, - self.launch.session.files(self.launch.root), - self.native.attestation.require_identity().session_id, source_reads, - )) + return await super().context_proof() async def invalidate_identity(self) -> tuple[AgentEvent, ...]: # A tracked turn has no authority to recover a session another command replaced. diff --git a/src/agent_comms/turn_input_source.py b/src/agent_comms/turn_input_source.py index 2b66f8643f..90add0e6f3 100644 --- a/src/agent_comms/turn_input_source.py +++ b/src/agent_comms/turn_input_source.py @@ -14,6 +14,7 @@ from .acp_extension import InputStartedUpdate, NoQueueScope, UnidentifiedInputStarted from .agent_backend import BusyInputPolicy, SteerInputPolicy from .channel_input_batch import InputBatch, SingleInputBatch +from .errors import RelationViolationError from .field_codec import InputId, TurnId from .input_attempt import InputAlreadyCleared, MissingInput from .input_key import InputKey @@ -76,6 +77,11 @@ async def accepted_start( ) -> InputStartedUpdate: return update + def require_following_source( + self, inputs: InputDrain, session_id: str, source: TurnInputSource, + ) -> None: + raise RelationViolationError("An input without an acceptance cannot own following context") + @abstractmethod def release(self, inputs: InputDrain, session_id: str) -> None: ... @@ -124,6 +130,12 @@ async def accepted_start( update = replace(update, text=await queued[self.input_id].started(inputs)) return update.identified(self.input_id) + def require_following_source( + self, inputs: InputDrain, session_id: str, source: TurnInputSource, + ) -> None: + if inputs.state(session_id).following.get(self.input_id) is not source: + raise RelationViolationError("Following context outlived its actual accepted input") + def release(self, inputs: InputDrain, session_id: str) -> None: inputs.state(session_id).queued.pop(self.input_id, None) @@ -199,6 +211,9 @@ def _prepare( ) return GrantedSend() + def require_context_source(self, sender: OwnedSend) -> None: + """An original's lease and durable binding fence its retained context.""" + def valid_keys(self, text: str) -> bool: return len(self.keys) <= 1 @@ -369,6 +384,9 @@ class FollowingTurnInput(TurnInputSource): when_busy: ClassVar[type[BusyInputPolicy]] = SteerInputPolicy owner_check = RunningOrdinaryOwnerCheck + def require_context_source(self, sender: OwnedSend) -> None: + self.acceptance.require_following_source(sender.inputs, sender.session_id, self) + def continue_response(self, response, parts): """An actual following request may revise the turn's automatic answer.""" return response.later_input(parts) diff --git a/tests/native_backend_fixture.py b/tests/native_backend_fixture.py index bc354d1ac5..7fe45e327b 100644 --- a/tests/native_backend_fixture.py +++ b/tests/native_backend_fixture.py @@ -21,6 +21,7 @@ from agent_comms.child_process import BoundedRun, join_retirement from agent_comms.comms import wire from agent_comms.coordinator import Coordination +from agent_comms.cursor_publication import CursorDelivery from agent_comms.field_codec import FieldCodec, InputId from agent_comms.fresh_private_session import create_fresh_private_session from agent_comms.native_arguments import GivenArguments, GivenBinary, NativeArguments @@ -62,6 +63,7 @@ def __init__(self, root, project, session, provider, config, package: VerifiedNa self.config = config self.package = package self.persistent = PersistentPiSession() + self.cursor_delivery = CursorDelivery() self.participant = self.participant_at(root, project) self.starts = [] self.children: list[PiSessionChild] = [] @@ -197,6 +199,7 @@ async def run(self, text, *, followup=None, queue=None, **options): tool_mode=NoSelectedTools(), steering_queue=queue, input_start=self, + cursor_delivery=self.cursor_delivery, **options, session_directory=self.root): result.append(event) @@ -303,6 +306,7 @@ async def close_server(): async def retire_all(): async with AsyncExitStack() as retirement: + retirement.callback(owner.cursor_delivery.close) retirement.push_async_callback(close_server) for child in owner.children: retirement.push_async_callback(child.shutdown) diff --git a/tests/pi_session_turn.py b/tests/pi_session_turn.py index fd8f95a8f4..883f271fcf 100644 --- a/tests/pi_session_turn.py +++ b/tests/pi_session_turn.py @@ -4,11 +4,12 @@ from agent_comms.repo import Checkout -from contextlib import aclosing +from contextlib import AsyncExitStack, aclosing from pathlib import Path import agent_comms.backend as backend from agent_comms.comms import wire +from agent_comms.cursor_publication import CursorDelivery from agent_comms.agent_backend import NoInputStartListener from agent_comms.pi_native_backend import PersistentPiSession from agent_comms.native_package import VerifiedNativePackage @@ -24,15 +25,16 @@ async def one_turn_events(agent_bin, agent_args, task, cwd, env_extra=None, owner = comms.registry.declare(Thread( "independent-turn", frozenset(), Checkout(Path(cwd)), )).incarnation.require_recorded() - try: + async with AsyncExitStack() as resources: + cursor_delivery = CursorDelivery() + resources.callback(cursor_delivery.close) + resources.push_async_callback(session.close) async with aclosing( backend.stream_agent_events(agent_bin, agent_args, task, cwd, env_extra, persistent_session=session, root=comms.root, owner=owner, tool_mode=tool_mode, - input_start=NoInputStartListener(), + input_start=NoInputStartListener(), cursor_delivery=cursor_delivery, package=VerifiedNativePackage.acquire(package), session_directory=Path(cwd), **kwargs) ) as stream: async for event in stream: yield event - finally: - await session.close() diff --git a/tests/test_following_context_notifications.py b/tests/test_following_context_notifications.py index 6156cb95e5..850b193b53 100644 --- a/tests/test_following_context_notifications.py +++ b/tests/test_following_context_notifications.py @@ -24,7 +24,7 @@ from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof from agent_comms.native_tool_call import OwnerToolConnection from agent_comms.pi_commands import FollowingPrompt -from agent_comms.tracked_turn import ObservedNativeCommit, TrackedInputContext +from agent_comms.backend import ObservedNativeCommit, TrackedInputContext from agent_comms.turn_lease import TurnLeaseFence from agent_comms.native_runtime_input import NativeRuntimeInput from agent_comms.pi_events import ContextCommitted, InputCommitted diff --git a/tests/test_pi_payloads.py b/tests/test_pi_payloads.py index 2aec06eb6a..6d0def274b 100644 --- a/tests/test_pi_payloads.py +++ b/tests/test_pi_payloads.py @@ -198,7 +198,7 @@ def test_optional_native_observations_have_named_absence_and_one_serialization() def test_tracked_commit_observations_preserve_original_receipt_and_refuse_repetition(): from agent_comms.native_failures import NativePiUnavailable - from agent_comms.tracked_turn import ObservedNativeCommit, PendingNativeCommit + from agent_comms.backend import ObservedNativeCommit, PendingNativeCommit event = decode({"type": "input_committed", "inputId": "a" * 32, "sessionId": "session", "sessionEntryId": "entry", "sessionEntryStart": 128}) pending = PendingNativeCommit() diff --git a/tests/test_stack_native_compaction.py b/tests/test_stack_native_compaction.py index 33400ba2d3..647460a16c 100644 --- a/tests/test_stack_native_compaction.py +++ b/tests/test_stack_native_compaction.py @@ -87,7 +87,7 @@ def _is_summary(request: dict) -> bool: "summary_failure", "post_compaction_tool_rounds", ]) async def test_pi_engine_compacts_inside_a_core_turn( - case: str, monkeypatch, native_runtime_package, private_directory) -> None: + case: str, monkeypatch, native_runtime_package, private_directory, cursor_delivery) -> None: native_bin = os.environ.get("AC_NATIVE_STACK_BIN") if not native_bin: pytest.skip("Set AC_NATIVE_STACK_BIN to the prepared pinned Pi launcher") @@ -230,6 +230,7 @@ def log_message(self, *_args): root=project, owner=NativeBackendFixture.participant_at(project, project), env_extra=child_env, session_file=str(session), input_start=AnnouncedInputStart(), + cursor_delivery=cursor_delivery, persistent_session=pi_session, package=native_runtime_package, tool_mode=NoSelectedTools(), From 6fba9a20bfe0139730c91fd946c2ff4105173073 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 21:36:48 -0400 Subject: [PATCH 061/109] Derive client checker import roots from each captured project --- tests/test_integration.py | 33 +++++++++++++++++++++++------ tests/test_module_layers_clients.py | 22 +++++++++++++++++++ tools/debt_ratchet.py | 2 +- tools/module_layers.py | 22 +++++++++++++++++-- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 6bd9a7b2ef..598feb17f0 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -186,19 +186,27 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( ) -> None: pair = mutual_sources plugin = ('from mypy.plugin import Plugin\nfrom agent_comms.api import old_api\n' + 'from performance.capture_live import capture\n' 'def plugin(version: str) -> type[Plugin]:\n' - ' assert old_api() == "baseline"\n return Plugin\n') + ' assert old_api() == capture() == "baseline"\n return Plugin\n') + capture = 'from agent_comms.api import old_api\n\ndef capture() -> str:\n return old_api()\n' + script = ('from agent_comms.api import old_api\nfrom performance.capture_live import capture\n' + 'assert old_api() == capture() == "baseline"\n') # The native config and its plugin are real baseline source, then migrated on feature. pair.client.git(pair.client.checkout, "switch", "main") project = tomllib.loads((pair.client.checkout / "pyproject.toml").read_text()) - project["tool"]["mypy"] = {"plugins": ["mypy_source_plugin.py"]} - pair.client.commit_change("Declare the baseline native mypy plugin", *pair.client.write_files({ - Path("pyproject.toml"): tomli_w.dumps(project), Path("mypy_source_plugin.py"): plugin})) + project["tool"]["mypy"] = {"plugins": ["mypy_source_plugin.py"], "mypy_path": ["src", "tools"]} + pair.client.commit_change("Declare baseline script roots and native mypy plugin", *pair.client.write_files({ + Path("pyproject.toml"): tomli_w.dumps(project), Path("mypy_source_plugin.py"): plugin, + Path("tools/performance/capture_live.py"): capture, Path("tools/acceptance.py"): script})) pair.client.git(pair.client.checkout, "push", "origin", "main") pair.client.git(pair.client.checkout, "switch", "feature") pair.client.git(pair.client.checkout, "merge", "--no-edit", "main") - head = pair.client.publish_change("Migrate the real plugin alongside the client", *pair.client.write_files({ - Path("mypy_source_plugin.py"): plugin.replace("old_api", "new_api").replace("baseline", "candidate")})) + head = pair.client.publish_change("Migrate scripts and native plugin alongside the client", *pair.client.write_files({ + path: text.replace("old_api", "new_api").replace("baseline", "candidate") + for path, text in ((Path("mypy_source_plugin.py"), plugin), + (Path("tools/performance/capture_live.py"), capture), + (Path("tools/acceptance.py"), script))})) stamp = "2026-10-24T00:00:00Z" github_cli.put(pair.client.github, DraftListedPullRequest( pair.client.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) @@ -208,6 +216,19 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( clients = materialized_clients(pair.output, inputs) assert len(ClientTypeErrors.parts(pair.core.checkout, source.baseline.commit, head, {"src/agent_comms/api.py"}, "src/", clients)) == 2 + for side in (BaseSide, HeadSide): + client, = clients.at(pair.core.checkout, source.baseline.commit, head, side) + assert client.import_paths == tuple(str(client.checkout / path) for path in ("src", "tools")) + assert client.files_importing("agent_comms") == [ + "mypy_source_plugin.py", "src/workflow.py", "tools/acceptance.py", "tools/performance/capture_live.py"] + # Python's script entrypoint and the plugin's import must see this same cut. + entry = subprocess.run((sys.executable, "tools/acceptance.py"), cwd=client.checkout, + env={**os.environ, "PYTHONPATH": os.pathsep.join(( + str(side.require_materialized(pair.output, source) / "src"), *client.import_paths))}, + capture_output=True, text=True, timeout=30) + assert entry.returncode == 0, entry.stdout + entry.stderr + # Both namespace files are selected under --explicit-package-bases: a duplicate + # performance/tools.performance identity aborts the real checker, not a zero count. result = compare(pair.core.checkout, source.baseline.commit, head, "src", 1, measured=(ClientTypeErrors,), clients=clients) assert not result.failed diff --git a/tests/test_module_layers_clients.py b/tests/test_module_layers_clients.py index 5e49450ffa..b20fe6c855 100644 --- a/tests/test_module_layers_clients.py +++ b/tests/test_module_layers_clients.py @@ -28,6 +28,28 @@ def write(path: Path, text: str = "") -> None: path.write_text(text) +@pytest.mark.parametrize("declaration, roots", ( + ("", ("src", ".")), + ('mypy_path = ["src", "tools"]', ("src", "tools")), + ('mypy_path = "src, tools"', ("src", "tools")), + ("mypy_path = []", ()), + ('mypy_path = ["{external}", "src"]', ("{external}", "src")), +)) +def test_client_roots_follow_each_projects_native_declaration( + tmp_path: Path, declaration: str, roots: tuple[str, ...], +) -> None: + external = tmp_path / "external-library" + external.mkdir() + client = tmp_path / "client" + dependency = tmp_path / "dependency" + write(client / "pyproject.toml", '[tool.mypy]\n' + declaration.format(external=external)) + write(dependency / "pyproject.toml", '[tool.mypy]\nmypy_path = ["lib"]\n') + acquired = StackClient("client", client, dependencies=(dependency,)) + expected = tuple(str((client / path.format(external=external)).resolve()) for path in roots) + assert acquired.source_paths == expected + assert acquired.import_paths == (*expected, str(dependency / "lib")) + + def test_materialized_clients_check_matched_sources_and_both_mixed_sources_fail( mutual_sources: MutualSources, github_cli: GitHubFixture, materialized_clients: Callable[[Path, IntegrationInput], MaterializedClients], diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 330944017c..74f55cf6b6 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -953,7 +953,7 @@ def checked(cls, repo: Path, ref: str, client: StackClient, side: type[Side]) -> cache, "--follow-imports=silent", "--ignore-missing-imports", *selected)), cwd=client.checkout, timeout=600, runtime=runtime, env={**os.environ, "MYPYPATH": os.pathsep.join(imports), - "PYTHONPATH": os.pathsep.join((*imports, os.environ.get("PYTHONPATH", "")))}, + "PYTHONPATH": os.pathsep.join(imports)}, ) output = cls.checker_output(result, f"{client.name} at {ref}") return Counter(f"{client.name}/{match[1]}" for match in cls.finding.finditer(output) diff --git a/tools/module_layers.py b/tools/module_layers.py index 0712e7906e..bfb5cd98ba 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -39,6 +39,9 @@ from functools import cache, cached_property from pathlib import Path +from mypy.config_parser import toml_config_types + +from agent_comms.field_codec import FieldCodec, boundary_decoder from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side from agent_comms.installer.commands import InstallCommands from agent_comms.installer.runtime_resolution import CorePackage, ProjectMetadata, StackSources @@ -313,10 +316,25 @@ class StackClient(StackSources): compared: tuple[Path, ...] = () dependencies: tuple[Path, ...] = () + @cached_property + @boundary_decoder + def source_paths(self) -> tuple[str, ...]: + """The acquired project's declaration owns development import bases. + + Use mypy's native TOML conversion (array or delimited string), not another + path dialect. An explicit empty override stays empty. Projects without an + override retain the existing installed-package/src convention. + """ + project = tomllib.loads((self.checkout / "pyproject.toml").read_text()) + declared = project.get("tool", {}).get("mypy", {}).get("mypy_path", ["src", "."]) + roots = FieldCodec.decode(list[str], toml_config_types["mypy_path"](declared)) + return tuple(str((self.checkout / root).resolve()) for root in roots) + @property def import_paths(self) -> tuple[str, ...]: - return tuple(str(root / path) for root in (self.checkout, *self.dependencies) - for path in ("src", ".")) + return tuple(dict.fromkeys((*self.source_paths, + *(path for checkout in self.dependencies + for path in StackClient(self.name, checkout).source_paths)))) def files_importing(self, module: str) -> list[str]: """A selected path comes from either cut, but absence on this cut stays absent.""" From f38af5e5ef972921405aeba1636e652500d01475 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 23:16:47 -0400 Subject: [PATCH 062/109] Bound notification recipients before claim proof and reply acquisition --- src/agent_comms/bus.py | 9 ++ src/agent_comms/bus_publication.py | 5 + src/agent_comms/bus_tables.py | 22 +++ src/agent_comms/historical_views.py | 8 +- src/agent_comms/history_views.py | 7 +- src/agent_comms/notification_assignment.py | 137 +++++++++--------- src/agent_comms/notification_evidence.py | 61 +++++++- src/agent_comms/presentation.py | 150 ++++++++++---------- src/agent_comms/private_registry_guard.py | 10 +- src/agent_comms/selected_participant.py | 11 +- src/agent_comms/thread_presentation.py | 6 +- src/agent_comms/tools.py | 18 +-- src/agent_comms/ui_model/observation.py | 11 +- tests/test_notification_recipient_window.py | 145 +++++++++++++++++++ tests/test_outbound_transcript_source.py | 20 +-- tests/test_recipient_activity_index.py | 5 +- 16 files changed, 432 insertions(+), 193 deletions(-) create mode 100644 tests/test_notification_recipient_window.py diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index dd5378505e..b57a442cd1 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -429,6 +429,15 @@ def replies(self, references: tuple[MessageReference, ...]) -> tuple[BusRecord, headers = (BusRow.require(db, seq=seq) for seq in sorted(sequences)) return tuple(header.kind.read_from(db, header.seq) for header in headers) + def recipient_replies( + self, keys: tuple[tuple[ThreadLookup, MessageReference], ...], + ) -> tuple[BusRecord, ...]: + """Read only the first reply of each bounded original/recipient pair.""" + with self.observing() as db: + sequences = {row.seq for row in AnsweredSource.first_for_recipients(db, keys)} + headers = (BusRow.require(db, seq=seq) for seq in sorted(sequences)) + return tuple(header.kind.read_from(db, header.seq) for header in headers) + def changed_names(self, after: int) -> frozenset[str]: with self.observing() as db: return frozenset(FieldCodec.decode(str, row[0]) for row in db.execute( diff --git a/src/agent_comms/bus_publication.py b/src/agent_comms/bus_publication.py index 8a76a606b8..316e7c6322 100644 --- a/src/agent_comms/bus_publication.py +++ b/src/agent_comms/bus_publication.py @@ -51,6 +51,11 @@ def writes(cls) -> tuple[type[ProducedRow], ...]: decisions: tuple[RecipientDecision, ...] control: str + def __post_init__(self) -> None: + super().__post_init__() + if len(self.decisions) != len(self.recipients): + raise ValueError("Frozen recipients and their decisions must have equal length") + def projections(self): from .bus_tables import AwaitingAnswerSource, ReceivedSource, SentSource diff --git a/src/agent_comms/bus_tables.py b/src/agent_comms/bus_tables.py index 4195c5cd3f..3ac4fdfc8c 100644 --- a/src/agent_comms/bus_tables.py +++ b/src/agent_comms/bus_tables.py @@ -199,6 +199,28 @@ class AnsweredSource(messages.BusProjectionTables): seq: Annotated[int, Column(primary_key=True)] indexes = (Index(("original",)), Index(("seq", "original"))) + @classmethod + def first_for_recipients( + cls, db: sqlite3.Connection, keys: tuple[tuple[ThreadLookup, MessageReference], ...], + ) -> tuple[AnsweredSource, ...]: + """At most one reply per selected (original, frozen author), before body decoding.""" + from itertools import batched + + selected = [] + for batch in batched(tuple(dict.fromkeys(keys)), db.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER) // 2): + values = ",".join("(?,?)" for _ in batch) + selected.extend(cls.read(db.execute( + f"WITH selected(lookup,original) AS (VALUES {values}) " + f"SELECT answer.original,answer.seq FROM selected " + f"JOIN {cls.declared_name} answer ON answer.original=selected.original " + f"AND answer.seq=(SELECT candidate.seq FROM {cls.declared_name} candidate " + f"CROSS JOIN {SentSource.declared_name} author " + "ON author.lookup=selected.lookup AND author.seq=candidate.seq " + "WHERE candidate.original=selected.original ORDER BY candidate.seq LIMIT 1)", + tuple(value for lookup, reference in batch for value in (lookup, reference.seq)), + ))) + return tuple(selected) + @classmethod def unanswered_references( cls, db: sqlite3.Connection, sequences: tuple[int, ...], responders: frozenset[str], diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index 19e2e8a795..8858312b85 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -33,7 +33,8 @@ ) from .messages import CarriesEnvelope, Message from .page_traversal import HistoryCursor, OlderTraversal -from .presentation import MessageNotification, OutcomeViewer +from .presentation import MessageNotification, OutcomeViewer, FirstRecipient, RecipientCursor +from .notification_evidence import RecipientSelection from .private_path import FileObservation, FileRevision, ObservedPath, PrivateDirectoryRole from .read_basis import ( AnonymousViewer, @@ -353,7 +354,8 @@ def require(cls, sources: tuple[HistorySource, ...], key: str) -> HistorySource: return source def notification_references(self, sources: tuple[HistorySource, ...], references, - viewer: OutcomeViewer): + viewer: OutcomeViewer, *, after: RecipientCursor = FirstRecipient(), + limit: int = RecipientSelection.max_limit): """Read this namespace within its archive's current catalogue read, as this viewer may see it.""" @@ -363,7 +365,7 @@ def notification_references(self, sources: tuple[HistorySource, ...], references result = MessageNotification.read_references( Bus(Bus.path_in(root)), references, partial(MessageNotification.recorded_delivery_window, root, self.provenance, - viewer=viewer), + viewer=viewer, after=after, limit=limit), ) self.validate() return result diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index e5c1c1571e..245cbb268e 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -56,7 +56,7 @@ from .messages import Message from .messaging import Messaging from .page_traversal import LiveCursor -from .notification_evidence import NotificationWindow +from .notification_evidence import NotificationWindow, RecipientSelection from .presentation import ( BusPresentation, ChannelView, @@ -65,7 +65,7 @@ InboxRead, MessageNotification, NoRevision, - OutcomeViewer, + OutcomeViewer, FirstRecipient, RecipientCursor, ReadState, ThreadRecord, ThreadView, @@ -222,9 +222,10 @@ def observed_activity( def message_notifications_for_references( self, viewer: OutcomeViewer, references: Sequence[MessageReference], + *, after: RecipientCursor = FirstRecipient(), limit: int = RecipientSelection.max_limit, ) -> NotificationWindow: return MessageNotification.references( - self.root, self.registry, self.bus.bus, references, viewer=viewer) + self.root, self.registry, self.bus.bus, references, viewer=viewer, after=after, limit=limit) def history_page(self, target: CommandTarget, bounds: PageBounds = NEWEST_PAGE, *, viewer: DisplayViewer = AnonymousViewer()) -> MessagePage: diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index e60c62df8c..08de78d8a7 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -5,6 +5,7 @@ import sqlite3 from abc import abstractmethod from collections.abc import Iterable, Iterator, Sequence +from itertools import batched from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, NoReturn @@ -26,7 +27,7 @@ SentProgress, ShownToModelProgress, ) -from .notification_evidence import NotificationAssignmentRead, NotificationWindow, SourceEvidence +from .notification_evidence import NotificationAssignmentRead, NotificationWindow, NotificationSelection from .private_registry_guard import RegistryPrivacy from .registry_store import RegistryStore from .typed_table import BooleanStorage, TypedRow @@ -148,9 +149,6 @@ def completed_notification(self, notification: MessageNotification) -> MessageNo def answered(self, notification: MessageNotification) -> MessageNotification: return self.response.answered(notification) - def recorded_response(self, message: Delivered) -> Self: - return replace(self, response=self.response.recorded(message)) - @dataclass(frozen=True) class AssignmentActivityEvidence(TypedRow): @@ -233,33 +231,31 @@ 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)) + # A retained read owns its recipient cut. Re-reading a different viewer's + # superset would both decode it and report a permanent false change. + return tuple(read for read in reads + if read.differs_from(cls.read(root, read.selection).evidence)) @classmethod - def resolve(cls, root: Path, read: NotificationAssignmentRead) -> tuple[NotificationAssignment, ...]: - resolved = tuple(cls(assignment, activity.resolve(pending_at_ms=assignment.updated_at_ms)) - for assignment, activity in read.rows) - return cls._recorded_conversation_responses(root, read.sources, resolved) + def resolve(cls, read: NotificationAssignmentRead) -> tuple[NotificationAssignment, ...]: + return tuple(cls(assignment, activity.resolve(pending_at_ms=assignment.updated_at_ms)) + for assignment, activity in read.rows) @classmethod def for_deliveries( - cls, originals: Iterable[Delivered], records: Iterable[NotificationAssignment], + cls, selection: NotificationSelection, records: Iterable[NotificationAssignment], ) -> Iterator[tuple[Delivered, tuple[NotificationSource, ...]]]: """Match notification assignments to messages and their recorded recipients.""" by_recipient = {} for record in records: key = (record.assignment.source, record.assignment.recipient_lookup) by_recipient.setdefault(key, []).append(record) - for original in originals: + for page in selection.pages: + original = page.source reference = original.message.reference sources = [] - for recipient, decision in zip(original.recipients, original.decisions, strict=True): + for position in page.positions: + recipient, decision = original.recipients[position], original.decisions[position] matching = by_recipient.get((reference, recipient.recipient_lookup), ()) if len(matching) > 1: raise ValueError("Original notification recipient has multiple handling rows") @@ -267,99 +263,100 @@ def for_deliveries( yield original, tuple(sources) @classmethod - def _recorded_conversation_responses( - cls, root: Path, sources: tuple[Delivered, ...], - rows: tuple[NotificationAssignment, ...], - ) -> tuple[NotificationAssignment, ...]: - eligible = tuple( - row for row in rows if row.assignment.lifecycle.records_conversation_response - ) + def capture_responses(cls, root: Path, read: NotificationAssignmentRead) -> NotificationAssignmentRead: + """Capture bounded independent replies after releasing the coordinator snapshot. + + Retain them on the existing evidence so an open view notices a reply even + when neither the claim nor its context proof changed. + """ + eligible = tuple(assignment for assignment, _activity in read.rows + if assignment.lifecycle.records_conversation_response) if not eligible: - return rows - - originals = {original.message.reference: original for original in sources} - captured = Bus(Bus.path_in(root)).replies( - tuple({row.assignment.source for row in eligible})) + return read + originals = {source.message.reference: source for source in read.sources} + captured = Bus(Bus.path_in(root)).recipient_replies(tuple( + (assignment.recipient_lookup, assignment.source) for assignment in eligible)) replies: dict[MessageReference, list[Delivered]] = {} for record in captured: for reply in record.deliveries(): for answered in reply.message.answers: replies.setdefault(answered, []).append(reply) resolved = [] - for row in rows: - if row.assignment.lifecycle.records_conversation_response: - response = row.assignment.lifecycle.recorded_response( - originals[row.assignment.source], - replies.get(row.assignment.source, ()), - row.assignment.recipient_lookup, + for assignment, activity in read.rows: + if assignment.lifecycle.records_conversation_response: + response = assignment.lifecycle.recorded_response( + originals[assignment.source], replies.get(assignment.source, ()), + assignment.recipient_lookup, ) if response is not None: - row = replace(row, activity=row.activity.recorded_response(response)) - resolved.append(row) - return tuple(resolved) + activity = replace(activity, response=activity.response.recorded(response)) + resolved.append((assignment, activity)) + return replace(read, rows=tuple(resolved)) @classmethod def capture( - cls, connection: sqlite3.Connection, sources: Sequence[Delivered], + cls, connection: sqlite3.Connection, selection: NotificationSelection, privacy: RegistryPrivacy, ) -> NotificationAssignmentRead: - """Capture detached assignment evidence for one bounded window of originals - from the caller's SQLite snapshot.""" - sources = tuple(sources) - if len(sources) > NotificationWindow.window_limit: + """Probe only selected frozen keys before joining any claim/context/proof. + + The existing (recipient_lookup, wire_seq) unique index admits each row; + message_id corroborates the exact original rather than a current name. + """ + if len(selection.pages) > NotificationWindow.window_limit: raise ValueError("Notification reads require a bounded visible message window") - if not sources: - return NotificationAssignmentRead((), ()) + keys = tuple(dict.fromkeys(selection.keys)) + if not keys: + return NotificationAssignmentRead(selection, ()) privacy.coordinator_schema().require_schema(connection) 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)})), " + rows = [] + batch_size = connection.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER) // 3 + for batch in batched(keys, batch_size): + selected = ",".join("(?,?,?)" for _ in batch) + parameters = tuple(value for lookup, reference in batch + for value in (lookup, reference.seq, reference.message_id)) + rows.extend(WakeAssignment.joined(connection.execute( + f"WITH selected(recipient_lookup,wire_seq,message_id) AS (VALUES {selected}), " + f"w AS MATERIALIZED (SELECT original.* FROM selected " + f"JOIN {WakeAssignment.declared_name} original " + "ON original.recipient_lookup=selected.recipient_lookup " + "AND original.wire_seq=selected.wire_seq AND original.message_id=selected.message_id), " 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 " + "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, - ) - 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) + "ORDER BY w.wire_seq DESC,w.recipient_lookup", parameters), AssignmentActivityEvidence)) + attempts = HarnessDelivery.for_recipients(connection, keys) + return NotificationAssignmentRead(selection, tuple(rows), attempts) @classmethod def read( - cls, root: Path, sources: Sequence[Delivered] + cls, root: Path, selection: NotificationSelection ) -> NotificationAssignmentRead: """Capture 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, selection, privacy, ) as result: - return result + captured = result + return cls.capture_responses(root, captured) @classmethod def missing_store( - cls, store: MissingStore, sources: Sequence[Delivered], privacy: RegistryPrivacy, + cls, store: MissingStore, selection: NotificationSelection, privacy: RegistryPrivacy, ) -> NotificationAssignmentRead: - return privacy.missing_notification_assignments(sources) + return privacy.missing_notification_assignments(selection) @classmethod def invalid_store( - cls, store: InvalidStore, sources: Sequence[Delivered], privacy: RegistryPrivacy, + cls, store: InvalidStore, selection: NotificationSelection, privacy: RegistryPrivacy, ) -> 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 e24a2317a1..a1c8743531 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, WireRevision, OutcomeViewer from .thread_identity import ThreadIncarnation @@ -39,6 +39,53 @@ def consumed_progress(self, recipient: ThreadLookup, return progress +@dataclass(frozen=True) +class RecipientSelection: + """A bounded slice of original frozen positions, never a replacement audience.""" + + source: Delivered + eligible: range + limit: int + max_limit: ClassVar[int] = 100 + + def __post_init__(self) -> None: + self.require_limit(self.limit) + + @classmethod + def require_limit(cls, limit: int) -> None: + if not 1 <= limit <= cls.max_limit: + raise ValueError(f"Recipient limit must be between 1 and {cls.max_limit}") + + @property + def positions(self) -> range: + return self.eligible[:self.limit] + + @property + def remaining(self) -> int: + return len(self.eligible) - len(self.positions) + + @property + def recipients(self) -> tuple[FrozenRecipient, ...]: + return tuple(self.source.recipients[position] for position in self.positions) + + +@dataclass(frozen=True) +class NotificationSelection: + """The exact per-original recipient cuts used by every expensive evidence reader.""" + + viewer: OutcomeViewer + pages: tuple[RecipientSelection, ...] + + @property + def sources(self) -> tuple[Delivered, ...]: + return tuple(page.source for page in self.pages) + + @property + def keys(self) -> tuple[tuple[ThreadLookup, MessageReference], ...]: + return tuple((recipient.recipient_lookup, page.source.message.reference) + for page in self.pages for recipient in page.recipients) + + @dataclass(frozen=True) class NotificationAssignmentRead: """A window's claims and its harness attempts, both keyed by the exact original. @@ -46,13 +93,13 @@ class NotificationAssignmentRead: 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.""" - sources: tuple[Delivered, ...] + selection: NotificationSelection rows: tuple[tuple[WakeAssignment, AssignmentActivityEvidence], ...] attempts: tuple[HarnessDelivery, ...] = () - @classmethod - def empty(cls) -> NotificationAssignmentRead: - return cls((), ()) + @property + def sources(self) -> tuple[Delivered, ...]: + return self.selection.sources @cached_property def evidence(self) -> Mapping[MessageReference, SourceEvidence]: @@ -94,6 +141,10 @@ class NotificationWindow(ABC): reads: tuple[NotificationAssignmentRead, ...] read_marks: tuple[NotificationReadMark, ...] = () + def remaining(self, reference: MessageReference) -> int: + return next((page.remaining for read in self.reads for page in read.selection.pages + if page.source.message.reference == reference), 0) + @classmethod def chunks[T](cls, items: Iterable[T]) -> Iterator[tuple[T, ...]]: return batched(items, cls.window_limit) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 3a21c7715a..181688bb61 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from bisect import bisect_left, bisect_right from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from contextlib import contextmanager @@ -71,7 +72,8 @@ ) from .read_ledger import NoReadNotice, ReadLedger, ReadNotice from .notification_assignment import NotificationAssignment -from .notification_evidence import NotificationAssignmentRead, NotificationReadMark, NotificationWindow +from .notification_evidence import (NotificationAssignmentRead, NotificationReadMark, NotificationWindow, + NotificationSelection, RecipientSelection) from .notification_read import CapturedNotificationWindow from .recorded_bus import NoRecordedBus, RecordedBusIdentity from .registry_document import DEFAULT_ROSTER, RosterVisibility @@ -122,12 +124,44 @@ from .ui_model.observation import ThreadsObserved +class RecipientCursor(SubclassUnion, affix=""): + """Select frozen, lookup-ordered positions before expensive evidence acquisition.""" + + @abstractmethod + def positions(self, source: Delivered) -> range: ... + + +@dataclass(frozen=True) +class FirstRecipient(NullMember, RecipientCursor): + def positions(self, source: Delivered) -> range: + return range(len(source.recipients)) + + +@dataclass(frozen=True) +class AfterRecipient(RecordMember, RecipientCursor): + recipient: FrozenRecipient + + def positions(self, source: Delivered) -> range: + start = bisect_right(source.recipients, self.recipient.recipient_lookup, + key=lambda item: item.recipient_lookup) + return range(start, len(source.recipients)) + + class OutcomeViewer(ABC): """Who reads a delivery window decides which of its outcomes are shown.""" @abstractmethod - def includes(self, sender_lookup: ThreadLookup, recipient: FrozenRecipient) -> bool: - pass + def positions(self, source: Delivered) -> range: ... + + def selection(self, sources: Sequence[Delivered], *, after: RecipientCursor = FirstRecipient(), + limit: int = RecipientSelection.max_limit) -> NotificationSelection: + RecipientSelection.require_limit(limit) + pages = [] + for source in sources: + eligible, following = self.positions(source), after.positions(source) + pages.append(RecipientSelection( + source, range(max(eligible.start, following.start), min(eligible.stop, following.stop)), limit)) + return NotificationSelection(self, tuple(pages)) @abstractmethod def sees_direct_read(self, source: Delivered) -> bool: @@ -142,8 +176,8 @@ def sees(self, source: Delivered) -> bool: class AllOutcomes(OutcomeViewer): """An unscoped reader sees every recipient's outcome and every human read.""" - def includes(self, sender_lookup: ThreadLookup, recipient: FrozenRecipient) -> bool: - return True + def positions(self, source: Delivered) -> range: + return range(len(source.recipients)) def sees_direct_read(self, source: Delivered) -> bool: return True @@ -162,16 +196,20 @@ class OwnerOutcomes(OutcomeViewer): def of(cls, owner: ThreadProvenance) -> OwnerOutcomes: return cls(owner.incarnation.require_recorded().lookup) - def includes(self, sender_lookup: ThreadLookup, recipient: FrozenRecipient) -> bool: - return self.lookup in (sender_lookup, recipient.recipient_lookup) + def positions(self, source: Delivered) -> range: + if source.sender_lookup == self.lookup: + return range(len(source.recipients)) + position = bisect_left(source.recipients, self.lookup, key=lambda item: item.recipient_lookup) + if position < len(source.recipients) and source.recipients[position].recipient_lookup == self.lookup: + return range(position, position + 1) + return range(0) def sees_direct_read(self, source: Delivered) -> bool: return source.sender_lookup == self.lookup def sees(self, source: Delivered) -> bool: """The message's sender and its original recipients may read it.""" - return self.lookup == source.sender_lookup or any( - self.lookup == recipient.recipient_lookup for recipient in source.recipients) + return self.lookup == source.sender_lookup or bool(self.positions(source)) @dataclass(frozen=True, slots=True) @@ -215,7 +253,8 @@ def summary(cls, notifications: Iterable[MessageNotification]) -> str: ordered = cls.ordered(notifications) if not ordered: return "No recorded notification result" - # Each notification exists because the frozen publication addressed its recipient. + # This is a bounded observation, not the complete frozen audience and + # not a set of runtime acknowledgements. Membership proves neither. total = len(ordered) consumed = sum(notification.state.consumed for notification in ordered) read = sum(bool(notification.displayed_to) for notification in ordered) @@ -226,9 +265,9 @@ def summary(cls, notifications: Iterable[MessageNotification]) -> str: return " · ".join(( *leading.state.summary_prefix(leading), *(f"{label} {count}" for label, count in counted if count), - f"Delivered {total}/{total}", - f"{ConsumedDelivery.label} {consumed}/{total}", - f"{HumanReadProgress.label} {read}/{total}", + f"Observed {total} recipients (bounded)", + f"{ConsumedDelivery.label} {consumed} observed", + *((f"{HumanReadProgress.label} {read} observed",) if read else ()), )) @staticmethod @@ -247,12 +286,13 @@ def timed_state(label: str, timestamp_ms: int | None) -> str: def references( cls, root: Path, registry: Registration, log: Bus, references: Sequence[MessageReference], *, viewer: OutcomeViewer, + after: RecipientCursor = FirstRecipient(), limit: int = RecipientSelection.max_limit, ) -> NotificationWindow: """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, viewer.selection(sources, after=after, limit=limit)) + return cls.delivery_window(root, registry, read) return cls.read_references(log, references, project) @@ -275,61 +315,59 @@ def read_references( @classmethod def delivery_window( - cls, root: Path, registry: Registration, sources: Sequence[Delivered], - assignment_read: NotificationAssignmentRead, *, viewer: OutcomeViewer, + cls, root: Path, registry: Registration, assignment_read: NotificationAssignmentRead, ) -> NotificationWindow: - """Project the original frozen audience even before handling is recorded.""" + """The captured selection owns original membership and viewer visibility.""" + sources = assignment_read.sources if not sources: return CapturedNotificationWindow({}, (assignment_read,)) snapshot = registry.snapshot() observations = AgentActivity(root, registry).observe_recipients( - ((source, recipient) for source in sources for recipient in source.recipients - if viewer.includes(source.sender_lookup, recipient)), + ((page.source, recipient) for page in assignment_read.selection.pages for recipient in page.recipients), snapshot=snapshot, ) return cls.project_delivery_window( - root, snapshot, sources, observations, - viewer=viewer, assignment_read=assignment_read, + root, snapshot, observations, assignment_read=assignment_read, ) @classmethod def recorded_delivery_window(cls, root: Path, namespace: RegistryProvenance, sources: Sequence[Delivered], *, - viewer: OutcomeViewer) -> NotificationWindow: + viewer: OutcomeViewer, after: RecipientCursor = FirstRecipient(), + limit: int = RecipientSelection.max_limit) -> NotificationWindow: + selection = viewer.selection(sources, after=after, limit=limit) observations = { recipient.recipient_lookup: RecordedRecipientActivity() - for source in sources for recipient in source.recipients + for page in selection.pages for recipient in page.recipients } - read = NotificationAssignment.read(root, sources) + read = NotificationAssignment.read(root, selection) return cls.project_delivery_window( - root, namespace, sources, observations, - assignment_read=read, viewer=viewer, + root, namespace, 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, + observations: Mapping[ThreadLookup, RecipientActivity], + *, assignment_read: NotificationAssignmentRead, ) -> NotificationWindow: """Original assignment and read authorities own both display projections.""" + sources, viewer = assignment_read.sources, assignment_read.selection.viewer 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,)) - rows = NotificationAssignment.resolve(root, assignment_read) + rows = NotificationAssignment.resolve(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(assignment_read.selection, rows): 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,17 +429,15 @@ def sent_sources( @classmethod def for_sources( - cls, root: Path, registry: Registration, viewer: OutcomeViewer, - sources: Sequence[Delivered], *, - assignment_read: NotificationAssignmentRead, + cls, root: Path, registry: Registration, 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, + root, registry, assignment_read, ) return tuple( replace(notification, message=source.message) - for source in sources + for source in assignment_read.sources for notification in projected.outcomes[source.message.seq, source.message.message_id] ) @@ -412,44 +448,6 @@ class SentMessageOutcome(MessageNotification): message: Message = field(kw_only=True) -class RecipientCursor(SubclassUnion, affix=""): - """Where a page of one message's recipient outcomes starts; recipients are ordered by - their stable lookup, never by a display name.""" - - @abstractmethod - def following( - self, outcomes: Iterable[MessageNotification], - ) -> tuple[MessageNotification, ...]: - """The outcomes after this cursor, in recipient order.""" - - @staticmethod - def ordered(outcomes: Iterable[MessageNotification]) -> tuple[MessageNotification, ...]: - return tuple(sorted(outcomes, key=lambda outcome: outcome.recipient_identity.recipient_lookup)) - - -@dataclass(frozen=True) -class FirstRecipient(NullMember, RecipientCursor): - """No cursor given: start at the first recipient.""" - - def following( - self, outcomes: Iterable[MessageNotification], - ) -> tuple[MessageNotification, ...]: - return self.ordered(outcomes) - - -@dataclass(frozen=True) -class AfterRecipient(RecordMember, RecipientCursor): - """Start after this recipient, the last row of the previous page.""" - - recipient: FrozenRecipient - - def following( - self, outcomes: Iterable[MessageNotification], - ) -> tuple[MessageNotification, ...]: - return tuple(outcome for outcome in self.ordered(outcomes) - if outcome.recipient_identity.recipient_lookup > self.recipient.recipient_lookup) - - @dataclass(frozen=True, slots=True) class SentMessageSummary: """One sent message's recipient handling: its original reference and the totals diff --git a/src/agent_comms/private_registry_guard.py b/src/agent_comms/private_registry_guard.py index 045ab785da..90039358dd 100644 --- a/src/agent_comms/private_registry_guard.py +++ b/src/agent_comms/private_registry_guard.py @@ -46,7 +46,7 @@ from .private_path import PrivateFileRole from .private_runtime_schema import PrivateCoordinatorSchema, PrivateCoordinatorTable from .thread_identity import ClaimedIdentity -from .notification_evidence import NotificationAssignmentRead +from .notification_evidence import NotificationAssignmentRead, NotificationSelection if TYPE_CHECKING: @@ -162,7 +162,7 @@ def context_entered_sql(self, assignment: str) -> str: @abstractmethod def missing_notification_assignments( - self, sources: Sequence[Delivered], + self, selection: NotificationSelection, ) -> NotificationAssignmentRead: ... @abstractmethod @@ -202,9 +202,9 @@ def context_entered_sql(self, assignment: str) -> str: return "0" def missing_notification_assignments( - self, sources: Sequence[Delivered], + self, selection: NotificationSelection, ) -> NotificationAssignmentRead: - return NotificationAssignmentRead(tuple(sources), ()) + return NotificationAssignmentRead(selection, ()) def cursor_scope( self, snapshot: RegistrySnapshot, thread_name: str, session_id: str, @@ -283,7 +283,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, selection: NotificationSelection, ) -> 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..fbf9ffaac9 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -203,13 +203,11 @@ async def select( @staticmethod def capture_sent_outcomes( - comms: Comms, owner: Thread, sources: Sequence[Delivered], - assignment_read: NotificationAssignmentRead, + comms: Comms, 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, assignment_read, ) return tuple( outcome @@ -253,12 +251,13 @@ def prepare( store, lookup, after_seq=after_seq ) sent_read = NotificationAssignment.capture( - store.session._connection, sent_sources, privacy, + store.session._connection, OwnerOutcomes.of(sent_owner).selection(sent_sources), privacy, ) + sent_read = NotificationAssignment.capture_responses(comms.root, sent_read) if pending: participant.pointer.require_idle() sent_outcomes = SelectedParticipant.capture_sent_outcomes( - comms, sent_owner, sent_sources, sent_read + comms, sent_read ) return owner, participant, pending, sent_outcomes diff --git a/src/agent_comms/thread_presentation.py b/src/agent_comms/thread_presentation.py index 975703cae8..264a2c3c92 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -264,10 +264,10 @@ def with_sources(self, window: TranscriptSourceWindow) -> ThreadPresentation: 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) + viewer = OwnerOutcomes.of(window.read.identity.thread) + notification_read = NotificationAssignment.read(transcripts.root, viewer.selection(sources)) projected = MessageNotification.delivery_window( - transcripts.root, transcripts.registry, sources, notification_read, - viewer=OwnerOutcomes.of(window.read.identity.thread), + transcripts.root, transcripts.registry, notification_read, ) incoming = tuple( replace(notification, message=source.message) diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 9e27aac205..b74f296f04 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -58,6 +58,7 @@ from .pi_payloads import PublishedGoalToolDetails from .pi_vocabulary import ServiceTier, ThinkingLevelField from .presentation import AfterRecipient, ChannelView, FirstRecipient, MessageNotification, RecipientCursor +from .notification_evidence import RecipientSelection from .progress_claims import ProgressClaim from .read_basis import NamedViewer from .relationships import ( @@ -707,24 +708,25 @@ class CommsMessageOutcomesTool(ReadTool, ToolRequest): reference: Annotated[ MessageReference, *tool_field("The message's seq and message_id, as comms_threads lists them") ] - limit: Annotated[int, *tool_field("Maximum recipient rows in this page")] = 100 + limit: Annotated[int, *tool_field("Maximum recipient rows in this page")] = RecipientSelection.max_limit after: Annotated[ RecipientCursor, *tool_field("The previous page's next cursor; omit for the first page") ] = FirstRecipient() def apply(self, comms: Comms, caller: ToolCaller) -> MessageOutcomes: - if self.limit < 1: - raise ToolRefusedError("limit must be positive.") + try: + RecipientSelection.require_limit(self.limit) + except ValueError as error: + raise ToolRefusedError(str(error)) from error source, = comms.bus.bus.deliveries_for_references((self.reference,)) viewer = caller.outcome_viewer(comms) if not viewer.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) - following = self.after.following( - window.outcomes.get((source.message.seq, source.message.message_id), ())) - page = following[:self.limit] + window = ReadNotifications(str(comms.root), (self.reference,), viewer=viewer, + after=self.after, limit=self.limit).read(comms) + page = window.outcomes.get((source.message.seq, source.message.message_id), ()) return MessageOutcomes( - source.message, page, len(following) - len(page), + source.message, page, window.remaining(self.reference), AfterRecipient(page[-1].recipient_identity) if page else self.after, ) diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index b517aac113..c083212a9b 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -58,9 +58,10 @@ 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 NoNotificationWindow, NotificationWindow, RecipientSelection from agent_comms.presentation import ( AllOutcomes, CoordinationSnapshot, MessageNotification, NoRevision, OutcomeViewer, ReadState, + FirstRecipient, RecipientCursor, WireRevision, ) from agent_comms.registry_document import RosterVisibility @@ -621,9 +622,12 @@ class ReadNotifications(ReadRequest): references: tuple[MessageReference, ...] viewer: OutcomeViewer = field(default_factory=AllOutcomes, kw_only=True) + after: RecipientCursor = field(default_factory=FirstRecipient, kw_only=True) + limit: int = field(default=RecipientSelection.max_limit, kw_only=True) def read(self, comms: Comms) -> NotificationWindow: - return comms.views.message_notifications_for_references(self.viewer, self.references) + return comms.views.message_notifications_for_references( + self.viewer, self.references, after=self.after, limit=self.limit) def changed_since(self, comms: Comms, revision: WireRevision, previous: NotificationsRead) -> bool: @@ -639,7 +643,8 @@ class ReadRecordedNotifications(ReadNotifications): def read(self, comms: Comms) -> 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, self.viewer, + after=self.after, limit=self.limit) def changed_since(self, comms: Comms, revision: WireRevision, previous: NotificationsRead) -> bool: diff --git a/tests/test_notification_recipient_window.py b/tests/test_notification_recipient_window.py new file mode 100644 index 0000000000..43fca1ce17 --- /dev/null +++ b/tests/test_notification_recipient_window.py @@ -0,0 +1,145 @@ +"""Bound real frozen recipient acquisition before assignment/harness/reply decoding. + +No model, provider, product monkeypatch or hand-edited coordination row is used. +""" +from __future__ import annotations + +from pathlib import Path +import pytest + +from agent_comms.bus_publication import Delivered +from agent_comms.cli_bridges.claude import ClaudeBridge +from agent_comms.comms import Comms +from agent_comms.coordination_tables.harness_deliveries import HarnessDelivery +from agent_comms.coordinator import Coordination +from agent_comms.harness_delivery_states import PersistedOutcome +from agent_comms.notification_assignment import NotificationAssignment +from agent_comms.notification_evidence import RecipientSelection +from agent_comms.presentation import AllOutcomes, AfterRecipient, MessageNotification, OwnerOutcomes +from agent_comms.read_ledger import ReadLedger +from agent_comms.repo import Checkout +from agent_comms.threads import Thread +from agent_comms.ui_model.observation import ReadNotifications +from conftest import accept_initial, publish_initial +from test_coordinated_runtime import _root, tmp_path # noqa: F401 + + +@pytest.fixture +def frozen_window(tmp_path: Path) -> tuple[Comms, Delivered]: + root, root_id, comms, _initial, people = _root(tmp_path) + for index in range(14): + comms.registry.declare(Thread( + f"window-{index}", frozenset({"team"}), Checkout(tmp_path), + process_identity=people[1].process_identity, + )) + message = publish_initial(comms, "sender", "#team", "A frozen recipient window") + source = comms.bus.bus.delivery(message.seq) + with Coordination(root / "coordination.sqlite3") as store: + for recipient in source.recipients: + store.participants.register(recipient.recipient_lookup, committed=True) + accept_initial(comms.bus, root_id, message.seq, store) + return comms, source + + +def record_attempt(comms: Comms, source: Delivered, recipient_position: int) -> None: + recipient = source.recipients[recipient_position] + delivery = HarnessDelivery(ClaudeBridge, recipient.recipient_lookup, + source.message.message_id, source.message.seq, PersistedOutcome) + with Coordination(comms.root / "coordination.sqlite3") as store: + with store.session.writing() as connection: + assert delivery.begin(store.assignments, connection) + + +def test_acquired_rows_and_proofs_are_exact_bounded_subset_without_rewriting_original( + frozen_window: tuple[Comms, Delivered], +) -> None: + comms, source = frozen_window + frozen = source.recipients + before_reads = ReadLedger.at(comms.root).read() + before_wire = comms.bus.bus.revision() + viewer = AllOutcomes() + full = NotificationAssignment.read(comms.root, viewer.selection((source,))) + selection = viewer.selection((source,), limit=3) + bounded = NotificationAssignment.read(comms.root, selection) + selected = {recipient.recipient_lookup for recipient in selection.pages[0].recipients} + assert len(full.rows) == len(frozen) > len(bounded.rows) == 3 + assert {row.recipient_lookup for row, _activity in bounded.rows} == selected + assert bounded.sources[0] is source + assert source.recipients is frozen + full_window = MessageNotification.delivery_window(comms.root, comms.registry, full) + bounded_window = MessageNotification.delivery_window(comms.root, comms.registry, bounded) + key = source.message.seq, source.message.message_id + assert bounded_window.outcomes[key] == tuple( + row for row in full_window.outcomes[key] if row.recipient_identity.recipient_lookup in selected) + assert bounded_window.remaining(source.message.reference) == len(frozen) - 3 + assert ReadLedger.at(comms.root).read() == before_reads + assert comms.bus.bus.revision() == before_wire + + +def test_retained_cut_ignores_other_recipient_change_but_observes_its_own_proof( + frozen_window: tuple[Comms, Delivered], +) -> None: + comms, source = frozen_window + selection = AllOutcomes().selection((source,), limit=3) + before = NotificationAssignment.read(comms.root, selection) + record_attempt(comms, source, len(source.recipients) - 1) + assert NotificationAssignment.changed((before,), comms.root) == () + record_attempt(comms, source, 0) + assert NotificationAssignment.changed((before,), comms.root) == (before,) + changed = NotificationAssignment.read(comms.root, selection) + assert len(changed.attempts) == 1 + assert changed.attempts[0].recipient_lookup == source.recipients[0].recipient_lookup + + +def test_cursor_and_owner_scope_are_applied_by_actual_read_request_before_acquisition( + frozen_window: tuple[Comms, Delivered], +) -> None: + comms, source = frozen_window + reference = source.message.reference + first = ReadNotifications(str(comms.root), (reference,), limit=3).read(comms) + key = reference.seq, reference.message_id + cursor = AfterRecipient(first.outcomes[key][-1].recipient_identity) + second = ReadNotifications(str(comms.root), (reference,), after=cursor, limit=3).read(comms) + assert len(first.outcomes[key]) == len(second.outcomes[key]) == 3 + assert {row.recipient_identity for row in first.outcomes[key]}.isdisjoint( + row.recipient_identity for row in second.outcomes[key]) + assert second.remaining(reference) == len(source.recipients) - 6 + own = source.recipients[-1] + recipient = ReadNotifications(str(comms.root), (reference,), + viewer=OwnerOutcomes(own.recipient_lookup), limit=1).read(comms) + assert tuple(row.recipient_identity for row in recipient.outcomes[key]) == (own,) + with pytest.raises(ValueError): + AllOutcomes().selection((source,), limit=RecipientSelection.max_limit + 1) + + +def test_reply_acquisition_returns_only_first_reply_of_selected_frozen_author( + frozen_window: tuple[Comms, Delivered], +) -> None: + comms, source = frozen_window + selected, other = source.recipients[0], source.recipients[-1] + first = comms.messaging.send(selected.canonical_thread, source.message.sender, "first answer", + answers=(source.message.reference,)).message + comms.messaging.send(selected.canonical_thread, source.message.sender, "later answer", + answers=(source.message.reference,)) + comms.messaging.send(other.canonical_thread, source.message.sender, "other author's answer", + answers=(source.message.reference,)) + replies = comms.bus.bus.recipient_replies(((selected.recipient_lookup, source.message.reference),)) + assert tuple(reply.message.reference for reply in replies) == (first.reference,) + + +def test_independent_reply_changes_retained_evidence_without_claiming_context( + frozen_window: tuple[Comms, Delivered], +) -> None: + comms, source = frozen_window + selection = AllOutcomes().selection((source,), limit=1) + recipient = selection.pages[0].recipients[0] + record_attempt(comms, source, 0) + before = NotificationAssignment.read(comms.root, selection) + response = comms.messaging.send(recipient.canonical_thread, source.message.sender, + "An independent answer", answers=(source.message.reference,)).message + assert NotificationAssignment.changed((before,), comms.root) == (before,) + after = NotificationAssignment.read(comms.root, selection) + window = MessageNotification.delivery_window(comms.root, comms.registry, after) + row, = window.outcomes[source.message.seq, source.message.message_id] + assert row.answer == response.reference + assert not row.state.consumed diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index 83dec2c377..5ab2867250 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -33,23 +33,24 @@ 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,)) - original = NotificationAssignment.resolve(root, captured) + selection = AllOutcomes().selection((initial,)) + captured = NotificationAssignment.read(root, selection) + original = NotificationAssignment.resolve(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, selection) 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, selection) assert reopened == captured - assert NotificationAssignment.resolve(root, reopened) == original + assert NotificationAssignment.resolve(reopened) == original with TranscriptRead.publication_source(root) as native_read: assert native_read is not None assert comms.bus.bus.revision() == wire_before @@ -128,15 +129,16 @@ 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,))) + selection = AllOutcomes().selection((initial,)) + records = NotificationAssignment.resolve(NotificationAssignment.read(root, selection)) assert records - projected = tuple(NotificationAssignment.for_deliveries((initial,), records)) + projected = tuple(NotificationAssignment.for_deliveries(selection, records)) 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(selection, (*records, records[0]))) # Duplicate evidences outside the acquired deliveries do not change their result. - assert tuple(NotificationAssignment.for_deliveries((), (*records, records[0]))) == () + assert tuple(NotificationAssignment.for_deliveries(AllOutcomes().selection(()), (*records, records[0]))) == () 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..008a6f3c47 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -86,8 +86,9 @@ def test_viewer_selects_the_outcomes_of_a_delivery_window(tmp_path, viewer_of, s original = comms.bus.bus.delivery(sent.seq) lookups = {name: thread.incarnation.lookup for name, thread in comms.registry.snapshot().threads.items()} + viewer = viewer_of(lookups) window = MessageNotification.delivery_window( - comms.root, comms.registry, (original,), - NotificationAssignment.read(comms.root, (original,)), viewer=viewer_of(lookups), + comms.root, comms.registry, + NotificationAssignment.read(comms.root, viewer.selection((original,))), ) assert {item.recipient for item in window.outcomes[(sent.seq, sent.message_id)]} == seen From 08b2149ffe7bbf33f236ebc2e4e7955f7dba7833 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 23:36:00 -0400 Subject: [PATCH 063/109] Use retained receipt timing in historical notification controls --- tests/test_historical_notifications.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/test_historical_notifications.py b/tests/test_historical_notifications.py index 230641afe0..fa106410ef 100644 --- a/tests/test_historical_notifications.py +++ b/tests/test_historical_notifications.py @@ -72,7 +72,6 @@ def test_recorded_display_is_sparse_and_exact_incarnation(archived_notification) assert unpainted.displayed_to == () # A human display is Read and nothing else: no context entry rides along with it. assert painted.state == unpainted.state and not painted.state.consumed - assert MessageNotification.summary((painted,)).endswith("Consumed 0/1 · Read 1/1") assert live.bus.reads.cursor("reader", live.registry.snapshot()) == ReadCursor() ledger = ReadLedger.at(source.root) document = ledger.read() @@ -133,7 +132,6 @@ def human_display(): assert human_display() == (human.incarnation,) displayed = notification() assert displayed.state == consumed.state and not displayed.displayed_to - assert MessageNotification.summary((displayed,)).endswith("Consumed 1/1 · Read 0/1") def test_native_context_commit_is_consumed(): @@ -260,7 +258,8 @@ def test_sent_recipient_progresses_through_durable_consumption_and_answer(archiv activity=queued_activity, updated_at_ms=1_000, ) - consumed_activity = replace(sent_activity, progress=ShownToModelProgress(2_000)) + context_entry = ShownToModelProgress(2_000) + consumed_activity = replace(sent_activity, progress=context_entry) consumed = engagement.notification( recipient, SENDER, @@ -325,8 +324,9 @@ def delivered_to(observation, activity): assert idle.label.startswith("Consumed ") and not idle.state.seen_working unconsumed = delivered_to(observed(ActivityState.WORKING), sent_activity) assert unconsumed.label.startswith("Sent ") and not unconsumed.state.seen_working - confirmed = replace(unconsumed, state=unconsumed.state.harness_consumed()) - assert confirmed.label == "Seen, working" and confirmed.state.seen_working + confirmed = replace(unconsumed, state=unconsumed.state.harness_consumed(context_entry.at_ms)) + assert confirmed.state.seen_working and confirmed.state.consumed + assert confirmed.label == confirmed.timed_state(WorkingTakenProgress.label, context_entry.at_ms) # An explicit ignore decision is the only Seen, not answering. ignored = IgnoredAssignment.build(FullWake(), None, None) declined = ignored.notification(recipient, SENDER, observation=RecordedRecipientActivity(), @@ -336,9 +336,8 @@ def delivered_to(observation, activity): activity=sent_activity, updated_at_ms=2_000) assert unconfirmed.label == "Not answering" and not unconfirmed.state.seen_not_answering assert unconfirmed.state == IgnoredProgress(SentProgress(1_000)) - assert MessageNotification.summary((working, declined, unconfirmed, idle)) == ( - "Seen, working 1 · Seen, not answering 1 · Delivered 4/4 · Consumed 3/4 · Read 0/4") - assert MessageNotification.summary((unconfirmed,)) == "Not answering · Delivered 1/1 · Consumed 0/1 · Read 0/1" + assert sum(row.state.consumed for row in (working, declined, unconfirmed, idle)) == 3 + assert not unconfirmed.state.consumed assert delivered_answer.label.startswith("Answered ") assert delivered_answer.answer == MessageReference(99, reply_id) assert sent.recipient_identity == consumed.recipient_identity == answered.recipient_identity From 29a56272ec8eb06bd27dc9a76049becc7e69d561 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 23:49:00 -0400 Subject: [PATCH 064/109] Join native proof fixture writes through the isolated worker --- tests/test_following_context_notifications.py | 2 +- tests/test_harness_receipt_context.py | 4 +- tests/test_live_context_notifications.py | 57 ++++++++++--------- 3 files changed, 33 insertions(+), 30 deletions(-) diff --git a/tests/test_following_context_notifications.py b/tests/test_following_context_notifications.py index 850b193b53..73eab488f6 100644 --- a/tests/test_following_context_notifications.py +++ b/tests/test_following_context_notifications.py @@ -40,7 +40,7 @@ @asynccontextmanager async def following_session(directory: Path) -> AsyncIterator[PrivateSendClaim]: async with prepared_native_input(directory) as claim: - await acknowledge_context(claim, recorded_input_context(claim)) + await acknowledge_context(claim, await recorded_input_context(claim)) yield claim diff --git a/tests/test_harness_receipt_context.py b/tests/test_harness_receipt_context.py index c8e9b30f7d..6cb8054ddc 100644 --- a/tests/test_harness_receipt_context.py +++ b/tests/test_harness_receipt_context.py @@ -6,7 +6,6 @@ from agent_comms.cli_bridges.claude import ClaudeBridge from agent_comms.coordination_tables.harness_deliveries import HarnessDelivery from agent_comms.coordinator import Coordination -from agent_comms.field_codec import TranscriptRecordId from agent_comms.harness_delivery_states import ( AcceptedOutcome, DeliveredOutcome, EmittedOutcome, FilteredOutcome, ObservedMonitor, PersistedOutcome, UnconfirmedOutcome, UnrecordedMonitor, @@ -14,6 +13,7 @@ from agent_comms.notification_progress import HarnessConsumedProgress from agent_comms.presentation import AllOutcomes from test_coordinated_runtime import _root, tmp_path # noqa: F401 +from transcript_record_fixture import transcript_record @pytest.mark.parametrize(('outcome', 'monitor', 'consumed', 'arrived'), ( @@ -23,7 +23,7 @@ (DeliveredOutcome, UnrecordedMonitor(), False, 0), (AcceptedOutcome, UnrecordedMonitor(), False, 1), (PersistedOutcome, UnrecordedMonitor(), False, 1), - (DeliveredOutcome, ObservedMonitor(TranscriptRecordId('complete-original'), 1791964800.125), True, 1), + (DeliveredOutcome, ObservedMonitor(transcript_record('complete-original'), 1791964800.125), True, 1), )) def test_only_retained_complete_body_proof_promotes_consumption( tmp_path, outcome, monitor, consumed, arrived, diff --git a/tests/test_live_context_notifications.py b/tests/test_live_context_notifications.py index 70849d2bb8..edbb443491 100644 --- a/tests/test_live_context_notifications.py +++ b/tests/test_live_context_notifications.py @@ -21,27 +21,30 @@ from test_native_failure_recovery import SentHistory, prepared_native_input, prepared_native_triage -def recorded_input_context(claim: PrivateSendClaim) -> NativeContextProof: - """Record the actual reserved prompt at its selected session's byte position.""" - session, participant = claim.session, claim.selected - SentHistory.write(claim, session, participant) - identity = session.identity - entry = 'input-before-answer' - offset = append_input_entry(identity.path, { - 'type': 'message', 'id': entry, - 'timestamp': '2026-10-01T00:00:00.000Z', - 'message': {'role': 'user', 'inputId': claim.input_id, - 'inputDigest': native_request_digest(claim.prompt.text), - 'content': [{'type': 'text', 'text': claim.prompt.text}]}, - }) - record = { - 'schema': 2, 'type': 'context_committed', - 'inputId': claim.input_id, 'sessionId': identity.session_id, - 'sessionEntryId': entry, 'requestGeneration': 1, - 'llmContextDigest': 'f' * 64, 'committedAtMs': 1_791_964_800_001, - } - write_proof_rows(session.files(participant.comms.root), (record,)) - return recorded_context(identity.path, record, session_entry_start=offset) +async def recorded_input_context(claim: PrivateSendClaim) -> NativeContextProof: + """Join the isolated raw writer before returning its retained session/journal proof.""" + def write() -> NativeContextProof: + session, participant = claim.session, claim.selected + SentHistory.write(claim, session, participant) + identity = session.identity + entry = 'input-before-answer' + offset = append_input_entry(identity.path, { + 'type': 'message', 'id': entry, + 'timestamp': '2026-10-01T00:00:00.000Z', + 'message': {'role': 'user', 'inputId': claim.input_id, + 'inputDigest': native_request_digest(claim.prompt.text), + 'content': [{'type': 'text', 'text': claim.prompt.text}]}, + }) + record = { + 'schema': 2, 'type': 'context_committed', + 'inputId': claim.input_id, 'sessionId': identity.session_id, + 'sessionEntryId': entry, 'requestGeneration': 1, + 'llmContextDigest': 'f' * 64, 'committedAtMs': 1_791_964_800_001, + } + write_proof_rows(session.files(participant.comms.root), (record,)) + return recorded_context(identity.path, record, session_entry_start=offset) + + return await Coordination.run_worker(write) async def acknowledge_context(claim: PrivateSendClaim, context: NativeContextProof) -> None: @@ -54,7 +57,7 @@ async def test_reserved_context_notifies_before_any_assistant_reply(tmp_path: Pa async with prepared_native_input( tmp_path, source_bodies=tuple(f'Grouped source {index}' for index in range(source_count - 1)), ) as claim: - context = recorded_input_context(claim) + context = await recorded_input_context(claim) comms = claim.selected.comms originals = tuple(row.source for row in claim.stage.assignments) assert len(originals) == source_count @@ -81,7 +84,7 @@ async def test_reserved_context_notifies_before_any_assistant_reply(tmp_path: Pa async def test_triage_consumption_precedes_and_survives_later_verdict(tmp_path: Path): async with prepared_native_triage(tmp_path) as claim: - context = recorded_input_context(claim) + context = await recorded_input_context(claim) originals = tuple(row.source for row in claim.stage.assignments) before = claim.selected.comms.views.message_notifications_for_references(AllOutcomes(), originals) assert all(not row.state.consumed for rows in before.outcomes.values() for row in rows) @@ -109,7 +112,7 @@ async def test_triage_consumption_precedes_and_survives_later_verdict(tmp_path: async def test_triage_failure_keeps_previously_acknowledged_context_and_no_retry(tmp_path: Path): async with prepared_native_triage(tmp_path) as claim: - context = recorded_input_context(claim) + context = await recorded_input_context(claim) await acknowledge_context(claim, context) with Coordination(claim.store_path) as store: claim.stage.fail_decision(store, claim.participant, claim.input_id, claim.token_digest, context) @@ -123,7 +126,7 @@ async def test_triage_failure_keeps_previously_acknowledged_context_and_no_retry async def test_later_corroborated_request_preserves_first_context_receipt(tmp_path: Path): async with prepared_native_input(tmp_path) as claim: - context = recorded_input_context(claim) + context = await recorded_input_context(claim) await acknowledge_context(claim, context) files = claim.session.files(claim.selected.comms.root) # Append through the declared journal owner, preserving its original file identity. @@ -143,7 +146,7 @@ async def test_later_corroborated_request_preserves_first_context_receipt(tmp_pa async def test_same_request_contradiction_cannot_replace_context_receipt(tmp_path: Path): async with prepared_native_input(tmp_path) as claim: - context = recorded_input_context(claim) + context = await recorded_input_context(claim) await acknowledge_context(claim, context) with Coordination(claim.store_path) as store: with pytest.raises(IdentityConflict): @@ -160,7 +163,7 @@ async def test_uncorroborated_live_context_never_grants_consumption( tmp_path: Path, corrupt: Callable[[NativeContextProof], NativeContextProof], ): async with prepared_native_input(tmp_path) as claim: - context = recorded_input_context(claim) + context = await recorded_input_context(claim) with pytest.raises(IdentityConflict): await acknowledge_context(claim, corrupt(context)) with Coordination(claim.store_path) as store: From 04dce31fca2f7be33b15b42abbb8f7eb860677d4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 23:58:30 -0400 Subject: [PATCH 065/109] Separate reviewed source members from explicit combined-carrier promotion --- .github/workflows/debt-ratchet.yml | 30 +++-- src/agent_comms/commander/judge.py | 14 +- src/agent_comms/head_review.py | 116 +++++++++++++---- src/agent_comms/integration.py | 88 +++++++++---- src/agent_comms/notification_reads.py | 7 +- src/agent_comms/todos.py | 101 +++++++++++---- tests/integration_source_fixture.py | 32 +++-- tests/test_checker_stack.py | 9 +- tests/test_head_review.py | 177 ++++++++++++++++++-------- tests/test_integration.py | 70 ++++++++-- tests/test_module_layers_clients.py | 9 +- tests/test_notification_read_state.py | 6 +- tests/test_project_metadata.py | 3 +- 13 files changed, 480 insertions(+), 182 deletions(-) diff --git a/.github/workflows/debt-ratchet.yml b/.github/workflows/debt-ratchet.yml index b060efef82..159e37cae0 100644 --- a/.github/workflows/debt-ratchet.yml +++ b/.github/workflows/debt-ratchet.yml @@ -187,26 +187,28 @@ jobs: name: review-verdict needs: ready runs-on: ubuntu-latest - timeout-minutes: 2 + timeout-minutes: 5 permissions: + contents: read statuses: read pull-requests: read env: GH_TOKEN: ${{ github.token }} - QUEUE_REF: ${{ github.event.merge_group.head_ref }} steps: - # A pull request is pushed before its verdict exists; the queue's group - # is where the commander's verdict on the head must already be success. - - name: The commander's verdict on the queued head is success - if: github.event_name == 'merge_group' - shell: bash - run: | - [[ $QUEUE_REF =~ /pr-([0-9]+)- ]] - head=$(gh api "repos/$GITHUB_REPOSITORY/pulls/${BASH_REMATCH[1]}" --jq .head.sha) - verdict=$(gh api "repos/$GITHUB_REPOSITORY/commits/$head/status" \ - --jq '[.statuses[] | select(.context == "agent-comms/review")][0].state // "none"') - echo "PR #${BASH_REMATCH[1]} head $head: agent-comms/review=$verdict" - [[ $verdict == success ]] + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version-file: pyproject.toml + - name: Install the native source-input reader + run: python -m pip install -e src/agent_comms/skills/refactor-audit -e . + # The PR check proves a separate combined carrier and exact reviewed members. + # The queue additionally requires the root's explicit promotion at this head. + # A hand gh auto-merge request cannot turn source acceptance into that permission. + - name: Only the captured combined carrier may merge + run: PYTHONPATH=src python -m agent_comms.integration --check-merge-inputs "$RUNNER_TEMP/merge-inputs" ratchet: name: debt-${{ matrix.kind }} diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index f8cd72fad5..79a0689432 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -54,9 +54,9 @@ EditSendBackVerdict, PeerAcceptedVerdict, RewrittenVerdict, - GrownHeadVerdict, GrowthReason, ReviewedHeadVerdict, + PromotedHeadVerdict, QualifiedHeadVerdict, RejectedHeadVerdict, HeadVerdict, @@ -493,12 +493,12 @@ def edit_problems(self) -> dict[str, str]: @dataclass(frozen=True, kw_only=True) class OkVerb(ReasonedVerb, QuotingVerb): - verdict = ReviewedHeadVerdict - help = "the edit or exact PR head is accepted" + verdict: ClassVar[type[ReviewedHeadVerdict]] = ReviewedHeadVerdict + help = "the edit or exact source PR head is accepted, without enqueueing it" def head_operation(self) -> HeadVerdict: reason = VerdictReason(self.why) - return (GrownHeadVerdict(reason, GrowthReason.every(self.growth)) if self.growth + return (self.verdict.grown(reason, GrowthReason.every(self.growth)) if self.growth else self.verdict(reason)) growth: Annotated[ tuple[str, ...], @@ -562,6 +562,12 @@ def check_edits(self, edits: list[Edit]) -> None: pass +@dataclass(frozen=True, kw_only=True) +class PromoteVerb(OkVerb, HeadOnlyVerb): + help = "explicitly review and enqueue this captured combined carrier, never an individual source" + verdict = PromotedHeadVerdict + + @dataclass(frozen=True, kw_only=True) class ReviewingVerb(HeadOnlyVerb): help = "a fork is reading this exact PR head; its delivery is not owed until ok or back" diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index f1add45efa..d3ed9660b0 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -185,9 +185,23 @@ def require_tree(self, repository: Repo) -> None: if self.expected_tree(repository) != self.candidate_tree: raise ToolRefusedError(f"{self.repository.to_wire()} candidate tree is not its captured combination") + def require_ancestry(self, repository: Repo, candidate: str) -> None: + """A resolved Git combination retains every actual source, not a guessed patch.""" + self.require_origin(repository) + WorkBranches(repository.checkout).require_commits( + "origin", self.baseline.commit, candidate, *(head.head for head in self.heads)) + if not Repo.descends(repository.checkout, self.baseline.commit, candidate) or any( + not Repo.descends(repository.checkout, head.head, candidate) for head in self.heads): + raise ToolRefusedError("The resolved candidate dropped a captured source parent") + if Repo.git(repository.checkout, "rev-parse", f"{candidate}^{{tree}}").strip() != self.candidate_tree: + raise ToolRefusedError("The resolved candidate tree differs from its captured source") + def require_candidate(self, repository: Repo, carrier: PullRequestHead) -> None: - """The event/review owner verifies the current carrier; all other heads stay exact.""" + """An ordinary/companion combination is reproduced; every source head stays exact.""" self.require_tree(repository) + self.require_heads(carrier) + + def require_heads(self, carrier: PullRequestHead) -> None: for head in self.heads: if not head.same_pull_request(carrier): current = head.current() @@ -196,7 +210,7 @@ def require_candidate(self, repository: Repo, carrier: PullRequestHead) -> None: @dataclass(frozen=True) -class IntegrationInput: +class IntegrationInput(DeclaredFamily, affix="Input"): """Immutable compared source cuts, bound by their carrier's reviewed Git commit. This is not an authorization record. Exact-head review and draft state still decide @@ -251,29 +265,30 @@ def validate(self, repositories: tuple[Repo, ...], carrier: PullRequestHead) -> if current.base != WorkBranches.main: raise ToolRefusedError("The integration carrier no longer targets main") self.require_carrier(core, carrier) - for entry in self.sources: + self.require_local_candidate(core, carrier) + local = self.source(carrier.repo) + if IntegrationSource.main_head(core) != local.baseline.commit: + raise ToolRefusedError(f"{core.github.to_wire()} main moved; recapture inputs") + for entry in self.companions(carrier.repo): entry.validate(Repo.selected(repositories, entry.repository.to_wire()), carrier) + def companions(self, repository: RepositoryName) -> tuple[IntegrationSource, ...]: + return tuple(source for source in self.sources if source.repository != repository) + + def require_source_reviews(self) -> None: + """Native promotion and the hosted gate require these same exact source decisions.""" + for source in self.sources: + for head in source.heads: + if not HeadReview.accepted(head.repo, head.head): + raise ToolRefusedError( + f"{head.repo.to_wire()}#{head.pr} at {head.head} has no accepted exact-head " + "source review; pending qualification cannot promote the carrier.") + def require_carrier(self, core: Repo, carrier: PullRequestHead) -> None: - """The same immutable carrier proof is used by final review and tested Git events.""" - WorkBranches(core.checkout).require_commits("origin", carrier.head) - source = self.source(carrier.repo) - self.require_repositories(self.declared_repositories(core, source.baseline.commit, carrier.head)) - members = tuple(head for head in source.heads if head.same_pull_request(carrier)) - if len(members) != 1: - raise ToolRefusedError("The integration carrier is not a unique captured local PR") - if members[0].head == carrier.head: - raise ToolRefusedError("An input captures the carrier's earlier source, never its own commit SHA") - for head in source.heads: - if not Repo.descends(core.checkout, head.head, carrier.head): - raise ToolRefusedError(f"The carrier dropped original source #{head.pr}") - if Repo.git(core.checkout, "rev-parse", f"{carrier.head}^{{tree}}").strip() != source.candidate_tree: - raise ToolRefusedError("The carrier tree differs from its captured candidate") - if self.from_message(WorkBranches(core.checkout).commit_message(carrier.head)) != (self,): - raise ToolRefusedError("The current carrier does not contain this exact canonical input") - StackSources.require_compatible( - Repo.git(core.checkout, "show", f"{source.baseline.commit}:stack/pyproject.toml"), - Repo.git(core.checkout, "show", f"{carrier.head}:stack/pyproject.toml")) + raise ToolRefusedError("An individual source is not a captured combined carrier") + + def require_local_candidate(self, core: Repo, carrier: PullRequestHead) -> None: + self.source(core.github).require_candidate(core, carrier) @property def canonical(self) -> str: @@ -320,6 +335,47 @@ def digest_from_message(cls, message: str) -> str: return "".join(inputs.digest for inputs in cls.from_message(message)) +@dataclass(frozen=True) +class OrdinaryInput(IntegrationInput): + """One source can be compared, but never substitutes for combined merge authority.""" + + +@dataclass(frozen=True) +class CombinedInput(IntegrationInput): + """The actual separate carrier before publication of this immutable source capture.""" + + carrier: PullRequestHead + + def __post_init__(self) -> None: + super().__post_init__() + self.source(self.carrier.repo) + if len(self.carrier.head) != 40 or self.carrier.head != self.carrier.head.lower(): + raise MalformedRecord("A combined carrier requires its full prepublication Git identity") + if any(head.same_pull_request(self.carrier) for source in self.sources for head in source.heads): + raise MalformedRecord("The combined carrier is separate from every source member") + + def require_local_candidate(self, core: Repo, carrier: PullRequestHead) -> None: + self.source(core.github).require_heads(carrier) + + def require_carrier(self, core: Repo, carrier: PullRequestHead) -> None: + """Review and hosted merge checks consume this same Git-bound carrier distinction.""" + if not self.carrier.same_pull_request(carrier) or core.github != self.carrier.repo: + raise ToolRefusedError("This is not the captured combined carrier pull request") + reader = WorkBranches(core.checkout) + reader.require_commits("origin", self.carrier.head, carrier.head) + source = self.source(core.github) + self.require_repositories(self.declared_repositories(core, source.baseline.commit, carrier.head)) + source.require_ancestry(core, self.carrier.head) + source.require_ancestry(core, carrier.head) + if reader.commit_parents(carrier.head) != (self.carrier.head,): + raise ToolRefusedError("The published carrier must directly preserve its captured prepublication head") + if self.from_message(reader.commit_message(carrier.head)) != (self,): + raise ToolRefusedError("The current carrier does not contain this exact canonical input") + StackSources.require_compatible( + Repo.git(core.checkout, "show", f"{source.baseline.commit}:stack/pyproject.toml"), + Repo.git(core.checkout, "show", f"{carrier.head}:stack/pyproject.toml")) + + class Side(DeclaredFamily, affix="Side"): """One compared source cut; selection and materialized path belong to this member.""" @@ -392,7 +448,7 @@ def lines(self, request: PullRequestHead) -> tuple[str, ...]: ... @dataclass(frozen=True) class UntouchedMergeArm(MergeArm): - """Only acceptance arms; every other verdict leaves auto-merge as it was.""" + """Source review records a decision without requesting a merge.""" def lines(self, request: PullRequestHead) -> tuple[str, ...]: return () @@ -540,3 +596,17 @@ def post(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> ) if posted.returncode: raise ToolRefusedError(posted.stderr.strip() or f"Review status on {head} failed.") + + +class MergeHeadReview(HeadReview, declared_name=None): + """An explicit permission to merge, separate from accepting source for combination.""" + + context = "agent-comms/merge" + + +@dataclass(frozen=True) +class UnpromotedHeadReview(MergeHeadReview): + state = "pending" + + def description(self, reviewer: str, subject: str) -> str: + return f"Not promoted by {reviewer} on {subject}" diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index 61c44ddb11..176476ec9b 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -20,14 +20,14 @@ from .declared_family import DeclaredFamily from .errors import ToolRefusedError from .field_codec import FieldCodec, GitCommitText, PathText, WireName, boundary_decoder -from .head_review import BaseSide, HeadReview, HeadSide, IntegrationInput, IntegrationSource, PullRequestHead, Side +from .head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, IntegrationSource, OrdinaryInput, PullRequestHead, Side from .installer.runtime_resolution import StackSources from .launch_runtime import CheckoutSource, RestoredRuntime from .repo import Checkout, DetachedStart, GitCommandError, Repo from .systemd_unit import AgentsSlice from .repository_name import RepositoryName from .test_lease import LeaseStart, TestLease -from .todos import PullRequests +from .todos import PromotedHeadVerdict, PullRequests from .work_branches import WorkBranches class IntegrationFailed(ToolRefusedError): @@ -288,10 +288,11 @@ class TestedSources(DeclaredFamily, affix="Sources"): environment: RunnerEnvironment = field(kw_only=True, compare=False) resolve_option: ClassVar[str] = "--resolve-ci-inputs" revalidate_option: ClassVar[str] = "--revalidate-ci-inputs" + merge_option: ClassVar[str] = "--check-merge-inputs" @classmethod def options(cls) -> tuple[str, ...]: - return cls.resolve_option, cls.revalidate_option + return cls.resolve_option, cls.revalidate_option, cls.merge_option @property def parents(self) -> tuple[str, ...]: @@ -334,8 +335,20 @@ def require_carrier(self) -> None: def main_commits(self, inputs: IntegrationInput) -> dict[RepositoryName, str]: return {source.repository: source.baseline.commit for source in inputs.sources} - def require_local_candidate(self, source: IntegrationSource) -> None: - source.require_candidate(self.repository, self.carrier) + def require_local_candidate(self, inputs: IntegrationInput) -> None: + inputs.require_local_candidate(self.repository, self.carrier) + + def require_merge_review(self) -> None: + """A PR can compute before its review; queue admission requires explicit promotion.""" + + def check_merge_inputs(self, directory: Path) -> IntegrationInput: + """The required hosted check uses the same typed carrier proof as native promotion.""" + inputs = self.materialize(directory) + inputs.require_carrier(self.repository, self.carrier) + inputs.require_source_reviews() + self.require_merge_review() + print(f"COMBINED CARRIER #{self.carrier.pr} {self.carrier.head}; inputs {inputs.digest}", flush=True) + return inputs def materialize(self, directory: Path) -> IntegrationInput: """Capture once from Git/forge, then use these same exact checkouts for every consumer.""" @@ -366,7 +379,7 @@ def materialize(self, directory: Path) -> IntegrationInput: WorkBranches(repository.checkout).require_commits("origin", commit) tree = Repo.git(repository.checkout, "rev-parse", f"{commit}^{{tree}}").strip() sources.append(IntegrationSource(RepositoryCommit(repository.github, commit), (), tree)) - inputs = IntegrationInput(tuple(sorted(sources, key=lambda source: source.repository.key))) + inputs = OrdinaryInput(tuple(sorted(sources, key=lambda source: source.repository.key))) self.validate(inputs, tuple(repositories)) for source in inputs.sources: repository = Repo.selected(tuple(repositories), source.repository.to_wire()) @@ -423,8 +436,8 @@ def validate(self, inputs: IntegrationInput, repositories: tuple[Repo, ...]) -> repository = Repo.selected(repositories, source.repository.to_wire()) if IntegrationSource.main_head(repository) != mains[source.repository]: raise IntegrationFailed(f"{source.repository.to_wire()} main moved; recapture") - self.require_local_candidate(local) - for source in (source for source in inputs.sources if source.repository != self.repository.github): + self.require_local_candidate(inputs) + for source in inputs.companions(self.repository.github): source.require_candidate(Repo.selected(repositories, source.repository.to_wire()), self.carrier) @@ -444,6 +457,11 @@ def resolve(cls, repository: Repo, tested: str, document: object, *, class MergeGroupSources(TestedSources, declared_name="merge_group"): + def require_merge_review(self) -> None: + if not (HeadReview.accepted(self.carrier.repo, self.carrier.head) + and PromotedHeadVerdict.accepted(self.carrier.repo, self.carrier.head)): + raise IntegrationFailed("The exact combined carrier has no explicit reviewed merge promotion") + @classmethod @boundary_decoder def resolve(cls, repository: Repo, tested: str, document: object, *, @@ -506,9 +524,10 @@ def require_carrier(self) -> None: def main_commits(self, inputs: IntegrationInput) -> dict[RepositoryName, str]: return {**super().main_commits(inputs), self.repository.github: self.tested} - def require_local_candidate(self, source: IntegrationSource) -> None: + def require_local_candidate(self, inputs: IntegrationInput) -> None: """Main owns the transition from exact open sources to exact merged ancestry.""" - source.require_tree(self.repository) + source = inputs.source(self.repository.github) + source.require_ancestry(self.repository, self.carrier.head) for head in (head for head in source.heads if not head.same_pull_request(self.carrier)): rows = tuple(row for row in PullRequests().rows(head.repo, (head.pr,)) if row.head == head.head and row.base == WorkBranches.main) @@ -543,8 +562,9 @@ def accepted_heads(self) -> tuple[PullRequestHead, ...]: if HeadReview.accepted(repo.github, request.head) ) - def qualify(self, context: CommanderContext, problems: tuple[str, ...]) -> IntegrationInput: - """Prepare one checking carrier from stored orders; never test, post, ready or arm it.""" + def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, + carrier: PullRequestHead) -> CombinedInput: + """Capture the existing resolved carrier and its distinct stored source members.""" self.checkout.require_editable() self.failure.require_qualification() if not problems or len(set(problems)) != len(problems): @@ -580,14 +600,13 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...]) -> Integ raise IntegrationFailed("A linked source is closed, ambiguous or targets another base") selected.append(PullRequestHead(repo.github, row.number, row.head)) heads[repo.github] = tuple(selected) - primary = PullRequests.linked_among((todos[0],), core.github) - carriers = tuple(head for head in heads[core.github] if head.pr in primary) - if len(carriers) != 1: - raise IntegrationFailed("The primary problem has no unique existing local carrier") + current = carrier.current() + candidate_commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() + if current.repo != core.github or current.base != WorkBranches.main or current.head != candidate_commit: + raise IntegrationFailed("Capture requires the actual published combined carrier at this checkout's HEAD") self.output.mkdir(parents=True, exist_ok=False) IntegrationCheckout.create(registered_core, BaseSide.checkout(self.output, core.github), base) - tree = self.checkout.combine_sources(base, heads[core.github]) - candidate_commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() + tree = Repo.git(self.checkout.path, "rev-parse", "HEAD^{tree}").strip() required = IntegrationInput.declared_repositories(registered_core, base, candidate_commit) if linked_repositories - set(required): raise IntegrationFailed("A selected order links an unknown stack repository") @@ -614,8 +633,10 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...]) -> Integ raise IntegrationFailed("A captured main moved; preserve this failed attempt and recapture") for head in source.heads: head.current() - if source.expected_tree(repo) != source.candidate_tree: - raise IntegrationFailed("Actual and reproduced candidate trees differ") + if source.repository == core.github: + source.require_ancestry(repo, candidate_commit) + else: + source.require_tree(repo) current_todos = tuple(context.comms.goals.todos.get(todo.id) for todo in todos) for todo in current_todos: todo.require_commander(context.incarnation) @@ -623,15 +644,16 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...]) -> Integ linked = PullRequests.linked_among(current_todos, source.repository) if any(head.pr not in linked for head in source.heads): raise IntegrationFailed("A selected problem no longer links its captured source") - inputs = IntegrationInput(tuple(sorted(sources, key=lambda source: source.repository.key))) + inputs = CombinedInput(tuple(sorted(sources, key=lambda source: source.repository.key)), carrier) context.comms.goals.todos.require_input_links(inputs) if Repo.git(self.checkout.path, "rev-parse", f"{base}^{{tree}}").strip() == inputs.source(core.github).candidate_tree: raise IntegrationFailed("The Core carrier has no substantive source change") original_message = WorkBranches(self.checkout.path).commit_message(candidate_commit) if IntegrationInput.from_message(original_message): raise IntegrationFailed("Capture requires a fresh substantive integration commit, not nested metadata") - Repo.git(self.checkout.path, "commit", "--amend", "-m", - f"{original_message.rstrip()}\n\n{inputs.trailer}") + carrier.current() + Repo.git(self.checkout.path, "commit", "--allow-empty", "-m", + f"Capture the resolved combined sources\n\n{inputs.trailer}") commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() IntegrationCheckout.create(registered_core, HeadSide.checkout(self.output, core.github), commit) inputs.write(self.output) @@ -751,9 +773,13 @@ def run() -> int: help="materialize the actual GitHub event's exact compared sources; no bus") operation.add_argument(TestedSources.revalidate_option, type=Path, metavar="OUTPUT", help="revalidate the same event/input and source heads before CI success") + operation.add_argument(TestedSources.merge_option, type=Path, metavar="OUTPUT", + help="require a real combined carrier and its exact reviewed source members") operation.add_argument("--qualify", default="", metavar="PROBLEM", help="prepare sources from this actual primary order, without running tests") local_options.add_argument("--with-problem", action="append", default=[], metavar="PROBLEM") + local_options.add_argument("--carrier", type=int, default=0, metavar="PR", + help="the existing separate combined PR at this checkout's published HEAD") local_options.add_argument("--comms-root", type=Path, help="the existing authorized root for stored-problem qualification") parser.add_argument( @@ -770,7 +796,7 @@ def run() -> int: pytest_arguments = pytest_arguments[1:] arguments.pytest_arguments = pytest_arguments values = vars(arguments) - if arguments.resolve_ci_inputs or arguments.revalidate_ci_inputs: + if arguments.resolve_ci_inputs or arguments.revalidate_ci_inputs or arguments.check_merge_inputs: forbidden = {option.dest for option in local_options._group_actions if values[option.dest]} if forbidden: parser.error("CI source resolution does not accept tests, a bus root or local qualification options: " @@ -779,8 +805,10 @@ def run() -> int: graph = TestedSources.read((arguments.checkout or Path.cwd()).resolve()) if arguments.resolve_ci_inputs: graph.materialize(arguments.resolve_ci_inputs.resolve()) - else: + elif arguments.revalidate_ci_inputs: graph.revalidate(arguments.revalidate_ci_inputs.resolve()) + else: + graph.check_merge_inputs(arguments.check_merge_inputs.resolve()) return 0 missing = {option.dest for option in (checkout_option, output_option, risk_option) if not values[option.dest]} @@ -798,10 +826,14 @@ def run() -> int: if pytest_arguments: parser.error("Qualification cannot run tests") context = CommanderContext.read_root(resolve_comms_route(arguments.comms_root).root) - run.qualify(context, (arguments.qualify, *arguments.with_problem)) + if arguments.carrier <= 0: + parser.error("Qualification requires --carrier naming the actual separate combined PR") + carrier = PullRequestHead.at(Repo.of(run.checkout.path).github, arguments.carrier) + run.qualify(context, (arguments.qualify, *arguments.with_problem), + carrier=PullRequestHead(carrier.repo, carrier.pr, carrier.head)) return 0 - if arguments.with_problem: - parser.error("--with-problem requires --qualify") + if arguments.with_problem or arguments.carrier: + parser.error("--with-problem and --carrier require --qualify") return run.execute() diff --git a/src/agent_comms/notification_reads.py b/src/agent_comms/notification_reads.py index a3b0986a31..c2bf00ca25 100644 --- a/src/agent_comms/notification_reads.py +++ b/src/agent_comms/notification_reads.py @@ -21,8 +21,11 @@ from .presentation import WireRevision +@dataclass(frozen=True) class NotificationRead(UiRead[NotificationWindow], ABC): - """A batched notification window read; each member owns when its settled read is stale.""" + """Every notification read declares its references; members own evidence freshness.""" + + references: tuple[MessageReference, ...] @abstractmethod def changed_since(self, comms: Comms, revision: WireRevision, @@ -34,8 +37,6 @@ 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) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 4c90667f46..169d5cce2a 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -102,6 +102,8 @@ HeadReviewStatus, IntegrationInput, MergeArm, + MergeHeadReview, + UnpromotedHeadReview, OpenPullRequestHead, PreparedHeadReview, PullRequestHead, @@ -4083,14 +4085,23 @@ class HeadVerdict(HeadReview, declared_name=None): @classmethod def require_merge(cls, request: PullRequestHead) -> None: - """Only a verdict that allows integration lets the owner's admin bypass merge.""" - if not cls.allows_integration(): + """Source review cannot authorize even the owner's direct merge path.""" + if not cls.allows_merge(): raise TodoError( f"{request.repo.to_wire()}#{request.pr} at {request.head} has no recorded ok verdict.") + @classmethod + def allows_merge(cls) -> bool: + return False + def prepare_sources(self, goals: Goals, request: ReadPullRequestHead) -> None: """Marking review pending or rejecting it grants no source promotion permission.""" + def post(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> None: + # Withdraw first: a failed source-status write cannot leave an earlier promotion live. + UnpromotedHeadReview().post(repo, head, reviewer, subject) + super().post(repo, head, reviewer, subject) + def reviewed(self, goals: Goals, request: PullRequestHead, prepared: PreparedHeadReview, reviewer: ThreadIncarnation, order: HeadReviewOrder) -> HeadReviewed: request = replace(request, head=prepared.current.head) @@ -4100,7 +4111,7 @@ def reviewed(self, goals: Goals, request: PullRequestHead, prepared: PreparedHea return HeadReviewed(recorded, self.landing(prepared.current)) def landing(self, current: OpenPullRequestHead) -> MergeArm: - """Only an acceptance moves the PR toward main; every other verdict leaves auto-merge be.""" + """Source verdicts never request merge; combined promotion overrides this behavior.""" return UntouchedMergeArm() def subject(self, goals: Goals, request: ReadPullRequestHead) -> str: @@ -4164,6 +4175,8 @@ class QualifiedHeadVerdict(DecidedHeadVerdict): @dataclass(frozen=True) class ReviewedHeadVerdict(DecidedHeadVerdict): + """Accept an exact source for combination, never enqueue its individual PR.""" + state = "success" verb = "Accepted" preparation_field = GrowthReason.option @@ -4194,24 +4207,9 @@ def carry(self, goals: Goals, record: HeadReviewRecord, current: ReadPullRequest def allows_integration(cls) -> bool: return True - def prepare_sources(self, goals: Goals, request: ReadPullRequestHead) -> None: - """Final acceptance checks the actual source owners, never an author's self-hash.""" - project = goals.registry.snapshot().commander_project.require() - repository = Repo.selected(project.repos, request.repo.to_wire()) - message = WorkBranches(repository.checkout).commit_message(request.head) - for inputs in IntegrationInput.from_message(message): - goals.todos.require_input_links(inputs) - inputs.validate(project.repos, request) - for source in inputs.sources: - for head in source.heads: - if not head.same_pull_request(request) and not HeadReview.accepted(head.repo, head.head): - raise TodoError( - f"{head.repo.to_wire()}#{head.pr} at {head.head} has no accepted exact-head " - "source review; pending qualification cannot promote the carrier.") - - def landing(self, current: OpenPullRequestHead) -> MergeArm: - """Accepting the current head means landing it: arm it, pinned to that head.""" - return current.arm() + @classmethod + def grown(cls, reason: VerdictReason, growth: GrowthReason) -> ReviewedHeadVerdict: + return GrownHeadVerdict(reason, growth) def subject(self, goals: Goals, request: ReadPullRequestHead) -> str: project = goals.registry.snapshot().commander_project.require() @@ -4237,8 +4235,8 @@ def description(self, reviewer: str, subject: str) -> str: @dataclass(frozen=True) -class GrownHeadVerdict(ReviewedHeadVerdict): - """Acceptance of a head whose source growth the reviewer explains file by file.""" +class ExplainedGrowth: + """Source review and combined promotion share the same explicit growth contract.""" growth: GrowthReason @@ -4249,6 +4247,63 @@ def require_growth(self, growth: SourceGrowth) -> None: f"({growth.listing()}).") +@dataclass(frozen=True) +class GrownHeadVerdict(ExplainedGrowth, ReviewedHeadVerdict): + """Acceptance of a source whose growth the reviewer explains file by file.""" + + +@dataclass(frozen=True) +class PromotedHeadVerdict(ReviewedHeadVerdict, MergeHeadReview): + """Only the root's explicit review of a captured combined carrier requests merge.""" + + verb = "Promoted" + + @classmethod + def allows_merge(cls) -> bool: + return True + + @classmethod + def grown(cls, reason: VerdictReason, growth: GrowthReason) -> ReviewedHeadVerdict: + return GrownPromotedHeadVerdict(reason, growth) + + def carry(self, goals: Goals, record: HeadReviewRecord, current: ReadPullRequestHead, + reader: WorkBranches) -> bool: + """A combined promotion belongs to the reviewed exact head, never a later patch.""" + return False + + def prepare_sources(self, goals: Goals, request: ReadPullRequestHead) -> None: + project = goals.registry.snapshot().commander_project.require() + repository = Repo.selected(project.repos, request.repo.to_wire()) + captured = IntegrationInput.from_message(WorkBranches(repository.checkout).commit_message(request.head)) + if not captured: + raise TodoError("Only a captured combined carrier can be promoted; accept individual source with judge ok") + inputs, = captured + goals.todos.require_input_links(inputs) + inputs.validate(project.repos, request) + inputs.require_source_reviews() + + def reviewed(self, goals: Goals, request: PullRequestHead, prepared: PreparedHeadReview, + reviewer: ThreadIncarnation, order: HeadReviewOrder) -> HeadReviewed: + project = goals.registry.snapshot().commander_project.require() + if reviewer.commanding_identity != project.commander: + raise TodoError("Only this root's commander may promote the combined carrier") + return super().reviewed(goals, request, prepared, reviewer, order) + + def post(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> None: + # Review and permission to merge are different facts. Pending/rejected source + # review still invalidates an earlier promotion in the hosted required check. + ReviewedHeadVerdict(self.reason).post(repo, head, reviewer, subject) + HeadReview.post(self, repo, head, reviewer, subject) + + def landing(self, current: OpenPullRequestHead) -> MergeArm: + return current.arm() + + +@dataclass(frozen=True) +class GrownPromotedHeadVerdict(ExplainedGrowth, PromotedHeadVerdict): + """The same growth explanation, with explicit combined-carrier merge authority.""" + + @dataclass(frozen=True) class RejectedHeadVerdict(DecidedHeadVerdict): reason: StrikeReason diff --git a/tests/integration_source_fixture.py b/tests/integration_source_fixture.py index 436e58535b..b6390801b4 100644 --- a/tests/integration_source_fixture.py +++ b/tests/integration_source_fixture.py @@ -11,13 +11,13 @@ from agent_comms.commander.order import Commander, CommanderContext from agent_comms.comms import Comms from agent_comms.goal_actions import CreateProblemGoalAction -from agent_comms.head_review import IntegrationInput, PullRequestHead +from agent_comms.head_review import CombinedInput, IntegrationInput, IntegrationSource, PullRequestHead from agent_comms.installer.commands import InstallCommands -from agent_comms.integration import IntegrationCheckout +from agent_comms.integration import IntegrationCheckout, IntegrationRun from agent_comms.recorded_time import RecordedEventTime from agent_comms.repo import Repo from agent_comms.repository_name import RepositoryName -from agent_comms.todos import DraftListedPullRequest, RecordPullRequest +from agent_comms.todos import DraftListedPullRequest, PullRequests, RecordPullRequest from fixture_git import ENVIRONMENT from github_fixture import GitHubFixture from source_revision_fixture import SourceRepository @@ -31,16 +31,32 @@ class MutualSources: client: SourceRepository checkout: IntegrationCheckout output: Path + github: GitHubFixture + carrier_number: int = 101 def publish_carrier(self, github: GitHubFixture) -> PullRequestHead: - """Only tests emulate the root's explicit publication to the existing primary PR.""" + """Publish the separate combined PR; source-member refs and Todo links stay intact.""" commit = self.core.git(self.checkout.path, "rev-parse", "HEAD").strip() - self.core.git(self.checkout.path, "push", "origin", f"{commit}:refs/pull/{self.core.number}/head") + self.core.git(self.checkout.path, "push", "origin", f"{commit}:refs/pull/{self.carrier_number}/head") stamp = "2026-10-24T00:00:00Z" github.put(self.core.github, DraftListedPullRequest( - self.core.number, "feature", commit, stamp, "main", reviewable_times=(stamp,), + self.carrier_number, "combined", commit, stamp, "main", reviewable_times=(stamp,), )) - return PullRequestHead(self.core.github, self.core.number, commit) + return PullRequestHead(self.core.github, self.carrier_number, commit) + + def prepare_carrier(self, problems: tuple[str, ...]) -> PullRequestHead: + """Fixture setup owns an actual published combination before production capture.""" + todos = tuple(self.context.comms.goals.todos.get(problem) for problem in problems) + linked = PullRequests.linked_among(todos, self.core.github) + heads = tuple(PullRequestHead(self.core.github, row.number, row.head) + for row in PullRequests(todos).rows(self.core.github, tuple(sorted(linked))) + if row.as_unmerged()) + self.checkout.combine_sources(IntegrationSource.main_head(self.core), heads) + return self.publish_carrier(self.github) + + def capture(self, problems: tuple[str, ...], risk: str) -> CombinedInput: + return IntegrationRun(self.checkout, self.output, risk).qualify( + self.context, problems, carrier=self.prepare_carrier(problems)) @pytest.fixture @@ -92,7 +108,7 @@ def source_pair( source.number, "feature", source.branch_commit, stamp, "main", reviewable_times=(stamp,), )) checkout = IntegrationCheckout.create(core, tmp_path / "qualification", core.main_commit) - return MutualSources(context, core, client, checkout, tmp_path / "qualified-inputs") + return MutualSources(context, core, client, checkout, tmp_path / "qualified-inputs", github_cli) @pytest.fixture diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py index df9b992890..c5eb8c63cf 100644 --- a/tests/test_checker_stack.py +++ b/tests/test_checker_stack.py @@ -15,7 +15,6 @@ from agent_comms.installer.commands import InstallCommands from agent_comms.installer.refusals import UpgradeRefused from agent_comms.installer.runtime_resolution import CorePackage, GitSource, ResolvedLock -from agent_comms.integration import IntegrationRun from agent_comms.launch_runtime import LaunchRuntime from agent_comms.repository_name import RepositoryName from agent_comms.todos import DraftListedPullRequest @@ -62,7 +61,7 @@ def test_private_project_preserves_settings_pins_and_refuses_external_migration( stamp = "2026-10-24T00:00:00Z" github_cli.put(pair.core.github, DraftListedPullRequest( pair.core.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) - inputs = IntegrationRun(pair.checkout, pair.output, "full private checker project").qualify(pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), "full private checker project") with InstallCommands() as commands: checker = CheckerStack(checkout=tmp_path / "checker", commands=commands, home=tmp_path, runtime=LaunchRuntime.running(), source_directory=pair.output, @@ -116,8 +115,7 @@ def test_bootstrap_extra_is_runtime_only_when_the_virtual_root_requests_it( stamp = "2026-10-24T00:00:00Z" github_cli.put(pair.core.github, DraftListedPullRequest( pair.core.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) - inputs = IntegrationRun(pair.checkout, pair.output, "runtime root extras, not bootstrap extras").qualify( - pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), "runtime root extras, not bootstrap extras") with InstallCommands() as commands: checker = CheckerStack(checkout=tmp_path / "checker", commands=commands, home=tmp_path, runtime=LaunchRuntime.running(), source_directory=pair.output, inputs=inputs, uv_program=test_uv) @@ -176,8 +174,7 @@ def common_checker( source.git(source.checkout, "push", "origin", f"HEAD:refs/pull/{source.number}/head") github_cli.put(source.github, DraftListedPullRequest( source.number, "feature", source.branch_commit, stamp, "main", reviewable_times=(stamp,))) - inputs = IntegrationRun(pair.checkout, pair.output, "one complete runtime/checker solve").qualify( - pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), "one complete runtime/checker solve") checker = CheckerStack(checkout=tmp_path / "checker", commands=commands, home=tmp_path, runtime=LaunchRuntime.running(), source_directory=pair.output, inputs=inputs, uv_program=test_uv) checker.write_project() diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 83f2665929..e736ff529a 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -18,7 +18,7 @@ from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.build_identity import RepositoryCommit from agent_comms.head_review import ( - DraftMergeArm, HeadReview, IntegrationInput, IntegrationSource, PullRequestHead, ReadPullRequestHead, + CombinedInput, DraftMergeArm, HeadReview, IntegrationInput, IntegrationSource, OrdinaryInput, PullRequestHead, ReadPullRequestHead, ) from agent_comms.integration import IntegrationRun from agent_comms.heartbeat_subjects import CommanderHistory, HeadSubject @@ -39,7 +39,7 @@ from agent_comms.todos import ( CarriedHeadVerdict, ConflictMergeBlock, DraftListedPullRequest, FailedCheckMergeBlock, GrownHeadVerdict, GrowthReason, OrderHeadReview, LinkedHeadReview, NoHeadOrder, OrderClaim, ProgressRead, PullRequestLink, PullRequests, RecordPullRequest, RejectedHeadVerdict, - HeadVerdict, QualifiedHeadVerdict, ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, + HeadVerdict, PromotedHeadVerdict, QualifiedHeadVerdict, ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, StrikeReason, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, WithdrawTodo, ) from github_fixture import GitHubFixture @@ -105,6 +105,12 @@ def invoke(comms, request, verdict, order=NoHeadOrder()): return tool.invoke(comms, tool.arguments, caller=ThreadCaller("lead")) +def review_posts(github: GitHubFixture, head: str) -> tuple[tuple[str, ...], ...]: + """Count source decisions independently of their derived merge withdrawal.""" + return tuple(call for call in github.calls() if f"statuses/{head}" in call[1] + and f"context={HeadReview.context}" in call) + + def test_qualification_records_each_open_order_cut_without_promoting_or_finishing_work( linked_head_bus, claim_order): """A ready companion's source stage is a decision, not permission to ship its incomplete pair.""" @@ -136,7 +142,7 @@ def qualify_unchanged(): invoke(comms, request, UnderReviewHeadVerdict(), OrderHeadReview.for_request( (todo,), request, UnquotedHeadReview.of)) qualify_unchanged() # Reviewing: the recorded source cut changes no held work. - posts = tuple(call for call in github.calls() if f"statuses/{request.head}" in call[1]) + posts = review_posts(github, request.head) assert len(posts) == 4 assert all("state=pending" in call for call in posts) assert not any(call[:2] == ("pr", "merge") for call in github.calls()) @@ -227,7 +233,7 @@ def test_native_companion_adoption_is_the_same_reviewed_orders_actual_qualificat linked = comms.goals.todos.get(before.id) assert linked == replace(before, revision=before.revision + 1, order=before.order.checked().linked_to(pair.client.link)) - inputs = IntegrationRun(pair.checkout, pair.output, "native qualified companion discovery").qualify(pair.context, ("P2",)) + inputs = pair.capture(("P2",), "native qualified companion discovery") assert inputs.source(pair.client.github).heads == ( PullRequestHead(pair.client.github, pair.client.number, pair.client.branch_commit),) assert comms.goals.todos.get(before.id) == linked @@ -339,17 +345,19 @@ def test_qualification_withdraws_acceptance_without_claiming_to_disarm_the_reque comms, github, request = head_bus invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) arms = tuple(call for call in github.calls() if call[:2] == ("pr", "merge")) - assert len(arms) == 1 and HeadReview.accepted(request.repo, request.head) + assert arms == () and HeadReview.accepted(request.repo, request.head) + with pytest.raises(TodoError): + ReviewedHeadVerdict.require_merge(request) invoke(comms, request, QualifiedHeadVerdict(QUALIFIED)) assert StoredHeadReview.current_verdict(comms.goals.todos, request) is QualifiedHeadVerdict assert not HeadReview.accepted(request.repo, request.head) assert tuple(call for call in github.calls() if call[:2] == ("pr", "merge")) == arms - posts = tuple(call for call in github.calls() if f"statuses/{request.head}" in call[1]) + posts = review_posts(github, request.head) assert "state=pending" in posts[-1] - # Only a later full acceptance can permit promotion and request an arm again. + # Reaccepting this source permits combination, never an individual merge. invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) assert HeadReview.accepted(request.repo, request.head) - assert len(tuple(call for call in github.calls() if call[:2] == ("pr", "merge"))) == 2 + assert not any(call[:2] == ("pr", "merge") for call in github.calls()) def test_qualification_codec_and_head_only_cli_require_a_reason(): @@ -420,7 +428,7 @@ def test_qualification_does_not_carry_acceptance_to_an_unchanged_later_head(head @pytest.fixture def captured_input(reviewed_source: SourceRepository) -> IntegrationInput: source = reviewed_source - return IntegrationInput((IntegrationSource( + return OrdinaryInput((IntegrationSource( RepositoryCommit(source.github, source.main_commit), (PullRequestHead(source.github, source.number, source.branch_commit),), source.git(source.checkout, "rev-parse", f"{source.branch_commit}^{{tree}}").strip(), @@ -473,7 +481,7 @@ def test_integration_source_refuses_duplicates_abbreviations_and_foreign_members with pytest.raises(MalformedRecord): replace(source, heads=(replace(head, repo=RepositoryName.from_wire("other/repository")),)) with pytest.raises(MalformedRecord): - IntegrationInput((source, source)) + OrdinaryInput((source, source)) with pytest.raises(ToolRefusedError): captured_input.require_repositories((source.repository, RepositoryName.from_wire("other/repository"))) @@ -488,7 +496,7 @@ def test_companion_input_changes_the_identity_of_an_unchanged_local_patch( (PullRequestHead(companion.github, companion.number, companion.branch_commit),), companion.git(companion.checkout, "rev-parse", f"{companion.branch_commit}^{{tree}}").strip(), ) - inputs = IntegrationInput(tuple(sorted((*captured_input.sources, client), key=lambda row: row.repository.key))) + inputs = replace(captured_input, sources=tuple(sorted((*captured_input.sources, client), key=lambda row: row.repository.key))) companion.git(companion.checkout, "checkout", "feature") owner = companion.checkout / "owner.py" owner.write_text(owner.read_text() + "new_export = True\n") @@ -520,23 +528,32 @@ def test_pending_sources_can_compute_but_only_reviewed_other_heads_allow_carrier client = PullRequestHead(pair.client.github, pair.client.number, pair.client.branch_commit) invoke(comms, client, UnderReviewHeadVerdict()) run = IntegrationRun(pair.checkout, pair.output, "pending inputs compute without promotion") - inputs = run.qualify(pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), run.risk) carrier = pair.publish_carrier(github_cli) invoke(comms, carrier, UnderReviewHeadVerdict()) for verdict in (UnderReviewHeadVerdict(), QualifiedHeadVerdict(QUALIFIED)): invoke(comms, client, verdict) with pytest.raises(TodoError, match="no accepted exact-head source review"): - invoke(comms, carrier, ReviewedHeadVerdict(ACCEPTED)) + invoke(comms, carrier, PromotedHeadVerdict(ACCEPTED)) assert not HeadReview.accepted(carrier.repo, carrier.head) invoke(comms, client, ReviewedHeadVerdict(ACCEPTED)) assert HeadReview.accepted(client.repo, client.head) original, = inputs.source(pair.core.github).heads assert not HeadReview.accepted(original.repo, original.head) - invoke(comms, carrier, ReviewedHeadVerdict(ACCEPTED)) + with pytest.raises(TodoError, match="no accepted exact-head source review"): + invoke(comms, carrier, PromotedHeadVerdict(ACCEPTED)) + invoke(comms, original, ReviewedHeadVerdict(ACCEPTED)) + invoke(comms, carrier, PromotedHeadVerdict(ACCEPTED)) assert HeadReview.accepted(carrier.repo, carrier.head) - assert StoredHeadReview.current_verdict(comms.goals.todos, carrier) is ReviewedHeadVerdict + assert PromotedHeadVerdict.accepted(carrier.repo, carrier.head) + assert StoredHeadReview.current_verdict(comms.goals.todos, carrier) is PromotedHeadVerdict assert carrier.current().arm() == DraftMergeArm() assert run.accepted_heads() == () + invoke(comms, carrier, QualifiedHeadVerdict(QUALIFIED)) + assert not PromotedHeadVerdict.accepted(carrier.repo, carrier.head) + invoke(comms, carrier, ReviewedHeadVerdict(ACCEPTED)) + assert HeadReview.accepted(carrier.repo, carrier.head) + assert not PromotedHeadVerdict.accepted(carrier.repo, carrier.head) assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) @@ -544,7 +561,7 @@ def test_a_moved_companion_refuses_final_review_without_writing_acceptance( mutual_sources: MutualSources, github_cli: GitHubFixture) -> None: pair = mutual_sources run = IntegrationRun(pair.checkout, pair.output, "a changed actual source invalidates the input") - run.qualify(pair.context, ("P2", "P3")) + pair.capture(("P2", "P3"), run.risk) carrier = pair.publish_carrier(github_cli) pair.client.publish_change("Move the companion", *pair.client.write_files({ Path("src/new_file.py"): "changed = True\n", @@ -554,11 +571,47 @@ def test_a_moved_companion_refuses_final_review_without_writing_acceptance( pair.client.number, "feature", pair.client.branch_commit, stamp, "main", reviewable_times=(stamp,), )) with pytest.raises(TodoError, match="moved"): - invoke(pair.context.comms, carrier, ReviewedHeadVerdict(ACCEPTED)) + invoke(pair.context.comms, carrier, PromotedHeadVerdict(ACCEPTED)) assert not HeadReview.accepted(carrier.repo, carrier.head) assert not StoredHeadReview.head_reviews(pair.context.comms.goals.todos, carrier) +@pytest.mark.parametrize("damage", ("member", "tree", "carrier", "missing-source")) +def test_combined_promotion_refuses_changed_members_trees_carriers_and_missing_sources( + mutual_sources: MutualSources, github_cli: GitHubFixture, damage: str) -> None: + pair = mutual_sources + comms = pair.context.comms + inputs = pair.capture(("P2", "P3"), "every captured identity must survive promotion") + carrier = pair.publish_carrier(github_cli) + for source in inputs.sources: + for head in source.heads: + invoke(comms, head, ReviewedHeadVerdict(ACCEPTED)) + if damage == "member": + moved = pair.core.publish_change("Change a captured local source", *pair.core.write_files({ + Path("src/agent_comms/moved.py"): "moved = True\n"})) + opened(github_cli, pair.core, pair.core.number, moved) + elif damage == "tree": + path = pair.checkout.path / "not-reviewed.py" + path.write_text("unreviewed = True\n") + pair.core.git(pair.checkout.path, "add", "not-reviewed.py") + pair.core.git(pair.checkout.path, "commit", "-m", f"Change the combined tree\n\n{inputs.trailer}") + carrier = pair.publish_carrier(github_cli) + elif damage == "carrier": + # The same Git payload under another PR number is not its captured carrier. + pair.core.git(pair.checkout.path, "push", "origin", f"{carrier.head}:refs/pull/102/head") + carrier = opened(github_cli, pair.core, 102, carrier.head) + else: + # Keep valid canonical encoding while attempting to omit an actual declared client. + incomplete = replace(inputs, sources=(inputs.source(pair.core.github),)) + pair.core.git(pair.checkout.path, "commit", "--allow-empty", "-m", incomplete.trailer) + carrier = pair.publish_carrier(github_cli) + with pytest.raises(ToolRefusedError): + invoke(comms, carrier, PromotedHeadVerdict(ACCEPTED)) + assert not PromotedHeadVerdict.accepted(carrier.repo, carrier.head) + assert not StoredHeadReview.head_reviews(comms.goals.todos, carrier) + assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) + + def test_integration_reads_latest_review_in_the_declared_context(github_cli, tmp_path): source = SourceRepository.create(tmp_path / 'status-source') github_cli.configure(source.github) @@ -589,7 +642,7 @@ def test_orderless_head_family_persists_both_outcomes_and_posts_each_exact_head( current = ReadPullRequestHead(request.repo, request.pr, request.head, base="main") assert StoredHeadReview.head_reviews(comms.goals.todos, current) == StoredHeadReview.head_reviews(comms.goals.todos, request) assert tuple(todo.strike_count for todo in comms.goals.todos.list(request.repo)) == before - posts = tuple(call for call in github.calls() if f"statuses/{request.head}" in call[1]) + posts = review_posts(github, request.head) assert len(posts) == 2 assert "state=success" in posts[0] and "state=failure" in posts[1] @@ -610,43 +663,57 @@ def test_judge_ok_and_back_dispatch_orderless_prs_without_positional_edit_ids(he "--slop", "IMPL-5", "--fact", "the same decision is written twice", "--at", location, "--derive", "base-method", "--by", location], context) assert isinstance(StoredHeadReview.head_reviews(comms.goals.todos, request)[0].verdict, RejectedHeadVerdict) - assert len(tuple(call for call in github.calls() if f"statuses/{request.head}" in call[1])) == 2 + assert len(review_posts(github, request.head)) == 2 -def test_judge_ok_requests_merge_at_the_judged_head_unless_draft_and_fails_loudly( - head_bus, reviewed_source, capsys): - """2026-10-26: #3454 printed ok and stayed unarmed; #3444 needed a hand bypass-merge.""" +@pytest.mark.parametrize("ready,refused", ((False, False), (True, False), (True, True))) +def test_only_explicit_combined_promotion_arms_the_reviewed_head_and_reports_forge_failure( + mutual_sources: MutualSources, github_cli: GitHubFixture, capsys, + ready: bool, refused: bool) -> None: + pair = mutual_sources + comms = pair.context.comms + inputs = pair.capture(("P2", "P3"), "explicit combined promotion owns enqueue") + request = pair.publish_carrier(github_cli) + for source in inputs.sources: + for head in source.heads: + invoke(comms, head, ReviewedHeadVerdict(ACCEPTED)) + assert request.pr not in {head.pr for head in inputs.source(request.repo).heads} + assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) + if ready: + stamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + github_cli.put(request.repo, UnmergedListedPullRequest( + request.pr, "combined", request.head, stamp, "main", reviewable_times=(stamp,))) + if refused: + github_cli.refuse_arm(request.repo, request.pr, "Merge permission denied") + arguments = ["promote", "--repo", request.repo.to_wire(), "--pr", str(request.pr), + "--head", request.head, "--why", ACCEPTED.text] + context = order.Commander.read_root(comms.root) + if refused: + with pytest.raises(ToolRefusedError, match="Merge permission denied"): + judge.main(arguments, context) + else: + judge.main(arguments, context) + arms = [call for call in github_cli.calls() if call[:2] == ("pr", "merge")] + assert arms == ([("pr", "merge", str(request.pr), "--repo", request.repo.to_wire(), + "--auto", "--merge", "--match-head-commit", request.head)] if ready else []) + if not ready: + assert f"#{request.pr} is a draft, so not armed" in capsys.readouterr().out + assert StoredHeadReview.current_verdict(comms.goals.todos, request) is PromotedHeadVerdict + assert PromotedHeadVerdict.accepted(request.repo, request.head) + PromotedHeadVerdict.require_merge(request) + + +def test_source_acceptance_never_enqueues_and_promotion_refuses_an_individual_member(head_bus) -> None: comms, github, request = head_bus - for number in (8, 9): - reviewed_source.git(reviewed_source.checkout, "push", "-q", "origin", - f"{request.head}:refs/pull/{number}/head") context = order.Commander.read_root(comms.root) - stamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") - github.put(request.repo, DraftListedPullRequest( - 8, "fix/draft", request.head, stamp, "main", reviewable_times=(stamp,))) - github.put(request.repo, UnmergedListedPullRequest( - 9, "fix/refused", request.head, stamp, "main", reviewable_times=(stamp,))) - github.refuse_arm(request.repo, 9, "Merge permission denied") - - def ok(number): - judge.main(["ok", "--repo", request.repo.to_wire(), "--pr", str(number), - "--head", request.head, "--why", ACCEPTED.text], context) - - def arms(number): - return [call for call in github.calls() if call[:3] == ("pr", "merge", str(number))] - - ok(request.pr) - assert arms(request.pr) == [("pr", "merge", str(request.pr), "--repo", request.repo.to_wire(), - "--auto", "--merge", "--match-head-commit", request.head)] - assert f"#{request.pr} merge requested at {request.head}" in capsys.readouterr().out - ok(8) - assert not arms(8) - assert "#8 is a draft, so not armed" in capsys.readouterr().out - with pytest.raises(ToolRefusedError, match="Merge permission denied"): - ok(9) - assert "ok" not in capsys.readouterr().out - assert isinstance(StoredHeadReview.head_reviews( - comms.goals.todos, replace(request, pr=9))[0].verdict, ReviewedHeadVerdict) + arguments = ["--repo", request.repo.to_wire(), "--pr", str(request.pr), + "--head", request.head, "--why", ACCEPTED.text] + judge.main(["ok", *arguments], context) + assert HeadReview.accepted(request.repo, request.head) + with pytest.raises(ToolRefusedError, match="captured combined carrier"): + judge.main(["promote", *arguments], context) + assert not PromotedHeadVerdict.accepted(request.repo, request.head) + assert not any(call[:2] == ("pr", "merge") for call in github.calls()) def test_a_verdict_lists_every_argument_refusal_in_one_run_and_records_nothing(head_bus): @@ -680,7 +747,7 @@ def test_judge_reviewing_after_ok_withdraws_the_accepted_status_on_that_head( common = ["--pr", str(request.pr), "--head", request.head] judge.main(["ok", *common, "--why", ACCEPTED.text], context) judge.main(["reviewing", *common], context) - posts = tuple(call for call in github.calls() if f"statuses/{request.head}" in call[1]) + posts = review_posts(github, request.head) assert [next(part for part in post if part.startswith("state=")) for post in posts] == [ "state=success", "state=pending"] @@ -715,7 +782,7 @@ def test_review_ok_follows_an_unchanged_change(head_bus, reviewed_source): assert pulls.open(request.repo)[0].has_head_review assert StoredHeadReview.head_reviews(comms.goals.todos, moved)[0].verdict == CarriedHeadVerdict(ACCEPTED, request) assert not OrderlessUnreviewedHead.scan_reviews(comms.goals, "lead", pulls) - carried_posts = tuple(call for call in github.calls() if f"statuses/{merged}" in call[1]) + carried_posts = review_posts(github, merged) assert len(carried_posts) == 1 assert f"description=carried from {request.head[:9]}: same change" in carried_posts[0] @@ -868,7 +935,7 @@ def test_acceptance_over_the_growth_limit_needs_a_reason_naming_each_growing_fil assert not StoredHeadReview.head_reviews(comms.goals.todos, request) invoke(comms, request, GrownHeadVerdict(ACCEPTED, GrowthReason( "grown.py declares forty one values that the reviewed request needs as separate names"))) - posts = tuple(call for call in github.calls() if f"statuses/{head}" in call[1]) + posts = review_posts(github, head) assert len(posts) == 1 and any(f"(src +{grown})" in part for part in posts[0]) # The actual CLI must report both independent failures, then infer the unique @@ -930,7 +997,7 @@ def commit(parent, lines, message): github.put(source.github, UnmergedListedPullRequest( 9, "fix/child", head, stamp, "fix/parent", reviewable_times=(stamp,))) invoke(comms, PullRequestHead(source.github, 9, head), ReviewedHeadVerdict(ACCEPTED)) - posts = tuple(call for call in github.calls() if f"statuses/{head}" in call[1]) + posts = review_posts(github, head) assert len(posts) == 1 and any("(src +1)" in part for part in posts[0]) diff --git a/tests/test_integration.py b/tests/test_integration.py index 598feb17f0..7c81c4ec9c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -19,7 +19,7 @@ from agent_comms.repo import Repo from agent_comms.todos import ( DraftListedPullRequest, MergedListedPullRequest, PullRequestCommit, QualifiedHeadVerdict, - ReviewedHeadVerdict, UnmergedListedPullRequest, VerdictReason, + PromotedHeadVerdict, ReviewedHeadVerdict, UnmergedListedPullRequest, VerdictReason, ) from agent_comms.field_codec import FieldCodec from agent_comms.goal_actions import CreateProblemGoalAction @@ -47,7 +47,7 @@ def test_actual_ci_cli_uses_verified_carrier_not_synthetic_message_and_revalidat tmp_path: Path, event: str, ) -> None: pair = mutual_sources - inputs = integration.IntegrationRun(pair.checkout, pair.output, "CI source graph").qualify(pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), "CI source graph") carrier = pair.publish_carrier(github_cli) local = inputs.source(pair.core.github) # Actual synthetic merge: its own message deliberately has no source metadata. @@ -73,6 +73,9 @@ def test_actual_ci_cli_uses_verified_carrier_not_synthetic_message_and_revalidat env=environment, capture_output=True, text=True, timeout=120) assert result.returncode == 0, result.stdout + result.stderr assert IntegrationInput.at(output) == inputs + assert inputs.carrier.pr == carrier.pr != pair.core.number + assert local.heads == (PullRequestHead(pair.core.github, pair.core.number, pair.core.branch_commit),) + assert pair.core.git(pair.checkout.path, "rev-parse", "HEAD^").strip() == inputs.carrier.head for source in inputs.sources: for side in (BaseSide, HeadSide): side.require_materialized(output, source) @@ -121,6 +124,54 @@ def test_actual_ordinary_ci_cli_captures_main_once_and_never_steals_an_unlinked_ assert Package(base, resource.at(checkout.path, baseline, tested, BaseSide)).missing_names == () assert {name for _, name in Package(candidate, resource.at(checkout.path, baseline, tested, HeadSide)).missing_names} == {"old_api"} assert not any("state=success" in call or call[:2] == ("pr", "merge") for call in github_cli.calls()) + refused = subprocess.run((sys.executable, "-m", "agent_comms.integration", "--check-merge-inputs", + str(tmp_path / "refused-individual")), cwd=checkout.path, + env=environment, capture_output=True, text=True, timeout=120) + assert refused.returncode != 0 and "individual source" in refused.stderr + + +@pytest.mark.parametrize("event", ("pull_request", "merge_group")) +def test_required_hosted_merge_gate_distinguishes_source_review_from_combined_promotion( + mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, event: str, +) -> None: + pair = mutual_sources + inputs = pair.capture(("P2", "P3"), "hosted gate must refuse an individually armed source") + carrier = pair.publish_carrier(github_cli) + for source in inputs.sources: + for head in source.heads: + ReviewedHeadVerdict(VerdictReason("The exact source is accepted for this actual combined carrier")).post( + head.repo, head.head, "lead", "source") + # A source review on the combined head is still not a merge promotion. + ReviewedHeadVerdict(VerdictReason("The combined source is reviewed but not yet promoted for merge")).post( + carrier.repo, carrier.head, "lead", "combined source") + local = inputs.source(pair.core.github) + tested = pair.core.git(pair.core.checkout, "commit-tree", local.candidate_tree, + "-p", local.baseline.commit, "-p", carrier.head, + "-m", "Actual queue/PR merge").strip() + checkout = integration.IntegrationCheckout.create(pair.core, tmp_path / "merge-gate", tested) + documents = { + "pull_request": {"number": carrier.pr, "pull_request": {"head": {"sha": carrier.head}}}, + "merge_group": {"merge_group": {"head_sha": tested, "base_sha": local.baseline.commit, + "head_ref": f"refs/heads/gh-readonly-queue/main/pr-{carrier.pr}-{carrier.head}"}}, + } + document = tmp_path / "event.json" + document.write_text(json.dumps(documents[event])) + environment = {**os.environ, **ENVIRONMENT, "GITHUB_EVENT_NAME": event, + "GITHUB_EVENT_PATH": str(document), "GITHUB_SHA": tested, + "GITHUB_REPOSITORY": pair.core.github.to_wire(), "RUNNER_TEMP": str(tmp_path), + "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")} + command = (sys.executable, "-m", "agent_comms.integration", "--check-merge-inputs") + result = subprocess.run((*command, str(tmp_path / "before-promotion")), cwd=checkout.path, + env=environment, capture_output=True, text=True, timeout=120) + assert (result.returncode == 0) == (event == "pull_request"), result.stdout + result.stderr + if event == "merge_group": + assert "no explicit reviewed merge promotion" in result.stderr + PromotedHeadVerdict(VerdictReason("The actual combined carrier is explicitly reviewed for merge")).post( + carrier.repo, carrier.head, "lead", "promotion") + promoted = subprocess.run((*command, str(tmp_path / "after-promotion")), cwd=checkout.path, + env=environment, capture_output=True, text=True, timeout=120) + assert promoted.returncode == 0, promoted.stdout + promoted.stderr + assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) def test_actual_main_cli_keeps_merged_local_members_exact_and_refuses_later_head_changes( @@ -145,8 +196,7 @@ def test_actual_main_cli_keeps_merged_local_members_exact_and_refuses_later_head order=replace(primary.order.checked(), pull_requests=(peer.link,), check=replace(primary.order.check, directory=peer.checkout)), review_at=RecordedEventTime(9999999999.0))) - inputs = integration.IntegrationRun(pair.checkout, pair.output, "actual main tuple").qualify( - pair.context, ("P2", "P3", "P4")) + inputs = pair.capture(("P2", "P3", "P4"), "actual main tuple") carrier = pair.publish_carrier(github_cli) pair.core.git(pair.core.checkout, "switch", "main") pair.core.git(pair.core.checkout, "merge", "--no-ff", "--no-edit", carrier.head) @@ -210,7 +260,7 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( stamp = "2026-10-24T00:00:00Z" github_cli.put(pair.client.github, DraftListedPullRequest( pair.client.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) - inputs = integration.IntegrationRun(pair.checkout, pair.output, "real client checker tuple and native plugins").qualify(pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), "real client checker tuple and native plugins") source = inputs.source(pair.core.github) head = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() clients = materialized_clients(pair.output, inputs) @@ -313,7 +363,7 @@ def test_qualification_captures_actual_orders_without_reviewing_or_arming_source mutual_sources: MutualSources, github_cli: GitHubFixture) -> None: pair = mutual_sources run = integration.IntegrationRun(pair.checkout, pair.output, "mutually migrated API/import source") - inputs = run.qualify(pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), run.risk) assert FieldCodec.decode(IntegrationInput, FieldCodec.parse((pair.output / "input.json").read_bytes())) == inputs carrier = Repo.git(pair.checkout.path, "rev-parse", "HEAD").strip() assert IntegrationInput.from_message(Repo.git(pair.checkout.path, "show", "--format=%B", "--no-patch", carrier)) == (inputs,) @@ -324,6 +374,9 @@ def test_qualification_captures_actual_orders_without_reviewing_or_arming_source assert Repo.git(HeadSide.checkout(pair.output, source.repository), "rev-parse", "HEAD^{tree}").strip() == source.candidate_tree assert not HeadReview.accepted(source.repository, source.heads[0].head) assert run.accepted_heads() == () + assert inputs.carrier.pr != pair.core.number + assert pair.core.git(pair.checkout.path, "rev-parse", "HEAD^").strip() == inputs.carrier.head + assert all(not head.same_pull_request(inputs.carrier) for source in inputs.sources for head in source.heads) assert not any(call[:2] == ("pr", "merge") or "POST" in call for call in github_cli.calls()) @@ -332,8 +385,7 @@ def test_real_mutual_imports_work_for_both_matched_cuts_and_fail_for_both_mixed_ import sys pair = mutual_sources - integration.IntegrationRun(pair.checkout, pair.output, "both mixed imports must fail").qualify( - pair.context, ("P2", "P3")) + pair.capture(("P2", "P3"), "both mixed imports must fail") results = {} for core_side in (BaseSide, HeadSide): for client_side in (BaseSide, HeadSide): @@ -358,7 +410,7 @@ def test_changed_main_during_source_review_refuses_the_frozen_input( mutual_sources: MutualSources, github_cli: GitHubFixture) -> None: pair = mutual_sources run = integration.IntegrationRun(pair.checkout, pair.output, "stale baseline must refuse") - inputs = run.qualify(pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), run.risk) carrier = pair.publish_carrier(github_cli) pair.client.git(pair.client.checkout, "checkout", "main") pair.client.commit_change("Advance client main", *pair.client.write_files({Path("main-only.py"): "advanced = True\n"})) diff --git a/tests/test_module_layers_clients.py b/tests/test_module_layers_clients.py index b20fe6c855..90aa7f89ed 100644 --- a/tests/test_module_layers_clients.py +++ b/tests/test_module_layers_clients.py @@ -15,7 +15,6 @@ from module_layers import MaterializedClients, OutgoingWire, Package, StackClient from debt_ratchet import ClientTypeErrors from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side -from agent_comms.integration import IntegrationRun from agent_comms.todos import DraftListedPullRequest from github_fixture import GitHubFixture from integration_source_fixture import MutualSources @@ -55,7 +54,7 @@ def test_materialized_clients_check_matched_sources_and_both_mixed_sources_fail( materialized_clients: Callable[[Path, IntegrationInput], MaterializedClients], ) -> None: pair = mutual_sources - inputs = IntegrationRun(pair.checkout, pair.output, "materialized module clients").qualify(pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), "materialized module clients") core = inputs.source(pair.core.github) base = BaseSide.require_materialized(pair.output, core) head = HeadSide.require_materialized(pair.output, core) @@ -95,7 +94,7 @@ def test_actual_client_membership_stays_absent_on_its_own_cut( stamp = "2026-10-24T00:00:00Z" github_cli.put(pair.core.github, DraftListedPullRequest( pair.core.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) - inputs = IntegrationRun(pair.checkout, pair.output, "actual added and removed client").qualify(pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), "actual added and removed client") resource = materialized_clients(pair.output, inputs) core = inputs.source(pair.core.github) tested = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() @@ -117,7 +116,7 @@ def test_materialized_client_files_come_from_each_actual_cut( stamp = "2026-10-24T00:00:00Z" github_cli.put(pair.client.github, DraftListedPullRequest( pair.client.number, "feature", commit, stamp, "main", reviewable_times=(stamp,))) - inputs = IntegrationRun(pair.checkout, pair.output, "side-specific client files").qualify(pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), "side-specific client files") cuts: dict[type[Side], set[str]] = {} resource = materialized_clients(pair.output, inputs) for side in (BaseSide, HeadSide): @@ -150,7 +149,7 @@ def test_materialized_selection_includes_peer_imports_but_not_absent_paths( pair.core.number, "feature", core_head, stamp, "main", reviewable_times=(stamp,))) github_cli.put(pair.client.github, DraftListedPullRequest( pair.client.number, "feature", commit, stamp, "main", reviewable_times=(stamp,))) - inputs = IntegrationRun(pair.checkout, pair.output, "selected file union").qualify(pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), "selected file union") resource = materialized_clients(pair.output, inputs) core = inputs.source(pair.core.github) head = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() diff --git a/tests/test_notification_read_state.py b/tests/test_notification_read_state.py index 8e1bca0b2b..bb6b4a195a 100644 --- a/tests/test_notification_read_state.py +++ b/tests/test_notification_read_state.py @@ -171,10 +171,12 @@ def test_changed_notification_interest_keeps_only_retained_read_states(): @pytest.mark.parametrize("operation", [ LiveNotificationRead(REFERENCES), RecordedNotificationRead(REFERENCES, "archive"), ], ids=["live", "recorded"]) -def test_notification_operations_are_root_free_family_records(operation): +def test_notification_operations_are_root_free_family_records(operation: NotificationRead) -> None: + assert operation.references == REFERENCES encoded = FieldCodec.encode(operation) assert "root" not in encoded and "request_id" not in encoded - assert FieldCodec.decode(NotificationRead, encoded) == operation + decoded = FieldCodec.decode(NotificationRead, encoded) + assert decoded == operation and decoded.references == REFERENCES assert FieldCodec.decode(UiRead, encoded) == operation request = ReadNotifications("root", operation, request_id=4) assert FieldCodec.decode(ReadNotifications, FieldCodec.encode(request)) == request diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 24547ee449..034a8a0600 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -15,7 +15,6 @@ from agent_comms.installer.commands import InstallCommands from agent_comms.installer.refusals import UpgradeRefused from agent_comms.installer.runtime_resolution import ProjectMetadata, StackSources -from agent_comms.integration import IntegrationRun from agent_comms.repository_name import RepositoryName from github_fixture import GitHubFixture from integration_source_fixture import MutualSources @@ -113,7 +112,7 @@ def test_materialized_metadata_is_built_once_and_revalidated_when_borrowed( materialized_clients: Callable[[Path, IntegrationInput], MaterializedClients], ) -> None: pair = mutual_sources - inputs = IntegrationRun(pair.checkout, pair.output, "backend metadata lifetime").qualify(pair.context, ("P2", "P3")) + inputs = pair.capture(("P2", "P3"), "backend metadata lifetime") resource = materialized_clients(pair.output, inputs) metadata = resource.metadata built = len(resource.commands.clock.phases) From e7295c9a9a8964e3add16eaf774737e7c05e55df Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 00:05:49 -0400 Subject: [PATCH 066/109] Project independent replies without context-taking claim eligibility --- src/agent_comms/assignment_states.py | 23 +---------- src/agent_comms/notification_assignment.py | 41 ++++++------------- src/agent_comms/notification_evidence.py | 23 +++++++++-- src/agent_comms/notification_progress.py | 16 +++++--- src/agent_comms/presentation.py | 1 + tests/test_following_context_notifications.py | 3 +- tests/test_historical_notifications.py | 7 ++-- tests/test_notification_recipient_window.py | 34 ++++++++++++++- 8 files changed, 84 insertions(+), 64 deletions(-) diff --git a/src/agent_comms/assignment_states.py b/src/agent_comms/assignment_states.py index e824ee6eb8..e6184666f1 100644 --- a/src/agent_comms/assignment_states.py +++ b/src/agent_comms/assignment_states.py @@ -26,7 +26,7 @@ from .wake_policy import BoundedTriageWake, Engagement, FullWake, PassiveWake, WakePolicy if TYPE_CHECKING: - from collections.abc import Iterable, Sequence + from collections.abc import Iterable from typing import Self from .agent_activity import RecipientActivity @@ -141,13 +141,6 @@ def require_completion(self) -> Engagement: raise IdentityConflict("wake assignment is not completed") notification_state: ClassVar[type[ContextualProgress]] = ContextualProgress - records_conversation_response: ClassVar[bool] = False - - def recorded_response( - self, original: Delivered, replies: Sequence[Delivered], recipient_lookup: ThreadLookup - ) -> Delivered | None: - return None - def notification( self, recipient: FrozenRecipient, @@ -525,20 +518,6 @@ class DeliveredAssignment( """ decision: PendingDecision - records_conversation_response = True - - def recorded_response( - self, original: Delivered, replies: Sequence[Delivered], recipient_lookup: ThreadLookup - ) -> Delivered | None: - return next( - ( - reply.message - for reply in replies - if reply.sender_lookup == recipient_lookup - ), - None, - ) - def notification( self, recipient: FrozenRecipient, diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index 08de78d8a7..da5ccd9b60 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -8,6 +8,7 @@ from itertools import batched from dataclasses import dataclass, replace from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING, NoReturn from .audience_manifest import FrozenRecipient @@ -21,7 +22,6 @@ from .field_codec import FieldCodec, MalformedRecord, MessageId, ThreadLookup, boundary_decoder from .message_reference import MessageReference from .notification_progress import ( - AnsweredProgress, AssignmentProgress, QueuedProgress, SentProgress, @@ -126,12 +126,7 @@ def completed_notification(self, progress: AssignmentProgress, notification: Mes ) def answered(self, notification: MessageNotification) -> MessageNotification: - return replace( - notification, - state=AnsweredProgress(notification.state), - answer=self.reference, - answered_at_ms=self.received_at_ms, - ) + return notification.state.answered_notification(notification, self.reference, self.received_at_ms) @dataclass(frozen=True) @@ -266,32 +261,22 @@ def for_deliveries( def capture_responses(cls, root: Path, read: NotificationAssignmentRead) -> NotificationAssignmentRead: """Capture bounded independent replies after releasing the coordinator snapshot. - Retain them on the existing evidence so an open view notices a reply even - when neither the claim nor its context proof changed. + A reply belongs to its original/frozen author, not to a claim lifecycle or + model-context proof. The bus returns only the first reply per selected key, + in sequence order, including recipients with no claim at all. """ - eligible = tuple(assignment for assignment, _activity in read.rows - if assignment.lifecycle.records_conversation_response) - if not eligible: + keys = frozenset(read.selection.keys) + if not keys: return read - originals = {source.message.reference: source for source in read.sources} - captured = Bus(Bus.path_in(root)).recipient_replies(tuple( - (assignment.recipient_lookup, assignment.source) for assignment in eligible)) - replies: dict[MessageReference, list[Delivered]] = {} + captured = Bus(Bus.path_in(root)).recipient_replies(read.selection.keys) + responses: dict[tuple[ThreadLookup, MessageReference], AssignmentResponse] = {} for record in captured: for reply in record.deliveries(): for answered in reply.message.answers: - replies.setdefault(answered, []).append(reply) - resolved = [] - for assignment, activity in read.rows: - if assignment.lifecycle.records_conversation_response: - response = assignment.lifecycle.recorded_response( - originals[assignment.source], replies.get(assignment.source, ()), - assignment.recipient_lookup, - ) - if response is not None: - activity = replace(activity, response=activity.response.recorded(response)) - resolved.append((assignment, activity)) - return replace(read, rows=tuple(resolved)) + key = (reply.sender_lookup, answered) + if key in keys: + responses[key] = responses.get(key, UnansweredResponse()).recorded(reply.message) + return replace(read, responses=MappingProxyType(responses)) @classmethod def capture( diff --git a/src/agent_comms/notification_evidence.py b/src/agent_comms/notification_evidence.py index a1c8743531..342e829676 100644 --- a/src/agent_comms/notification_evidence.py +++ b/src/agent_comms/notification_evidence.py @@ -7,6 +7,7 @@ from dataclasses import dataclass, field from functools import cached_property from itertools import batched +from types import MappingProxyType from typing import TYPE_CHECKING, ClassVar if TYPE_CHECKING: @@ -17,7 +18,7 @@ from .coordination_tables.harness_deliveries import HarnessDelivery from .field_codec import ThreadLookup from .message_reference import MessageReference - from .notification_assignment import AssignmentActivityEvidence + from .notification_assignment import AssignmentActivityEvidence, AssignmentResponse from .notification_progress import DeliveryProgress from .presentation import MessageNotification, WireRevision, OutcomeViewer from .thread_identity import ThreadIncarnation @@ -25,11 +26,17 @@ @dataclass(frozen=True) class SourceEvidence: - """One original's captured claims and harness attempts, the attempts grouped once by - their frozen recipient. Equality over both is the window's change test.""" + """One original's claims, attempts and independent responses, grouped by frozen + recipient. Equality over all three is the window's change test.""" claims: tuple[tuple[WakeAssignment, AssignmentActivityEvidence], ...] = () attempts: Mapping[ThreadLookup, tuple[HarnessDelivery, ...]] = field(default_factory=dict) + responses: Mapping[ThreadLookup, AssignmentResponse] = field(default_factory=lambda: MappingProxyType({})) + + def answered(self, recipient: ThreadLookup, notification: MessageNotification) -> MessageNotification: + from .notification_assignment import UnansweredResponse + + return self.responses.get(recipient, UnansweredResponse()).answered(notification) def consumed_progress(self, recipient: ThreadLookup, progress: DeliveryProgress) -> DeliveryProgress: @@ -96,6 +103,8 @@ class NotificationAssignmentRead: selection: NotificationSelection rows: tuple[tuple[WakeAssignment, AssignmentActivityEvidence], ...] attempts: tuple[HarnessDelivery, ...] = () + responses: Mapping[tuple[ThreadLookup, MessageReference], AssignmentResponse] = field( + default_factory=lambda: MappingProxyType({})) @property def sources(self) -> tuple[Delivered, ...]: @@ -110,12 +119,18 @@ def evidence(self) -> Mapping[MessageReference, SourceEvidence]: attempts: dict[MessageReference, dict[ThreadLookup, list[HarnessDelivery]]] = { source.message.reference: {} for source in self.sources } + responses: dict[MessageReference, dict[ThreadLookup, AssignmentResponse]] = { + source.message.reference: {} for source in self.sources + } + for (lookup, source), response in self.responses.items(): + responses[source][lookup] = response for row in self.rows: claims.setdefault(row[0].source, []).append(row) for attempt in self.attempts: attempts[attempt.reference].setdefault(attempt.recipient_lookup, []).append(attempt) return {source: SourceEvidence(tuple(claims[source]), { - lookup: tuple(rows) for lookup, rows in attempts[source].items()}) + lookup: tuple(rows) for lookup, rows in attempts[source].items()}, + MappingProxyType(responses[source])) for source in claims} def differs_from(self, fresh: Mapping[MessageReference, SourceEvidence]) -> bool: diff --git a/src/agent_comms/notification_progress.py b/src/agent_comms/notification_progress.py index b2d4efdda6..7e89670745 100644 --- a/src/agent_comms/notification_progress.py +++ b/src/agent_comms/notification_progress.py @@ -72,6 +72,12 @@ def counted_summary_prefix(self, notification: MessageNotification) -> tuple[str """The lead a counted wrapper prints; a consumed proof is already named by its count.""" return (notification.label,) + def answered_notification(self, notification: MessageNotification, + reference: MessageReference, answered_at_ms: int) -> MessageNotification: + """An independent answer preserves this state's actual context proof.""" + return replace(notification, state=AnsweredProgress(self), + answer=reference, answered_at_ms=answered_at_ms, changed=True) + def harness_consumed(self, observed_at_ms: int) -> DeliveryProgress: """Retain the original complete-body transcript observation time.""" return HarnessConsumedProgress(observed_at_ms) @@ -181,6 +187,11 @@ class FailedProgress(ContextualProgress): class AnsweredProgress(SettledDelivery, ContextualProgress): label = "Answered" + def answered_notification(self, notification: MessageNotification, + reference: MessageReference, answered_at_ms: int) -> MessageNotification: + """A later independent response cannot replace an already recorded answer.""" + return notification + def notification_label(self, notification: MessageNotification) -> str: return notification.timed_state(self.label, notification.answered_at_ms) @@ -205,11 +216,6 @@ def selected_notification(self, notification: MessageNotification, *, busy: bool @abstractmethod def consumed_notification(self, notification: MessageNotification) -> MessageNotification: ... - def answered_notification(self, notification: MessageNotification, - reference: MessageReference, answered_at_ms: int) -> MessageNotification: - return replace(notification, state=AnsweredProgress(self), - answer=reference, answered_at_ms=answered_at_ms) - @dataclass(frozen=True) class SentProgress(WaitingDelivery, AssignmentProgress): diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 181688bb61..913674a9d9 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -374,6 +374,7 @@ def project_delivery_window( recipient.recipient_lookup, notification.state) notification = replace(notification, state=state, changed=notification.changed or state.consumed) + notification = evidence[source.message.reference].answered(recipient.recipient_lookup, notification) displayed_to = observation.displayed_recipient( notification, source, reads, document, snapshot ) diff --git a/tests/test_following_context_notifications.py b/tests/test_following_context_notifications.py index 73eab488f6..157a1844ea 100644 --- a/tests/test_following_context_notifications.py +++ b/tests/test_following_context_notifications.py @@ -258,7 +258,8 @@ async def test_cancelled_input_or_released_lease_cannot_acquire_context(tmp_path live = following_input(claim, generation=3) live.start() proof = await live.verify() - claim.selected.comms.owners.release(claim.owner.thread.name) + lease = live.lease + assert claim.selected.comms.registry.release_turn(lease)[0] with pytest.raises(RelationViolationError): live.acknowledge(proof) assert all(not row.model_contexts() for row in live.dispositions.read(live.keys).rows.values()) diff --git a/tests/test_historical_notifications.py b/tests/test_historical_notifications.py index fa106410ef..2d32df90e1 100644 --- a/tests/test_historical_notifications.py +++ b/tests/test_historical_notifications.py @@ -28,7 +28,8 @@ from agent_comms.coordinator import Coordination from agent_comms.harness_delivery_states import DeliveredOutcome, ObservedMonitor from agent_comms.coordination_errors import DMScopeChanged, SchemaVersionError -from agent_comms.field_codec import InputId, MessageId, TranscriptRecordId, TurnId +from agent_comms.field_codec import InputId, MessageId, TurnId +from transcript_record_fixture import transcript_record from agent_comms.read_basis import HistoricalDisplay, ReadCursor from agent_comms.read_ledger import ReadLedger from agent_comms.thread_identity import AgentThreadRole, UserThreadRole @@ -108,7 +109,7 @@ def test_harness_consumption_survives_human_display(archived_notification): store.session.writing() as db: attempt = HarnessDelivery(ClaudeBridge, recipient.recipient_lookup, shown.message_id, shown.seq, DeliveredOutcome, ObservedMonitor( - TranscriptRecordId('complete-original'), shown.timestamp)) + transcript_record('complete-original'), shown.timestamp)) assert attempt.begin(store.assignments, db) attempt.record(store.assignments, db) @@ -480,7 +481,7 @@ def test_live_cli_ack_does_not_become_archived_ack(archived_notification): for message in unread: attempt = HarnessDelivery(ClaudeBridge, lookup, message.message_id, message.seq, DeliveredOutcome, ObservedMonitor( - TranscriptRecordId('complete-original'), message.timestamp)) + transcript_record('complete-original'), message.timestamp)) assert attempt.begin(store.assignments, db) attempt.record(store.assignments, db) current, = old.views.message_notifications_for_references(AllOutcomes(), (messages[0].reference,)).outcomes[ diff --git a/tests/test_notification_recipient_window.py b/tests/test_notification_recipient_window.py index 43fca1ce17..0a32501874 100644 --- a/tests/test_notification_recipient_window.py +++ b/tests/test_notification_recipient_window.py @@ -13,7 +13,7 @@ from agent_comms.coordination_tables.harness_deliveries import HarnessDelivery from agent_comms.coordinator import Coordination from agent_comms.harness_delivery_states import PersistedOutcome -from agent_comms.notification_assignment import NotificationAssignment +from agent_comms.notification_assignment import AnsweredResponse, NotificationAssignment from agent_comms.notification_evidence import RecipientSelection from agent_comms.presentation import AllOutcomes, AfterRecipient, MessageNotification, OwnerOutcomes from agent_comms.read_ledger import ReadLedger @@ -135,6 +135,7 @@ def test_independent_reply_changes_retained_evidence_without_claiming_context( recipient = selection.pages[0].recipients[0] record_attempt(comms, source, 0) before = NotificationAssignment.read(comms.root, selection) + reads = ReadLedger.at(comms.root).read() response = comms.messaging.send(recipient.canonical_thread, source.message.sender, "An independent answer", answers=(source.message.reference,)).message assert NotificationAssignment.changed((before,), comms.root) == (before,) @@ -143,3 +144,34 @@ def test_independent_reply_changes_retained_evidence_without_claiming_context( row, = window.outcomes[source.message.seq, source.message.message_id] assert row.answer == response.reference assert not row.state.consumed + assert after.rows == before.rows and after.attempts == before.attempts + assert ReadLedger.at(comms.root).read() == reads + later = comms.messaging.send(recipient.canonical_thread, source.message.sender, + "A later independent answer", answers=(source.message.reference,)).message + assert NotificationAssignment.changed((after,), comms.root) == () + assert AnsweredResponse(later.reference, int(later.timestamp * 1000)).answered(row) is row + + +def test_independent_reply_without_any_claim_is_retained_without_context_or_read_mutation( + frozen_window: tuple[Comms, Delivered], +) -> None: + comms, _accepted_source = frozen_window + message = publish_initial(comms, "sender", "#team", "An original without a handling claim") + source = comms.bus.bus.delivery(message.seq) + selection = AllOutcomes().selection((source,), limit=1) + recipient = selection.pages[0].recipients[0] + reads = ReadLedger.at(comms.root).read() + before = NotificationAssignment.read(comms.root, selection) + assert before.rows == before.attempts == () + response = comms.messaging.send(recipient.canonical_thread, source.message.sender, + "An answer without an input claim", answers=(message.reference,)).message + assert NotificationAssignment.changed((before,), comms.root) == (before,) + after = NotificationAssignment.read(comms.root, selection) + assert after.rows == after.attempts == () + assert after.responses[recipient.recipient_lookup, message.reference] == AnsweredResponse( + response.reference, int(response.timestamp * 1000)) + window = MessageNotification.delivery_window(comms.root, comms.registry, after) + row, = window.outcomes[message.seq, message.message_id] + assert row.answer == response.reference and row.changed + assert not row.state.consumed and not row.displayed_to + assert ReadLedger.at(comms.root).read() == reads From fe0ca3c2fac5a2652ef0306c29b0b78daa223bd1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 00:19:20 -0400 Subject: [PATCH 067/109] Keep explicit merge permission out of persisted source-review records --- src/agent_comms/commander/judge.py | 22 +++++++--- src/agent_comms/head_review.py | 43 +++++++++++++++++++ src/agent_comms/integration.py | 8 ++-- src/agent_comms/order_tools.py | 3 -- src/agent_comms/todos.py | 68 ++---------------------------- tests/test_head_review.py | 45 +++++++++++--------- tests/test_integration.py | 9 ++-- 7 files changed, 95 insertions(+), 103 deletions(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 79a0689432..b5fb776f30 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -32,7 +32,7 @@ import time from typing import Annotated, ClassVar from abc import abstractmethod -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, replace from functools import cached_property from pathlib import Path @@ -43,7 +43,7 @@ Argument, BoundedText, FieldCodec, MalformedRecord, MessageId, RecordProblems, ) from agent_comms.declared_family import DeclaredFamily -from agent_comms.head_review import PullRequestHead +from agent_comms.head_review import HeadReviewOrder, PromotedHeadReview, PullRequestHead from agent_comms.recorded_time import EventTime from agent_comms.errors import TodoError from agent_comms.todos import ( @@ -56,7 +56,7 @@ RewrittenVerdict, GrowthReason, ReviewedHeadVerdict, - PromotedHeadVerdict, + HeadReviewed, QualifiedHeadVerdict, RejectedHeadVerdict, HeadVerdict, @@ -312,8 +312,7 @@ def run(self, options: HeadVerdictVerb, ctx: order.Commander) -> None: options.check_edits([]) linked = OrderHeadReview.for_request(ctx.todos(), self.request, options.order_link, options.problem) - reviewed = ctx.call(CommsHeadVerdictTool(request=self.request, - operation=options.head_operation(), order=linked)) + reviewed = options.review_head(ctx, self.request, linked) print(f'PR #{self.request.pr} head {self.request.head}: {options.declared_name}') for line in reviewed.merge.lines(self.request): print(line) @@ -473,6 +472,11 @@ def act(self, ctx: order.Commander) -> None: def head_evidence(self) -> tuple[FileLine, ...]: return () + def review_head(self, ctx: order.CommanderContext, request: PullRequestHead, + linked: HeadReviewOrder) -> HeadReviewed: + return ctx.call(CommsHeadVerdictTool(request=request, + operation=self.head_operation(), order=linked)) + @abstractmethod def head_operation(self) -> HeadVerdict: ... @@ -565,7 +569,13 @@ def check_edits(self, edits: list[Edit]) -> None: @dataclass(frozen=True, kw_only=True) class PromoteVerb(OkVerb, HeadOnlyVerb): help = "explicitly review and enqueue this captured combined carrier, never an individual source" - verdict = PromotedHeadVerdict + + def review_head(self, ctx: order.CommanderContext, request: PullRequestHead, + linked: HeadReviewOrder) -> HeadReviewed: + permission = PromotedHeadReview() + permission.require_sources(ctx.comms.goals, request, ctx.actor_identity) + reviewed = super().review_head(ctx, request, linked) + return replace(reviewed, merge=permission.enqueue(reviewed.record)) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index d3ed9660b0..4a411b1f61 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -603,6 +603,49 @@ class MergeHeadReview(HeadReview, declared_name=None): context = "agent-comms/merge" + @classmethod + def require_merge(cls, request: PullRequestHead) -> None: + if not (HeadReview.accepted(request.repo, request.head) + and cls.accepted(request.repo, request.head)): + raise ToolRefusedError("The exact combined carrier has no explicit reviewed merge promotion") + + +@dataclass(frozen=True) +class PromotedHeadReview(MergeHeadReview): + """A forge permission, never a persisted source-review verdict.""" + + state = "success" + + @classmethod + def allows_integration(cls) -> bool: + return True + + def description(self, reviewer: str, subject: str) -> str: + return f"Explicitly promoted by {reviewer} on {subject}" + + def require_sources(self, goals: Goals, request: PullRequestHead, + reviewer: ThreadIncarnation) -> None: + project = goals.registry.snapshot().commander_project.require() + if reviewer.commanding_identity != project.commander: + raise ToolRefusedError("Only this root's commander may promote the combined carrier") + repository = Repo.selected(project.repos, request.repo.to_wire()) + captured = IntegrationInput.from_message(WorkBranches(repository.checkout).commit_message(request.head)) + if not captured: + raise ToolRefusedError( + "Only a captured combined carrier can be promoted; accept individual source with judge ok") + inputs, = captured + goals.todos.require_input_links(inputs) + inputs.validate(project.repos, request) + inputs.require_source_reviews() + + def enqueue(self, record: HeadReviewRecord) -> MergeArm: + request = record.request + if not HeadReview.accepted(request.repo, request.head): + raise ToolRefusedError("The exact combined source review must succeed before promotion") + current = request.current() + self.post(request.repo, request.head, record.actor.name, f"#{request.pr}") + return current.arm() + @dataclass(frozen=True) class UnpromotedHeadReview(MergeHeadReview): diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index 176476ec9b..2a511d4701 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -20,14 +20,14 @@ from .declared_family import DeclaredFamily from .errors import ToolRefusedError from .field_codec import FieldCodec, GitCommitText, PathText, WireName, boundary_decoder -from .head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, IntegrationSource, OrdinaryInput, PullRequestHead, Side +from .head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, IntegrationSource, MergeHeadReview, OrdinaryInput, PullRequestHead, Side from .installer.runtime_resolution import StackSources from .launch_runtime import CheckoutSource, RestoredRuntime from .repo import Checkout, DetachedStart, GitCommandError, Repo from .systemd_unit import AgentsSlice from .repository_name import RepositoryName from .test_lease import LeaseStart, TestLease -from .todos import PromotedHeadVerdict, PullRequests +from .todos import PullRequests from .work_branches import WorkBranches class IntegrationFailed(ToolRefusedError): @@ -458,9 +458,7 @@ def resolve(cls, repository: Repo, tested: str, document: object, *, class MergeGroupSources(TestedSources, declared_name="merge_group"): def require_merge_review(self) -> None: - if not (HeadReview.accepted(self.carrier.repo, self.carrier.head) - and PromotedHeadVerdict.accepted(self.carrier.repo, self.carrier.head)): - raise IntegrationFailed("The exact combined carrier has no explicit reviewed merge promotion") + MergeHeadReview.require_merge(self.carrier) @classmethod @boundary_decoder diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index 8506ae2b87..894acf23b5 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -214,9 +214,6 @@ def apply(self, comms: Comms, caller: ThreadCaller) -> HeadReviewed: heads.append(self.request.current()) preparations = [] for current in heads: - with RecordProblems.collecting(problems, '--head', - 'recapture integration inputs from their actual linked sources'): - self.operation.prepare_sources(comms.goals, current) with RecordProblems.collecting(problems, self.operation.preparation_field, self.operation.preparation_fix): preparations.append(PreparedHeadReview(current, self.operation.subject(comms.goals, current))) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 169d5cce2a..15df9ce786 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -4086,16 +4086,10 @@ class HeadVerdict(HeadReview, declared_name=None): @classmethod def require_merge(cls, request: PullRequestHead) -> None: """Source review cannot authorize even the owner's direct merge path.""" - if not cls.allows_merge(): + if not cls.allows_integration(): raise TodoError( f"{request.repo.to_wire()}#{request.pr} at {request.head} has no recorded ok verdict.") - - @classmethod - def allows_merge(cls) -> bool: - return False - - def prepare_sources(self, goals: Goals, request: ReadPullRequestHead) -> None: - """Marking review pending or rejecting it grants no source promotion permission.""" + MergeHeadReview.require_merge(request) def post(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> None: # Withdraw first: a failed source-status write cannot leave an earlier promotion live. @@ -4111,7 +4105,7 @@ def reviewed(self, goals: Goals, request: PullRequestHead, prepared: PreparedHea return HeadReviewed(recorded, self.landing(prepared.current)) def landing(self, current: OpenPullRequestHead) -> MergeArm: - """Source verdicts never request merge; combined promotion overrides this behavior.""" + """Source verdicts never request merge; native explicit promotion owns enqueueing.""" return UntouchedMergeArm() def subject(self, goals: Goals, request: ReadPullRequestHead) -> str: @@ -4192,10 +4186,6 @@ def carry(self, goals: Goals, record: HeadReviewRecord, current: ReadPullRequest return False if reviewed_change != current_change: return False - try: - self.prepare_sources(goals, current) - except (GitCommandError, WorkBranchError, subprocess.TimeoutExpired, ToolRefusedError, MalformedRecord): - return False verdict = CarriedHeadVerdict(self.reason, record.request) carried = replace(record, request=replace(record.request, head=current.head), verdict=verdict, at=RecordedEventTime(time.time())) @@ -4252,58 +4242,6 @@ class GrownHeadVerdict(ExplainedGrowth, ReviewedHeadVerdict): """Acceptance of a source whose growth the reviewer explains file by file.""" -@dataclass(frozen=True) -class PromotedHeadVerdict(ReviewedHeadVerdict, MergeHeadReview): - """Only the root's explicit review of a captured combined carrier requests merge.""" - - verb = "Promoted" - - @classmethod - def allows_merge(cls) -> bool: - return True - - @classmethod - def grown(cls, reason: VerdictReason, growth: GrowthReason) -> ReviewedHeadVerdict: - return GrownPromotedHeadVerdict(reason, growth) - - def carry(self, goals: Goals, record: HeadReviewRecord, current: ReadPullRequestHead, - reader: WorkBranches) -> bool: - """A combined promotion belongs to the reviewed exact head, never a later patch.""" - return False - - def prepare_sources(self, goals: Goals, request: ReadPullRequestHead) -> None: - project = goals.registry.snapshot().commander_project.require() - repository = Repo.selected(project.repos, request.repo.to_wire()) - captured = IntegrationInput.from_message(WorkBranches(repository.checkout).commit_message(request.head)) - if not captured: - raise TodoError("Only a captured combined carrier can be promoted; accept individual source with judge ok") - inputs, = captured - goals.todos.require_input_links(inputs) - inputs.validate(project.repos, request) - inputs.require_source_reviews() - - def reviewed(self, goals: Goals, request: PullRequestHead, prepared: PreparedHeadReview, - reviewer: ThreadIncarnation, order: HeadReviewOrder) -> HeadReviewed: - project = goals.registry.snapshot().commander_project.require() - if reviewer.commanding_identity != project.commander: - raise TodoError("Only this root's commander may promote the combined carrier") - return super().reviewed(goals, request, prepared, reviewer, order) - - def post(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> None: - # Review and permission to merge are different facts. Pending/rejected source - # review still invalidates an earlier promotion in the hosted required check. - ReviewedHeadVerdict(self.reason).post(repo, head, reviewer, subject) - HeadReview.post(self, repo, head, reviewer, subject) - - def landing(self, current: OpenPullRequestHead) -> MergeArm: - return current.arm() - - -@dataclass(frozen=True) -class GrownPromotedHeadVerdict(ExplainedGrowth, PromotedHeadVerdict): - """The same growth explanation, with explicit combined-carrier merge authority.""" - - @dataclass(frozen=True) class RejectedHeadVerdict(DecidedHeadVerdict): reason: StrikeReason diff --git a/tests/test_head_review.py b/tests/test_head_review.py index e736ff529a..a62a269eb1 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -18,7 +18,7 @@ from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.build_identity import RepositoryCommit from agent_comms.head_review import ( - CombinedInput, DraftMergeArm, HeadReview, IntegrationInput, IntegrationSource, OrdinaryInput, PullRequestHead, ReadPullRequestHead, + CombinedInput, DraftMergeArm, HeadReview, IntegrationInput, IntegrationSource, OrdinaryInput, PromotedHeadReview, PullRequestHead, ReadPullRequestHead, ) from agent_comms.integration import IntegrationRun from agent_comms.heartbeat_subjects import CommanderHistory, HeadSubject @@ -39,7 +39,7 @@ from agent_comms.todos import ( CarriedHeadVerdict, ConflictMergeBlock, DraftListedPullRequest, FailedCheckMergeBlock, GrownHeadVerdict, GrowthReason, OrderHeadReview, LinkedHeadReview, NoHeadOrder, OrderClaim, ProgressRead, PullRequestLink, PullRequests, RecordPullRequest, RejectedHeadVerdict, - HeadVerdict, PromotedHeadVerdict, QualifiedHeadVerdict, ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, + HeadVerdict, HeadReviewed, QualifiedHeadVerdict, ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, StrikeReason, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, WithdrawTodo, ) from github_fixture import GitHubFixture @@ -105,6 +105,11 @@ def invoke(comms, request, verdict, order=NoHeadOrder()): return tool.invoke(comms, tool.arguments, caller=ThreadCaller("lead")) +def promote(context: order.CommanderContext, request: PullRequestHead) -> HeadReviewed: + return judge.PromoteVerb(pr=request.pr, head=request.head, repo=request.repo.to_wire(), + why=ACCEPTED.text).review_head(context, request, NoHeadOrder()) + + def review_posts(github: GitHubFixture, head: str) -> tuple[tuple[str, ...], ...]: """Count source decisions independently of their derived merge withdrawal.""" return tuple(call for call in github.calls() if f"statuses/{head}" in call[1] @@ -346,7 +351,7 @@ def test_qualification_withdraws_acceptance_without_claiming_to_disarm_the_reque invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) arms = tuple(call for call in github.calls() if call[:2] == ("pr", "merge")) assert arms == () and HeadReview.accepted(request.repo, request.head) - with pytest.raises(TodoError): + with pytest.raises(ToolRefusedError, match="no explicit reviewed merge promotion"): ReviewedHeadVerdict.require_merge(request) invoke(comms, request, QualifiedHeadVerdict(QUALIFIED)) assert StoredHeadReview.current_verdict(comms.goals.todos, request) is QualifiedHeadVerdict @@ -533,27 +538,27 @@ def test_pending_sources_can_compute_but_only_reviewed_other_heads_allow_carrier invoke(comms, carrier, UnderReviewHeadVerdict()) for verdict in (UnderReviewHeadVerdict(), QualifiedHeadVerdict(QUALIFIED)): invoke(comms, client, verdict) - with pytest.raises(TodoError, match="no accepted exact-head source review"): - invoke(comms, carrier, PromotedHeadVerdict(ACCEPTED)) + with pytest.raises(ToolRefusedError, match="no accepted exact-head source review"): + promote(pair.context, carrier) assert not HeadReview.accepted(carrier.repo, carrier.head) invoke(comms, client, ReviewedHeadVerdict(ACCEPTED)) assert HeadReview.accepted(client.repo, client.head) original, = inputs.source(pair.core.github).heads assert not HeadReview.accepted(original.repo, original.head) - with pytest.raises(TodoError, match="no accepted exact-head source review"): - invoke(comms, carrier, PromotedHeadVerdict(ACCEPTED)) + with pytest.raises(ToolRefusedError, match="no accepted exact-head source review"): + promote(pair.context, carrier) invoke(comms, original, ReviewedHeadVerdict(ACCEPTED)) - invoke(comms, carrier, PromotedHeadVerdict(ACCEPTED)) + promote(pair.context, carrier) assert HeadReview.accepted(carrier.repo, carrier.head) - assert PromotedHeadVerdict.accepted(carrier.repo, carrier.head) - assert StoredHeadReview.current_verdict(comms.goals.todos, carrier) is PromotedHeadVerdict + assert PromotedHeadReview.accepted(carrier.repo, carrier.head) + assert StoredHeadReview.current_verdict(comms.goals.todos, carrier) is ReviewedHeadVerdict assert carrier.current().arm() == DraftMergeArm() assert run.accepted_heads() == () invoke(comms, carrier, QualifiedHeadVerdict(QUALIFIED)) - assert not PromotedHeadVerdict.accepted(carrier.repo, carrier.head) + assert not PromotedHeadReview.accepted(carrier.repo, carrier.head) invoke(comms, carrier, ReviewedHeadVerdict(ACCEPTED)) assert HeadReview.accepted(carrier.repo, carrier.head) - assert not PromotedHeadVerdict.accepted(carrier.repo, carrier.head) + assert not PromotedHeadReview.accepted(carrier.repo, carrier.head) assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) @@ -570,8 +575,8 @@ def test_a_moved_companion_refuses_final_review_without_writing_acceptance( github_cli.put(pair.client.github, DraftListedPullRequest( pair.client.number, "feature", pair.client.branch_commit, stamp, "main", reviewable_times=(stamp,), )) - with pytest.raises(TodoError, match="moved"): - invoke(pair.context.comms, carrier, PromotedHeadVerdict(ACCEPTED)) + with pytest.raises(ToolRefusedError, match="moved"): + promote(pair.context, carrier) assert not HeadReview.accepted(carrier.repo, carrier.head) assert not StoredHeadReview.head_reviews(pair.context.comms.goals.todos, carrier) @@ -606,8 +611,8 @@ def test_combined_promotion_refuses_changed_members_trees_carriers_and_missing_s pair.core.git(pair.checkout.path, "commit", "--allow-empty", "-m", incomplete.trailer) carrier = pair.publish_carrier(github_cli) with pytest.raises(ToolRefusedError): - invoke(comms, carrier, PromotedHeadVerdict(ACCEPTED)) - assert not PromotedHeadVerdict.accepted(carrier.repo, carrier.head) + promote(pair.context, carrier) + assert not PromotedHeadReview.accepted(carrier.repo, carrier.head) assert not StoredHeadReview.head_reviews(comms.goals.todos, carrier) assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) @@ -698,9 +703,9 @@ def test_only_explicit_combined_promotion_arms_the_reviewed_head_and_reports_for "--auto", "--merge", "--match-head-commit", request.head)] if ready else []) if not ready: assert f"#{request.pr} is a draft, so not armed" in capsys.readouterr().out - assert StoredHeadReview.current_verdict(comms.goals.todos, request) is PromotedHeadVerdict - assert PromotedHeadVerdict.accepted(request.repo, request.head) - PromotedHeadVerdict.require_merge(request) + assert StoredHeadReview.current_verdict(comms.goals.todos, request) is ReviewedHeadVerdict + assert PromotedHeadReview.accepted(request.repo, request.head) + ReviewedHeadVerdict.require_merge(request) def test_source_acceptance_never_enqueues_and_promotion_refuses_an_individual_member(head_bus) -> None: @@ -712,7 +717,7 @@ def test_source_acceptance_never_enqueues_and_promotion_refuses_an_individual_me assert HeadReview.accepted(request.repo, request.head) with pytest.raises(ToolRefusedError, match="captured combined carrier"): judge.main(["promote", *arguments], context) - assert not PromotedHeadVerdict.accepted(request.repo, request.head) + assert not PromotedHeadReview.accepted(request.repo, request.head) assert not any(call[:2] == ("pr", "merge") for call in github.calls()) diff --git a/tests/test_integration.py b/tests/test_integration.py index 7c81c4ec9c..0b722dea5d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -14,12 +14,12 @@ from agent_comms import integration from agent_comms.errors import ToolRefusedError -from agent_comms.head_review import BaseSide, HeadReview, HeadSide, IntegrationInput, PullRequestHead +from agent_comms.head_review import BaseSide, HeadReview, HeadSide, IntegrationInput, PromotedHeadReview, PullRequestHead from agent_comms.repository_name import RepositoryName from agent_comms.repo import Repo from agent_comms.todos import ( DraftListedPullRequest, MergedListedPullRequest, PullRequestCommit, QualifiedHeadVerdict, - PromotedHeadVerdict, ReviewedHeadVerdict, UnmergedListedPullRequest, VerdictReason, + ReviewedHeadVerdict, UnmergedListedPullRequest, VerdictReason, ) from agent_comms.field_codec import FieldCodec from agent_comms.goal_actions import CreateProblemGoalAction @@ -166,8 +166,9 @@ def test_required_hosted_merge_gate_distinguishes_source_review_from_combined_pr assert (result.returncode == 0) == (event == "pull_request"), result.stdout + result.stderr if event == "merge_group": assert "no explicit reviewed merge promotion" in result.stderr - PromotedHeadVerdict(VerdictReason("The actual combined carrier is explicitly reviewed for merge")).post( - carrier.repo, carrier.head, "lead", "promotion") + ReviewedHeadVerdict(VerdictReason("The actual combined carrier is explicitly reviewed for merge")).post( + carrier.repo, carrier.head, "lead", "source") + PromotedHeadReview().post(carrier.repo, carrier.head, "lead", "promotion") promoted = subprocess.run((*command, str(tmp_path / "after-promotion")), cwd=checkout.path, env=environment, capture_output=True, text=True, timeout=120) assert promoted.returncode == 0, promoted.stdout + promoted.stderr From 7fc1930de5d6ccb14e38a05ee36deaaae5fc5cbb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 00:41:14 -0400 Subject: [PATCH 068/109] Project human read feedback from the captured display provenance --- src/agent_comms/presentation.py | 31 +++- src/agent_comms/read_basis.py | 32 +++- tests/test_notification_display_boundary.py | 58 ++++++++ tests/test_notification_display_viewer.py | 156 ++++++++++++++++++++ 4 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 tests/test_notification_display_boundary.py create mode 100644 tests/test_notification_display_viewer.py diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 913674a9d9..32c9cac95d 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -163,6 +163,11 @@ def selection(self, sources: Sequence[Delivered], *, after: RecipientCursor = Fi source, range(max(eligible.start, following.start), min(eligible.stop, following.stop)), limit)) return NotificationSelection(self, tuple(pages)) + def visible_reads(self, displayed_to: tuple[ThreadIncarnation, ...], + source: RecordedBusIdentity) -> tuple[ThreadIncarnation, ...]: + """Unscoped/operator and agent readers retain every human paint receipt.""" + return displayed_to + @abstractmethod def sees_direct_read(self, source: Delivered) -> bool: pass @@ -186,6 +191,23 @@ def sees(self, source: Delivered) -> bool: return True +@dataclass(frozen=True, slots=True) +class DisplayOutcomes(AllOutcomes): + """Full operator access, minus only this display's source-matching viewer. + + Incarnation equality owns rename/recreation; the captured bus owns namespace. + The live registry cannot establish either fact for an archived display. + """ + + viewer: ThreadIncarnation + bus_identity: RecordedBusIdentity + + def visible_reads(self, displayed_to: tuple[ThreadIncarnation, ...], + source: RecordedBusIdentity) -> tuple[ThreadIncarnation, ...]: + return tuple(reader for reader in displayed_to + if reader != self.viewer or not self.bus_identity.matches(source)) + + @dataclass(frozen=True, slots=True) class OwnerOutcomes(OutcomeViewer): """A sender sees all outcomes; a recipient sees only its original outcome.""" @@ -252,7 +274,9 @@ def summary(cls, notifications: Iterable[MessageNotification]) -> str: """One message's state and totals; clients never classify or count agents.""" ordered = cls.ordered(notifications) if not ordered: - return "No recorded notification result" + # A matched original is sent even when there is no recipient feedback + # (including when its only synthetic row was the viewing human). + return DeliveryProgress.label # This is a bounded observation, not the complete frozen audience and # not a set of runtime acknowledgements. Membership proves neither. total = len(ordered) @@ -382,7 +406,7 @@ def project_delivery_window( result[key].append( replace( notification, - displayed_to=displayed_to, + displayed_to=viewer.visible_reads(displayed_to, document.bus_identity), changed=notification.changed or bool(displayed_to), ) ) @@ -409,8 +433,9 @@ def direct_read_notification( source, recipient, snapshot, document=document ) mark = NotificationReadMark(source, recipient, displayed_to) + visible = viewer.visible_reads(displayed_to, document.bus_identity) notification = (cls(recipient, source.sender_lookup, HumanReadProgress(), - displayed_to=displayed_to, changed=True),) if displayed_to else () + displayed_to=visible, changed=True),) if visible else () return CapturedNotificationWindow({(message.seq, message.message_id): notification}, (), (mark,)) return CapturedNotificationWindow({}, ()) diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index cfe4c6f5fa..6aef779977 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -143,18 +143,28 @@ class DisplayIdentity: viewer: ThreadIncarnation history_revision: FileObservation + bus_identity: RecordedBusIdentity + + @property + def notification_identity(self) -> DisplayIdentity: + """The captured viewer/source cut, without an unrelated channel/DM payload.""" + return DisplayIdentity(viewer=self.viewer, history_revision=self.history_revision, + bus_identity=self.bus_identity) + + def outcome_viewer(self) -> OutcomeViewer: + from .presentation import DisplayOutcomes + + return DisplayOutcomes(self.viewer, self.bus_identity) @dataclass(frozen=True, slots=True) class ChannelDisplayIdentity(DisplayIdentity): - bus_identity: RecordedBusIdentity scope: ChannelDisplayScope @dataclass(frozen=True, slots=True) class DMDisplayIdentity(DisplayIdentity): root_identity: tuple[int, int] - bus_identity: RecordedBusIdentity project_path: Annotated[Path, PathText] requested_peer: str peer: ThreadIncarnation @@ -172,6 +182,10 @@ class SelectedDisplay(ABC): @abstractmethod def identity(self) -> DisplayIdentity | NoDisplaySelection: ... + @property + def notification_identity(self) -> DisplayIdentity | NoDisplaySelection: + return self.identity.notification_identity + @abstractmethod def acknowledge(self, views: HistoryViews, page: MessagePage, *, worktree: Checkout) -> None: ... @@ -194,6 +208,15 @@ class NoDisplaySelection(SelectedDisplay): def identity(self): return self + @property + def notification_identity(self) -> NoDisplaySelection: + return self + + def outcome_viewer(self) -> OutcomeViewer: + from .presentation import AllOutcomes + + return AllOutcomes() + def acknowledge(self, views: HistoryViews, page: MessagePage, *, worktree: Checkout) -> None: raise DMPaintedBoundChanged() @@ -248,7 +271,8 @@ class HistoricalSelection(SelectedDisplay): @property def identity(self): return DisplayIdentity(viewer=self.displayed.displayed.viewer_identity, - history_revision=self.history_revision) + history_revision=self.history_revision, + bus_identity=self.displayed.displayed.bus_identity) def acknowledge(self, views: HistoryViews, page: MessagePage, *, worktree: Checkout) -> None: self.displayed.acknowledge(views.bus.history, views.registry) @@ -306,7 +330,7 @@ def flush(self) -> None: from .message_bus import MessageBus from .message_page import MessagePage, PageBounds from .messages import Message, MessageKind - from .presentation import BusPresentation + from .presentation import BusPresentation, OutcomeViewer from .read_ledger import ReadDocument, ReadLedger from .registration import Registration from .registry_document import RegistrySnapshot, UserThreadLookup diff --git a/tests/test_notification_display_boundary.py b/tests/test_notification_display_boundary.py new file mode 100644 index 0000000000..4b8007b725 --- /dev/null +++ b/tests/test_notification_display_boundary.py @@ -0,0 +1,58 @@ +"""The combined P883 UI envelope retains display scope without granting tool authority. + +These controls require the matched producer plus the two-file fc311cd64 boundary patch. +""" + +from dataclasses import replace + +from agent_comms.notification_reads import RecordedNotificationRead +from agent_comms.presentation import AllOutcomes +from agent_comms.ui_model.observation import ReadNotifications +from test_notification_display_viewer import archived_readers # noqa: F401 + + +def test_notification_request_retains_viewer_source_and_full_operator_access(archived_readers): + fixture = archived_readers + fixture.paint('self') + fixture.paint('other') + identity = fixture.selection('self').notification_identity + other = fixture.selection('other').notification_identity + operation = RecordedNotificationRead((fixture.message.reference,), fixture.source.key) + request = ReadNotifications(str(fixture.live.root), operation, display=identity) + another_viewer = replace(request, display=other) + assert request != another_viewer + assert len(frozenset((request, another_viewer))) == 2 + scoped = request.read(fixture.live) + raw = fixture.notifications(AllOutcomes()) + assert scoped.outcomes == fixture.notifications(identity.outcome_viewer()).outcomes + assert scoped.read_marks == raw.read_marks + assert {row.recipient_identity for rows in scoped.outcomes.values() for row in rows} == { + row.recipient_identity for rows in raw.outcomes.values() for row in rows} + assert {reader.name for rows in scoped.outcomes.values() for row in rows + for reader in row.displayed_to} == {'other'} + assert {reader.name for rows in another_viewer.read(fixture.live).outcomes.values() for row in rows + for reader in row.displayed_to} == {'self'} + # Anonymous/operator operations still expose the raw human evidence; display is UI envelope data. + assert ReadNotifications(str(fixture.live.root), operation).read(fixture.live).outcomes == raw.outcomes + + +def test_same_viewer_in_a_foreign_source_is_a_distinct_notification_request(archived_readers): + fixture = archived_readers + identity = fixture.selection('self').notification_identity + live_page = fixture.live.views.channel_display_page( + '#team', viewer=fixture.live.views.display_viewer(fixture.live.registry.require('self').worktree), + ) + live_identity = live_page.selection.notification_identity + assert identity.viewer == live_identity.viewer + assert not identity.bus_identity.matches(live_identity.bus_identity) + # Hold the rest of the captured fields fixed: source identity alone must distinguish cache keys. + foreign = replace(identity, bus_identity=live_identity.bus_identity) + operation = RecordedNotificationRead((fixture.message.reference,), fixture.source.key) + request = ReadNotifications(str(fixture.live.root), operation, display=identity) + another_source = replace(request, display=foreign) + assert request != another_source + assert len(frozenset((request, another_source))) == 2 + fixture.paint('self') + raw = fixture.notifications(AllOutcomes()) + assert another_source.read(fixture.live).outcomes == raw.outcomes + assert request.read(fixture.live).read_marks == raw.read_marks diff --git a/tests/test_notification_display_viewer.py b/tests/test_notification_display_viewer.py new file mode 100644 index 0000000000..556267aa45 --- /dev/null +++ b/tests/test_notification_display_viewer.py @@ -0,0 +1,156 @@ +"""Captured display provenance filters paint, never the underlying evidence or audience.""" + +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from agent_comms.bus import Bus +from agent_comms.channel_targets import ThreadTarget +from agent_comms.comms import Comms, wire +from agent_comms.historical_views import HistorySource +from agent_comms.messages import Message +from agent_comms.notification_progress import DeliveryProgress +from agent_comms.presentation import AllOutcomes, MessageNotification, OwnerOutcomes +from agent_comms.read_basis import HistoricalDisplay, HistoricalSelection +from agent_comms.read_ledger import ReadLedger +from agent_comms.repo import Checkout +from agent_comms.thread_identity import AgentThreadRole, UserThreadRole +from agent_comms.threads import Thread +from bus_record_fixture import prepare_native_comms +from conftest import publish_initial + + +@dataclass(frozen=True) +class ArchivedReaders: + live: Comms + source: HistorySource + message: Message + + def selection(self, name: str) -> HistoricalSelection: + basis = self.live.bus.reads.capture( + name, (self.message,), self.live.registry.snapshot(), + Bus(Bus.path_in(Path(self.source.root))), + document=self.live.bus.reads.read(), conversation_snapshot=self.source.provenance, + ) + return HistoricalSelection(HistoricalDisplay(self.source, basis)) + + def paint(self, name: str) -> None: + self.selection(name).displayed.acknowledge(self.live.bus.history, self.live.registry) + + def notifications(self, viewer): + with self.live.bus.history.read_sources() as sources: + return self.source.notification_references(sources, (self.message.reference,), viewer) + + @property + def reads(self) -> ReadLedger: + return ReadLedger.at(Path(self.source.root)) + + +@pytest.fixture +def archived_readers(tmp_path, *, thread_registration): + old = prepare_native_comms(wire(tmp_path / 'original')) + live = prepare_native_comms(wire(tmp_path / 'current')) + for comms in (old, live): + for index, name in enumerate(('sender', 'self', 'other')): + role = AgentThreadRole if comms is old or name == 'sender' else UserThreadRole + comms.registry.declare(Thread( + name, frozenset({'team'}), Checkout(tmp_path), created_at=10.0 + index, role=role, + )) + message = publish_initial(old, 'sender', '#team', 'retained audience and human paint') + source = live.views.attach_history(old.root) + return ArchivedReaders(live, source, message) + + +@pytest.mark.parametrize('painted', (('self',), ('other',), ('self', 'other'))) +def test_display_subtraction_preserves_raw_other_reads_and_real_audience(archived_readers, painted): + fixture = archived_readers + for name in painted: + fixture.paint(name) + identity = fixture.selection('self').notification_identity + raw_document = fixture.reads.read() + live_document = fixture.live.bus.reads.read() + raw = fixture.notifications(AllOutcomes()) + shown = fixture.notifications(identity.outcome_viewer()) + agent = fixture.notifications(OwnerOutcomes.of(fixture.source.provenance.require('sender'))) + key = (fixture.message.seq, fixture.message.message_id) + before = {item.recipient_identity: item for item in raw.outcomes[key]} + after = {item.recipient_identity: item for item in shown.outcomes[key]} + assert before.keys() == after.keys() + assert before and shown.read_marks == raw.read_marks + assert agent.outcomes == raw.outcomes + for recipient, item in before.items(): + visible = after[recipient] + assert visible.state == item.state and visible.answer == item.answer + assert visible.displayed_to == tuple(reader for reader in item.displayed_to + if reader != identity.viewer) + assert {reader.name for item in after.values() for reader in item.displayed_to} == ( + {'other'} if 'other' in painted else set()) + assert fixture.reads.read() == raw_document + assert fixture.live.bus.reads.read() == live_document + # The retained comparison is deliberately raw, not the projected zero/self subtraction. + assert all(mark.displayed_to == fixture.reads.historical_displayed_recipient( + mark.source.message, mark.recipient, fixture.source.provenance, document=raw_document, + ) for mark in shown.read_marks) + + +def test_archive_display_requires_its_own_bus_even_for_the_same_viewer(archived_readers): + fixture = archived_readers + fixture.paint('self') + captured = fixture.selection('self').notification_identity + live_page = fixture.live.views.channel_display_page( + '#team', viewer=fixture.live.views.display_viewer(fixture.live.registry.require('self').worktree), + ) + foreign = live_page.selection.notification_identity + assert foreign.viewer == captured.viewer + assert not foreign.bus_identity.matches(captured.bus_identity) + raw = fixture.notifications(AllOutcomes()) + assert any(mark.displayed_to for mark in raw.read_marks) + assert fixture.notifications(foreign.outcome_viewer()).outcomes == raw.outcomes + + +def test_archive_viewer_rename_and_recreation_use_incarnation_not_current_name(archived_readers): + fixture = archived_readers + fixture.paint('self') + captured = fixture.selection('self').notification_identity + before = fixture.notifications(captured.outcome_viewer()) + raw = fixture.reads.read() + fixture.live.registry.rename('self', 'renamed') + renamed = fixture.selection('renamed').notification_identity + assert renamed.viewer == captured.viewer + assert fixture.notifications(renamed.outcome_viewer()).outcomes == before.outcomes + previous = fixture.live.registry.require('renamed') + fixture.live.registry.unregister('renamed') + fixture.live.registry.remove('renamed') + fixture.live.registry.declare(Thread( + 'self', previous.tags, previous.worktree, created_at=111.0, role=UserThreadRole, + )) + recreated = fixture.selection('self').notification_identity + assert recreated.viewer != captured.viewer + other = fixture.notifications(recreated.outcome_viewer()) + assert other.outcomes == fixture.notifications(AllOutcomes()).outcomes + assert any(mark.displayed_to for mark in other.read_marks) + assert fixture.reads.read() == raw + + +def test_direct_human_self_row_disappears_without_read_or_unread_mutation(comms, tmp_path): + worktree = Checkout(tmp_path) + human = comms.messaging.user_identity(worktree) + comms.registry.declare(Thread('sender', frozenset(), worktree)) + message = comms.messaging.send('sender', human.name, 'painted direct human message').message + page = comms.views.dm_display_page(ThreadTarget('sender'), worktree=worktree) + assert message in page.messages + comms.views.mark_page_read(page, worktree=worktree) + identity = page.selection.notification_identity + document = comms.bus.reads.read() + unread = comms.views.viewer_snapshot(worktree).unread + full = comms.views.message_notifications_for_references(AllOutcomes(), (message.reference,)) + shown = comms.views.message_notifications_for_references(identity.outcome_viewer(), (message.reference,)) + key = (message.seq, message.message_id) + assert full.outcomes[key] and full.outcomes[key][0].displayed_to == (human.incarnation,) + assert shown.outcomes[key] == () + assert MessageNotification.summary(shown.outcomes[key]) == DeliveryProgress.label + assert shown.read_marks == full.read_marks + assert not comms.bus.reads.changed_notifications(shown.read_marks, comms.registry.snapshot()) + assert comms.bus.reads.read() == document + assert comms.views.viewer_snapshot(worktree).unread == unread From 236374611b948e5ad0b7bc4d064dfbf0fbb51e72 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 00:44:04 -0400 Subject: [PATCH 069/109] Derive carrier gates, queue evidence and companion sources from their owners --- .github/workflows/debt-ratchet.yml | 35 ++++-- src/agent_comms/cli_verbs/merge_review.py | 3 +- src/agent_comms/commander/judge.py | 2 +- src/agent_comms/commander/order.py | 4 +- src/agent_comms/head_review.py | 143 ++++++++++++++++++---- src/agent_comms/integration.py | 106 ++++++++++++---- src/agent_comms/phase_evidence.py | 37 +++--- src/agent_comms/repo.py | 9 +- src/agent_comms/todos.py | 19 +-- tests/github_cli_stub.py | 25 ++-- tests/github_fixture.py | 11 ++ tests/integration_source_fixture.py | 5 +- tests/test_head_review.py | 74 +++++++++-- tests/test_integration.py | 85 ++++++++++++- tests/test_phase_evidence.py | 37 +++--- tests/test_todos.py | 2 +- 16 files changed, 457 insertions(+), 140 deletions(-) diff --git a/.github/workflows/debt-ratchet.yml b/.github/workflows/debt-ratchet.yml index 159e37cae0..39c5ab2e01 100644 --- a/.github/workflows/debt-ratchet.yml +++ b/.github/workflows/debt-ratchet.yml @@ -45,27 +45,36 @@ jobs: - run: true shell: bash - # The base every job measures from, drafts included. A pull request checks out its merge into - # current main, so the base is that merge's first parent; pull_request.base.sha is main when the - # PR was opened, and measuring from it charges the PR with everything main merged since. + # The native event/source reader owns both the comparison base and combined-job selection. + # Ordinary sources still get diagnostics; naming a branch batch/ grants nothing. base: runs-on: ubuntu-latest - timeout-minutes: 1 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read outputs: - base: ${{ steps.base.outputs.base }} + base: ${{ steps.sources.outputs.base }} + combined: ${{ steps.sources.outputs.combined }} steps: - uses: actions/checkout@v4 with: - fetch-depth: 2 - - id: base - shell: bash + submodules: recursive + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version-file: pyproject.toml + - name: Install the native source-input reader + run: python -m pip install -e src/agent_comms/skills/refactor-audit -e . + - name: Derive job selection from the actual tested sources + id: sources env: - QUEUE_BASE: ${{ github.event.merge_group.base_sha }} - run: echo "base=${QUEUE_BASE:-$(git rev-parse HEAD^1)}" >> "$GITHUB_OUTPUT" + GH_TOKEN: ${{ github.token }} + run: PYTHONPATH=src python -m agent_comms.integration --resolve-ci-inputs "$RUNNER_TEMP/job-sources" --workflow-output "$GITHUB_OUTPUT" smoke: needs: [ready, base] - if: github.event_name == 'merge_group' || startsWith(github.head_ref, 'batch/') + if: needs.base.outputs.combined == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -144,7 +153,7 @@ jobs: install-rehearsal: name: install-rehearsal needs: [ready, base] - if: github.event_name == 'merge_group' || startsWith(github.head_ref, 'batch/') + if: needs.base.outputs.combined == 'true' runs-on: ubuntu-latest timeout-minutes: 15 env: @@ -190,7 +199,7 @@ jobs: timeout-minutes: 5 permissions: contents: read - statuses: read + statuses: write pull-requests: read env: GH_TOKEN: ${{ github.token }} diff --git a/src/agent_comms/cli_verbs/merge_review.py b/src/agent_comms/cli_verbs/merge_review.py index 8806e63181..468a79df92 100644 --- a/src/agent_comms/cli_verbs/merge_review.py +++ b/src/agent_comms/cli_verbs/merge_review.py @@ -41,6 +41,7 @@ def apply(self, ctx: Comms) -> MergeReviewResult: for bypass in QueueBypass.matching(simple): target = bypass.target() request = PullRequestHead(RepositoryName.from_wire(target.repository), target.number, target.head) - StoredHeadReview.current_verdict(ctx.goals.todos, request).require_merge(request) + project = snapshot.commander_project.require() + StoredHeadReview.current_verdict(ctx.goals.todos, request).require_merge(request, project.repos) heads.append(request) return MergeReviewResult(tuple(heads)) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index b5fb776f30..a5a2462df2 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -575,7 +575,7 @@ def review_head(self, ctx: order.CommanderContext, request: PullRequestHead, permission = PromotedHeadReview() permission.require_sources(ctx.comms.goals, request, ctx.actor_identity) reviewed = super().review_head(ctx, request, linked) - return replace(reviewed, merge=permission.enqueue(reviewed.record)) + return replace(reviewed, merge=permission.enqueue(reviewed.record, ctx.repos)) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index bbd190dfc0..9d33532920 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -1133,12 +1133,10 @@ def run(self, ctx: Commander, todo: Todo, source: Repo, class NewPullRequestSelection(PullRequestSelection): def run(self, ctx: Commander, todo: Todo, source: Repo, title: str, body_file: str) -> str: - if not source.holds(todo): - raise ValueError(f"Create this order's PR in {todo.repo.to_wire()}, not {source.github.to_wire()}.") if not title or not body_file: raise ValueError("Creating a PR requires --title and --body-file.") return todo.order.checked().open_pull_request( - todo, source.checkout, title, Path(body_file).read_text(), + todo, source, title, Path(body_file).read_text(), ctx.comms.goals.todos, ctx.incarnation) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index 4a411b1f61..f5413f4f27 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -18,12 +18,13 @@ from .errors import ToolRefusedError from .field_codec import FieldCodec, GitCommitText, MalformedRecord, RecordProblems from .installer.runtime_resolution import StackSources -from .repo import Repo +from .repo import GitCommandError, Repo from .repository_name import RepositoryName from .work_branches import WorkBranches if TYPE_CHECKING: from .goal_management import Goals + from .integration import IntegrationCheckout from .thread_identity import ThreadIncarnation from .todos import HeadReviewRecord, HeadVerdict, Todo @@ -121,7 +122,7 @@ def current(self) -> OpenPullRequestHead: @dataclass(frozen=True) -class IntegrationSource: +class IntegrationSource(DeclaredFamily, affix="Source"): """One repository's captured main, exact PR overlays and resulting Git tree.""" baseline: RepositoryCommit @@ -158,17 +159,18 @@ def main_head(repository: Repo) -> str: raise ToolRefusedError("Remote main did not resolve to a full Git identity") return commit - def expected_tree(self, repository: Repo) -> str: - """Reproduce the ordered Git merge result without moving a registered checkout.""" - reader = WorkBranches(repository.checkout) - reader.require_commits("origin", self.baseline.commit, *(head.head for head in self.heads)) - commit = self.baseline.commit - for head in self.heads: - tree = Repo.git(repository.checkout, "merge-tree", "--write-tree", commit, head.head).strip() - commit = Repo.git(repository.checkout, "commit-tree", tree, - "-p", commit, "-p", head.head, - "-m", "Validate captured source combination").strip() - return Repo.git(repository.checkout, "rev-parse", f"{commit}^{{tree}}").strip() + @property + def candidate_commit(self) -> str: + return self.baseline.commit + + def materialize(self, repository: Repo, path: Path) -> IntegrationCheckout: + from .integration import IntegrationCheckout + checkout = IntegrationCheckout.create(repository, path, self.candidate_commit) + self.prepare_candidate(checkout) + return checkout + + def prepare_candidate(self, checkout: IntegrationCheckout) -> None: + checkout.combine_sources(self.baseline.commit, self.heads) def require_origin(self, repository: Repo) -> None: if Repo.of(repository.checkout).github != self.repository: @@ -180,10 +182,9 @@ def validate(self, repository: Repo, carrier: PullRequestHead) -> None: raise ToolRefusedError(f"{self.repository.to_wire()} main moved; recapture inputs") self.require_candidate(repository, carrier) + @abstractmethod def require_tree(self, repository: Repo) -> None: - self.require_origin(repository) - if self.expected_tree(repository) != self.candidate_tree: - raise ToolRefusedError(f"{self.repository.to_wire()} candidate tree is not its captured combination") + """Prove this source's candidate through its declared Git construction.""" def require_ancestry(self, repository: Repo, candidate: str) -> None: """A resolved Git combination retains every actual source, not a guessed patch.""" @@ -209,6 +210,55 @@ def require_heads(self, carrier: PullRequestHead) -> None: raise ToolRefusedError(f"#{head.pr} no longer targets captured main") +@dataclass(frozen=True) +class MergedIntegrationSource(IntegrationSource): + """Ordered source heads whose reproducible Git merge owns the candidate tree.""" + + def expected_tree(self, repository: Repo) -> str: + reader = WorkBranches(repository.checkout) + reader.require_commits("origin", self.baseline.commit, *(head.head for head in self.heads)) + commit = self.baseline.commit + for head in self.heads: + tree = Repo.git(repository.checkout, "merge-tree", "--write-tree", commit, head.head).strip() + commit = Repo.git(repository.checkout, "commit-tree", tree, + "-p", commit, "-p", head.head, + "-m", "Validate captured source combination").strip() + return Repo.git(repository.checkout, "rev-parse", f"{commit}^{{tree}}").strip() + + def require_tree(self, repository: Repo) -> None: + self.require_origin(repository) + if self.expected_tree(repository) != self.candidate_tree: + raise ToolRefusedError(f"{self.repository.to_wire()} candidate tree is not its captured combination") + + +@dataclass(frozen=True) +class ResolvedIntegrationSource(IntegrationSource): + """A separate published companion carrier preserves the actual resolved member tree.""" + + carrier: PullRequestHead + + def __post_init__(self) -> None: + super().__post_init__() + if (self.carrier.repo != self.repository or len(self.carrier.head) != 40 + or self.carrier.head != self.carrier.head.lower()): + raise MalformedRecord("A resolved source requires its exact full carrier identity") + if any(head.same_pull_request(self.carrier) for head in self.heads): + raise MalformedRecord("A resolved companion carrier is separate from its source members") + + @property + def candidate_commit(self) -> str: + return self.carrier.head + + def prepare_candidate(self, checkout: IntegrationCheckout) -> None: + self.require_ancestry(Repo.of(checkout.path), self.carrier.head) + + def require_tree(self, repository: Repo) -> None: + self.require_ancestry(repository, self.carrier.head) + current = self.carrier.current() + if current.base != WorkBranches.main: + raise ToolRefusedError("The resolved companion carrier no longer targets main") + + @dataclass(frozen=True) class IntegrationInput(DeclaredFamily, affix="Input"): """Immutable compared source cuts, bound by their carrier's reviewed Git commit. @@ -284,6 +334,16 @@ def require_source_reviews(self) -> None: f"{head.repo.to_wire()}#{head.pr} at {head.head} has no accepted exact-head " "source review; pending qualification cannot promote the carrier.") + @property + def combined(self) -> bool: + return False + + def write_workflow_output(self, path: Path, repository: RepositoryName) -> None: + """GitHub consumes these derived values, not another payload or branch-name rule.""" + with path.open("a") as output: + output.write(f"base={self.source(repository).baseline.commit}\n") + output.write(f"combined={str(self.combined).lower()}\n") + def require_carrier(self, core: Repo, carrier: PullRequestHead) -> None: raise ToolRefusedError("An individual source is not a captured combined carrier") @@ -346,6 +406,10 @@ class CombinedInput(IntegrationInput): carrier: PullRequestHead + @property + def combined(self) -> bool: + return True + def __post_init__(self) -> None: super().__post_init__() self.source(self.carrier.repo) @@ -562,6 +626,10 @@ class HeadReview(DeclaredFamily, affix="HeadReview"): # GitHub caps a status description at 140 characters. description_limit: ClassVar[int] = 140 + @staticmethod + def required_contexts() -> tuple[str, ...]: + return (HeadReview.context,) + @classmethod def allows_integration(cls) -> bool: return False @@ -604,7 +672,31 @@ class MergeHeadReview(HeadReview, declared_name=None): context = "agent-comms/merge" @classmethod - def require_merge(cls, request: PullRequestHead) -> None: + def required_contexts(cls) -> tuple[str, ...]: + return (*super().required_contexts(), cls.context) + + @classmethod + def require_combined(cls, request: PullRequestHead, + repositories: tuple[Repo, ...]) -> IntegrationInput: + repository = Repo.selected(repositories, request.repo.to_wire()) + captured = IntegrationInput.from_message( + WorkBranches(repository.checkout).commit_message(request.head)) + if not captured: + raise ToolRefusedError("Only a captured combined carrier may merge; accept individual source with judge ok") + inputs, = captured + inputs.validate(repositories, request) + inputs.require_source_reviews() + return inputs + + @classmethod + def require_merge(cls, request: PullRequestHead, + repositories: tuple[Repo, ...]) -> IntegrationInput: + inputs = cls.require_combined(request, repositories) + cls.require_permission(request) + return inputs + + @classmethod + def require_permission(cls, request: PullRequestHead) -> None: if not (HeadReview.accepted(request.repo, request.head) and cls.accepted(request.repo, request.head)): raise ToolRefusedError("The exact combined carrier has no explicit reviewed merge promotion") @@ -628,22 +720,21 @@ def require_sources(self, goals: Goals, request: PullRequestHead, project = goals.registry.snapshot().commander_project.require() if reviewer.commanding_identity != project.commander: raise ToolRefusedError("Only this root's commander may promote the combined carrier") - repository = Repo.selected(project.repos, request.repo.to_wire()) - captured = IntegrationInput.from_message(WorkBranches(repository.checkout).commit_message(request.head)) - if not captured: - raise ToolRefusedError( - "Only a captured combined carrier can be promoted; accept individual source with judge ok") - inputs, = captured + inputs = self.require_combined(request, project.repos) goals.todos.require_input_links(inputs) - inputs.validate(project.repos, request) - inputs.require_source_reviews() - def enqueue(self, record: HeadReviewRecord) -> MergeArm: + def enqueue(self, record: HeadReviewRecord, repositories: tuple[Repo, ...]) -> MergeArm: request = record.request if not HeadReview.accepted(request.repo, request.head): raise ToolRefusedError("The exact combined source review must succeed before promotion") current = request.current() + self.require_combined(request, repositories) self.post(request.repo, request.head, record.actor.name, f"#{request.pr}") + try: + self.require_merge(request, repositories) + except (ToolRefusedError, GitCommandError): + UnpromotedHeadReview().post(request.repo, request.head, record.actor.name, f"#{request.pr}") + raise return current.arm() diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index 2a511d4701..25b33994a9 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -20,7 +20,7 @@ from .declared_family import DeclaredFamily from .errors import ToolRefusedError from .field_codec import FieldCodec, GitCommitText, PathText, WireName, boundary_decoder -from .head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, IntegrationSource, MergeHeadReview, OrdinaryInput, PullRequestHead, Side +from .head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, IntegrationSource, MergedIntegrationSource, MergeHeadReview, OrdinaryInput, PromotedHeadReview, PullRequestHead, ResolvedIntegrationSource, Side, UnpromotedHeadReview from .installer.runtime_resolution import StackSources from .launch_runtime import CheckoutSource, RestoredRuntime from .repo import Checkout, DetachedStart, GitCommandError, Repo @@ -338,15 +338,31 @@ def main_commits(self, inputs: IntegrationInput) -> dict[RepositoryName, str]: def require_local_candidate(self, inputs: IntegrationInput) -> None: inputs.require_local_candidate(self.repository, self.carrier) - def require_merge_review(self) -> None: - """A PR can compute before its review; queue admission requires explicit promotion.""" + def require_merge_review(self, repositories: tuple[Repo, ...]) -> IntegrationInput: + """Source computation precedes promotion; its exact combined proof is still required.""" + return MergeHeadReview.require_combined(self.carrier, repositories) + + def publish_merge_review(self) -> None: + """Only the validated merge-group event projects permission onto its tested SHA.""" + + def withdraw_merge_review(self) -> None: + """A failed group projection withdraws its permission; ordinary source checks post nothing.""" def check_merge_inputs(self, directory: Path) -> IntegrationInput: """The required hosted check uses the same typed carrier proof as native promotion.""" + self.withdraw_merge_review() inputs = self.materialize(directory) - inputs.require_carrier(self.repository, self.carrier) - inputs.require_source_reviews() - self.require_merge_review() + repositories = tuple(Repo.of(BaseSide.checkout(directory, source.repository)) + for source in inputs.sources) + if self.require_merge_review(repositories) != inputs: + raise IntegrationFailed("The hosted input changed after source materialization") + try: + self.publish_merge_review() + if self.require_merge_review(repositories) != inputs: + raise IntegrationFailed("The hosted input changed during review publication") + except (ToolRefusedError, GitCommandError, IntegrationFailed): + self.withdraw_merge_review() + raise print(f"COMBINED CARRIER #{self.carrier.pr} {self.carrier.head}; inputs {inputs.digest}", flush=True) return inputs @@ -372,13 +388,13 @@ def materialize(self, directory: Path) -> IntegrationInput: if payload: inputs, = payload else: - sources = [IntegrationSource(RepositoryCommit(self.repository.github, self.baseline), + sources: list[IntegrationSource] = [MergedIntegrationSource(RepositoryCommit(self.repository.github, self.baseline), (self.carrier,), self.tree)] for repository in repositories[1:]: commit = IntegrationSource.main_head(repository) WorkBranches(repository.checkout).require_commits("origin", commit) tree = Repo.git(repository.checkout, "rev-parse", f"{commit}^{{tree}}").strip() - sources.append(IntegrationSource(RepositoryCommit(repository.github, commit), (), tree)) + sources.append(MergedIntegrationSource(RepositoryCommit(repository.github, commit), (), tree)) inputs = OrdinaryInput(tuple(sorted(sources, key=lambda source: source.repository.key))) self.validate(inputs, tuple(repositories)) for source in inputs.sources: @@ -389,8 +405,7 @@ def materialize(self, directory: Path) -> IntegrationInput: if source.repository == self.repository.github: IntegrationCheckout.create(repository, HeadSide.checkout(directory, source.repository), self.tested) else: - head = IntegrationCheckout.create(repository, HeadSide.checkout(directory, source.repository), source.baseline.commit) - head.combine_sources(source.baseline.commit, source.heads) + source.materialize(repository, HeadSide.checkout(directory, source.repository)) for side in Side.sides(): side.require_materialized(directory, source) checkout = side.checkout(directory, source.repository) @@ -457,8 +472,26 @@ def resolve(cls, repository: Repo, tested: str, document: object, *, class MergeGroupSources(TestedSources, declared_name="merge_group"): - def require_merge_review(self) -> None: - MergeHeadReview.require_merge(self.carrier) + def require_merge_review(self, repositories: tuple[Repo, ...]) -> IntegrationInput: + return MergeHeadReview.require_merge(self.carrier, repositories) + + def publish_merge_review(self) -> None: + """GitHub evaluates required contexts on the actual tested group, not the PR head. + + The event has already proved the exact base, carrier, member decisions and tree. + Carry those decisions through the existing nominal status owners; no new verdict + is saved, and an ordinary PR never publishes a merge-group result. + """ + from .todos import CarriedHeadVerdict, VerdictReason + CarriedHeadVerdict(VerdictReason( + "The validated merge group preserves this promoted carrier and its exact reviewed sources"), + self.carrier).post(self.carrier.repo, self.tested, "merge-group proof", f"#{self.carrier.pr}") + # Source posting leaves the group's permission pending until current carrier permission succeeds. + MergeHeadReview.require_permission(self.carrier) + PromotedHeadReview().post(self.carrier.repo, self.tested, "merge-group proof", f"#{self.carrier.pr}") + + def withdraw_merge_review(self) -> None: + UnpromotedHeadReview().post(self.carrier.repo, self.tested, "merge-group proof", f"#{self.carrier.pr}") @classmethod @boundary_decoder @@ -561,7 +594,8 @@ def accepted_heads(self) -> tuple[PullRequestHead, ...]: ) def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, - carrier: PullRequestHead) -> CombinedInput: + carrier: PullRequestHead, + companions: tuple[PullRequestHead, ...] = ()) -> CombinedInput: """Capture the existing resolved carrier and its distinct stored source members.""" self.checkout.require_editable() self.failure.require_qualification() @@ -608,7 +642,10 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, required = IntegrationInput.declared_repositories(registered_core, base, candidate_commit) if linked_repositories - set(required): raise IntegrationFailed("A selected order links an unknown stack repository") - sources = [IntegrationSource(RepositoryCommit(core.github, base), heads[core.github], tree)] + resolved = {companion.repo: companion for companion in companions} + if len(resolved) != len(companions) or set(resolved) - (set(required) - {core.github}): + raise IntegrationFailed("Select at most one actual carrier per declared companion repository") + sources: list[IntegrationSource] = [MergedIntegrationSource(RepositoryCommit(core.github, base), heads[core.github], tree)] for name in required: if name == core.github: continue @@ -617,13 +654,24 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, mains[name] = IntegrationSource.main_head(repositories[name]) heads[name] = () repo = repositories[name] - if len(heads[name]) > 1: - raise IntegrationFailed("A companion repository has no unique existing linked carrier") baseline = mains[name] IntegrationCheckout.create(repo, BaseSide.checkout(self.output, name), baseline) - candidate = IntegrationCheckout.create(repo, HeadSide.checkout(self.output, name), baseline) - tree = candidate.combine_sources(baseline, heads[name]) - sources.append(IntegrationSource(RepositoryCommit(name, baseline), heads[name], tree)) + # Resolved carrier selection is a foreign CLI boundary; all later consumers + # take the source family and never rediscover this distinction. + source: IntegrationSource + if name in resolved: + companion = resolved[name] + companion.current() + WorkBranches(repo.checkout).require_commits("origin", companion.head) + tree = Repo.git(repo.checkout, "rev-parse", f"{companion.head}^{{tree}}").strip() + source = ResolvedIntegrationSource( + RepositoryCommit(name, baseline), heads[name], tree, companion) + source.materialize(repo, HeadSide.checkout(self.output, name)) + else: + candidate = IntegrationCheckout.create(repo, HeadSide.checkout(self.output, name), baseline) + tree = candidate.combine_sources(baseline, heads[name]) + source = MergedIntegrationSource(RepositoryCommit(name, baseline), heads[name], tree) + sources.append(source) # Revalidate original sources before freezing the shared value or attaching its payload. for source in sources: repo = repositories[source.repository] @@ -775,9 +823,14 @@ def run() -> int: help="require a real combined carrier and its exact reviewed source members") operation.add_argument("--qualify", default="", metavar="PROBLEM", help="prepare sources from this actual primary order, without running tests") + parser.add_argument("--workflow-output", type=Path, + help="append derived base/job selection to GitHub's output file during CI resolution") local_options.add_argument("--with-problem", action="append", default=[], metavar="PROBLEM") local_options.add_argument("--carrier", type=int, default=0, metavar="PR", help="the existing separate combined PR at this checkout's published HEAD") + local_options.add_argument("--companion-carrier", nargs=2, action="append", default=[], + metavar=("REPOSITORY", "PR"), + help="preserve a declared companion's actual resolved carrier; members come from Todo links") local_options.add_argument("--comms-root", type=Path, help="the existing authorized root for stored-problem qualification") parser.add_argument( @@ -794,6 +847,8 @@ def run() -> int: pytest_arguments = pytest_arguments[1:] arguments.pytest_arguments = pytest_arguments values = vars(arguments) + if arguments.workflow_output and not arguments.resolve_ci_inputs: + parser.error("--workflow-output requires --resolve-ci-inputs") if arguments.resolve_ci_inputs or arguments.revalidate_ci_inputs or arguments.check_merge_inputs: forbidden = {option.dest for option in local_options._group_actions if values[option.dest]} if forbidden: @@ -802,7 +857,9 @@ def run() -> int: arguments.drop_on_failure.require_qualification() graph = TestedSources.read((arguments.checkout or Path.cwd()).resolve()) if arguments.resolve_ci_inputs: - graph.materialize(arguments.resolve_ci_inputs.resolve()) + inputs = graph.materialize(arguments.resolve_ci_inputs.resolve()) + if arguments.workflow_output: + inputs.write_workflow_output(arguments.workflow_output, graph.repository.github) elif arguments.revalidate_ci_inputs: graph.revalidate(arguments.revalidate_ci_inputs.resolve()) else: @@ -827,11 +884,14 @@ def run() -> int: if arguments.carrier <= 0: parser.error("Qualification requires --carrier naming the actual separate combined PR") carrier = PullRequestHead.at(Repo.of(run.checkout.path).github, arguments.carrier) + companions = tuple(PullRequestHead.at(Repo.selected(context.repos, name).github, int(number)) + for name, number in arguments.companion_carrier) run.qualify(context, (arguments.qualify, *arguments.with_problem), - carrier=PullRequestHead(carrier.repo, carrier.pr, carrier.head)) + carrier=PullRequestHead(carrier.repo, carrier.pr, carrier.head), + companions=tuple(PullRequestHead(head.repo, head.pr, head.head) for head in companions)) return 0 - if arguments.with_problem or arguments.carrier: - parser.error("--with-problem and --carrier require --qualify") + if arguments.with_problem or arguments.carrier or arguments.companion_carrier: + parser.error("--with-problem, --carrier and --companion-carrier require --qualify") return run.execute() diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index 4f4e1fb89a..9cf3e3eab8 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -22,7 +22,7 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec -from agent_comms.head_review import HeadReview +from agent_comms.head_review import MergeHeadReview from agent_comms.pinned_audit import ForeignAbsenceProbe, GodFunction, ParsedModule from agent_comms.private_path import FileObservation, FileRevision from agent_comms.repo import Repo @@ -489,10 +489,11 @@ class ParityEvidence(AllEvidence): @dataclass(frozen=True) class BranchRule: - """An active rule GitHub applies to main: its ruleset and the status checks it requires.""" + """An active GitHub rule, including its actual merge-queue requirement.""" ruleset: int required: tuple[str, ...] + queue: bool def requires(self, check: str) -> bool: return check in self.required @@ -508,10 +509,9 @@ class BypassActor: @dataclass(frozen=True) class RulesetGates(PhaseEvidence): - """Main requires HeadReview's exact status; only the owner's admin role may bypass. + """Main requires source review, explicit merge permission and the queue, without bypass. - The bypass hook demands a recorded verdict on that head. GitHub rules change - without a main commit, so read them once per reading, not per main commit. + GitHub policy changes without a main commit; read it once per reading. """ def retained(self, previous: MainTree, current: MainTree) -> bool: @@ -519,9 +519,9 @@ def retained(self, previous: MainTree, current: MainTree) -> bool: return False rules_query: ClassVar[str] = ( - "[.[] | {ruleset: .ruleset_id, required: [.parameters.required_status_checks[]?.context]}]" + "[.[] | {ruleset: .ruleset_id, required: [.parameters.required_status_checks[]?.context]," + " queue: (.type == \"merge_queue\")}]" ) - owner_bypass: ClassVar[BypassActor] = BypassActor("RepositoryRole", 5) bypass_query: ClassVar[str] = ( ".bypass_actors | map({" + ", ".join(entry.key for entry in FieldCodec.argument_fields(BypassActor)) @@ -534,18 +534,19 @@ def holds(self, reading: PlanReading) -> bool: tuple[BranchRule, ...], self.api(main, f"rules/branches/{main.branch}", self.rules_query), ) - return any( - self.owner_only_bypass(main, rule.ruleset) - for rule in rules - if rule.requires(HeadReview.context) - ) - - def owner_only_bypass(self, main: MainTree, ruleset: int) -> bool: + enforced = {ruleset for ruleset in {rule.ruleset for rule in rules} + if self.no_bypass(main, ruleset)} + protected = tuple(rule for rule in rules if rule.ruleset in enforced) + return (all(any(rule.requires(context) for rule in protected) + for context in MergeHeadReview.required_contexts()) + and any(rule.queue for rule in protected)) + + def no_bypass(self, main: MainTree, ruleset: int) -> bool: actors = FieldCodec.decode( tuple[BypassActor, ...], self.api(main, f"rulesets/{ruleset}", self.bypass_query), ) - return all(actor == self.owner_bypass for actor in actors) + return not actors @staticmethod def api(main: MainTree, path: str, query: str) -> object: @@ -556,9 +557,9 @@ def api(main: MainTree, path: str, query: str) -> object: def describe(self) -> str: return ( - f"an active rule on {MainTree.branch} requires {HeadReview.context} directly" - " and only the owner's admin role can bypass it; the hook refuses that bypass" - " without a recorded ok verdict on the head" + f"active rules on {MainTree.branch} require " + + " and ".join(MergeHeadReview.required_contexts()) + + " directly and an actual merge queue, with no bypass actors" ) diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index cd0215becd..65474c916c 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Self +from typing import Annotated, Self from pydantic import BaseModel, ConfigDict, InstanceOf, PlainSerializer, PlainValidator @@ -18,10 +18,6 @@ from .native_build_inputs import GitLaunch from .repository_name import RepositoryName -if TYPE_CHECKING: - from .todos import Todo - - class RepositorySelection(ABC): """A repository choice is applied by the native project it configures.""" @@ -126,9 +122,6 @@ def named(self, name: str) -> bool: """A repository name or path identifies this declared checkout, not another worktree.""" return self.github.named(name) or self.checkout == Path(name).resolve() - def holds(self, todo: Todo) -> bool: - return self.github == todo.repo - def default_branch(self) -> str: """The origin's declared default, never a guessed branch name.""" try: diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 15df9ce786..2acbb12f42 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -2679,23 +2679,23 @@ def observed_merges( return tuple(self.merged(merged.sha) for merged in MergedProgress.found(source) if repo.contains(merged.sha)) - def open_pull_request(self, todo: Todo, checkout: Path, title: str, body: str, + def open_pull_request(self, todo: Todo, source: Repo, title: str, body: str, store: TodoStore, actor: ThreadIncarnation) -> str: """Create once; record the returned identity on this order, not in its prose.""" todo.require_actor(actor, todo.held().generation) self.require_linkable() created = subprocess.run( - ("gh", "pr", "create", "--draft", "-R", todo.repo.to_wire(), + ("gh", "pr", "create", "--draft", "-R", source.github.to_wire(), "--title", title, "--body-file", "-"), input=body, - cwd=checkout, + cwd=source.checkout, capture_output=True, text=True, timeout=60) if created.returncode: raise TodoError(created.stderr.strip() or "Order pull request creation failed.") url = created.stdout.strip() try: store.apply(todo.id, RecordPullRequest(todo.revision, actor, - PullRequestLink.created(todo.repo, url), + PullRequestLink.created(source.github, url), todo.held().generation)) except (ValueError, TodoError, subprocess.SubprocessError) as error: raise TodoError(f"Created {url}, but recording its link failed: {error}. " @@ -4084,12 +4084,12 @@ class HeadVerdict(HeadReview, declared_name=None): preparation_fix: ClassVar[str] = 'supply the current PR head SHA with --head' @classmethod - def require_merge(cls, request: PullRequestHead) -> None: + def require_merge(cls, request: PullRequestHead, repositories: tuple[Repo, ...]) -> None: """Source review cannot authorize even the owner's direct merge path.""" if not cls.allows_integration(): raise TodoError( f"{request.repo.to_wire()}#{request.pr} at {request.head} has no recorded ok verdict.") - MergeHeadReview.require_merge(request) + MergeHeadReview.require_merge(request, repositories) def post(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> None: # Withdraw first: a failed source-status write cannot leave an earlier promotion live. @@ -4099,9 +4099,11 @@ def post(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> def reviewed(self, goals: Goals, request: PullRequestHead, prepared: PreparedHeadReview, reviewer: ThreadIncarnation, order: HeadReviewOrder) -> HeadReviewed: request = replace(request, head=prepared.current.head) + # Do not store a rejection/reviewing row while its old merge permission remains live. + UnpromotedHeadReview().post(request.repo, request.head, reviewer.name, prepared.subject) recorded = order.record_review(goals, request, self, reviewer) self.after_record(goals, recorded) - self.post(request.repo, request.head, reviewer.name, prepared.subject) + super().post(request.repo, request.head, reviewer.name, prepared.subject) return HeadReviewed(recorded, self.landing(prepared.current)) def landing(self, current: OpenPullRequestHead) -> MergeArm: @@ -4189,8 +4191,9 @@ def carry(self, goals: Goals, record: HeadReviewRecord, current: ReadPullRequest verdict = CarriedHeadVerdict(self.reason, record.request) carried = replace(record, request=replace(record.request, head=current.head), verdict=verdict, at=RecordedEventTime(time.time())) + UnpromotedHeadReview().post(current.repo, current.head, record.actor.name, f"#{current.pr}") goals.todos.record_head(carried) - verdict.post(current.repo, current.head, record.actor.name, f"#{current.pr}") + HeadReview.post(verdict, current.repo, current.head, record.actor.name, f"#{current.pr}") return True @classmethod diff --git a/tests/github_cli_stub.py b/tests/github_cli_stub.py index bb79f5031c..80ff233976 100755 --- a/tests/github_cli_stub.py +++ b/tests/github_cli_stub.py @@ -22,7 +22,15 @@ def main(): repositories = json.loads((directory / "pull_requests.json").read_text()) with (directory / "calls.jsonl").open("a") as calls: calls.write(json.dumps(arguments) + "\n") - if arguments[:2] == ["pr", "view"]: + if arguments[:2] == ["pr", "create"]: + repository = arguments[arguments.index("-R") + 1] + data = repositories[repository] + row = data.pop("next_creation") + assert "--draft" in arguments and arguments[-2:] == ["--body-file", "-"] + data["requests"][str(row["number"])] = {"body": sys.stdin.read(), "listed": row} + (directory / "pull_requests.json").write_text(json.dumps(repositories)) + print(f"https://github.com/{repository}/pull/{row['number']}") + elif arguments[:2] == ["pr", "view"]: data = repositories[arguments[arguments.index("-R") + 1]] request = data["requests"][arguments[2]] if refusal := request.get("read_refusal"): @@ -51,20 +59,19 @@ def main(): or entry["listed"]["head"] == parts[1]])) elif parts[:1] == ["statuses"] and len(parts) == 2: assert arguments[arguments.index("--method") + 1] == "POST" + fields = dict(arguments[index + 1].split("=", 1) + for index, word in enumerate(arguments) if word == "-f") + if refusal := data.get("status_refusals", {}).get(parts[1], {}).get(fields["context"]): + sys.exit(refusal) + data.setdefault("statuses", {}).setdefault(parts[1], {})[fields["context"]] = fields["state"] + (directory / "pull_requests.json").write_text(json.dumps(repositories)) print("{}") elif len(parts) == 3 and parts[0] == "commits" and parts[2] == "status": query = arguments[arguments.index("--jq") + 1] selected = re.search(r'select\(\.context == ("(?:\\.|[^"\\])*")\)', query) assert selected, query context = json.loads(selected.group(1)) - states = {} - endpoint = f"repos/{owner}/{name}/statuses/{parts[1]}" - for line in (directory / "calls.jsonl").read_text().splitlines(): - posted = json.loads(line) - if posted[:2] == ["api", endpoint] and "--method" in posted: - fields = dict(posted[index + 1].split("=", 1) - for index, word in enumerate(posted) if word == "-f") - states[fields["context"]] = fields["state"] + states = data.get("statuses", {}).get(parts[1], {}) print(json.dumps([states[context]] if context in states else [])) else: raise AssertionError(f"Unexpected repository endpoint: {parts}") diff --git a/tests/github_fixture.py b/tests/github_fixture.py index ab1e952e30..2456633ffa 100644 --- a/tests/github_fixture.py +++ b/tests/github_fixture.py @@ -42,6 +42,17 @@ def refuse_read(self, repository: RepositoryName, number: int, refusal: str) -> data[repository.to_wire()]["requests"][str(number)]["read_refusal"] = refusal self.path.write_text(json.dumps(data)) + def create_next(self, repository: RepositoryName, row: ListedPullRequest) -> None: + """The external forge will create this real checkout's declared PR, not edit another.""" + data = json.loads(self.path.read_text()) + data[repository.to_wire()]["next_creation"] = FieldCodec.encode(row) + self.path.write_text(json.dumps(data)) + + def refuse_status(self, repository: RepositoryName, head: str, context: str, refusal: str) -> None: + data = json.loads(self.path.read_text()) + data[repository.to_wire()].setdefault("status_refusals", {}).setdefault(head, {})[context] = refusal + self.path.write_text(json.dumps(data)) + def refuse_arm(self, repository: RepositoryName, number: int, refusal: str) -> None: """GitHub's answer when enabling auto-merge on this pull request fails.""" data = json.loads(self.path.read_text()) diff --git a/tests/integration_source_fixture.py b/tests/integration_source_fixture.py index b6390801b4..499bc88d29 100644 --- a/tests/integration_source_fixture.py +++ b/tests/integration_source_fixture.py @@ -54,9 +54,10 @@ def prepare_carrier(self, problems: tuple[str, ...]) -> PullRequestHead: self.checkout.combine_sources(IntegrationSource.main_head(self.core), heads) return self.publish_carrier(self.github) - def capture(self, problems: tuple[str, ...], risk: str) -> CombinedInput: + def capture(self, problems: tuple[str, ...], risk: str, *, + companions: tuple[PullRequestHead, ...] = ()) -> CombinedInput: return IntegrationRun(self.checkout, self.output, risk).qualify( - self.context, problems, carrier=self.prepare_carrier(problems)) + self.context, problems, carrier=self.prepare_carrier(problems), companions=companions) @pytest.fixture diff --git a/tests/test_head_review.py b/tests/test_head_review.py index a62a269eb1..803acc3413 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -18,7 +18,7 @@ from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.build_identity import RepositoryCommit from agent_comms.head_review import ( - CombinedInput, DraftMergeArm, HeadReview, IntegrationInput, IntegrationSource, OrdinaryInput, PromotedHeadReview, PullRequestHead, ReadPullRequestHead, + CombinedInput, DraftMergeArm, HeadReview, IntegrationInput, IntegrationSource, MergedIntegrationSource, MergeHeadReview, OrdinaryInput, PromotedHeadReview, PullRequestHead, ReadPullRequestHead, ) from agent_comms.integration import IntegrationRun from agent_comms.heartbeat_subjects import CommanderHistory, HeadSubject @@ -138,7 +138,7 @@ def qualify_unchanged(): assert not record.verdict.allows_integration() assert not HeadReview.accepted(request.repo, request.head) with pytest.raises(TodoError, match="no recorded ok verdict"): - record.verdict.require_merge(request) + record.verdict.require_merge(request, context.repos) qualify_unchanged() # Pending: the order still requires every unfinished clause. claim_order("P2", pull_request=request.pr, head=request.head) @@ -246,7 +246,35 @@ def test_native_companion_adoption_is_the_same_reviewed_orders_actual_qualificat assert not any(call[:2] in (("pr", "create"), ("pr", "merge")) for call in github_cli.calls()) -def test_companion_metadata_cannot_create_edit_or_adopt_missing_pr( +def test_native_companion_creation_records_its_owned_checkout_repository_not_the_primary( + linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], companion_checkout: Path, + tmp_path: Path, +) -> None: + comms, github, primary = linked_head_bus + before = comms.goals.todos.get("P2") + companion = SourceRepository.of(companion_checkout) + number = primary.pr + 1 + stamp = "2026-10-24T00:00:00Z" + github.create_next(companion.github, DraftListedPullRequest( + number, "companion-feature", companion.git(companion_checkout, "rev-parse", "HEAD").strip(), + stamp, "main", reviewable_times=(stamp,))) + body = tmp_path / "companion-body.md" + body.write_text("The actual paired source belongs to this existing order.\n") + with chdir(companion_checkout): + order.main(["pr", before.id, "--title", "Migrate the paired source", + "--body-file", str(body)], order.Commander.read_root(comms.root)) + after = comms.goals.todos.get(before.id) + link = PullRequestLink(companion.github, number) + assert after == replace(before, revision=before.revision + 1, + order=before.order.checked().linked_to(link)) + assert after.repo == before.repo == primary.repo != companion.github + assert PullRequests.linked_among((after,), companion.github) == {number: [after]} + created = tuple(call for call in github.calls() if call[:2] == ("pr", "create")) + assert created == (("pr", "create", "--draft", "-R", companion.github.to_wire(), + "--title", "Migrate the paired source", "--body-file", "-"),) + + +def test_companion_adoption_cannot_edit_or_adopt_missing_pr( linked_head_bus: tuple[Comms, GitHubFixture, PullRequestHead], companion_checkout: Path, ) -> None: comms, github, primary = linked_head_bus @@ -254,7 +282,6 @@ def test_companion_metadata_cannot_create_edit_or_adopt_missing_pr( context = order.Commander.read_root(comms.root) with chdir(companion_checkout): for arguments in ( - ["--title", "Do not create a companion", "--body-file", "not-read.md"], ["--existing", str(primary.pr), "--title", "Do not edit"], ["--existing", "999999"], ): @@ -351,8 +378,8 @@ def test_qualification_withdraws_acceptance_without_claiming_to_disarm_the_reque invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) arms = tuple(call for call in github.calls() if call[:2] == ("pr", "merge")) assert arms == () and HeadReview.accepted(request.repo, request.head) - with pytest.raises(ToolRefusedError, match="no explicit reviewed merge promotion"): - ReviewedHeadVerdict.require_merge(request) + with pytest.raises(ToolRefusedError, match="captured combined carrier"): + ReviewedHeadVerdict.require_merge(request, order.Commander.read_root(comms.root).repos) invoke(comms, request, QualifiedHeadVerdict(QUALIFIED)) assert StoredHeadReview.current_verdict(comms.goals.todos, request) is QualifiedHeadVerdict assert not HeadReview.accepted(request.repo, request.head) @@ -433,7 +460,7 @@ def test_qualification_does_not_carry_acceptance_to_an_unchanged_later_head(head @pytest.fixture def captured_input(reviewed_source: SourceRepository) -> IntegrationInput: source = reviewed_source - return OrdinaryInput((IntegrationSource( + return OrdinaryInput((MergedIntegrationSource( RepositoryCommit(source.github, source.main_commit), (PullRequestHead(source.github, source.number, source.branch_commit),), source.git(source.checkout, "rev-parse", f"{source.branch_commit}^{{tree}}").strip(), @@ -496,7 +523,7 @@ def test_companion_input_changes_the_identity_of_an_unchanged_local_patch( github_cli: GitHubFixture) -> None: source = reviewed_source companion = SourceRepository.create(tmp_path / "companion", RepositoryName.from_wire("example/client")) - client = IntegrationSource( + client = MergedIntegrationSource( RepositoryCommit(companion.github, companion.main_commit), (PullRequestHead(companion.github, companion.number, companion.branch_commit),), companion.git(companion.checkout, "rev-parse", f"{companion.branch_commit}^{{tree}}").strip(), @@ -705,7 +732,36 @@ def test_only_explicit_combined_promotion_arms_the_reviewed_head_and_reports_for assert f"#{request.pr} is a draft, so not armed" in capsys.readouterr().out assert StoredHeadReview.current_verdict(comms.goals.todos, request) is ReviewedHeadVerdict assert PromotedHeadReview.accepted(request.repo, request.head) - ReviewedHeadVerdict.require_merge(request) + ReviewedHeadVerdict.require_merge(request, context.repos) + # A stale pair of green carrier statuses cannot bypass current member decisions. + member = inputs.source(pair.client.github).heads[0] + QualifiedHeadVerdict(QUALIFIED).post(member.repo, member.head, "lead", "withdraw member") + assert HeadReview.accepted(request.repo, request.head) + assert MergeHeadReview.accepted(request.repo, request.head) + with pytest.raises(ToolRefusedError, match="no accepted exact-head source review"): + ReviewedHeadVerdict.require_merge(request, context.repos) + + +@pytest.mark.parametrize("verdict", (UnderReviewHeadVerdict(), RejectedHeadVerdict(REASON))) +def test_failed_permission_withdrawal_preserves_the_preceding_source_record( + mutual_sources: MutualSources, github_cli: GitHubFixture, verdict: HeadVerdict, +) -> None: + pair = mutual_sources + comms = pair.context.comms + inputs = pair.capture(("P2", "P3"), "withdraw before recording another source decision") + request = pair.publish_carrier(github_cli) + for source in inputs.sources: + for head in source.heads: + invoke(comms, head, ReviewedHeadVerdict(ACCEPTED)) + promote(pair.context, request) + before = StoredHeadReview.head_reviews(comms.goals.todos, request) + github_cli.refuse_status(request.repo, request.head, MergeHeadReview.context, + "Cannot withdraw the merge permission") + with pytest.raises(ToolRefusedError, match="Cannot withdraw"): + invoke(comms, request, verdict) + assert StoredHeadReview.head_reviews(comms.goals.todos, request) == before + assert HeadReview.accepted(request.repo, request.head) + assert MergeHeadReview.accepted(request.repo, request.head) def test_source_acceptance_never_enqueues_and_promotion_refuses_an_individual_member(head_bus) -> None: diff --git a/tests/test_integration.py b/tests/test_integration.py index 0b722dea5d..fa70742c0b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -14,7 +14,7 @@ from agent_comms import integration from agent_comms.errors import ToolRefusedError -from agent_comms.head_review import BaseSide, HeadReview, HeadSide, IntegrationInput, PromotedHeadReview, PullRequestHead +from agent_comms.head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, MergeHeadReview, PromotedHeadReview, PullRequestHead, ResolvedIntegrationSource from agent_comms.repository_name import RepositoryName from agent_comms.repo import Repo from agent_comms.todos import ( @@ -69,9 +69,12 @@ def test_actual_ci_cli_uses_verified_carrier_not_synthetic_message_and_revalidat "GITHUB_REPOSITORY": pair.core.github.to_wire(), "RUNNER_TEMP": str(tmp_path), "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")} command = (sys.executable, "-m", "agent_comms.integration") - result = subprocess.run((*command, "--resolve-ci-inputs", str(output)), cwd=checkout.path, + workflow_output = tmp_path / "workflow-output" + result = subprocess.run((*command, "--resolve-ci-inputs", str(output), + "--workflow-output", str(workflow_output)), cwd=checkout.path, env=environment, capture_output=True, text=True, timeout=120) assert result.returncode == 0, result.stdout + result.stderr + assert workflow_output.read_text() == f"base={local.baseline.commit}\ncombined=true\n" assert IntegrationInput.at(output) == inputs assert inputs.carrier.pr == carrier.pr != pair.core.number assert local.heads == (PullRequestHead(pair.core.github, pair.core.number, pair.core.branch_commit),) @@ -109,9 +112,12 @@ def test_actual_ordinary_ci_cli_captures_main_once_and_never_steals_an_unlinked_ "GITHUB_EVENT_PATH": str(document), "GITHUB_SHA": tested, "GITHUB_REPOSITORY": pair.core.github.to_wire(), "RUNNER_TEMP": str(tmp_path), "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")} - result = subprocess.run((sys.executable, "-m", "agent_comms.integration", "--resolve-ci-inputs", str(output)), + workflow_output = tmp_path / "ordinary-workflow-output" + result = subprocess.run((sys.executable, "-m", "agent_comms.integration", "--resolve-ci-inputs", str(output), + "--workflow-output", str(workflow_output)), cwd=checkout.path, env=environment, capture_output=True, text=True, timeout=120) assert result.returncode == 0, result.stdout + result.stderr + assert workflow_output.read_text() == f"base={baseline}\ncombined=false\n" inputs = IntegrationInput.at(output) core = inputs.source(pair.core.github) client = inputs.source(pair.client.github) @@ -128,6 +134,16 @@ def test_actual_ordinary_ci_cli_captures_main_once_and_never_steals_an_unlinked_ str(tmp_path / "refused-individual")), cwd=checkout.path, env=environment, capture_output=True, text=True, timeout=120) assert refused.returncode != 0 and "individual source" in refused.stderr + document.write_text(json.dumps({"merge_group": { + "head_sha": tested, "base_sha": baseline, + "head_ref": f"refs/heads/gh-readonly-queue/main/pr-{pair.core.number}-{head}"}})) + queued = subprocess.run((sys.executable, "-m", "agent_comms.integration", "--check-merge-inputs", + str(tmp_path / "refused-ordinary-queue")), cwd=checkout.path, + env={**environment, "GITHUB_EVENT_NAME": "merge_group"}, + capture_output=True, text=True, timeout=120) + assert queued.returncode != 0 and "individual source" in queued.stderr + assert not HeadReview.accepted(pair.core.github, tested) + assert not MergeHeadReview.accepted(pair.core.github, tested) @pytest.mark.parametrize("event", ("pull_request", "merge_group")) @@ -172,6 +188,69 @@ def test_required_hosted_merge_gate_distinguishes_source_review_from_combined_pr promoted = subprocess.run((*command, str(tmp_path / "after-promotion")), cwd=checkout.path, env=environment, capture_output=True, text=True, timeout=120) assert promoted.returncode == 0, promoted.stdout + promoted.stderr + assert HeadReview.accepted(carrier.repo, tested) is (event == "merge_group") + assert MergeHeadReview.accepted(carrier.repo, tested) is (event == "merge_group") + member = inputs.source(pair.client.github).heads[0] + QualifiedHeadVerdict(VerdictReason("This member is no longer accepted for the combined source")).post( + member.repo, member.head, "lead", "withdraw member") + stale = subprocess.run((*command, str(tmp_path / "after-member-withdrawal")), cwd=checkout.path, + env=environment, capture_output=True, text=True, timeout=120) + assert stale.returncode != 0 and "no accepted exact-head source review" in stale.stderr + assert not MergeHeadReview.accepted(carrier.repo, tested) + assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) + + +def test_resolved_companion_keeps_multiple_original_links_and_the_published_carrier_tree( + mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, +) -> None: + pair = mutual_sources + peer = SourceRepository(checkout=pair.client.checkout, github=pair.client.github, number=8) + peer.git(peer.checkout, "switch", "-c", "second-client-source", "main") + peer_head = peer.publish_change("Independent companion source", *peer.write_files({ + Path("src/extra.py"): "extra = 1\n"})) + stamp = "2026-10-24T00:00:00Z" + github_cli.put(peer.github, DraftListedPullRequest( + peer.number, "second-client-source", peer_head, stamp, "main", reviewable_times=(stamp,))) + comms = pair.context.comms + primary = comms.goals.todos.get("P2") + comms.goals.update_goal("worker", CreateProblemGoalAction( + problem="P4", commander="lead", request=primary.request, + assigner=comms.registry.require("lead").incarnation, + repo=peer.github, text="Include the second existing companion source", + order=replace(primary.order.checked(), pull_requests=(peer.link,), + check=replace(primary.order.check, directory=peer.checkout)), + review_at=RecordedEventTime(9999999999.0))) + member_heads = (PullRequestHead(pair.client.github, pair.client.number, pair.client.branch_commit), + PullRequestHead(peer.github, peer.number, peer_head)) + resolved = integration.IntegrationCheckout.create(pair.client, tmp_path / "resolved-client", pair.client.main_commit) + resolved.combine_sources(pair.client.main_commit, member_heads) + # The reviewed carrier may contain a resolved change; replaying member merges loses it. + (resolved.path / "src/resolution.py").write_text("resolved = True\n") + peer.git(resolved.path, "add", "src/resolution.py") + peer.git(resolved.path, "commit", "-m", "Resolve the actual companion carrier") + companion_head = peer.git(resolved.path, "rev-parse", "HEAD").strip() + number = 102 + peer.git(resolved.path, "push", "origin", f"{companion_head}:refs/pull/{number}/head") + github_cli.put(peer.github, DraftListedPullRequest( + number, "resolved-companion", companion_head, stamp, "main", reviewable_times=(stamp,))) + companion = PullRequestHead(peer.github, number, companion_head) + inputs = pair.capture(("P2", "P3", "P4"), "preserve actual resolved companion sources", companions=(companion,)) + carrier = pair.publish_carrier(github_cli) + source = inputs.source(peer.github) + assert isinstance(source, ResolvedIntegrationSource) + assert source.carrier == companion and source.heads == member_heads + assert inputs == CombinedInput.at(pair.output) + assert peer.git(HeadSide.checkout(pair.output, peer.github), "rev-parse", "HEAD").strip() == companion_head + inputs.validate(pair.context.repos, carrier) + replaced = replace(source, candidate_tree=source.baseline.commit) + with pytest.raises(ToolRefusedError, match="tree differs"): + replaced.require_tree(peer) + github_cli.put(peer.github, DraftListedPullRequest( + number, "resolved-companion", peer_head, stamp, "main", reviewable_times=(stamp,))) + with pytest.raises(ToolRefusedError, match="moved"): + inputs.validate(pair.context.repos, carrier) + assert comms.goals.todos.get("P2").repo == pair.core.github + assert {link.number for pid in ("P3", "P4") for link in comms.goals.todos.get(pid).order.checked().pull_requests} == {pair.client.number, peer.number} assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) diff --git a/tests/test_phase_evidence.py b/tests/test_phase_evidence.py index acf3259646..abbc1a1b5d 100644 --- a/tests/test_phase_evidence.py +++ b/tests/test_phase_evidence.py @@ -4,7 +4,7 @@ import pytest -from agent_comms.head_review import HeadReview +from agent_comms.head_review import HeadReview, MergeHeadReview from agent_comms.phase_evidence import ( AllEvidence, ClassDeclared, @@ -264,30 +264,37 @@ def test_only_foreign_comparison_refusal_exempts_an_owned_type_check(source, all assert (not checks.owned()) is allowed -@pytest.mark.parametrize(("required", "actors", "expected"), ( - ((HeadReview.context,), [], True), - ((HeadReview.context,), [{"actor_type": "RepositoryRole", "actor_id": 5}], True), - ((HeadReview.context,), [{"actor_type": "Integration", "actor_id": 15368}], False), - ((HeadReview.context,), [{"actor_type": "RepositoryRole", "actor_id": 5}, - {"actor_type": "Team", "actor_id": 9}], False), - (("review-verdict",), [], False), - (("smoke",), [], False), +@pytest.mark.parametrize(("required", "queue", "actors", "expected"), ( + (MergeHeadReview.required_contexts(), True, [], True), + (MergeHeadReview.required_contexts(), False, [], False), + (MergeHeadReview.required_contexts(), True, [{"actor_type": "RepositoryRole", "actor_id": 5}], False), + (MergeHeadReview.required_contexts(), True, [{"actor_type": "Integration", "actor_id": 15368}], False), + ((HeadReview.context,), True, [], False), + ((MergeHeadReview.context,), True, [], False), + (("review-verdict",), True, [], False), + (("smoke",), True, [], False), )) -def test_ruleset_requires_exact_review_context_with_owner_only_bypass( - tmp_path, required, actors, expected, +def test_ruleset_requires_both_owned_contexts_and_actual_queue_without_bypass( + tmp_path, required, queue, actors, expected, ): main = main_of(tmp_path, {"src/a.py": "x = 1\n"}).main - rules = [{"ruleset": 7, "required": ["smoke"]}, - {"ruleset": 8, "required": list(required)}] - answers = {"rules/branches/main": rules, "rulesets/8": actors} + # The real API may split independent requirements across rulesets. + rules = [{"ruleset": 7, "required": ["smoke"], "queue": False}, + *({"ruleset": 8, "required": [context], "queue": False} for context in required), + {"ruleset": 9, "required": [], "queue": queue}] + answers = {"rules/branches/main": rules, "rulesets/7": [], + "rulesets/8": actors, "rulesets/9": []} + reads = [] class Answered(RulesetGates): @staticmethod def api(main, path, query): + reads.append(path) return answers[path] assert Answered().holds(PlanReading((), main)) is expected - assert HeadReview.context in RulesetGates().describe() + assert reads.count("rulesets/8") == 1 + assert all(context in RulesetGates().describe() for context in MergeHeadReview.required_contexts()) def test_cleanup_evidence_counts_restated_members_and_class_rosters(tmp_path): diff --git a/tests/test_todos.py b/tests/test_todos.py index c3ce9bda5f..e3f845e897 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -390,7 +390,7 @@ def test_merged_order_refuses_a_new_pull_request( with pytest.raises(TodoError, match="merged as 2b9c1f1ab"): link.changed(merged) with pytest.raises(TodoError, match="Ask the commander for a send-back"): - merged.order.open_pull_request(merged, tmp_path, "Title", "Body", store, + merged.order.open_pull_request(merged, source_repository, "Title", "Body", store, held.owner_identity) assert github_cli.calls() == () assert link.changed(todo).order.checked().pull_requests == (PullRequestLink(repo, 3085),) From c77254e9e160213ffc25aa3239336078ff93a82e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 00:52:38 -0400 Subject: [PATCH 070/109] Declare real viewer fixture contracts and retain rename request identity --- tests/test_notification_display_boundary.py | 14 +++++++++---- tests/test_notification_display_viewer.py | 22 ++++++++++++--------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/test_notification_display_boundary.py b/tests/test_notification_display_boundary.py index 4b8007b725..5d985d7dfd 100644 --- a/tests/test_notification_display_boundary.py +++ b/tests/test_notification_display_boundary.py @@ -7,11 +7,12 @@ from agent_comms.notification_reads import RecordedNotificationRead from agent_comms.presentation import AllOutcomes +from agent_comms.read_basis import NamedViewer from agent_comms.ui_model.observation import ReadNotifications -from test_notification_display_viewer import archived_readers # noqa: F401 +from test_notification_display_viewer import ArchivedReaders, archived_readers # noqa: F401 -def test_notification_request_retains_viewer_source_and_full_operator_access(archived_readers): +def test_notification_request_retains_viewer_source_and_full_operator_access(archived_readers: ArchivedReaders) -> None: fixture = archived_readers fixture.paint('self') fixture.paint('other') @@ -34,13 +35,18 @@ def test_notification_request_retains_viewer_source_and_full_operator_access(arc for reader in row.displayed_to} == {'self'} # Anonymous/operator operations still expose the raw human evidence; display is UI envelope data. assert ReadNotifications(str(fixture.live.root), operation).read(fixture.live).outcomes == raw.outcomes + fixture.live.registry.rename('self', 'renamed') + renamed = fixture.selection('renamed').notification_identity + # A rename retains the incarnation and must not manufacture a different view/cache identity. + assert request == replace(request, display=renamed) + assert replace(request, display=renamed).read(fixture.live).outcomes == scoped.outcomes -def test_same_viewer_in_a_foreign_source_is_a_distinct_notification_request(archived_readers): +def test_same_viewer_in_a_foreign_source_is_a_distinct_notification_request(archived_readers: ArchivedReaders) -> None: fixture = archived_readers identity = fixture.selection('self').notification_identity live_page = fixture.live.views.channel_display_page( - '#team', viewer=fixture.live.views.display_viewer(fixture.live.registry.require('self').worktree), + '#team', viewer=NamedViewer('self'), ) live_identity = live_page.selection.notification_identity assert identity.viewer == live_identity.viewer diff --git a/tests/test_notification_display_viewer.py b/tests/test_notification_display_viewer.py index 556267aa45..fd8d7cf280 100644 --- a/tests/test_notification_display_viewer.py +++ b/tests/test_notification_display_viewer.py @@ -10,9 +10,11 @@ from agent_comms.comms import Comms, wire from agent_comms.historical_views import HistorySource from agent_comms.messages import Message +from agent_comms.notification_evidence import NotificationWindow from agent_comms.notification_progress import DeliveryProgress -from agent_comms.presentation import AllOutcomes, MessageNotification, OwnerOutcomes -from agent_comms.read_basis import HistoricalDisplay, HistoricalSelection +from agent_comms.presentation import AllOutcomes, MessageNotification, OutcomeViewer, OwnerOutcomes +from agent_comms.registration import Registration +from agent_comms.read_basis import HistoricalDisplay, HistoricalSelection, NamedViewer from agent_comms.read_ledger import ReadLedger from agent_comms.repo import Checkout from agent_comms.thread_identity import AgentThreadRole, UserThreadRole @@ -38,7 +40,7 @@ def selection(self, name: str) -> HistoricalSelection: def paint(self, name: str) -> None: self.selection(name).displayed.acknowledge(self.live.bus.history, self.live.registry) - def notifications(self, viewer): + def notifications(self, viewer: OutcomeViewer) -> NotificationWindow: with self.live.bus.history.read_sources() as sources: return self.source.notification_references(sources, (self.message.reference,), viewer) @@ -48,7 +50,7 @@ def reads(self) -> ReadLedger: @pytest.fixture -def archived_readers(tmp_path, *, thread_registration): +def archived_readers(tmp_path: Path, *, thread_registration: Registration) -> ArchivedReaders: old = prepare_native_comms(wire(tmp_path / 'original')) live = prepare_native_comms(wire(tmp_path / 'current')) for comms in (old, live): @@ -63,7 +65,9 @@ def archived_readers(tmp_path, *, thread_registration): @pytest.mark.parametrize('painted', (('self',), ('other',), ('self', 'other'))) -def test_display_subtraction_preserves_raw_other_reads_and_real_audience(archived_readers, painted): +def test_display_subtraction_preserves_raw_other_reads_and_real_audience( + archived_readers: ArchivedReaders, painted: tuple[str, ...], +) -> None: fixture = archived_readers for name in painted: fixture.paint(name) @@ -94,12 +98,12 @@ def test_display_subtraction_preserves_raw_other_reads_and_real_audience(archive ) for mark in shown.read_marks) -def test_archive_display_requires_its_own_bus_even_for_the_same_viewer(archived_readers): +def test_archive_display_requires_its_own_bus_even_for_the_same_viewer(archived_readers: ArchivedReaders) -> None: fixture = archived_readers fixture.paint('self') captured = fixture.selection('self').notification_identity live_page = fixture.live.views.channel_display_page( - '#team', viewer=fixture.live.views.display_viewer(fixture.live.registry.require('self').worktree), + '#team', viewer=NamedViewer('self'), ) foreign = live_page.selection.notification_identity assert foreign.viewer == captured.viewer @@ -109,7 +113,7 @@ def test_archive_display_requires_its_own_bus_even_for_the_same_viewer(archived_ assert fixture.notifications(foreign.outcome_viewer()).outcomes == raw.outcomes -def test_archive_viewer_rename_and_recreation_use_incarnation_not_current_name(archived_readers): +def test_archive_viewer_rename_and_recreation_use_incarnation_not_current_name(archived_readers: ArchivedReaders) -> None: fixture = archived_readers fixture.paint('self') captured = fixture.selection('self').notification_identity @@ -133,7 +137,7 @@ def test_archive_viewer_rename_and_recreation_use_incarnation_not_current_name(a assert fixture.reads.read() == raw -def test_direct_human_self_row_disappears_without_read_or_unread_mutation(comms, tmp_path): +def test_direct_human_self_row_disappears_without_read_or_unread_mutation(comms: Comms, tmp_path: Path) -> None: worktree = Checkout(tmp_path) human = comms.messaging.user_identity(worktree) comms.registry.declare(Thread('sender', frozenset(), worktree)) From 87dabd8837e5d5200e064748c137db88592619f0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 00:57:34 -0400 Subject: [PATCH 071/109] Select the actual live bus in foreign display source controls --- tests/test_notification_display_boundary.py | 6 ++---- tests/test_notification_display_viewer.py | 10 +++++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/test_notification_display_boundary.py b/tests/test_notification_display_boundary.py index 5d985d7dfd..25a61cb279 100644 --- a/tests/test_notification_display_boundary.py +++ b/tests/test_notification_display_boundary.py @@ -7,7 +7,6 @@ from agent_comms.notification_reads import RecordedNotificationRead from agent_comms.presentation import AllOutcomes -from agent_comms.read_basis import NamedViewer from agent_comms.ui_model.observation import ReadNotifications from test_notification_display_viewer import ArchivedReaders, archived_readers # noqa: F401 @@ -45,9 +44,8 @@ def test_notification_request_retains_viewer_source_and_full_operator_access(arc def test_same_viewer_in_a_foreign_source_is_a_distinct_notification_request(archived_readers: ArchivedReaders) -> None: fixture = archived_readers identity = fixture.selection('self').notification_identity - live_page = fixture.live.views.channel_display_page( - '#team', viewer=NamedViewer('self'), - ) + live_page = fixture.live_page('self') + assert not live_page.messages live_identity = live_page.selection.notification_identity assert identity.viewer == live_identity.viewer assert not identity.bus_identity.matches(live_identity.bus_identity) diff --git a/tests/test_notification_display_viewer.py b/tests/test_notification_display_viewer.py index fd8d7cf280..1ee2cad820 100644 --- a/tests/test_notification_display_viewer.py +++ b/tests/test_notification_display_viewer.py @@ -9,6 +9,7 @@ from agent_comms.channel_targets import ThreadTarget from agent_comms.comms import Comms, wire from agent_comms.historical_views import HistorySource +from agent_comms.message_page import MessagePage from agent_comms.messages import Message from agent_comms.notification_evidence import NotificationWindow from agent_comms.notification_progress import DeliveryProgress @@ -40,6 +41,10 @@ def selection(self, name: str) -> HistoricalSelection: def paint(self, name: str) -> None: self.selection(name).displayed.acknowledge(self.live.bus.history, self.live.registry) + def live_page(self, name: str) -> MessagePage: + """Read only this physical bus, not the integrated archive's nonempty page.""" + return self.live.views.presentation.channel_page('#team', viewer=NamedViewer(name)) + def notifications(self, viewer: OutcomeViewer) -> NotificationWindow: with self.live.bus.history.read_sources() as sources: return self.source.notification_references(sources, (self.message.reference,), viewer) @@ -102,9 +107,8 @@ def test_archive_display_requires_its_own_bus_even_for_the_same_viewer(archived_ fixture = archived_readers fixture.paint('self') captured = fixture.selection('self').notification_identity - live_page = fixture.live.views.channel_display_page( - '#team', viewer=NamedViewer('self'), - ) + live_page = fixture.live_page('self') + assert not live_page.messages foreign = live_page.selection.notification_identity assert foreign.viewer == captured.viewer assert not foreign.bus_identity.matches(captured.bus_identity) From 02dd1abff0b909617ba612efd52ac69e734712ed Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 01:03:41 -0400 Subject: [PATCH 072/109] Derive contained orderless sources from saved root reviews --- src/agent_comms/head_review.py | 7 ++- src/agent_comms/integration.py | 44 ++++++++-------- src/agent_comms/todos.py | 35 ++++++++++--- tests/github_cli_stub.py | 9 ++-- tests/github_fixture.py | 6 ++- tests/test_head_review.py | 95 ++++++++++++++++++++++++++++++---- 6 files changed, 154 insertions(+), 42 deletions(-) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index f5413f4f27..cd60cff62a 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -586,6 +586,11 @@ def arm(self) -> MergeArm: class HeadReviewRelation(DeclaredFamily): """Order relationships share one declaration family and compose their capabilities.""" + def root_sources(self, record: HeadReviewRecord, + commander: ThreadIncarnation) -> tuple[PullRequestHead, ...]: + """Only an orderless relationship supplies authority outside current Todo links.""" + return () + class HeadReviewOrder(HeadReviewRelation, declared_name=None): """The input that authorizes a head review's optional order change.""" @@ -721,7 +726,7 @@ def require_sources(self, goals: Goals, request: PullRequestHead, if reviewer.commanding_identity != project.commander: raise ToolRefusedError("Only this root's commander may promote the combined carrier") inputs = self.require_combined(request, project.repos) - goals.todos.require_input_links(inputs) + goals.todos.require_input_sources(inputs) def enqueue(self, record: HeadReviewRecord, repositories: tuple[Repo, ...]) -> MergeArm: request = record.request diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index 25b33994a9..16533f42de 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -27,7 +27,7 @@ from .systemd_unit import AgentsSlice from .repository_name import RepositoryName from .test_lease import LeaseStart, TestLease -from .todos import PullRequests +from .todos import PullRequests, StoredHeadReview from .work_branches import WorkBranches class IntegrationFailed(ToolRefusedError): @@ -609,11 +609,18 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, core = Repo.of(self.checkout.path) registered_core = Repo.selected(context.repos, core.github.to_wire()) base = IntegrationSource.main_head(registered_core) - WorkBranches(registered_core.checkout).require_commits("origin", base) + current = carrier.current() + candidate_commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() + if current.repo != core.github or current.base != WorkBranches.main or current.head != candidate_commit: + raise IntegrationFailed("Capture requires the actual published combined carrier at this checkout's HEAD") + WorkBranches(registered_core.checkout).require_commits("origin", base, candidate_commit) + required = IntegrationInput.declared_repositories(registered_core, base, candidate_commit) linked_repositories = {link.repo for todo in todos for order in todo.order.declarations for link in order.pull_requests} - initial = {*IntegrationInput.declared_repositories(registered_core, base, base), *linked_repositories} - repositories = {name: Repo.selected(context.repos, name.to_wire()) for name in initial} + if linked_repositories - set(required): + raise IntegrationFailed("A selected order links an unknown stack repository") + repositories = {name: Repo.selected(context.repos, name.to_wire()) for name in required} + carriers = (carrier, *companions) mains = {core.github: base, **{name: IntegrationSource.main_head(repo) for name, repo in repositories.items() if name != core.github}} pulls = PullRequests(todos) @@ -631,17 +638,20 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, if not row.as_unmerged() or len(linked[row.number]) != 1 or row.base != WorkBranches.main: raise IntegrationFailed("A linked source is closed, ambiguous or targets another base") selected.append(PullRequestHead(repo.github, row.number, row.head)) - heads[repo.github] = tuple(selected) - current = carrier.current() - candidate_commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() - if current.repo != core.github or current.base != WorkBranches.main or current.head != candidate_commit: - raise IntegrationFailed("Capture requires the actual published combined carrier at this checkout's HEAD") + candidates = tuple(PullRequestHead(repo.github, row.number, row.head) + for row in pulls.open(repo.github) if row.base == WorkBranches.main) + approved = StoredHeadReview.root_sources(context.comms.goals.todos, candidates, context.incarnation) + for head in approved: + for container in carriers: + if (container.repo == repo.github + and not head.same_pull_request(container)): + WorkBranches(repo.checkout).require_commits("origin", head.head, container.head) + if Repo.descends(repo.checkout, head.head, container.head): + selected.append(head) + heads[repo.github] = tuple(sorted({head.pr: head for head in selected}.values(), key=lambda head: head.pr)) self.output.mkdir(parents=True, exist_ok=False) IntegrationCheckout.create(registered_core, BaseSide.checkout(self.output, core.github), base) tree = Repo.git(self.checkout.path, "rev-parse", "HEAD^{tree}").strip() - required = IntegrationInput.declared_repositories(registered_core, base, candidate_commit) - if linked_repositories - set(required): - raise IntegrationFailed("A selected order links an unknown stack repository") resolved = {companion.repo: companion for companion in companions} if len(resolved) != len(companions) or set(resolved) - (set(required) - {core.github}): raise IntegrationFailed("Select at most one actual carrier per declared companion repository") @@ -649,10 +659,6 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, for name in required: if name == core.github: continue - if name not in repositories: - repositories[name] = Repo.selected(context.repos, name.to_wire()) - mains[name] = IntegrationSource.main_head(repositories[name]) - heads[name] = () repo = repositories[name] baseline = mains[name] IntegrationCheckout.create(repo, BaseSide.checkout(self.output, name), baseline) @@ -686,12 +692,8 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, current_todos = tuple(context.comms.goals.todos.get(todo.id) for todo in todos) for todo in current_todos: todo.require_commander(context.incarnation) - for source in sources: - linked = PullRequests.linked_among(current_todos, source.repository) - if any(head.pr not in linked for head in source.heads): - raise IntegrationFailed("A selected problem no longer links its captured source") inputs = CombinedInput(tuple(sorted(sources, key=lambda source: source.repository.key)), carrier) - context.comms.goals.todos.require_input_links(inputs) + context.comms.goals.todos.require_input_sources(inputs) if Repo.git(self.checkout.path, "rev-parse", f"{base}^{{tree}}").strip() == inputs.source(core.github).candidate_tree: raise IntegrationFailed("The Core carrier has no substantive source change") original_message = WorkBranches(self.checkout.path).commit_message(candidate_commit) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 2acbb12f42..ebac06de5e 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -4096,11 +4096,16 @@ def post(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> UnpromotedHeadReview().post(repo, head, reviewer, subject) super().post(repo, head, reviewer, subject) + def begin_review(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> None: + UnpromotedHeadReview().post(repo, head, reviewer, subject) + # A failed final status write must not leave a newly rejected member source green. + HeadReview.post(UnderReviewHeadVerdict(), repo, head, reviewer, subject) + def reviewed(self, goals: Goals, request: PullRequestHead, prepared: PreparedHeadReview, reviewer: ThreadIncarnation, order: HeadReviewOrder) -> HeadReviewed: request = replace(request, head=prepared.current.head) # Do not store a rejection/reviewing row while its old merge permission remains live. - UnpromotedHeadReview().post(request.repo, request.head, reviewer.name, prepared.subject) + self.begin_review(request.repo, request.head, reviewer.name, prepared.subject) recorded = order.record_review(goals, request, self, reviewer) self.after_record(goals, recorded) super().post(request.repo, request.head, reviewer.name, prepared.subject) @@ -4191,7 +4196,7 @@ def carry(self, goals: Goals, record: HeadReviewRecord, current: ReadPullRequest verdict = CarriedHeadVerdict(self.reason, record.request) carried = replace(record, request=replace(record.request, head=current.head), verdict=verdict, at=RecordedEventTime(time.time())) - UnpromotedHeadReview().post(current.repo, current.head, record.actor.name, f"#{current.pr}") + verdict.begin_review(current.repo, current.head, record.actor.name, f"#{current.pr}") goals.todos.record_head(carried) HeadReview.post(verdict, current.repo, current.head, record.actor.name, f"#{current.pr}") return True @@ -4307,6 +4312,11 @@ def rejected(self, goals: Goals, record: HeadReviewRecord) -> None: ... @dataclass(frozen=True) class NoHeadOrder(HeadReviewOrder, HeadOrderLink): + def root_sources(self, record: HeadReviewRecord, + commander: ThreadIncarnation) -> tuple[PullRequestHead, ...]: + return ((record.request,) if record.actor.commanding_identity == commander + and record.verdict.allows_integration() else ()) + def rejected(self, goals: Goals, record: HeadReviewRecord) -> None: """There is no holder to strike or wake.""" @@ -4590,6 +4600,15 @@ def head_reviews(cls, store: TodoStore, request: PullRequestHead) -> tuple[HeadR with store.snapshot() as db: return cls.head_reviews_among(db, (request,))[request] + @classmethod + def root_sources(cls, store: TodoStore, requests: Iterable[PullRequestHead], + commander: ThreadIncarnation) -> tuple[PullRequestHead, ...]: + """One exact-head snapshot; the saved relationship owns orderless source authority.""" + with store.snapshot() as db: + reviewed = cls.head_reviews_among(db, requests) + return tuple(source for records in reviewed.values() for record in records + for source in record.order.root_sources(record, commander)) + @classmethod def current_verdict(cls, store: TodoStore, request: PullRequestHead) -> type[HeadVerdict]: """The newest committed verdict on this exact head, or the unrecorded one.""" @@ -6081,15 +6100,19 @@ def for_goal(self, owner: ThreadIncarnation, goal_id: GoalId) -> tuple[Todo, ... return self._select(Assignment.sql_field('generation') + '=?', (goal_id,), reader=partial(self.held_in, unit=owner)) - def require_input_links(self, inputs: IntegrationInput) -> None: - """Only current stored orders, never metadata supplied by an author, link these sources.""" + def require_input_sources(self, inputs: IntegrationInput) -> None: + """Current Todo links or an exact saved root orderless decision own each source.""" todos = self.open_orders() + commander = RegistryStore.at(self.root).snapshot().commander_project.require().commander + approved = set(StoredHeadReview.root_sources( + self, (head for source in inputs.sources for head in source.heads), commander)) for source in inputs.sources: linked = PullRequests.linked_among(todos, source.repository) for head in source.heads: - if len(linked.get(head.pr, ())) != 1: + owners = linked.get(head.pr, ()) + if len(owners) > 1 or not (owners or head in approved): raise TodoError( - f"{head.repo.to_wire()}#{head.pr} has no unique actual Todo source authority") + f"{head.repo.to_wire()}#{head.pr} has neither unique Todo nor saved root source authority") def open_orders(self) -> tuple[Todo, ...]: """Every open order, whichever commander created it: their links say which requests diff --git a/tests/github_cli_stub.py b/tests/github_cli_stub.py index 80ff233976..83ba23ba37 100755 --- a/tests/github_cli_stub.py +++ b/tests/github_cli_stub.py @@ -31,8 +31,10 @@ def main(): (directory / "pull_requests.json").write_text(json.dumps(repositories)) print(f"https://github.com/{repository}/pull/{row['number']}") elif arguments[:2] == ["pr", "view"]: - data = repositories[arguments[arguments.index("-R") + 1]] - request = data["requests"][arguments[2]] + repository = arguments[arguments.index("-R") + 1] + data = repositories[repository] + reference = arguments[2].removeprefix(f"https://github.com/{repository}/pull/") + request = data["requests"][reference] if refusal := request.get("read_refusal"): print(refusal, file=sys.stderr) raise SystemExit(1) @@ -62,7 +64,8 @@ def main(): fields = dict(arguments[index + 1].split("=", 1) for index, word in enumerate(arguments) if word == "-f") if refusal := data.get("status_refusals", {}).get(parts[1], {}).get(fields["context"]): - sys.exit(refusal) + if not refusal["state"] or refusal["state"] == fields["state"]: + sys.exit(refusal["message"]) data.setdefault("statuses", {}).setdefault(parts[1], {})[fields["context"]] = fields["state"] (directory / "pull_requests.json").write_text(json.dumps(repositories)) print("{}") diff --git a/tests/github_fixture.py b/tests/github_fixture.py index 2456633ffa..954ac3fdd7 100644 --- a/tests/github_fixture.py +++ b/tests/github_fixture.py @@ -48,9 +48,11 @@ def create_next(self, repository: RepositoryName, row: ListedPullRequest) -> Non data[repository.to_wire()]["next_creation"] = FieldCodec.encode(row) self.path.write_text(json.dumps(data)) - def refuse_status(self, repository: RepositoryName, head: str, context: str, refusal: str) -> None: + def refuse_status(self, repository: RepositoryName, head: str, context: str, + refusal: str, *, state: str = "") -> None: data = json.loads(self.path.read_text()) - data[repository.to_wire()].setdefault("status_refusals", {}).setdefault(head, {})[context] = refusal + data[repository.to_wire()].setdefault("status_refusals", {}).setdefault(head, {})[context] = { + "state": state, "message": refusal} self.path.write_text(json.dumps(data)) def refuse_arm(self, repository: RepositoryName, number: int, refusal: str) -> None: diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 803acc3413..fd9da38d53 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -148,7 +148,7 @@ def qualify_unchanged(): (todo,), request, UnquotedHeadReview.of)) qualify_unchanged() # Reviewing: the recorded source cut changes no held work. posts = review_posts(github, request.head) - assert len(posts) == 4 + assert len(posts) == 8 assert all("state=pending" in call for call in posts) assert not any(call[:2] == ("pr", "merge") for call in github.calls()) @@ -675,8 +675,8 @@ def test_orderless_head_family_persists_both_outcomes_and_posts_each_exact_head( assert StoredHeadReview.head_reviews(comms.goals.todos, current) == StoredHeadReview.head_reviews(comms.goals.todos, request) assert tuple(todo.strike_count for todo in comms.goals.todos.list(request.repo)) == before posts = review_posts(github, request.head) - assert len(posts) == 2 - assert "state=success" in posts[0] and "state=failure" in posts[1] + assert [next(part for part in post if part.startswith("state=")) for post in posts] == [ + "state=pending", "state=success", "state=pending", "state=failure"] def test_judge_ok_and_back_dispatch_orderless_prs_without_positional_edit_ids(head_bus): @@ -695,7 +695,7 @@ def test_judge_ok_and_back_dispatch_orderless_prs_without_positional_edit_ids(he "--slop", "IMPL-5", "--fact", "the same decision is written twice", "--at", location, "--derive", "base-method", "--by", location], context) assert isinstance(StoredHeadReview.head_reviews(comms.goals.todos, request)[0].verdict, RejectedHeadVerdict) - assert len(review_posts(github, request.head)) == 2 + assert len(review_posts(github, request.head)) == 4 @pytest.mark.parametrize("ready,refused", ((False, False), (True, False), (True, True))) @@ -764,6 +764,81 @@ def test_failed_permission_withdrawal_preserves_the_preceding_source_record( assert MergeHeadReview.accepted(request.repo, request.head) +@pytest.mark.parametrize("state", ("accepted", "unreviewed", "rejected", "stale", "uncontained", "status-only")) +def test_capture_derives_only_exact_saved_root_orderless_sources_in_the_actual_carrier( + mutual_sources: MutualSources, github_cli: GitHubFixture, state: str, +) -> None: + pair = mutual_sources + comms = pair.context.comms + peer = SourceRepository(checkout=pair.core.checkout, github=pair.core.github, number=9) + peer.git(peer.checkout, "switch", "-c", "orderless-source", "main") + commit = peer.publish_change("Actual orderless source", *peer.write_files({ + Path("src/agent_comms/orderless.py"): "orderless = 1\n"})) + request = opened(github_cli, peer, peer.number, commit) + if state not in ("unreviewed", "status-only"): + invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) + if state == "rejected": + invoke(comms, request, RejectedHeadVerdict(REASON)) + if state == "status-only": + ReviewedHeadVerdict(ACCEPTED).post(request.repo, request.head, "lead", "not a saved decision") + if state == "stale": + commit = peer.publish_change("A later unreviewed head", *peer.write_files({ + Path("src/agent_comms/orderless.py"): "orderless = 2\n"})) + request = opened(github_cli, peer, peer.number, commit) + primary = PullRequestHead(pair.core.github, pair.core.number, pair.core.branch_commit) + invoke(comms, primary, ReviewedHeadVerdict(ACCEPTED)) + pair.prepare_carrier(("P2", "P3")) + if state != "uncontained": + pair.checkout.include(request) + carrier = pair.publish_carrier(github_cli) + inputs = IntegrationRun(pair.checkout, pair.output, "retain actual orderless source authority").qualify( + pair.context, ("P2", "P3"), carrier=carrier) + local = inputs.source(pair.core.github) + expected = (primary, request) if state == "accepted" else (primary,) + assert local.heads == expected # The linked primary also has a NoHeadOrder review: no duplicate. + comms.goals.todos.require_input_sources(inputs) + assert all(link.number != request.pr for todo in comms.goals.todos.open_orders() + for link in todo.order.checked().pull_requests) + if state == "accepted": + record, = StoredHeadReview.head_reviews(comms.goals.todos, request) + assert record.order == NoHeadOrder() and record.actor == pair.context.incarnation + else: + altered = replace(local, heads=(primary, request)) + forged = replace(inputs, sources=tuple(altered if source.repository == pair.core.github else source + for source in inputs.sources)) + if state == "uncontained": + comms.goals.todos.require_input_sources(forged) + with pytest.raises(ToolRefusedError, match="dropped a captured source parent"): + altered.require_ancestry(pair.core, inputs.carrier.head) + else: + with pytest.raises(TodoError, match="saved root source authority"): + comms.goals.todos.require_input_sources(forged) + assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) + + +def test_failed_final_rejection_status_leaves_the_member_pending_not_old_green( + mutual_sources: MutualSources, github_cli: GitHubFixture, +) -> None: + pair = mutual_sources + comms = pair.context.comms + inputs = pair.capture(("P2", "P3"), "a failed source publication must stay closed") + request = pair.publish_carrier(github_cli) + for source in inputs.sources: + for head in source.heads: + invoke(comms, head, ReviewedHeadVerdict(ACCEPTED)) + invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) + PromotedHeadReview().post(request.repo, request.head, "lead", "explicit carrier promotion") + MergeHeadReview.require_merge(request, pair.context.repos) + member = inputs.source(pair.client.github).heads[0] + github_cli.refuse_status(member.repo, member.head, HeadReview.context, "final source status refused", state="failure") + with pytest.raises(ToolRefusedError, match="final source status refused"): + invoke(comms, member, RejectedHeadVerdict(REASON)) + assert isinstance(StoredHeadReview.head_reviews(comms.goals.todos, member)[0].verdict, RejectedHeadVerdict) + assert not HeadReview.accepted(member.repo, member.head) + with pytest.raises(ToolRefusedError, match="no accepted exact-head source review"): + MergeHeadReview.require_merge(request, pair.context.repos) + + def test_source_acceptance_never_enqueues_and_promotion_refuses_an_individual_member(head_bus) -> None: comms, github, request = head_bus context = order.Commander.read_root(comms.root) @@ -810,7 +885,7 @@ def test_judge_reviewing_after_ok_withdraws_the_accepted_status_on_that_head( judge.main(["reviewing", *common], context) posts = review_posts(github, request.head) assert [next(part for part in post if part.startswith("state=")) for post in posts] == [ - "state=success", "state=pending"] + "state=pending", "state=success", "state=pending", "state=pending"] @pytest.mark.parametrize("verdict", (ReviewedHeadVerdict(ACCEPTED), QualifiedHeadVerdict(QUALIFIED))) @@ -844,8 +919,8 @@ def test_review_ok_follows_an_unchanged_change(head_bus, reviewed_source): assert StoredHeadReview.head_reviews(comms.goals.todos, moved)[0].verdict == CarriedHeadVerdict(ACCEPTED, request) assert not OrderlessUnreviewedHead.scan_reviews(comms.goals, "lead", pulls) carried_posts = review_posts(github, merged) - assert len(carried_posts) == 1 - assert f"description=carried from {request.head[:9]}: same change" in carried_posts[0] + assert len(carried_posts) == 2 and "state=pending" in carried_posts[0] + assert f"description=carried from {request.head[:9]}: same change" in carried_posts[1] owner = source.checkout / "owner.py" owner.write_text(owner.read_text() + 'spacing = "a b"\n') @@ -997,7 +1072,8 @@ def test_acceptance_over_the_growth_limit_needs_a_reason_naming_each_growing_fil invoke(comms, request, GrownHeadVerdict(ACCEPTED, GrowthReason( "grown.py declares forty one values that the reviewed request needs as separate names"))) posts = review_posts(github, head) - assert len(posts) == 1 and any(f"(src +{grown})" in part for part in posts[0]) + assert len(posts) == 2 and "state=pending" in posts[0] + assert any(f"(src +{grown})" in part for part in posts[1]) # The actual CLI must report both independent failures, then infer the unique # order when corrected. Native orderless acceptance above remains legal. @@ -1059,7 +1135,8 @@ def commit(parent, lines, message): 9, "fix/child", head, stamp, "fix/parent", reviewable_times=(stamp,))) invoke(comms, PullRequestHead(source.github, 9, head), ReviewedHeadVerdict(ACCEPTED)) posts = review_posts(github, head) - assert len(posts) == 1 and any("(src +1)" in part for part in posts[0]) + assert len(posts) == 2 and "state=pending" in posts[0] + assert any("(src +1)" in part for part in posts[1]) def test_judge_ok_keeps_every_repeated_growth_reason(): From 0dd3db428d1b9a2d257a1152edcdf96109aee9f3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 04:05:26 -0400 Subject: [PATCH 073/109] Own proof reads and notification types without execution import loops --- src/agent_comms/attempt_recovery.py | 5 +- src/agent_comms/bus_tables.py | 3 +- src/agent_comms/commander/order.py | 3 +- src/agent_comms/harness_delivery_states.py | 4 +- src/agent_comms/historical_views.py | 3 +- src/agent_comms/history_views.py | 2 +- src/agent_comms/native_entries.py | 18 ++- src/agent_comms/native_input_proof.py | 67 ++++------ src/agent_comms/native_runtime_input.py | 6 +- src/agent_comms/native_transcript.py | 8 +- src/agent_comms/notification_assignment.py | 65 +-------- src/agent_comms/notification_evidence.py | 75 +++++++++-- src/agent_comms/presentation.py | 119 +---------------- src/agent_comms/private_send_claim.py | 2 +- src/agent_comms/read_basis.py | 123 +++++++++++++++++- src/agent_comms/selected_participant.py | 3 +- src/agent_comms/started_input.py | 6 +- src/agent_comms/thread_presentation.py | 9 +- src/agent_comms/tool_caller.py | 3 +- src/agent_comms/tools.py | 3 +- src/agent_comms/turn_input_source.py | 28 ++-- src/agent_comms/ui_model/observation.py | 4 +- stack/native/native-proof-schema.mjs | 2 +- stack/pi-native.sha256 | 6 +- tests/c4_coordination_installed_ui.py | 3 +- tests/native_proof_cases.py | 13 +- tests/reply_relevance_installed_journey.py | 2 +- tests/test_acp_queue_contract.py | 6 +- tests/test_channel_bus_ownership.py | 2 +- tests/test_following_context_notifications.py | 4 +- tests/test_harness_receipt_context.py | 2 +- tests/test_historical_notifications.py | 17 +-- tests/test_live_context_notifications.py | 6 +- tests/test_message_notifications.py | 2 +- tests/test_native_evidence_custody.py | 17 ++- tests/test_native_input_bus_path.py | 5 +- tests/test_native_input_evidence_index.py | 3 +- tests/test_native_pi.py | 35 ++--- tests/test_notification_display_boundary.py | 2 +- tests/test_notification_display_viewer.py | 3 +- tests/test_notification_recipient_window.py | 6 +- tests/test_outbound_transcript_source.py | 2 +- tests/test_recipient_activity_index.py | 3 +- tests/test_wake_candidate_index.py | 5 +- 44 files changed, 357 insertions(+), 348 deletions(-) diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index 10b94c588d..dac0151191 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -44,7 +44,6 @@ from .coordination_database import CoordinationStore from .native_input_record import FullNativeExecution, TriageNativeExecution -from .native_input_proof import NativeContextProof from .native_prompt_binding import ( expected_prompt_bindings, expected_prompt_matches_journal, @@ -474,10 +473,10 @@ def recover_released_native_attempt( if not expected_prompt_matches_journal(session.path, binding, evidence=evidence): raise RecoveryBlocked("native recovery input does not match its binding") try: - proof = NativeContextProof.read_evidence( + proof = evidence.context_proof( NativeSessionFiles.for_identity(session, root=store.session.path.parent, owner=source.owner_identity), - source.input_id, evidence=evidence, + source.input_id, session_entry_start=source.require_context_proof().session_entry_start, ) except (NativePiUnavailable, UnrecordedNativeContext): diff --git a/src/agent_comms/bus_tables.py b/src/agent_comms/bus_tables.py index 3ac4fdfc8c..6e77415b88 100644 --- a/src/agent_comms/bus_tables.py +++ b/src/agent_comms/bus_tables.py @@ -6,6 +6,7 @@ import sqlite3 from collections.abc import Iterable from dataclasses import dataclass +from itertools import batched from typing import TYPE_CHECKING, Annotated from agent_comms import messages @@ -204,8 +205,6 @@ def first_for_recipients( cls, db: sqlite3.Connection, keys: tuple[tuple[ThreadLookup, MessageReference], ...], ) -> tuple[AnsweredSource, ...]: """At most one reply per selected (original, frozen author), before body decoding.""" - from itertools import batched - selected = [] for batch in batched(tuple(dict.fromkeys(keys)), db.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER) // 2): values = ",".join("(?,?)" for _ in batch) diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index abedb1624d..59df04dea1 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -74,7 +74,8 @@ from agent_comms.owner_requests import NewestOpenRequest, OwnerRequestId, RequestSelection from agent_comms.phase_evidence import MainTree, PlanReading from agent_comms.plan_phases import OrderPhase, PlanPhase -from agent_comms.presentation import AllOutcomes, MessageNotification +from agent_comms.read_basis import AllOutcomes +from agent_comms.presentation import MessageNotification from agent_comms.recorded_time import RecordedEventTime from agent_comms.registry_document import RegisteredCommanderProject from agent_comms.repo import Repo diff --git a/src/agent_comms/harness_delivery_states.py b/src/agent_comms/harness_delivery_states.py index 4b2775c430..539625c970 100644 --- a/src/agent_comms/harness_delivery_states.py +++ b/src/agent_comms/harness_delivery_states.py @@ -66,9 +66,9 @@ class UnconfirmedOutcome(Outcome): class DeliveredOutcome(FilteredOutcome): - """An original harness receipt, not evidence that a model took the body. + """An original harness response, not evidence that a model took the body. - Older generic receipts retain this spelling and their actual monitor proof. + Older generic responses retain this spelling and their actual monitor proof. A producer name alone cannot upgrade their evidence to model context. """ diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index 8858312b85..11d9526a0e 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -33,7 +33,8 @@ ) from .messages import CarriesEnvelope, Message from .page_traversal import HistoryCursor, OlderTraversal -from .presentation import MessageNotification, OutcomeViewer, FirstRecipient, RecipientCursor +from .read_basis import OutcomeViewer, FirstRecipient, RecipientCursor +from .presentation import MessageNotification from .notification_evidence import RecipientSelection from .private_path import FileObservation, FileRevision, ObservedPath, PrivateDirectoryRole from .read_basis import ( diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index 245cbb268e..cc44e33d3a 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -57,6 +57,7 @@ from .messaging import Messaging from .page_traversal import LiveCursor from .notification_evidence import NotificationWindow, RecipientSelection +from .read_basis import OutcomeViewer, FirstRecipient, RecipientCursor from .presentation import ( BusPresentation, ChannelView, @@ -65,7 +66,6 @@ InboxRead, MessageNotification, NoRevision, - OutcomeViewer, FirstRecipient, RecipientCursor, ReadState, ThreadRecord, ThreadView, diff --git a/src/agent_comms/native_entries.py b/src/agent_comms/native_entries.py index b726c2a609..0b8729f2d2 100644 --- a/src/agent_comms/native_entries.py +++ b/src/agent_comms/native_entries.py @@ -24,7 +24,6 @@ from .store_files import AtomicFile if TYPE_CHECKING: - from .native_input_proof import NativeContextProof from .importing import ImportedConversation from .declared_family import DeclaredFamily, UnknownFamilyMember @@ -40,7 +39,7 @@ WireOmitDefault, ) from .importing import ImportedSessionMetadata -from .native_input_proof import NativeInputLocation +from .native_input_proof import NativeContextJournal, NativeContextProof, NativeInputLocation from .pi_payloads import Opaque, PiField, PiMessage, PiOmitted, PiPayload, ReportedText from .pi_vocabulary import PendingStopReason, ThinkingLevel from .recorded_time import GeneratedTimestamp @@ -406,6 +405,21 @@ def read_input(self, location): self.observe() return self.tracked(location.input_id) + def context_proof( + self, files: NativeSessionFiles, input_id: InputId, *, + generations: range = range(1, 2**53), session_entry_start: int, + ) -> NativeContextProof: + """Corroborate a journal row against this reader's original acquired bytes.""" + session_file = files.session.absolute() + with self.borrow(session_file, self) as evidence: + with NativeContextJournal.open_evidence(files) as db: + row = NativeContextJournal.for_input(db, input_id, generations) + location = NativeInputLocation(input_id, row.session_id, row.session_entry_id, + session_entry_start=session_entry_start) + entry = evidence.read_input(location) + return row.corroborate(session_file, evidence.header(), entry, + session_entry_start=session_entry_start) + def stopped_input(self, input_id: InputId): """Read only this input's contiguous branch, ending at the next input. diff --git a/src/agent_comms/native_input_proof.py b/src/agent_comms/native_input_proof.py index 0399bdcd71..cd07d71c87 100644 --- a/src/agent_comms/native_input_proof.py +++ b/src/agent_comms/native_input_proof.py @@ -25,11 +25,11 @@ PathText, Sha256Text, WireName, - boundary_decoder, ) from .locked_store import SQLiteFile from .native_failures import NativePiUnavailable from .native_session_reopen import PiSessionId +from .turn_item import FreshNativeId from .private_path import FileIdentity, PrivateFileRole from .typed_table import ( Column, @@ -57,7 +57,7 @@ class NativeInputCommit: Column(primary_key=True), ] session_id: Annotated[PiSessionId, WireName("sessionId")] - session_entry_id: Annotated[str, WireName("sessionEntryId")] + session_entry_id: Annotated[FreshNativeId, WireName("sessionEntryId")] @property def input_commit(self) -> NativeInputCommit: @@ -120,7 +120,7 @@ def from_events( raise NativePiUnavailable("Native Pi input/context events disagree") return record - def __post_init__(self): + def __post_init__(self) -> None: Sha256Text.decode(self.llm_context_digest) if not 0 < self.request_generation <= 2**53 - 1: raise ValueError("Native context generation is outside its native range") @@ -165,7 +165,7 @@ def native_contract(cls) -> dict[str, JsonValue]: """Generate the native writer's SQLite contract from this declaration.""" table = cls.declared_name return { - "objects": cls.schema_objects(), + "objects": {name: statement for name, statement in cls.schema_objects().items()}, "columns": [ {"name": plan.name, "wire": plan.key} for plan in FieldCodec.field_plans(cls) ], @@ -212,22 +212,22 @@ def open_evidence(cls, files: NativeSessionFiles) -> Iterator[sqlite3.Connection @classmethod def for_input( - cls, db: sqlite3.Connection, input_id: InputId, generation: int | None = None - ) -> NativeContextJournal | None: - """The primary key answers one input without scanning lifetime history.""" - where = "input_id=?" - parameters: tuple[InputId | int, ...] = (input_id,) - if generation is not None: - where += " AND request_generation=?" - parameters += (generation,) - rows = cls.read( - db.execute( - f"SELECT {cls._column_list(tuple(cls.column_projection()))} FROM {cls.declared_name} " - f"WHERE {where} ORDER BY request_generation DESC LIMIT 1", - parameters, - ) - ) - return next(iter(rows), None) + cls, db: sqlite3.Connection, input_id: InputId, + generations: range = range(1, 2**53), + ) -> NativeContextJournal: + """Require the latest input proof in this indexed generation interval.""" + if generations.step != 1: + raise ValueError("Context proof generations require a contiguous interval") + rows = cls.read(db.execute( + f"SELECT {cls._column_list(tuple(cls.column_projection()))} FROM {cls.declared_name} " + "WHERE input_id=? AND request_generation>=? AND request_generation None: - """Repeated requests may include this input; its first receipt stays immutable. + """Repeated requests may include this input; its first proof stays immutable. A different native entry or conflicting proof of the same request is not another observation of that entry. Callers corroborate later generations @@ -269,29 +269,8 @@ def corroborates_input( """ if self.input_id != input_id or files.session != self.session_file: return False - return self.read_evidence( + return evidence.context_proof( files, input_id, - request_generation=self.request_generation, evidence=evidence, + generations=range(self.request_generation, self.request_generation + 1), session_entry_start=self.session_entry_start, ) == self - - @classmethod - @boundary_decoder - def read_evidence( - cls, files: NativeSessionFiles, input_id: InputId, *, request_generation: int | None = None, - evidence: NativeEvidenceRead | None = None, - session_entry_start: int, - ) -> NativeContextProof: - """Corroborate live recorded events; parsed bytes alone grant no authority.""" - session_file = files.session.absolute() - from .native_entries import NativeInputEvidenceRead - with NativeInputEvidenceRead.borrow(session_file, evidence) as evidence: - with NativeContextJournal.open_evidence(files) as db: - row = NativeContextJournal.for_input(db, input_id, request_generation) - if row is None: - raise NativePiUnavailable("The input has no assembled-context proof") - location = NativeInputLocation(input_id, row.session_id, row.session_entry_id, - session_entry_start=session_entry_start) - entry = evidence.read_input(location) - return row.corroborate(session_file, evidence.header(), entry, - session_entry_start=session_entry_start) diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index 3b0768a045..7bd604638e 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -19,6 +19,7 @@ from .coordination_tables.participants import OwnerGenerations from .coordination_tables.responses import ResponseObligation from .class_fact import ClassFact +from .turn_item import FreshNativeId from .declared_family import DeclaredFamily from .field_codec import ( FieldCodec, @@ -133,7 +134,8 @@ def notification_context_sql(cls) -> str: ) @ClassFact - def write_once_columns(cls) -> tuple[str, ...]: + @staticmethod + def write_once_columns(cls: type[NativeRuntimeInput]) -> tuple[str, ...]: # The declared context group is immutable, not the later triage verdict. return ('sent_owner_claimed_generation', 'session_id', 'session_file', 'verdict', *(plan.name for plan in FieldCodec.field_plans(cls) @@ -293,7 +295,7 @@ def published_replies(self, db, user, owner_lookup): ] = field(default_factory=UnrecordedNativeClaim) session_id: PiSessionId | NoPiSessionId = NoPiSessionId() session_file: str | None = None - session_entry_id: Annotated[NativeColumn, NativeContext(str), Column(storage=TextStorage, nullable=True)] = UnrecordedNativeColumn() + session_entry_id: Annotated[NativeColumn, NativeContext(FreshNativeId), Column(storage=TextStorage, nullable=True)] = UnrecordedNativeColumn() session_entry_start: Annotated[ NativeInputPosition, NativeInputLocation.position_column, diff --git a/src/agent_comms/native_transcript.py b/src/agent_comms/native_transcript.py index 8fcccdbaf4..e72f8866cd 100644 --- a/src/agent_comms/native_transcript.py +++ b/src/agent_comms/native_transcript.py @@ -13,6 +13,7 @@ from .errors import RelationViolationError from .native_input_proof import NativeContextJournal +from .native_failures import NativePiUnavailable if TYPE_CHECKING: from .native_input_proof import NativeContextProof @@ -346,9 +347,10 @@ def require_publication_context( with self.path.open("rb") as stream: header = SessionEntry.read(stream.readline()) with NativeContextJournal.open_evidence(files) as db: - proof = NativeContextJournal.for_input(db, user.input_id) - if proof is None: - raise MalformedRecord("Native publication has no committed context inclusion") + try: + proof = NativeContextJournal.for_input(db, user.input_id) + except NativePiUnavailable as missing: + raise MalformedRecord("Native publication has no committed context inclusion") from missing return proof.corroborate( self.path.absolute(), header, user, session_entry_start=session_entry_start, ) diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index b979565f3f..5bb1e25343 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -19,7 +19,7 @@ from .coordination_tables.harness_deliveries import HarnessDelivery from .coordination_tables.publications import PublicationEvidence from .declared_family import DeclaredFamily -from .field_codec import FieldCodec, MalformedRecord, MessageId, ThreadLookup, boundary_decoder +from .field_codec import FieldCodec, MalformedRecord, ThreadLookup, boundary_decoder from .message_reference import MessageReference from .notification_progress import ( AssignmentProgress, @@ -27,7 +27,8 @@ SentProgress, ShownToModelProgress, ) -from .notification_evidence import NotificationAssignmentRead, NotificationWindow, NotificationSelection +from .notification_evidence import (AssignmentResponse, NotificationAssignmentRead, NotificationWindow, + NotificationSelection, UnansweredResponse) from .private_registry_guard import RegistryPrivacy from .registry_store import RegistryStore from .typed_table import BooleanStorage, TypedRow @@ -74,64 +75,6 @@ def progress(self, *, pending_at_ms: int, queued: bool) -> AssignmentProgress: return ShownToModelProgress(self.committed_at_ms) -@dataclass(frozen=True) -class AssignmentResponse(DeclaredFamily, affix="Response"): - def recorded(self, message: Delivered) -> AssignmentResponse: - return self - - @classmethod - @boundary_decoder - def from_columns( - cls, seq: object, message_id: object, received_at_ms: object - ) -> AssignmentResponse: - """SQL NULL is the one foreign fact tested; present values decode as their own types.""" - columns = (seq, message_id, received_at_ms) - if all(column is None for column in columns): - return UnansweredResponse() - if any(column is None for column in columns): - raise MalformedRecord("Published response evidence is incomplete") - return AnsweredResponse( - MessageReference(FieldCodec.decode(int, seq), FieldCodec.decode(MessageId, message_id)), - FieldCodec.decode(int, received_at_ms), - ) - - @abstractmethod - def completed_notification( - self, progress: AssignmentProgress, notification: MessageNotification - ) -> MessageNotification: ... - - @abstractmethod - def answered(self, notification: MessageNotification) -> MessageNotification: - """The notification with this recorded reply attached; consumption stays its proof's.""" - - -class UnansweredResponse(AssignmentResponse): - def recorded(self, message: Delivered) -> AssignmentResponse: - return AnsweredResponse( - message.reference, int(message.timestamp * 1000) - ) - - def completed_notification(self, progress: AssignmentProgress, notification: MessageNotification) -> MessageNotification: - return progress.consumed_notification(notification) - - def answered(self, notification: MessageNotification) -> MessageNotification: - return notification - - -@dataclass(frozen=True) -class AnsweredResponse(AssignmentResponse): - reference: MessageReference - received_at_ms: int - - def completed_notification(self, progress: AssignmentProgress, notification: MessageNotification) -> MessageNotification: - return progress.answered_notification( - notification, self.reference, self.received_at_ms - ) - - def answered(self, notification: MessageNotification) -> MessageNotification: - return notification.state.answered_notification(notification, self.reference, self.received_at_ms) - - @dataclass(frozen=True) class AssignmentActivity: triage_inflight: bool @@ -289,7 +232,7 @@ def capture( ) -> NotificationAssignmentRead: """Probe only selected frozen keys before joining any claim/context/proof. - The existing (recipient_lookup, wire_seq) unique index admits each row; + The existing (recipient_lookup, wire_seq) unique index selects each row; message_id corroborates the exact original rather than a current name. """ if len(selection.pages) > NotificationWindow.window_limit: diff --git a/src/agent_comms/notification_evidence.py b/src/agent_comms/notification_evidence.py index 8ec04fa03c..0889dbbec7 100644 --- a/src/agent_comms/notification_evidence.py +++ b/src/agent_comms/notification_evidence.py @@ -10,18 +10,79 @@ from types import MappingProxyType from typing import TYPE_CHECKING, ClassVar +from .declared_family import DeclaredFamily +from .field_codec import FieldCodec, MalformedRecord, MessageId, ThreadLookup, boundary_decoder +from .message_reference import MessageReference +from .thread_identity import ThreadIncarnation + if TYPE_CHECKING: from .audience_manifest import FrozenRecipient + from .read_basis import OutcomeViewer from .bus_publication import Delivered from .comms import Comms from .coordination_tables.assignments import WakeAssignment from .coordination_tables.harness_deliveries import HarnessDelivery - from .field_codec import ThreadLookup - from .message_reference import MessageReference - from .notification_assignment import AssignmentActivityEvidence, AssignmentResponse - from .notification_progress import DeliveryProgress - from .presentation import MessageNotification, WireRevision, OutcomeViewer - from .thread_identity import ThreadIncarnation + from .notification_assignment import AssignmentActivityEvidence + from .notification_progress import AssignmentProgress, DeliveryProgress + from .presentation import MessageNotification, WireRevision + + +@dataclass(frozen=True) +class AssignmentResponse(DeclaredFamily, affix="Response"): + def recorded(self, message: Delivered) -> AssignmentResponse: + return self + + @classmethod + @boundary_decoder + def from_columns( + cls, seq: object, message_id: object, received_at_ms: object + ) -> AssignmentResponse: + """SQL NULL is the one foreign fact tested; present values decode as their own types.""" + columns = (seq, message_id, received_at_ms) + if all(column is None for column in columns): + return UnansweredResponse() + if any(column is None for column in columns): + raise MalformedRecord("Published response evidence is incomplete") + return AnsweredResponse( + MessageReference(FieldCodec.decode(int, seq), FieldCodec.decode(MessageId, message_id)), + FieldCodec.decode(int, received_at_ms), + ) + + @abstractmethod + def completed_notification( + self, progress: AssignmentProgress, notification: MessageNotification + ) -> MessageNotification: ... + + @abstractmethod + def answered(self, notification: MessageNotification) -> MessageNotification: + """The notification with this recorded reply attached; consumption stays its proof's.""" + + +class UnansweredResponse(AssignmentResponse): + def recorded(self, message: Delivered) -> AssignmentResponse: + return AnsweredResponse( + message.reference, int(message.timestamp * 1000) + ) + + def completed_notification(self, progress: AssignmentProgress, notification: MessageNotification) -> MessageNotification: + return progress.consumed_notification(notification) + + def answered(self, notification: MessageNotification) -> MessageNotification: + return notification + + +@dataclass(frozen=True) +class AnsweredResponse(AssignmentResponse): + reference: MessageReference + received_at_ms: int + + def completed_notification(self, progress: AssignmentProgress, notification: MessageNotification) -> MessageNotification: + return progress.answered_notification( + notification, self.reference, self.received_at_ms + ) + + def answered(self, notification: MessageNotification) -> MessageNotification: + return notification.state.answered_notification(notification, self.reference, self.received_at_ms) @dataclass(frozen=True) @@ -34,8 +95,6 @@ class SourceEvidence: responses: Mapping[ThreadLookup, AssignmentResponse] = field(default_factory=lambda: MappingProxyType({})) def answered(self, recipient: ThreadLookup, notification: MessageNotification) -> MessageNotification: - from .notification_assignment import UnansweredResponse - return self.responses.get(recipient, UnansweredResponse()).answered(notification) def consumed_progress(self, recipient: ThreadLookup, diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 32c9cac95d..2a5c8b8ce6 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -from bisect import bisect_left, bisect_right from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from contextlib import contextmanager @@ -36,10 +35,7 @@ FieldCodec, JsonShapeFamily, JsonShapeMember, - NullMember, PresenceExclude, - RecordMember, - SubclassUnion, ThreadLookup, WatchPaths, WatchViews, @@ -72,8 +68,8 @@ ) from .read_ledger import NoReadNotice, ReadLedger, ReadNotice from .notification_assignment import NotificationAssignment -from .notification_evidence import (NotificationAssignmentRead, NotificationReadMark, NotificationWindow, - NotificationSelection, RecipientSelection) +from .notification_evidence import NotificationAssignmentRead, NotificationReadMark, NotificationWindow, NotificationSelection, RecipientSelection +from .read_basis import FirstRecipient, OutcomeViewer, RecipientCursor from .notification_read import CapturedNotificationWindow from .recorded_bus import NoRecordedBus, RecordedBusIdentity from .registry_document import DEFAULT_ROSTER, RosterVisibility @@ -120,120 +116,9 @@ from .registration import Registration from .registry_document import RegistrySnapshot from .registry_provenance import DeletedThread, RegistryProvenance - from .threads import ThreadProvenance from .ui_model.observation import ThreadsObserved -class RecipientCursor(SubclassUnion, affix=""): - """Select frozen, lookup-ordered positions before expensive evidence acquisition.""" - - @abstractmethod - def positions(self, source: Delivered) -> range: ... - - -@dataclass(frozen=True) -class FirstRecipient(NullMember, RecipientCursor): - def positions(self, source: Delivered) -> range: - return range(len(source.recipients)) - - -@dataclass(frozen=True) -class AfterRecipient(RecordMember, RecipientCursor): - recipient: FrozenRecipient - - def positions(self, source: Delivered) -> range: - start = bisect_right(source.recipients, self.recipient.recipient_lookup, - key=lambda item: item.recipient_lookup) - return range(start, len(source.recipients)) - - -class OutcomeViewer(ABC): - """Who reads a delivery window decides which of its outcomes are shown.""" - - @abstractmethod - def positions(self, source: Delivered) -> range: ... - - def selection(self, sources: Sequence[Delivered], *, after: RecipientCursor = FirstRecipient(), - limit: int = RecipientSelection.max_limit) -> NotificationSelection: - RecipientSelection.require_limit(limit) - pages = [] - for source in sources: - eligible, following = self.positions(source), after.positions(source) - pages.append(RecipientSelection( - source, range(max(eligible.start, following.start), min(eligible.stop, following.stop)), limit)) - return NotificationSelection(self, tuple(pages)) - - def visible_reads(self, displayed_to: tuple[ThreadIncarnation, ...], - source: RecordedBusIdentity) -> tuple[ThreadIncarnation, ...]: - """Unscoped/operator and agent readers retain every human paint receipt.""" - return displayed_to - - @abstractmethod - def sees_direct_read(self, source: Delivered) -> bool: - pass - - @abstractmethod - def sees(self, source: Delivered) -> bool: - """Whether this reader may read the message at all.""" - - -@dataclass(frozen=True, slots=True) -class AllOutcomes(OutcomeViewer): - """An unscoped reader sees every recipient's outcome and every human read.""" - - def positions(self, source: Delivered) -> range: - return range(len(source.recipients)) - - def sees_direct_read(self, source: Delivered) -> bool: - return True - - def sees(self, source: Delivered) -> bool: - return True - - -@dataclass(frozen=True, slots=True) -class DisplayOutcomes(AllOutcomes): - """Full operator access, minus only this display's source-matching viewer. - - Incarnation equality owns rename/recreation; the captured bus owns namespace. - The live registry cannot establish either fact for an archived display. - """ - - viewer: ThreadIncarnation - bus_identity: RecordedBusIdentity - - def visible_reads(self, displayed_to: tuple[ThreadIncarnation, ...], - source: RecordedBusIdentity) -> tuple[ThreadIncarnation, ...]: - return tuple(reader for reader in displayed_to - if reader != self.viewer or not self.bus_identity.matches(source)) - - -@dataclass(frozen=True, slots=True) -class OwnerOutcomes(OutcomeViewer): - """A sender sees all outcomes; a recipient sees only its original outcome.""" - - lookup: ThreadLookup - - @classmethod - def of(cls, owner: ThreadProvenance) -> OwnerOutcomes: - return cls(owner.incarnation.require_recorded().lookup) - - def positions(self, source: Delivered) -> range: - if source.sender_lookup == self.lookup: - return range(len(source.recipients)) - position = bisect_left(source.recipients, self.lookup, key=lambda item: item.recipient_lookup) - if position < len(source.recipients) and source.recipients[position].recipient_lookup == self.lookup: - return range(position, position + 1) - return range(0) - - def sees_direct_read(self, source: Delivered) -> bool: - return source.sender_lookup == self.lookup - - def sees(self, source: Delivered) -> bool: - """The message's sender and its original recipients may read it.""" - return self.lookup == source.sender_lookup or bool(self.positions(source)) - - @dataclass(frozen=True, slots=True) class MessageNotification: """Read-only display of one recipient's durable assignment decision.""" diff --git a/src/agent_comms/private_send_claim.py b/src/agent_comms/private_send_claim.py index ec460a3ecc..66c96e4ef8 100644 --- a/src/agent_comms/private_send_claim.py +++ b/src/agent_comms/private_send_claim.py @@ -235,7 +235,7 @@ async def context_committed( """Publish corroborated entry while the model is still running. The context journal is borrowed before coordinator locking, as on the - terminal path. Neither the RPC event nor model activity is a receipt. + terminal path. Neither the RPC event nor model activity proves context inclusion. """ def acknowledge(store: Coordination) -> None: self.verify(store, context, source_reads) diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index e406729f69..c37d4e8d7a 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -16,7 +16,7 @@ import json from abc import ABC, abstractmethod from bisect import bisect_left, bisect_right -from collections.abc import Collection, Iterable, Iterator, Mapping +from collections.abc import Collection, Iterable, Iterator, Mapping, Sequence from contextlib import contextmanager from dataclasses import dataclass, field, replace from itertools import product @@ -28,15 +28,127 @@ from .coordination_errors import DMPaintedBoundChanged, DMScopeChanged, HistoricalSourceChanged from .declared_family import DeclaredFamily from .errors import RelationViolationError, UnregisteredThreadError -from .field_codec import FieldCodec, NullMember, PathText, SingleFieldValue, SubclassUnion +from .field_codec import FieldCodec, NullMember, RecordMember, PathText, SingleFieldValue, SubclassUnion, ThreadLookup from .mentions import ThreadMention from .message_reference import MessageReference +from .notification_evidence import NotificationSelection, RecipientSelection +from .audience_manifest import FrozenRecipient from .private_path import FileObservation, MissingFile from .projection_source import ProducedRow, ProjectionSource from .recorded_bus import RecordedBus, RecordedBusIdentity from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation +class RecipientCursor(SubclassUnion, affix=""): + """Select frozen, lookup-ordered positions before expensive evidence acquisition.""" + + @abstractmethod + def positions(self, source: Delivered) -> range: ... + + +@dataclass(frozen=True) +class FirstRecipient(NullMember, RecipientCursor): + def positions(self, source: Delivered) -> range: + return range(len(source.recipients)) + + +@dataclass(frozen=True) +class AfterRecipient(RecordMember, RecipientCursor): + recipient: FrozenRecipient + + def positions(self, source: Delivered) -> range: + start = bisect_right(source.recipients, self.recipient.recipient_lookup, + key=lambda item: item.recipient_lookup) + return range(start, len(source.recipients)) + + +class OutcomeViewer(ABC): + """Who reads a delivery window decides which of its outcomes are shown.""" + + @abstractmethod + def positions(self, source: Delivered) -> range: ... + + def selection(self, sources: Sequence[Delivered], *, after: RecipientCursor = FirstRecipient(), + limit: int = RecipientSelection.max_limit) -> NotificationSelection: + RecipientSelection.require_limit(limit) + pages = [] + for source in sources: + eligible, following = self.positions(source), after.positions(source) + pages.append(RecipientSelection( + source, range(max(eligible.start, following.start), min(eligible.stop, following.stop)), limit)) + return NotificationSelection(self, tuple(pages)) + + def visible_reads(self, displayed_to: tuple[ThreadIncarnation, ...], + source: RecordedBusIdentity) -> tuple[ThreadIncarnation, ...]: + """Unscoped/operator and agent readers retain every human read mark.""" + return displayed_to + + @abstractmethod + def sees_direct_read(self, source: Delivered) -> bool: + pass + + @abstractmethod + def sees(self, source: Delivered) -> bool: + """Whether this reader may read the message at all.""" + + +@dataclass(frozen=True, slots=True) +class AllOutcomes(OutcomeViewer): + """An unscoped reader sees every recipient's outcome and every human read.""" + + def positions(self, source: Delivered) -> range: + return range(len(source.recipients)) + + def sees_direct_read(self, source: Delivered) -> bool: + return True + + def sees(self, source: Delivered) -> bool: + return True + + +@dataclass(frozen=True, slots=True) +class DisplayOutcomes(AllOutcomes): + """Full operator access, minus only this display's source-matching viewer. + + Incarnation equality owns rename/recreation; the captured bus owns namespace. + The live registry cannot establish either fact for an archived display. + """ + + viewer: ThreadIncarnation + bus_identity: RecordedBusIdentity + + def visible_reads(self, displayed_to: tuple[ThreadIncarnation, ...], + source: RecordedBusIdentity) -> tuple[ThreadIncarnation, ...]: + return tuple(reader for reader in displayed_to + if reader != self.viewer or not self.bus_identity.matches(source)) + + +@dataclass(frozen=True, slots=True) +class OwnerOutcomes(OutcomeViewer): + """A sender sees all outcomes; a recipient sees only its original outcome.""" + + lookup: ThreadLookup + + @classmethod + def of(cls, owner: ThreadProvenance) -> OwnerOutcomes: + return cls(owner.incarnation.require_recorded().lookup) + + def positions(self, source: Delivered) -> range: + if source.sender_lookup == self.lookup: + return range(len(source.recipients)) + position = bisect_left(source.recipients, self.lookup, key=lambda item: item.recipient_lookup) + if position < len(source.recipients) and source.recipients[position].recipient_lookup == self.lookup: + return range(position, position + 1) + return range(0) + + def sees_direct_read(self, source: Delivered) -> bool: + return source.sender_lookup == self.lookup + + def sees(self, source: Delivered) -> bool: + """The message's sender and its original recipients may read it.""" + return self.lookup == source.sender_lookup or bool(self.positions(source)) + + class DisplayViewer(SubclassUnion, affix="Viewer"): """The reader owns read evidence, aliases and cursor selection.""" @@ -152,8 +264,6 @@ def notification_identity(self) -> DisplayIdentity: bus_identity=self.bus_identity) def outcome_viewer(self) -> OutcomeViewer: - from .presentation import DisplayOutcomes - return DisplayOutcomes(self.viewer, self.bus_identity) @@ -213,8 +323,6 @@ def notification_identity(self) -> NoDisplaySelection: return self def outcome_viewer(self) -> OutcomeViewer: - from .presentation import AllOutcomes - return AllOutcomes() def acknowledge(self, views: HistoryViews, page: MessagePage, *, worktree: Checkout) -> None: @@ -318,6 +426,7 @@ def flush(self) -> None: if TYPE_CHECKING: + from .threads import ThreadProvenance import sqlite3 from .private_registry_guard import RegistryPrivacy @@ -330,7 +439,7 @@ def flush(self) -> None: from .message_bus import MessageBus from .message_page import MessagePage, PageBounds from .messages import Message, MessageKind - from .presentation import BusPresentation, OutcomeViewer + from .presentation import BusPresentation from .read_ledger import ReadDocument, ReadLedger from .registration import Registration from .registry_document import RegistrySnapshot, UserThreadLookup diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index fbf9ffaac9..35d1b1611a 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -38,7 +38,8 @@ from .notification_evidence import NotificationAssignmentRead, NotificationWindow from .participant_store import ParticipantSnapshot from .pi_events import TurnContextObserved -from .presentation import MessageNotification, OwnerOutcomes, SentMessageOutcome +from .read_basis import OwnerOutcomes +from .presentation import MessageNotification, SentMessageOutcome from .publisher import ParticipantPublisher from .selected_source_batch import SelectedSource, SelectedSourceBatch from .threads import Thread diff --git a/src/agent_comms/started_input.py b/src/agent_comms/started_input.py index 90e3c0e810..c980ff6d8a 100644 --- a/src/agent_comms/started_input.py +++ b/src/agent_comms/started_input.py @@ -7,6 +7,7 @@ from agent_comms.lifecycle_capability import Unresolved from .coordinator import Coordination +from .field_codec import AcpSessionId if TYPE_CHECKING: from .acp_extension import InputStartedUpdate, QueueScope @@ -36,10 +37,11 @@ async def started(self: TurnInputSource, inputs: InputDrain, session_id: str, sc if any(stored is not None and not stored.items_with(Unresolved, stored) for stored in map(document.rows.get, self.keys)): await inputs.emit_input_delivery_changed(session_id) + session = AcpSessionId(session_id) await inputs.runtime.publish_update( session_id=session_id, update=(await self.acceptance.started_update( - self, inputs, session_id, row, scope, + self, inputs, session, row, scope, )).acp_chunk(), ) - self.acceptance.release(inputs, session_id) + self.acceptance.release(inputs, session) await inputs.emit_queue_state(session_id) diff --git a/src/agent_comms/thread_presentation.py b/src/agent_comms/thread_presentation.py index 264a2c3c92..8b38cca19f 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -258,15 +258,14 @@ def same_observation(self, other: ThreadPresentation) -> bool: def with_sources(self, window: TranscriptSourceWindow) -> ThreadPresentation: """Incoming and sent outcomes are views of one captured notification window.""" - from .notification_assignment import NotificationAssignment - from .presentation import MessageNotification, OwnerOutcomes + from . import notification_assignment, presentation, read_basis transcripts = window.read.owner sources = tuple({source.message.reference: source for source in (*window.sources, *window.sent_sources)}.values()) - viewer = OwnerOutcomes.of(window.read.identity.thread) - notification_read = NotificationAssignment.read(transcripts.root, viewer.selection(sources)) - projected = MessageNotification.delivery_window( + viewer = read_basis.OwnerOutcomes.of(window.read.identity.thread) + notification_read = notification_assignment.NotificationAssignment.read(transcripts.root, viewer.selection(sources)) + projected = presentation.MessageNotification.delivery_window( transcripts.root, transcripts.registry, notification_read, ) incoming = tuple( diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index 451c08fa7d..09bdc93a54 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -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 .read_basis import AllOutcomes, OutcomeViewer, OwnerOutcomes +from .presentation import SentMessageSummary, ThreadRecord from .thread_execution import CommanderForkExecution from .thread_identity import ThreadIncarnation from .tool_result import ToolResult diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index b74f296f04..c6e1c0b07f 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -57,7 +57,8 @@ from .page_traversal import NewestCursor, PageCursor from .pi_payloads import PublishedGoalToolDetails from .pi_vocabulary import ServiceTier, ThinkingLevelField -from .presentation import AfterRecipient, ChannelView, FirstRecipient, MessageNotification, RecipientCursor +from .read_basis import AfterRecipient, FirstRecipient, RecipientCursor +from .presentation import ChannelView, MessageNotification from .notification_evidence import RecipientSelection from .progress_claims import ProgressClaim from .read_basis import NamedViewer diff --git a/src/agent_comms/turn_input_source.py b/src/agent_comms/turn_input_source.py index 90add0e6f3..6a242777a9 100644 --- a/src/agent_comms/turn_input_source.py +++ b/src/agent_comms/turn_input_source.py @@ -15,7 +15,7 @@ from .agent_backend import BusyInputPolicy, SteerInputPolicy from .channel_input_batch import InputBatch, SingleInputBatch from .errors import RelationViolationError -from .field_codec import InputId, TurnId +from .field_codec import AcpSessionId, InputId, TurnId from .input_attempt import InputAlreadyCleared, MissingInput from .input_key import InputKey from .messages import Message @@ -55,12 +55,12 @@ def controller(self, state: DrainedSession, runtime: RuntimeServer) -> Controlle @abstractmethod def require_current( - self, inputs: InputDrain, session_id: str, source: TurnInputSource, + self, inputs: InputDrain, session_id: AcpSessionId, source: TurnInputSource, current: Thread, claim_generation: int, ) -> None: ... async def started_update( - self, source: TurnInputSource, inputs: InputDrain, session_id: str, + self, source: TurnInputSource, inputs: InputDrain, session_id: AcpSessionId, row: input_attempt.StartedInput, scope: QueueScope, ) -> InputStartedUpdate: current = await inputs.queue_binding(session_id) @@ -73,29 +73,29 @@ async def started_update( return source.started_update_of(row, accepted) async def accepted_start( - self, update: InputStartedUpdate, inputs: InputDrain, session_id: str, + self, update: InputStartedUpdate, inputs: InputDrain, session_id: AcpSessionId, ) -> InputStartedUpdate: return update def require_following_source( - self, inputs: InputDrain, session_id: str, source: TurnInputSource, + self, inputs: InputDrain, session_id: AcpSessionId, source: TurnInputSource, ) -> None: raise RelationViolationError("An input without an acceptance cannot own following context") @abstractmethod - def release(self, inputs: InputDrain, session_id: str) -> None: ... + def release(self, inputs: InputDrain, session_id: AcpSessionId) -> None: ... class NoTurnAcceptance(TurnAcceptance): """A scheduled or selected turn has no queued ACP acceptance.""" def require_current( - self, inputs: InputDrain, session_id: str, source: TurnInputSource, + self, inputs: InputDrain, session_id: AcpSessionId, source: TurnInputSource, current: Thread, claim_generation: int, ) -> None: return - def release(self, inputs: InputDrain, session_id: str) -> None: + def release(self, inputs: InputDrain, session_id: AcpSessionId) -> None: return @@ -107,7 +107,7 @@ def controller(self, state: DrainedSession, runtime: RuntimeServer) -> Controlle return state.queued[self.input_id].controller def require_current( - self, inputs: InputDrain, session_id: str, source: TurnInputSource, + self, inputs: InputDrain, session_id: AcpSessionId, source: TurnInputSource, current: Thread, claim_generation: int, ) -> None: try: @@ -123,7 +123,7 @@ def require_current( ).require_valid() async def accepted_start( - self, update: InputStartedUpdate, inputs: InputDrain, session_id: str, + self, update: InputStartedUpdate, inputs: InputDrain, session_id: AcpSessionId, ) -> InputStartedUpdate: queued = inputs.state(session_id).queued if self.input_id in queued: @@ -131,12 +131,12 @@ async def accepted_start( return update.identified(self.input_id) def require_following_source( - self, inputs: InputDrain, session_id: str, source: TurnInputSource, + self, inputs: InputDrain, session_id: AcpSessionId, source: TurnInputSource, ) -> None: if inputs.state(session_id).following.get(self.input_id) is not source: raise RelationViolationError("Following context outlived its actual accepted input") - def release(self, inputs: InputDrain, session_id: str) -> None: + def release(self, inputs: InputDrain, session_id: AcpSessionId) -> None: inputs.state(session_id).queued.pop(self.input_id, None) @@ -177,7 +177,7 @@ def _prepare( # The queue grants a send, not a running tool's lifetime. Once Pi # starts this exact input, StartedInput removes its queue entry. self.acceptance.require_current( - sender.inputs, sender.session_id, self, current, sender.claim_generation, + sender.inputs, AcpSessionId(sender.session_id), self, current, sender.claim_generation, ) except ReservationViolationError as error: return sender._refusal(error.rule) @@ -385,7 +385,7 @@ class FollowingTurnInput(TurnInputSource): owner_check = RunningOrdinaryOwnerCheck def require_context_source(self, sender: OwnedSend) -> None: - self.acceptance.require_following_source(sender.inputs, sender.session_id, self) + self.acceptance.require_following_source(sender.inputs, AcpSessionId(sender.session_id), self) def continue_response(self, response, parts): """An actual following request may revise the turn's automatic answer.""" diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index c083212a9b..575ff91f3c 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -59,9 +59,9 @@ from agent_comms.history_views import RetiredViews from agent_comms.message_reference import MessageReference from agent_comms.notification_evidence import NoNotificationWindow, NotificationWindow, RecipientSelection +from agent_comms.read_basis import AllOutcomes, OutcomeViewer, FirstRecipient, RecipientCursor from agent_comms.presentation import ( - AllOutcomes, CoordinationSnapshot, MessageNotification, NoRevision, OutcomeViewer, ReadState, - FirstRecipient, RecipientCursor, + CoordinationSnapshot, MessageNotification, NoRevision, ReadState, WireRevision, ) from agent_comms.registry_document import RosterVisibility diff --git a/stack/native/native-proof-schema.mjs b/stack/native/native-proof-schema.mjs index 92f335f476..7e45d1e43d 100644 --- a/stack/native/native-proof-schema.mjs +++ b/stack/native/native-proof-schema.mjs @@ -1,7 +1,7 @@ // Rendered at build from NativeContextJournal; never edited. export const nativeProofSchema = { "objects": { - "native_context_journal": "CREATE TABLE \"native_context_journal\" (\"input_id\" TEXT NOT NULL CHECK (\"input_id\" IS NULL OR typeof(\"input_id\") = 'text') CHECK (length(\"input_id\") BETWEEN 32 AND 32) CHECK (\"input_id\" NOT GLOB '*[^0-9a-f]*') CHECK (instr(\"input_id\", char(0)) = 0), \"session_id\" TEXT NOT NULL CHECK (\"session_id\" IS NULL OR typeof(\"session_id\") = 'text'), \"session_entry_id\" TEXT NOT NULL, \"request_generation\" INTEGER NOT NULL CHECK (request_generation BETWEEN 1 AND 9007199254740991), \"llm_context_digest\" TEXT NOT NULL CHECK (\"llm_context_digest\" IS NULL OR typeof(\"llm_context_digest\") = 'text') CHECK (length(\"llm_context_digest\") BETWEEN 64 AND 64) CHECK (\"llm_context_digest\" NOT GLOB '*[^0-9a-f]*') CHECK (instr(\"llm_context_digest\", char(0)) = 0), \"committed_at_ms\" INTEGER NOT NULL CHECK (committed_at_ms BETWEEN 1 AND 9007199254740991), \"schema\" INTEGER NOT NULL CHECK (\"schema\" IN (2)), \"type\" TEXT NOT NULL CHECK (\"type\" IN ('context_committed')), PRIMARY KEY (\"input_id\", \"request_generation\")) STRICT, WITHOUT ROWID", + "native_context_journal": "CREATE TABLE \"native_context_journal\" (\"input_id\" TEXT NOT NULL CHECK (\"input_id\" IS NULL OR typeof(\"input_id\") = 'text') CHECK (length(\"input_id\") BETWEEN 32 AND 32) CHECK (\"input_id\" NOT GLOB '*[^0-9a-f]*') CHECK (instr(\"input_id\", char(0)) = 0), \"session_id\" TEXT NOT NULL CHECK (\"session_id\" IS NULL OR typeof(\"session_id\") = 'text'), \"session_entry_id\" TEXT NOT NULL CHECK (\"session_entry_id\" IS NULL OR typeof(\"session_entry_id\") = 'text'), \"request_generation\" INTEGER NOT NULL CHECK (request_generation BETWEEN 1 AND 9007199254740991), \"llm_context_digest\" TEXT NOT NULL CHECK (\"llm_context_digest\" IS NULL OR typeof(\"llm_context_digest\") = 'text') CHECK (length(\"llm_context_digest\") BETWEEN 64 AND 64) CHECK (\"llm_context_digest\" NOT GLOB '*[^0-9a-f]*') CHECK (instr(\"llm_context_digest\", char(0)) = 0), \"committed_at_ms\" INTEGER NOT NULL CHECK (committed_at_ms BETWEEN 1 AND 9007199254740991), \"schema\" INTEGER NOT NULL CHECK (\"schema\" IN (2)), \"type\" TEXT NOT NULL CHECK (\"type\" IN ('context_committed')), PRIMARY KEY (\"input_id\", \"request_generation\")) STRICT, WITHOUT ROWID", "native_context_journal_request_generation_idx": "CREATE INDEX \"native_context_journal_request_generation_idx\" ON \"native_context_journal\" (\"request_generation\")", "native_context_journal_session_id_idx": "CREATE INDEX \"native_context_journal_session_id_idx\" ON \"native_context_journal\" (\"session_id\")", "native_context_journal_insert_before_abort": "CREATE TRIGGER \"native_context_journal_insert_before_abort\" BEFORE INSERT ON \"native_context_journal\" WHEN NEW.request_generation < COALESCE((SELECT MAX(request_generation)\n FROM native_context_journal), 0) OR EXISTS(SELECT 1 FROM native_context_journal\n WHERE request_generation=NEW.request_generation\n AND (llm_context_digest != NEW.llm_context_digest\n OR committed_at_ms != NEW.committed_at_ms)) OR NEW.session_id\n != (SELECT session_id FROM native_context_journal ORDER BY session_id LIMIT 1) BEGIN SELECT RAISE(ABORT, 'Insert violates row membership'); END", diff --git a/stack/pi-native.sha256 b/stack/pi-native.sha256 index ae1305f692..0ed44a2d97 100644 --- a/stack/pi-native.sha256 +++ b/stack/pi-native.sha256 @@ -22,7 +22,7 @@ fef47246f4ce18621bae844a940e7d009cefc7df7cdf00ccb98198d9c407a696 dist/core/sess 4fa4e9a0ba80f43b560eb827dbd0985ea56624170b51401b39398d76ffcbcefa dist/core/export-html/index.js d7ad93bcb341424fa79014ce2fec4226326cfab1e8d5fee27c1859138e120161 dist/core/sdk.js 37ae95f1f0ca7c88a75f3c83101e3d7b621c15afced8ac8ec287f958f150cb30 dist/core/native-proof-journal.js -42b8245b0f6c25304792047c99046724402658034be26343a26aed6755223646 dist/core/native-proof-schema.js +bc883f29dbb2a9ba342cb4635a483a5d350ee7d8cb208d43d424449776d41177 dist/core/native-proof-schema.js aae7f609bfa65d29ed16e311456921a88221622e08cf6a1f5c7aab8e58bd69e6 node_modules/@earendil-works/pi-ai/dist/api/openai-completions.js 99f2649a743a3bf2b8765b502c04cd8d45bf0f954bb1419b446669c07de0c4db agent-comms-extensions/global-agent-comms/index.mjs 8b9fce004ea6e178729afa9d2252c5031592125191b9bee796663d2b525c21f5 dist/agent-comms-imports.json @@ -56,5 +56,5 @@ d64e8c05c7932d377034820b1db0df45612afe64885c0765f8f348a12e1bc955 dist/core/skil 8e8276bbabcd898136d431345611928626fad6b6f03ac572f1d8506e6ece326f dist/core/system-prompt.d.ts 0391543cf703a93a0163b37b3ccebe53e0f12bff6f6162b86d56fb017fe49011 dist/core/system-prompt.js # agent-comms-native-stock-v1 @earendil-works/pi-coding-agent@0.85.1 -# agent-comms-native-tree-v1 09b00ac37b5c2e652588df901eb61c3b1983248071781cd17c9247f21e782d93 -# agent-comms-native-inputs-v1 4991f959f91f0ea0f2fc10f847303993fe4c95f443bb4e10782cb8bb67fe605a +# agent-comms-native-tree-v1 724dbf68818d1932e6981b737b8e1a8cf4280e592f4d63013306e41e9d15feb8 +# agent-comms-native-inputs-v1 5d32a1691f83443a9c82235830923331f195ce524046354b075d400e08496eb9 diff --git a/tests/c4_coordination_installed_ui.py b/tests/c4_coordination_installed_ui.py index 513345022c..36ef961d91 100644 --- a/tests/c4_coordination_installed_ui.py +++ b/tests/c4_coordination_installed_ui.py @@ -24,7 +24,8 @@ sys.path.insert(0, str(args.toad_tests)) from runtime_fixture import ToadApp, wait_channel_roster from agent_comms.comms import wire -from agent_comms.presentation import AllOutcomes, MessageNotification +from agent_comms.read_basis import AllOutcomes +from agent_comms.presentation import MessageNotification from agent_comms.notification_evidence import NotificationWindow from agent_comms.registry_document import RosterVisibility from agent_comms.threads import Thread diff --git a/tests/native_proof_cases.py b/tests/native_proof_cases.py index 6e233201fb..1b9044c515 100644 --- a/tests/native_proof_cases.py +++ b/tests/native_proof_cases.py @@ -7,11 +7,22 @@ from collections.abc import Iterable from pathlib import Path -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, InputId +from agent_comms.native_entries import NativeEvidenceRead, NativeInputEvidenceRead from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof, NativeContextRecord from agent_comms.native_session_files import NativeSessionFiles +def read_context_proof( + files: NativeSessionFiles, input_id: InputId, *, session_entry_start: int, + generations: range = range(1, 2**53), evidence: NativeEvidenceRead | None = None, +) -> NativeContextProof: + """Keep fixture reads inside the same production byte-reader lifetime.""" + with NativeInputEvidenceRead.borrow(files.session, evidence) as reader: + return reader.context_proof(files, input_id, generations=generations, + session_entry_start=session_entry_start) + + def _context_fields(row: dict[str, object]) -> dict[str, object]: return {key: row[key] for _, key in FieldCodec.record_fields(NativeContextRecord)} diff --git a/tests/reply_relevance_installed_journey.py b/tests/reply_relevance_installed_journey.py index 867c192268..2032815180 100644 --- a/tests/reply_relevance_installed_journey.py +++ b/tests/reply_relevance_installed_journey.py @@ -1,6 +1,6 @@ """One configured saved-helper fork and fresh USER channel request, never a retry.""" -from agent_comms.presentation import AllOutcomes +from agent_comms.read_basis import AllOutcomes from agent_comms.repo import Checkout from agent_comms.channel_targets import CommandTarget diff --git a/tests/test_acp_queue_contract.py b/tests/test_acp_queue_contract.py index b58729414b..3b350537c8 100644 --- a/tests/test_acp_queue_contract.py +++ b/tests/test_acp_queue_contract.py @@ -246,7 +246,7 @@ async def test_original_queued_and_routed_starts_own_their_input_identity(tmp_pa prompt="original", original_display=NoDisplayText(), origins=(), dependency=NoInputDependency(), batch=SingleInputBatch((row,)), ) - started = await original.acceptance.started_update(original, agent.inputs, "beta", row, scope) + started = await original.acceptance.started_update(original, agent.inputs, AcpSessionId("beta"), row, scope) assert type(started) is IdentifiedInputStarted assert started.input_id == input_id and started.native_id == native_id @@ -256,7 +256,7 @@ async def test_original_queued_and_routed_starts_own_their_input_identity(tmp_pa queued_id, "queued text", ) accepted = await AcceptedTurn(queued_id).accepted_start( - UnidentifiedInputStarted(None, scope, 1, native_id), agent.inputs, "beta", + UnidentifiedInputStarted(None, scope, 1, native_id), agent.inputs, AcpSessionId("beta"), ) assert type(accepted) is IdentifiedInputStarted assert accepted.input_id == queued_id and accepted.text == "queued text" @@ -271,6 +271,6 @@ async def test_original_queued_and_routed_starts_own_their_input_identity(tmp_pa batch=SingleInputBatch((routed_row,)), ) started = await routed.acceptance.started_update( - routed, agent.inputs, "beta", routed_row, scope) + routed, agent.inputs, AcpSessionId("beta"), routed_row, scope) assert type(started) is UnidentifiedInputStarted assert not started.started_request(input_id) diff --git a/tests/test_channel_bus_ownership.py b/tests/test_channel_bus_ownership.py index 80db5171b5..1fc01cb4f7 100644 --- a/tests/test_channel_bus_ownership.py +++ b/tests/test_channel_bus_ownership.py @@ -1,6 +1,6 @@ """Channel membership and notifications have one canonical bus authority.""" -from agent_comms.presentation import AllOutcomes +from agent_comms.read_basis import AllOutcomes from agent_comms.repo import Checkout from contextlib import AsyncExitStack from dataclasses import replace diff --git a/tests/test_following_context_notifications.py b/tests/test_following_context_notifications.py index 157a1844ea..522325ed1f 100644 --- a/tests/test_following_context_notifications.py +++ b/tests/test_following_context_notifications.py @@ -101,9 +101,9 @@ def append_generation(self, generation: int) -> FollowingContext: files = self.claim.session.files(self.claim.selected.comms.root) with closing(sqlite3.connect(files.input_proof)) as db, db: original = NativeContextJournal.for_input( - db, self.input_event.input_id, self.context_event.request_generation, + db, self.input_event.input_id, + range(self.context_event.request_generation, self.context_event.request_generation + 1), ) - assert original is not None later = replace(original, request_generation=generation, committed_at_ms=original.committed_at_ms + 1, llm_context_digest='b' * 64) diff --git a/tests/test_harness_receipt_context.py b/tests/test_harness_receipt_context.py index 6cb8054ddc..8e74d4634e 100644 --- a/tests/test_harness_receipt_context.py +++ b/tests/test_harness_receipt_context.py @@ -11,7 +11,7 @@ ObservedMonitor, PersistedOutcome, UnconfirmedOutcome, UnrecordedMonitor, ) from agent_comms.notification_progress import HarnessConsumedProgress -from agent_comms.presentation import AllOutcomes +from agent_comms.read_basis import AllOutcomes from test_coordinated_runtime import _root, tmp_path # noqa: F401 from transcript_record_fixture import transcript_record diff --git a/tests/test_historical_notifications.py b/tests/test_historical_notifications.py index 2d32df90e1..6d62490bf6 100644 --- a/tests/test_historical_notifications.py +++ b/tests/test_historical_notifications.py @@ -1,7 +1,9 @@ """Authored archived deliveries use recorded owners, not live availability.""" -from agent_comms.presentation import AllOutcomes, MessageNotification +from agent_comms.notification_evidence import AssignmentResponse, AnsweredResponse, UnansweredResponse +from agent_comms.read_basis import AllOutcomes +from agent_comms.presentation import MessageNotification from agent_comms.repo import Checkout from agent_comms.bus import Bus from bus_record_fixture import prepare_native_comms @@ -145,7 +147,6 @@ def test_native_context_commit_is_consumed(): def test_response_evidence_rejects_mixed_sql_columns(): - from agent_comms.notification_assignment import AssignmentResponse for values in ((1, None, 2), (None, "message-id", 2), (1, "message-id", None)): with pytest.raises(ValueError): @@ -156,7 +157,6 @@ def test_unshown_assignment_resolves_running_turn_as_timed_queue(): from agent_comms.notification_assignment import ( AssignmentActivityEvidence, NotShownContextEvidence, - UnansweredResponse, ) from agent_comms.notification_progress import QueuedProgress, SentProgress @@ -187,10 +187,7 @@ def test_recorded_engagement_is_not_an_active_turn(archived_notification): old, _live, _source, messages, root_id = archived_notification recipient, = old.bus.bus.delivery(messages[0].seq).recipients assignment = EngagedAssignment.build(FullWake(), "authored-execution", "sender") - from agent_comms.notification_assignment import ( - AssignmentActivity, - UnansweredResponse, - ) + from agent_comms.notification_assignment import AssignmentActivity from agent_comms.notification_progress import ShownToModelProgress activity = AssignmentActivity( @@ -226,11 +223,7 @@ def test_sent_recipient_progresses_through_durable_consumption_and_answer(archiv old, _live, _source, messages, root_id = archived_notification recipient, = old.bus.bus.delivery(messages[0].seq).recipients from agent_comms.message_reference import MessageReference - from agent_comms.notification_assignment import ( - AnsweredResponse, - AssignmentActivity, - UnansweredResponse, - ) + from agent_comms.notification_assignment import AssignmentActivity from agent_comms.activity import ActivityState, ObservedActivity, ReadyDrainReadiness from agent_comms.agent_activity import LiveRecipientActivity from agent_comms.notification_progress import ( diff --git a/tests/test_live_context_notifications.py b/tests/test_live_context_notifications.py index edbb443491..b4785afcdd 100644 --- a/tests/test_live_context_notifications.py +++ b/tests/test_live_context_notifications.py @@ -12,7 +12,7 @@ from agent_comms.field_codec import InputId from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof from agent_comms.native_runtime_input import NativeRuntimeInput -from agent_comms.presentation import AllOutcomes +from agent_comms.read_basis import AllOutcomes from agent_comms.private_send_claim import PrivateSendClaim from agent_comms.private_sidecar import native_request_digest from agent_comms.selected_triage import AbsentTriageDecisionRecord, DecidedTriageRecord, IgnoreSelectedTriage @@ -131,8 +131,8 @@ async def test_later_corroborated_request_preserves_first_context_receipt(tmp_pa files = claim.session.files(claim.selected.comms.root) # Append through the declared journal owner, preserving its original file identity. with closing(sqlite3.connect(files.input_proof)) as db, db: - first = NativeContextJournal.for_input(db, claim.input_id, context.request_generation) - assert first is not None + first = NativeContextJournal.for_input( + db, claim.input_id, range(context.request_generation, context.request_generation + 1)) later = replace(first, request_generation=first.request_generation + 1, llm_context_digest='d' * 64, committed_at_ms=first.committed_at_ms + 1) later.insert(db) diff --git a/tests/test_message_notifications.py b/tests/test_message_notifications.py index 23d86badf8..e997f8af09 100644 --- a/tests/test_message_notifications.py +++ b/tests/test_message_notifications.py @@ -1,5 +1,5 @@ """Read real persisted notification assignments without scheduling a model.""" -from agent_comms.presentation import AllOutcomes +from agent_comms.read_basis import AllOutcomes from agent_comms.repo import Checkout from dataclasses import replace from test_coordinated_runtime import _root, tmp_path # noqa: F401 diff --git a/tests/test_native_evidence_custody.py b/tests/test_native_evidence_custody.py index 883ae20be6..0c88fb1ecf 100644 --- a/tests/test_native_evidence_custody.py +++ b/tests/test_native_evidence_custody.py @@ -13,7 +13,10 @@ from agent_comms.coordinator import Coordination from agent_comms.native_entries import NativeEntry, NativeEvidenceScope from agent_comms.native_failures import NativePiUnavailable -from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof, NativeInputLocation +from agent_comms.native_input_proof import NativeContextJournal, NativeInputLocation +from agent_comms.field_codec import InputId +from agent_comms.native_session_reopen import PiSessionId +from agent_comms.turn_item import FreshNativeId from agent_comms.native_pi import PrivateEvidenceRead from agent_comms.native_transcript import NativeTranscript from agent_comms.store_files import ( @@ -65,9 +68,9 @@ def test_acquired_input_source_reverifies_rewritten_file_from_start(tmp_path, re .replace(INPUT_ID.encode(), restored_input.encode()) .replace(b"separate", b"restored")) _header, original = NativeTranscript(session).forward(0, session.stat().st_size) - location = NativeInputLocation(INPUT_ID, "sid", "entry", session_entry_start=original.start) + location = NativeInputLocation(INPUT_ID, PiSessionId("sid"), FreshNativeId("entry"), session_entry_start=original.start) restored_location = NativeInputLocation( - restored_input, "new", "entry", session_entry_start=original.start, + InputId(restored_input), PiSessionId("new"), FreshNativeId("entry"), session_entry_start=original.start, ) with NativeEvidenceScope() as scope: evidence = scope.for_source(session) @@ -189,13 +192,13 @@ def observe(store): store.participants.get(OWNER_LOOKUP).owner_identity.owner_lookup == OWNER_LOOKUP ) - NativeContextProof.read_evidence(files, INPUT_ID, evidence=evidence, - session_entry_start=input_record.start) + evidence.context_proof(files, INPUT_ID, + session_entry_start=input_record.start) entered.set() assert release.wait(2), 'bounded fixture release missing' assert not evidence.source.stream.closed - NativeContextProof.read_evidence(files, INPUT_ID, evidence=evidence, - session_entry_start=input_record.start) + evidence.context_proof(files, INPUT_ID, + session_entry_start=input_record.start) pending = asyncio.create_task(Coordination.run_async(path, observe, clock_ms=original.session.now)) diff --git a/tests/test_native_input_bus_path.py b/tests/test_native_input_bus_path.py index 31b2398020..7d606a36c7 100644 --- a/tests/test_native_input_bus_path.py +++ b/tests/test_native_input_bus_path.py @@ -20,7 +20,8 @@ from agent_comms.native_package import VerifiedNativePackage from agent_comms.turn_watchdog import ModelWait from agent_comms.native_arguments import NativeInputProofArgument -from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof +from agent_comms.native_input_proof import NativeContextJournal +from native_proof_cases import read_context_proof from agent_comms.native_transcript import NativeTranscript from bus_history import read_history from delivery_owner_fixture import reply_turn_finished @@ -60,7 +61,7 @@ async def test_saved_owner_dm_replies_and_core_reads_exact_native_proof(tmp_path assert len(rows) == 1 record = next(record for record in NativeTranscript(native.session).forward( 0, native.session.stat().st_size) if record.entry.input_id == rows[0].input_id) - proof = NativeContextProof.read_evidence(files, rows[0].input_id, + proof = read_context_proof(files, rows[0].input_id, session_entry_start=record.start) assert proof.session_id == files.session_id assert not agent.turns.turn_state(session).active.phase.busy diff --git a/tests/test_native_input_evidence_index.py b/tests/test_native_input_evidence_index.py index e572fd5a8d..4bb40fda61 100644 --- a/tests/test_native_input_evidence_index.py +++ b/tests/test_native_input_evidence_index.py @@ -4,7 +4,6 @@ import pytest from agent_comms.native_entries import NativeEvidenceScope -from agent_comms.native_input_proof import NativeContextProof from agent_comms.native_session_reopen import NativeSessionIdentity from native_proof_cases import write_proof_rows from test_native_pi import INPUT_ID, _evidence @@ -32,7 +31,7 @@ def test_shared_input_reader_indexes_the_whole_committed_cut(tmp_path, newest_fi evidence = scope.for_source(session) NativeSessionIdentity.from_evidence(evidence) order = (newer, INPUT_ID) if newest_first else (INPUT_ID, newer) - proofs = [NativeContextProof.read_evidence(files, input_id, evidence=evidence, + proofs = [evidence.context_proof(files, input_id, session_entry_start=offsets[input_id]) for input_id in order] assert {proof.input_id for proof in proofs} == {INPUT_ID, newer} diff --git a/tests/test_native_pi.py b/tests/test_native_pi.py index aa2b1ba2f9..f0e53f2c63 100644 --- a/tests/test_native_pi.py +++ b/tests/test_native_pi.py @@ -22,14 +22,15 @@ NativePiTerminalFailure, ) from agent_comms.native_session_files import NativeSessionFiles -from agent_comms.native_session_reopen import NativeSessionIdentity +from agent_comms.native_session_reopen import NativeSessionIdentity, PiSessionId +from agent_comms.turn_item import FreshNativeId from agent_comms.native_startup import NATIVE_STARTUP_POLICY from agent_comms.native_transcript import NativeTranscript from agent_comms.selected_actions import NoSelectedTools from agent_comms.selected_session import SelectedSession from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.tracked_turn import TrackedTurnSession -from native_proof_cases import read_proof_rows, write_proof_rows +from native_proof_cases import read_context_proof, read_proof_rows, write_proof_rows INPUT_ID = InputId('a' * 32) DIGEST = 'b' * 64 @@ -63,11 +64,11 @@ def test_read_only_journal_parser_is_not_recovery_authority(tmp_path): files = _evidence(tmp_path) session = files.session _header, record = NativeTranscript(session).forward(0, session.stat().st_size) - first = NativeContextProof.read_evidence(files, INPUT_ID, session_entry_start=record.start) - assert first == NativeContextProof.read_evidence(files, INPUT_ID, session_entry_start=record.start) + first = read_context_proof(files, INPUT_ID, session_entry_start=record.start) + assert first == read_context_proof(files, INPUT_ID, session_entry_start=record.start) assert (first.session_id, first.session_entry_id, first.request_generation) == ('sid','entry',1) with pytest.raises(NativePiUnavailable): - NativeContextProof.read_evidence(files, 'd'*32, session_entry_start=record.start) + read_context_proof(files, InputId('d'*32), session_entry_start=record.start) class JournalDamage(DeclaredFamily, affix='Damage'): @@ -152,7 +153,7 @@ def test_corrupt_or_redirected_journal_cannot_assert_context(tmp_path, damage): _header, record = NativeTranscript(session).forward(0, session.stat().st_size) damage().alter(files) with pytest.raises(NativePiUnavailable): - NativeContextProof.read_evidence(files, INPUT_ID, session_entry_start=record.start) + read_context_proof(files, INPUT_ID, session_entry_start=record.start) def test_native_journal_declaration_preserves_current_envelope_and_rejects_bad_facts(tmp_path): @@ -163,7 +164,7 @@ def test_native_journal_declaration_preserves_current_envelope_and_rejects_bad_f row = read_proof_rows(files)[0] decoded = FieldCodec.decode(NativeContextJournal, row) assert FieldCodec.encode(decoded) == row - assert decoded.at(session, session_entry_start=record.start) == NativeContextProof.read_evidence( + assert decoded.at(session, session_entry_start=record.start) == read_context_proof( files, INPUT_ID, session_entry_start=record.start) for changed in ( {**row,'schema':True}, {**row,'schema':1}, {**row,'committedAtMs':0}, @@ -174,7 +175,7 @@ def test_native_journal_declaration_preserves_current_envelope_and_rejects_bad_f ): with pytest.raises((ValueError, TypeError)): FieldCodec.decode(NativeContextJournal, changed) - assert NativeContextProof.read_evidence(files, INPUT_ID, session_entry_start=record.start) == decoded.at( + assert read_context_proof(files, INPUT_ID, session_entry_start=record.start) == decoded.at( session, session_entry_start=record.start) assert files.input_proof.read_bytes() == original @@ -187,11 +188,11 @@ def test_proof_reader_rejects_session_id_duplicate_or_untrusted_ancestor(tmp_pat user = json.loads(data.splitlines()[1]) session.write_text(data+json.dumps({**user,'id':'another'})+'\n') with pytest.raises(NativePiUnavailable): - NativeContextProof.read_evidence(files, INPUT_ID, session_entry_start=record.start) + read_context_proof(files, INPUT_ID, session_entry_start=record.start) session.write_text(data) session.parent.chmod(0o777) with pytest.raises(NativePiUnavailable): - NativeContextProof.read_evidence(files, INPUT_ID, session_entry_start=record.start) + read_context_proof(files, INPUT_ID, session_entry_start=record.start) @pytest.mark.parametrize('provider,model', [('', 'model'),('provider',''),('--bad','model')]) @@ -234,7 +235,7 @@ def test_provider_failure_notice_does_not_broadcast_untrusted_error_body(tmp_pat files = _evidence(tmp_path) session = files.session _header, record = NativeTranscript(session).forward(0, session.stat().st_size) - context = NativeContextProof(INPUT_ID,'sid','entry',1,DIGEST,1_791_964_800_001,session, + context = NativeContextProof(INPUT_ID,PiSessionId('sid'),FreshNativeId('entry'),1,DIGEST,1_791_964_800_001,session, session_entry_start=record.start) failed = NativePiTerminalFailure('SECRET provider response including prompt data', context, SelectedModel('provider','model'), ModelRequestFailed('SECRET provider response including prompt data')) @@ -253,23 +254,23 @@ def test_recorded_context_remains_verifiable_after_later_tool_rounds(tmp_path): files = _evidence(tmp_path) session = files.session _header, record = NativeTranscript(session).forward(0, session.stat().st_size) - recorded = NativeContextProof.read_evidence(files, INPUT_ID, session_entry_start=record.start) + recorded = read_context_proof(files, INPUT_ID, session_entry_start=record.start) first = read_proof_rows(files)[0] later = {**first,'requestGeneration':2,'llmContextDigest':'d'*64} with sqlite3.connect(files.input_proof) as db: FieldCodec.decode(NativeContextJournal,later).insert(db) - assert NativeContextProof.read_evidence(files, INPUT_ID, session_entry_start=record.start).request_generation == 2 - assert NativeContextProof.read_evidence(files, INPUT_ID, request_generation=1, session_entry_start=record.start) == recorded + assert read_context_proof(files, INPUT_ID, session_entry_start=record.start).request_generation == 2 + assert read_context_proof(files, INPUT_ID, generations=range(1, 2), session_entry_start=record.start) == recorded with NativeEntry.open_evidence(session) as evidence: assert recorded.corroborates_input(INPUT_ID, files=files, evidence=evidence) assert not recorded.corroborates_input('e'*32, files=files, evidence=evidence) - input_event = InputCommitted(input_id=INPUT_ID,session_id='sid',session_entry_id='entry',session_entry_start=record.start) + input_event = InputCommitted(input_id=INPUT_ID,session_id=PiSessionId('sid'),session_entry_id=FreshNativeId('entry'),session_entry_start=record.start) context_event = ContextCommitted.from_wire({key:value for key,value in first.items() if key != 'schema'}) assert _verify_context(files,INPUT_ID,'sid',input_event,context_event) == recorded with pytest.raises(NativePiUnavailable): _verify_context(files,INPUT_ID,'sid',input_event,replace(context_event,llm_context_digest='d'*64)) with pytest.raises(NativePiUnavailable): - NativeContextProof.read_evidence(files,INPUT_ID,request_generation=3,session_entry_start=record.start) + read_context_proof(files,INPUT_ID,generations=range(3, 4),session_entry_start=record.start) def test_native_proof_database_enforces_generation_and_digest_without_scanning(tmp_path): @@ -283,7 +284,7 @@ def test_native_proof_database_enforces_generation_and_digest_without_scanning(t {**first,'inputId':'d'*32,'requestGeneration':2,'llmContextDigest':'e'*64}): with pytest.raises(sqlite3.IntegrityError): FieldCodec.decode(NativeContextJournal,invalid).insert(db) - assert NativeContextProof.read_evidence(files,INPUT_ID,request_generation=1,session_entry_start=record.start).request_generation == 1 + assert read_context_proof(files,INPUT_ID,generations=range(1, 2),session_entry_start=record.start).request_generation == 1 def test_proof_observation_rejects_file_replacement(tmp_path): diff --git a/tests/test_notification_display_boundary.py b/tests/test_notification_display_boundary.py index 25a61cb279..6ee6968fed 100644 --- a/tests/test_notification_display_boundary.py +++ b/tests/test_notification_display_boundary.py @@ -6,7 +6,7 @@ from dataclasses import replace from agent_comms.notification_reads import RecordedNotificationRead -from agent_comms.presentation import AllOutcomes +from agent_comms.read_basis import AllOutcomes from agent_comms.ui_model.observation import ReadNotifications from test_notification_display_viewer import ArchivedReaders, archived_readers # noqa: F401 diff --git a/tests/test_notification_display_viewer.py b/tests/test_notification_display_viewer.py index 1ee2cad820..2aad70188b 100644 --- a/tests/test_notification_display_viewer.py +++ b/tests/test_notification_display_viewer.py @@ -13,7 +13,8 @@ from agent_comms.messages import Message from agent_comms.notification_evidence import NotificationWindow from agent_comms.notification_progress import DeliveryProgress -from agent_comms.presentation import AllOutcomes, MessageNotification, OutcomeViewer, OwnerOutcomes +from agent_comms.read_basis import AllOutcomes, OutcomeViewer, OwnerOutcomes +from agent_comms.presentation import MessageNotification from agent_comms.registration import Registration from agent_comms.read_basis import HistoricalDisplay, HistoricalSelection, NamedViewer from agent_comms.read_ledger import ReadLedger diff --git a/tests/test_notification_recipient_window.py b/tests/test_notification_recipient_window.py index 0a32501874..a5c10b5141 100644 --- a/tests/test_notification_recipient_window.py +++ b/tests/test_notification_recipient_window.py @@ -13,9 +13,11 @@ from agent_comms.coordination_tables.harness_deliveries import HarnessDelivery from agent_comms.coordinator import Coordination from agent_comms.harness_delivery_states import PersistedOutcome -from agent_comms.notification_assignment import AnsweredResponse, NotificationAssignment +from agent_comms.notification_assignment import NotificationAssignment +from agent_comms.notification_evidence import AnsweredResponse +from agent_comms.read_basis import AllOutcomes, AfterRecipient, OwnerOutcomes from agent_comms.notification_evidence import RecipientSelection -from agent_comms.presentation import AllOutcomes, AfterRecipient, MessageNotification, OwnerOutcomes +from agent_comms.presentation import MessageNotification from agent_comms.read_ledger import ReadLedger from agent_comms.repo import Checkout from agent_comms.threads import Thread diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index 5ab2867250..de455dd98f 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -1,6 +1,6 @@ """Whole conversations derive their original frozen wire sources, in both directions.""" -from agent_comms.presentation import AllOutcomes +from agent_comms.read_basis import AllOutcomes from agent_comms.repo import Checkout from agent_comms.channel_targets import CommandTarget diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 008a6f3c47..ffbfed215b 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -13,7 +13,8 @@ from agent_comms.comms import wire from agent_comms.errors import RelationViolationError from agent_comms.notification_assignment import NotificationAssignment -from agent_comms.presentation import AllOutcomes, MessageNotification, OwnerOutcomes +from agent_comms.read_basis import AllOutcomes, OwnerOutcomes +from agent_comms.presentation import MessageNotification from agent_comms.messages import InfoKind, Message from agent_comms.read_ledger import ReadDocument, ReadLedger from agent_comms.read_basis import ConversationCursor, ReadCursor diff --git a/tests/test_wake_candidate_index.py b/tests/test_wake_candidate_index.py index a22dbbb632..c56fbda6fc 100644 --- a/tests/test_wake_candidate_index.py +++ b/tests/test_wake_candidate_index.py @@ -14,9 +14,8 @@ from agent_comms.input_drain import NoPrivateObservation, PrivateObservation from agent_comms.locked_store import RegistryFile from agent_comms.messages import InfoKind, Message, NoticeRole -from agent_comms.notification_assignment import ( - AnsweredResponse, AssignmentActivity, NotificationAssignment, UnansweredResponse, -) +from agent_comms.notification_assignment import AssignmentActivity, NotificationAssignment +from agent_comms.notification_evidence import AnsweredResponse, UnansweredResponse from agent_comms.notification_progress import ShownToModelProgress from agent_comms.read_basis import ChannelDisplayScope, ConversationCursor from agent_comms.read_ledger import ReadLedger From d333e46ce9c0d6f6c2fe491f9c87b60e152a7236 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 10:22:19 -0400 Subject: [PATCH 074/109] Import Delivered for the annotations that name it read_basis.py annotated twelve signatures with Delivered after the import loop was broken, but never imported it under TYPE_CHECKING, so mypy saw an undefined name in each and debt-src grew TypeErrors by 14. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/agent_comms/read_basis.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index c37d4e8d7a..167c25e8c9 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -431,6 +431,7 @@ def flush(self) -> None: from .private_registry_guard import RegistryPrivacy from .bus import Bus, BusReader, BusSnapshot + from .bus_publication import Delivered from .catalog_document import CatalogDocument from .catalog_store import ChannelCatalog from .channels import Channels, TagChannel From 7645a306791acb9b1e090c597cc9dcf33c5ce90c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 10:50:05 -0400 Subject: [PATCH 075/109] A delivery decides who owes it an answer; read_basis imports Delivered plainly Delivered.projections built a RepliesDisplayScope per recipient on every write just to ask whether the recipient owes the sender an answer: a record asking the display layer a storage fact, and the bus_publication -> read_basis edge that forced read_basis to name Delivered under TYPE_CHECKING. Delivered.awaits_answer_from states the three facts the scope reduced to (the recipient sent it, it was addressed to the recipient, or its response policy names the recipient a required responder); the scope construction, the backwards import and the TYPE_CHECKING import are deleted, and read_basis imports Delivered at module level. test_notification_answers_require_frozen_delivery_not_a_public_answer_reference called NotificationAssignment._recorded_conversation_responses, which f38af5e5e folded into capture_responses; it now asserts the same two behaviours through AllOutcomes().selection and capture_responses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/agent_comms/bus_publication.py | 13 ++++++++----- src/agent_comms/read_basis.py | 2 +- tests/test_wake_candidate_index.py | 31 +++++++++--------------------- 3 files changed, 18 insertions(+), 28 deletions(-) diff --git a/src/agent_comms/bus_publication.py b/src/agent_comms/bus_publication.py index 316e7c6322..ba6f7406b1 100644 --- a/src/agent_comms/bus_publication.py +++ b/src/agent_comms/bus_publication.py @@ -31,7 +31,6 @@ from .field_codec import FieldCodec, MessageId, ThreadLookup from .repo import Checkout -from .read_basis import RepliesDisplayScope from .wake_policy import RecipientDecision if TYPE_CHECKING: @@ -63,15 +62,19 @@ def projections(self): yield SentSource(self.sender_lookup, self.seq) for recipient in self.recipients: yield ReceivedSource(recipient.recipient_lookup, self.seq) - scope = RepliesDisplayScope( - frozenset({recipient.canonical_thread}), frozenset({self.sender}), - ) - if scope.includes(self): + if self.awaits_answer_from(recipient.canonical_thread): yield AwaitingAnswerSource( recipient.recipient_lookup, self.seq, recipient.canonical_thread, self.role.response_policy(self).starts_direct_turn, ) + def awaits_answer_from(self, recipient: str) -> bool: + """Whether this delivery leaves the recipient owing its sender an answer: the + recipient sent it to itself, it was addressed to the recipient directly, or its + response policy names the recipient a required responder.""" + return (self.sender == recipient or self.target == recipient + or bool(self.response_eligibility((recipient,)).required_responders)) + @property def history_record(self) -> Self: """A copied delivery keeps its frozen audience: its recipients received it there too.""" diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 167c25e8c9..1cd847d63b 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -33,6 +33,7 @@ from .message_reference import MessageReference from .notification_evidence import NotificationSelection, RecipientSelection from .audience_manifest import FrozenRecipient +from .bus_publication import Delivered from .private_path import FileObservation, MissingFile from .projection_source import ProducedRow, ProjectionSource from .recorded_bus import RecordedBus, RecordedBusIdentity @@ -431,7 +432,6 @@ def flush(self) -> None: from .private_registry_guard import RegistryPrivacy from .bus import Bus, BusReader, BusSnapshot - from .bus_publication import Delivered from .catalog_document import CatalogDocument from .catalog_store import ChannelCatalog from .channels import Channels, TagChannel diff --git a/tests/test_wake_candidate_index.py b/tests/test_wake_candidate_index.py index c56fbda6fc..8a412feba3 100644 --- a/tests/test_wake_candidate_index.py +++ b/tests/test_wake_candidate_index.py @@ -5,25 +5,21 @@ import pytest -from agent_comms.field_codec import AssignmentId, GoalId, GoalWaitId -from agent_comms.assignment_states import DeliveredAssignment, PendingDecision +from agent_comms.field_codec import GoalId, GoalWaitId from agent_comms.bus import BusRecord, PublicRecord -from agent_comms.coordination_tables.assignments import WakeAssignment from agent_comms.goal_presentation import GoalWaitTarget from agent_comms.goal_waits import RecordedGoalWait from agent_comms.input_drain import NoPrivateObservation, PrivateObservation from agent_comms.locked_store import RegistryFile from agent_comms.messages import InfoKind, Message, NoticeRole -from agent_comms.notification_assignment import AssignmentActivity, NotificationAssignment -from agent_comms.notification_evidence import AnsweredResponse, UnansweredResponse -from agent_comms.notification_progress import ShownToModelProgress -from agent_comms.read_basis import ChannelDisplayScope, ConversationCursor +from agent_comms.notification_assignment import NotificationAssignment +from agent_comms.notification_evidence import AnsweredResponse, NotificationAssignmentRead +from agent_comms.read_basis import AllOutcomes, ChannelDisplayScope, ConversationCursor from agent_comms.read_ledger import ReadLedger from agent_comms.registration import Registration from agent_comms.proven_source_coverage import SourcePrefix from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.wake_candidate_index import WakeCandidateIndex -from agent_comms.wake_policy import FullWake, MessageAudience from bus_record_fixture import append_reply, record_from from agent_comms.threads import Thread @@ -130,24 +126,15 @@ def test_read_prefix_preserves_unpainted_holes_and_compacts_exact_reads(bus): def test_notification_answers_require_frozen_delivery_not_a_public_answer_reference(bus, owner, peer): original = append_reply(bus, peer, owner) - assignment = WakeAssignment( - assignment_id=AssignmentId("shown"), recipient=owner.name, recipient_lookup=owner.lookup, - wire_seq=original.seq, message_id=original.message_id, audience=MessageAudience.DIRECT, - lifecycle=DeliveredAssignment(PendingDecision(FullWake())), - accepted_at_ms=1, updated_at_ms=1) - row = NotificationAssignment(assignment, AssignmentActivity( - False, ShownToModelProgress(1), UnansweredResponse())) + read = NotificationAssignmentRead(AllOutcomes().selection((original,)), ()) + key = (owner.lookup, original.reference) bus.append(lambda seq, db: PublicRecord.from_message(Message( owner.name, peer.name, "public answer is not frozen delivery evidence", InfoKind, seq=seq, answers=(original.reference,)))) - unanswered, = NotificationAssignment._recorded_conversation_responses( - bus.path.parent, (original,), (row,)) - assert isinstance(unanswered.activity.response, UnansweredResponse) + assert key not in NotificationAssignment.capture_responses(bus.path.parent, read).responses reply = append_reply(bus, owner, peer, answers=(original.reference,)) - answered, = NotificationAssignment._recorded_conversation_responses( - bus.path.parent, (original,), (row,)) - assert answered.activity.response == AnsweredResponse( - reply.reference, int(reply.timestamp * 1000)) + assert NotificationAssignment.capture_responses(bus.path.parent, read).responses[key] == ( + AnsweredResponse(reply.reference, int(reply.timestamp * 1000))) def test_wait_finds_dependency_reply_after_its_recorded_sequence(bus, owner, peer): From d09fc16c08887faf6ccc034f13e02cb4aeabb0f4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 11:16:41 -0400 Subject: [PATCH 076/109] Encode the native session entry id at its text column 0dd3db428 declared session_entry_id as NativeContext(FreshNativeId) over TextStorage, but write_context still handed the nominal id straight to TextNativeColumn, whose decoder takes exactly str, so every recorded native context write raised TypeError (six tests in test_live_context_notifications). The column stores the id's text spelling; FieldCodec.encode produces it at the storage boundary, and reads still compare equal by value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/agent_comms/native_runtime_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index 7bd604638e..02ae4b7d20 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -343,7 +343,7 @@ def write_context(self, db: sqlite3.Connection, context: NativeContextProof) -> db, where="input_id=? AND session_entry_id IS NULL", parameters=(self.input_id,), - session_entry_id=TextNativeColumn(context.session_entry_id), + session_entry_id=TextNativeColumn(FieldCodec.encode(context.session_entry_id)), session_entry_start=RecordedNativePosition(context.session_entry_start), request_generation=NumberNativeColumn(context.request_generation), llm_context_digest=TextNativeColumn(context.llm_context_digest), From 6e472d0a7e179da388194531bf54caa8a8c3820e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 11:40:59 -0400 Subject: [PATCH 077/109] Declare transcript page bounds as PagePosition and PageLimit families before/after/through: TranscriptCursor | None restated "no bound" as None across TranscriptPageRead, TranscriptReadIdentity and the page readers. PagePosition (LatestPage, EarlierThan, LaterThan) owns source/offset validation, traversal and fork-start display; PageLimit (AtFrontier, Through) owns the page bound and source resolution. Cursors build their positions and limit; UnreadTranscriptCursor answers latest/frontier and replaces the None that meant "commit required" in TranscriptChangedUpdate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/agent_comms/acp_extension.py | 2 +- src/agent_comms/manual_compaction_bridge.py | 3 +- src/agent_comms/order_tools.py | 18 +- src/agent_comms/transcripts.py | 250 ++++++++++++++------ src/agent_comms/ui_model/observation.py | 9 +- tests/test_codex_earlier_pages.py | 13 +- tests/test_connector.py | 3 +- tests/test_external_transcript.py | 12 +- tests/test_native_output_commit.py | 3 +- tests/test_notification_read_state.py | 6 +- tests/test_outbound_transcript_source.py | 4 +- tests/test_transcript_pages.py | 22 +- tests/test_transcript_partial_tail.py | 5 +- 13 files changed, 233 insertions(+), 117 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 666fec1122..1905428090 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -109,7 +109,7 @@ class TextNoticeUpdate(AgentCommsUpdate): @dataclass(frozen=True) class TranscriptChangedUpdate(AgentCommsUpdate): - cursor: TranscriptCursor | None + cursor: TranscriptCursor class ClaimBlockedFailure(BackendDeliveryFailure): diff --git a/src/agent_comms/manual_compaction_bridge.py b/src/agent_comms/manual_compaction_bridge.py index ac4ed68331..093eaab079 100644 --- a/src/agent_comms/manual_compaction_bridge.py +++ b/src/agent_comms/manual_compaction_bridge.py @@ -17,6 +17,7 @@ from . import agent_events as events from .field_codec import TurnId from .acp_extension import CompactionCommittedUpdate, TranscriptChangedUpdate, encode_updates +from .transcripts import UnreadTranscriptCursor from .agent_backend import ( CompactionOutcome, FailedCompaction, @@ -114,7 +115,7 @@ async def _compact_started(runner, session_id, thread_name, instructions): update=AgentMessageChunk( session_update="agent_message_chunk", content=TextContentBlock(type="text", text=""), - field_meta=encode_updates(TranscriptChangedUpdate(None)), + field_meta=encode_updates(TranscriptChangedUpdate(UnreadTranscriptCursor())), ), ) return outcome diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index 894acf23b5..516363fc32 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -63,7 +63,7 @@ tool_field, ) from .tool_result import ToolResult -from .transcripts import TranscriptCursor, TranscriptRead +from .transcripts import AtFrontier, LatestPage, PageLimit, PagePosition, TranscriptRead from .transcript_events import SentTranscript from .turn_digests import ( ActivityTurnView, @@ -571,11 +571,12 @@ class TranscriptPageRead( 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 + "Send a returned cursor as an earlier_than or later_than position for the " + "next page; historical_source reads an archived source.") + position: Annotated[PagePosition, *tool_field( + "latest_page, or earlier_than/later_than a returned cursor")] = LatestPage() + limit: Annotated[PageLimit, *tool_field( + "at_frontier, or through a cursor already shown")] = AtFrontier() historical_source: Annotated[ str | None, *tool_field("Archived source to read instead of current history") ] = None @@ -583,9 +584,8 @@ class TranscriptPageRead( 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, + position=self.position, + limit=self.limit, historical_source=self.historical_source, ) diff --git a/src/agent_comms/transcripts.py b/src/agent_comms/transcripts.py index 7f98da9363..8ee1c646ed 100644 --- a/src/agent_comms/transcripts.py +++ b/src/agent_comms/transcripts.py @@ -47,6 +47,7 @@ NoWireFrontier, TranscriptDraft, TranscriptSource, + TranscriptTraversal, WireFrontier, ) from .turn_digests import ThreadTurns, TurnBound, TurnDigestRead @@ -72,8 +73,6 @@ class TranscriptCursor(DeclaredFamily, affix="TranscriptCursor"): session_file: ClassVar[str | None] = None offset: ClassVar[int] = 0 wire: ClassVar[WireFrontier] = NoWireFrontier() - # A read position bounds a page; nothing read is no position. - bounds_page: ClassVar[bool] = True def at_offset(self, offset: int) -> TranscriptCursor: return replace(self, offset=offset) @@ -100,13 +99,25 @@ def within(self, other: TranscriptCursor) -> bool: return (self.accepts_source(other) and self.offset <= other.offset and self.wire.within(other.wire)) - async def read_page(self, loader: Callable[..., Awaitable[TranscriptPage]], - **bounds: TranscriptCursor) -> TranscriptPage: - return await loader(**self.page_bounds(bounds), through=self) + def earlier_page(self) -> PagePosition: + """The page that ends where this read position starts.""" + return EarlierThan(self) - @staticmethod - def page_bounds(bounds: dict[str, TranscriptCursor]) -> dict[str, TranscriptCursor]: - return {name: cursor for name, cursor in bounds.items() if cursor.bounds_page} + def later_page(self) -> PagePosition: + """The page that starts where this read position ends.""" + return LaterThan(self) + + def page_limit(self) -> PageLimit: + """A painted read position pins the page to the source it was read from.""" + return Through(self) + + def requires_commit(self) -> bool: + """A changed transcript at a read position needs no commit before it is read again.""" + return False + + async def read_page(self, loader: Callable[..., Awaitable[TranscriptPage]], + position: PagePosition) -> TranscriptPage: + return await loader(position=position, limit=self.page_limit()) def covers_incoming(self, sequence: int) -> bool: # Callers hold a canonical recipient assignment, never an arbitrary seq. @@ -143,12 +154,20 @@ def same_position(self, other: TranscriptCursor) -> bool: def accepts_source(self, other: TranscriptCursor) -> bool: return True - bounds_page: ClassVar[bool] = False + def earlier_page(self) -> PagePosition: + """Nothing read bounds no page: the latest one.""" + return LatestPage() - async def read_page(self, loader: Callable[..., Awaitable[TranscriptPage]], - **bounds: TranscriptCursor) -> TranscriptPage: - # Nothing read pins no source window; the page's own bounds still apply. - return await loader(**self.page_bounds(bounds)) + def later_page(self) -> PagePosition: + return LatestPage() + + def page_limit(self) -> PageLimit: + """Nothing read pins no source window; the page's own frontier applies.""" + return AtFrontier() + + def requires_commit(self) -> bool: + """A replaced source with nothing read must be committed before it is read again.""" + return True def at_offset(self, offset: int) -> TranscriptCursor: return self @@ -226,6 +245,113 @@ class WireTranscriptCursor(NativeTranscriptCursor): wire: AssignedSourceCursor = field() +@dataclass(frozen=True) +class PagePosition(DeclaredFamily, affix=""): + """Where a bounded transcript page starts: the latest page, or one side of a read cursor.""" + + def require_source(self, runtime: RuntimeLocation) -> None: + """The latest page has no cursor that could belong to another source.""" + + def require_offset(self, reader: TranscriptSource, size: int) -> None: + """The latest page has no cursor offset to validate.""" + + def within(self, frontier: TranscriptCursor) -> bool: + return True + + def initial(self, frontier: TranscriptCursor) -> TranscriptCursor: + return frontier + + def traversal(self) -> TranscriptTraversal: + return EarlierTranscript() + + def shows_fork_start(self, frontier: TranscriptCursor) -> bool: + """An inherited thread's fork origin closes its oldest page; the latest page reaches it.""" + return True + + def later_only(self) -> PagePosition: + """A re-read under a new limit keeps only a later-than cursor; any other page starts latest.""" + return LatestPage() + + +@dataclass(frozen=True, slots=True) +class LatestPage(PagePosition): + """The newest page up to the frontier.""" + + +@dataclass(frozen=True, slots=True) +class CursorPage(PagePosition, declared_name=None): + """A page beside a read cursor, which must belong to the page's source and fit its cut.""" + + cursor: TranscriptCursor + + def require_source(self, runtime: RuntimeLocation) -> None: + if self.cursor.session_file != runtime.read_key: + raise TranscriptChanged() + + def require_offset(self, reader: TranscriptSource, size: int) -> None: + if not reader.valid_offset(self.cursor.offset, size): + raise TranscriptChanged() + + def within(self, frontier: TranscriptCursor) -> bool: + return frontier.contains(self.cursor) + + def initial(self, frontier: TranscriptCursor) -> TranscriptCursor: + return self.cursor + + +@dataclass(frozen=True, slots=True) +class EarlierThan(CursorPage): + def shows_fork_start(self, frontier: TranscriptCursor) -> bool: + return self.cursor == frontier + + +@dataclass(frozen=True, slots=True) +class LaterThan(CursorPage): + def traversal(self) -> TranscriptTraversal: + return LaterTranscript() + + def shows_fork_start(self, frontier: TranscriptCursor) -> bool: + return False + + def later_only(self) -> PagePosition: + return self + + +@dataclass(frozen=True) +class PageLimit(DeclaredFamily, affix=""): + """How far a page's source reaches: its live frontier, or a cursor already painted.""" + + def bound(self, frontier: TranscriptCursor) -> TranscriptCursor: + return frontier + + def resolve_source( + self, member: type[TranscriptReadIdentity], owner: Transcripts, name: str, + historical_source: str | None, + ) -> PageSource: + return member.resolve_source(owner, name, None, historical_source) + + +@dataclass(frozen=True, slots=True) +class AtFrontier(PageLimit): + """The page reaches the source's current frontier.""" + + +@dataclass(frozen=True, slots=True) +class Through(PageLimit): + """The page reaches no further than a cursor already painted, in that cursor's source.""" + + cursor: TranscriptCursor + + def bound(self, frontier: TranscriptCursor) -> TranscriptCursor: + return self.cursor + + def resolve_source( + self, member: type[TranscriptReadIdentity], owner: Transcripts, name: str, + historical_source: str | None, + ) -> PageSource: + return member.resolve_source(owner, name, self.cursor.session_file, historical_source) + + @dataclass(frozen=True, slots=True) class TranscriptPage: events: tuple[TranscriptEvent, ...] @@ -275,9 +401,8 @@ class TranscriptReadIdentity(DeclaredFamily, affix="TranscriptReadIdentity"): reply_revision: PublishedReplyRevision read_revision: Annotated[FileObservation, ContentExclude()] wire_frontier: WireFrontier - before: TranscriptCursor | None - after: TranscriptCursor | None - through: TranscriptCursor | None + position: PagePosition + limit: PageLimit def __post_init__(self) -> None: # A decoded witness reads its transcript under the root that published its thread. @@ -329,7 +454,8 @@ def content_bus(self) -> RecordedBusIdentity: @property def page_bound(self) -> TranscriptCursor: """The last native byte and assigned message included in this page.""" - return self.through or self.thread.runtime.transcript_cursor(self.wire_frontier, self.records_cut.offset, source=self.runtime) + return self.limit.bound(self.thread.runtime.transcript_cursor( + self.wire_frontier, self.records_cut.offset, source=self.runtime)) def contains_records_cut(self, other: TranscriptReadIdentity) -> bool: """Whether ``other`` still contains the thread records this read captured.""" @@ -470,8 +596,7 @@ def _read_page(self) -> tuple[TranscriptPage, TranscriptDraft]: return self.owner._thread_transcript_page( self.source, identity=identity, - before=identity.before, - after=identity.after, + position=identity.position, max_messages=20, max_bytes=64 * 1024, through=identity.page_bound, @@ -520,23 +645,22 @@ def bind_page_read( name: str, identity: TranscriptReadIdentity, *, - before: TranscriptCursor | None = None, - after: TranscriptCursor | None = None, - through: TranscriptCursor | None = None, + position: PagePosition = LatestPage(), + limit: PageLimit = AtFrontier(), ) -> TranscriptRead: """Bind the original published witness to this actual source request.""" if identity.root != str(self.root): raise StaleRevision("Transcript witness belongs to another root") - if (identity.before, identity.after, identity.through) != (before, after, through): + if (identity.position, identity.limit) != (position, limit): raise StaleRevision("Transcript witness belongs to another page window") identity.require_binding(self, name) - source = self._page_source(name, identity.page_bound, identity.historical_source) + source = self._page_source(name, Through(identity.page_bound), identity.historical_source) return TranscriptRead(self, identity, source) def capture_native_output( self, name: str, location: NativeRecordLocation, ) -> NativeCommittedPrefix: - page = self._page_source(name, None, None) + page = self._page_source(name, AtFrontier(), None) cut = page.records.committed_output_cut(location) original = NativeRecordLocation(**{ item.name: getattr(location, item.name) @@ -550,15 +674,14 @@ def capture_page_read( self, name: str, *, - before: TranscriptCursor | None = None, - after: TranscriptCursor | None = None, - through: TranscriptCursor | None = None, + position: PagePosition = LatestPage(), + limit: PageLimit = AtFrontier(), historical_source: str | None = None, ) -> TranscriptRead: - page = self._page_source(name, through, historical_source) + page = self._page_source(name, limit, historical_source) frontier = page.projection.frontier identity = self._capture_page_identity( - page, name, frontier, before=before, after=after, through=through, + page, name, frontier, position=position, limit=limit, ) return TranscriptRead(self, identity, page) @@ -568,9 +691,8 @@ def capture_page_window( *, source_limit: int = 1, sent_source_limit: int = 0, - before: TranscriptCursor | None = None, - after: TranscriptCursor | None = None, - through: TranscriptCursor | None = None, + position: PagePosition = LatestPage(), + limit: PageLimit = AtFrontier(), historical_source: str | None = None, ) -> TranscriptSourceWindow: """Expose the source owner's complete identity without parsing its page. @@ -583,10 +705,10 @@ def capture_page_window( raise ValueError("Transcript source capture requires a bounded window") if not 0 <= sent_source_limit <= NotificationWindow.window_limit: raise ValueError("Sent source capture requires a bounded window") - page = self._page_source(name, through, historical_source) + page = self._page_source(name, limit, historical_source) captured = page.projection.window(limit=source_limit, sent_limit=sent_source_limit) identity = self._capture_page_identity( - page, name, captured.cursor, before=before, after=after, through=through, + page, name, captured.cursor, position=position, limit=limit, ) return TranscriptSourceWindow( TranscriptRead(self, identity, page), captured.sources, captured.sent_sources @@ -597,16 +719,15 @@ def recapture_page_identity( ) -> TranscriptReadIdentity: """Recapture identity facts without reconstructing presentation source windows.""" page = self._page_source( - identity.requested_name, identity.through, identity.historical_source + identity.requested_name, identity.limit, identity.historical_source ) frontier = page.projection.frontier return self._capture_page_identity( page, identity.requested_name, frontier, - before=identity.before, - after=identity.after, - through=identity.through, + position=identity.position, + limit=identity.limit, ) def _capture_page_identity( @@ -615,9 +736,8 @@ def _capture_page_identity( name: str, wire_frontier: WireFrontier, *, - before: TranscriptCursor | None, - after: TranscriptCursor | None, - through: TranscriptCursor | None, + position: PagePosition, + limit: PageLimit, ) -> TranscriptReadIdentity: root, thread, records = page.root, page.thread, page.records with records.reading() as captured_records: @@ -635,9 +755,8 @@ def _capture_page_identity( page.projection.reply_revision(records), FileRevision.capture(root / self.bus.reads.path.name), wire_frontier, - before, - after, - through, + position, + limit, ) def turns(self, name: str, bound: TurnBound) -> ThreadTurns: @@ -645,15 +764,13 @@ def turns(self, name: str, bound: TurnBound) -> ThreadTurns: def external(self, name: str) -> ExternalTranscript: """Where a CLI bridge publishes the items of its sessionless bus thread.""" - page = self._page_source(name, None, None) + page = self._page_source(name, AtFrontier(), None) return page.runtime.external_records(page.root, page.thread.incarnation) - def _page_source(self, name: str, through: TranscriptCursor | None, historical_source: str | None) -> PageSource: + def _page_source(self, name: str, limit: PageLimit, historical_source: str | None) -> PageSource: member = (LiveTranscriptReadIdentity if historical_source is None else RecordedTranscriptReadIdentity) - return member.resolve_source( - self, name, through.session_file if through is not None else None, historical_source, - ) + return limit.resolve_source(member, self, name, historical_source) def _thread_transcript_source( self, name: str, source_runtime: str | None = None, *, registry: RegistrySnapshot @@ -681,35 +798,30 @@ def _thread_transcript_page( page: PageSource, *, identity: TranscriptReadIdentity, - before: TranscriptCursor | None, - after: TranscriptCursor | None, + position: PagePosition, max_messages: int, max_bytes: int, - through: TranscriptCursor | None, + through: TranscriptCursor, ) -> tuple[TranscriptPage, TranscriptDraft]: - """Read page and draft while their record owner retains one source lifecycle.""" + """Read page and draft while their record owner retains one source lifecycle; the + page reaches through its witness's painted bound.""" self.page_reads += 1 thread, runtime = page.thread, page.runtime - cursor = before or after - if cursor and cursor.session_file != runtime.read_key: - raise TranscriptChanged() + position.require_source(runtime) source = page.projection with page.records.reading() as reader: size = reader.cut().offset - if cursor and not reader.valid_offset(cursor.offset, size): + position.require_offset(reader, size) + if through.session_file != runtime.read_key or not reader.valid_offset(through.offset, size): raise TranscriptChanged() - if through is not None: - if through.session_file != runtime.read_key or not reader.valid_offset(through.offset, size): - raise TranscriptChanged() - size = through.offset - if cursor and not reader.valid_offset(cursor.offset, size): - raise TranscriptChanged() - frontier = through or thread.runtime.transcript_cursor(source.frontier, size, source=runtime) + size = through.offset + position.require_offset(reader, size) + frontier = through source.require_cursor(frontier) - if frontier.wire.sequence < 0 or (cursor is not None and not frontier.contains(cursor)): + if frontier.wire.sequence < 0 or not position.within(frontier): raise TranscriptChanged() - traversal = LaterTranscript() if after is not None else EarlierTranscript() - initial = cursor or frontier + traversal = position.traversal() + initial = position.initial(frontier) with reader.projection(source, page.routes) as project: consumed, records = traversal.read_records( reader, project, source, initial, frontier, @@ -722,7 +834,7 @@ def _thread_transcript_page( events = traversal.finish(records) if requested_earlier and not events: raise CoordinationReadUnavailable("Waiting for the bridge's earlier transcript page") - if page.inherited and not after and (cursor is None or cursor == frontier): + if page.inherited and position.shows_fork_start(frontier): events = (*events, *self._fork_start_events(thread)) return ( TranscriptPage( diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index 5a0d591406..330bdfbb04 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -567,15 +567,14 @@ def read(self, comms: Comms) -> TranscriptSnapshotUpdate | None: read = comms.transcripts.bind_page_read( identity.requested_name, identity, - before=identity.before, - after=identity.after, - through=identity.through, + position=identity.position, + limit=identity.limit, ) if read.content_current(): return None operation = TranscriptPageRead( - thread=identity.requested_name, before=identity.before, after=identity.after, - through=identity.through, historical_source=identity.historical_source, + thread=identity.requested_name, position=identity.position, limit=identity.limit, + historical_source=identity.historical_source, ) return UnreadableHistory.outcome(lambda: operation.read(comms)).raised() diff --git a/tests/test_codex_earlier_pages.py b/tests/test_codex_earlier_pages.py index a60e0f258e..5e80c9519e 100644 --- a/tests/test_codex_earlier_pages.py +++ b/tests/test_codex_earlier_pages.py @@ -1,4 +1,5 @@ """Real Unix WebSocket replies, watch frames and Core pager; no replaced transports.""" +from agent_comms.transcripts import EarlierThan, Through from agent_comms.repo import Checkout import asyncio import json @@ -80,7 +81,7 @@ async def serve(request): assert [event.text for event in newest.events] == ['recent1', 'recent2'] assert newest.has_older with pytest.raises(CoordinationReadUnavailable): - comms.transcripts.capture_page_read('cli', before=newest.before).read() + comms.transcripts.capture_page_read('cli', position=EarlierThan(newest.before)).read() request = await asyncio.wait_for(frames.get(), 3) # The exact JSON line parser used by the running bridge owns decoding. request = WATCH_LINE.validate_json(request.model_dump_json()) @@ -90,20 +91,20 @@ async def serve(request): await request.handle(bridge, None) assert seen == [None, 'p1'] # Zero is a real external item coordinate, including a pinned read. - older = comms.transcripts.capture_page_read('cli', before=newest.before, - through=newest.before).read() + older = comms.transcripts.capture_page_read('cli', position=EarlierThan(newest.before), + limit=Through(newest.before)).read() assert [event.text for event in older.events] == ['older1', 'older2'] with pytest.raises(CoordinationReadUnavailable): - comms.transcripts.capture_page_read('cli', before=older.before).read() + comms.transcripts.capture_page_read('cli', position=EarlierThan(older.before)).read() request = await asyncio.wait_for(frames.get(), 3) await WATCH_LINE.validate_json(request.model_dump_json()).handle(bridge, None) - oldest = comms.transcripts.capture_page_read('cli', before=older.before).read() + oldest = comms.transcripts.capture_page_read('cli', position=EarlierThan(older.before)).read() assert [event.text for event in oldest.events] == ['oldest'] assert not oldest.has_older assert seen == [None, 'p1', 'p2'] # Existing source coordinates survived both prepends. assert [event.text for event in comms.transcripts.capture_page_read( - 'cli', before=newest.before).read().events] == ['oldest', 'older1', 'older2'] + 'cli', position=EarlierThan(newest.before)).read().events] == ['oldest', 'older1', 'older2'] assert [event.text for event in comms.transcripts.capture_page_read('cli').read().events] == [ 'oldest', 'older1', 'older2', 'recent1', 'recent2'] with transcript.snapshot() as connection: diff --git a/tests/test_connector.py b/tests/test_connector.py index e238f4afab..9aae136cb1 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -1,6 +1,7 @@ """The claude.ai connector: OAuth in front of an MCP transport over the ToolRequest family.""" +from agent_comms.transcripts import EarlierThan from agent_comms.repo import Checkout import asyncio import base64 @@ -323,7 +324,7 @@ async def test_a_stale_transcript_cursor_is_a_failed_call_with_its_coordination_ 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) + stale = TranscriptPageRead(thread="worker", position=EarlierThan(page.before)) with pytest.raises(TranscriptChanged) as changed: stale.read(comms) token = await approved_token(client, comms) diff --git a/tests/test_external_transcript.py b/tests/test_external_transcript.py index 7039e9cea6..e088f87e04 100644 --- a/tests/test_external_transcript.py +++ b/tests/test_external_transcript.py @@ -8,7 +8,7 @@ from agent_comms.comms import wire from agent_comms.field_codec import FieldCodec from agent_comms.transcript_events import AssistantTranscript, NoticeTranscript -from agent_comms.transcripts import TranscriptReadIdentity, UnreadTranscriptCursor +from agent_comms.transcripts import LatestPage, TranscriptReadIdentity, UnreadTranscriptCursor from agent_comms.turn_digests import LastTurns from agent_comms.threads import Thread from agent_comms.turn_item import NativeItemId, NativeTurnId @@ -269,13 +269,13 @@ async def loader(**bounds): return comms.transcripts.capture_page_read("codex", **bounds).read() unread = UnreadTranscriptCursor() - newest = asyncio.run(unread.read_page(loader)) - older = asyncio.run(unread.read_page(loader, before=newest.before)) - later = asyncio.run(unread.read_page(loader, after=older.after)) + newest = asyncio.run(unread.read_page(loader, LatestPage())) + older = asyncio.run(unread.read_page(loader, newest.before.earlier_page())) + later = asyncio.run(unread.read_page(loader, older.after.later_page())) # An unread page's own edges are not bounds: an agent tab opened before any # read still gets the newest page instead of a refused source. - assert asyncio.run(unread.read_page(loader, before=unread)).events == newest.events - assert asyncio.run(newest.after.read_page(loader, before=unread)).events == newest.events + assert asyncio.run(unread.read_page(loader, unread.earlier_page())).events == newest.events + assert asyncio.run(newest.after.read_page(loader, unread.earlier_page())).events == newest.events assert [event.text for event in newest.events] == [f"item {number}" for number in range(1, 21)] assert [event.text for event in older.events] == ["item 0"] assert later.events == newest.events diff --git a/tests/test_native_output_commit.py b/tests/test_native_output_commit.py index 718ce938ab..c3d13e4060 100644 --- a/tests/test_native_output_commit.py +++ b/tests/test_native_output_commit.py @@ -19,6 +19,7 @@ from agent_comms.native_output import AssistantOutput, ToolOutput from agent_comms.pi_events import NativeOutputCommitted, PiEvent from agent_comms.session_file import RegistrySessionLocation +from agent_comms.transcripts import Through from agent_comms.threads import Thread from conftest import installed_comms @@ -56,7 +57,7 @@ def test_output_proof_covers_only_original_prefix_and_survives_later_appends(com fresh = comms.transcripts.capture_page_read('worker').identity assert proof.page_bound.offset == marker.session_entry_end < fresh.page_bound.offset assert proof.contains_native_prefix(fresh) - assert not proof.contains_native_prefix(replace(fresh, through=fresh.page_bound.at_offset(marker.session_entry_end - 1))) + assert not proof.contains_native_prefix(replace(fresh, limit=Through(fresh.page_bound.at_offset(marker.session_entry_end - 1)))) assert not proof.contains_native_prefix(replace(fresh, root='/different/source')) assert not proof.contains_native_prefix(replace(fresh, thread=replace(fresh.thread, created_at=fresh.thread.created_at.to_wire() + 1))) diff --git a/tests/test_notification_read_state.py b/tests/test_notification_read_state.py index bb6b4a195a..4f3cbccafe 100644 --- a/tests/test_notification_read_state.py +++ b/tests/test_notification_read_state.py @@ -36,7 +36,7 @@ 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.transcripts import EarlierThan, NativeTranscriptCursor from agent_comms.ui_model.observation import ( ObserveNotifications, ReadNotifications, ReadOperation, ReadOwnerOperation, ReadTranscript, ReadTranscriptUpdate, RefreshTranscript, SubscribeTranscript, @@ -279,7 +279,7 @@ def test_notification_rank_overrides_follow_the_mro_without_changing_parent_rank 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), + TranscriptPageRead(thread="worker", position=EarlierThan(NativeTranscriptCursor("session.jsonl", 7)), historical_source="archive"), ] @@ -315,7 +315,7 @@ def test_witnesses_cursors_and_archive_each_distinguish_a_shared_read_request(): 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], position=EarlierThan(NativeTranscriptCursor("session.jsonl", 8)))), replace(page, operation=replace(SHARED_UI_READS[1], historical_source="other-archive")), ) assert len({commander, *distinct}) == 1 + len(distinct) diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index 8210278745..113557c91f 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -24,7 +24,7 @@ from agent_comms.pi_payloads import PublishedMessageToolDetails from agent_comms.tool_caller import ThreadCaller from agent_comms.tools import invoke_tool -from agent_comms.transcripts import TranscriptRead +from agent_comms.transcripts import EarlierThan, TranscriptRead from test_coordinated_runtime import _root, tmp_path # noqa: F401 from agent_comms.threads import Thread @@ -322,7 +322,7 @@ def test_published_source_binding_keeps_exact_original_request(tmp_path): # noq with pytest.raises(StaleRevision): comms.transcripts.bind_page_read( "beta", identity, - before=identity.page_bound.at_offset(0), + position=EarlierThan(identity.page_bound.at_offset(0)), ) comms.registry.rename("beta", "renamed") assert comms.transcripts.bind_page_read("renamed", identity).identity is identity diff --git a/tests/test_transcript_pages.py b/tests/test_transcript_pages.py index 38fa13ed2d..abc3badf69 100644 --- a/tests/test_transcript_pages.py +++ b/tests/test_transcript_pages.py @@ -10,7 +10,7 @@ from conftest import installed_comms from agent_comms.threads import Thread from agent_comms.thread_identity import RecordedThreadIncarnation -from agent_comms.transcripts import NativeTranscriptCursor, WireTranscriptCursor +from agent_comms.transcripts import EarlierThan, LaterThan, NativeTranscriptCursor, Through, WireTranscriptCursor from agent_comms.thread_text import ForkedParent, GivenThreadText from agent_comms.acp_extension import TranscriptSnapshotUpdate from agent_comms.field_codec import FieldCodec @@ -45,7 +45,7 @@ def test_pages_reach_beginning_and_return_to_tail_without_duplicates(tmp_path): assert seen == [f"Record {i}" for i in range(85, 105)] while page.has_older: page = comms.transcripts.capture_page_read( - "worker", before=page.before).read() + "worker", position=EarlierThan(page.before)).read() seen[:0] = [event.text for event in page.events] assert seen == [f"Record {i}" for i in range(105)] assert page.before.offset == 0 @@ -58,7 +58,7 @@ def test_pages_reach_beginning_and_return_to_tail_without_duplicates(tmp_path): seen = [event.text for event in page.events] while page.has_newer: page = comms.transcripts.capture_page_read( - "worker", after=page.after, through=through).read() + "worker", position=LaterThan(page.after), limit=Through(through)).read() seen.extend(event.text for event in page.events) assert seen == [f"Record {i}" for i in range(105)] @@ -67,8 +67,8 @@ def shared_page(comms, operation: TranscriptPageRead) -> TranscriptSnapshotUpdat """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)) + operation.thread, position=operation.position, limit=operation.limit, + 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_fields(operation), @@ -86,14 +86,14 @@ def test_one_page_operation_pages_alike_for_the_ui_and_mcp(tmp_path): 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)) + snapshot = shared_page(comms, TranscriptPageRead(thread="worker", position=EarlierThan(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)) + thread="worker", position=LaterThan(snapshot.page.after), limit=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))) @@ -115,9 +115,9 @@ def test_oversized_message_and_file_identity(tmp_path): assert not page.has_older comms.registry.declare(replace(thread, session_file=RegistrySessionLocation.from_path(str(tmp_path / "other.jsonl"), comms.root))) with pytest.raises(TranscriptChanged): - comms.transcripts.capture_page_read("worker", before=page.before).read() + comms.transcripts.capture_page_read("worker", position=EarlierThan(page.before)).read() with pytest.raises(TranscriptChanged): - comms.transcripts.capture_page_read("worker", before=NativeTranscriptCursor(str(path), -1)).read() + comms.transcripts.capture_page_read("worker", position=EarlierThan(NativeTranscriptCursor(str(path), -1))).read() def test_empty_transcript_and_non_message_records(tmp_path): @@ -179,7 +179,7 @@ def test_inherited_scroll_window_survives_child_session_persistence(tmp_path): seen = [event.text for event in page.events if event.declared_name == "assistant"] while page.has_older: page = comms.transcripts.capture_page_read( - "child", before=page.before, through=through).read() + "child", position=EarlierThan(page.before), limit=Through(through)).read() seen[:0] = [event.text for event in page.events if event.declared_name == "assistant"] assert seen == [f"Record {i}" for i in range(35)] assert comms.transcripts.capture_page_read("child").read().after.session_file == str(own_path) @@ -187,7 +187,7 @@ def test_inherited_scroll_window_survives_child_session_persistence(tmp_path): transcript(unrelated, 1) with pytest.raises(TranscriptChanged): comms.transcripts.capture_page_read( - "child", through=NativeTranscriptCursor(str(unrelated), unrelated.stat().st_size)).read() + "child", limit=Through(NativeTranscriptCursor(str(unrelated), unrelated.stat().st_size))).read() def test_wire_cursor_keeps_the_furthest_committed_native_prefix_within_it(): diff --git a/tests/test_transcript_partial_tail.py b/tests/test_transcript_partial_tail.py index e871ffee2e..170cb0eeb7 100644 --- a/tests/test_transcript_partial_tail.py +++ b/tests/test_transcript_partial_tail.py @@ -1,5 +1,6 @@ """A line still being appended is not history yet, so a newer read never stalls on it.""" +from agent_comms.transcripts import LaterThan, Through from agent_comms.repo import Checkout import json @@ -30,12 +31,12 @@ def test_newer_read_moves_past_a_line_still_being_appended(tmp_path, private_com newest = comms.transcripts.capture_page_read("saved").read() assert newest.after.offset == complete through = newest.after - stalled = comms.transcripts.capture_page_read("saved", after=through, through=through).read() + stalled = comms.transcripts.capture_page_read("saved", position=LaterThan(through), limit=Through(through)).read() assert (stalled.after, stalled.has_newer) == (through, False) with session.open("a") as stream: stream.write(pending[len(pending) // 2:]) grown = comms.transcripts.capture_page_read("saved").read().after - newer = comms.transcripts.capture_page_read("saved", after=through, through=grown).read() + newer = comms.transcripts.capture_page_read("saved", position=LaterThan(through), limit=Through(grown)).read() assert newer.after == grown assert [event.text.startswith("Second answer") for event in newer.events] == [True] From 6e5bdc2e86c4ffb746f91dfdc17c88d4869f9dd2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 11:56:03 -0400 Subject: [PATCH 078/109] Show model-consumed state for inputs a turn already took into context A channel message that an ordinary or following turn took into the model stayed Sent: its ContextConsumedInput row was never read by notification capture, and SourceEvidence folded only harness attempts. - DeliveryProgress.harness_consumed(ms) becomes model_consumed(proof): one transition, two leaves. Harness transcripts pass HarnessConsumedProgress, ContextConsumedInput passes ShownToModelProgress at its committed time, the same member native context commits already map to. - ConsumptionEvidence declares consumed_progress once; HarnessDelivery and InputAttempt inherit it, and SourceEvidence folds attempts and inputs through it. - NotificationAssignment.capture_inputs reads the selection's exact InputDispositions keys once per window after the coordinator snapshot; capture_detached joins it with capture_responses for both callers. - The following-context journey asserts the projected state of every original and following key; its fixture entry id is a FreshNativeId. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- .../coordination_tables/harness_deliveries.py | 3 +- src/agent_comms/harness_delivery_states.py | 3 +- src/agent_comms/input_attempt.py | 10 +++++- src/agent_comms/notification_assignment.py | 23 +++++++++++++- src/agent_comms/notification_evidence.py | 28 ++++++++++++++--- src/agent_comms/notification_progress.py | 20 ++++++------ src/agent_comms/registry_provenance.py | 7 +++++ src/agent_comms/selected_participant.py | 2 +- tests/test_following_context_notifications.py | 31 +++++++++++++++++-- tests/test_historical_notifications.py | 3 +- 10 files changed, 107 insertions(+), 23 deletions(-) diff --git a/src/agent_comms/coordination_tables/harness_deliveries.py b/src/agent_comms/coordination_tables/harness_deliveries.py index 91888f8036..8e078abaf5 100644 --- a/src/agent_comms/coordination_tables/harness_deliveries.py +++ b/src/agent_comms/coordination_tables/harness_deliveries.py @@ -19,6 +19,7 @@ from agent_comms.harness_delivery_states import MonitorEvidence, Outcome, UnrecordedMonitor from agent_comms.harness_install import HarnessSession from agent_comms.message_reference import MessageReference +from agent_comms.notification_evidence import ConsumptionEvidence from agent_comms.typed_table import Column, ForeignKey, FrozenColumn, ImmutableRows, Index, NoDelete if TYPE_CHECKING: @@ -28,7 +29,7 @@ @dataclass(frozen=True, slots=True) -class HarnessDelivery(NoDelete, CoordinatorRow, declared_name="harness_deliveries"): +class HarnessDelivery(NoDelete, CoordinatorRow, ConsumptionEvidence, declared_name="harness_deliveries"): """One original attempt to put a bus message into a recipient's harness session.""" bridge: Annotated[type[HarnessSession], FrozenColumn(primary_key=True)] diff --git a/src/agent_comms/harness_delivery_states.py b/src/agent_comms/harness_delivery_states.py index 539625c970..b01838d7ab 100644 --- a/src/agent_comms/harness_delivery_states.py +++ b/src/agent_comms/harness_delivery_states.py @@ -15,6 +15,7 @@ from .channel_targets import CommandTarget from .declared_family import DeclaredFamily from .field_codec import TranscriptRecordId +from .notification_progress import HarnessConsumedProgress if TYPE_CHECKING: from .assignment_states import AssignmentState @@ -205,7 +206,7 @@ def evidence(self, recorded: type[Outcome]) -> tuple[type[Outcome], MonitorEvide return DeliveredOutcome, self def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: - return progress.harness_consumed(round(self.timestamp * 1000)) + return progress.model_consumed(HarnessConsumedProgress(round(self.timestamp * 1000))) def claim_steps(self, state: AssignmentState) -> tuple[type[AssignmentState], ...]: return state.harness_delivery() diff --git a/src/agent_comms/input_attempt.py b/src/agent_comms/input_attempt.py index 5ed6106244..4a4de8f1d7 100644 --- a/src/agent_comms/input_attempt.py +++ b/src/agent_comms/input_attempt.py @@ -30,6 +30,8 @@ from .input_key import InputKey, InputKeyText, PublicInputKeyText from .lifecycle_capability import LifecycleCapability, Unresolved from .native_input_proof import NativeContextProof +from .notification_evidence import ConsumptionEvidence +from .notification_progress import DeliveryProgress, ShownToModelProgress from .reported_turn import ReportedTurnState from .text_digest import TextDigest from .thread_identity import GenerationCounter, ThreadIncarnation @@ -91,7 +93,7 @@ def require_moved(self, refusal: str) -> MovedInput: @dataclass(frozen=True) -class InputAttempt(LifecycleCapability, DeclaredFamily, affix="Input"): +class InputAttempt(LifecycleCapability, ConsumptionEvidence, DeclaredFamily, affix="Input"): exists: ClassVar[bool] = False accepts_reservation: ClassVar[bool] = False has_started: ClassVar[bool] = False @@ -135,6 +137,9 @@ def context_provenance(self) -> InputProvenance: def model_contexts(self) -> tuple[NativeContextProof, ...]: return () + def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: + return progress + def context_committed( self, lease: TurnLeaseFence, sent_text: str, context: NativeContextProof, *, snapshot: RegistrySnapshot, @@ -477,6 +482,9 @@ class ContextConsumedInput(StartedInput): def model_contexts(self) -> tuple[NativeContextProof, ...]: return (self.context,) + def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: + return progress.model_consumed(ShownToModelProgress(self.context.committed_at_ms)) + def record_context(self, context: NativeContextProof) -> InputTransition: self.context.require_continuation(context) return UnchangedInput(self) diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index dcf83537b7..4edfa275a2 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -19,6 +19,7 @@ from .coordination_tables.publications import PublicationEvidence from .declared_family import DeclaredFamily from .field_codec import FieldCodec, ThreadLookup, boundary_decoder +from .input_disposition import InputDispositions from .message_reference import MessageReference from .notification_progress import ( AssignmentProgress, @@ -224,6 +225,26 @@ def capture_responses(cls, root: Path, read: NotificationAssignmentRead) -> Noti responses[key] = responses.get(key, UnansweredResponse()).recorded(reply.message) return replace(read, responses=MappingProxyType(responses)) + @classmethod + def capture_inputs(cls, root: Path, read: NotificationAssignmentRead) -> NotificationAssignmentRead: + """Capture the selected recipients' stored inputs after releasing the coordinator snapshot. + + An ordinary or following turn that took the original into the model proves it by its + own input row, keyed as the delivery recorded it; one keyed read covers the window. + """ + registry = RegistryStore.at(root).snapshot() + keys = {InputDispositions.bus_key(page.source.message, thread): + (recipient.recipient_lookup, page.source.message.reference) + for page in read.selection.pages for recipient in page.recipients + for thread in registry.recipient(recipient, page.source).input_owners()} + rows = InputDispositions(root).read(tuple(keys)).rows + return replace(read, inputs=MappingProxyType({keys[key]: row for key, row in rows.items()})) + + @classmethod + def capture_detached(cls, root: Path, read: NotificationAssignmentRead) -> NotificationAssignmentRead: + """Every bus-side evidence read that follows the coordinator snapshot.""" + return cls.capture_inputs(root, cls.capture_responses(root, read)) + @classmethod def capture( cls, connection: sqlite3.Connection, selection: NotificationSelection, @@ -285,7 +306,7 @@ def read( cls, selection, privacy, ) as result: captured = result - return cls.capture_responses(root, captured) + return cls.capture_detached(root, captured) @classmethod def missing_store( diff --git a/src/agent_comms/notification_evidence.py b/src/agent_comms/notification_evidence.py index 059b4e5b5f..af007a8de3 100644 --- a/src/agent_comms/notification_evidence.py +++ b/src/agent_comms/notification_evidence.py @@ -27,6 +27,14 @@ from .presentation import MessageNotification, WireRevision +class ConsumptionEvidence(ABC): + """A recorded fact about one delivery that may prove it entered the model's context.""" + + @abstractmethod + def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: + """The delivery's progress after this fact; only a context proof moves it.""" + + @dataclass(frozen=True) class AssignmentResponse(DeclaredFamily, affix="Response"): def recorded(self, message: Delivered) -> AssignmentResponse: @@ -87,11 +95,12 @@ def answered(self, notification: MessageNotification) -> MessageNotification: @dataclass(frozen=True) class SourceEvidence: - """One original's claims, attempts and independent responses, grouped by frozen - recipient. Equality over all three is the window's change test.""" + """One original's claims, harness attempts, stored inputs and independent responses, + grouped by frozen recipient. Equality over all of them is the window's change test.""" claims: tuple[tuple[WakeAssignment, AssignmentActivityEvidence], ...] = () attempts: Mapping[ThreadLookup, tuple[HarnessDelivery, ...]] = field(default_factory=dict) + inputs: Mapping[ThreadLookup, tuple[ConsumptionEvidence, ...]] = field(default_factory=dict) responses: Mapping[ThreadLookup, AssignmentResponse] = field(default_factory=lambda: MappingProxyType({})) def answered(self, recipient: ThreadLookup, notification: MessageNotification) -> MessageNotification: @@ -99,8 +108,9 @@ def answered(self, recipient: ThreadLookup, notification: MessageNotification) - def consumed_progress(self, recipient: ThreadLookup, progress: DeliveryProgress) -> DeliveryProgress: - """The recipient's progress after each of its own attempts on this original.""" - for attempt in self.attempts.get(recipient, ()): + """The recipient's progress after each of its own harness attempts and stored inputs + of this original.""" + for attempt in (*self.attempts.get(recipient, ()), *self.inputs.get(recipient, ())): progress = attempt.consumed_progress(progress) return progress @@ -166,6 +176,8 @@ class NotificationAssignmentRead: attempts: tuple[HarnessDelivery, ...] = () responses: Mapping[tuple[ThreadLookup, MessageReference], AssignmentResponse] = field( default_factory=lambda: MappingProxyType({})) + inputs: Mapping[tuple[ThreadLookup, MessageReference], ConsumptionEvidence] = field( + default_factory=lambda: MappingProxyType({})) @property def sources(self) -> tuple[Delivered, ...]: @@ -183,15 +195,21 @@ def evidence(self) -> Mapping[MessageReference, SourceEvidence]: responses: dict[MessageReference, dict[ThreadLookup, AssignmentResponse]] = { source.message.reference: {} for source in self.sources } + inputs: dict[MessageReference, dict[ThreadLookup, tuple[ConsumptionEvidence, ...]]] = { + source.message.reference: {} for source in self.sources + } for (lookup, source), response in self.responses.items(): responses[source][lookup] = response + for (lookup, source), stored in self.inputs.items(): + inputs[source][lookup] = (stored,) for row in self.rows: claims.setdefault(row[0].source, []).append(row) for attempt in self.attempts: attempts[attempt.reference].setdefault(attempt.recipient_lookup, []).append(attempt) return {source: SourceEvidence(tuple(claims[source]), { lookup: tuple(rows) for lookup, rows in attempts[source].items()}, - MappingProxyType(responses[source])) + responses=MappingProxyType(responses[source]), + inputs=MappingProxyType(inputs[source])) for source in claims} def differs_from(self, fresh: Mapping[MessageReference, SourceEvidence]) -> bool: diff --git a/src/agent_comms/notification_progress.py b/src/agent_comms/notification_progress.py index 7e89670745..0585288323 100644 --- a/src/agent_comms/notification_progress.py +++ b/src/agent_comms/notification_progress.py @@ -78,9 +78,9 @@ def answered_notification(self, notification: MessageNotification, return replace(notification, state=AnsweredProgress(self), answer=reference, answered_at_ms=answered_at_ms, changed=True) - def harness_consumed(self, observed_at_ms: int) -> DeliveryProgress: - """Retain the original complete-body transcript observation time.""" - return HarnessConsumedProgress(observed_at_ms) + def model_consumed(self, proof: ShownToModelProgress) -> DeliveryProgress: + """The first proof that the message entered the model's context, at its own time.""" + return proof class ConsumedDelivery(SettledDelivery): @@ -90,8 +90,8 @@ class ConsumedDelivery(SettledDelivery): ignored_label: ClassVar[str] = "Seen, not answering" consumed: ClassVar[bool] = True - def harness_consumed(self, observed_at_ms: int) -> Self: - """An earlier proof already timed the entry; a harness confirmation adds nothing.""" + def model_consumed(self, proof: ShownToModelProgress) -> Self: + """An earlier proof already timed the entry; a later one adds nothing.""" return self def working_notification_label(self, notification: MessageNotification) -> str: @@ -122,16 +122,16 @@ class ContextualProgress(WaitingDelivery, DeliveryProgress): def consumed(self) -> bool: return self.proof.consumed - def harness_consumed(self, observed_at_ms: int) -> DeliveryProgress: - return replace(self, proof=self.proof.harness_consumed(observed_at_ms)) + def model_consumed(self, proof: ShownToModelProgress) -> DeliveryProgress: + return replace(self, proof=self.proof.model_consumed(proof)) class PendingProgress(ContextualProgress): - """A pending claim adds no label of its own: once a harness proves entry, the proof is + """A pending claim adds no label of its own: once entry is proven, the proof is the whole state.""" - def harness_consumed(self, observed_at_ms: int) -> DeliveryProgress: - return self.proof.harness_consumed(observed_at_ms) + def model_consumed(self, proof: ShownToModelProgress) -> DeliveryProgress: + return self.proof.model_consumed(proof) class PassiveProgress(ContextualProgress): diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index b64031fed8..adfb14e5d2 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -50,6 +50,10 @@ def activity(self, observations, snapshot): ... @abstractmethod def read_effect(self, reads, message, snapshot, *, document=None): ... + def input_owners(self) -> tuple[Thread, ...]: + """The live thread whose stored inputs this recipient's deliveries are keyed by.""" + return () + @dataclass(frozen=True, slots=True) class KnownRecipient(RecipientSelection): @@ -68,6 +72,9 @@ class RegisteredRecipient(KnownRecipient): def activity(self, observations, snapshot): return self.thread.execution.observe_recipient(observations, snapshot, self.thread) + def input_owners(self) -> tuple[Thread, ...]: + return (self.thread,) + @dataclass(frozen=True, slots=True) class ArchivedRecipient(KnownRecipient): diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index 9b7c959871..a63ca9646b 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -252,7 +252,7 @@ def prepare( sent_read = NotificationAssignment.capture( store.session._connection, OwnerOutcomes.of(sent_owner).selection(sent_sources), privacy, ) - sent_read = NotificationAssignment.capture_responses(comms.root, sent_read) + sent_read = NotificationAssignment.capture_detached(comms.root, sent_read) if pending: participant.pointer.require_idle() sent_outcomes = SelectedParticipant.capture_sent_outcomes( diff --git a/tests/test_following_context_notifications.py b/tests/test_following_context_notifications.py index 522325ed1f..e6690b27ee 100644 --- a/tests/test_following_context_notifications.py +++ b/tests/test_following_context_notifications.py @@ -20,15 +20,19 @@ from agent_comms.input_disposition import InputDispositions from agent_comms.input_key import InputKey from agent_comms.input_origin import WireInputOrigin +from agent_comms.message_reference import MessageReference from agent_comms.native_failures import NativePiUnavailable from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof from agent_comms.native_tool_call import OwnerToolConnection +from agent_comms.notification_progress import DeliveryProgress, ShownToModelProgress from agent_comms.pi_commands import FollowingPrompt from agent_comms.backend import ObservedNativeCommit, TrackedInputContext +from agent_comms.turn_item import FreshNativeId from agent_comms.turn_lease import TurnLeaseFence from agent_comms.native_runtime_input import NativeRuntimeInput from agent_comms.pi_events import ContextCommitted, InputCommitted from agent_comms.private_send_claim import PrivateSendClaim +from agent_comms.read_basis import AllOutcomes from agent_comms.private_sidecar import native_request_digest from conftest import accept_initial, publish_initial from native_proof_cases import append_input_entry @@ -48,6 +52,7 @@ async def following_session(directory: Path) -> AsyncIterator[PrivateSendClaim]: class FollowingContext: claim: PrivateSendClaim keys: tuple[InputKey, ...] + references: tuple[MessageReference, ...] text: str input_event: InputCommitted context_event: ContextCommitted @@ -61,6 +66,10 @@ def dispositions(self) -> InputDispositions: def lease(self) -> TurnLeaseFence: return self.claim.owner.thread.require_turn_lease() + def notification_states(self) -> tuple[DeliveryProgress, ...]: + """The projected state of each exact original for its one frozen recipient.""" + return notification_states(self.claim, self.references) + def start(self) -> None: for key in self.keys: assert self.dispositions.started( @@ -114,6 +123,17 @@ def append_generation(self, generation: int) -> FollowingContext: )) +def notification_states( + claim: PrivateSendClaim, references: tuple[MessageReference, ...], +) -> tuple[DeliveryProgress, ...]: + window = claim.selected.comms.views.message_notifications_for_references(AllOutcomes(), references) + owner = claim.owner.thread.incarnation.require_recorded().lookup + rows = tuple(row for reference in references + for row in window.outcomes[(reference.seq, reference.message_id)]) + assert [row.recipient_identity.recipient_lookup for row in rows] == [owner] * len(references) + return tuple(row.state for row in rows) + + def following_input(claim: PrivateSendClaim, *, generation: int, count: int = 1) -> FollowingContext: """Reserve and bind originals through InputDispositions, then retain native evidence. @@ -142,7 +162,7 @@ def following_input(claim: PrivateSendClaim, *, generation: int, count: int = 1) native_id=native_id, text=text, ) session = claim.session.identity - entry = f'following-{native_id}' + entry = FreshNativeId(f'following-{native_id}') start = append_input_entry(session.path, { 'type': 'message', 'id': entry, 'timestamp': '2026-10-01T00:00:00.002Z', 'message': {'role': 'user', 'inputId': native_id, @@ -158,7 +178,7 @@ def following_input(claim: PrivateSendClaim, *, generation: int, count: int = 1) with closing(sqlite3.connect(claim.session.files(comms.root).input_proof)) as db, db: recorded.insert(db) return FollowingContext( - claim, keys, text, + claim, keys, tuple(message.message.reference for message in messages), text, InputCommitted(input_id=native_id, session_id=session.session_id, session_entry_id=entry, session_entry_start=start), ContextCommitted(input_id=native_id, session_id=session.session_id, @@ -175,18 +195,25 @@ async def test_two_following_ids_and_grouped_keys_keep_their_own_first_proof(tmp first.start() second.start() assert first.input_event.input_id != second.input_event.input_id != claim.input_id + originals = tuple(row.source for row in claim.stage.assignments) + assert all(state.consumed for state in notification_states(claim, originals)) assert all(not row.model_contexts() for row in first.dispositions.read( first.keys + second.keys).rows.values()) + assert not any(state.consumed for state in first.notification_states() + second.notification_states()) proof = await first.verify() first.acknowledge(proof) for row in first.dispositions.read(first.keys).rows.values(): assert row.has_started and not row.items_with(Unresolved, row) assert row.model_contexts() == (proof,) + assert first.notification_states() == (ShownToModelProgress(proof.committed_at_ms),) * 2 assert all(not row.model_contexts() for row in second.dispositions.read(second.keys).rows.values()) + assert not any(state.consumed for state in second.notification_states()) second_proof = await second.verify() second.acknowledge(second_proof) assert all(row.model_contexts() == (second_proof,) for row in second.dispositions.read(second.keys).rows.values()) + assert second.notification_states() == (ShownToModelProgress(second_proof.committed_at_ms),) + assert first.notification_states() == (ShownToModelProgress(proof.committed_at_ms),) * 2 with Coordination(claim.store_path) as store: original = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) assert original.require_context_proof().input_id == claim.input_id diff --git a/tests/test_historical_notifications.py b/tests/test_historical_notifications.py index 46cd353a9c..729942b2a3 100644 --- a/tests/test_historical_notifications.py +++ b/tests/test_historical_notifications.py @@ -29,6 +29,7 @@ from agent_comms.coordination_tables.harness_deliveries import HarnessDelivery from agent_comms.coordinator import Coordination from agent_comms.harness_delivery_states import DeliveredOutcome, ObservedMonitor +from agent_comms.notification_progress import HarnessConsumedProgress from agent_comms.coordination_errors import DMScopeChanged, SchemaVersionError from agent_comms.field_codec import InputId, MessageId, TurnId from transcript_record_fixture import transcript_record @@ -318,7 +319,7 @@ def delivered_to(observation, activity): assert idle.label.startswith("Consumed ") and not idle.state.seen_working unconsumed = delivered_to(observed(ActivityState.WORKING), sent_activity) assert unconsumed.label.startswith("Sent ") and not unconsumed.state.seen_working - confirmed = replace(unconsumed, state=unconsumed.state.harness_consumed(context_entry.at_ms)) + confirmed = replace(unconsumed, state=unconsumed.state.model_consumed(HarnessConsumedProgress(context_entry.at_ms))) assert confirmed.state.seen_working and confirmed.state.consumed assert confirmed.label == confirmed.timed_state(WorkingTakenProgress.label, context_entry.at_ms) # An explicit ignore decision is the only Seen, not answering. From 2a9693b12f62c8c38c4fff115e31ad4dbca48c69 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:08:26 -0400 Subject: [PATCH 079/109] Keep #3581's one real P924 control and delete its restated ones The two #monitor rows repeated test_claude_automatic_reply.py's channel case and widened the shared bridge fixture for it; the binding and diagnostics asserts re-checked the fixture; the helpers imported inside their bodies. The notice-role control and the direct-reply control stay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- tests/test_monitor_delivery.py | 36 ++++++++++------------------------ 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/tests/test_monitor_delivery.py b/tests/test_monitor_delivery.py index 1647fb7c05..a4b33810a4 100644 --- a/tests/test_monitor_delivery.py +++ b/tests/test_monitor_delivery.py @@ -49,13 +49,17 @@ from agent_comms.coordination_errors import CoordinationReadUnavailable from agent_comms.coordination_tables.harness_deliveries import HarnessDelivery, HarnessObservedPart from agent_comms.coordinator import Coordination -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, InputId from agent_comms.message_reference import BridgeStart, MessageReference from agent_comms.runtime_updates import ( AllTurnsEnded, FinishedItem, FinishedTurn, HiddenItem, RuntimeUpdate, ) from agent_comms.turn_item import NativeItemId, NativeTurnId from agent_comms.messages import AlertKind, InfoKind, Message, NoticeRole +from agent_comms.channel_targets import ThreadTarget +from agent_comms.diagnostics import record_terminal_failure +from agent_comms.transcript_events import AssistantTranscript +from agent_comms.turn_failure import BackendFailed from agent_comms.threads import Thread @@ -128,8 +132,8 @@ async def test_empty_journal_starts_at_binding_and_ignores_user_reads(comms, tmp @pytest.fixture def bridge(comms, tmp_path): - declare_test_bus(comms, channels=('monitor',), threads=tuple( - Thread(name, frozenset({'monitor'}), Checkout(tmp_path)) + declare_test_bus(comms, threads=tuple( + Thread(name, frozenset(), Checkout(tmp_path)) for name in ('claude-code', 'viewer', 'peer') )) practice = PracticeCopy(comms.root, tmp_path / 'copied-bus') @@ -1056,10 +1060,6 @@ async def confirm_and_finish_reply( bridge: ClaudeBridge, message: Message, expected: tuple[str, ...], end: RuntimeUpdate, ) -> None: """Real monitor consumption feeds the ordinary staged output and terminal update.""" - from agent_comms.transcript_events import AssistantTranscript - - assert bridge.binding.bus == 'viewer' - assert bridge.binding.thread == 'session' output = bridge.output_type.for_comms( bridge.comms.transcripts.external(bridge.binding.bus), bridge.comms) async with attached_monitor(bridge) as reader: @@ -1100,10 +1100,6 @@ def replies() -> tuple[tuple[str, str], ...]: @pytest.mark.asyncio async def test_diagnostic_notice_is_consumed_without_replying_to_the_failed_sender(bridge, reply_end): - from agent_comms.diagnostics import record_terminal_failure - from agent_comms.field_codec import InputId - from agent_comms.turn_failure import BackendFailed - diagnostic = record_terminal_failure( bridge.comms.root, subject=InputId.mint(), thread='peer', failure=BackendFailed, sequences=(), source_error=RuntimeError('native request failed')) @@ -1111,28 +1107,16 @@ async def test_diagnostic_notice_is_consumed_without_replying_to_the_failed_send 'peer', 'viewer', f'Request failed. No automatic retry. [Open diagnostic]({diagnostic.as_uri()})', AlertKind, role=NoticeRole()).message await confirm_and_finish_reply(bridge, message, (), reply_end) - assert diagnostic.is_file() - assert json.loads(diagnostic.read_text())['failure'] == BackendFailed.declared_name - assert bridge.comms.bus.pending_count('peer') == 0 @pytest.mark.asyncio -@pytest.mark.parametrize(('target', 'body', 'expected'), ( - ('viewer', 'ordinary direct request', ('claude-code',)), - ('#monitor', 'channel information', ()), - ('#monitor', '@viewer please answer this channel request', ()), -)) -async def test_confirmed_reply_routes_follow_direct_and_channel_policy( - bridge, reply_end, target, body, expected, -): - message = bridge.comms.messaging.send('claude-code', target, body).message - await confirm_and_finish_reply(bridge, message, expected, reply_end) +async def test_confirmed_direct_request_replies_to_its_sender(bridge, reply_end): + message = bridge.comms.messaging.send('claude-code', 'viewer', 'ordinary direct request').message + await confirm_and_finish_reply(bridge, message, ('claude-code',), reply_end) @pytest.mark.asyncio async def test_confirmed_human_direct_request_receives_one_reply(bridge, reply_end): - from agent_comms.channel_targets import ThreadTarget - message = bridge.comms.messaging.send_user_message( ThreadTarget('viewer'), 'please answer once', worktree=Checkout(bridge.options.state.parent)) From 22c0ef74e1eca65d1731228637c16713185879ce Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:45:06 -0400 Subject: [PATCH 080/109] Own queue evidence, source selection and answer presentation in their declared families --- src/agent_comms/acp_extension.py | 6 +- src/agent_comms/bus_publication.py | 12 +- src/agent_comms/bus_read.py | 89 ++-------- src/agent_comms/cli_bridges/claude.py | 13 +- .../cli_bridges/claude_protocol.py | 4 +- src/agent_comms/cli_bridges/shared.py | 9 +- src/agent_comms/connector_caller.py | 2 +- .../coordination_tables/harness_deliveries.py | 14 +- src/agent_comms/harness_delivery_states.py | 24 ++- src/agent_comms/historical_views.py | 17 +- src/agent_comms/messages.py | 7 +- src/agent_comms/native_input_record.py | 8 + src/agent_comms/native_runtime_input.py | 6 +- src/agent_comms/notification_assignment.py | 20 ++- src/agent_comms/notification_evidence.py | 69 ++++---- src/agent_comms/notification_progress.py | 11 +- src/agent_comms/notification_reads.py | 38 ++--- src/agent_comms/order_tools.py | 30 ++-- src/agent_comms/presentation.py | 8 +- src/agent_comms/read_basis.py | 58 +++++-- src/agent_comms/tool_caller.py | 9 +- src/agent_comms/tools.py | 2 +- src/agent_comms/transcripts.py | 160 ++++++++---------- src/agent_comms/ui_model/observation.py | 2 +- tests/test_harness_receipt_context.py | 5 +- tests/test_historical_notifications.py | 9 +- tests/test_historical_views.py | 9 +- tests/test_live_context_notifications.py | 5 +- tests/test_monitor_delivery.py | 2 +- tests/test_notification_read_state.py | 24 +-- tests/test_transcript_pages.py | 3 +- tools/module_layers.py | 21 ++- 32 files changed, 337 insertions(+), 359 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 1905428090..d7190842f1 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -54,7 +54,7 @@ from .thread_identity import ClaimScoped, ThreadIncarnation from .tool_result import ToolResult from .transcripts import ( - NativeCommittedPrefix, TranscriptCursor, TranscriptPage, TranscriptRead, TranscriptReadIdentity, + LivePageSource, NativeCommittedPrefix, PageSource, TranscriptCursor, TranscriptPage, TranscriptRead, TranscriptReadIdentity, ) from .turn_context import ContextManifest from .turn_lease import TurnState @@ -711,9 +711,9 @@ def from_read(cls, read: TranscriptRead) -> Self: return cls(page, read.identity, draft.external_row()) @classmethod - def capture(cls, transcripts, name, *, historical_source=None): + def capture(cls, transcripts, name, *, page_source: PageSource = LivePageSource()): """Publish the page together with the source witness that read it.""" - read = transcripts.capture_page_read(name, historical_source=historical_source) + read = transcripts.capture_page_read(name, page_source=page_source) return cls.from_read(read) diff --git a/src/agent_comms/bus_publication.py b/src/agent_comms/bus_publication.py index ba6f7406b1..8799c36b30 100644 --- a/src/agent_comms/bus_publication.py +++ b/src/agent_comms/bus_publication.py @@ -32,6 +32,7 @@ from .field_codec import FieldCodec, MessageId, ThreadLookup from .repo import Checkout from .wake_policy import RecipientDecision +from .channel_targets import ThreadTarget if TYPE_CHECKING: from agent_comms.bus import Bus, BusReader, RecordBuilder @@ -56,25 +57,16 @@ def __post_init__(self) -> None: raise ValueError("Frozen recipients and their decisions must have equal length") def projections(self): - from .bus_tables import AwaitingAnswerSource, ReceivedSource, SentSource - yield from super().projections() yield SentSource(self.sender_lookup, self.seq) for recipient in self.recipients: yield ReceivedSource(recipient.recipient_lookup, self.seq) - if self.awaits_answer_from(recipient.canonical_thread): + if self.awaits_answer_from(ThreadTarget(recipient.canonical_thread)): yield AwaitingAnswerSource( recipient.recipient_lookup, self.seq, recipient.canonical_thread, self.role.response_policy(self).starts_direct_turn, ) - def awaits_answer_from(self, recipient: str) -> bool: - """Whether this delivery leaves the recipient owing its sender an answer: the - recipient sent it to itself, it was addressed to the recipient directly, or its - response policy names the recipient a required responder.""" - return (self.sender == recipient or self.target == recipient - or bool(self.response_eligibility((recipient,)).required_responders)) - @property def history_record(self) -> Self: """A copied delivery keeps its frozen audience: its recipients received it there too.""" diff --git a/src/agent_comms/bus_read.py b/src/agent_comms/bus_read.py index ff06c15081..0747011dfa 100644 --- a/src/agent_comms/bus_read.py +++ b/src/agent_comms/bus_read.py @@ -1,110 +1,56 @@ -"""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. -""" +"""Read operations share their declaration; callers supply the read authority.""" from __future__ import annotations import asyncio from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import TYPE_CHECKING, NoReturn -from .launch_runtime import CallingThreadUnavailable +from .comms import Comms from .owner_connection import ( OwnerConnection, OwnerReading, OwnerReplyConnection, ResultRuntimeRequest, RuntimeRequestContext, ) -from .read_basis import AllOutcomes, DisplayIdentity, NoDisplaySelection, OutcomeViewer +from .read_basis import DisplayCut, NoDisplaySelection, OutcomeViewer, ReadAuthority +from .registration import Registration from .tool_declaration import ToolDeclaration +from .tool_request import ThreadReadTool from .tool_result import ToolResult -if TYPE_CHECKING: - from .comms import Comms - - -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.""" - - 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.""" - - __slots__ = () - - def outcome_viewer(self, comms: Comms) -> OutcomeViewer: - return AllOutcomes() - @dataclass(frozen=True) -class OperatorReader(OperatorOutcomes, BusReader): - """The owner's UI: it observes the whole bus as the operator. A read made for one painted - display reads under that display's captured identity, so the human's own read of what - the display shows is not reported back to them.""" +class OperatorReader(ReadAuthority): + """An operator read is scoped by its actual painted display, or is unscoped.""" - display: DisplayIdentity | NoDisplaySelection = field(default_factory=NoDisplaySelection) + display: DisplayCut = field(default_factory=NoDisplaySelection) - def outcome_viewer(self, comms: Comms) -> OutcomeViewer: + def outcome_viewer(self, registry: Registration) -> OutcomeViewer: return self.display.outcome_viewer() class BusRead[Result](ToolDeclaration, ABC): - """A read of Core's stores, declared once with its arguments; boundaries derive it.""" + """One operation owns its arguments and its read; the boundary supplies authority.""" - def apply(self, comms: Comms, caller: BusReader) -> Result: - """The MCP boundary runs the read under its calling thread's authority.""" + def apply(self, comms: Comms, caller: ReadAuthority) -> Result: return self.read_as(comms, caller) @abstractmethod - def read_as(self, comms: Comms, reader: BusReader) -> Result: - """The read under the reader's authority.""" + def read_as(self, comms: Comms, reader: ReadAuthority) -> Result: ... 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()) -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 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. - """ +class OwnerLiveRead[Result: ToolResult = ToolResult](BusRead[Result], ThreadReadTool, ABC): + """Only the selected thread's live owner answers, through its own request queue.""" - if TYPE_CHECKING: - # The composed ThreadReadTool declares the field; an annotation here would shadow it. - thread: str - - def read_as(self, comms: Comms, reader: BusReader) -> Result: + def read_as(self, comms: Comms, reader: ReadAuthority) -> Result: owner = comms.registry.require(reader.subject(self.thread)).incarnation 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: - """The answer computed inside the owner, under its own context.""" + async def owned(self, ctx: RuntimeRequestContext) -> Result: ... @abstractmethod def decode_reply(self, raw: object) -> Result: ... @@ -112,9 +58,6 @@ 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: diff --git a/src/agent_comms/cli_bridges/claude.py b/src/agent_comms/cli_bridges/claude.py index 2596f31573..9294924572 100755 --- a/src/agent_comms/cli_bridges/claude.py +++ b/src/agent_comms/cli_bridges/claude.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, AsyncIterator, BinaryIO, ClassVar from agent_comms.bus import Bus -from agent_comms.harness_delivery_states import Outcome, WakeOnlyMonitor +from agent_comms.harness_delivery_states import BoardWake, Outcome, WakeOnlyMonitor from agent_comms.messages import Message from agent_comms.private_path import FileRevision from agent_comms.runtime_updates import RuntimeUpdate @@ -26,7 +26,7 @@ from agent_comms.task_attachments.commander_pings import CommanderUpdate -class ClaudeBridge(ClaudeJsonLines, ClaudeHistory, LineMonitorBridge): +class ClaudeBridge(BoardWake, ClaudeJsonLines, ClaudeHistory, LineMonitorBridge): """Follow this Claude session's file without submitting an input. Heartbeat packets reach the session through the board its tool-call hook prints, which @@ -34,11 +34,6 @@ class ClaudeBridge(ClaudeJsonLines, ClaudeHistory, LineMonitorBridge): frames Monitor events as routine background, so a line said once there scrolls away. The Monitor only gets a wake line, since an idle session runs no tool calls.""" - board_wake: ClassVar[str] = ( - "Heartbeat: your board changed. New and changed lines print once as BOARD: on your next " - "tool call; later calls print one unchanged count." - ) - incoming_frame = ClaudeMonitorFrame window_bytes = ClaudeMonitorFrame.BATCH_CHARS * 2 RECENT_BYTES = 4 * 1024 * 1024 @@ -58,9 +53,7 @@ async def deliver_board(self, update: CommanderUpdate, message: Message) -> type return await update.wake(self, message) async def wake(self, message: Message) -> type[Outcome]: - return await self.deliver( - replace(message, body=self.board_wake), reception=WakeOnlyMonitor, - ) + return await self.deliver(message, reception=WakeOnlyMonitor) def history_start(self, stream: BinaryIO, boundary: int) -> int: """On restart only the last 4 MiB is published again; Core's finish is idempotent, so diff --git a/src/agent_comms/cli_bridges/claude_protocol.py b/src/agent_comms/cli_bridges/claude_protocol.py index ad3406f693..9ea4148dde 100644 --- a/src/agent_comms/cli_bridges/claude_protocol.py +++ b/src/agent_comms/cli_bridges/claude_protocol.py @@ -108,12 +108,12 @@ def header(self, number: int, total: int) -> str: def parts(self) -> tuple[str, ...]: # At least one body character fits each part. Using the maximum possible # ordinal width avoids rebuilding the body to discover its final count. - upper = len(self.message.body) # Message already requires a nonempty body. + upper = len(self.body) # The displayed body does not replace the original identity. budget = self.EVENT_CHARS - self.event_size(self.header(upper, upper)) bodies: list[str] = [] current: list[str] = [] used = 0 - for character in self.message.body: + for character in self.body: size = self.event_size(character) - 2 # JSON quotes belong to the header budget. if size > budget: raise ValueError('Claude Monitor header leaves no room for a body character') diff --git a/src/agent_comms/cli_bridges/shared.py b/src/agent_comms/cli_bridges/shared.py index b490fadb7a..7a8437d043 100644 --- a/src/agent_comms/cli_bridges/shared.py +++ b/src/agent_comms/cli_bridges/shared.py @@ -1529,7 +1529,14 @@ class TranscriptConfirmsDelivery: """Delivery is confirmed only by observing it in the bridge's own transcript subscription.""" +@dataclass(frozen=True) class MonitorIncomingFrame(IncomingFrame): + reception: type[PendingMonitor] = ReceivingMonitor + + @property + def body(self) -> str: + return self.reception.displayed_body(self.message.body) + @property @abstractmethod def parts(self) -> tuple[str, ...]: ... @@ -1660,7 +1667,7 @@ async def deliver_to( self, attachment: MonitorAttachment, message: messages.Message, reception: type[PendingMonitor], ) -> type[Outcome]: - frame = self.incoming_frame(message) + frame = self.incoming_frame(message, reception=reception) receiving = partial(replace, monitor=reception.for_parts(len(frame.parts))) attempt = self.attempt(message, UnconfirmedOutcome) await self.coordinator_write(partial(attempt.apply, receiving)) diff --git a/src/agent_comms/connector_caller.py b/src/agent_comms/connector_caller.py index 43646991ba..96b4172705 100644 --- a/src/agent_comms/connector_caller.py +++ b/src/agent_comms/connector_caller.py @@ -13,7 +13,7 @@ from .head_review import HeadReviewOrder, PullRequestHead from .thread_identity import ThreadIncarnation from .todos import DirectiveText -from .bus_read import OperatorOutcomes +from .read_basis import OperatorOutcomes from .tool_caller import CommanderForkCaller, NoThreadCaller, ThreadCaller, ToolCaller from .tool_request import ToolArgumentsRefusedError, ToolRequest diff --git a/src/agent_comms/coordination_tables/harness_deliveries.py b/src/agent_comms/coordination_tables/harness_deliveries.py index 8e078abaf5..663d3b519f 100644 --- a/src/agent_comms/coordination_tables/harness_deliveries.py +++ b/src/agent_comms/coordination_tables/harness_deliveries.py @@ -19,17 +19,14 @@ from agent_comms.harness_delivery_states import MonitorEvidence, Outcome, UnrecordedMonitor from agent_comms.harness_install import HarnessSession from agent_comms.message_reference import MessageReference -from agent_comms.notification_evidence import ConsumptionEvidence from agent_comms.typed_table import Column, ForeignKey, FrozenColumn, ImmutableRows, Index, NoDelete if TYPE_CHECKING: from agent_comms.assignment_store import AssignmentStore - from agent_comms.notification_progress import DeliveryProgress - from agent_comms.presentation import MessageNotification @dataclass(frozen=True, slots=True) -class HarnessDelivery(NoDelete, CoordinatorRow, ConsumptionEvidence, declared_name="harness_deliveries"): +class HarnessDelivery(NoDelete, CoordinatorRow, declared_name="harness_deliveries"): """One original attempt to put a bus message into a recipient's harness session.""" bridge: Annotated[type[HarnessSession], FrozenColumn(primary_key=True)] @@ -75,15 +72,6 @@ def first_write(self, db: sqlite3.Connection) -> bool: self.insert(db) return True - def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: - return self.monitor.consumed_progress(progress) - - def arrival_count(self) -> int: - return self.monitor.arrival_count(self.outcome) - - def arrival_label(self, notification: MessageNotification) -> str: - return self.monitor.arrival_label(self.outcome, notification) - def settle_claims(self, assignments: AssignmentStore, db: sqlite3.Connection) -> None: """What this attempt means for the recipient's claim on its message: the claim left the native queue, then took the steps this outcome proves. Each phase reads the claim as it diff --git a/src/agent_comms/harness_delivery_states.py b/src/agent_comms/harness_delivery_states.py index b01838d7ab..704d822dca 100644 --- a/src/agent_comms/harness_delivery_states.py +++ b/src/agent_comms/harness_delivery_states.py @@ -10,11 +10,12 @@ from abc import abstractmethod from dataclasses import dataclass, replace from functools import partial -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from .channel_targets import CommandTarget from .declared_family import DeclaredFamily from .field_codec import TranscriptRecordId +from .notification_evidence import ConsumptionEvidence from .notification_progress import HarnessConsumedProgress if TYPE_CHECKING: @@ -109,7 +110,7 @@ def input_detail(cls) -> str: @dataclass(frozen=True) -class MonitorEvidence(DeclaredFamily): +class MonitorEvidence(ConsumptionEvidence, DeclaredFamily): """Unsent and terminal observations cannot accept another delivery.""" async def confirm(self, bridge: LineMonitorBridge, delivery: HarnessDelivery, @@ -142,18 +143,35 @@ class UnrecordedMonitor(MonitorEvidence): pass +class BoardWake: + """The board notice's text, shared by its publisher and its transport evidence.""" + + board_wake: ClassVar[str] = ( + "Heartbeat: your board changed. New and changed lines print once as BOARD: on your next " + "tool call; later calls print one unchanged count." + ) + + class PendingMonitor(MonitorEvidence, declared_name=None): """The emission owns whether its later observation can prove the original body.""" + @classmethod + def displayed_body(cls, body: str) -> str: + return body + @classmethod @abstractmethod def for_parts(cls, total: int) -> PendingMonitor: ... @dataclass(frozen=True) -class WakeOnlyMonitor(PendingMonitor): +class WakeOnlyMonitor(BoardWake, PendingMonitor): """A board wake is not the original packet, even if its bytes happen to match.""" + @classmethod + def displayed_body(cls, body: str) -> str: + return cls.board_wake + @classmethod def for_parts(cls, total: int) -> WakeOnlyMonitor: return cls() diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index 018062734a..305497789c 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -3,6 +3,7 @@ from __future__ import annotations from agent_comms.repo import Checkout +import re import shutil import tempfile from abc import ABC, abstractmethod @@ -21,7 +22,7 @@ from .channels import Channel from .coordination_errors import HistoricalSourceChanged from .errors import UnregisteredThreadError -from .field_codec import FieldCodec, ScratchName +from .field_codec import FieldCodec, NominalSpelling, NominalText, ScratchName from .locked_store import DocumentChange, RegistryFile, RootFile from .maintenance_barrier import CarriedDocument from .message_page import ( @@ -257,6 +258,14 @@ def live_page(self, presentation: BusPresentation, bounds: PageBounds) -> Messag ) +class HistorySourceKey(NominalText): + """The archive's catalogue identity, distinct from a thread or runtime key.""" + + +class HistorySourceKeyText(NominalSpelling[HistorySourceKey]): + spelling = re.compile(r"[^\x00]+") + + @dataclass(frozen=True, slots=True) class HistorySource: """An immutable bus/registry snapshot attached by the destination MessageBus.""" @@ -274,8 +283,8 @@ def __post_init__(self) -> None: object.__setattr__(self, "provenance", self.provenance.bound_to(Path(self.original_root))) @property - def key(self) -> str: - return self.root + def key(self) -> HistorySourceKey: + return HistorySourceKey(self.root) @classmethod def capture_original(cls, root: Path) -> HistorySource: @@ -349,7 +358,7 @@ def require_original(self, root: Path) -> None: raise ValueError("Recorded provenance lost original bus ownership") @classmethod - def require(cls, sources: tuple[HistorySource, ...], key: str) -> HistorySource: + def require(cls, sources: tuple[HistorySource, ...], key: HistorySourceKey) -> HistorySource: source = next((item for item in sources if item.key == key), None) if source is None: raise HistoricalSourceChanged("Historical source detached; refresh history") diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 7425d003dd..50e094b169 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -32,7 +32,7 @@ ) from .change_report import FileLine, NoChangeReport, ReportEvidence, SourceRoots -from .channel_targets import CommandTarget +from .channel_targets import CommandTarget, ThreadTarget from .coordination_errors import IdentityConflict from .declared_family import DeclaredFamily from .envelope_claim_transitions import ( @@ -1012,6 +1012,11 @@ def from_wire(cls, data: Mapping) -> Message: kind = cast(builtins.type[MessageKind], plan.value.decode(plan.raw(data))) return kind.decode_message({"ts": 0.0, **data}) + def awaits_answer_from(self, recipient: ThreadTarget) -> bool: + """The original message owns whether this named recipient owes its sender an answer.""" + return (self.sender == recipient.name or self.target == recipient.name + or bool(self.response_eligibility((recipient.name,)).required_responders)) + def response_eligibility( self, audience: Sequence[str], *, aliases: Mapping[str, str] = NO_ALIASES ) -> ResponseEligibility: diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index 1aad5751ad..f908e12bb4 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -450,6 +450,14 @@ def context_anchor(self) -> NativeColumn: def reference_stage(self) -> type[NativeInputExecution]: return self.context_value("stage") + def context_columns(self, context: NativeContextProof) -> dict[str, object]: + """Build the context group through each column's declared codec, not a caller roster.""" + return { + plan.name: plan.value.decode(FieldCodec.encode(getattr(context, plan.name))) + for plan in FieldCodec.field_plans(type(self)) + if NativeContext.single(plan.annotation) + } + def context_value(self, name: str) -> object: """A context field decoded as its NativeContext directive declares it.""" plan = next(plan for plan in FieldCodec.field_plans(type(self)) if plan.name == name) diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index 02ae4b7d20..a321700d94 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -343,11 +343,7 @@ def write_context(self, db: sqlite3.Connection, context: NativeContextProof) -> db, where="input_id=? AND session_entry_id IS NULL", parameters=(self.input_id,), - session_entry_id=TextNativeColumn(FieldCodec.encode(context.session_entry_id)), - session_entry_start=RecordedNativePosition(context.session_entry_start), - request_generation=NumberNativeColumn(context.request_generation), - llm_context_digest=TextNativeColumn(context.llm_context_digest), - committed_at_ms=NumberNativeColumn(context.committed_at_ms), + **self.context_columns(context), ) if updated.rowcount != 1: raise StaleFence("native proof was previously committed") diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index 4edfa275a2..c8d4e2b156 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -27,7 +27,7 @@ SentProgress, ShownToModelProgress, ) -from .notification_evidence import (AssignmentResponse, NotificationAssignmentRead, NotificationWindow, +from .notification_evidence import (AssignmentResponse, ConsumptionEvidence, NotificationAssignmentRead, NotificationWindow, NotificationSelection, UnansweredResponse) from .private_registry_guard import RegistryPrivacy from .registry_store import RegistryStore @@ -233,12 +233,15 @@ def capture_inputs(cls, root: Path, read: NotificationAssignmentRead) -> Notific own input row, keyed as the delivery recorded it; one keyed read covers the window. """ registry = RegistryStore.at(root).snapshot() - keys = {InputDispositions.bus_key(page.source.message, thread): - (recipient.recipient_lookup, page.source.message.reference) - for page in read.selection.pages for recipient in page.recipients + keys = {InputDispositions.bus_key(page.source.message, thread): key + for page in read.selection.pages for key, recipient in page.entries for thread in registry.recipient(recipient, page.source).input_owners()} rows = InputDispositions(root).read(tuple(keys)).rows - return replace(read, inputs=MappingProxyType({keys[key]: row for key, row in rows.items()})) + consumption = dict(read.consumption) + for key, row in rows.items(): + selected = keys[key] + consumption[selected] = (*consumption.get(selected, ()), row) + return replace(read, consumption=MappingProxyType(consumption)) @classmethod def capture_detached(cls, root: Path, read: NotificationAssignmentRead) -> NotificationAssignmentRead: @@ -290,10 +293,13 @@ def capture( ), AssignmentActivityEvidence, )) - attempts = HarnessDelivery.for_recipients(connection, keys) + consumption: dict[tuple[ThreadLookup, MessageReference], tuple[ConsumptionEvidence, ...]] = {} + for attempt in HarnessDelivery.for_recipients(connection, keys): + key = (attempt.recipient_lookup, attempt.reference) + consumption[key] = (*consumption.get(key, ()), attempt.monitor) return NotificationAssignmentRead( selection, tuple(sorted(rows, key=lambda row: (-row[0].wire_seq, row[0].recipient))), - attempts) + MappingProxyType(consumption)) @classmethod def read( diff --git a/src/agent_comms/notification_evidence.py b/src/agent_comms/notification_evidence.py index af007a8de3..65439c364f 100644 --- a/src/agent_comms/notification_evidence.py +++ b/src/agent_comms/notification_evidence.py @@ -15,6 +15,13 @@ from .message_reference import MessageReference from .thread_identity import ThreadIncarnation + +class ConsumptionEvidence(ABC): + """A recorded fact that may prove a delivery entered the model's context.""" + + @abstractmethod + def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: ... + if TYPE_CHECKING: from .audience_manifest import FrozenRecipient from .read_basis import OutcomeViewer @@ -27,14 +34,6 @@ from .presentation import MessageNotification, WireRevision -class ConsumptionEvidence(ABC): - """A recorded fact about one delivery that may prove it entered the model's context.""" - - @abstractmethod - def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: - """The delivery's progress after this fact; only a context proof moves it.""" - - @dataclass(frozen=True) class AssignmentResponse(DeclaredFamily, affix="Response"): def recorded(self, message: Delivered) -> AssignmentResponse: @@ -56,6 +55,12 @@ def from_columns( FieldCodec.decode(int, received_at_ms), ) + def detail_texts(self) -> tuple[str, ...]: + return () + + def notification_label(self, notification: MessageNotification, label: str) -> str: + raise ValueError("An unanswered source has no answer timestamp") + @abstractmethod def completed_notification( self, progress: AssignmentProgress, notification: MessageNotification @@ -85,12 +90,16 @@ class AnsweredResponse(AssignmentResponse): received_at_ms: int def completed_notification(self, progress: AssignmentProgress, notification: MessageNotification) -> MessageNotification: - return progress.answered_notification( - notification, self.reference, self.received_at_ms - ) + return progress.answered_notification(notification, self) def answered(self, notification: MessageNotification) -> MessageNotification: - return notification.state.answered_notification(notification, self.reference, self.received_at_ms) + return notification.state.answered_notification(notification, self) + + def detail_texts(self) -> tuple[str, ...]: + return (f"Answer #{self.reference.seq} · {self.reference.message_id}",) + + def notification_label(self, notification: MessageNotification, label: str) -> str: + return notification.timed_state(label, self.received_at_ms) @dataclass(frozen=True) @@ -99,8 +108,7 @@ class SourceEvidence: grouped by frozen recipient. Equality over all of them is the window's change test.""" claims: tuple[tuple[WakeAssignment, AssignmentActivityEvidence], ...] = () - attempts: Mapping[ThreadLookup, tuple[HarnessDelivery, ...]] = field(default_factory=dict) - inputs: Mapping[ThreadLookup, tuple[ConsumptionEvidence, ...]] = field(default_factory=dict) + consumption: Mapping[ThreadLookup, tuple[ConsumptionEvidence, ...]] = field(default_factory=dict) responses: Mapping[ThreadLookup, AssignmentResponse] = field(default_factory=lambda: MappingProxyType({})) def answered(self, recipient: ThreadLookup, notification: MessageNotification) -> MessageNotification: @@ -110,7 +118,7 @@ def consumed_progress(self, recipient: ThreadLookup, progress: DeliveryProgress) -> DeliveryProgress: """The recipient's progress after each of its own harness attempts and stored inputs of this original.""" - for attempt in (*self.attempts.get(recipient, ()), *self.inputs.get(recipient, ())): + for attempt in self.consumption.get(recipient, ()): progress = attempt.consumed_progress(progress) return progress @@ -144,6 +152,11 @@ def remaining(self) -> int: def recipients(self) -> tuple[FrozenRecipient, ...]: return tuple(self.source.recipients[position] for position in self.positions) + @property + def entries(self) -> tuple[tuple[tuple[ThreadLookup, MessageReference], FrozenRecipient], ...]: + return tuple(((recipient.recipient_lookup, self.source.message.reference), recipient) + for recipient in self.recipients) + @dataclass(frozen=True) class NotificationSelection: @@ -158,8 +171,7 @@ def sources(self) -> tuple[Delivered, ...]: @property def keys(self) -> tuple[tuple[ThreadLookup, MessageReference], ...]: - return tuple((recipient.recipient_lookup, page.source.message.reference) - for page in self.pages for recipient in page.recipients) + return tuple(key for page in self.pages for key, _recipient in page.entries) @dataclass(frozen=True) @@ -173,10 +185,9 @@ class NotificationAssignmentRead: selection: NotificationSelection rows: tuple[tuple[WakeAssignment, AssignmentActivityEvidence], ...] - attempts: tuple[HarnessDelivery, ...] = () - responses: Mapping[tuple[ThreadLookup, MessageReference], AssignmentResponse] = field( + consumption: Mapping[tuple[ThreadLookup, MessageReference], tuple[ConsumptionEvidence, ...]] = field( default_factory=lambda: MappingProxyType({})) - inputs: Mapping[tuple[ThreadLookup, MessageReference], ConsumptionEvidence] = field( + responses: Mapping[tuple[ThreadLookup, MessageReference], AssignmentResponse] = field( default_factory=lambda: MappingProxyType({})) @property @@ -189,27 +200,21 @@ def evidence(self) -> Mapping[MessageReference, SourceEvidence]: claims: dict[MessageReference, list[tuple[WakeAssignment, AssignmentActivityEvidence]]] = { source.message.reference: [] for source in self.sources } - attempts: dict[MessageReference, dict[ThreadLookup, list[HarnessDelivery]]] = { + consumption: dict[MessageReference, dict[ThreadLookup, tuple[ConsumptionEvidence, ...]]] = { source.message.reference: {} for source in self.sources } responses: dict[MessageReference, dict[ThreadLookup, AssignmentResponse]] = { source.message.reference: {} for source in self.sources } - inputs: dict[MessageReference, dict[ThreadLookup, tuple[ConsumptionEvidence, ...]]] = { - source.message.reference: {} for source in self.sources - } for (lookup, source), response in self.responses.items(): responses[source][lookup] = response - for (lookup, source), stored in self.inputs.items(): - inputs[source][lookup] = (stored,) + for (lookup, source), facts in self.consumption.items(): + consumption[source][lookup] = facts for row in self.rows: claims.setdefault(row[0].source, []).append(row) - for attempt in self.attempts: - attempts[attempt.reference].setdefault(attempt.recipient_lookup, []).append(attempt) - return {source: SourceEvidence(tuple(claims[source]), { - lookup: tuple(rows) for lookup, rows in attempts[source].items()}, - responses=MappingProxyType(responses[source]), - inputs=MappingProxyType(inputs[source])) + return {source: SourceEvidence( + tuple(claims[source]), MappingProxyType(consumption[source]), + MappingProxyType(responses[source])) for source in claims} def differs_from(self, fresh: Mapping[MessageReference, SourceEvidence]) -> bool: diff --git a/src/agent_comms/notification_progress.py b/src/agent_comms/notification_progress.py index 0585288323..5ecd2a25c0 100644 --- a/src/agent_comms/notification_progress.py +++ b/src/agent_comms/notification_progress.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, ClassVar, Self from .declared_family import DeclaredOrder -from .message_reference import MessageReference +from .notification_evidence import AssignmentResponse from .thread_presentation import ThreadPresentationStatus, WorkingThreadPresentationStatus if TYPE_CHECKING: @@ -73,10 +73,9 @@ def counted_summary_prefix(self, notification: MessageNotification) -> tuple[str return (notification.label,) def answered_notification(self, notification: MessageNotification, - reference: MessageReference, answered_at_ms: int) -> MessageNotification: + response: AssignmentResponse) -> MessageNotification: """An independent answer preserves this state's actual context proof.""" - return replace(notification, state=AnsweredProgress(self), - answer=reference, answered_at_ms=answered_at_ms, changed=True) + return replace(notification, state=AnsweredProgress(self), answer=response, changed=True) def model_consumed(self, proof: ShownToModelProgress) -> DeliveryProgress: """The first proof that the message entered the model's context, at its own time.""" @@ -188,12 +187,12 @@ class AnsweredProgress(SettledDelivery, ContextualProgress): label = "Answered" def answered_notification(self, notification: MessageNotification, - reference: MessageReference, answered_at_ms: int) -> MessageNotification: + response: AssignmentResponse) -> MessageNotification: """A later independent response cannot replace an already recorded answer.""" return notification def notification_label(self, notification: MessageNotification) -> str: - return notification.timed_state(self.label, notification.answered_at_ms) + return notification.answer.notification_label(notification, self.label) class CompletedProgress(AnsweredProgress): diff --git a/src/agent_comms/notification_reads.py b/src/agent_comms/notification_reads.py index ccbb0eaa5d..6528a8e81f 100644 --- a/src/agent_comms/notification_reads.py +++ b/src/agent_comms/notification_reads.py @@ -1,32 +1,21 @@ -"""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. -""" +"""Root-free notification reads retain their original source and display cut.""" from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import TYPE_CHECKING -from .bus_read import BusReader, UiRead -from .historical_views import HistorySource +from .bus_read import UiRead +from .comms import Comms +from .historical_views import HistorySource, HistorySourceKey from .message_reference import MessageReference from .notification_evidence import NotificationWindow, RecipientSelection from .notification_read import NotificationsRead -from .read_basis import FirstRecipient, RecipientCursor - -if TYPE_CHECKING: - from .comms import Comms - from .presentation import WireRevision +from .presentation import WireRevision +from .read_basis import FirstRecipient, ReadAuthority, RecipientCursor @dataclass(frozen=True) class NotificationRead(UiRead[NotificationWindow], ABC): - """Every notification read declares its references and the bounded recipient page it - reads after its cursor; members own evidence freshness.""" - references: tuple[MessageReference, ...] after: RecipientCursor = field(default_factory=FirstRecipient, kw_only=True) limit: int = field(default=RecipientSelection.max_limit, kw_only=True) @@ -38,12 +27,9 @@ def changed_since(self, comms: Comms, revision: WireRevision, @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.""" - - def read_as(self, comms: Comms, reader: BusReader) -> NotificationWindow: + def read_as(self, comms: Comms, reader: ReadAuthority) -> NotificationWindow: return comms.views.message_notifications_for_references( - reader.outcome_viewer(comms), self.references, after=self.after, limit=self.limit) + reader.outcome_viewer(comms.registry), self.references, after=self.after, limit=self.limit) def changed_since(self, comms: Comms, revision: WireRevision, previous: NotificationsRead) -> bool: @@ -52,15 +38,13 @@ def changed_since(self, comms: Comms, revision: WireRevision, @dataclass(frozen=True) class RecordedNotificationRead(LiveNotificationRead): - """The selected archive's recorded handling, independent of today's recipients.""" - - source: str + source: HistorySourceKey - def read_as(self, comms: Comms, reader: BusReader) -> NotificationWindow: + def read_as(self, comms: Comms, reader: ReadAuthority) -> 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), + sources, self.references, reader.outcome_viewer(comms.registry), after=self.after, limit=self.limit) def changed_since(self, comms: Comms, revision: WireRevision, diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index 516363fc32..235cb16905 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -35,7 +35,8 @@ ToolCallVerdict, ) from .acp_extension import GoalSnapshotReply, TranscriptSnapshotUpdate, TurnChangedUpdate, UpdateMetadata -from .bus_read import BusReader, OwnerLiveRead, UiRead +from .bus_read import OwnerLiveRead, UiRead +from .read_basis import ReadAuthority from .commander.vibe_check import OverloadDegradation from .commander_hud import CommanderFrame, CommanderHud, CommanderUnitDetail from .comms import Comms @@ -50,6 +51,7 @@ from .order_story import FleetStory, OrderStory from .order_work import OrderWork from .plan_phases import OrderPhase +from .owner_connection import RuntimeRequestContext from .progress_claims import ProgressClaim from .send_result import CorrectionResult, SendResult from .thread_identity import ThreadIncarnation @@ -63,7 +65,7 @@ tool_field, ) from .tool_result import ToolResult -from .transcripts import AtFrontier, LatestPage, PageLimit, PagePosition, TranscriptRead +from .transcripts import AtFrontier, LatestPage, LivePageSource, PageLimit, PagePosition, PageSource, TranscriptRead from .transcript_events import SentTranscript from .turn_digests import ( ActivityTurnView, @@ -76,10 +78,6 @@ UnjudgedEdits, ) -if TYPE_CHECKING: - from .owner_connection import RuntimeRequestContext - - @dataclass(frozen=True) class TodoChanged(ToolResult): todo: Todo @@ -517,7 +515,7 @@ def apply(self, comms: Comms, caller: ToolCaller) -> ThreadTurns: @dataclass(frozen=True, kw_only=True) class InputDeliveryRead( - OwnerLiveRead[DeliveryOverview], ThreadReadTool, declared_name="comms_input_delivery", + OwnerLiveRead[DeliveryOverview], declared_name="comms_input_delivery", ): """The one declaration of a unit's input delivery, answered by its live native owner.""" @@ -540,7 +538,7 @@ def decode_reply(self, raw: object) -> DeliveryOverview: @dataclass(frozen=True, kw_only=True) -class OwnerGoalRead(OwnerLiveRead[GoalSnapshotReply], ThreadReadTool, declared_name="comms_owner_goal"): +class OwnerGoalRead(OwnerLiveRead[GoalSnapshotReply], 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.""" @@ -572,24 +570,24 @@ class TranscriptPageRead( label = "Transcript page" description = ("Read one bounded page of a thread's transcript with its current draft. " "Send a returned cursor as an earlier_than or later_than position for the " - "next page; historical_source reads an archived source.") + "next page; page_source selects live history or an archive.") position: Annotated[PagePosition, *tool_field( "latest_page, or earlier_than/later_than a returned cursor")] = LatestPage() limit: Annotated[PageLimit, *tool_field( "at_frontier, or through a cursor already shown")] = AtFrontier() - historical_source: Annotated[ - str | None, *tool_field("Archived source to read instead of current history") - ] = None + page_source: Annotated[ + PageSource, *tool_field("Live history or a recorded archive identity") + ] = LivePageSource() - def capture_as(self, comms: Comms, reader: BusReader) -> TranscriptRead: + def capture_as(self, comms: Comms, reader: ReadAuthority) -> TranscriptRead: return comms.transcripts.capture_page_read( reader.subject(self.thread), position=self.position, limit=self.limit, - historical_source=self.historical_source, + page_source=self.page_source, ) - def read_as(self, comms: Comms, reader: BusReader) -> TranscriptSnapshotUpdate: + def read_as(self, comms: Comms, reader: ReadAuthority) -> TranscriptSnapshotUpdate: return TranscriptSnapshotUpdate.from_read(self.capture_as(comms, reader)) @@ -609,7 +607,7 @@ class CommanderUnitRead( 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: + def read_as(self, comms: Comms, reader: ReadAuthority) -> 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") diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index ee00e322a4..61c7d5acb6 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -68,7 +68,7 @@ ) from .read_ledger import NoReadNotice, ReadLedger, ReadNotice from .notification_assignment import NotificationAssignment -from .notification_evidence import NotificationAssignmentRead, NotificationReadMark, NotificationWindow, RecipientSelection +from .notification_evidence import AssignmentResponse, UnansweredResponse, NotificationAssignmentRead, NotificationReadMark, NotificationWindow, RecipientSelection from .read_basis import FirstRecipient, OutcomeViewer, RecipientCursor from .notification_read import CapturedNotificationWindow from .recorded_bus import NoRecordedBus, RecordedBusIdentity @@ -128,8 +128,7 @@ class MessageNotification: state: DeliveryProgress message: Message | None = None displayed_to: tuple[ThreadIncarnation, ...] = () - answer: MessageReference | None = None - answered_at_ms: int | None = None + answer: AssignmentResponse = field(default_factory=UnansweredResponse) changed: bool = False @property @@ -147,8 +146,7 @@ def sent_outcome(self) -> SentMessageOutcome | None: if not self.changed or self.message is None: return None return SentMessageOutcome(self.recipient_identity, self.sender_lookup, self.state, - message=self.message, answer=self.answer, - answered_at_ms=self.answered_at_ms) + message=self.message, answer=self.answer) @classmethod def ordered(cls, notifications: Iterable[MessageNotification]) -> tuple[MessageNotification, ...]: diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 1cd847d63b..24a317504c 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -21,7 +21,7 @@ from dataclasses import dataclass, field, replace from itertools import product from pathlib import Path -from typing import TYPE_CHECKING, Annotated, ClassVar, Self, TextIO +from typing import TYPE_CHECKING, Annotated, ClassVar, NoReturn, Self, TextIO from .activity_clocks import ActivityClocks, ChannelActivity from .channel_targets import CommandTarget, ThreadTarget @@ -30,6 +30,7 @@ from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import FieldCodec, NullMember, RecordMember, PathText, SingleFieldValue, SubclassUnion, ThreadLookup from .mentions import ThreadMention +from .launch_runtime import CallingThreadUnavailable from .message_reference import MessageReference from .notification_evidence import NotificationSelection, RecipientSelection from .audience_manifest import FrozenRecipient @@ -250,8 +251,39 @@ def read_page( replace(scope, displayed=result.displayed))) +class ReadAuthority(ABC): + """Whose read this is; distinct from the SQL reader that acquires its rows.""" + + @abstractmethod + def outcome_viewer(self, registry: Registration) -> OutcomeViewer: ... + + def subject(self, named: str) -> str: + return named or self.own_thread() + + def own_thread(self) -> str: + return self.refuse_thread() + + def refuse_thread(self) -> NoReturn: + raise CallingThreadUnavailable("This tool requires a thread caller.") + + +class OperatorOutcomes: + """An unscoped operator reader sees every recipient outcome.""" + + def outcome_viewer(self, *_context: object) -> OutcomeViewer: + return AllOutcomes() + + +class DisplayCut(OperatorOutcomes, ABC): + """The selected display cut, including the absence of a painted display.""" + + @property + @abstractmethod + def notification_identity(self) -> DisplayCut: ... + + @dataclass(frozen=True, slots=True, kw_only=True) -class DisplayIdentity: +class DisplayIdentity(DisplayCut): """The scope observation compared between pages, independent of paint evidence.""" viewer: ThreadIncarnation @@ -264,7 +296,7 @@ def notification_identity(self) -> DisplayIdentity: return DisplayIdentity(viewer=self.viewer, history_revision=self.history_revision, bus_identity=self.bus_identity) - def outcome_viewer(self) -> OutcomeViewer: + def outcome_viewer(self, *_context: object) -> OutcomeViewer: return DisplayOutcomes(self.viewer, self.bus_identity) @@ -291,10 +323,10 @@ class SelectedDisplay(ABC): @property @abstractmethod - def identity(self) -> DisplayIdentity | NoDisplaySelection: ... + def identity(self) -> DisplayCut: ... @property - def notification_identity(self) -> DisplayIdentity | NoDisplaySelection: + def notification_identity(self) -> DisplayCut: return self.identity.notification_identity @abstractmethod @@ -312,7 +344,7 @@ def select_evidence(self, selected: frozenset[int]) -> SelectedDisplay: ... @dataclass(frozen=True, slots=True) -class NoDisplaySelection(SelectedDisplay): +class NoDisplaySelection(SelectedDisplay, DisplayCut): has_evidence: ClassVar[bool] = False @property @@ -323,9 +355,6 @@ def identity(self): def notification_identity(self) -> NoDisplaySelection: return self - def outcome_viewer(self) -> OutcomeViewer: - return AllOutcomes() - def acknowledge(self, views: HistoryViews, page: MessagePage, *, worktree: Checkout) -> None: raise DMPaintedBoundChanged() @@ -1151,11 +1180,6 @@ def includes(self, message: Message) -> bool: class RepliesDisplayScope(AnyTargetDisplayScope, DMDisplayScope): """Peer requests, direct or channel-addressed, and every reply by the first participant.""" - @ClassFact - def writes(cls) -> tuple[type[ProducedRow], ...]: - return (AwaitingAnswerSource,) - - @property def condition(self) -> tuple[str, tuple]: names = tuple(self.first_names | self.second_names) @@ -1163,9 +1187,9 @@ def condition(self) -> tuple[str, tuple]: return f"{{sender}} IN ({marks})", names def includes(self, message: Message) -> bool: - return (message.sender in self.first_names or super().includes(message) - or (message.sender in self.second_names - and bool(message.response_eligibility(tuple(self.first_names)).required_responders))) + return (message.sender in self.first_names + or (message.sender in self.second_names and any( + message.awaits_answer_from(ThreadTarget(name)) for name in self.first_names))) @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index b8d27973c3..c09349d023 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -10,8 +10,7 @@ from .errors import ToolRefusedError from .field_codec import WireForm from .launch_root import LaunchRoot -from .bus_read import BusReader, OperatorOutcomes -from .read_basis import OutcomeViewer, OwnerOutcomes +from .read_basis import OperatorOutcomes, OutcomeViewer, OwnerOutcomes, ReadAuthority from .presentation import SentMessageSummary, ThreadRecord from .thread_execution import CommanderForkExecution from .thread_identity import ThreadIncarnation @@ -32,7 +31,7 @@ class ThreadListing(ToolResult): sent_messages: tuple[SentMessageSummary, ...] -class ToolCaller(BusReader): +class ToolCaller(ReadAuthority): def acting_caller(self, grant: ToolCaller) -> ToolCaller: return self @@ -138,8 +137,8 @@ def listing(self, comms: Comms, threads: tuple[ThreadRecord, ...]) -> ThreadList presentation = comms.views.thread_presentations((self.name,)).require(self.name) return ThreadListing(threads, SentMessageSummary.for_sources(presentation.sent_notifications())) - def outcome_viewer(self, comms: Comms) -> OutcomeViewer: - return OwnerOutcomes.of(comms.registry.require(self.name)) + def outcome_viewer(self, registry: Registration) -> OutcomeViewer: + return OwnerOutcomes.of(registry.require(self.name)) def catalog(self, comms: Comms, tools: tuple[dict[str, object], ...]) -> ToolCatalogResult: return ThreadToolCatalogResult( diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 3965e41e4c..1c2c5d1290 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -702,7 +702,7 @@ def apply(self, comms: Comms, caller: ToolCaller) -> MessageOutcomes: except ValueError as error: raise ToolRefusedError(str(error)) from error source, = comms.bus.bus.deliveries_for_references((self.reference,)) - if not caller.outcome_viewer(comms).sees(source): + if not caller.outcome_viewer(comms.registry).sees(source): raise ToolRefusedError(f"Message {source.message.seq} is not readable by this caller.") window = LiveNotificationRead( (self.reference,), after=self.after, limit=self.limit).read_as(comms, caller) diff --git a/src/agent_comms/transcripts.py b/src/agent_comms/transcripts.py index 8ee1c646ed..5218183c95 100644 --- a/src/agent_comms/transcripts.py +++ b/src/agent_comms/transcripts.py @@ -20,7 +20,7 @@ from .errors import RelationViolationError from .external_transcript import ExternalTranscript from .field_codec import ContentExclude, FieldCodec, WireName, projected -from .historical_views import HistorySource +from .historical_views import HistorySource, HistorySourceKey from .message_bus import MessageBus from .message_reference import MessageReference from .messaging import Messaging @@ -111,6 +111,13 @@ def page_limit(self) -> PageLimit: """A painted read position pins the page to the source it was read from.""" return Through(self) + def matches_runtime(self, runtime: RuntimeLocation) -> bool: + return self.session_file == runtime.read_key + + def require_readable(self, runtime: RuntimeLocation, reader: TranscriptSource, size: int) -> None: + if not self.matches_runtime(runtime) or not reader.valid_offset(self.offset, size): + raise TranscriptChanged() + def requires_commit(self) -> bool: """A changed transcript at a read position needs no commit before it is read again.""" return False @@ -249,11 +256,8 @@ class WireTranscriptCursor(NativeTranscriptCursor): class PagePosition(DeclaredFamily, affix=""): """Where a bounded transcript page starts: the latest page, or one side of a read cursor.""" - def require_source(self, runtime: RuntimeLocation) -> None: - """The latest page has no cursor that could belong to another source.""" - - def require_offset(self, reader: TranscriptSource, size: int) -> None: - """The latest page has no cursor offset to validate.""" + def require_readable(self, runtime: RuntimeLocation, reader: TranscriptSource, size: int) -> None: + """The latest page has no cursor to validate.""" def within(self, frontier: TranscriptCursor) -> bool: return True @@ -284,13 +288,8 @@ class CursorPage(PagePosition, declared_name=None): cursor: TranscriptCursor - def require_source(self, runtime: RuntimeLocation) -> None: - if self.cursor.session_file != runtime.read_key: - raise TranscriptChanged() - - def require_offset(self, reader: TranscriptSource, size: int) -> None: - if not reader.valid_offset(self.cursor.offset, size): - raise TranscriptChanged() + def require_readable(self, runtime: RuntimeLocation, reader: TranscriptSource, size: int) -> None: + self.cursor.require_readable(runtime, reader, size) def within(self, frontier: TranscriptCursor) -> bool: return frontier.contains(self.cursor) @@ -324,11 +323,9 @@ class PageLimit(DeclaredFamily, affix=""): def bound(self, frontier: TranscriptCursor) -> TranscriptCursor: return frontier - def resolve_source( - self, member: type[TranscriptReadIdentity], owner: Transcripts, name: str, - historical_source: str | None, - ) -> PageSource: - return member.resolve_source(owner, name, None, historical_source) + def accepts_runtime(self, runtime: RuntimeLocation) -> bool: + """An unpinned page uses the thread's first available transcript source.""" + return True @dataclass(frozen=True, slots=True) @@ -345,11 +342,8 @@ class Through(PageLimit): def bound(self, frontier: TranscriptCursor) -> TranscriptCursor: return self.cursor - def resolve_source( - self, member: type[TranscriptReadIdentity], owner: Transcripts, name: str, - historical_source: str | None, - ) -> PageSource: - return member.resolve_source(owner, name, self.cursor.session_file, historical_source) + def accepts_runtime(self, runtime: RuntimeLocation) -> bool: + return self.cursor.matches_runtime(runtime) @dataclass(frozen=True, slots=True) @@ -371,7 +365,7 @@ def metadata(self) -> dict[str, object]: @dataclass(frozen=True, slots=True) -class PageSource: +class ResolvedPageSource: """The retained stores one thread's transcript page is read from.""" thread: Thread @@ -385,6 +379,44 @@ class PageSource: projection: TranscriptSource +class PageSource(DeclaredFamily, affix="PageSource"): + """The chosen live or archived namespace owns its acquisition and witness member.""" + + @abstractmethod + def resolve(self, owner: Transcripts, name: str, limit: PageLimit) -> ResolvedPageSource: ... + + +@dataclass(frozen=True) +class LivePageSource(PageSource): + def resolve(self, owner: Transcripts, name: str, limit: PageLimit) -> ResolvedPageSource: + registry = owner.registry.snapshot() + thread, runtime, inherited = owner._thread_transcript_source(name, limit, registry=registry) + records = runtime.history_source(owner.root, thread.incarnation) + return ResolvedPageSource( + thread, runtime, inherited, owner.root, owner.bus.bus, owner.routes, records, + LiveTranscriptReadIdentity, + LiveAssignedTranscriptSource.for_thread(owner.root, thread, owner.bus.bus)) + + +@dataclass(frozen=True) +class RecordedPageSource(PageSource): + source: HistorySourceKey + + def resolve(self, owner: Transcripts, name: str, limit: PageLimit) -> ResolvedPageSource: + source = HistorySource.require(owner.bus.history.sources(), self.source) + thread = source.provenance.require(name) + runtime = thread.runtime + if not limit.accepts_runtime(runtime): + raise TranscriptChanged() + root = Path(source.root) + bus = Bus(Bus.path_in(root)) + records = runtime.history_source(root, thread.incarnation) + return ResolvedPageSource( + thread, runtime, False, root, bus, TranscriptRoutes(root), records, + partial(RecordedTranscriptReadIdentity, source=source), + AssignedTranscriptSource.for_recorded(root, thread, bus, records)) + + @dataclass(frozen=True) class TranscriptReadIdentity(DeclaredFamily, affix="TranscriptReadIdentity"): """All canonical inputs to a bounded native page, including annotations.""" @@ -419,13 +451,8 @@ def content_thread(self) -> ThreadIncarnation: return self.thread.incarnation @property - def historical_source(self) -> str | None: - return None - - @classmethod - @abstractmethod - def resolve_source(cls, owner: Transcripts, name: str, source_runtime: str | None, historical_source: str | None) -> PageSource: - """Acquire this member's namespace, stores and identity constructor.""" + def page_source(self) -> PageSource: + return LivePageSource() @abstractmethod def namespace(self, owner: Transcripts) -> RegistryProvenance: @@ -490,17 +517,6 @@ class LiveTranscriptReadIdentity(TranscriptReadIdentity): def content_thread(self) -> tuple[ThreadIncarnation, ThreadParent, ThreadText]: return self.thread.incarnation, self.thread.parent, self.thread.task - @classmethod - def resolve_source(cls, owner: Transcripts, name: str, source_runtime: str | None, historical_source: str | None) -> PageSource: - registry = owner.registry.snapshot() - thread, runtime, inherited = owner._thread_transcript_source( - name, source_runtime, registry=registry, - ) - records = runtime.history_source(owner.root, thread.incarnation) - return PageSource(thread, runtime, inherited, - owner.root, owner.bus.bus, owner.routes, records, cls, - LiveAssignedTranscriptSource.for_thread(owner.root, thread, owner.bus.bus)) - def namespace(self, owner: Transcripts) -> RegistryProvenance: return owner.registry.snapshot() @@ -517,8 +533,8 @@ def transcript_root(self) -> Path: return Path(self.source.original_root) @property - def historical_source(self) -> str: - return self.source.key + def page_source(self) -> PageSource: + return RecordedPageSource(self.source.key) @projected(view="content", name="source") def content_source(self) -> tuple[str, str, RecordedBusIdentity, RecordedBusIdentity, int, FileObservation]: @@ -526,21 +542,6 @@ def content_source(self) -> tuple[str, str, RecordedBusIdentity, RecordedBusIden self.source.snapshot_bus_identity, self.source.snapshot_bus_revision, self.source.snapshot_registry_revision) - @classmethod - def resolve_source(cls, owner: Transcripts, name: str, source_runtime: str | None, historical_source: str | None) -> PageSource: - source = HistorySource.require(owner.bus.history.sources(), historical_source) - thread = source.provenance.require(name) - runtime = thread.runtime - if source_runtime is not None and source_runtime != runtime.read_key: - raise TranscriptChanged() - root = Path(source.root) - bus = Bus(Bus.path_in(root)) - records = runtime.history_source(root, thread.incarnation) - return PageSource(thread, runtime, False, root, - bus, TranscriptRoutes(root), records, - partial(cls, source=source), - AssignedTranscriptSource.for_recorded(root, thread, bus, records)) - def namespace(self, owner: Transcripts) -> RegistryProvenance: current = HistorySource.require(owner.bus.history.sources(), self.source.key) if current != self.source: @@ -558,7 +559,7 @@ class TranscriptRead: owner: Transcripts identity: TranscriptReadIdentity - source: PageSource + source: ResolvedPageSource # Required viewer reads stay pending in the read lifecycle. Their SQL # attempt must not consume that wait by blocking a presentation worker. source_wait: ClassVar[float] = 0 @@ -654,13 +655,13 @@ def bind_page_read( if (identity.position, identity.limit) != (position, limit): raise StaleRevision("Transcript witness belongs to another page window") identity.require_binding(self, name) - source = self._page_source(name, Through(identity.page_bound), identity.historical_source) + source = identity.page_source.resolve(self, name, Through(identity.page_bound)) return TranscriptRead(self, identity, source) def capture_native_output( self, name: str, location: NativeRecordLocation, ) -> NativeCommittedPrefix: - page = self._page_source(name, AtFrontier(), None) + page = LivePageSource().resolve(self, name, AtFrontier()) cut = page.records.committed_output_cut(location) original = NativeRecordLocation(**{ item.name: getattr(location, item.name) @@ -676,9 +677,9 @@ def capture_page_read( *, position: PagePosition = LatestPage(), limit: PageLimit = AtFrontier(), - historical_source: str | None = None, + page_source: PageSource = LivePageSource(), ) -> TranscriptRead: - page = self._page_source(name, limit, historical_source) + page = page_source.resolve(self, name, limit) frontier = page.projection.frontier identity = self._capture_page_identity( page, name, frontier, position=position, limit=limit, @@ -693,7 +694,7 @@ def capture_page_window( sent_source_limit: int = 0, position: PagePosition = LatestPage(), limit: PageLimit = AtFrontier(), - historical_source: str | None = None, + page_source: PageSource = LivePageSource(), ) -> TranscriptSourceWindow: """Expose the source owner's complete identity without parsing its page. @@ -705,7 +706,7 @@ def capture_page_window( raise ValueError("Transcript source capture requires a bounded window") if not 0 <= sent_source_limit <= NotificationWindow.window_limit: raise ValueError("Sent source capture requires a bounded window") - page = self._page_source(name, limit, historical_source) + page = page_source.resolve(self, name, limit) captured = page.projection.window(limit=source_limit, sent_limit=sent_source_limit) identity = self._capture_page_identity( page, name, captured.cursor, position=position, limit=limit, @@ -718,9 +719,7 @@ def recapture_page_identity( self, identity: TranscriptReadIdentity, ) -> TranscriptReadIdentity: """Recapture identity facts without reconstructing presentation source windows.""" - page = self._page_source( - identity.requested_name, identity.limit, identity.historical_source - ) + page = identity.page_source.resolve(self, identity.requested_name, identity.limit) frontier = page.projection.frontier return self._capture_page_identity( page, @@ -732,7 +731,7 @@ def recapture_page_identity( def _capture_page_identity( self, - page: PageSource, + page: ResolvedPageSource, name: str, wire_frontier: WireFrontier, *, @@ -764,21 +763,16 @@ def turns(self, name: str, bound: TurnBound) -> ThreadTurns: def external(self, name: str) -> ExternalTranscript: """Where a CLI bridge publishes the items of its sessionless bus thread.""" - page = self._page_source(name, AtFrontier(), None) + page = LivePageSource().resolve(self, name, AtFrontier()) return page.runtime.external_records(page.root, page.thread.incarnation) - def _page_source(self, name: str, limit: PageLimit, historical_source: str | None) -> PageSource: - member = (LiveTranscriptReadIdentity if historical_source is None - else RecordedTranscriptReadIdentity) - return limit.resolve_source(member, self, name, historical_source) - def _thread_transcript_source( - self, name: str, source_runtime: str | None = None, *, registry: RegistrySnapshot + self, name: str, limit: PageLimit, *, registry: RegistrySnapshot ) -> tuple[Thread, RuntimeLocation, bool]: """Resolve a temporary inherited view without changing runtime ownership.""" thread = registry.require(name) for location, inherited in thread.runtime.history_locations(thread, registry, set()): - if source_runtime is None or source_runtime == location.read_key: + if limit.accepts_runtime(location): return thread, location, inherited raise TranscriptChanged() @@ -795,7 +789,7 @@ def _fork_start_events(thread: Thread) -> tuple[TranscriptEvent, ...]: def _thread_transcript_page( self, - page: PageSource, + page: ResolvedPageSource, *, identity: TranscriptReadIdentity, position: PagePosition, @@ -807,15 +801,11 @@ def _thread_transcript_page( page reaches through its witness's painted bound.""" self.page_reads += 1 thread, runtime = page.thread, page.runtime - position.require_source(runtime) source = page.projection with page.records.reading() as reader: - size = reader.cut().offset - position.require_offset(reader, size) - if through.session_file != runtime.read_key or not reader.valid_offset(through.offset, size): - raise TranscriptChanged() + through.require_readable(runtime, reader, reader.cut().offset) size = through.offset - position.require_offset(reader, size) + position.require_readable(runtime, reader, size) frontier = through source.require_cursor(frontier) if frontier.wire.sequence < 0 or not position.within(frontier): diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index 330bdfbb04..ae99ed7e2a 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -574,7 +574,7 @@ def read(self, comms: Comms) -> TranscriptSnapshotUpdate | None: return None operation = TranscriptPageRead( thread=identity.requested_name, position=identity.position, limit=identity.limit, - historical_source=identity.historical_source, + page_source=identity.page_source, ) return UnreadableHistory.outcome(lambda: operation.read(comms)).raised() diff --git a/tests/test_harness_receipt_context.py b/tests/test_harness_receipt_context.py index 8e74d4634e..ceedb61bb2 100644 --- a/tests/test_harness_receipt_context.py +++ b/tests/test_harness_receipt_context.py @@ -11,6 +11,7 @@ ObservedMonitor, PersistedOutcome, UnconfirmedOutcome, UnrecordedMonitor, ) from agent_comms.notification_progress import HarnessConsumedProgress +from agent_comms.notification_evidence import UnansweredResponse from agent_comms.read_basis import AllOutcomes from test_coordinated_runtime import _root, tmp_path # noqa: F401 from transcript_record_fixture import transcript_record @@ -53,9 +54,9 @@ def test_only_retained_complete_body_proof_promotes_consumption( AllOutcomes(), (source.reference,), ).outcomes[source.seq, source.message_id] assert current.state.consumed is consumed - assert current.answer is None + assert current.answer == UnansweredResponse() assert not current.displayed_to - assert recorded.arrival_count() == arrived + assert recorded.monitor.arrival_count(recorded.outcome) == arrived assert recorded.monitor == monitor if consumed: assert current.label == current.timed_state(HarnessConsumedProgress.label, 1791964800125) diff --git a/tests/test_historical_notifications.py b/tests/test_historical_notifications.py index 729942b2a3..5c3bf35b28 100644 --- a/tests/test_historical_notifications.py +++ b/tests/test_historical_notifications.py @@ -298,7 +298,7 @@ def test_sent_recipient_progresses_through_durable_consumption_and_answer(archiv assert sent.label.startswith("Sent ") and sent.state == SentProgress(1_000) assert queued.label.startswith("Queued ") and queued.state == QueuedProgress(1_000) assert consumed.label.startswith("Consumed ") and consumed.state == SelectedConsumedProgress(2_000) - assert answered.label.startswith("Answered ") and answered.answered_at_ms == 3_000 + assert answered.label.startswith("Answered ") and answered.answer == AnsweredResponse(MessageReference(99, reply_id), 3_000) # A delivered claim carries no consumed time of its own: without native or harness proof # its message is not consumed, whatever the claim's update time says. A running turn took # it; that is not a decision to ignore it. @@ -334,7 +334,7 @@ def delivered_to(observation, activity): assert sum(row.state.consumed for row in (working, declined, unconfirmed, idle)) == 3 assert not unconfirmed.state.consumed assert delivered_answer.label.startswith("Answered ") - assert delivered_answer.answer == MessageReference(99, reply_id) + assert delivered_answer.answer == AnsweredResponse(MessageReference(99, reply_id), 3_000) assert sent.recipient_identity == consumed.recipient_identity == answered.recipient_identity @@ -365,7 +365,7 @@ def test_delivered_assignment_uses_its_recorded_conversation_response(archived_n ).outcomes[original.seq, original.message_id] assert unrelated.answers == () assert not notification.state.consumed - assert notification.answer is None + assert notification.answer == UnansweredResponse() import os from uuid import uuid4 @@ -413,8 +413,7 @@ def test_delivered_assignment_uses_its_recorded_conversation_response(archived_n assert response.answers == (original.reference,) assert notification.label.startswith("Answered ") - assert notification.answer == response.reference - assert notification.answered_at_ms == int(response.timestamp * 1000) + assert notification.answer == AnsweredResponse(response.reference, int(response.timestamp * 1000)) def test_recorded_response_resolution_keeps_other_collective_lifecycles( diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index 9840d8e064..84e1d66ece 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -1,6 +1,7 @@ """Actual saved buses and transcripts, with colliding live/source sequences.""" +from agent_comms.transcripts import RecordedPageSource from agent_comms.repo import Checkout import json import os @@ -226,7 +227,7 @@ def test_duplicate_and_newer_incarnations_sessions(tmp_path): declarations = {h.thread.name: h.thread for h in live.views.historical_threads()} assert declarations["alice"].created_at == declarations["bob"].created_at == 10.0 assert live.registry.require("alice").created_at == 20.0 - page = live.transcripts.capture_page_read("alice", historical_source=source.key).read() + page = live.transcripts.capture_page_read("alice", page_source=RecordedPageSource(source.key)).read() (answer,) = (e for e in page.events if e.declared_name == "assistant") assert answer.text == "old answer" assert answer.routing == routing @@ -261,7 +262,7 @@ def test_recorded_transcript_witness_roundtrip_keeps_original_namespace(tmp_path original_registry = (old.root / "registry.json").read_bytes() original_native = native.read_bytes() - read = live.transcripts.capture_page_read("alice", historical_source=source.key) + read = live.transcripts.capture_page_read("alice", page_source=RecordedPageSource(source.key)) assert isinstance(read.identity, RecordedTranscriptReadIdentity) assert read.identity.thread.incarnation == old.registry.require("recorded-alice").incarnation decoded = FieldCodec.decode(TranscriptReadIdentity, FieldCodec.encode(read.identity)) @@ -273,7 +274,7 @@ def test_recorded_transcript_witness_roundtrip_keeps_original_namespace(tmp_path 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)) + recorded = shared_page(live, TranscriptPageRead(thread="alice", page_source=RecordedPageSource(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) @@ -295,7 +296,7 @@ def test_detached_recorded_witness_cannot_rebind_to_same_name_live_thread(migrat from agent_comms.coordination_errors import StaleRevision _old, _prior, live, source = migrated - read = live.transcripts.capture_page_read("alice", historical_source=source.key) + read = live.transcripts.capture_page_read("alice", page_source=RecordedPageSource(source.key)) with pytest.raises(StaleRevision): live.transcripts.bind_page_read("bob", read.identity) live.bus.history.path.write_text("[]") diff --git a/tests/test_live_context_notifications.py b/tests/test_live_context_notifications.py index b4785afcdd..8cccc07d6b 100644 --- a/tests/test_live_context_notifications.py +++ b/tests/test_live_context_notifications.py @@ -10,6 +10,7 @@ from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordinator import Coordination from agent_comms.field_codec import InputId +from agent_comms.notification_evidence import UnansweredResponse from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof from agent_comms.native_runtime_input import NativeRuntimeInput from agent_comms.read_basis import AllOutcomes @@ -70,7 +71,7 @@ async def test_reserved_context_notifies_before_any_assistant_reply(tmp_path: Pa after = comms.views.message_notifications_for_references(AllOutcomes(), originals) after_rows = tuple(row for rows in after.outcomes.values() for row in rows) assert after_rows and all(row.state.consumed for row in after_rows) - assert all(row.answer is None for row in after_rows) + assert all(row.answer == UnansweredResponse() for row in after_rows) assert all(not row.displayed_to for row in after_rows) assert claim.stage.progress.current.starting with Coordination(claim.store_path) as store: @@ -91,7 +92,7 @@ async def test_triage_consumption_precedes_and_survives_later_verdict(tmp_path: await acknowledge_context(claim, context) active = claim.selected.comms.views.message_notifications_for_references(AllOutcomes(), originals) assert sum(row.state.consumed for rows in active.outcomes.values() for row in rows) == 1 - assert all(row.answer is None and not row.displayed_to + assert all(row.answer == UnansweredResponse() and not row.displayed_to for rows in active.outcomes.values() for row in rows) with Coordination(claim.store_path) as store: original = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) diff --git a/tests/test_monitor_delivery.py b/tests/test_monitor_delivery.py index a4b33810a4..24d665bd14 100644 --- a/tests/test_monitor_delivery.py +++ b/tests/test_monitor_delivery.py @@ -564,7 +564,7 @@ async def test_board_wake_observation_cannot_confirm_original_packet(bridge, ori retained, = await bridge.attempts(message.message_id) assert retained.outcome is EmittedOutcome assert retained.monitor == WakeOnlyMonitor() - assert not retained.consumed_progress(SentProgress(round(message.timestamp * 1000))).consumed + assert not retained.monitor.consumed_progress(SentProgress(round(message.timestamp * 1000))).consumed assert await bridge.coordinator_read(lambda db: HarnessObservedPart.of(db, retained)) == () assert not await bridge.coordinator_write(initial.begin) assert ReadLedger.at(bridge.comms.root).read() == human_reads diff --git a/tests/test_notification_read_state.py b/tests/test_notification_read_state.py index 4f3cbccafe..7bf11daa3a 100644 --- a/tests/test_notification_read_state.py +++ b/tests/test_notification_read_state.py @@ -1,3 +1,5 @@ +from agent_comms.transcripts import RecordedPageSource +from agent_comms.historical_views import HistorySourceKey import pickle import sys from dataclasses import replace @@ -47,7 +49,7 @@ def test_notification_read_states_use_the_request_revision_contract(comms): original = WireRevision.empty() later = replace(original, expiry_tick=original.expiry_tick + 60) live = LiveNotificationRead(()) - recorded = RecordedNotificationRead((), "archive") + recorded = RecordedNotificationRead((), HistorySourceKey("archive")) settled = NotificationsRead(original, {}) assert not live.changed_since(comms, original, settled) assert not live.changed_since(comms, later, settled) @@ -169,7 +171,7 @@ def test_changed_notification_interest_keeps_only_retained_read_states(): @pytest.mark.parametrize("operation", [ - LiveNotificationRead(REFERENCES), RecordedNotificationRead(REFERENCES, "archive"), + LiveNotificationRead(REFERENCES), RecordedNotificationRead(REFERENCES, HistorySourceKey("archive")), ], ids=["live", "recorded"]) def test_notification_operations_are_root_free_family_records(operation: NotificationRead) -> None: assert operation.references == REFERENCES @@ -187,26 +189,26 @@ def test_notification_operations_are_root_free_family_records(operation: Notific 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) + request = ReadNotifications("root", RecordedNotificationRead(REFERENCES, HistorySourceKey("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=RecordedNotificationRead(other_reference, HistorySourceKey("archive"))), + replace(request, operation=RecordedNotificationRead(REFERENCES, HistorySourceKey("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")) + kept = ReadNotifications("root", RecordedNotificationRead(REFERENCES, HistorySourceKey("archive"))) + replaced = ReadNotifications("root", RecordedNotificationRead(REFERENCES, HistorySourceKey("detached"))) + reattached = ReadNotifications("root", RecordedNotificationRead(REFERENCES, HistorySourceKey("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, + ReadNotifications("root", RecordedNotificationRead(REFERENCES, HistorySourceKey("archive"))), reattached, ))).observation() assert previous.for_interest(requested) is requested @@ -280,7 +282,7 @@ def test_notification_rank_overrides_follow_the_mro_without_changing_parent_rank SHARED_UI_READS = [ CommanderUnitRead(commander=LEAD, unit=WORKER, orders=ORDERS), TranscriptPageRead(thread="worker", position=EarlierThan(NativeTranscriptCursor("session.jsonl", 7)), - historical_source="archive"), + page_source=RecordedPageSource("archive")), ] @@ -316,7 +318,7 @@ def test_witnesses_cursors_and_archive_each_distinguish_a_shared_read_request(): replace(commander, operation=replace(SHARED_UI_READS[0], orders=(OrderId.mint(),))), page, replace(page, operation=replace(SHARED_UI_READS[1], position=EarlierThan(NativeTranscriptCursor("session.jsonl", 8)))), - replace(page, operation=replace(SHARED_UI_READS[1], historical_source="other-archive")), + replace(page, operation=replace(SHARED_UI_READS[1], page_source=RecordedPageSource("other-archive"))), ) assert len({commander, *distinct}) == 1 + len(distinct) diff --git a/tests/test_transcript_pages.py b/tests/test_transcript_pages.py index abc3badf69..85ab64438e 100644 --- a/tests/test_transcript_pages.py +++ b/tests/test_transcript_pages.py @@ -1,3 +1,4 @@ +from agent_comms.transcripts import RecordedPageSource from agent_comms.repo import Checkout from conftest import transcript_records from agent_comms.session_file import RegistrySessionLocation @@ -68,7 +69,7 @@ def shared_page(comms, operation: TranscriptPageRead) -> TranscriptSnapshotUpdat all three carry the same page, draft and witness as the domain capture.""" expected = TranscriptSnapshotUpdate.from_read(comms.transcripts.capture_page_read( operation.thread, position=operation.position, limit=operation.limit, - historical_source=operation.historical_source)) + page_source=RecordedPageSource(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_fields(operation), diff --git a/tools/module_layers.py b/tools/module_layers.py index bfb5cd98ba..d094c2a325 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -27,6 +27,7 @@ import ast import atexit import inspect +from io import StringIO import shutil import subprocess import sys @@ -39,7 +40,8 @@ from functools import cache, cached_property from pathlib import Path -from mypy.config_parser import toml_config_types +from mypy.config_parser import parse_config_file +from mypy.options import Options from agent_comms.field_codec import FieldCodec, boundary_decoder from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side @@ -325,10 +327,19 @@ def source_paths(self) -> tuple[str, ...]: path dialect. An explicit empty override stays empty. Projects without an override retain the existing installed-package/src convention. """ - project = tomllib.loads((self.checkout / "pyproject.toml").read_text()) - declared = project.get("tool", {}).get("mypy", {}).get("mypy_path", ["src", "."]) - roots = FieldCodec.decode(list[str], toml_config_types["mypy_path"](declared)) - return tuple(str((self.checkout / root).resolve()) for root in roots) + options = Options() + options.mypy_path = ["src", "."] + errors = StringIO() + parse_config_file(options, self.source_strict_flags, + str(self.checkout / "pyproject.toml"), stderr=errors) + diagnostics = errors.getvalue() + if diagnostics: + raise ValueError(diagnostics) + return tuple(str((self.checkout / root).resolve()) for root in options.mypy_path) + + @staticmethod + def source_strict_flags() -> None: + """Strictness changes no source roots; the checker owns strict type checking.""" @property def import_paths(self) -> tuple[str, ...]: From d06847ad00016517f60965f5b08070a54cbf4870 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:48:38 -0400 Subject: [PATCH 081/109] Choose painted cursor work before opening the read transaction --- src/agent_comms/transcripts.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/agent_comms/transcripts.py b/src/agent_comms/transcripts.py index 5218183c95..30cb6c3852 100644 --- a/src/agent_comms/transcripts.py +++ b/src/agent_comms/transcripts.py @@ -122,6 +122,11 @@ def requires_commit(self) -> bool: """A changed transcript at a read position needs no commit before it is read again.""" return False + def painted_read[Result](self, acknowledge: Callable[[TranscriptCursor], Result], + nothing: Result) -> Result: + """Choose painted acknowledgement work before any reader or write lock is opened.""" + return acknowledge(self) + async def read_page(self, loader: Callable[..., Awaitable[TranscriptPage]], position: PagePosition) -> TranscriptPage: return await loader(position=position, limit=self.page_limit()) @@ -176,6 +181,10 @@ def requires_commit(self) -> bool: """A replaced source with nothing read must be committed before it is read again.""" return True + def painted_read[Result](self, acknowledge: Callable[[TranscriptCursor], Result], + nothing: Result) -> Result: + return nothing + def at_offset(self, offset: int) -> TranscriptCursor: return self From 9021283e36704573937a9df834812eba369d3b42 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:05:12 -0400 Subject: [PATCH 082/109] Put carrier construction and notification answers on their actual owners --- src/agent_comms/bus_read.py | 2 +- src/agent_comms/cli_bridges/claude.py | 7 ++--- src/agent_comms/harness_delivery_states.py | 15 ++++------ src/agent_comms/head_review.py | 24 +++++++++------- src/agent_comms/integration.py | 17 +++++++---- src/agent_comms/native_session_files.py | 3 +- src/agent_comms/notification_evidence.py | 6 ---- src/agent_comms/notification_progress.py | 32 +++++++++++++++------ src/agent_comms/presentation.py | 9 ++++-- src/agent_comms/read_basis.py | 8 ++++-- tests/test_monitor_delivery.py | 4 +-- tests/test_native_context_reference.py | 22 +++++++------- tests/test_notification_display_boundary.py | 2 +- tests/test_notification_display_viewer.py | 12 ++++---- tests/test_transcript_pages.py | 2 +- 15 files changed, 90 insertions(+), 75 deletions(-) diff --git a/src/agent_comms/bus_read.py b/src/agent_comms/bus_read.py index 0747011dfa..2e6fd68755 100644 --- a/src/agent_comms/bus_read.py +++ b/src/agent_comms/bus_read.py @@ -23,7 +23,7 @@ class OperatorReader(ReadAuthority): display: DisplayCut = field(default_factory=NoDisplaySelection) def outcome_viewer(self, registry: Registration) -> OutcomeViewer: - return self.display.outcome_viewer() + return self.display.outcome_viewer(registry) class BusRead[Result](ToolDeclaration, ABC): diff --git a/src/agent_comms/cli_bridges/claude.py b/src/agent_comms/cli_bridges/claude.py index 9294924572..98c2dbd7a2 100755 --- a/src/agent_comms/cli_bridges/claude.py +++ b/src/agent_comms/cli_bridges/claude.py @@ -3,13 +3,12 @@ import asyncio from contextlib import aclosing -from dataclasses import replace from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, AsyncIterator, BinaryIO, ClassVar +from typing import TYPE_CHECKING, AsyncIterator, BinaryIO from agent_comms.bus import Bus -from agent_comms.harness_delivery_states import BoardWake, Outcome, WakeOnlyMonitor +from agent_comms.harness_delivery_states import Outcome, WakeOnlyMonitor from agent_comms.messages import Message from agent_comms.private_path import FileRevision from agent_comms.runtime_updates import RuntimeUpdate @@ -26,7 +25,7 @@ from agent_comms.task_attachments.commander_pings import CommanderUpdate -class ClaudeBridge(BoardWake, ClaudeJsonLines, ClaudeHistory, LineMonitorBridge): +class ClaudeBridge(ClaudeJsonLines, ClaudeHistory, LineMonitorBridge): """Follow this Claude session's file without submitting an input. Heartbeat packets reach the session through the board its tool-call hook prints, which diff --git a/src/agent_comms/harness_delivery_states.py b/src/agent_comms/harness_delivery_states.py index 704d822dca..9ca9daa25a 100644 --- a/src/agent_comms/harness_delivery_states.py +++ b/src/agent_comms/harness_delivery_states.py @@ -143,15 +143,6 @@ class UnrecordedMonitor(MonitorEvidence): pass -class BoardWake: - """The board notice's text, shared by its publisher and its transport evidence.""" - - board_wake: ClassVar[str] = ( - "Heartbeat: your board changed. New and changed lines print once as BOARD: on your next " - "tool call; later calls print one unchanged count." - ) - - class PendingMonitor(MonitorEvidence, declared_name=None): """The emission owns whether its later observation can prove the original body.""" @@ -165,9 +156,13 @@ def for_parts(cls, total: int) -> PendingMonitor: ... @dataclass(frozen=True) -class WakeOnlyMonitor(BoardWake, PendingMonitor): +class WakeOnlyMonitor(PendingMonitor): """A board wake is not the original packet, even if its bytes happen to match.""" + board_wake: ClassVar[str] = ( + "Heartbeat: your board changed. New and changed lines print once as BOARD: on your next " + "tool call; later calls print one unchanged count." + ) @classmethod def displayed_body(cls, body: str) -> str: return cls.board_wake diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index cd60cff62a..d4c6dfd67e 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -7,7 +7,7 @@ import hashlib import json -from abc import abstractmethod +from abc import ABC, abstractmethod from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Annotated, ClassVar @@ -18,13 +18,12 @@ from .errors import ToolRefusedError from .field_codec import FieldCodec, GitCommitText, MalformedRecord, RecordProblems from .installer.runtime_resolution import StackSources -from .repo import GitCommandError, Repo +from .repo import Checkout, GitCommandError, Repo from .repository_name import RepositoryName from .work_branches import WorkBranches if TYPE_CHECKING: from .goal_management import Goals - from .integration import IntegrationCheckout from .thread_identity import ThreadIncarnation from .todos import HeadReviewRecord, HeadVerdict, Todo @@ -121,6 +120,15 @@ def current(self) -> OpenPullRequestHead: return current +class SourceCheckout(Checkout, ABC): + """The nominal checkout capability a captured source needs to build its tree.""" + + __slots__ = () + + @abstractmethod + def combine_sources(self, base: str, heads: tuple[PullRequestHead, ...]) -> str: ... + + @dataclass(frozen=True) class IntegrationSource(DeclaredFamily, affix="Source"): """One repository's captured main, exact PR overlays and resulting Git tree.""" @@ -163,13 +171,7 @@ def main_head(repository: Repo) -> str: def candidate_commit(self) -> str: return self.baseline.commit - def materialize(self, repository: Repo, path: Path) -> IntegrationCheckout: - from .integration import IntegrationCheckout - checkout = IntegrationCheckout.create(repository, path, self.candidate_commit) - self.prepare_candidate(checkout) - return checkout - - def prepare_candidate(self, checkout: IntegrationCheckout) -> None: + def prepare_candidate(self, checkout: SourceCheckout) -> None: checkout.combine_sources(self.baseline.commit, self.heads) def require_origin(self, repository: Repo) -> None: @@ -249,7 +251,7 @@ def __post_init__(self) -> None: def candidate_commit(self) -> str: return self.carrier.head - def prepare_candidate(self, checkout: IntegrationCheckout) -> None: + def prepare_candidate(self, checkout: SourceCheckout) -> None: self.require_ancestry(Repo.of(checkout.path), self.carrier.head) def require_tree(self, repository: Repo) -> None: diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index 16533f42de..4ad1d6a5e1 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -20,14 +20,14 @@ from .declared_family import DeclaredFamily from .errors import ToolRefusedError from .field_codec import FieldCodec, GitCommitText, PathText, WireName, boundary_decoder -from .head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, IntegrationSource, MergedIntegrationSource, MergeHeadReview, OrdinaryInput, PromotedHeadReview, PullRequestHead, ResolvedIntegrationSource, Side, UnpromotedHeadReview +from .head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, IntegrationSource, MergedIntegrationSource, MergeHeadReview, OrdinaryInput, PromotedHeadReview, PullRequestHead, ResolvedIntegrationSource, Side, SourceCheckout, UnpromotedHeadReview from .installer.runtime_resolution import StackSources from .launch_runtime import CheckoutSource, RestoredRuntime from .repo import Checkout, DetachedStart, GitCommandError, Repo from .systemd_unit import AgentsSlice from .repository_name import RepositoryName from .test_lease import LeaseStart, TestLease -from .todos import PullRequests, StoredHeadReview +from .todos import CarriedHeadVerdict, PullRequests, StoredHeadReview, VerdictReason from .work_branches import WorkBranches class IntegrationFailed(ToolRefusedError): @@ -123,7 +123,7 @@ def remaining( return remaining -class IntegrationCheckout(Checkout): +class IntegrationCheckout(SourceCheckout): """The existing checkout owner owns combination and its changed-file selection.""" def require_editable(self) -> None: @@ -152,6 +152,12 @@ def create(cls, repository: Repo, path: Path, commit: str) -> IntegrationCheckou created.require_editable() return created + @classmethod + def materialize(cls, source: IntegrationSource, repository: Repo, path: Path) -> IntegrationCheckout: + checkout = cls.create(repository, path, source.candidate_commit) + source.prepare_candidate(checkout) + return checkout + def include(self, head: PullRequestHead) -> None: """Fetch exactly the captured member; Git, not a patch heuristic, combines it.""" Repo.git(self.path, "fetch", "origin", f"refs/pull/{head.pr}/head", timeout=300) @@ -405,7 +411,7 @@ def materialize(self, directory: Path) -> IntegrationInput: if source.repository == self.repository.github: IntegrationCheckout.create(repository, HeadSide.checkout(directory, source.repository), self.tested) else: - source.materialize(repository, HeadSide.checkout(directory, source.repository)) + IntegrationCheckout.materialize(source, repository, HeadSide.checkout(directory, source.repository)) for side in Side.sides(): side.require_materialized(directory, source) checkout = side.checkout(directory, source.repository) @@ -482,7 +488,6 @@ def publish_merge_review(self) -> None: Carry those decisions through the existing nominal status owners; no new verdict is saved, and an ordinary PR never publishes a merge-group result. """ - from .todos import CarriedHeadVerdict, VerdictReason CarriedHeadVerdict(VerdictReason( "The validated merge group preserves this promoted carrier and its exact reviewed sources"), self.carrier).post(self.carrier.repo, self.tested, "merge-group proof", f"#{self.carrier.pr}") @@ -672,7 +677,7 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, tree = Repo.git(repo.checkout, "rev-parse", f"{companion.head}^{{tree}}").strip() source = ResolvedIntegrationSource( RepositoryCommit(name, baseline), heads[name], tree, companion) - source.materialize(repo, HeadSide.checkout(self.output, name)) + IntegrationCheckout.materialize(source, repo, HeadSide.checkout(self.output, name)) else: candidate = IntegrationCheckout.create(repo, HeadSide.checkout(self.output, name), baseline) tree = candidate.combine_sources(baseline, heads[name]) diff --git a/src/agent_comms/native_session_files.py b/src/agent_comms/native_session_files.py index 3202f2fe29..a3accae93c 100644 --- a/src/agent_comms/native_session_files.py +++ b/src/agent_comms/native_session_files.py @@ -22,6 +22,7 @@ from .errors import RelationViolationError from .native_failures import NativePiUnavailable +from .native_input_proof import NativeContextJournal from .process_claim import ProcessSingleton from agent_comms.typed_table import ReconciledFile, TableSchema @@ -256,8 +257,6 @@ class NativeInputProof(DurableFile, ReconciledFile, PatternFile): @classmethod def table_schema(cls) -> type[TableSchema]: - from .native_input_proof import NativeContextJournal - return NativeContextJournal diff --git a/src/agent_comms/notification_evidence.py b/src/agent_comms/notification_evidence.py index 65439c364f..f0ae7dfed0 100644 --- a/src/agent_comms/notification_evidence.py +++ b/src/agent_comms/notification_evidence.py @@ -58,9 +58,6 @@ def from_columns( def detail_texts(self) -> tuple[str, ...]: return () - def notification_label(self, notification: MessageNotification, label: str) -> str: - raise ValueError("An unanswered source has no answer timestamp") - @abstractmethod def completed_notification( self, progress: AssignmentProgress, notification: MessageNotification @@ -98,9 +95,6 @@ def answered(self, notification: MessageNotification) -> MessageNotification: def detail_texts(self) -> tuple[str, ...]: return (f"Answer #{self.reference.seq} · {self.reference.message_id}",) - def notification_label(self, notification: MessageNotification, label: str) -> str: - return notification.timed_state(label, self.received_at_ms) - @dataclass(frozen=True) class SourceEvidence: diff --git a/src/agent_comms/notification_progress.py b/src/agent_comms/notification_progress.py index 5ecd2a25c0..16039c6fc9 100644 --- a/src/agent_comms/notification_progress.py +++ b/src/agent_comms/notification_progress.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, ClassVar, Self from .declared_family import DeclaredOrder -from .notification_evidence import AssignmentResponse +from .notification_evidence import AnsweredResponse, AssignmentResponse, UnansweredResponse from .thread_presentation import ThreadPresentationStatus, WorkingThreadPresentationStatus if TYPE_CHECKING: @@ -38,6 +38,10 @@ class DeliveryProgress(ThreadPresentationStatus): ignored_label: ClassVar[str] = "Not answering" consumed: ClassVar[bool] = False + @property + def answer(self) -> AssignmentResponse: + return UnansweredResponse() + def rank(self) -> int: return DeliveryRank.position_of(type(self)) @@ -73,9 +77,9 @@ def counted_summary_prefix(self, notification: MessageNotification) -> tuple[str return (notification.label,) def answered_notification(self, notification: MessageNotification, - response: AssignmentResponse) -> MessageNotification: + response: AnsweredResponse) -> MessageNotification: """An independent answer preserves this state's actual context proof.""" - return replace(notification, state=AnsweredProgress(self), answer=response, changed=True) + return replace(notification, state=AnsweredProgress(self, response), changed=True) def model_consumed(self, proof: ShownToModelProgress) -> DeliveryProgress: """The first proof that the message entered the model's context, at its own time.""" @@ -121,6 +125,10 @@ class ContextualProgress(WaitingDelivery, DeliveryProgress): def consumed(self) -> bool: return self.proof.consumed + @property + def answer(self) -> AssignmentResponse: + return self.proof.answer + def model_consumed(self, proof: ShownToModelProgress) -> DeliveryProgress: return replace(self, proof=self.proof.model_consumed(proof)) @@ -183,23 +191,29 @@ class FailedProgress(ContextualProgress): label = "Failed" +@dataclass(frozen=True) class AnsweredProgress(SettledDelivery, ContextualProgress): + response: AnsweredResponse label = "Answered" + @property + def answer(self) -> AnsweredResponse: + return self.response + def answered_notification(self, notification: MessageNotification, - response: AssignmentResponse) -> MessageNotification: + response: AnsweredResponse) -> MessageNotification: """A later independent response cannot replace an already recorded answer.""" return notification def notification_label(self, notification: MessageNotification) -> str: - return notification.answer.notification_label(notification, self.label) + return notification.timed_state(self.label, self.response.received_at_ms) -class CompletedProgress(AnsweredProgress): - detail = "The response workflow completed." +class CompletedProgress(SettledDelivery, ContextualProgress): + """Workflow completion is not evidence of an independent reply.""" - def notification_label(self, notification: MessageNotification) -> str: - return self.label + label = "Completed" + detail = "The response workflow completed." class AssignmentProgress(DeliveryProgress): @property diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 61c7d5acb6..54834e57f3 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -68,7 +68,7 @@ ) from .read_ledger import NoReadNotice, ReadLedger, ReadNotice from .notification_assignment import NotificationAssignment -from .notification_evidence import AssignmentResponse, UnansweredResponse, NotificationAssignmentRead, NotificationReadMark, NotificationWindow, RecipientSelection +from .notification_evidence import AssignmentResponse, NotificationAssignmentRead, NotificationReadMark, NotificationWindow, RecipientSelection from .read_basis import FirstRecipient, OutcomeViewer, RecipientCursor from .notification_read import CapturedNotificationWindow from .recorded_bus import NoRecordedBus, RecordedBusIdentity @@ -128,9 +128,12 @@ class MessageNotification: state: DeliveryProgress message: Message | None = None displayed_to: tuple[ThreadIncarnation, ...] = () - answer: AssignmentResponse = field(default_factory=UnansweredResponse) changed: bool = False + @property + def answer(self) -> AssignmentResponse: + return self.state.answer + @property def recipient(self) -> str: return self.recipient_identity.canonical_thread @@ -146,7 +149,7 @@ def sent_outcome(self) -> SentMessageOutcome | None: if not self.changed or self.message is None: return None return SentMessageOutcome(self.recipient_identity, self.sender_lookup, self.state, - message=self.message, answer=self.answer) + message=self.message) @classmethod def ordered(cls, notifications: Iterable[MessageNotification]) -> tuple[MessageNotification, ...]: diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 24a317504c..6ad260a55d 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -270,13 +270,17 @@ def refuse_thread(self) -> NoReturn: class OperatorOutcomes: """An unscoped operator reader sees every recipient outcome.""" - def outcome_viewer(self, *_context: object) -> OutcomeViewer: + __slots__ = () + + def outcome_viewer(self, registry: Registration) -> OutcomeViewer: return AllOutcomes() class DisplayCut(OperatorOutcomes, ABC): """The selected display cut, including the absence of a painted display.""" + __slots__ = () + @property @abstractmethod def notification_identity(self) -> DisplayCut: ... @@ -296,7 +300,7 @@ def notification_identity(self) -> DisplayIdentity: return DisplayIdentity(viewer=self.viewer, history_revision=self.history_revision, bus_identity=self.bus_identity) - def outcome_viewer(self, *_context: object) -> OutcomeViewer: + def outcome_viewer(self, registry: Registration) -> OutcomeViewer: return DisplayOutcomes(self.viewer, self.bus_identity) diff --git a/tests/test_monitor_delivery.py b/tests/test_monitor_delivery.py index 24d665bd14..9d05bfe481 100644 --- a/tests/test_monitor_delivery.py +++ b/tests/test_monitor_delivery.py @@ -544,7 +544,7 @@ async def test_a_body_that_fits_one_event_goes_inline_and_exports_nothing(bridge @pytest.mark.asyncio -@pytest.mark.parametrize('original_body', ('Original board packet', ClaudeBridge.board_wake)) +@pytest.mark.parametrize('original_body', ('Original board packet', WakeOnlyMonitor.board_wake)) async def test_board_wake_observation_cannot_confirm_original_packet(bridge, original_body): """Exercise the actual wake transport, including an accidental exact-body collision.""" message = bridge.comms.messaging.send('peer', 'viewer', original_body).message @@ -554,7 +554,7 @@ async def test_board_wake_observation_cannot_confirm_original_packet(bridge, ori async with attached_monitor(bridge) as reader: outcome = await bridge.wake(message) line = await only_event(reader) - assert ClaudeMonitorFrame.decode_line(line).body == ClaudeBridge.board_wake + assert ClaudeMonitorFrame.decode_line(line).body == WakeOnlyMonitor.board_wake await bridge.finish(bridge.attempt(message, outcome), partial(replace, outcome=outcome)) # Re-reading the wake cannot manufacture a proof of the original body. for _ in range(2): diff --git a/tests/test_native_context_reference.py b/tests/test_native_context_reference.py index 5ad9bd5fa5..60b227d9f0 100644 --- a/tests/test_native_context_reference.py +++ b/tests/test_native_context_reference.py @@ -123,7 +123,7 @@ def test_original_unrecorded_context_and_reserved_input_remain_distinct_from_cor @pytest.fixture -def historical_source(recorded): +def native_source(recorded): return dict( wire_root_id="a" * 32, source_seq=1, source_message_id=MessageId.mint("original-message"), assignment_id="d" * 32, input_id=recorded.input_id, @@ -143,7 +143,7 @@ def historical_source(recorded): (partial(CommittedCursorOwner, input_id=InputId("d" * 32)), False), ]) def test_cursor_owner_enforces_exact_settlement_without_restricting_refresh( - tmp_path, recorded, historical_source, make_owner, permitted, + tmp_path, recorded, native_source, make_owner, permitted, ): thread = Thread("owner", frozenset(), Checkout(tmp_path)) original = replace( @@ -151,7 +151,7 @@ def test_cursor_owner_enforces_exact_settlement_without_restricting_refresh( verdict=DecidedTriageRecord(IgnoreSelectedTriage), ) owner = make_owner(thread=thread, claimed_generation=2, generation=1, wire_root_id="a" * 32) - source = {**historical_source, "owner_lookup": original.owner_lookup} + source = {**native_source, "owner_lookup": original.owner_lookup} with sqlite3.connect(":memory:") as db: NativeRuntimeInput.create(db) original.insert(db) @@ -161,8 +161,8 @@ def test_cursor_owner_enforces_exact_settlement_without_restricting_refresh( assert InjectedInput(proof).allows(owner, db, UnstartedCursor()) is permitted -def test_original_native_sql_acquires_required_triage_or_full_recorded_proof(recorded, historical_source): - source = historical_source +def test_original_native_sql_acquires_required_triage_or_full_recorded_proof(recorded, native_source): + source = native_source with sqlite3.connect(":memory:") as db: NativeRuntimeInput.create(db) recorded.insert(db) @@ -207,7 +207,7 @@ def test_original_native_sql_acquires_required_triage_or_full_recorded_proof(rec def test_a_restarted_claim_advances_past_the_inputs_its_earlier_claim_sent( - tmp_path, recorded, historical_source, + tmp_path, recorded, native_source, ): """Every install mints a new claim; the thread's cursor and its inputs stay its own.""" thread = Thread("owner", frozenset(), Checkout(tmp_path)) @@ -220,7 +220,7 @@ def test_a_restarted_claim_advances_past_the_inputs_its_earlier_claim_sent( CurrentNativeCursor.create(db) sent.insert(db) proof = sent.execution.historical_proof( - sent, lifecycle=TriagePendingAssignment(), **{**historical_source, "owner_lookup": lookup}, + sent, lifecycle=TriagePendingAssignment(), **{**native_source, "owner_lookup": lookup}, ) assert restarted.matches_prefix(db, (proof,)) assert InjectedInput(proof).allows(restarted, db, UnstartedCursor()) @@ -243,8 +243,8 @@ def test_a_restarted_claim_advances_past_the_inputs_its_earlier_claim_sent( newer = replace(sent, input_id=FieldCodec.decode(InputId, "9" * 32), sent_owner_claimed_generation=RecordedNativeClaim(6)) newer.insert(db) newer_proof = newer.execution.historical_proof(newer, lifecycle=TriagePendingAssignment(), **{ - **historical_source, "owner_lookup": lookup, "input_id": newer.input_id, - "context": replace(historical_source["context"], input_id=newer.input_id), + **native_source, "owner_lookup": lookup, "input_id": newer.input_id, + "context": replace(native_source["context"], input_id=newer.input_id), "native_reference": newer.reference, }) assert restarted.matches_prefix(db, (proof,)) @@ -252,7 +252,7 @@ def test_a_restarted_claim_advances_past_the_inputs_its_earlier_claim_sent( def test_a_retried_participant_generation_advances_past_the_inputs_its_earlier_attempt_sent( - tmp_path, recorded, historical_source, + tmp_path, recorded, native_source, ): """A retry advances the participant generation (selected_participant.py prepare_attempt); same participant.""" thread = Thread("owner", frozenset(), Checkout(tmp_path)) @@ -266,7 +266,7 @@ def test_a_retried_participant_generation_advances_past_the_inputs_its_earlier_a CurrentNativeCursor.create(db) sent.insert(db) proof = sent.execution.historical_proof( - sent, lifecycle=TriagePendingAssignment(), **{**historical_source, "owner_lookup": lookup}, + sent, lifecycle=TriagePendingAssignment(), **{**native_source, "owner_lookup": lookup}, ) assert retried.matches_prefix(db, (proof,)) assert InjectedInput(proof).allows(retried, db, UnstartedCursor()) diff --git a/tests/test_notification_display_boundary.py b/tests/test_notification_display_boundary.py index 6ee6968fed..534a127db8 100644 --- a/tests/test_notification_display_boundary.py +++ b/tests/test_notification_display_boundary.py @@ -24,7 +24,7 @@ def test_notification_request_retains_viewer_source_and_full_operator_access(arc assert len(frozenset((request, another_viewer))) == 2 scoped = request.read(fixture.live) raw = fixture.notifications(AllOutcomes()) - assert scoped.outcomes == fixture.notifications(identity.outcome_viewer()).outcomes + assert scoped.outcomes == fixture.notifications(identity.outcome_viewer(fixture.live.registry)).outcomes assert scoped.read_marks == raw.read_marks assert {row.recipient_identity for rows in scoped.outcomes.values() for row in rows} == { row.recipient_identity for rows in raw.outcomes.values() for row in rows} diff --git a/tests/test_notification_display_viewer.py b/tests/test_notification_display_viewer.py index 2aad70188b..79f9ae6e7d 100644 --- a/tests/test_notification_display_viewer.py +++ b/tests/test_notification_display_viewer.py @@ -81,7 +81,7 @@ def test_display_subtraction_preserves_raw_other_reads_and_real_audience( raw_document = fixture.reads.read() live_document = fixture.live.bus.reads.read() raw = fixture.notifications(AllOutcomes()) - shown = fixture.notifications(identity.outcome_viewer()) + shown = fixture.notifications(identity.outcome_viewer(fixture.live.registry)) agent = fixture.notifications(OwnerOutcomes.of(fixture.source.provenance.require('sender'))) key = (fixture.message.seq, fixture.message.message_id) before = {item.recipient_identity: item for item in raw.outcomes[key]} @@ -115,19 +115,19 @@ def test_archive_display_requires_its_own_bus_even_for_the_same_viewer(archived_ assert not foreign.bus_identity.matches(captured.bus_identity) raw = fixture.notifications(AllOutcomes()) assert any(mark.displayed_to for mark in raw.read_marks) - assert fixture.notifications(foreign.outcome_viewer()).outcomes == raw.outcomes + assert fixture.notifications(foreign.outcome_viewer(fixture.live.registry)).outcomes == raw.outcomes def test_archive_viewer_rename_and_recreation_use_incarnation_not_current_name(archived_readers: ArchivedReaders) -> None: fixture = archived_readers fixture.paint('self') captured = fixture.selection('self').notification_identity - before = fixture.notifications(captured.outcome_viewer()) + before = fixture.notifications(captured.outcome_viewer(fixture.live.registry)) raw = fixture.reads.read() fixture.live.registry.rename('self', 'renamed') renamed = fixture.selection('renamed').notification_identity assert renamed.viewer == captured.viewer - assert fixture.notifications(renamed.outcome_viewer()).outcomes == before.outcomes + assert fixture.notifications(renamed.outcome_viewer(fixture.live.registry)).outcomes == before.outcomes previous = fixture.live.registry.require('renamed') fixture.live.registry.unregister('renamed') fixture.live.registry.remove('renamed') @@ -136,7 +136,7 @@ def test_archive_viewer_rename_and_recreation_use_incarnation_not_current_name(a )) recreated = fixture.selection('self').notification_identity assert recreated.viewer != captured.viewer - other = fixture.notifications(recreated.outcome_viewer()) + other = fixture.notifications(recreated.outcome_viewer(fixture.live.registry)) assert other.outcomes == fixture.notifications(AllOutcomes()).outcomes assert any(mark.displayed_to for mark in other.read_marks) assert fixture.reads.read() == raw @@ -154,7 +154,7 @@ def test_direct_human_self_row_disappears_without_read_or_unread_mutation(comms: document = comms.bus.reads.read() unread = comms.views.viewer_snapshot(worktree).unread full = comms.views.message_notifications_for_references(AllOutcomes(), (message.reference,)) - shown = comms.views.message_notifications_for_references(identity.outcome_viewer(), (message.reference,)) + shown = comms.views.message_notifications_for_references(identity.outcome_viewer(comms.registry), (message.reference,)) key = (message.seq, message.message_id) assert full.outcomes[key] and full.outcomes[key][0].displayed_to == (human.incarnation,) assert shown.outcomes[key] == () diff --git a/tests/test_transcript_pages.py b/tests/test_transcript_pages.py index 85ab64438e..eb4c380705 100644 --- a/tests/test_transcript_pages.py +++ b/tests/test_transcript_pages.py @@ -69,7 +69,7 @@ def shared_page(comms, operation: TranscriptPageRead) -> TranscriptSnapshotUpdat all three carry the same page, draft and witness as the domain capture.""" expected = TranscriptSnapshotUpdate.from_read(comms.transcripts.capture_page_read( operation.thread, position=operation.position, limit=operation.limit, - page_source=RecordedPageSource(operation.historical_source))) + page_source=operation.page_source)) direct = operation.read(comms) assert ReadTranscript(str(comms.root), operation).read(comms) == direct == expected assert invoke_tool(comms, operation.declared_name, FieldCodec.encode_fields(operation), From 144d49ed7d30d37c9ce49900a2cffec2dae30bc1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:12:46 -0400 Subject: [PATCH 083/109] Declare native journal membership on its proof file and reuse display cuts --- src/agent_comms/native_input_proof.py | 17 +++++++++++++++-- src/agent_comms/native_session_files.py | 17 ++--------------- src/agent_comms/ui_model/observation.py | 4 ++-- tests/test_bus_files.py | 2 +- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/agent_comms/native_input_proof.py b/src/agent_comms/native_input_proof.py index cd07d71c87..23288b28c4 100644 --- a/src/agent_comms/native_input_proof.py +++ b/src/agent_comms/native_input_proof.py @@ -26,7 +26,7 @@ Sha256Text, WireName, ) -from .locked_store import SQLiteFile +from .locked_store import DurableFile, PatternFile, SQLiteFile from .native_failures import NativePiUnavailable from .native_session_reopen import PiSessionId from .turn_item import FreshNativeId @@ -38,6 +38,8 @@ ImmutableRows, Index, IntegerStorage, + ReconciledFile, + TableSchema, WithoutRowid, ) @@ -141,9 +143,20 @@ def at(self, session_file: Path, *, session_entry_start: int) -> NativeContextPr ) +class NativeInputProof(DurableFile, ReconciledFile, PatternFile, DurableTables): + """A retained proof file whose journal declares membership through inheritance.""" + + __slots__ = () + filename = "input-proofs/{lookup}/{name}.sqlite" + + @classmethod + def table_schema(cls) -> type[TableSchema]: + return cls + + @dataclass(frozen=True, slots=True) class NativeContextJournal( - GuardedInserts, ImmutableRows, NativeContextRecord, WithoutRowid, DurableTables + GuardedInserts, ImmutableRows, NativeContextRecord, WithoutRowid, NativeInputProof ): """One proof declaration owns wire facts and the indexed durable journal.""" diff --git a/src/agent_comms/native_session_files.py b/src/agent_comms/native_session_files.py index a3accae93c..478b5bff57 100644 --- a/src/agent_comms/native_session_files.py +++ b/src/agent_comms/native_session_files.py @@ -15,16 +15,15 @@ from typing import Self from agent_comms.child_process import Platform, ProcessIdentity -from agent_comms.locked_store import DurableFile, PatternFile, SQLiteFile +from agent_comms.locked_store import DurableFile, PatternFile from agent_comms.native_session_reopen import NativeSessionIdentity from agent_comms.participant_identity import ParticipantIdentity from agent_comms.private_path import PrivateDirectoryRole, TrustedAncestorRole from .errors import RelationViolationError from .native_failures import NativePiUnavailable -from .native_input_proof import NativeContextJournal +from .native_input_proof import NativeInputProof from .process_claim import ProcessSingleton -from agent_comms.typed_table import ReconciledFile, TableSchema class SessionFiles: @@ -248,18 +247,6 @@ def input_proof(self) -> Path: ) -class NativeInputProof(DurableFile, ReconciledFile, PatternFile): - """One thread's native-session proof, retained and copied with its bus; the installer - reconciles each onto NativeContextJournal like every other declared store.""" - - __slots__ = () - filename = "input-proofs/{lookup}/{name}.sqlite" - - @classmethod - def table_schema(cls) -> type[TableSchema]: - return NativeContextJournal - - class BusTranscriptFile(PatternFile): """A bus location holding saved native transcripts and their writer locks.""" diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index ae99ed7e2a..dec39ed95f 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -59,7 +59,7 @@ 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.read_basis import DisplayIdentity, NoDisplaySelection +from agent_comms.read_basis import DisplayCut, NoDisplaySelection from agent_comms.presentation import ( CoordinationSnapshot, MessageNotification, NoRevision, ReadState, WireRevision, @@ -584,7 +584,7 @@ class ReadNotifications(ReadRequest): """The UI's root and request witness around one root-free notification operation.""" operation: NotificationRead - display: DisplayIdentity | NoDisplaySelection = field( + display: DisplayCut = field( default_factory=NoDisplaySelection, kw_only=True) """The painted display this read serves; it decides whose read marks the window omits.""" diff --git a/tests/test_bus_files.py b/tests/test_bus_files.py index 83311c00f2..2f6f41da18 100644 --- a/tests/test_bus_files.py +++ b/tests/test_bus_files.py @@ -16,7 +16,7 @@ from agent_comms.installer.backup import bus_files from agent_comms.locked_store import DeclaredFile, DurableFile, LiveFilesRequest, RootFile, RuntimeFile, SQLiteFile from agent_comms.native_input_proof import NativeContextJournal -from agent_comms.native_session_files import NativeInputProof +from agent_comms.native_input_proof import NativeInputProof from agent_comms.recorded_time import RecordedEventTime from agent_comms.repository_name import RepositoryName from agent_comms.threads import Thread From 7fe707f0a5d4592d037430046da98efaa9a26f05 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:42:16 -0400 Subject: [PATCH 084/109] Keep native journal rows separate from their file family --- src/agent_comms/native_input_proof.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/agent_comms/native_input_proof.py b/src/agent_comms/native_input_proof.py index 23288b28c4..a62b44c241 100644 --- a/src/agent_comms/native_input_proof.py +++ b/src/agent_comms/native_input_proof.py @@ -143,20 +143,9 @@ def at(self, session_file: Path, *, session_entry_start: int) -> NativeContextPr ) -class NativeInputProof(DurableFile, ReconciledFile, PatternFile, DurableTables): - """A retained proof file whose journal declares membership through inheritance.""" - - __slots__ = () - filename = "input-proofs/{lookup}/{name}.sqlite" - - @classmethod - def table_schema(cls) -> type[TableSchema]: - return cls - - @dataclass(frozen=True, slots=True) class NativeContextJournal( - GuardedInserts, ImmutableRows, NativeContextRecord, WithoutRowid, NativeInputProof + GuardedInserts, ImmutableRows, NativeContextRecord, WithoutRowid, DurableTables ): """One proof declaration owns wire facts and the indexed durable journal.""" @@ -251,6 +240,17 @@ def corroborate( return self.at(session_file, session_entry_start=session_entry_start) +class NativeInputProof(DurableFile, ReconciledFile, PatternFile): + """The retained proof file names its journal without joining their two families.""" + + __slots__ = () + filename = "input-proofs/{lookup}/{name}.sqlite" + + @classmethod + def table_schema(cls) -> type[TableSchema]: + return NativeContextJournal + + @dataclass(frozen=True, slots=True) class NativeContextProof(NativeContextRecord, NativeInputLocation): session_file: Annotated[Path, PathText] From bca44946c37d208f0956c2c82f6f3274294999ae Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 15:40:23 -0400 Subject: [PATCH 085/109] Close notification selection and native proof ownership across consumers --- src/agent_comms/audience_manifest.py | 31 +-- src/agent_comms/backend.py | 8 +- src/agent_comms/bus_read.py | 3 +- src/agent_comms/commander/order.py | 2 +- src/agent_comms/harness_delivery_states.py | 63 +---- src/agent_comms/historical_native_inputs.py | 4 +- src/agent_comms/historical_views.py | 6 +- src/agent_comms/history_views.py | 6 +- src/agent_comms/messages.py | 3 +- src/agent_comms/native_entries.py | 40 ++- src/agent_comms/native_file_artifact.py | 6 +- src/agent_comms/native_input_proof.py | 78 ++---- src/agent_comms/native_input_record.py | 15 +- src/agent_comms/native_integer.py | 28 +++ src/agent_comms/native_pi.py | 25 +- src/agent_comms/native_transcript.py | 10 +- src/agent_comms/notification_assignment.py | 4 +- src/agent_comms/notification_evidence.py | 231 ++++++++++++++---- src/agent_comms/notification_reads.py | 6 +- src/agent_comms/participant_identity.py | 34 ++- src/agent_comms/pi_events.py | 27 +- src/agent_comms/presentation.py | 10 +- src/agent_comms/private_registry_guard.py | 3 +- src/agent_comms/private_send_stage.py | 4 +- src/agent_comms/read_basis.py | 114 +-------- src/agent_comms/recorded_time.py | 11 + src/agent_comms/selected_participant.py | 4 +- src/agent_comms/thread_presentation.py | 4 +- src/agent_comms/tool_caller.py | 5 +- src/agent_comms/tools.py | 10 +- tests/c4_coordination_installed_ui.py | 2 +- tests/native_proof_cases.py | 4 +- tests/reply_relevance_installed_journey.py | 2 +- tests/test_channel_bus_ownership.py | 2 +- tests/test_following_context_notifications.py | 2 +- tests/test_harness_receipt_context.py | 21 +- tests/test_historical_notifications.py | 8 +- tests/test_live_context_notifications.py | 2 +- tests/test_message_notifications.py | 2 +- tests/test_native_evidence_custody.py | 2 +- tests/test_native_input_bus_path.py | 2 +- tests/test_native_pi.py | 20 +- tests/test_notification_display_boundary.py | 2 +- tests/test_notification_display_viewer.py | 4 +- tests/test_notification_recipient_window.py | 37 ++- tests/test_outbound_transcript_source.py | 2 +- tests/test_recipient_activity_index.py | 6 +- tests/test_wake_candidate_index.py | 5 +- 48 files changed, 465 insertions(+), 455 deletions(-) create mode 100644 src/agent_comms/native_integer.py diff --git a/src/agent_comms/audience_manifest.py b/src/agent_comms/audience_manifest.py index cd40f8b3c0..a0f3881f65 100644 --- a/src/agent_comms/audience_manifest.py +++ b/src/agent_comms/audience_manifest.py @@ -16,13 +16,12 @@ from .class_fact import ClassFact import hashlib import math -import re from collections.abc import Sequence from dataclasses import dataclass from functools import cached_property from typing import Final -from agent_comms.coordination_contracts import MAX_IDENTIFIER_CHARS +from .participant_identity import FrozenRecipientIdentity, ParticipantIdentity from .channel_targets import exact_stored_target from .field_codec import FieldCodec, MalformedRecord, ThreadLookup @@ -36,18 +35,6 @@ MAX_WIRE_SEQ: Final = (1 << 63) - 1 _ENVELOPE_DOMAIN: Final = b"agent-comms:wire-envelope:v1\0" _AUDIENCE_DOMAIN: Final = b"agent-comms:audience:v1\0" -_NAME: Final = re.compile(r"[a-zA-Z0-9_-]+\Z") - - -def _bounded_text(value: str, field: str, *, thread_name: bool = False) -> None: - if not isinstance(value, str) or not value or len(value) > MAX_IDENTIFIER_CHARS: - raise MalformedRecord(f"{field} must be a bounded nonempty string") - if thread_name and _NAME.fullmatch(value) is None: - raise MalformedRecord(f"{field} must be a canonical thread name") - if not thread_name and not value.strip(): - raise MalformedRecord(f"{field} must not be whitespace") - - def _canonical_json(value: object, *, limit: int) -> bytes: try: encoded = FieldCodec.compact( @@ -65,7 +52,7 @@ def _digest(domain: bytes, payload: bytes) -> str: @dataclass(frozen=True, slots=True) -class FrozenRecipient(ProjectionSource): +class FrozenRecipient(FrozenRecipientIdentity, ProjectionSource): """Stable coordination lookup paired with its send-time canonical name.""" @ClassFact @@ -73,14 +60,6 @@ def writes(cls) -> tuple[type[ProducedRow], ...]: return (ReceivedSource, AwaitingAnswerSource) - recipient_lookup: ThreadLookup - canonical_thread: str - - def __post_init__(self) -> None: - _bounded_text(self.recipient_lookup, "recipient_lookup") - _bounded_text(self.canonical_thread, "canonical_thread", thread_name=True) - - @dataclass(frozen=True, kw_only=True) class Audience(BusCapability): """The participant facts frozen at publication.""" @@ -108,8 +87,8 @@ def __post_init__(self) -> None: if type(self.seq) is not int or not 0 < self.seq <= MAX_WIRE_SEQ: raise MalformedRecord("wire_seq must be a positive SQLite-range integer") for name in ("target", "source_revision"): - _bounded_text(getattr(self, name), name) - _bounded_text(self.sender, "sender_name", thread_name=True) + ParticipantIdentity.require_text(getattr(self, name), name) + ParticipantIdentity.require_canonical(self.sender, "sender_name") if not exact_stored_target(self.target): raise MalformedRecord("aggregate views are not exact stored targets") if type(self.recipients) is not tuple or len(self.recipients) > MAX_RECIPIENTS: @@ -179,7 +158,7 @@ def freeze_audience( raise TypeError("externally_captured_recipients must be a bounded sequence") if len(externally_captured_recipients) > MAX_RECIPIENTS: raise MalformedRecord("too many frozen recipients") - _bounded_text(sender_name, "sender_name", thread_name=True) + ParticipantIdentity.require_canonical(sender_name, "sender_name") if prepared_message.sender != sender_name: raise MalformedRecord( "sender_name must be the externally captured canonical message sender" diff --git a/src/agent_comms/backend.py b/src/agent_comms/backend.py index b38f6d0294..2609d3a0cc 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -56,7 +56,7 @@ from .native_failures import NativePiUnavailable from .native_package import VerifiedNativePackage from .native_input_proof import NativeContextProof -from .native_pi import NativePiRpcLaunch, _verify_context, read_tracked_input_commitment +from .native_pi import NativePiRpcLaunch, read_tracked_input_commitment from .native_session_reopen import SessionRetention from .native_startup import NATIVE_STARTUP_POLICY, NativeStartup from .native_tool_call import NativeToolMode @@ -637,9 +637,9 @@ def proof( source_reads: NativeEvidenceScope, ) -> NativeContextProof: evidence = source_reads.for_source(files.session) - proof = _verify_context( - files, self.command.input_id, session_id, - self.input_commit.require(), self.context_commit.require(), evidence=evidence, + proof = evidence.verify_context( + files, self.input_commit.require(), + self.context_commit.require().record_for(self.command.input_id, session_id), ) committed = read_tracked_input_commitment( proof.session_file, self.command.input_id, evidence=evidence, diff --git a/src/agent_comms/bus_read.py b/src/agent_comms/bus_read.py index 2e6fd68755..c9682507a4 100644 --- a/src/agent_comms/bus_read.py +++ b/src/agent_comms/bus_read.py @@ -9,7 +9,8 @@ from .owner_connection import ( OwnerConnection, OwnerReading, OwnerReplyConnection, ResultRuntimeRequest, RuntimeRequestContext, ) -from .read_basis import DisplayCut, NoDisplaySelection, OutcomeViewer, ReadAuthority +from .read_basis import DisplayCut, NoDisplaySelection, ReadAuthority +from .notification_evidence import OutcomeViewer from .registration import Registration from .tool_declaration import ToolDeclaration from .tool_request import ThreadReadTool diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 230f9a1281..2881c46f29 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -74,7 +74,7 @@ from agent_comms.owner_requests import NewestOpenRequest, OwnerRequestId, RequestSelection from agent_comms.phase_evidence import PlanReading, PlanReadings from agent_comms.plan_phases import OrderPhase, PlanPhase -from agent_comms.read_basis import AllOutcomes +from agent_comms.notification_evidence import AllOutcomes from agent_comms.presentation import MessageNotification from agent_comms.recorded_time import RecordedEventTime from agent_comms.registry_document import RegisteredCommanderProject diff --git a/src/agent_comms/harness_delivery_states.py b/src/agent_comms/harness_delivery_states.py index 9ca9daa25a..afbc4cfe41 100644 --- a/src/agent_comms/harness_delivery_states.py +++ b/src/agent_comms/harness_delivery_states.py @@ -16,6 +16,7 @@ from .declared_family import DeclaredFamily from .field_codec import TranscriptRecordId from .notification_evidence import ConsumptionEvidence +from .recorded_time import Timestamped from .notification_progress import HarnessConsumedProgress if TYPE_CHECKING: @@ -24,7 +25,6 @@ from .cli_bridges.shared import LineMonitorBridge, MonitorPart, MonitorReadFile from .coordination_tables.harness_deliveries import HarnessDelivery from .notification_progress import DeliveryProgress - from .presentation import MessageNotification class Outcome(DeclaredFamily, affix="Outcome"): @@ -37,24 +37,8 @@ def settle(cls, successor: HarnessDelivery, assignments: AssignmentStore, successor.record(assignments, db) return successor - @classmethod - def arrival_count(cls) -> int: - return 0 - - @classmethod - def arrival_label(cls, notification: MessageNotification) -> str: - return "Arrival unknown" - - @classmethod - def input_detail(cls) -> str: - return "Attempt is uncertain; no replay is permitted." - class FilteredOutcome(Outcome): - @classmethod - def input_detail(cls) -> str: - return "No input was submitted." - @classmethod def settle(cls, successor: HarnessDelivery, assignments: AssignmentStore, db: sqlite3.Connection) -> HarnessDelivery: @@ -74,39 +58,17 @@ class DeliveredOutcome(FilteredOutcome): A producer name alone cannot upgrade their evidence to model context. """ - @classmethod - def input_detail(cls) -> str: - return "Harness reported delivery; model context is unproved." - class AcceptedOutcome(DeliveredOutcome): - @classmethod - def arrival_count(cls) -> int: - return 1 - - @classmethod - def arrival_label(cls, notification: MessageNotification) -> str: - return "Arrived (RPC accepted; time unrecorded)" - - @classmethod - def input_detail(cls) -> str: - return "RPC accepted input; model context proof is unavailable." + pass class PersistedOutcome(AcceptedOutcome): - @classmethod - def arrival_label(cls, notification: MessageNotification) -> str: - return "Arrived (saved input; time unrecorded)" - - @classmethod - def input_detail(cls) -> str: - return "Harness saved input; model context proof is unavailable." + pass class EmittedOutcome(UnconfirmedOutcome): - @classmethod - def input_detail(cls) -> str: - return "Emitted; recipient context remains unconfirmed." + pass @dataclass(frozen=True) @@ -128,12 +90,6 @@ def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: def claim_steps(self, state: AssignmentState) -> tuple[type[AssignmentState], ...]: return () - def arrival_count(self, outcome: type[Outcome]) -> int: - return outcome.arrival_count() - - def arrival_label(self, outcome: type[Outcome], notification: MessageNotification) -> str: - return outcome.arrival_label(notification) - def finished_with(self, export: MonitorReadFile) -> None: """A body without a complete original transcript proof keeps its export.""" @@ -163,6 +119,7 @@ class WakeOnlyMonitor(PendingMonitor): "Heartbeat: your board changed. New and changed lines print once as BOARD: on your next " "tool call; later calls print one unchanged count." ) + @classmethod def displayed_body(cls, body: str) -> str: return cls.board_wake @@ -210,7 +167,7 @@ class MismatchedMonitor(MonitorEvidence): @dataclass(frozen=True) -class ObservedMonitor(MonitorEvidence): +class ObservedMonitor(MonitorEvidence, Timestamped): record_id: TranscriptRecordId timestamp: float @@ -219,16 +176,10 @@ def evidence(self, recorded: type[Outcome]) -> tuple[type[Outcome], MonitorEvide return DeliveredOutcome, self def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: - return progress.model_consumed(HarnessConsumedProgress(round(self.timestamp * 1000))) + return progress.model_consumed(HarnessConsumedProgress(self.at_ms)) def claim_steps(self, state: AssignmentState) -> tuple[type[AssignmentState], ...]: return state.harness_delivery() - def arrival_count(self, outcome: type[Outcome]) -> int: - return 1 - - def arrival_label(self, outcome: type[Outcome], notification: MessageNotification) -> str: - return notification.timed_state("Arrived", round(self.timestamp * 1000)) - def finished_with(self, export: MonitorReadFile) -> None: export.retire() diff --git a/src/agent_comms/historical_native_inputs.py b/src/agent_comms/historical_native_inputs.py index 976592ea0d..505c651ecc 100644 --- a/src/agent_comms/historical_native_inputs.py +++ b/src/agent_comms/historical_native_inputs.py @@ -166,8 +166,8 @@ def read_historical_native_inputs( # Journal alone cannot promote an unrecorded or uncertain input. # The immutable SQL row is already present from the live event; # this check only corroborates its message-bearing context facts. - corroborated = recorded.corroborates_input( - row.input_id, evidence=source_reads.for_source(session_file), + corroborated = source_reads.for_source(session_file).corroborates_context( + recorded, input_id=row.input_id, files=NativeSessionFiles.for_identity(original_session, root=store.session.path.parent, owner=row.owner_identity), ) diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index 305497789c..9694eefd68 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -36,9 +36,9 @@ from .messages import CarriesEnvelope, Message from .notification_evidence import NotificationWindow from .page_traversal import HistoryCursor, OlderTraversal -from .read_basis import OutcomeViewer, FirstRecipient, RecipientCursor +from .notification_evidence import OutcomeViewer, FirstRecipient, RecipientCursor from .presentation import MessageNotification -from .notification_evidence import RecipientSelection +from .notification_evidence import RecipientLimit from .private_path import FileObservation, FileRevision, ObservedPath, PrivateDirectoryRole from .read_basis import ( AnonymousViewer, @@ -368,7 +368,7 @@ def require(cls, sources: tuple[HistorySource, ...], key: HistorySourceKey) -> H def notification_references(self, sources: tuple[HistorySource, ...], references: Sequence[MessageReference], viewer: OutcomeViewer, *, after: RecipientCursor = FirstRecipient(), - limit: int = RecipientSelection.max_limit) -> NotificationWindow: + limit: RecipientLimit = RecipientLimit()) -> NotificationWindow: """Read this namespace within its archive's current catalogue read, as this viewer may see it.""" diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index cc44e33d3a..97177d27a3 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -56,8 +56,8 @@ from .messages import Message from .messaging import Messaging from .page_traversal import LiveCursor -from .notification_evidence import NotificationWindow, RecipientSelection -from .read_basis import OutcomeViewer, FirstRecipient, RecipientCursor +from .notification_evidence import NotificationWindow +from .notification_evidence import FirstRecipient, OutcomeViewer, RecipientCursor, RecipientLimit from .presentation import ( BusPresentation, ChannelView, @@ -222,7 +222,7 @@ def observed_activity( def message_notifications_for_references( self, viewer: OutcomeViewer, references: Sequence[MessageReference], - *, after: RecipientCursor = FirstRecipient(), limit: int = RecipientSelection.max_limit, + *, after: RecipientCursor = FirstRecipient(), limit: RecipientLimit = RecipientLimit(), ) -> NotificationWindow: return MessageNotification.references( self.root, self.registry, self.bus.bus, references, viewer=viewer, after=after, limit=limit) diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 50e094b169..df36834940 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -57,6 +57,7 @@ from .mentions import ThreadMention from .message_reference import MessageReference from .page_traversal import LiveCursor, PageCursor +from .recorded_time import Timestamped from .task_sources import Decision, NoTaskAttachment, TaskAttachment from .thread_identity import AgentThreadRole, ThreadRole from .ui_model.message_category import InboundCategory, MessageCategory, OtherCategory @@ -831,7 +832,7 @@ class PublicSender(NullMember): @dataclass(frozen=True, slots=True) -class MessageInput(Addressed, BusCapability): +class MessageInput(Timestamped, Addressed, BusCapability): """The facts an input is keyed and written from: who, where, what text, which role. Attachments, reports and answers belong to Message; this is never a whole message diff --git a/src/agent_comms/native_entries.py b/src/agent_comms/native_entries.py index 0b8729f2d2..7122d77bcf 100644 --- a/src/agent_comms/native_entries.py +++ b/src/agent_comms/native_entries.py @@ -39,7 +39,7 @@ WireOmitDefault, ) from .importing import ImportedSessionMetadata -from .native_input_proof import NativeContextJournal, NativeContextProof, NativeInputLocation +from .native_input_proof import NativeContextJournal, NativeContextProof, NativeContextRecord, NativeInputLocation from .pi_payloads import Opaque, PiField, PiMessage, PiOmitted, PiPayload, ReportedText from .pi_vocabulary import PendingStopReason, ThinkingLevel from .recorded_time import GeneratedTimestamp @@ -407,18 +407,48 @@ def read_input(self, location): def context_proof( self, files: NativeSessionFiles, input_id: InputId, *, - generations: range = range(1, 2**53), session_entry_start: int, + generations: range = NativeContextRecord.interval(), session_entry_start: int, ) -> NativeContextProof: """Corroborate a journal row against this reader's original acquired bytes.""" session_file = files.session.absolute() with self.borrow(session_file, self) as evidence: - with NativeContextJournal.open_evidence(files) as db: + with NativeContextJournal.open_evidence(files.input_proof) as db: row = NativeContextJournal.for_input(db, input_id, generations) location = NativeInputLocation(input_id, row.session_id, row.session_entry_id, session_entry_start=session_entry_start) entry = evidence.read_input(location) - return row.corroborate(session_file, evidence.header(), entry, - session_entry_start=session_entry_start) + return row.corroborate(session_file, NativeInputLocation( + input_id, evidence.header().id, entry.id, + session_entry_start=session_entry_start, + )) + + def corroborates_context( + self, context: NativeContextProof, *, input_id: InputId, files: NativeSessionFiles, + ) -> bool: + """Corroborate a located context through this reader's own pinned bytes. + + This grants neither input replay nor recovery authority. + """ + if context.input_id != input_id or files.session != context.session_file: + return False + return self.context_proof( + files, input_id, + generations=range(context.request_generation, context.request_generation + 1), + session_entry_start=context.session_entry_start, + ) == context + + def verify_context( + self, files: NativeSessionFiles, input_event: NativeInputLocation, + context: NativeContextRecord, + ) -> NativeContextProof: + """Join the normalized acknowledgement and this acquired durable evidence exactly.""" + if context.input_commit != input_event.input_commit: + raise NativePiUnavailable("Native Pi input/context events disagree") + emitted = context.at(files.session, session_entry_start=input_event.session_entry_start) + with self.borrow(files.session, self) as evidence: + if not evidence.corroborates_context(emitted, input_id=input_event.input_id, files=files): + raise NativePiUnavailable("Native Pi emitted an event without matching durable proof") + return emitted def stopped_input(self, input_id: InputId): """Read only this input's contiguous branch, ending at the next input. diff --git a/src/agent_comms/native_file_artifact.py b/src/agent_comms/native_file_artifact.py index ef50d8e1ac..abd6efc0cf 100644 --- a/src/agent_comms/native_file_artifact.py +++ b/src/agent_comms/native_file_artifact.py @@ -3,6 +3,7 @@ from pathlib import Path from .declared_family import DeclaredFamily +from .native_integer import NativeIntegerBounds from .text_digest import TextDigest @@ -11,7 +12,7 @@ class NativeFileArtifact(DeclaredFamily, affix="FileArtifact"): @dataclass(frozen=True) -class Utf8FileWriteArtifact(NativeFileArtifact, declared_name="utf8_file_write"): +class Utf8FileWriteArtifact(NativeFileArtifact, NativeIntegerBounds, declared_name="utf8_file_write"): operation_path: str digest: TextDigest byte_count: int @@ -19,5 +20,4 @@ class Utf8FileWriteArtifact(NativeFileArtifact, declared_name="utf8_file_write") def __post_init__(self): if not Path(self.operation_path).is_absolute(): raise ValueError("Original SDK operation requires its addressed absolute path") - if not 0 <= self.byte_count <= 2**53 - 1: - raise ValueError("Original SDK operation byte count is invalid") + self.require_integer(self.byte_count, "Original SDK operation byte count") diff --git a/src/agent_comms/native_input_proof.py b/src/agent_comms/native_input_proof.py index a62b44c241..d3f37b4116 100644 --- a/src/agent_comms/native_input_proof.py +++ b/src/agent_comms/native_input_proof.py @@ -12,7 +12,7 @@ from contextlib import ExitStack, closing, contextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Annotated, ClassVar, Literal +from typing import Annotated, ClassVar, Literal from pydantic import JsonValue @@ -28,6 +28,7 @@ ) from .locked_store import DurableFile, PatternFile, SQLiteFile from .native_failures import NativePiUnavailable +from .native_integer import PositiveNativeIntegerBounds from .native_session_reopen import PiSessionId from .turn_item import FreshNativeId from .private_path import FileIdentity, PrivateFileRole @@ -40,15 +41,10 @@ IntegerStorage, ReconciledFile, TableSchema, + TypedTable, WithoutRowid, ) -if TYPE_CHECKING: - from . import pi_events as pi - from .native_entries import NativeEntry, NativeEvidenceRead, SessionEntry - from .native_session_files import NativeSessionFiles - - @dataclass(frozen=True, slots=True) class NativeInputCommit: """Original emitted input identity shared with its assembled context.""" @@ -82,13 +78,13 @@ class NativeInputLocation(NativeInputCommit): @dataclass(frozen=True, slots=True) -class NativeContextRecord(NativeInputCommit): +class NativeContextRecord(NativeInputCommit, PositiveNativeIntegerBounds): """Native context facts extend the original committed input declaration.""" request_generation: Annotated[ int, WireName("requestGeneration"), - Column(primary_key=True, check="request_generation BETWEEN 1 AND 9007199254740991"), + Column(primary_key=True, check=PositiveNativeIntegerBounds.sql_check("request_generation")), ] llm_context_digest: Annotated[ str, @@ -100,34 +96,13 @@ class NativeContextRecord(NativeInputCommit): committed_at_ms: Annotated[ int, WireName("committedAtMs"), - Column(check="committed_at_ms BETWEEN 1 AND 9007199254740991"), + Column(check=PositiveNativeIntegerBounds.sql_check("committed_at_ms")), ] - @classmethod - def from_events( - cls, input_id: InputId, session_id: PiSessionId, - input_event: pi.InputCommitted, context_event: pi.ContextCommitted, - ) -> NativeContextRecord: - """Strictly join the two emitted records through the context declaration. - - This is an emitted acknowledgement, not proof of durable inclusion. A journal - read must independently corroborate the same complete located record. - """ - emitted = FieldCodec.encode(context_event) - record = FieldCodec.decode(cls, {key: emitted[key] - for _, key in FieldCodec.record_fields(cls)}) - if record.input_commit != NativeInputCommit(input_id, session_id, record.session_entry_id): - raise NativePiUnavailable("Native Pi context names another original input") - if record.input_commit != input_event.input_commit: - raise NativePiUnavailable("Native Pi input/context events disagree") - return record - def __post_init__(self) -> None: Sha256Text.decode(self.llm_context_digest) - if not 0 < self.request_generation <= 2**53 - 1: - raise ValueError("Native context generation is outside its native range") - if not 0 < self.committed_at_ms <= 2**53 - 1: - raise ValueError("Native context commit time is outside its native range") + self.require_integer(self.request_generation, "Native context generation") + self.require_integer(self.committed_at_ms, "Native context commit time") def at(self, session_file: Path, *, session_entry_start: int) -> NativeContextProof: """Locate recorded facts; this does not grant acceptance or replay.""" @@ -180,7 +155,7 @@ def native_contract(cls) -> dict[str, JsonValue]: @classmethod @contextmanager - def open_evidence(cls, files: NativeSessionFiles) -> Iterator[sqlite3.Connection]: + def open_evidence(cls, path: Path) -> Iterator[sqlite3.Connection]: """Read an indexed snapshot after SQLite's bounded hot-journal recovery. A read is corroboration, never a fresh acknowledgement. Keep the private inode @@ -188,7 +163,6 @@ def open_evidence(cls, files: NativeSessionFiles) -> Iterator[sqlite3.Connection Open existing storage read/write for SQLite rollback, then forbid SQL writes. A cold history view can recover without a model turn or acknowledgement. """ - path = files.input_proof with ExitStack() as ownership: try: held = ownership.enter_context(closing( @@ -215,7 +189,7 @@ def open_evidence(cls, files: NativeSessionFiles) -> Iterator[sqlite3.Connection @classmethod def for_input( cls, db: sqlite3.Connection, input_id: InputId, - generations: range = range(1, 2**53), + generations: range = NativeContextRecord.interval(), ) -> NativeContextJournal: """Require the latest input proof in this indexed generation interval.""" if generations.step != 1: @@ -232,15 +206,14 @@ def for_input( raise NativePiUnavailable("The input has no assembled-context proof") from missing def corroborate( - self, session_file: Path, header: SessionEntry, entry: NativeEntry, *, - session_entry_start: int, + self, session_file: Path, location: NativeInputLocation, ) -> NativeContextProof: - if self.session_id != header.id or self.session_entry_id != entry.id: + if self.input_commit != location.input_commit: raise NativePiUnavailable("Native Pi proof journal source lineage differs") - return self.at(session_file, session_entry_start=session_entry_start) + return self.at(session_file, session_entry_start=location.session_entry_start) -class NativeInputProof(DurableFile, ReconciledFile, PatternFile): +class NativeInputProof(DurableFile, ReconciledFile, PatternFile, DurableTables): """The retained proof file names its journal without joining their two families.""" __slots__ = () @@ -248,7 +221,11 @@ class NativeInputProof(DurableFile, ReconciledFile, PatternFile): @classmethod def table_schema(cls) -> type[TableSchema]: - return NativeContextJournal + return cls + + @classmethod + def schema_rows(cls) -> tuple[type[TypedTable], ...]: + return (NativeContextJournal,) @dataclass(frozen=True, slots=True) @@ -270,20 +247,3 @@ def require_continuation(self, context: NativeContextProof) -> None: raise IdentityConflict("Native context precedes its original acknowledgement") if context.request_generation == self.request_generation and context != self: raise IdentityConflict("Native request has contradictory context evidence") - - def corroborates_input( - self, input_id: InputId, *, files: NativeSessionFiles, - evidence: NativeEvidenceRead, - ) -> bool: - """Check an already-observed live event against its isolated saved proof. - - This grants neither replay nor recovery authority. History IO occurs - before the coordinator read transaction, as on the native return path. - """ - if self.input_id != input_id or files.session != self.session_file: - return False - return evidence.context_proof( - files, input_id, - generations=range(self.request_generation, self.request_generation + 1), - session_entry_start=self.session_entry_start, - ) == self diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index f908e12bb4..95b2c4c655 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -31,6 +31,7 @@ MalformedRecord, NativeContext, ) +from .native_integer import PositiveNativeIntegerBounds from .native_session_reopen import PiSessionId from .transcript_routes import InputDisplay, NoDisplayText from .typed_table import SqlStorage, TextStorage @@ -377,7 +378,7 @@ class UnrecordedNativeInputReference(NativeContextReference): @dataclass(frozen=True) -class NativeInputReference(NativeContextReference): +class NativeInputReference(NativeContextReference, PositiveNativeIntegerBounds): """A cursor's bounded reference to original recorded native context.""" input_id: InputId @@ -387,6 +388,11 @@ class NativeInputReference(NativeContextReference): recorded = True + def __post_init__(self) -> None: + self.require_integer(self.request_generation, "Native reference generation") + if not self.session_id: + raise ValueError("Native reference lacks recorded identity") + def require_recorded(self) -> NativeInputReference: return self @@ -397,14 +403,9 @@ def acquire(cls, row: NativeInputContext) -> NativeInputReference: result = FieldCodec.construct(cls, dict( input_id=row.context_value("input_id"), stage=row.reference_stage, - session_id=row.context_value("session_id"), + session_id=FieldCodec.decode(PiSessionId, row.context_value("session_id")), request_generation=row.context_value("request_generation"), )) - result = FieldCodec.decode(cls, FieldCodec.encode(result, cls)) - if not 0 < result.request_generation <= 2**53 - 1: - raise MalformedRecord("Native reference generation is outside its native range") - if not result.session_id: - raise MalformedRecord("Native reference lacks recorded identity") return result except MalformedRecord as error: raise IdentityConflict("Native recorded context reference is incomplete") from error diff --git a/src/agent_comms/native_integer.py b/src/agent_comms/native_integer.py new file mode 100644 index 0000000000..93b344507f --- /dev/null +++ b/src/agent_comms/native_integer.py @@ -0,0 +1,28 @@ +"""The exact integer interval shared by Python, SQLite and the native JS writer.""" + +from typing import ClassVar + + +class NativeIntegerBounds: + """A numeric capability; owners retain their own fields and wire contracts.""" + + __slots__ = () + minimum: ClassVar[int] = 0 + maximum: ClassVar[int] = 2**53 - 1 + + @classmethod + def interval(cls) -> range: + return range(cls.minimum, cls.maximum + 1) + + @classmethod + def require_integer(cls, value: int, label: str) -> None: + if value not in cls.interval(): + raise ValueError(f"{label} is outside its native range") + + @classmethod + def sql_check(cls, column: str) -> str: + return f"{column} BETWEEN {cls.minimum} AND {cls.maximum}" + + +class PositiveNativeIntegerBounds(NativeIntegerBounds): + minimum = 1 diff --git a/src/agent_comms/native_pi.py b/src/agent_comms/native_pi.py index 84db56ba51..5d438690ec 100644 --- a/src/agent_comms/native_pi.py +++ b/src/agent_comms/native_pi.py @@ -32,7 +32,7 @@ from .launch_runtime import LaunchRuntime, RestartEnvironment, ThreadLaunch from .native_arguments import NativeArguments, SelectedModel from .native_failures import NativePiUnavailable, NativeRequestFailure -from .native_input_proof import NativeContextProof, NativeContextRecord +from .native_input_proof import NativeContextProof from .native_package import MANIFEST, UNIT_INSTRUCTIONS, VerifiedNativePackage from .native_session_files import NativeAgentSettings, NativeSessionFiles from .native_tool_call import NativeToolMode @@ -49,7 +49,6 @@ from .native_entries import NativeEvidenceRead from .selected_session import SelectedSession from .turn_failure import TurnFailure - from .native_session_reopen import PiSessionId CAPABILITY = "pi-native-input-v1-live-only" @@ -672,25 +671,3 @@ def read_tracked_input_commitment( return CommittedInput(evidence.tracked(input_id).message.input_digest.require()) except NativeInputUncommitted: return UncommittedInput() - - -def _verify_context( - files: NativeSessionFiles, - input_id: InputId, - session_id: PiSessionId, - input_event: pi.InputCommitted, - context_event: pi.ContextCommitted, - *, evidence: NativeEvidenceRead | None = None, -) -> NativeContextProof: - from .native_entries import NativeInputEvidenceRead - - session_file = files.session - try: - emitted = NativeContextRecord.from_events(input_id, session_id, input_event, context_event).at( - session_file, session_entry_start=input_event.session_entry_start) - except MalformedRecord as error: - raise NativePiUnavailable("Native Pi input/context acknowledgement is malformed") from error - with NativeInputEvidenceRead.borrow(session_file, evidence) as evidence: - if not emitted.corroborates_input(input_id, files=files, evidence=evidence): - raise NativePiUnavailable("Native Pi emitted an event without matching durable proof") - return emitted diff --git a/src/agent_comms/native_transcript.py b/src/agent_comms/native_transcript.py index e72f8866cd..5901951263 100644 --- a/src/agent_comms/native_transcript.py +++ b/src/agent_comms/native_transcript.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, BinaryIO from .errors import RelationViolationError -from .native_input_proof import NativeContextJournal +from .native_input_proof import NativeContextJournal, NativeInputLocation from .native_failures import NativePiUnavailable if TYPE_CHECKING: @@ -346,14 +346,14 @@ def require_publication_context( files.require_private_directory(self.path.parent) with self.path.open("rb") as stream: header = SessionEntry.read(stream.readline()) - with NativeContextJournal.open_evidence(files) as db: + with NativeContextJournal.open_evidence(files.input_proof) as db: try: proof = NativeContextJournal.for_input(db, user.input_id) except NativePiUnavailable as missing: raise MalformedRecord("Native publication has no committed context inclusion") from missing - return proof.corroborate( - self.path.absolute(), header, user, session_entry_start=session_entry_start, - ) + return proof.corroborate(self.path.absolute(), NativeInputLocation( + user.input_id, header.id, user.id, session_entry_start=session_entry_start, + )) def inputs_for(self, records) -> tuple: """Find the user input behind each record in one reverse walk for the batch. diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index c8d4e2b156..ad80e42a00 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -27,8 +27,8 @@ SentProgress, ShownToModelProgress, ) -from .notification_evidence import (AssignmentResponse, ConsumptionEvidence, NotificationAssignmentRead, NotificationWindow, - NotificationSelection, UnansweredResponse) +from .notification_evidence import AssignmentResponse, ConsumptionEvidence, NotificationAssignmentRead, NotificationWindow, UnansweredResponse +from .notification_evidence import NotificationSelection from .private_registry_guard import RegistryPrivacy from .registry_store import RegistryStore from .typed_table import BooleanStorage, TypedRow diff --git a/src/agent_comms/notification_evidence.py b/src/agent_comms/notification_evidence.py index f0ae7dfed0..2d9645097a 100644 --- a/src/agent_comms/notification_evidence.py +++ b/src/agent_comms/notification_evidence.py @@ -3,7 +3,8 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Iterable, Iterator, Mapping +from bisect import bisect_left, bisect_right +from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass, field from functools import cached_property from itertools import batched @@ -11,8 +12,14 @@ from typing import TYPE_CHECKING, ClassVar from .declared_family import DeclaredFamily -from .field_codec import FieldCodec, MalformedRecord, MessageId, ThreadLookup, boundary_decoder +from .field_codec import ( + FieldCodec, MalformedRecord, MessageId, NullMember, RecordMember, + SingleFieldValue, SubclassUnion, ThreadLookup, boundary_decoder, +) from .message_reference import MessageReference +from .recorded_bus import RecordedBusIdentity + +from .participant_identity import FrozenRecipientIdentity from .thread_identity import ThreadIncarnation @@ -23,8 +30,6 @@ class ConsumptionEvidence(ABC): def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: ... if TYPE_CHECKING: - from .audience_manifest import FrozenRecipient - from .read_basis import OutcomeViewer from .bus_publication import Delivered from .comms import Comms from .coordination_tables.assignments import WakeAssignment @@ -34,6 +39,173 @@ def consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: ... from .presentation import MessageNotification, WireRevision +@dataclass(frozen=True) +class RecipientLimit(SingleFieldValue): + """Decode one bounded recipient-window size at the request boundary.""" + + maximum: ClassVar[int] = 100 + value: int = maximum + + def __post_init__(self) -> None: + if not 1 <= self.value <= self.maximum: + raise ValueError(f"Recipient limit must be between 1 and {self.maximum}") + + @classmethod + def schema(cls) -> dict[str, object]: + return {"type": "integer", "minimum": 1, "maximum": cls.maximum} + + +@dataclass(frozen=True) +class RecipientSelection: + """A bounded slice of original frozen positions, never a replacement audience.""" + + source: Delivered + eligible: range + limit: RecipientLimit + + @property + def positions(self) -> range: + return self.eligible[:self.limit.value] + + @property + def remaining(self) -> int: + return len(self.eligible) - len(self.positions) + + @property + def recipients(self) -> tuple[FrozenRecipientIdentity, ...]: + return tuple(self.source.recipients[position] for position in self.positions) + + @property + def entries(self) -> tuple[tuple[tuple[ThreadLookup, MessageReference], FrozenRecipientIdentity], ...]: + return tuple(((recipient.recipient_lookup, self.source.message.reference), recipient) + for recipient in self.recipients) + + +@dataclass(frozen=True) +class NotificationSelection: + """The exact per-original recipient cuts used by every expensive evidence reader.""" + + viewer: OutcomeViewer + pages: tuple[RecipientSelection, ...] + + @property + def sources(self) -> tuple[Delivered, ...]: + return tuple(page.source for page in self.pages) + + @property + def keys(self) -> tuple[tuple[ThreadLookup, MessageReference], ...]: + return tuple(key for page in self.pages for key, _recipient in page.entries) + + +class RecipientCursor(SubclassUnion, affix=""): + """Select frozen, lookup-ordered positions before expensive evidence acquisition.""" + + @abstractmethod + def positions(self, source: Delivered) -> range: ... + + +@dataclass(frozen=True) +class FirstRecipient(NullMember, RecipientCursor): + def positions(self, source: Delivered) -> range: + return range(len(source.recipients)) + + +@dataclass(frozen=True) +class AfterRecipient(RecordMember, RecipientCursor): + recipient: FrozenRecipientIdentity + + def positions(self, source: Delivered) -> range: + start = bisect_right(source.recipients, self.recipient.recipient_lookup, + key=lambda item: item.recipient_lookup) + return range(start, len(source.recipients)) + + +class OutcomeViewer(ABC): + """Who reads a delivery window decides which of its outcomes are shown.""" + + @abstractmethod + def positions(self, source: Delivered) -> range: ... + + def selection(self, sources: Sequence[Delivered], *, after: RecipientCursor = FirstRecipient(), + limit: RecipientLimit = RecipientLimit()) -> NotificationSelection: + pages = [] + for source in sources: + eligible, following = self.positions(source), after.positions(source) + pages.append(RecipientSelection( + source, range(max(eligible.start, following.start), min(eligible.stop, following.stop)), limit)) + return NotificationSelection(self, tuple(pages)) + + def visible_reads(self, displayed_to: tuple[ThreadIncarnation, ...], + source: RecordedBusIdentity) -> tuple[ThreadIncarnation, ...]: + """Unscoped/operator and agent readers retain every human read mark.""" + return displayed_to + + @abstractmethod + def sees_direct_read(self, source: Delivered) -> bool: + pass + + @abstractmethod + def sees(self, source: Delivered) -> bool: + """Whether this reader may read the message at all.""" + + +@dataclass(frozen=True, slots=True) +class AllOutcomes(OutcomeViewer): + """An unscoped reader sees every recipient's outcome and every human read.""" + + def positions(self, source: Delivered) -> range: + return range(len(source.recipients)) + + def sees_direct_read(self, source: Delivered) -> bool: + return True + + def sees(self, source: Delivered) -> bool: + return True + + +@dataclass(frozen=True, slots=True) +class DisplayOutcomes(AllOutcomes): + """Full operator access, minus only this display's source-matching viewer. + + Incarnation equality owns rename/recreation; the captured bus owns namespace. + The live registry cannot establish either fact for an archived display. + """ + + viewer: ThreadIncarnation + bus_identity: RecordedBusIdentity + + def visible_reads(self, displayed_to: tuple[ThreadIncarnation, ...], + source: RecordedBusIdentity) -> tuple[ThreadIncarnation, ...]: + return tuple(reader for reader in displayed_to + if reader != self.viewer or not self.bus_identity.matches(source)) + + +@dataclass(frozen=True, slots=True) +class OwnerOutcomes(OutcomeViewer): + """A sender sees all outcomes; a recipient sees only its original outcome.""" + + lookup: ThreadLookup + + @classmethod + def of(cls, owner: ThreadIncarnation) -> OwnerOutcomes: + return cls(owner.require_recorded().lookup) + + def positions(self, source: Delivered) -> range: + if source.sender_lookup == self.lookup: + return range(len(source.recipients)) + position = bisect_left(source.recipients, self.lookup, key=lambda item: item.recipient_lookup) + if position < len(source.recipients) and source.recipients[position].recipient_lookup == self.lookup: + return range(position, position + 1) + return range(0) + + def sees_direct_read(self, source: Delivered) -> bool: + return source.sender_lookup == self.lookup + + def sees(self, source: Delivered) -> bool: + """The message's sender and its original recipients may read it.""" + return self.lookup == source.sender_lookup or bool(self.positions(source)) + + @dataclass(frozen=True) class AssignmentResponse(DeclaredFamily, affix="Response"): def recorded(self, message: Delivered) -> AssignmentResponse: @@ -71,7 +243,7 @@ def answered(self, notification: MessageNotification) -> MessageNotification: class UnansweredResponse(AssignmentResponse): def recorded(self, message: Delivered) -> AssignmentResponse: return AnsweredResponse( - message.reference, int(message.timestamp * 1000) + message.reference, message.at_ms ) def completed_notification(self, progress: AssignmentProgress, notification: MessageNotification) -> MessageNotification: @@ -117,55 +289,8 @@ def consumed_progress(self, recipient: ThreadLookup, return progress -@dataclass(frozen=True) -class RecipientSelection: - """A bounded slice of original frozen positions, never a replacement audience.""" - source: Delivered - eligible: range - limit: int - max_limit: ClassVar[int] = 100 - def __post_init__(self) -> None: - self.require_limit(self.limit) - - @classmethod - def require_limit(cls, limit: int) -> None: - if not 1 <= limit <= cls.max_limit: - raise ValueError(f"Recipient limit must be between 1 and {cls.max_limit}") - - @property - def positions(self) -> range: - return self.eligible[:self.limit] - - @property - def remaining(self) -> int: - return len(self.eligible) - len(self.positions) - - @property - def recipients(self) -> tuple[FrozenRecipient, ...]: - return tuple(self.source.recipients[position] for position in self.positions) - - @property - def entries(self) -> tuple[tuple[tuple[ThreadLookup, MessageReference], FrozenRecipient], ...]: - return tuple(((recipient.recipient_lookup, self.source.message.reference), recipient) - for recipient in self.recipients) - - -@dataclass(frozen=True) -class NotificationSelection: - """The exact per-original recipient cuts used by every expensive evidence reader.""" - - viewer: OutcomeViewer - pages: tuple[RecipientSelection, ...] - - @property - def sources(self) -> tuple[Delivered, ...]: - return tuple(page.source for page in self.pages) - - @property - def keys(self) -> tuple[tuple[ThreadLookup, MessageReference], ...]: - return tuple(key for page in self.pages for key, _recipient in page.entries) @dataclass(frozen=True) @@ -228,7 +353,7 @@ class NotificationReadMark: """ source: Delivered = field(compare=False) - recipient: FrozenRecipient + recipient: FrozenRecipientIdentity displayed_to: tuple[ThreadIncarnation, ...] reference: MessageReference = field(init=False) diff --git a/src/agent_comms/notification_reads.py b/src/agent_comms/notification_reads.py index 6528a8e81f..61c3e970c0 100644 --- a/src/agent_comms/notification_reads.py +++ b/src/agent_comms/notification_reads.py @@ -8,17 +8,17 @@ from .comms import Comms from .historical_views import HistorySource, HistorySourceKey from .message_reference import MessageReference -from .notification_evidence import NotificationWindow, RecipientSelection +from .notification_evidence import FirstRecipient, NotificationWindow, RecipientCursor, RecipientLimit from .notification_read import NotificationsRead from .presentation import WireRevision -from .read_basis import FirstRecipient, ReadAuthority, RecipientCursor +from .read_basis import ReadAuthority @dataclass(frozen=True) class NotificationRead(UiRead[NotificationWindow], ABC): references: tuple[MessageReference, ...] after: RecipientCursor = field(default_factory=FirstRecipient, kw_only=True) - limit: int = field(default=RecipientSelection.max_limit, kw_only=True) + limit: RecipientLimit = field(default_factory=RecipientLimit, kw_only=True) @abstractmethod def changed_since(self, comms: Comms, revision: WireRevision, diff --git a/src/agent_comms/participant_identity.py b/src/agent_comms/participant_identity.py index 7e06bbf69e..e898bf1644 100644 --- a/src/agent_comms/participant_identity.py +++ b/src/agent_comms/participant_identity.py @@ -1,8 +1,11 @@ """The stable participant lookup shared by registry and coordinator identities.""" +import re from abc import ABC, abstractmethod +from dataclasses import dataclass -from .field_codec import ThreadLookup +from .coordination_contracts import MAX_IDENTIFIER_CHARS +from .field_codec import MalformedRecord, ThreadLookup class ParticipantIdentity(ABC): @@ -10,6 +13,35 @@ class ParticipantIdentity(ABC): __slots__ = () + @staticmethod + def require_text(value: str, label: str) -> None: + if not isinstance(value, str) or not value or len(value) > MAX_IDENTIFIER_CHARS: + raise MalformedRecord(f"{label} must be a bounded nonempty string") + if not value.strip(): + raise MalformedRecord(f"{label} must not be whitespace") + + @classmethod + def require_canonical(cls, value: str, label: str) -> None: + cls.require_text(value, label) + if re.fullmatch(r"[a-zA-Z0-9_-]+", value) is None: + raise MalformedRecord(f"{label} must be a canonical thread name") + @property @abstractmethod def lookup(self) -> ThreadLookup: ... + + +@dataclass(frozen=True, slots=True) +class FrozenRecipientIdentity(ParticipantIdentity): + """Send-time participant identity, independent of bus projection writers.""" + + recipient_lookup: ThreadLookup + canonical_thread: str + + def __post_init__(self) -> None: + self.require_text(self.recipient_lookup, "recipient_lookup") + self.require_canonical(self.canonical_thread, "canonical_thread") + + @property + def lookup(self) -> ThreadLookup: + return self.recipient_lookup diff --git a/src/agent_comms/pi_events.py b/src/agent_comms/pi_events.py index 012b695abd..4d85233c85 100644 --- a/src/agent_comms/pi_events.py +++ b/src/agent_comms/pi_events.py @@ -15,14 +15,18 @@ DeclaredWireType, FieldCodec, FieldPlan, + InputId, + MalformedRecord, ToolCallId, TurnId, WireName, WireNullDefault, WireOmitDefault, + boundary_decoder, ) from .native_failures import NativePiUnavailable -from .native_input_proof import NativeInputCommit, NativeInputLocation +from .native_input_proof import NativeContextRecord, NativeInputCommit, NativeInputLocation +from .native_session_reopen import PiSessionId from .native_output import NativeOutputLocation, NativeRecordLocation from .native_pi import NativePiOperationFailed from .native_turn_context import NativeContextManifestData @@ -423,6 +427,27 @@ class ContextCommitted(NativePiEvent, NativeInputCommit): ) committed_at_ms: Annotated[int | PiOmitted, WireName("committedAtMs")] = PiOmitted() + @boundary_decoder + def record_for( + self, input_id: InputId, session_id: PiSessionId, + ) -> NativeContextRecord: + """Normalize the optional native acknowledgement into its required record. + + This event is not durable proof. The acquired evidence reader must corroborate + the complete located record independently. + """ + emitted = FieldCodec.encode(self) + try: + record = FieldCodec.decode(NativeContextRecord, { + key: emitted[key] for _, key in FieldCodec.record_fields(NativeContextRecord) + if key in emitted + }) + except MalformedRecord as error: + raise NativePiUnavailable("Native Pi input/context acknowledgement is malformed") from error + if record.input_commit != NativeInputCommit(input_id, session_id, record.session_entry_id): + raise NativePiUnavailable("Native Pi context names another original input") + return record + @dataclass(frozen=True, kw_only=True) class TurnContextObserved(NativePiEvent): diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 54834e57f3..79c3e736d8 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -68,8 +68,10 @@ ) from .read_ledger import NoReadNotice, ReadLedger, ReadNotice from .notification_assignment import NotificationAssignment -from .notification_evidence import AssignmentResponse, NotificationAssignmentRead, NotificationReadMark, NotificationWindow, RecipientSelection -from .read_basis import FirstRecipient, OutcomeViewer, RecipientCursor +from .notification_evidence import ( + AssignmentResponse, FirstRecipient, NotificationAssignmentRead, NotificationReadMark, + NotificationWindow, OutcomeViewer, RecipientCursor, RecipientLimit, +) from .notification_read import CapturedNotificationWindow from .recorded_bus import NoRecordedBus, RecordedBusIdentity from .registry_document import DEFAULT_ROSTER, RosterVisibility @@ -196,7 +198,7 @@ def timed_state(label: str, timestamp_ms: int | None) -> str: def references( cls, root: Path, registry: Registration, log: Bus, references: Sequence[MessageReference], *, viewer: OutcomeViewer, - after: RecipientCursor = FirstRecipient(), limit: int = RecipientSelection.max_limit, + after: RecipientCursor = FirstRecipient(), limit: RecipientLimit = RecipientLimit(), ) -> NotificationWindow: """Borrow mounted references in the original bounded delivery windows, as this viewer may see them.""" @@ -244,7 +246,7 @@ def delivery_window( def recorded_delivery_window(cls, root: Path, namespace: RegistryProvenance, sources: Sequence[Delivered], *, viewer: OutcomeViewer, after: RecipientCursor = FirstRecipient(), - limit: int = RecipientSelection.max_limit) -> NotificationWindow: + limit: RecipientLimit = RecipientLimit()) -> NotificationWindow: selection = viewer.selection(sources, after=after, limit=limit) observations = { recipient.recipient_lookup: RecordedRecipientActivity() diff --git a/src/agent_comms/private_registry_guard.py b/src/agent_comms/private_registry_guard.py index e4cf92e71d..f8a1e7c585 100644 --- a/src/agent_comms/private_registry_guard.py +++ b/src/agent_comms/private_registry_guard.py @@ -46,7 +46,8 @@ from .private_path import PrivateFileRole from .private_runtime_schema import PrivateCoordinatorTable from .thread_identity import ClaimedIdentity -from .notification_evidence import NotificationAssignmentRead, NotificationSelection +from .notification_evidence import NotificationAssignmentRead +from .notification_evidence import NotificationSelection if TYPE_CHECKING: diff --git a/src/agent_comms/private_send_stage.py b/src/agent_comms/private_send_stage.py index da48ea11fc..88953a8f89 100644 --- a/src/agent_comms/private_send_stage.py +++ b/src/agent_comms/private_send_stage.py @@ -163,8 +163,8 @@ def verify( # Disk evidence corroborates those events, never authorizes recovery. with source_reads.borrow(): evidence = source_reads.for_source(context.session_file) - if not context.corroborates_input( - input_id, evidence=evidence, + if not evidence.corroborates_context( + context, input_id=input_id, files=NativeSessionFiles.for_identity(NativeSessionIdentity.from_context(context), root=store.session.path.parent, owner=owner.thread.incarnation.require_recorded()), diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 6ad260a55d..e117ef9c88 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -32,125 +32,13 @@ from .mentions import ThreadMention from .launch_runtime import CallingThreadUnavailable from .message_reference import MessageReference -from .notification_evidence import NotificationSelection, RecipientSelection -from .audience_manifest import FrozenRecipient -from .bus_publication import Delivered +from .notification_evidence import AllOutcomes, DisplayOutcomes, OutcomeViewer from .private_path import FileObservation, MissingFile from .projection_source import ProducedRow, ProjectionSource from .recorded_bus import RecordedBus, RecordedBusIdentity from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation -class RecipientCursor(SubclassUnion, affix=""): - """Select frozen, lookup-ordered positions before expensive evidence acquisition.""" - - @abstractmethod - def positions(self, source: Delivered) -> range: ... - - -@dataclass(frozen=True) -class FirstRecipient(NullMember, RecipientCursor): - def positions(self, source: Delivered) -> range: - return range(len(source.recipients)) - - -@dataclass(frozen=True) -class AfterRecipient(RecordMember, RecipientCursor): - recipient: FrozenRecipient - - def positions(self, source: Delivered) -> range: - start = bisect_right(source.recipients, self.recipient.recipient_lookup, - key=lambda item: item.recipient_lookup) - return range(start, len(source.recipients)) - - -class OutcomeViewer(ABC): - """Who reads a delivery window decides which of its outcomes are shown.""" - - @abstractmethod - def positions(self, source: Delivered) -> range: ... - - def selection(self, sources: Sequence[Delivered], *, after: RecipientCursor = FirstRecipient(), - limit: int = RecipientSelection.max_limit) -> NotificationSelection: - RecipientSelection.require_limit(limit) - pages = [] - for source in sources: - eligible, following = self.positions(source), after.positions(source) - pages.append(RecipientSelection( - source, range(max(eligible.start, following.start), min(eligible.stop, following.stop)), limit)) - return NotificationSelection(self, tuple(pages)) - - def visible_reads(self, displayed_to: tuple[ThreadIncarnation, ...], - source: RecordedBusIdentity) -> tuple[ThreadIncarnation, ...]: - """Unscoped/operator and agent readers retain every human read mark.""" - return displayed_to - - @abstractmethod - def sees_direct_read(self, source: Delivered) -> bool: - pass - - @abstractmethod - def sees(self, source: Delivered) -> bool: - """Whether this reader may read the message at all.""" - - -@dataclass(frozen=True, slots=True) -class AllOutcomes(OutcomeViewer): - """An unscoped reader sees every recipient's outcome and every human read.""" - - def positions(self, source: Delivered) -> range: - return range(len(source.recipients)) - - def sees_direct_read(self, source: Delivered) -> bool: - return True - - def sees(self, source: Delivered) -> bool: - return True - - -@dataclass(frozen=True, slots=True) -class DisplayOutcomes(AllOutcomes): - """Full operator access, minus only this display's source-matching viewer. - - Incarnation equality owns rename/recreation; the captured bus owns namespace. - The live registry cannot establish either fact for an archived display. - """ - - viewer: ThreadIncarnation - bus_identity: RecordedBusIdentity - - def visible_reads(self, displayed_to: tuple[ThreadIncarnation, ...], - source: RecordedBusIdentity) -> tuple[ThreadIncarnation, ...]: - return tuple(reader for reader in displayed_to - if reader != self.viewer or not self.bus_identity.matches(source)) - - -@dataclass(frozen=True, slots=True) -class OwnerOutcomes(OutcomeViewer): - """A sender sees all outcomes; a recipient sees only its original outcome.""" - - lookup: ThreadLookup - - @classmethod - def of(cls, owner: ThreadProvenance) -> OwnerOutcomes: - return cls(owner.incarnation.require_recorded().lookup) - - def positions(self, source: Delivered) -> range: - if source.sender_lookup == self.lookup: - return range(len(source.recipients)) - position = bisect_left(source.recipients, self.lookup, key=lambda item: item.recipient_lookup) - if position < len(source.recipients) and source.recipients[position].recipient_lookup == self.lookup: - return range(position, position + 1) - return range(0) - - def sees_direct_read(self, source: Delivered) -> bool: - return source.sender_lookup == self.lookup - - def sees(self, source: Delivered) -> bool: - """The message's sender and its original recipients may read it.""" - return self.lookup == source.sender_lookup or bool(self.positions(source)) - - class DisplayViewer(SubclassUnion, affix="Viewer"): """The reader owns read evidence, aliases and cursor selection.""" diff --git a/src/agent_comms/recorded_time.py b/src/agent_comms/recorded_time.py index 80f914c6ac..5b08488348 100644 --- a/src/agent_comms/recorded_time.py +++ b/src/agent_comms/recorded_time.py @@ -11,6 +11,17 @@ from .declared_family import DeclaredFamily from .field_codec import AcceptsNull, FieldCodec, FieldRepresentation, NullMember, boundary_decoder +class Timestamped: + """A recorded Unix-seconds clock owns its integer-millisecond projection.""" + + __slots__ = () + timestamp: float + + @property + def at_ms(self) -> int: + return round(self.timestamp * EventMilliseconds.units_per_second) + + class RecordedEventSeconds(FieldRepresentation): """The recorded-time value of an existing numeric-seconds contract.""" diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index a63ca9646b..3b0e0084c6 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -37,7 +37,7 @@ from .notification_evidence import NotificationAssignmentRead, NotificationWindow from .participant_store import ParticipantSnapshot from .pi_events import TurnContextObserved -from .read_basis import OwnerOutcomes +from .notification_evidence import OwnerOutcomes from .presentation import MessageNotification, SentMessageOutcome from .publisher import ParticipantPublisher from .selected_source_batch import SelectedSource, SelectedSourceBatch @@ -250,7 +250,7 @@ def prepare( store, lookup, after_seq=after_seq ) sent_read = NotificationAssignment.capture( - store.session._connection, OwnerOutcomes.of(sent_owner).selection(sent_sources), privacy, + store.session._connection, OwnerOutcomes.of(sent_owner.incarnation).selection(sent_sources), privacy, ) sent_read = NotificationAssignment.capture_detached(comms.root, sent_read) if pending: diff --git a/src/agent_comms/thread_presentation.py b/src/agent_comms/thread_presentation.py index edd2a2c2cb..ffb8400d36 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -269,12 +269,12 @@ def with_sources(self, window: TranscriptSourceWindow) -> ThreadPresentation: as the thread's owner may see them; the incoming outcomes carry public envelopes.""" from .notification_assignment import NotificationAssignment from .presentation import MessageNotification - from .read_basis import OwnerOutcomes + from .notification_evidence import OwnerOutcomes transcripts = window.read.owner sources = tuple({source.message.reference: source for source in (*window.sources, *window.sent_sources)}.values()) - viewer = OwnerOutcomes.of(window.read.identity.thread) + viewer = OwnerOutcomes.of(window.read.identity.thread.incarnation) notification_read = NotificationAssignment.read(transcripts.root, viewer.selection(sources)) projected = MessageNotification.delivery_window( transcripts.root, transcripts.registry, notification_read) diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index c09349d023..206fd0cede 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -10,7 +10,8 @@ from .errors import ToolRefusedError from .field_codec import WireForm from .launch_root import LaunchRoot -from .read_basis import OperatorOutcomes, OutcomeViewer, OwnerOutcomes, ReadAuthority +from .read_basis import OperatorOutcomes, ReadAuthority +from .notification_evidence import OutcomeViewer, OwnerOutcomes from .presentation import SentMessageSummary, ThreadRecord from .thread_execution import CommanderForkExecution from .thread_identity import ThreadIncarnation @@ -138,7 +139,7 @@ def listing(self, comms: Comms, threads: tuple[ThreadRecord, ...]) -> ThreadList return ThreadListing(threads, SentMessageSummary.for_sources(presentation.sent_notifications())) def outcome_viewer(self, registry: Registration) -> OutcomeViewer: - return OwnerOutcomes.of(registry.require(self.name)) + return OwnerOutcomes.of(registry.require(self.name).incarnation) def catalog(self, comms: Comms, tools: tuple[dict[str, object], ...]) -> ToolCatalogResult: return ThreadToolCatalogResult( diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index ccaa3049ea..5ee01d80b5 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -57,9 +57,9 @@ from .page_traversal import NewestCursor, PageCursor from .pi_payloads import PublishedGoalToolDetails from .pi_vocabulary import ServiceTier, ThinkingLevelField -from .read_basis import AfterRecipient, FirstRecipient, RecipientCursor +from .notification_evidence import AfterRecipient, FirstRecipient, RecipientCursor from .presentation import ChannelView, MessageNotification -from .notification_evidence import RecipientSelection +from .notification_evidence import RecipientLimit from .progress_claims import ProgressClaim from .read_basis import NamedViewer from .relationships import ( @@ -691,16 +691,12 @@ class CommsMessageOutcomesTool(ReadTool, ToolRequest): reference: Annotated[ MessageReference, *tool_field("The message's seq and message_id, as comms_threads lists them") ] - limit: Annotated[int, *tool_field("Maximum recipient rows in this page")] = RecipientSelection.max_limit + limit: Annotated[RecipientLimit, *tool_field("Maximum recipient rows in this page")] = RecipientLimit() after: Annotated[ RecipientCursor, *tool_field("The previous page's next cursor; omit for the first page") ] = FirstRecipient() def apply(self, comms: Comms, caller: ToolCaller) -> MessageOutcomes: - try: - RecipientSelection.require_limit(self.limit) - except ValueError as error: - raise ToolRefusedError(str(error)) from error source, = comms.bus.bus.deliveries_for_references((self.reference,)) if not caller.outcome_viewer(comms.registry).sees(source): raise ToolRefusedError(f"Message {source.message.seq} is not readable by this caller.") diff --git a/tests/c4_coordination_installed_ui.py b/tests/c4_coordination_installed_ui.py index 36ef961d91..dc6f3de68a 100644 --- a/tests/c4_coordination_installed_ui.py +++ b/tests/c4_coordination_installed_ui.py @@ -24,7 +24,7 @@ sys.path.insert(0, str(args.toad_tests)) from runtime_fixture import ToadApp, wait_channel_roster from agent_comms.comms import wire -from agent_comms.read_basis import AllOutcomes +from agent_comms.notification_evidence import AllOutcomes from agent_comms.presentation import MessageNotification from agent_comms.notification_evidence import NotificationWindow from agent_comms.registry_document import RosterVisibility diff --git a/tests/native_proof_cases.py b/tests/native_proof_cases.py index 1b9044c515..f6a6cda9c4 100644 --- a/tests/native_proof_cases.py +++ b/tests/native_proof_cases.py @@ -15,7 +15,7 @@ def read_context_proof( files: NativeSessionFiles, input_id: InputId, *, session_entry_start: int, - generations: range = range(1, 2**53), evidence: NativeEvidenceRead | None = None, + generations: range = NativeContextRecord.interval(), evidence: NativeEvidenceRead | None = None, ) -> NativeContextProof: """Keep fixture reads inside the same production byte-reader lifetime.""" with NativeInputEvidenceRead.borrow(files.session, evidence) as reader: @@ -57,7 +57,7 @@ def write_proof_rows(files: NativeSessionFiles, rows: Iterable[dict[str, object] def read_proof_rows(files: NativeSessionFiles) -> list[dict[str, object]]: - with NativeContextJournal.open_evidence(files) as db: + with NativeContextJournal.open_evidence(files.input_proof) as db: return [ FieldCodec.encode(row) for row in NativeContextJournal.select(db, order_by=("request_generation", "input_id")) diff --git a/tests/reply_relevance_installed_journey.py b/tests/reply_relevance_installed_journey.py index 2032815180..e526c94631 100644 --- a/tests/reply_relevance_installed_journey.py +++ b/tests/reply_relevance_installed_journey.py @@ -1,6 +1,6 @@ """One configured saved-helper fork and fresh USER channel request, never a retry.""" -from agent_comms.read_basis import AllOutcomes +from agent_comms.notification_evidence import AllOutcomes from agent_comms.repo import Checkout from agent_comms.channel_targets import CommandTarget diff --git a/tests/test_channel_bus_ownership.py b/tests/test_channel_bus_ownership.py index 1fc01cb4f7..78bafd9e24 100644 --- a/tests/test_channel_bus_ownership.py +++ b/tests/test_channel_bus_ownership.py @@ -1,6 +1,6 @@ """Channel membership and notifications have one canonical bus authority.""" -from agent_comms.read_basis import AllOutcomes +from agent_comms.notification_evidence import AllOutcomes from agent_comms.repo import Checkout from contextlib import AsyncExitStack from dataclasses import replace diff --git a/tests/test_following_context_notifications.py b/tests/test_following_context_notifications.py index e6690b27ee..3faace972e 100644 --- a/tests/test_following_context_notifications.py +++ b/tests/test_following_context_notifications.py @@ -32,7 +32,7 @@ from agent_comms.native_runtime_input import NativeRuntimeInput from agent_comms.pi_events import ContextCommitted, InputCommitted from agent_comms.private_send_claim import PrivateSendClaim -from agent_comms.read_basis import AllOutcomes +from agent_comms.notification_evidence import AllOutcomes from agent_comms.private_sidecar import native_request_digest from conftest import accept_initial, publish_initial from native_proof_cases import append_input_entry diff --git a/tests/test_harness_receipt_context.py b/tests/test_harness_receipt_context.py index ceedb61bb2..b62c1390d9 100644 --- a/tests/test_harness_receipt_context.py +++ b/tests/test_harness_receipt_context.py @@ -12,22 +12,22 @@ ) from agent_comms.notification_progress import HarnessConsumedProgress from agent_comms.notification_evidence import UnansweredResponse -from agent_comms.read_basis import AllOutcomes +from agent_comms.notification_evidence import AllOutcomes from test_coordinated_runtime import _root, tmp_path # noqa: F401 from transcript_record_fixture import transcript_record -@pytest.mark.parametrize(('outcome', 'monitor', 'consumed', 'arrived'), ( - (UnconfirmedOutcome, UnrecordedMonitor(), False, 0), - (EmittedOutcome, UnrecordedMonitor(), False, 0), - (FilteredOutcome, UnrecordedMonitor(), False, 0), - (DeliveredOutcome, UnrecordedMonitor(), False, 0), - (AcceptedOutcome, UnrecordedMonitor(), False, 1), - (PersistedOutcome, UnrecordedMonitor(), False, 1), - (DeliveredOutcome, ObservedMonitor(transcript_record('complete-original'), 1791964800.125), True, 1), +@pytest.mark.parametrize(('outcome', 'monitor', 'consumed'), ( + (UnconfirmedOutcome, UnrecordedMonitor(), False), + (EmittedOutcome, UnrecordedMonitor(), False), + (FilteredOutcome, UnrecordedMonitor(), False), + (DeliveredOutcome, UnrecordedMonitor(), False), + (AcceptedOutcome, UnrecordedMonitor(), False), + (PersistedOutcome, UnrecordedMonitor(), False), + (DeliveredOutcome, ObservedMonitor(transcript_record('complete-original'), 1791964800.125), True), )) def test_only_retained_complete_body_proof_promotes_consumption( - tmp_path, outcome, monitor, consumed, arrived, + tmp_path, outcome, monitor, consumed, ): """This exercises projection of recorded evidence, not transcript acquisition. @@ -56,7 +56,6 @@ def test_only_retained_complete_body_proof_promotes_consumption( assert current.state.consumed is consumed assert current.answer == UnansweredResponse() assert not current.displayed_to - assert recorded.monitor.arrival_count(recorded.outcome) == arrived assert recorded.monitor == monitor if consumed: assert current.label == current.timed_state(HarnessConsumedProgress.label, 1791964800125) diff --git a/tests/test_historical_notifications.py b/tests/test_historical_notifications.py index 5c3bf35b28..2d087076aa 100644 --- a/tests/test_historical_notifications.py +++ b/tests/test_historical_notifications.py @@ -2,7 +2,7 @@ from agent_comms.notification_evidence import AssignmentResponse, AnsweredResponse, UnansweredResponse -from agent_comms.read_basis import AllOutcomes +from agent_comms.notification_evidence import AllOutcomes from agent_comms.presentation import MessageNotification from agent_comms.repo import Checkout from agent_comms.bus import Bus @@ -127,7 +127,7 @@ def human_display(): consumed = notification() assert consumed.state.consumed and not consumed.displayed_to - assert consumed.label == consumed.timed_state('Consumed', round(shown.timestamp * 1000)) + assert consumed.label == consumed.timed_state('Consumed', shown.at_ms) ledger = live.bus.reads.read() assert human_display() == () # The UI's "mark read" on the agent's thread, as the registered human viewer. @@ -413,7 +413,7 @@ def test_delivered_assignment_uses_its_recorded_conversation_response(archived_n assert response.answers == (original.reference,) assert notification.label.startswith("Answered ") - assert notification.answer == AnsweredResponse(response.reference, int(response.timestamp * 1000)) + assert notification.answer == AnsweredResponse(response.reference, response.at_ms) def test_recorded_response_resolution_keeps_other_collective_lifecycles( @@ -481,7 +481,7 @@ def test_live_cli_ack_does_not_become_archived_ack(archived_notification): messages[0].seq, messages[0].message_id ] assert current.state.consumed and not current.displayed_to - assert current.label == current.timed_state('Consumed', round(messages[0].timestamp * 1000)) + assert current.label == current.timed_state('Consumed', messages[0].at_ms) with live.bus.history.read_sources() as sources: archived, = source.notification_references(sources, (messages[0].reference,), AllOutcomes()).outcomes[ messages[0].seq, messages[0].message_id diff --git a/tests/test_live_context_notifications.py b/tests/test_live_context_notifications.py index 8cccc07d6b..78367d5458 100644 --- a/tests/test_live_context_notifications.py +++ b/tests/test_live_context_notifications.py @@ -13,7 +13,7 @@ from agent_comms.notification_evidence import UnansweredResponse from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof from agent_comms.native_runtime_input import NativeRuntimeInput -from agent_comms.read_basis import AllOutcomes +from agent_comms.notification_evidence import AllOutcomes from agent_comms.private_send_claim import PrivateSendClaim from agent_comms.private_sidecar import native_request_digest from agent_comms.selected_triage import AbsentTriageDecisionRecord, DecidedTriageRecord, IgnoreSelectedTriage diff --git a/tests/test_message_notifications.py b/tests/test_message_notifications.py index 07d21c3933..9e2e5fe514 100644 --- a/tests/test_message_notifications.py +++ b/tests/test_message_notifications.py @@ -1,5 +1,5 @@ """Read real persisted notification assignments without scheduling a model.""" -from agent_comms.read_basis import AllOutcomes +from agent_comms.notification_evidence import AllOutcomes from agent_comms.repo import Checkout from dataclasses import replace from test_coordinated_runtime import _root, tmp_path # noqa: F401 diff --git a/tests/test_native_evidence_custody.py b/tests/test_native_evidence_custody.py index 0c88fb1ecf..f91d91fe82 100644 --- a/tests/test_native_evidence_custody.py +++ b/tests/test_native_evidence_custody.py @@ -121,7 +121,7 @@ def test_indexed_proof_borrow_does_not_classify_consumer_sql_failure(tmp_path): files = _evidence(tmp_path) original = sqlite3.OperationalError("consumer transaction refused") with pytest.raises(sqlite3.OperationalError) as caught: - with NativeContextJournal.open_evidence(files) as db: + with NativeContextJournal.open_evidence(files.input_proof) as db: assert NativeContextJournal.for_input(db, INPUT_ID) is not None raise original assert caught.value is original diff --git a/tests/test_native_input_bus_path.py b/tests/test_native_input_bus_path.py index 7d606a36c7..0360247c60 100644 --- a/tests/test_native_input_bus_path.py +++ b/tests/test_native_input_bus_path.py @@ -56,7 +56,7 @@ async def test_saved_owner_dm_replies_and_core_reads_exact_native_proof(tmp_path assert NativeInputProofArgument(str(files.input_proof)).argv in tuple(zip(launch.argv, launch.argv[1:])) assert files.input_proof.is_relative_to(native.root) assert not native.session.with_name(native.session.name+'.input-proof').exists() - with NativeContextJournal.open_evidence(files) as db: + with NativeContextJournal.open_evidence(files.input_proof) as db: rows = NativeContextJournal.select(db) assert len(rows) == 1 record = next(record for record in NativeTranscript(native.session).forward( diff --git a/tests/test_native_pi.py b/tests/test_native_pi.py index f0e53f2c63..c64b973624 100644 --- a/tests/test_native_pi.py +++ b/tests/test_native_pi.py @@ -14,8 +14,9 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec, InputId from agent_comms.native_arguments import SelectedModel -from agent_comms.native_entries import NativeEntry +from agent_comms.native_entries import NativeEntry, NativeInputEvidenceRead from agent_comms.native_failures import NativePiUnavailable +from agent_comms.pi_events import ContextCommitted, InputCommitted from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof from agent_comms.native_pi import ( NativePiRpcLaunch, @@ -248,8 +249,6 @@ def test_provider_failure_notice_does_not_broadcast_untrusted_error_body(tmp_pat def test_recorded_context_remains_verifiable_after_later_tool_rounds(tmp_path): from dataclasses import replace - from agent_comms.native_pi import _verify_context - from agent_comms.pi_events import ContextCommitted, InputCommitted files = _evidence(tmp_path) session = files.session @@ -262,13 +261,16 @@ def test_recorded_context_remains_verifiable_after_later_tool_rounds(tmp_path): assert read_context_proof(files, INPUT_ID, session_entry_start=record.start).request_generation == 2 assert read_context_proof(files, INPUT_ID, generations=range(1, 2), session_entry_start=record.start) == recorded with NativeEntry.open_evidence(session) as evidence: - assert recorded.corroborates_input(INPUT_ID, files=files, evidence=evidence) - assert not recorded.corroborates_input('e'*32, files=files, evidence=evidence) + assert evidence.corroborates_context(recorded, input_id=INPUT_ID, files=files) + assert not evidence.corroborates_context(recorded, input_id=InputId('e'*32), files=files) input_event = InputCommitted(input_id=INPUT_ID,session_id=PiSessionId('sid'),session_entry_id=FreshNativeId('entry'),session_entry_start=record.start) context_event = ContextCommitted.from_wire({key:value for key,value in first.items() if key != 'schema'}) - assert _verify_context(files,INPUT_ID,'sid',input_event,context_event) == recorded - with pytest.raises(NativePiUnavailable): - _verify_context(files,INPUT_ID,'sid',input_event,replace(context_event,llm_context_digest='d'*64)) + with NativeInputEvidenceRead.open(session) as evidence: + assert evidence.verify_context(files, input_event, + context_event.record_for(INPUT_ID, PiSessionId('sid'))) == recorded + with pytest.raises(NativePiUnavailable): + evidence.verify_context(files, input_event, + replace(context_event, llm_context_digest='d'*64).record_for(INPUT_ID, PiSessionId('sid'))) with pytest.raises(NativePiUnavailable): read_context_proof(files,INPUT_ID,generations=range(3, 4),session_entry_start=record.start) @@ -291,7 +293,7 @@ def test_proof_observation_rejects_file_replacement(tmp_path): files = _evidence(tmp_path) journal = files.input_proof with pytest.raises(NativePiUnavailable): - with NativeContextJournal.open_evidence(files) as db: + with NativeContextJournal.open_evidence(files.input_proof) as db: assert NativeContextJournal.for_input(db,INPUT_ID).input_id == INPUT_ID changed = journal.with_name('replacement') changed.write_bytes(journal.read_bytes()) diff --git a/tests/test_notification_display_boundary.py b/tests/test_notification_display_boundary.py index 534a127db8..8664f41c8d 100644 --- a/tests/test_notification_display_boundary.py +++ b/tests/test_notification_display_boundary.py @@ -6,7 +6,7 @@ from dataclasses import replace from agent_comms.notification_reads import RecordedNotificationRead -from agent_comms.read_basis import AllOutcomes +from agent_comms.notification_evidence import AllOutcomes from agent_comms.ui_model.observation import ReadNotifications from test_notification_display_viewer import ArchivedReaders, archived_readers # noqa: F401 diff --git a/tests/test_notification_display_viewer.py b/tests/test_notification_display_viewer.py index 79f9ae6e7d..5ec79c6d39 100644 --- a/tests/test_notification_display_viewer.py +++ b/tests/test_notification_display_viewer.py @@ -13,7 +13,7 @@ from agent_comms.messages import Message from agent_comms.notification_evidence import NotificationWindow from agent_comms.notification_progress import DeliveryProgress -from agent_comms.read_basis import AllOutcomes, OutcomeViewer, OwnerOutcomes +from agent_comms.notification_evidence import AllOutcomes, OutcomeViewer, OwnerOutcomes from agent_comms.presentation import MessageNotification from agent_comms.registration import Registration from agent_comms.read_basis import HistoricalDisplay, HistoricalSelection, NamedViewer @@ -82,7 +82,7 @@ def test_display_subtraction_preserves_raw_other_reads_and_real_audience( live_document = fixture.live.bus.reads.read() raw = fixture.notifications(AllOutcomes()) shown = fixture.notifications(identity.outcome_viewer(fixture.live.registry)) - agent = fixture.notifications(OwnerOutcomes.of(fixture.source.provenance.require('sender'))) + agent = fixture.notifications(OwnerOutcomes.of(fixture.source.provenance.require('sender').incarnation)) key = (fixture.message.seq, fixture.message.message_id) before = {item.recipient_identity: item for item in raw.outcomes[key]} after = {item.recipient_identity: item for item in shown.outcomes[key]} diff --git a/tests/test_notification_recipient_window.py b/tests/test_notification_recipient_window.py index f09aa7e5bd..946e7cd982 100644 --- a/tests/test_notification_recipient_window.py +++ b/tests/test_notification_recipient_window.py @@ -15,8 +15,8 @@ from agent_comms.harness_delivery_states import PersistedOutcome from agent_comms.notification_assignment import NotificationAssignment from agent_comms.notification_evidence import AnsweredResponse -from agent_comms.read_basis import AllOutcomes, AfterRecipient, OwnerOutcomes -from agent_comms.notification_evidence import RecipientSelection +from agent_comms.notification_evidence import AllOutcomes, AfterRecipient, OwnerOutcomes +from agent_comms.notification_evidence import RecipientLimit from agent_comms.presentation import MessageNotification from agent_comms.read_ledger import ReadLedger from agent_comms.repo import Checkout @@ -62,7 +62,7 @@ def test_acquired_rows_and_proofs_are_exact_bounded_subset_without_rewriting_ori before_wire = comms.bus.bus.revision() viewer = AllOutcomes() full = NotificationAssignment.read(comms.root, viewer.selection((source,))) - selection = viewer.selection((source,), limit=3) + selection = viewer.selection((source,), limit=RecipientLimit(3)) bounded = NotificationAssignment.read(comms.root, selection) selected = {recipient.recipient_lookup for recipient in selection.pages[0].recipients} assert len(full.rows) == len(frozen) > len(bounded.rows) == 3 @@ -83,15 +83,14 @@ def test_retained_cut_ignores_other_recipient_change_but_observes_its_own_proof( frozen_window: tuple[Comms, Delivered], ) -> None: comms, source = frozen_window - selection = AllOutcomes().selection((source,), limit=3) + selection = AllOutcomes().selection((source,), limit=RecipientLimit(3)) before = NotificationAssignment.read(comms.root, selection) record_attempt(comms, source, len(source.recipients) - 1) assert NotificationAssignment.changed((before,), comms.root) == () record_attempt(comms, source, 0) assert NotificationAssignment.changed((before,), comms.root) == (before,) changed = NotificationAssignment.read(comms.root, selection) - assert len(changed.attempts) == 1 - assert changed.attempts[0].recipient_lookup == source.recipients[0].recipient_lookup + assert tuple(changed.consumption) == ((source.recipients[0].recipient_lookup, source.reference),) def test_cursor_and_owner_scope_are_applied_by_actual_read_request_before_acquisition( @@ -99,21 +98,21 @@ def test_cursor_and_owner_scope_are_applied_by_actual_read_request_before_acquis ) -> None: comms, source = frozen_window reference = source.message.reference - first = ReadNotifications(str(comms.root), LiveNotificationRead((reference,), limit=3)).read(comms) + first = ReadNotifications(str(comms.root), LiveNotificationRead((reference,), limit=RecipientLimit(3))).read(comms) key = reference.seq, reference.message_id cursor = AfterRecipient(first.outcomes[key][-1].recipient_identity) second = ReadNotifications( - str(comms.root), LiveNotificationRead((reference,), after=cursor, limit=3)).read(comms) + str(comms.root), LiveNotificationRead((reference,), after=cursor, limit=RecipientLimit(3))).read(comms) assert len(first.outcomes[key]) == len(second.outcomes[key]) == 3 assert {row.recipient_identity for row in first.outcomes[key]}.isdisjoint( row.recipient_identity for row in second.outcomes[key]) assert second.remaining(reference) == len(source.recipients) - 6 own = source.recipients[-1] recipient = comms.views.message_notifications_for_references( - OwnerOutcomes(own.recipient_lookup), (reference,), limit=1) + OwnerOutcomes(own.recipient_lookup), (reference,), limit=RecipientLimit(1)) assert tuple(row.recipient_identity for row in recipient.outcomes[key]) == (own,) with pytest.raises(ValueError): - AllOutcomes().selection((source,), limit=RecipientSelection.max_limit + 1) + RecipientLimit(RecipientLimit.maximum + 1) def test_reply_acquisition_returns_only_first_reply_of_selected_frozen_author( @@ -135,7 +134,7 @@ def test_independent_reply_changes_retained_evidence_without_claiming_context( frozen_window: tuple[Comms, Delivered], ) -> None: comms, source = frozen_window - selection = AllOutcomes().selection((source,), limit=1) + selection = AllOutcomes().selection((source,), limit=RecipientLimit(1)) recipient = selection.pages[0].recipients[0] record_attempt(comms, source, 0) before = NotificationAssignment.read(comms.root, selection) @@ -146,14 +145,14 @@ def test_independent_reply_changes_retained_evidence_without_claiming_context( after = NotificationAssignment.read(comms.root, selection) window = MessageNotification.delivery_window(comms.root, comms.registry, after) row, = window.outcomes[source.message.seq, source.message.message_id] - assert row.answer == response.reference + assert row.answer == AnsweredResponse(response.reference, response.at_ms) assert not row.state.consumed - assert after.rows == before.rows and after.attempts == before.attempts + assert after.rows == before.rows and after.consumption == before.consumption assert ReadLedger.at(comms.root).read() == reads later = comms.messaging.send(recipient.canonical_thread, source.message.sender, "A later independent answer", answers=(source.message.reference,)).message assert NotificationAssignment.changed((after,), comms.root) == () - assert AnsweredResponse(later.reference, int(later.timestamp * 1000)).answered(row) is row + assert AnsweredResponse(later.reference, later.at_ms).answered(row) is row def test_independent_reply_without_any_claim_is_retained_without_context_or_read_mutation( @@ -162,20 +161,20 @@ def test_independent_reply_without_any_claim_is_retained_without_context_or_read comms, _accepted_source = frozen_window message = publish_initial(comms, "sender", "#team", "An original without a handling claim") source = comms.bus.bus.delivery(message.seq) - selection = AllOutcomes().selection((source,), limit=1) + selection = AllOutcomes().selection((source,), limit=RecipientLimit(1)) recipient = selection.pages[0].recipients[0] reads = ReadLedger.at(comms.root).read() before = NotificationAssignment.read(comms.root, selection) - assert before.rows == before.attempts == () + assert before.rows == () and not before.consumption response = comms.messaging.send(recipient.canonical_thread, source.message.sender, "An answer without an input claim", answers=(message.reference,)).message assert NotificationAssignment.changed((before,), comms.root) == (before,) after = NotificationAssignment.read(comms.root, selection) - assert after.rows == after.attempts == () + assert after.rows == () and not after.consumption assert after.responses[recipient.recipient_lookup, message.reference] == AnsweredResponse( - response.reference, int(response.timestamp * 1000)) + response.reference, response.at_ms) window = MessageNotification.delivery_window(comms.root, comms.registry, after) row, = window.outcomes[message.seq, message.message_id] - assert row.answer == response.reference and row.changed + assert row.answer == AnsweredResponse(response.reference, response.at_ms) and row.changed assert not row.state.consumed and not row.displayed_to assert ReadLedger.at(comms.root).read() == reads diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index 113557c91f..fc064bfe71 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -1,6 +1,6 @@ """Whole conversations derive their original frozen wire sources, in both directions.""" -from agent_comms.read_basis import AllOutcomes, OwnerOutcomes +from agent_comms.notification_evidence import AllOutcomes, OwnerOutcomes from agent_comms.repo import Checkout from agent_comms.channel_targets import CommandTarget diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index c17bc39c4b..cfaa259d29 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -13,7 +13,7 @@ from agent_comms.comms import wire from agent_comms.errors import RelationViolationError from agent_comms.notification_assignment import NotificationAssignment -from agent_comms.read_basis import AllOutcomes, OwnerOutcomes +from agent_comms.notification_evidence import AllOutcomes, OwnerOutcomes from agent_comms.presentation import MessageNotification from agent_comms.messages import InfoKind, Message from agent_comms.read_ledger import ReadDocument, ReadLedger @@ -104,7 +104,7 @@ def test_a_viewer_follows_frozen_lookups_through_rename_and_never_into_a_rebirth key = (sent.seq, sent.message_id) def seen_by(name: str) -> set[str]: - viewer = OwnerOutcomes.of(comms.registry.require(name)) + viewer = OwnerOutcomes.of(comms.registry.require(name).incarnation) read = NotificationAssignment.read(comms.root, viewer.selection((original,))) assert read.selection.viewer == viewer window = MessageNotification.delivery_window(comms.root, comms.registry, read) @@ -128,7 +128,7 @@ def test_a_public_root_without_a_coordinator_keeps_its_viewer_and_projects_visib 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")) + viewer = OwnerOutcomes.of(comms.registry.require("peer-a").incarnation) read = NotificationAssignment.read(comms.root, viewer.selection((original,))) assert (read.sources, read.rows, read.attempts, read.selection.viewer) == ((original,), (), (), viewer) window = MessageNotification.delivery_window(comms.root, comms.registry, read) diff --git a/tests/test_wake_candidate_index.py b/tests/test_wake_candidate_index.py index 8a412feba3..56ffc8f02a 100644 --- a/tests/test_wake_candidate_index.py +++ b/tests/test_wake_candidate_index.py @@ -14,7 +14,8 @@ from agent_comms.messages import InfoKind, Message, NoticeRole from agent_comms.notification_assignment import NotificationAssignment from agent_comms.notification_evidence import AnsweredResponse, NotificationAssignmentRead -from agent_comms.read_basis import AllOutcomes, ChannelDisplayScope, ConversationCursor +from agent_comms.read_basis import ChannelDisplayScope, ConversationCursor +from agent_comms.notification_evidence import AllOutcomes from agent_comms.read_ledger import ReadLedger from agent_comms.registration import Registration from agent_comms.proven_source_coverage import SourcePrefix @@ -134,7 +135,7 @@ def test_notification_answers_require_frozen_delivery_not_a_public_answer_refere assert key not in NotificationAssignment.capture_responses(bus.path.parent, read).responses reply = append_reply(bus, owner, peer, answers=(original.reference,)) assert NotificationAssignment.capture_responses(bus.path.parent, read).responses[key] == ( - AnsweredResponse(reply.reference, int(reply.timestamp * 1000))) + AnsweredResponse(reply.reference, reply.at_ms)) def test_wait_finds_dependency_reply_after_its_recorded_sequence(bus, owner, peer): From 9da9af0de038906de58778ca3f37cabde17e8a0d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 16:05:42 -0400 Subject: [PATCH 086/109] Let native values supply their recorded session without redecoding --- src/agent_comms/native_input_record.py | 20 +++++++++++++------- src/agent_comms/native_session_reopen.py | 14 +++++++++++++- tests/test_native_context_reference.py | 19 +++++++++++++------ 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index 95b2c4c655..7451595e25 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -32,7 +32,7 @@ NativeContext, ) from .native_integer import PositiveNativeIntegerBounds -from .native_session_reopen import PiSessionId +from .native_session_reopen import NativeSessionIdValue, PiSessionId from .transcript_routes import InputDisplay, NoDisplayText from .typed_table import SqlStorage, TextStorage @@ -88,7 +88,7 @@ def constraints(cls, column: str, annotation: object = None) -> tuple[str, ...]: return (NativeInputExecution.sql_constraint(column),) -class NativeColumn(DeclaredFamily, JsonShapeFamily, affix="NativeColumn"): +class NativeColumn(DeclaredFamily, JsonShapeFamily, NativeSessionIdValue, affix="NativeColumn"): """One nullable column of an original native row: SQL NULL until its group is recorded. The group's owner (the stage, or the context acknowledgement) decides what a @@ -127,6 +127,9 @@ def holds(self, value: object) -> bool: class TextNativeColumn(NativeColumn, JsonShapeMember): value: str + def require_recorded_session(self) -> PiSessionId: + return FieldCodec.decode(PiSessionId, self.value) + def holds(self, value: object) -> bool: return self.value == value @@ -403,7 +406,7 @@ def acquire(cls, row: NativeInputContext) -> NativeInputReference: result = FieldCodec.construct(cls, dict( input_id=row.context_value("input_id"), stage=row.reference_stage, - session_id=FieldCodec.decode(PiSessionId, row.context_value("session_id")), + session_id=row.session_id.require_recorded_session(), request_generation=row.context_value("request_generation"), )) return result @@ -439,7 +442,7 @@ class NativeInputContext: input_id: InputId | NativeColumn stage: type[NativeInputExecution] | NativeColumn - session_id: PiSessionId | NativeColumn + session_id: NativeSessionIdValue request_generation: NativeColumn @property @@ -483,10 +486,13 @@ def unrecorded_reference(self) -> NativeContextReference: return UnrecordedNativeInputReference() def recorded_reference(self) -> NativeContextReference: + reference = NativeInputReference.acquire(self) + reference_fields = {name for name, _annotation in FieldCodec.record_fields(type(reference))} try: for plan in FieldCodec.field_plans(type(self)): - for directive in NativeContext.single(plan.annotation): - FieldCodec.decode(directive.value, getattr(self, plan.name).value) + if plan.name not in reference_fields: + for directive in NativeContext.single(plan.annotation): + FieldCodec.decode(directive.value, getattr(self, plan.name).value) except MalformedRecord as error: raise IdentityConflict("Native recorded context group is partial") from error - return NativeInputReference.acquire(self) + return reference diff --git a/src/agent_comms/native_session_reopen.py b/src/agent_comms/native_session_reopen.py index b840783cab..166d1fcb60 100644 --- a/src/agent_comms/native_session_reopen.py +++ b/src/agent_comms/native_session_reopen.py @@ -24,7 +24,16 @@ class NativeReopenError(ValueError): """Saved session cannot be safely reloaded; no provider input may start.""" -class NativeSessionSelection: +class NativeSessionIdValue: + """A native value can require a recorded session without recovering its kind.""" + + __slots__ = () + + def require_recorded_session(self) -> PiSessionId: + raise MalformedRecord("Native reference lacks recorded session identity") + + +class NativeSessionSelection(NativeSessionIdValue): """The selected-id declaration owns the saved-session answer for an input.""" def selected_session(self, row: NativeRuntimeInput) -> NativeSessionIdentity | NoSessionRetention: @@ -35,6 +44,9 @@ def selected_session(self, row: NativeRuntimeInput) -> NativeSessionIdentity | N class PiSessionId(FreshNativeId, NativeSessionSelection): """The Pi session manager's saved session identity.""" + def require_recorded_session(self) -> PiSessionId: + return self + def selected_session(self, row: NativeRuntimeInput) -> NativeSessionIdentity: return row.require_session_identity() diff --git a/tests/test_native_context_reference.py b/tests/test_native_context_reference.py index 60b227d9f0..7078de9f3f 100644 --- a/tests/test_native_context_reference.py +++ b/tests/test_native_context_reference.py @@ -50,7 +50,7 @@ from agent_comms.native_session_reopen import PiSessionId -def cursor(): +def cursor() -> CurrentNativeCursor: return CurrentNativeCursor( wire_root_id=RootId("a" * 32), recipient_lookup=ThreadLookup("b" * 32), owner_generation=1, owner_claimed_generation=2, covered_seq=1, injected_seq=1, @@ -72,7 +72,7 @@ def test_native_cursor_state_owns_start_observation_and_input_proof( assert not state.proves_input("d" * 32) -def reservation(): +def reservation() -> NativeRuntimeInput: return NativeRuntimeInput( input_id=FieldCodec.decode(InputId, "c" * 32), stage=TriageNativeExecution, execution_id=UnrecordedNativeColumn(), attempt_ordinal=UnrecordedNativeColumn(), @@ -82,7 +82,7 @@ def reservation(): @pytest.fixture -def recorded(): +def recorded() -> NativeRuntimeInput: return replace( reservation(), sent_owner_claimed_generation=RecordedNativeClaim(2), session_id=PiSessionId("original-session"), session_file="/original/session.jsonl", @@ -96,7 +96,8 @@ def recorded(): @pytest.mark.parametrize("changes", [ {"input_id": UnrecordedNativeColumn()}, {"stage": UnrecordedNativeColumn()}, - {"session_id": UnrecordedNativeColumn()}, {"request_generation": UnrecordedNativeColumn()}, + {"session_id": UnrecordedNativeColumn()}, {"session_id": NumberNativeColumn(1)}, + {"session_id": TextNativeColumn("")}, {"request_generation": UnrecordedNativeColumn()}, {"request_generation": NumberNativeColumn(0)}, {"request_generation": NumberNativeColumn(True)}, ]) def test_partial_original_cursor_never_becomes_unrecorded_or_complete(changes): @@ -104,7 +105,9 @@ def test_partial_original_cursor_never_becomes_unrecorded_or_complete(changes): _ = replace(cursor(), **changes).reference -def test_original_unrecorded_context_and_reserved_input_remain_distinct_from_corruption(recorded): +def test_original_unrecorded_context_and_reserved_input_remain_distinct_from_corruption( + recorded: NativeRuntimeInput, +) -> None: empty = replace(cursor(), covered_seq=0, injected_seq=0, input_id=UnrecordedNativeColumn(), stage=UnrecordedNativeColumn(), session_id=UnrecordedNativeColumn(), request_generation=UnrecordedNativeColumn()) @@ -112,9 +115,13 @@ def test_original_unrecorded_context_and_reserved_input_remain_distinct_from_cor assert reservation().reference == UnrecordedNativeInputReference() with pytest.raises(IdentityConflict): _ = replace(reservation(), request_generation=NumberNativeColumn(1)).reference - assert recorded.reference == NativeInputReference( + reference = recorded.reference.require_recorded() + assert reference == NativeInputReference( recorded.input_id, type(recorded.execution), recorded.session_id, 1 ) + assert reference.session_id is recorded.session_id + selected = cursor().reference.require_recorded().session_id + assert selected == recorded.session_id and selected.require_recorded_session() is selected with pytest.raises(IdentityConflict): _ = replace(recorded, session_file=None).reference for name in ("session_entry_id", "session_entry_start", "llm_context_digest", "committed_at_ms"): From 6e17149921b3f0617e8218a65fa4ed532ca19536 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 16:54:30 -0400 Subject: [PATCH 087/109] Watch every notification evidence store and keep session decoding on rows --- src/agent_comms/native_input_record.py | 15 +-- src/agent_comms/native_runtime_input.py | 4 + src/agent_comms/native_session_reopen.py | 10 +- src/agent_comms/notification_assignment.py | 9 ++ src/agent_comms/notification_reads.py | 2 +- src/agent_comms/presentation.py | 3 +- .../test_notification_observation_sources.py | 118 ++++++++++++++++++ 7 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 tests/test_notification_observation_sources.py diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index 7451595e25..6facbad92d 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -32,7 +32,7 @@ NativeContext, ) from .native_integer import PositiveNativeIntegerBounds -from .native_session_reopen import NativeSessionIdValue, PiSessionId +from .native_session_reopen import PiSessionId from .transcript_routes import InputDisplay, NoDisplayText from .typed_table import SqlStorage, TextStorage @@ -88,7 +88,7 @@ def constraints(cls, column: str, annotation: object = None) -> tuple[str, ...]: return (NativeInputExecution.sql_constraint(column),) -class NativeColumn(DeclaredFamily, JsonShapeFamily, NativeSessionIdValue, affix="NativeColumn"): +class NativeColumn(DeclaredFamily, JsonShapeFamily, affix="NativeColumn"): """One nullable column of an original native row: SQL NULL until its group is recorded. The group's owner (the stage, or the context acknowledgement) decides what a @@ -127,9 +127,6 @@ def holds(self, value: object) -> bool: class TextNativeColumn(NativeColumn, JsonShapeMember): value: str - def require_recorded_session(self) -> PiSessionId: - return FieldCodec.decode(PiSessionId, self.value) - def holds(self, value: object) -> bool: return self.value == value @@ -406,7 +403,7 @@ def acquire(cls, row: NativeInputContext) -> NativeInputReference: result = FieldCodec.construct(cls, dict( input_id=row.context_value("input_id"), stage=row.reference_stage, - session_id=row.session_id.require_recorded_session(), + session_id=row.reference_session_id, request_generation=row.context_value("request_generation"), )) return result @@ -442,7 +439,6 @@ class NativeInputContext: input_id: InputId | NativeColumn stage: type[NativeInputExecution] | NativeColumn - session_id: NativeSessionIdValue request_generation: NativeColumn @property @@ -454,6 +450,11 @@ def context_anchor(self) -> NativeColumn: def reference_stage(self) -> type[NativeInputExecution]: return self.context_value("stage") + @property + def reference_session_id(self) -> object: + """Acquire the session through its existing NativeContext column directive.""" + return self.context_value("session_id") + def context_columns(self, context: NativeContextProof) -> dict[str, object]: """Build the context group through each column's declared codec, not a caller roster.""" return { diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index a321700d94..6883085a53 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -156,6 +156,10 @@ def __post_init__(self): self.session_id.selected_session(self) self.reference + @property + def reference_session_id(self) -> PiSessionId: + return self.session_id.require_recorded_session() + @property def selected_session(self) -> NativeSessionIdentity | NoSessionRetention: """The original SQL session group owns its selected and absent cases.""" diff --git a/src/agent_comms/native_session_reopen.py b/src/agent_comms/native_session_reopen.py index 166d1fcb60..86a96f677d 100644 --- a/src/agent_comms/native_session_reopen.py +++ b/src/agent_comms/native_session_reopen.py @@ -24,18 +24,12 @@ class NativeReopenError(ValueError): """Saved session cannot be safely reloaded; no provider input may start.""" -class NativeSessionIdValue: - """A native value can require a recorded session without recovering its kind.""" - - __slots__ = () +class NativeSessionSelection: + """The selected-id declaration owns the saved-session answer for an input.""" def require_recorded_session(self) -> PiSessionId: raise MalformedRecord("Native reference lacks recorded session identity") - -class NativeSessionSelection(NativeSessionIdValue): - """The selected-id declaration owns the saved-session answer for an input.""" - def selected_session(self, row: NativeRuntimeInput) -> NativeSessionIdentity | NoSessionRetention: FieldCodec.decode(type(None), row.session_file) return NoSessionRetention() diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index ad80e42a00..301514d828 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -169,6 +169,15 @@ class NotificationAssignment(NotificationSource): assignment: WakeAssignment activity: AssignmentActivity + @classmethod + def source_paths(cls, root: Path) -> tuple[Path, ...]: + """Commit sources for coordinator, independent replies and stored inputs.""" + return ( + *CoordinationStore.source_paths(root), + *Bus.source_paths(root), + *InputDispositions.source_paths(root), + ) + @classmethod def changed(cls, reads: Iterable[NotificationAssignmentRead], root: Path) -> tuple[NotificationAssignmentRead, ...]: """Acquire fresh bounded rows once; retained reads own their equality.""" diff --git a/src/agent_comms/notification_reads.py b/src/agent_comms/notification_reads.py index 61c3e970c0..4082974f92 100644 --- a/src/agent_comms/notification_reads.py +++ b/src/agent_comms/notification_reads.py @@ -37,7 +37,7 @@ def changed_since(self, comms: Comms, revision: WireRevision, @dataclass(frozen=True) -class RecordedNotificationRead(LiveNotificationRead): +class RecordedNotificationRead(NotificationRead): source: HistorySourceKey def read_as(self, comms: Comms, reader: ReadAuthority) -> NotificationWindow: diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 79c3e736d8..1c2e3fa7a8 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -24,7 +24,6 @@ from .channels import Channel, ChannelMemberIndex, Channels from .cli_result import CliResult from .commander_frame_file import CommanderFrameFile -from .coordination_database import CoordinationStore from .coordination_errors import DMPaintedBoundChanged, StaleRevision from .declared_family import DeclaredFamily from .display_order import ChannelSort, NameChannelSort @@ -799,7 +798,7 @@ def relationship_paths(views: HistoryViews) -> tuple[Path, ...]: def notification_paths(views: HistoryViews) -> tuple[Path, ...]: - return CoordinationStore.source_paths(views.root) + return NotificationAssignment.source_paths(views.root) def external_transcript_paths(views: HistoryViews) -> tuple[Path, ...]: diff --git a/tests/test_notification_observation_sources.py b/tests/test_notification_observation_sources.py new file mode 100644 index 0000000000..2dcff4d20a --- /dev/null +++ b/tests/test_notification_observation_sources.py @@ -0,0 +1,118 @@ +"""Open notification interests refresh from real input-only and reply-only commits.""" + +from collections.abc import Iterator +from contextlib import contextmanager +from multiprocessing import Pipe +from multiprocessing.connection import Connection +from pathlib import Path + +from agent_comms.bus_publication import Delivered +from agent_comms.comms import Comms +from agent_comms.coordination_database import CoordinationStore +from agent_comms.message_reference import MessageReference +from agent_comms.notification_evidence import AnsweredResponse, RecipientLimit, UnansweredResponse +from agent_comms.notification_reads import LiveNotificationRead +from agent_comms.private_path import FileObservation, FileRevision +from agent_comms.ui_model.observation import ( + NotificationsObserved, ObserveNotifications, ObservationService, + ReadNotifications, RevisionObserved, +) +from test_coordinated_runtime import tmp_path # noqa: F401 +from test_following_context_notifications import following_input, following_session +from test_notification_recipient_window import frozen_window # noqa: F401 + + +@contextmanager +def observing_notifications( + comms: Comms, references: tuple[MessageReference, ...], +) -> Iterator[tuple[ObservationService, Connection]]: + client, server = Pipe() + with client, server: + service = ObservationService(comms, server) + request = ReadNotifications( + str(comms.root), LiveNotificationRead(references, limit=RecipientLimit(1)), + ) + ObserveNotifications(frozenset((request,))).receive(service) + try: + yield service, client + finally: + service.retire() + comms.views.transcript_reads.close() + + +def notification_reply(client: Connection) -> NotificationsObserved: + """Both real pipe frames must arrive; a missed wake fails without a blocking recv.""" + assert client.poll() + revision: object = client.recv() + assert isinstance(revision, RevisionObserved) + assert client.poll() + result: object = client.recv() + assert isinstance(result, NotificationsObserved) + assert result.revision == revision.revision + assert not client.poll() + return result + + +def coordinator_revision(comms: Comms) -> tuple[FileObservation, ...]: + return tuple(FileRevision.capture(path) + for path in CoordinationStore.source_paths(comms.root)) + + +async def test_open_notification_interest_refreshes_after_input_only_context(tmp_path: Path) -> None: + async with following_session(tmp_path) as claim: + case = following_input(claim, generation=2) + case.start() + proof = await case.verify() + comms = claim.selected.comms + with observing_notifications(comms, case.references) as (service, client): + await service.observe(set()) + before = notification_reply(client) + old, = before.outcomes[case.references[0].seq, case.references[0].message_id] + assert not old.state.consumed and old.answer == UnansweredResponse() + coordinator = coordinator_revision(comms) + + case.acknowledge(proof) + + await service.observe(set()) + after = notification_reply(client) + assert coordinator_revision(comms) == coordinator + assert after.revision.bus_changes == before.revision.bus_changes + assert after.revision.bus_seq == before.revision.bus_seq + assert after.revision.registry == before.revision.registry + assert after.revision.activity == before.revision.activity + assert after.revision.notifications != before.revision.notifications + current, = after.outcomes[case.references[0].seq, case.references[0].message_id] + assert current.state.consumed and current.state.at_ms == proof.committed_at_ms + assert current.answer == UnansweredResponse() + await service.observe(set()) + assert not client.poll() + + +async def test_open_notification_interest_refreshes_after_reply_only_commit( + frozen_window: tuple[Comms, Delivered], +) -> None: + comms, source = frozen_window + reference = source.reference + with observing_notifications(comms, (reference,)) as (service, client): + await service.observe(set()) + before = notification_reply(client) + old, = before.outcomes[reference.seq, reference.message_id] + assert old.answer == UnansweredResponse() and not old.state.consumed + coordinator = coordinator_revision(comms) + + response = comms.messaging.send( + old.recipient_identity.canonical_thread, source.message.sender, + "Independent answer", answers=(reference,), + ).message + + await service.observe(set()) + after = notification_reply(client) + assert coordinator_revision(comms) == coordinator + assert after.revision.registry == before.revision.registry + assert after.revision.activity == before.revision.activity + assert after.revision.notifications != before.revision.notifications + current, = after.outcomes[reference.seq, reference.message_id] + assert current.answer == AnsweredResponse(response.reference, response.at_ms) + assert not current.state.consumed + await service.observe(set()) + assert not client.poll() From 4a27c246a4f5a7fbf30a70d1ee719c6bb735c40d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 18:17:24 -0400 Subject: [PATCH 088/109] Use Repo explicit commit acquisition across integration sources --- src/agent_comms/head_review.py | 11 ++++++----- src/agent_comms/integration.py | 12 ++++++------ tests/test_integration.py | 34 +++++++++++++++++++++++++++++++--- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index d4c6dfd67e..e6d22a2587 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -191,8 +191,9 @@ def require_tree(self, repository: Repo) -> None: def require_ancestry(self, repository: Repo, candidate: str) -> None: """A resolved Git combination retains every actual source, not a guessed patch.""" self.require_origin(repository) - WorkBranches(repository.checkout).require_commits( - "origin", self.baseline.commit, candidate, *(head.head for head in self.heads)) + Repo.require_commits( + repository.checkout, repository.remote, + self.baseline.commit, candidate, *(head.head for head in self.heads)) if not Repo.descends(repository.checkout, self.baseline.commit, candidate) or any( not Repo.descends(repository.checkout, head.head, candidate) for head in self.heads): raise ToolRefusedError("The resolved candidate dropped a captured source parent") @@ -217,8 +218,8 @@ class MergedIntegrationSource(IntegrationSource): """Ordered source heads whose reproducible Git merge owns the candidate tree.""" def expected_tree(self, repository: Repo) -> str: - reader = WorkBranches(repository.checkout) - reader.require_commits("origin", self.baseline.commit, *(head.head for head in self.heads)) + Repo.require_commits(repository.checkout, repository.remote, + self.baseline.commit, *(head.head for head in self.heads)) commit = self.baseline.commit for head in self.heads: tree = Repo.git(repository.checkout, "merge-tree", "--write-tree", commit, head.head).strip() @@ -428,7 +429,7 @@ def require_carrier(self, core: Repo, carrier: PullRequestHead) -> None: if not self.carrier.same_pull_request(carrier) or core.github != self.carrier.repo: raise ToolRefusedError("This is not the captured combined carrier pull request") reader = WorkBranches(core.checkout) - reader.require_commits("origin", self.carrier.head, carrier.head) + Repo.require_commits(core.checkout, core.remote, self.carrier.head, carrier.head) source = self.source(core.github) self.require_repositories(self.declared_repositories(core, source.baseline.commit, carrier.head)) source.require_ancestry(core, self.carrier.head) diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index 4ad1d6a5e1..b90123be70 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -146,7 +146,7 @@ def fresh_main(self) -> str: def create(cls, repository: Repo, path: Path, commit: str) -> IntegrationCheckout: if path.exists(): raise IntegrationFailed(f"Preserve existing qualification checkout: {path}") - WorkBranches(repository.checkout).require_commits("origin", commit) + Repo.require_commits(repository.checkout, repository.remote, commit) path.parent.mkdir(parents=True, exist_ok=True) created = cls.add(repository.checkout, path, DetachedStart(commit)) created.require_editable() @@ -398,7 +398,7 @@ def materialize(self, directory: Path) -> IntegrationInput: (self.carrier,), self.tree)] for repository in repositories[1:]: commit = IntegrationSource.main_head(repository) - WorkBranches(repository.checkout).require_commits("origin", commit) + Repo.require_commits(repository.checkout, repository.remote, commit) tree = Repo.git(repository.checkout, "rev-parse", f"{commit}^{{tree}}").strip() sources.append(MergedIntegrationSource(RepositoryCommit(repository.github, commit), (), tree)) inputs = OrdinaryInput(tuple(sorted(sources, key=lambda source: source.repository.key))) @@ -618,7 +618,7 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, candidate_commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() if current.repo != core.github or current.base != WorkBranches.main or current.head != candidate_commit: raise IntegrationFailed("Capture requires the actual published combined carrier at this checkout's HEAD") - WorkBranches(registered_core.checkout).require_commits("origin", base, candidate_commit) + Repo.require_commits(registered_core.checkout, registered_core.remote, base, candidate_commit) required = IntegrationInput.declared_repositories(registered_core, base, candidate_commit) linked_repositories = {link.repo for todo in todos for order in todo.order.declarations for link in order.pull_requests} @@ -631,7 +631,7 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, pulls = PullRequests(todos) heads: dict[RepositoryName, tuple[PullRequestHead, ...]] = {} for repo in repositories.values(): - WorkBranches(repo.checkout).require_commits("origin", mains[repo.github]) + Repo.require_commits(repo.checkout, repo.remote, mains[repo.github]) linked = PullRequests.linked_among(todos, repo.github) selected = [] for row in pulls.rows(repo.github, tuple(sorted(linked))): @@ -650,7 +650,7 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, for container in carriers: if (container.repo == repo.github and not head.same_pull_request(container)): - WorkBranches(repo.checkout).require_commits("origin", head.head, container.head) + Repo.require_commits(repo.checkout, repo.remote, head.head, container.head) if Repo.descends(repo.checkout, head.head, container.head): selected.append(head) heads[repo.github] = tuple(sorted({head.pr: head for head in selected}.values(), key=lambda head: head.pr)) @@ -673,7 +673,7 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, if name in resolved: companion = resolved[name] companion.current() - WorkBranches(repo.checkout).require_commits("origin", companion.head) + Repo.require_commits(repo.checkout, repo.remote, companion.head) tree = Repo.git(repo.checkout, "rev-parse", f"{companion.head}^{{tree}}").strip() source = ResolvedIntegrationSource( RepositoryCommit(name, baseline), heads[name], tree, companion) diff --git a/tests/test_integration.py b/tests/test_integration.py index fa70742c0b..2c4474e77c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -16,7 +16,7 @@ from agent_comms.errors import ToolRefusedError from agent_comms.head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, MergeHeadReview, PromotedHeadReview, PullRequestHead, ResolvedIntegrationSource from agent_comms.repository_name import RepositoryName -from agent_comms.repo import Repo +from agent_comms.repo import Repo, UnavailableCommitError from agent_comms.todos import ( DraftListedPullRequest, MergedListedPullRequest, PullRequestCommit, QualifiedHeadVerdict, ReviewedHeadVerdict, UnmergedListedPullRequest, VerdictReason, @@ -51,10 +51,14 @@ def test_actual_ci_cli_uses_verified_carrier_not_synthetic_message_and_revalidat carrier = pair.publish_carrier(github_cli) local = inputs.source(pair.core.github) # Actual synthetic merge: its own message deliberately has no source metadata. - tested = pair.core.git(pair.core.checkout, "commit-tree", local.candidate_tree, + tested = pair.core.git(pair.core.remote_path, "commit-tree", local.candidate_tree, "-p", local.baseline.commit, "-p", carrier.head, "-m", "Synthetic PR/queue merge; no input metadata").strip() + pair.core.git(pair.core.remote_path, "update-ref", f"refs/pull/{carrier.pr}/merge", tested) + assert not pair.core.has_commit(tested) checkout = integration.IntegrationCheckout.create(pair.core, tmp_path / "ci-checkout", tested) + assert pair.core.git(checkout.path, "rev-parse", "HEAD^{tree}").strip() == local.candidate_tree + assert pair.core.git(checkout.path, "show", "-s", "--format=%P", "HEAD").split() == [local.baseline.commit, carrier.head] assert IntegrationInput.from_message(Repo.git(checkout.path, "show", "--no-patch", "--format=%B", "HEAD")) == () documents = { "pull_request": {"number": carrier.pr, "pull_request": {"head": {"sha": carrier.head}}}, @@ -102,9 +106,13 @@ def test_actual_ordinary_ci_cli_captures_main_once_and_never_steals_an_unlinked_ head = pair.core.branch_commit baseline = pair.core.main_commit tree = pair.core.git(pair.core.checkout, "rev-parse", f"{head}^{{tree}}").strip() - tested = pair.core.git(pair.core.checkout, "commit-tree", tree, "-p", baseline, + tested = pair.core.git(pair.core.remote_path, "commit-tree", tree, "-p", baseline, "-p", head, "-m", "Actual ordinary synthetic merge").strip() + pair.core.git(pair.core.remote_path, "update-ref", f"refs/pull/{pair.core.number}/merge", tested) + assert not pair.core.has_commit(tested) checkout = integration.IntegrationCheckout.create(pair.core, tmp_path / "ordinary-ci", tested) + assert pair.core.git(checkout.path, "rev-parse", "HEAD^{tree}").strip() == tree + assert pair.core.git(checkout.path, "show", "-s", "--format=%P", "HEAD").split() == [baseline, head] document = tmp_path / "ordinary-event.json" document.write_text(json.dumps({"number": pair.core.number, "pull_request": {"head": {"sha": head}}})) output = tmp_path / "ordinary-inputs" @@ -146,6 +154,26 @@ def test_actual_ordinary_ci_cli_captures_main_once_and_never_steals_an_unlinked_ assert not MergeHeadReview.accepted(pair.core.github, tested) +def test_integration_checkout_refuses_commits_absent_from_its_own_origin( + source_repository: SourceRepository, tmp_path: Path, +) -> None: + source = source_repository + foreign = SourceRepository.create(tmp_path / "foreign-origin") + foreign_head = foreign.publish_change("Foreign-only source", *foreign.write_files({ + Path("foreign.py"): "foreign = True\n", + })) + before = source.git(source.checkout, "rev-parse", "HEAD", "HEAD^{tree}") + published = source.git(source.remote_path, "show-ref") + for index, commit in enumerate((foreign_head, "f" * 40)): + destination = tmp_path / f"unavailable-{index}" + assert not source.has_commit(commit) + with pytest.raises(UnavailableCommitError): + integration.IntegrationCheckout.create(source, destination, commit) + assert not destination.exists() + assert source.git(source.checkout, "rev-parse", "HEAD", "HEAD^{tree}") == before + assert source.git(source.remote_path, "show-ref") == published + + @pytest.mark.parametrize("event", ("pull_request", "merge_group")) def test_required_hosted_merge_gate_distinguishes_source_review_from_combined_promotion( mutual_sources: MutualSources, github_cli: GitHubFixture, tmp_path: Path, event: str, From 7035ada685c370c9ab62821c7ff3e89f8e578a02 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:25:12 -0400 Subject: [PATCH 089/109] Own source roles, verdict recording and repository acquisition --- src/agent_comms/head_review.py | 92 +++++++++++++++---- .../installer/runtime_resolution.py | 23 +++-- src/agent_comms/integration.py | 82 ++++++++--------- src/agent_comms/owned_send.py | 4 +- src/agent_comms/owned_turn.py | 4 +- src/agent_comms/repo.py | 4 + src/agent_comms/todos.py | 33 +++---- src/agent_comms/turn_input_source.py | 4 +- tests/test_checker_stack.py | 17 +++- tests/test_head_review.py | 12 ++- tools/checker_stack.py | 5 +- 11 files changed, 182 insertions(+), 98 deletions(-) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index e6d22a2587..2f8c031c69 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -128,6 +128,14 @@ class SourceCheckout(Checkout, ABC): @abstractmethod def combine_sources(self, base: str, heads: tuple[PullRequestHead, ...]) -> str: ... + @classmethod + @abstractmethod + def create(cls, repository: Repo, path: Path, commit: str) -> SourceCheckout: ... + + @classmethod + @abstractmethod + def materialize(cls, source: IntegrationSource, repository: Repo, path: Path) -> SourceCheckout: ... + @dataclass(frozen=True) class IntegrationSource(DeclaredFamily, affix="Source"): @@ -167,6 +175,22 @@ def main_head(repository: Repo) -> str: raise ToolRefusedError("Remote main did not resolve to a full Git identity") return commit + @property + def local_sources(self) -> tuple[LocalIntegrationSource, ...]: + return () + + @property + def companion_sources(self) -> tuple[IntegrationSource, ...]: + return (self,) + + def require_ordinary(self, carrier: PullRequestHead) -> None: + if self.heads: + raise ToolRefusedError("An ordinary comparison cannot supply companion overlays") + + def materialize_head(self, repository: Repo, path: Path, tested: str, + checkout: type[SourceCheckout]) -> SourceCheckout: + return checkout.materialize(self, repository, path) + @property def candidate_commit(self) -> str: return self.baseline.commit @@ -191,9 +215,7 @@ def require_tree(self, repository: Repo) -> None: def require_ancestry(self, repository: Repo, candidate: str) -> None: """A resolved Git combination retains every actual source, not a guessed patch.""" self.require_origin(repository) - Repo.require_commits( - repository.checkout, repository.remote, - self.baseline.commit, candidate, *(head.head for head in self.heads)) + repository.acquire(self.baseline.commit, candidate, *(head.head for head in self.heads)) if not Repo.descends(repository.checkout, self.baseline.commit, candidate) or any( not Repo.descends(repository.checkout, head.head, candidate) for head in self.heads): raise ToolRefusedError("The resolved candidate dropped a captured source parent") @@ -218,8 +240,7 @@ class MergedIntegrationSource(IntegrationSource): """Ordered source heads whose reproducible Git merge owns the candidate tree.""" def expected_tree(self, repository: Repo) -> str: - Repo.require_commits(repository.checkout, repository.remote, - self.baseline.commit, *(head.head for head in self.heads)) + repository.acquire(self.baseline.commit, *(head.head for head in self.heads)) commit = self.baseline.commit for head in self.heads: tree = Repo.git(repository.checkout, "merge-tree", "--write-tree", commit, head.head).strip() @@ -234,6 +255,27 @@ def require_tree(self, repository: Repo) -> None: raise ToolRefusedError(f"{self.repository.to_wire()} candidate tree is not its captured combination") +@dataclass(frozen=True) +class LocalIntegrationSource(MergedIntegrationSource): + """The tested repository's actual merge, not a replay of its own published PR.""" + + @property + def local_sources(self) -> tuple[LocalIntegrationSource, ...]: + return (self,) + + @property + def companion_sources(self) -> tuple[IntegrationSource, ...]: + return () + + def require_ordinary(self, carrier: PullRequestHead) -> None: + if self.heads != (carrier,): + raise ToolRefusedError("An ordinary comparison requires its exact local carrier") + + def materialize_head(self, repository: Repo, path: Path, tested: str, + checkout: type[SourceCheckout]) -> SourceCheckout: + return checkout.create(repository, path, tested) + + @dataclass(frozen=True) class ResolvedIntegrationSource(IntegrationSource): """A separate published companion carrier preserves the actual resolved member tree.""" @@ -280,6 +322,19 @@ def __post_init__(self) -> None: raise MalformedRecord("Integration inputs require unique, nonempty repository sources.") if repositories != tuple(sorted(repositories, key=lambda repo: repo.key)): raise MalformedRecord("Integration sources must be in repository-name order.") + _ = self.local + + @property + def local(self) -> LocalIntegrationSource: + selected = tuple(local for source in self.sources for local in source.local_sources) + if len(selected) != 1: + raise MalformedRecord("Integration inputs require exactly one local source.") + local, = selected + return local + + @property + def companions(self) -> tuple[IntegrationSource, ...]: + return tuple(companion for source in self.sources for companion in source.companion_sources) def require_repositories(self, repositories: tuple[RepositoryName, ...]) -> None: """The actual stack declaration supplies coverage, never a caller's package roster.""" @@ -308,7 +363,10 @@ def source_urls(core: Repo, baseline: str, candidate: str) -> dict[RepositoryNam if len(set(identities)) != len(identities): raise ToolRefusedError("One stack cut declares a Git repository more than once") for url, branch in declared.values(): - urls[Repo.repository_url(url)] = url + identity = Repo.repository_url(url) + if identity == core.github: + raise ToolRefusedError("The stack repeats Core as a Git companion source") + urls[identity] = url return urls def validate(self, repositories: tuple[Repo, ...], carrier: PullRequestHead) -> None: @@ -319,15 +377,13 @@ def validate(self, repositories: tuple[Repo, ...], carrier: PullRequestHead) -> raise ToolRefusedError("The integration carrier no longer targets main") self.require_carrier(core, carrier) self.require_local_candidate(core, carrier) - local = self.source(carrier.repo) + local = self.local + local.require_origin(core) if IntegrationSource.main_head(core) != local.baseline.commit: raise ToolRefusedError(f"{core.github.to_wire()} main moved; recapture inputs") - for entry in self.companions(carrier.repo): + for entry in self.companions: entry.validate(Repo.selected(repositories, entry.repository.to_wire()), carrier) - def companions(self, repository: RepositoryName) -> tuple[IntegrationSource, ...]: - return tuple(source for source in self.sources if source.repository != repository) - def require_source_reviews(self) -> None: """Native promotion and the hosted gate require these same exact source decisions.""" for source in self.sources: @@ -351,7 +407,8 @@ def require_carrier(self, core: Repo, carrier: PullRequestHead) -> None: raise ToolRefusedError("An individual source is not a captured combined carrier") def require_local_candidate(self, core: Repo, carrier: PullRequestHead) -> None: - self.source(core.github).require_candidate(core, carrier) + self.local.require_origin(core) + self.local.require_candidate(core, carrier) @property def canonical(self) -> str: @@ -415,22 +472,25 @@ def combined(self) -> bool: def __post_init__(self) -> None: super().__post_init__() - self.source(self.carrier.repo) + if self.local.repository != self.carrier.repo: + raise MalformedRecord("The local source must belong to the captured carrier repository") if len(self.carrier.head) != 40 or self.carrier.head != self.carrier.head.lower(): raise MalformedRecord("A combined carrier requires its full prepublication Git identity") if any(head.same_pull_request(self.carrier) for source in self.sources for head in source.heads): raise MalformedRecord("The combined carrier is separate from every source member") def require_local_candidate(self, core: Repo, carrier: PullRequestHead) -> None: - self.source(core.github).require_heads(carrier) + self.local.require_origin(core) + self.local.require_heads(carrier) def require_carrier(self, core: Repo, carrier: PullRequestHead) -> None: """Review and hosted merge checks consume this same Git-bound carrier distinction.""" if not self.carrier.same_pull_request(carrier) or core.github != self.carrier.repo: raise ToolRefusedError("This is not the captured combined carrier pull request") reader = WorkBranches(core.checkout) - Repo.require_commits(core.checkout, core.remote, self.carrier.head, carrier.head) - source = self.source(core.github) + core.acquire(self.carrier.head, carrier.head) + source = self.local + source.require_origin(core) self.require_repositories(self.declared_repositories(core, source.baseline.commit, carrier.head)) source.require_ancestry(core, self.carrier.head) source.require_ancestry(core, carrier.head) diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index 9b7acad97c..d157d29cc3 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -409,14 +409,7 @@ def require_compatible(cls, baseline: str, candidate: str) -> None: migrations. Candidate metadata/settings are copied intact by the private project writer. """ documents = tuple(tomllib.loads(text) for text in (baseline, candidate)) - local_names: set[str] = set() - for document in documents: - match document: - case {"tool": {"uv": {"sources": dict() as sources}}}: - for name, source in sources.items(): - match source: - case {"git": str(), "branch": str()} | {"path": str()}: - local_names.add(canonicalize_name(FieldCodec.decode(str, name))) + local_names = cls.local_packages(*documents) environment = cls.marker_environment(Path(sys.executable)) contracts = tuple(frozenset(str(requirement) for requirement in cls.virtual_requirements(document, environment) if canonicalize_name(requirement.name) not in local_names) @@ -424,6 +417,20 @@ def require_compatible(cls, baseline: str, candidate: str) -> None: if contracts[0] != contracts[1]: raise ValueError("External runtime dependency migration is unsupported by this common environment") + @staticmethod + @boundary_decoder + def local_packages(*documents: dict[str, object]) -> frozenset[str]: + """The source declarations own which distribution names are local.""" + names: set[str] = set() + for document in documents: + match document: + case {"tool": {"uv": {"sources": dict() as sources}}}: + for name, source in sources.items(): + match source: + case {"git": str(), "branch": str()} | {"path": str()}: + names.add(canonicalize_name(FieldCodec.decode(str, name))) + return frozenset(names) + @classmethod def virtual_requirements(cls, document: dict[str, object], environment: dict[str, str]) -> tuple[Requirement, ...]: """Only package=false roots have a declarative (non-wheel) runtime contract.""" diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index b90123be70..5c67c20e4b 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -20,7 +20,7 @@ from .declared_family import DeclaredFamily from .errors import ToolRefusedError from .field_codec import FieldCodec, GitCommitText, PathText, WireName, boundary_decoder -from .head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, IntegrationSource, MergedIntegrationSource, MergeHeadReview, OrdinaryInput, PromotedHeadReview, PullRequestHead, ResolvedIntegrationSource, Side, SourceCheckout, UnpromotedHeadReview +from .head_review import BaseSide, CombinedInput, HeadReview, HeadSide, IntegrationInput, IntegrationSource, LocalIntegrationSource, MergedIntegrationSource, MergeHeadReview, OrdinaryInput, PromotedHeadReview, PullRequestHead, ResolvedIntegrationSource, Side, SourceCheckout, UnpromotedHeadReview from .installer.runtime_resolution import StackSources from .launch_runtime import CheckoutSource, RestoredRuntime from .repo import Checkout, DetachedStart, GitCommandError, Repo @@ -146,7 +146,7 @@ def fresh_main(self) -> str: def create(cls, repository: Repo, path: Path, commit: str) -> IntegrationCheckout: if path.exists(): raise IntegrationFailed(f"Preserve existing qualification checkout: {path}") - Repo.require_commits(repository.checkout, repository.remote, commit) + repository.acquire(commit) path.parent.mkdir(parents=True, exist_ok=True) created = cls.add(repository.checkout, path, DetachedStart(commit)) created.require_editable() @@ -366,7 +366,7 @@ def check_merge_inputs(self, directory: Path) -> IntegrationInput: self.publish_merge_review() if self.require_merge_review(repositories) != inputs: raise IntegrationFailed("The hosted input changed during review publication") - except (ToolRefusedError, GitCommandError, IntegrationFailed): + except (ToolRefusedError, GitCommandError): self.withdraw_merge_review() raise print(f"COMBINED CARRIER #{self.carrier.pr} {self.carrier.head}; inputs {inputs.digest}", flush=True) @@ -380,44 +380,39 @@ def materialize(self, directory: Path) -> IntegrationInput: payload = IntegrationInput.from_message( WorkBranches(self.repository.checkout).commit_message(self.carrier.head)) urls = IntegrationInput.source_urls(self.repository, self.baseline, self.tested) - repositories = [self.repository] + companions: list[Repo] = [] for name, url in sorted(urls.items(), key=lambda item: item[0].key): - if name == self.repository.github: - raise IntegrationFailed("The stack repeats Core as a Git companion source") checkout = directory / "repositories" / Path(*name.key) checkout.parent.mkdir(parents=True, exist_ok=True) Repo.git(self.repository.checkout, "clone", "--no-checkout", url, str(checkout), timeout=300) repository = Repo.of(checkout) if repository.github != name: raise IntegrationFailed("A declared source clone has a different actual Git origin") - repositories.append(repository) + companions.append(repository) + repositories = (self.repository, *companions) if payload: inputs, = payload else: - sources: list[IntegrationSource] = [MergedIntegrationSource(RepositoryCommit(self.repository.github, self.baseline), + sources: list[IntegrationSource] = [LocalIntegrationSource(RepositoryCommit(self.repository.github, self.baseline), (self.carrier,), self.tree)] - for repository in repositories[1:]: + for repository in companions: commit = IntegrationSource.main_head(repository) - Repo.require_commits(repository.checkout, repository.remote, commit) + repository.acquire(commit) tree = Repo.git(repository.checkout, "rev-parse", f"{commit}^{{tree}}").strip() sources.append(MergedIntegrationSource(RepositoryCommit(repository.github, commit), (), tree)) inputs = OrdinaryInput(tuple(sorted(sources, key=lambda source: source.repository.key))) - self.validate(inputs, tuple(repositories)) + self.validate(inputs, repositories) for source in inputs.sources: repository = Repo.selected(tuple(repositories), source.repository.to_wire()) IntegrationCheckout.create(repository, BaseSide.checkout(directory, source.repository), source.baseline.commit) - # The actual tested Core merge already carries all local parents. Fetching its - # earlier own PR head through refs/pull would instead fetch the new carrier. - if source.repository == self.repository.github: - IntegrationCheckout.create(repository, HeadSide.checkout(directory, source.repository), self.tested) - else: - IntegrationCheckout.materialize(source, repository, HeadSide.checkout(directory, source.repository)) + source.materialize_head(repository, HeadSide.checkout(directory, source.repository), + self.tested, IntegrationCheckout) for side in Side.sides(): side.require_materialized(directory, source) checkout = side.checkout(directory, source.repository) print(f"SOURCE {side.declared_name} {source.repository.to_wire()} " f"{Repo.git(checkout, 'rev-parse', 'HEAD').strip()} {checkout}", flush=True) - self.validate(inputs, tuple(repositories)) + self.validate(inputs, repositories) inputs.write(directory) print(f"CI INPUT {inputs.digest}; source computation only, no review or merge permission", flush=True) return inputs @@ -436,7 +431,8 @@ def revalidate(self, directory: Path) -> IntegrationInput: def validate(self, inputs: IntegrationInput, repositories: tuple[Repo, ...]) -> None: """Re-run at job exit: source checks compute, but never publish a verdict.""" self.require_carrier() - local = inputs.source(self.repository.github) + local = inputs.local + local.require_origin(self.repository) if local.baseline.commit != self.baseline or local.candidate_tree != self.tree: raise IntegrationFailed("Tested base/tree differs from the frozen source input; recapture") inputs.require_repositories(IntegrationInput.declared_repositories( @@ -448,17 +444,15 @@ def validate(self, inputs: IntegrationInput, repositories: tuple[Repo, ...]) -> raise IntegrationFailed("The tested carrier and materialized input disagree") inputs.require_carrier(self.repository, self.carrier) else: - # Ordinary mode is precisely one actual carrier plus unchanged captured mains. - if local.heads != (self.carrier,) or any(source.heads for source in inputs.sources - if source.repository != self.repository.github): - raise IntegrationFailed("An ordinary comparison cannot supply companion overlays") + for source in inputs.sources: + source.require_ordinary(self.carrier) mains = self.main_commits(inputs) for source in inputs.sources: repository = Repo.selected(repositories, source.repository.to_wire()) if IntegrationSource.main_head(repository) != mains[source.repository]: raise IntegrationFailed(f"{source.repository.to_wire()} main moved; recapture") self.require_local_candidate(inputs) - for source in inputs.companions(self.repository.github): + for source in inputs.companions: source.require_candidate(Repo.selected(repositories, source.repository.to_wire()), self.carrier) @@ -562,7 +556,7 @@ def main_commits(self, inputs: IntegrationInput) -> dict[RepositoryName, str]: def require_local_candidate(self, inputs: IntegrationInput) -> None: """Main owns the transition from exact open sources to exact merged ancestry.""" - source = inputs.source(self.repository.github) + source = inputs.local source.require_ancestry(self.repository, self.carrier.head) for head in (head for head in source.heads if not head.same_pull_request(self.carrier)): rows = tuple(row for row in PullRequests().rows(head.repo, (head.pr,)) @@ -618,20 +612,22 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, candidate_commit = Repo.git(self.checkout.path, "rev-parse", "HEAD").strip() if current.repo != core.github or current.base != WorkBranches.main or current.head != candidate_commit: raise IntegrationFailed("Capture requires the actual published combined carrier at this checkout's HEAD") - Repo.require_commits(registered_core.checkout, registered_core.remote, base, candidate_commit) + registered_core.acquire(base, candidate_commit) required = IntegrationInput.declared_repositories(registered_core, base, candidate_commit) linked_repositories = {link.repo for todo in todos for order in todo.order.declarations for link in order.pull_requests} if linked_repositories - set(required): raise IntegrationFailed("A selected order links an unknown stack repository") - repositories = {name: Repo.selected(context.repos, name.to_wire()) for name in required} + companion_repositories = {name: Repo.selected(context.repos, name.to_wire()) for name in required} + companion_repositories.pop(core.github) + repositories = {core.github: registered_core, **companion_repositories} carriers = (carrier, *companions) mains = {core.github: base, **{name: IntegrationSource.main_head(repo) - for name, repo in repositories.items() if name != core.github}} + for name, repo in companion_repositories.items()}} pulls = PullRequests(todos) heads: dict[RepositoryName, tuple[PullRequestHead, ...]] = {} for repo in repositories.values(): - Repo.require_commits(repo.checkout, repo.remote, mains[repo.github]) + repo.acquire(mains[repo.github]) linked = PullRequests.linked_among(todos, repo.github) selected = [] for row in pulls.rows(repo.github, tuple(sorted(linked))): @@ -650,7 +646,7 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, for container in carriers: if (container.repo == repo.github and not head.same_pull_request(container)): - Repo.require_commits(repo.checkout, repo.remote, head.head, container.head) + repo.acquire(head.head, container.head) if Repo.descends(repo.checkout, head.head, container.head): selected.append(head) heads[repo.github] = tuple(sorted({head.pr: head for head in selected}.values(), key=lambda head: head.pr)) @@ -658,13 +654,10 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, IntegrationCheckout.create(registered_core, BaseSide.checkout(self.output, core.github), base) tree = Repo.git(self.checkout.path, "rev-parse", "HEAD^{tree}").strip() resolved = {companion.repo: companion for companion in companions} - if len(resolved) != len(companions) or set(resolved) - (set(required) - {core.github}): + if len(resolved) != len(companions) or set(resolved) - companion_repositories.keys(): raise IntegrationFailed("Select at most one actual carrier per declared companion repository") - sources: list[IntegrationSource] = [MergedIntegrationSource(RepositoryCommit(core.github, base), heads[core.github], tree)] - for name in required: - if name == core.github: - continue - repo = repositories[name] + sources: list[IntegrationSource] = [LocalIntegrationSource(RepositoryCommit(core.github, base), heads[core.github], tree)] + for name, repo in companion_repositories.items(): baseline = mains[name] IntegrationCheckout.create(repo, BaseSide.checkout(self.output, name), baseline) # Resolved carrier selection is a foreign CLI boundary; all later consumers @@ -673,7 +666,7 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, if name in resolved: companion = resolved[name] companion.current() - Repo.require_commits(repo.checkout, repo.remote, companion.head) + repo.acquire(companion.head) tree = Repo.git(repo.checkout, "rev-parse", f"{companion.head}^{{tree}}").strip() source = ResolvedIntegrationSource( RepositoryCommit(name, baseline), heads[name], tree, companion) @@ -683,23 +676,22 @@ def qualify(self, context: CommanderContext, problems: tuple[str, ...], *, tree = candidate.combine_sources(baseline, heads[name]) source = MergedIntegrationSource(RepositoryCommit(name, baseline), heads[name], tree) sources.append(source) - # Revalidate original sources before freezing the shared value or attaching its payload. - for source in sources: + inputs = CombinedInput(tuple(sorted(sources, key=lambda source: source.repository.key)), carrier) + inputs.local.require_ancestry(registered_core, candidate_commit) + for source in inputs.companions: + source.require_tree(repositories[source.repository]) + # Revalidate original sources before attaching the shared value's payload. + for source in inputs.sources: repo = repositories[source.repository] if IntegrationSource.main_head(repo) != source.baseline.commit: raise IntegrationFailed("A captured main moved; preserve this failed attempt and recapture") for head in source.heads: head.current() - if source.repository == core.github: - source.require_ancestry(repo, candidate_commit) - else: - source.require_tree(repo) current_todos = tuple(context.comms.goals.todos.get(todo.id) for todo in todos) for todo in current_todos: todo.require_commander(context.incarnation) - inputs = CombinedInput(tuple(sorted(sources, key=lambda source: source.repository.key)), carrier) context.comms.goals.todos.require_input_sources(inputs) - if Repo.git(self.checkout.path, "rev-parse", f"{base}^{{tree}}").strip() == inputs.source(core.github).candidate_tree: + if Repo.git(self.checkout.path, "rev-parse", f"{base}^{{tree}}").strip() == inputs.local.candidate_tree: raise IntegrationFailed("The Core carrier has no substantive source change") original_message = WorkBranches(self.checkout.path).commit_message(candidate_commit) if IntegrationInput.from_message(original_message): diff --git a/src/agent_comms/owned_send.py b/src/agent_comms/owned_send.py index f54321eed6..24e75cd9a3 100644 --- a/src/agent_comms/owned_send.py +++ b/src/agent_comms/owned_send.py @@ -25,7 +25,7 @@ from .coordination_session import NativeCapture, NativeCommit from .coordinator import Coordination from .envelope_claim_transitions import NativeClaim -from .field_codec import ClaimId, InputId, TurnId +from .field_codec import AcpSessionId, ClaimId, InputId, TurnId from .input_attempt import InputAlreadyCleared from .reservation_rules import ReservationRule, ReservationViolationError from .tool_denial import SelectedToolDenied @@ -54,7 +54,7 @@ class OwnedSend(CodingToolOwner, InputStartListener, SendBoundary): inputs: InputDrain goal_store: GoalAttemptStore - session_id: str + session_id: AcpSessionId turn: TurnId claim_generation: int original_source: OriginalTurnInput diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index bc2d12e72a..73e66a99a4 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -29,7 +29,7 @@ from .channel_input_batch import InputBatch from .coordination_errors import CoordinationError, Refused from .coordinator import Coordination -from .field_codec import GoalId, TurnId +from .field_codec import AcpSessionId, FieldCodec, GoalId, TurnId from .goals import Goal from .image_input import ImageInput from .input_attempt import ReservedInput @@ -137,7 +137,7 @@ def __init__( dependency: InputDependency = NoInputDependency(), ) -> None: self.runner = runner - self.session_id = session_id + self.session_id = FieldCodec.decode(AcpSessionId, session_id) self.thread_name = thread_name self.task = task self.origins = origins diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index be9cedff5a..9e3f7d69be 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -198,6 +198,10 @@ def require_commits(cls, checkout: Path | str, remote: str, *objects: str) -> No raise UnavailableCommitError(checkout, missing, f"remote {remote}: {error}") from error cls.require_local_commits(checkout, *objects) + def acquire(self, *objects: str) -> None: + """Acquire exact commits from this repository's own remote.""" + self.require_commits(self.checkout, self.remote, *objects) + def contains(self, sha: str, revision: str = "HEAD") -> bool: """Strict local ancestry; only valid local non-ancestors mean False.""" self.require_local_commits(self.checkout, sha, revision) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 98212fc2d5..641cee1ee0 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -4137,24 +4137,23 @@ def require_merge(cls, request: PullRequestHead, repositories: tuple[Repo, ...]) f"{request.repo.to_wire()}#{request.pr} at {request.head} has no recorded ok verdict.") MergeHeadReview.require_merge(request, repositories) - def post(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> None: - # Withdraw first: a failed source-status write cannot leave an earlier promotion live. - UnpromotedHeadReview().post(repo, head, reviewer, subject) - super().post(repo, head, reviewer, subject) + @contextmanager + def recording(self, request: PullRequestHead, reviewer: str, subject: str) -> Iterator[None]: + """Withdraw both old permissions before the write; publish its decision once after it.""" + UnpromotedHeadReview().post(request.repo, request.head, reviewer, subject) + UnderReviewHeadVerdict().post(request.repo, request.head, reviewer, subject) + yield + self.publish_recorded(request, reviewer, subject) - def begin_review(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> None: - UnpromotedHeadReview().post(repo, head, reviewer, subject) - # A failed final status write must not leave a newly rejected member source green. - HeadReview.post(UnderReviewHeadVerdict(), repo, head, reviewer, subject) + def publish_recorded(self, request: PullRequestHead, reviewer: str, subject: str) -> None: + """The reviewing mark already withdrew source permission; no second pending mark.""" def reviewed(self, goals: Goals, request: PullRequestHead, prepared: PreparedHeadReview, reviewer: ThreadIncarnation, order: HeadReviewOrder) -> HeadReviewed: request = replace(request, head=prepared.current.head) - # Do not store a rejection/reviewing row while its old merge permission remains live. - self.begin_review(request.repo, request.head, reviewer.name, prepared.subject) - recorded = order.record_review(goals, request, self, reviewer) - self.after_record(goals, recorded) - super().post(request.repo, request.head, reviewer.name, prepared.subject) + with self.recording(request, reviewer.name, prepared.subject): + recorded = order.record_review(goals, request, self, reviewer) + self.after_record(goals, recorded) return HeadReviewed(recorded, self.landing(prepared.current)) def landing(self, current: OpenPullRequestHead) -> MergeArm: @@ -4202,6 +4201,9 @@ class DecidedHeadVerdict(HeadVerdict, declared_name=None): reason: VerdictReason verb: ClassVar[str] + def publish_recorded(self, request: PullRequestHead, reviewer: str, subject: str) -> None: + self.post(request.repo, request.head, reviewer, subject) + def settles_edits(self, record: HeadReviewRecord, began: float) -> float: return record.at.latest_with(began) @@ -4244,9 +4246,8 @@ def carry(self, goals: Goals, record: HeadReviewRecord, current: ReadPullRequest verdict = CarriedHeadVerdict(self.reason, record.request) carried = replace(record, request=replace(record.request, head=current.head), verdict=verdict, at=RecordedEventTime(time.time())) - verdict.begin_review(current.repo, current.head, record.actor.name, f"#{current.pr}") - goals.todos.record_head(carried) - HeadReview.post(verdict, current.repo, current.head, record.actor.name, f"#{current.pr}") + with verdict.recording(current, record.actor.name, f"#{current.pr}"): + goals.todos.record_head(carried) return True @classmethod diff --git a/src/agent_comms/turn_input_source.py b/src/agent_comms/turn_input_source.py index 6a242777a9..7b2bf901f1 100644 --- a/src/agent_comms/turn_input_source.py +++ b/src/agent_comms/turn_input_source.py @@ -177,7 +177,7 @@ def _prepare( # The queue grants a send, not a running tool's lifetime. Once Pi # starts this exact input, StartedInput removes its queue entry. self.acceptance.require_current( - sender.inputs, AcpSessionId(sender.session_id), self, current, sender.claim_generation, + sender.inputs, sender.session_id, self, current, sender.claim_generation, ) except ReservationViolationError as error: return sender._refusal(error.rule) @@ -385,7 +385,7 @@ class FollowingTurnInput(TurnInputSource): owner_check = RunningOrdinaryOwnerCheck def require_context_source(self, sender: OwnedSend) -> None: - self.acceptance.require_following_source(sender.inputs, AcpSessionId(sender.session_id), self) + self.acceptance.require_following_source(sender.inputs, sender.session_id, self) def continue_response(self, response, parts): """An actual following request may revise the turn's automatic answer.""" diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py index c5eb8c63cf..b7aa87ecb3 100644 --- a/tests/test_checker_stack.py +++ b/tests/test_checker_stack.py @@ -14,7 +14,7 @@ from agent_comms.head_review import BaseSide, HeadSide from agent_comms.installer.commands import InstallCommands from agent_comms.installer.refusals import UpgradeRefused -from agent_comms.installer.runtime_resolution import CorePackage, GitSource, ResolvedLock +from agent_comms.installer.runtime_resolution import CorePackage, GitSource, ResolvedLock, StackSources from agent_comms.launch_runtime import LaunchRuntime from agent_comms.repository_name import RepositoryName from agent_comms.todos import DraftListedPullRequest @@ -26,6 +26,21 @@ pytest_plugins = ("test_holder_standing_rules", "integration_source_fixture") +def test_local_package_membership_comes_from_both_source_declarations() -> None: + before: dict[str, object] = {"tool": {"uv": {"sources": { + "Agent_Comms": {"path": "..", "editable": True}, + "Source_Client": {"git": "https://github.com/example/client", "branch": "main"}, + "registry-only": {"index": "public"}, + }}}} + after: dict[str, object] = {"tool": {"uv": {"sources": { + "new-local": {"path": "../new"}, + "registry-only": {"index": "private"}, + }}}} + assert StackSources.local_packages(before, after) == frozenset({ + "agent-comms", "source-client", "new-local", + }) + + @pytest.mark.parametrize("dependencies,allowed,declared_python", [ pytest.param((), True, "", id="dependencies0-True"), pytest.param(("idna==3.10",), False, "", id="dependencies1-False"), diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 3ba5042036..a94babf997 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -17,7 +17,7 @@ from agent_comms.goal_actions import CreateProblemGoalAction from agent_comms.build_identity import RepositoryCommit from agent_comms.head_review import ( - CombinedInput, DraftMergeArm, HeadReview, IntegrationInput, IntegrationSource, MergedIntegrationSource, MergeHeadReview, OrdinaryInput, PromotedHeadReview, PullRequestHead, ReadPullRequestHead, + CombinedInput, DraftMergeArm, HeadReview, IntegrationInput, IntegrationSource, LocalIntegrationSource, MergedIntegrationSource, MergeHeadReview, OrdinaryInput, PromotedHeadReview, PullRequestHead, ReadPullRequestHead, ) from agent_comms.integration import IntegrationRun from commander_heartbeat_fixture import capture_frame @@ -148,7 +148,7 @@ def qualify_unchanged(): (todo,), request, UnquotedHeadReview.of)) qualify_unchanged() # Reviewing: the recorded source cut changes no held work. posts = review_posts(github, request.head) - assert len(posts) == 8 + assert len(posts) == 7 assert all("state=pending" in call for call in posts) assert not any(call[:2] == ("pr", "merge") for call in github.calls()) @@ -460,7 +460,7 @@ def test_qualification_does_not_carry_acceptance_to_an_unchanged_later_head(head @pytest.fixture def captured_input(reviewed_source: SourceRepository) -> IntegrationInput: source = reviewed_source - return OrdinaryInput((MergedIntegrationSource( + return OrdinaryInput((LocalIntegrationSource( RepositoryCommit(source.github, source.main_commit), (PullRequestHead(source.github, source.number, source.branch_commit),), source.git(source.checkout, "rev-parse", f"{source.branch_commit}^{{tree}}").strip(), @@ -471,6 +471,8 @@ def test_integration_metadata_is_canonical_and_ordinary_messages_have_no_digest( captured_input: IntegrationInput) -> None: inputs = captured_input assert FieldCodec.decode(IntegrationInput, FieldCodec.parse(inputs.canonical)) == inputs + assert inputs.local is inputs.sources[0] + assert inputs.companions == () message = f"Combine the real source changes\n\n{inputs.trailer}\n" assert IntegrationInput.from_message(message) == (inputs,) assert IntegrationInput.digest_from_message(message) == inputs.digest @@ -529,6 +531,8 @@ def test_companion_input_changes_the_identity_of_an_unchanged_local_patch( companion.git(companion.checkout, "rev-parse", f"{companion.branch_commit}^{{tree}}").strip(), ) inputs = replace(captured_input, sources=tuple(sorted((*captured_input.sources, client), key=lambda row: row.repository.key))) + assert inputs.local is captured_input.local + assert inputs.companions == (client,) companion.git(companion.checkout, "checkout", "feature") owner = companion.checkout / "owner.py" owner.write_text(owner.read_text() + "new_export = True\n") @@ -885,7 +889,7 @@ def test_judge_reviewing_after_ok_withdraws_the_accepted_status_on_that_head( judge.main(["reviewing", *common], context) posts = review_posts(github, request.head) assert [next(part for part in post if part.startswith("state=")) for post in posts] == [ - "state=pending", "state=success", "state=pending", "state=pending"] + "state=pending", "state=success", "state=pending"] @pytest.mark.parametrize("verdict", (ReviewedHeadVerdict(ACCEPTED), QualifiedHeadVerdict(QUALIFIED))) diff --git a/tools/checker_stack.py b/tools/checker_stack.py index b1f7c35bc2..2045bfde62 100644 --- a/tools/checker_stack.py +++ b/tools/checker_stack.py @@ -52,8 +52,9 @@ def core(self) -> Path: @cached_property def local_names(self) -> frozenset[str]: source = self.inputs.source(CorePackage.repository) - return frozenset({CorePackage.distribution, *(name for side in Side.sides() - for name in self.sources(side.require_materialized(self.source_directory, source) / "stack"))}) + return self.local_packages(*(tomllib.loads(( + side.require_materialized(self.source_directory, source) / "stack/pyproject.toml" + ).read_text()) for side in Side.sides())) def require_uv(self) -> None: declared = DeclaredUv.of(self.core / "pyproject.toml", self.uv) From f8d56de408858035ff8b82f4fe064a6884408994 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 19:57:02 -0400 Subject: [PATCH 090/109] Use the real source remote path in integration fixtures --- tests/integration_source_fixture.py | 2 +- tests/test_checker_stack.py | 4 ++-- tests/test_source_revision.py | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/integration_source_fixture.py b/tests/integration_source_fixture.py index 499bc88d29..9235facbf9 100644 --- a/tests/integration_source_fixture.py +++ b/tests/integration_source_fixture.py @@ -83,7 +83,7 @@ def source_pair( 'constraint-dependencies = ["typing-extensions>=4.15"]\n' '[tool.uv.pip]\nstrict = true\n' '[tool.uv.sources]\nagent-comms = {path = "..", editable = true}\n' - f'source-client = {{git = "{client.remote}", branch = "main"}}\n' + f'source-client = {{git = "{client.remote_path.as_uri()}", branch = "main"}}\n' ), Path("stack/uv.lock"): 'version = 1\nrevision = 3\nrequires-python = ">=3.14"\npackage = []\n', Path("uv.lock"): 'version = 1\nrevision = 3\nrequires-python = ">=3.14"\npackage = []\n', diff --git a/tests/test_checker_stack.py b/tests/test_checker_stack.py index b7aa87ecb3..48ba0e01bf 100644 --- a/tests/test_checker_stack.py +++ b/tests/test_checker_stack.py @@ -167,12 +167,12 @@ def common_checker( core = tomllib.loads(pair.core.package_project("agent-comms", dependencies=("idna>=3.10,<4; python_version >= '3.10'",), extras={ "checking": ["idna<3.11", "six==1.16.0", "checker-plugin"]})) - plugin_source = {"git": plugin.remote.as_uri(), "rev": plugin.main_commit, "subdirectory": "python package"} + plugin_source = {"git": plugin.remote_path.as_uri(), "rev": plugin.main_commit, "subdirectory": "python package"} core["tool"]["uv"] = {"sources": {"checker-plugin": plugin_source}} pair.core.write_files({Path("pyproject.toml"): tomli_w.dumps(core)}) stack = tomllib.loads((pair.core.checkout / "stack/pyproject.toml").read_text()) stack["tool"]["uv"]["sources"]["checker-plugin"] = plugin_source - stack["tool"]["uv"]["sources"]["source-client"]["git"] = pair.client.remote.as_uri() + stack["tool"]["uv"]["sources"]["source-client"]["git"] = pair.client.remote_path.as_uri() stack["project"]["dependencies"] = ["agent-comms>=0.0.1; python_version >= '3.10'", "source-client"] stack["tool"]["uv"]["override-dependencies"].append("agent-comms>=0.0.1; python_version >= '3.10'") pair.core.write_files({Path("stack/pyproject.toml"): tomli_w.dumps(stack)}) diff --git a/tests/test_source_revision.py b/tests/test_source_revision.py index 7bd9267a07..56a4dcdb80 100644 --- a/tests/test_source_revision.py +++ b/tests/test_source_revision.py @@ -49,20 +49,20 @@ def test_seeded_source_publishes_descendants_and_merges_main_without_replacing_a candidate = source.publish_change("Replace the seeded API", *source.write_files({ Path("src/api.py"): 'def new_api() -> str:\n return "candidate"\n'})) assert source.descends(source.checkout, feature, candidate) - assert source.git(source.remote, "rev-parse", "refs/pull/7/head").strip() == candidate + assert source.git(source.remote_path, "rev-parse", "refs/pull/7/head").strip() == candidate source.git(source.checkout, "switch", "main") advanced = source.commit_change("Advance main independently", *source.write_files({ Path("main_only.py"): "advanced = True\n"})) source.git(source.checkout, "push", "origin", "main") - assert source.git(source.remote, "rev-parse", "main").strip() == advanced - assert source.git(source.remote, "rev-parse", "refs/pull/7/head").strip() == candidate + assert source.git(source.remote_path, "rev-parse", "main").strip() == advanced + assert source.git(source.remote_path, "rev-parse", "refs/pull/7/head").strip() == candidate source.git(source.checkout, "switch", "feature") source.git(source.checkout, "merge", "--no-edit", "main") final = source.publish_change("Publish the merged descendant", *source.write_files({ Path("src/next.py"): "next_value = 2\n"})) for ancestor in (candidate, advanced): assert source.descends(source.checkout, ancestor, final) - assert source.git(source.remote, "rev-parse", "refs/pull/7/head").strip() == final + assert source.git(source.remote_path, "rev-parse", "refs/pull/7/head").strip() == final assert source.git(source.checkout, "show", f"{final}:main_only.py") == "advanced = True\n" From d09e763b6f5b08c39e627e71bd288d5dbaf35206 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:08:04 -0400 Subject: [PATCH 091/109] Prepare both captured heads before comparing companion identities --- tests/test_head_review.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_head_review.py b/tests/test_head_review.py index a94babf997..0bc88a893e 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -547,6 +547,8 @@ def test_companion_input_changes_the_identity_of_an_unchanged_local_patch( "-m", f"Combine the substantive Core source\n\n{snapshot.trailer}", ).strip()) for snapshot in (inputs, updated)) reader = WorkBranches(source.checkout) + for head in heads: + reader.prepare_pull_request("main", head.head) before, after = (head.change_identity(reader, "main") for head in heads) assert before.patch == after.patch assert before.integration_digest == inputs.digest From a974c0f54b6545a9309f97ec9698db3edd90114e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 21:51:06 -0400 Subject: [PATCH 092/109] CI: debt_ratchet.py takes no --ci-inputs after #3606 Main's #3606 made tools/debt_ratchet.py the entry over the pinned audit, with no --ci-inputs argument; the merge kept #3566's workflow lines passing it, so debt-src and debt-tools exited 2 on argparse. checker_stack.py and module_layers.py still declare it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- .github/workflows/debt-ratchet-cache.yml | 2 +- .github/workflows/debt-ratchet.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/debt-ratchet-cache.yml b/.github/workflows/debt-ratchet-cache.yml index 802bebca3a..32694f09bd 100644 --- a/.github/workflows/debt-ratchet-cache.yml +++ b/.github/workflows/debt-ratchet-cache.yml @@ -70,7 +70,7 @@ jobs: - name: Measure main against its parent run: | PYTHONPATH=src:tools python tools/debt_ratchet.py \ - --root ${{ matrix.kind }} --base HEAD^1 --head HEAD --ci-inputs "$RUNNER_TEMP/paired-sources" || true + --root ${{ matrix.kind }} --base HEAD^1 --head HEAD || true - name: The same sources are still current before saving cache env: diff --git a/.github/workflows/debt-ratchet.yml b/.github/workflows/debt-ratchet.yml index 39c5ab2e01..da8276dc4e 100644 --- a/.github/workflows/debt-ratchet.yml +++ b/.github/workflows/debt-ratchet.yml @@ -271,7 +271,7 @@ jobs: set -o pipefail ratchet_status=0 PYTHONPATH=src:tools python tools/debt_ratchet.py \ - --root ${{ matrix.kind }} --base "$BASE" --head HEAD --ci-inputs "$RUNNER_TEMP/paired-sources" 2>ratchet-findings.txt | tee ratchet.txt || ratchet_status=$? + --root ${{ matrix.kind }} --base "$BASE" --head HEAD 2>ratchet-findings.txt | tee ratchet.txt || ratchet_status=$? cat ratchet-findings.txt >&2 exit "$ratchet_status" From a7ab81eec96d180d5da6a14ef7f7ba6bad4134c2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 22:02:12 -0400 Subject: [PATCH 093/109] Ratchet clients: pass uv, python and install commands to StackClient.fetched #3566 made StackClient.fetched (module_layers.py:371) require uv, python and commands to acquire each client's metadata; main's #3606 caller at agent_comms_project.py:252 kept the old two-argument call, so debt-src died with TypeError after the merge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- tools/agent_comms_project.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index c962eba36a..a65e5a0f99 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -42,6 +42,8 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import boundary_decoder from agent_comms.installer.carry import STEPS +from agent_comms.installer.commands import InstallCommands +from agent_comms.installer.runtime_resolution import StackSources from agent_comms.installer.probes.carry import Carry from agent_comms.phase_evidence import MainTree, PlanReading from agent_comms.pinned_audit import SKILL, audit @@ -249,7 +251,9 @@ def clients(repo: audit.project.Project, head: str) -> tuple[StackClient, ...]: atexit.register(shutil.rmtree, into, True) (into / "stack").mkdir() (into / stack).write_bytes(repo.blob(head, stack)) - return StackClient.fetched(into / "stack", into) + with InstallCommands() as commands: + return StackClient.fetched(into / "stack", into, uv=StackSources.frontend(), + python=Path(sys.executable), commands=commands) @staticmethod def selected(client: StackClient) -> list[str]: From cf7aa551fa2593718e337df9ac100572e93c127f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 22:47:08 -0400 Subject: [PATCH 094/109] Ratchet clients: the project owns its ClientSources; --ci-inputs reads CI's cuts #3606 moved ClientTypeErrors into AgentCommsProject with a fresh stack clone and no way to read the integration entrypoint's materialized cuts, so #3566 had to drop --ci-inputs from debt_ratchet.py (a974c0f54). AgentCommsProject now holds one ClientSources (default: the head's declared stack, cloned once); AgentCommsProject.from_arguments decodes --ci-inputs into MaterializedClients. ClientTypeErrors.parts asks it per compared side and also runs when the source requires clients (a companion can change while Core does not). The static clone in ClientTypeErrors.clients is deleted. Two tests still importing the deleted debt_ratchet.ClientTypeErrors/compare now build the project with its clients. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/test_integration.py | 12 +++--- tests/test_module_layers_clients.py | 7 ++-- tools/agent_comms_project.py | 60 ++++++++++++++++++----------- tools/debt_ratchet.py | 8 +++- 4 files changed, 54 insertions(+), 33 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 2c4474e77c..ee730bcbbf 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -29,7 +29,8 @@ from integration_source_fixture import MutualSources from source_revision_fixture import SourceRepository from fixture_git import ENVIRONMENT, SETTINGS -from debt_ratchet import ClientTypeErrors, compare +from agent_comms_project import AgentCommsProject, ClientTypeErrors +from agent_comms.pinned_audit import audit from module_layers import MaterializedClients, Package pytest_plugins = ("github_fixture", "test_holder_standing_rules", "integration_source_fixture") @@ -372,8 +373,9 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( source = inputs.source(pair.core.github) head = pair.core.git(pair.checkout.path, "rev-parse", "HEAD").strip() clients = materialized_clients(pair.output, inputs) - assert len(ClientTypeErrors.parts(pair.core.checkout, source.baseline.commit, head, - {"src/agent_comms/api.py"}, "src/", clients)) == 2 + project = AgentCommsProject(pair.core.checkout, clients=clients) + assert len(ClientTypeErrors.parts(project, source.baseline.commit, head, + {"src/agent_comms/api.py"}, "src/")) == 2 for side in (BaseSide, HeadSide): client, = clients.at(pair.core.checkout, source.baseline.commit, head, side) assert client.import_paths == tuple(str(client.checkout / path) for path in ("src", "tools")) @@ -387,8 +389,8 @@ def test_real_client_checker_compares_the_matched_tuple_in_its_one_interpreter( assert entry.returncode == 0, entry.stdout + entry.stderr # Both namespace files are selected under --explicit-package-bases: a duplicate # performance/tools.performance identity aborts the real checker, not a zero count. - result = compare(pair.core.checkout, source.baseline.commit, head, "src", 1, - measured=(ClientTypeErrors,), clients=clients) + result = audit.ratchet.compare(project, source.baseline.commit, head, "src", 1, + (ClientTypeErrors,)) assert not result.failed assert all(value == 0 for value in result.delta.values()) diff --git a/tests/test_module_layers_clients.py b/tests/test_module_layers_clients.py index 90aa7f89ed..57788fed81 100644 --- a/tests/test_module_layers_clients.py +++ b/tests/test_module_layers_clients.py @@ -13,7 +13,7 @@ import tomli_w from module_layers import MaterializedClients, OutgoingWire, Package, StackClient -from debt_ratchet import ClientTypeErrors +from agent_comms_project import AgentCommsProject, ClientTypeErrors from agent_comms.head_review import BaseSide, HeadSide, IntegrationInput, Side from agent_comms.todos import DraftListedPullRequest from github_fixture import GitHubFixture @@ -102,8 +102,9 @@ def test_actual_client_membership_stays_absent_on_its_own_cut( clients = resource.at(pair.core.checkout, core.baseline.commit, tested, side) assert len(clients) == int(present) assert all(client.checkout == side.checkout(pair.output, pair.client.github) for client in clients) - assert len(ClientTypeErrors.parts(pair.core.checkout, core.baseline.commit, tested, - {"src/agent_comms/api.py"}, "src/", resource)) == 1 + assert len(ClientTypeErrors.parts(AgentCommsProject(pair.core.checkout, clients=resource), + core.baseline.commit, tested, + {"src/agent_comms/api.py"}, "src/")) == 1 def test_materialized_client_files_come_from_each_actual_cut( diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index a65e5a0f99..f636dee82d 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -7,8 +7,8 @@ from __future__ import annotations +import argparse import ast -import atexit import dataclasses import inspect import json @@ -26,7 +26,7 @@ from subprocess import CompletedProcess import stored_formats -from module_layers import PACKAGE, StackClient +from module_layers import PACKAGE, ClientSources, MaterializedClients, StackClient import agent_comms from agent_comms import ( @@ -40,10 +40,11 @@ from agent_comms.child_process import ForkedProcessPool, SpawnedProcess from agent_comms.core_stamp import WheelStamp from agent_comms.declared_family import DeclaredFamily +from agent_comms.head_review import Side as CutSide from agent_comms.field_codec import boundary_decoder -from agent_comms.installer.carry import STEPS from agent_comms.installer.commands import InstallCommands from agent_comms.installer.runtime_resolution import StackSources +from agent_comms.installer.carry import STEPS from agent_comms.installer.probes.carry import Carry from agent_comms.phase_evidence import MainTree, PlanReading from agent_comms.pinned_audit import SKILL, audit @@ -103,6 +104,24 @@ class AgentCommsProject(audit.project.Project[PlanPhase]): """agent-comms' own facts, read from the modules that declare them.""" reader: StoredFormatReader = RevisionReader() + clients: ClientSources = ClientSources() + """Where the compared revisions' stack clients come from: the head's declared stack, + cloned once, unless CI already materialized each side's exact cut.""" + + @classmethod + def from_arguments(cls, root: Path, commands: InstallCommands) -> tuple[AgentCommsProject, list[str]]: + """This checkout measured with the clients its command line names, and the arguments + left for the audit's own command line. --ci-inputs names the integration entrypoint's + already-materialized exact source input.""" + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument( + "--ci-inputs", dest="clients", default=ClientSources(), + type=lambda directory: MaterializedClients.read( + Path(directory).resolve(), uv=StackSources.frontend(), + python=Path(sys.executable), commands=commands), + help="use the integration entrypoint's already-materialized exact source input") + known, rest = parser.parse_known_args() + return cls(root, clients=known.clients), rest probe = ( "import importlib.util, os, sys; sys.path[:0] = sys.argv[2].split(os.pathsep); " @@ -240,20 +259,13 @@ class ClientTypeErrors(audit.checkers.SideCounts): only the running app found it.""" @staticmethod - @cache - def clients(repo: audit.project.Project, head: str) -> tuple[StackClient, ...]: - """The head's stack clients, cloned once per run and read by both sides; a revision - declaring no stack has none.""" - stack = "stack/pyproject.toml" - if not repo.tracks(head, stack): - return () - into = Path(tempfile.mkdtemp(prefix="ratchet-clients-")) - atexit.register(shutil.rmtree, into, True) - (into / "stack").mkdir() - (into / stack).write_bytes(repo.blob(head, stack)) - with InstallCommands() as commands: - return StackClient.fetched(into / "stack", into, uv=StackSources.frontend(), - python=Path(sys.executable), commands=commands) + def cut(repo: AgentCommsProject, base: str, head: str, + side: type[audit.ratchet.Side]) -> tuple[StackClient, ...]: + """The clients of the materialized cut that selects the same revision as this compared + side. The audit and head_review each declare the base/head pair; until they are one + family the cut is chosen by the revision its own ref selects, never by its name.""" + return tuple(client for cut in CutSide.sides() if cut.ref(base, head) == side.ref(base, head) + for client in repo.clients.at(repo.path, base, head, cut)) @staticmethod def selected(client: StackClient) -> list[str]: @@ -285,15 +297,17 @@ def findings( if site.path in selected) @classmethod - def parts(cls, repo: audit.project.Project, base: str, head: str, changed: set[str], + def parts(cls, repo: AgentCommsProject, base: str, head: str, changed: set[str], root: str) -> tuple[Callable[[], object], ...]: - # Only a change to the package the clients import can change what they see. - if not any(path.startswith(f"src/{PACKAGE}/") for path in changed): + # Only a change to the package the clients import can change what they see, unless the + # clients are materialized per side: a companion can change while Core does not. + if not (repo.clients.requires_clients() + or any(path.startswith(f"src/{PACKAGE}/") for path in changed)): return () return tuple(partial(cls.findings, repo, side.ref(base, head), client, side) - for client in cls.clients(repo, head) - if cls.selected(client) - for side in audit.ratchet.Side.sides()) + for side in audit.ratchet.Side.sides() + for client in cls.cut(repo, base, head, side) + if cls.selected(client)) @classmethod def measured( diff --git a/tools/debt_ratchet.py b/tools/debt_ratchet.py index 292acaf2d2..238b877ec2 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 """The debt ratchet's CI entry: this checkout, measured as AgentCommsProject by the refactor-audit it pins. Every measure, the command line, the comparison, its JSON report and its exit status are -the audit's; the one fact this entry owns is which project it measures.""" +the audit's; the one fact this entry owns is which project it measures, and the project reads +where its own stack clients come from.""" from __future__ import annotations from pathlib import Path from agent_comms_project import AgentCommsProject +from agent_comms.installer.commands import InstallCommands from agent_comms.pinned_audit import audit if __name__ == "__main__": - raise SystemExit(audit.ratchet.main(AgentCommsProject(Path.cwd()))) + with InstallCommands() as commands: + project, arguments = AgentCommsProject.from_arguments(Path.cwd(), commands) + raise SystemExit(audit.ratchet.main(project, arguments)) From 4bf40297bded24773db2ce2727b0d3979905ca6b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 23:08:05 -0400 Subject: [PATCH 095/109] Ratchet clients: each cut declares the audit side it materializes ClientTypeErrors.cut matched cuts by revision equality, so when Core's base equals its head both cuts matched each side and every side measured both client cuts (codex-greentext, 9eeed9b6a5f1). head_review.Side now declares the audit side it measures; its ref copy of the base/head choice is deleted (its only caller was that comparison) and the ratchet selects the cut through Side.materializing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/agent_comms/head_review.py | 23 ++++++++++++----------- tools/agent_comms_project.py | 8 +++----- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index 2f8c031c69..08771c9df1 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -18,6 +18,7 @@ from .errors import ToolRefusedError from .field_codec import FieldCodec, GitCommitText, MalformedRecord, RecordProblems from .installer.runtime_resolution import StackSources +from .pinned_audit import audit from .repo import Checkout, GitCommandError, Repo from .repository_name import RepositoryName from .work_branches import WorkBranches @@ -504,17 +505,21 @@ def require_carrier(self, core: Repo, carrier: PullRequestHead) -> None: class Side(DeclaredFamily, affix="Side"): - """One compared source cut; selection and materialized path belong to this member.""" + """One compared source cut; selection and materialized path belong to this member. The + revision it holds is the audit side it materializes, which owns the base/head choice.""" - @staticmethod - @abstractmethod - def ref(base: str, head: str) -> str: - """This side's revision of the compared pair.""" + measures: ClassVar[type[audit.ratchet.Side]] @classmethod def sides(cls) -> tuple[type[Side], ...]: return cls.members_with(cls) + @classmethod + def materializing(cls, measured: type[audit.ratchet.Side]) -> type[Side]: + """The one cut holding the revision an audit side measures.""" + cut, = (side for side in cls.sides() if side.measures is measured) + return cut + @classmethod def checkout(cls, directory: Path, repository: RepositoryName) -> Path: return directory / cls.declared_name / Path(*repository.key) @@ -535,9 +540,7 @@ def require_revision(checkout: Path, source: IntegrationSource) -> None: class BaseSide(Side): - @staticmethod - def ref(base: str, head: str) -> str: - return base + measures = audit.ratchet.BaseSide @staticmethod def require_revision(checkout: Path, source: IntegrationSource) -> None: @@ -546,9 +549,7 @@ def require_revision(checkout: Path, source: IntegrationSource) -> None: class HeadSide(Side): - @staticmethod - def ref(base: str, head: str) -> str: - return head + measures = audit.ratchet.HeadSide @staticmethod def require_revision(checkout: Path, source: IntegrationSource) -> None: diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index f636dee82d..9808641550 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -261,11 +261,9 @@ class ClientTypeErrors(audit.checkers.SideCounts): @staticmethod def cut(repo: AgentCommsProject, base: str, head: str, side: type[audit.ratchet.Side]) -> tuple[StackClient, ...]: - """The clients of the materialized cut that selects the same revision as this compared - side. The audit and head_review each declare the base/head pair; until they are one - family the cut is chosen by the revision its own ref selects, never by its name.""" - return tuple(client for cut in CutSide.sides() if cut.ref(base, head) == side.ref(base, head) - for client in repo.clients.at(repo.path, base, head, cut)) + """The clients of the materialized cut that this compared side measures, selected by + the side each cut declares, so equal base and head revisions still keep two cuts.""" + return repo.clients.at(repo.path, base, head, CutSide.materializing(side)) @staticmethod def selected(client: StackClient) -> list[str]: From 30ad93bb6c59339cc678a8190ceaeec5f8955b39 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 23:08:28 -0400 Subject: [PATCH 096/109] RecipientLimit.schema answers the JsonValue schema its owner declares Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/agent_comms/notification_evidence.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/agent_comms/notification_evidence.py b/src/agent_comms/notification_evidence.py index c567e11019..aeb801921b 100644 --- a/src/agent_comms/notification_evidence.py +++ b/src/agent_comms/notification_evidence.py @@ -11,6 +11,8 @@ from types import MappingProxyType from typing import TYPE_CHECKING, ClassVar +from pydantic import JsonValue + from .declared_family import DeclaredFamily from .field_codec import ( FieldCodec, MalformedRecord, MessageId, NullMember, RecordMember, @@ -52,7 +54,7 @@ def __post_init__(self) -> None: raise ValueError(f"Recipient limit must be between {self.minimum} and {self.maximum}") @classmethod - def schema(cls) -> dict[str, object]: + def schema(cls) -> dict[str, JsonValue]: return {"type": "integer", "minimum": cls.minimum, "maximum": cls.maximum} From dd2a2bd7cbeb0de3fa56c12270b37946f4bf2f58 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 23:12:20 -0400 Subject: [PATCH 097/109] Publication context reads its ids from the tracked user it requires require_publication_context took any NativeEntry and passed Optional input and entry ids into NativeInputLocation. Its one caller already holds the tracked MessageEntry: MessageEntry.require_input_id refuses a missing input, FreshNativeId.require_present answers itself beside NoEntryId's refusal, and NativeEntry.read returns the class it decodes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/agent_comms/native_entries.py | 10 ++++++++-- src/agent_comms/native_transcript.py | 8 +++++--- src/agent_comms/turn_item.py | 4 ++++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/agent_comms/native_entries.py b/src/agent_comms/native_entries.py index 7122d77bcf..c3ab81daff 100644 --- a/src/agent_comms/native_entries.py +++ b/src/agent_comms/native_entries.py @@ -12,7 +12,7 @@ from dataclasses import dataclass, field, replace from datetime import UTC, datetime from pathlib import Path -from typing import TYPE_CHECKING, Annotated, ClassVar, Literal, Never, TextIO, cast +from typing import TYPE_CHECKING, Annotated, ClassVar, Literal, Never, Self, TextIO, cast from .locked_store import DurableFile from .native_pi import ( @@ -176,7 +176,7 @@ def wire_variant(cls, value): return cls @classmethod - def read(cls, raw: bytes) -> NativeEntry: + def read(cls, raw: bytes) -> Self: return cls.from_wire(FieldCodec.parse(raw)) @classmethod @@ -642,6 +642,12 @@ def input_id(self) -> InputId | None: # NativeEntry declares absence as None for every entry; this hands Pi's answer over. return self.message.input_id.value_or(None) + def require_input_id(self) -> InputId: + """A tracked user message names the input it carries; anything else is malformed.""" + for input_id in self.message.input_id.present(): + return input_id + raise MalformedRecord("Native publication requires a tracked input ID") + @property def tracked_user(self) -> MessageEntry | None: for input_id in self.message.input_id.present(): diff --git a/src/agent_comms/native_transcript.py b/src/agent_comms/native_transcript.py index 5901951263..91cf04b112 100644 --- a/src/agent_comms/native_transcript.py +++ b/src/agent_comms/native_transcript.py @@ -26,6 +26,7 @@ from .declared_family import DeclaredFamily from .field_codec import MalformedRecord from .native_entries import ( + MessageEntry, NativeEntry, NoEntryId, RecordedEntry, @@ -334,7 +335,7 @@ def annotation_ids(self, after: int, through: int) -> tuple[FreshNativeId, ...]: for identity in record.annotation_id.present()) def require_publication_context( - self, user: NativeEntry, *, session_entry_start: int, files: NativeSessionFiles, + self, user: MessageEntry, *, session_entry_start: int, files: NativeSessionFiles, ) -> NativeContextProof: """Corroborate the existing native producer's context, without replay. @@ -348,11 +349,12 @@ def require_publication_context( header = SessionEntry.read(stream.readline()) with NativeContextJournal.open_evidence(files.input_proof) as db: try: - proof = NativeContextJournal.for_input(db, user.input_id) + proof = NativeContextJournal.for_input(db, user.require_input_id()) except NativePiUnavailable as missing: raise MalformedRecord("Native publication has no committed context inclusion") from missing return proof.corroborate(self.path.absolute(), NativeInputLocation( - user.input_id, header.id, user.id, session_entry_start=session_entry_start, + user.require_input_id(), header.id, user.id.require_present(), + session_entry_start=session_entry_start, )) def inputs_for(self, records) -> tuple: diff --git a/src/agent_comms/turn_item.py b/src/agent_comms/turn_item.py index 747f1c1a2e..625cc9e217 100644 --- a/src/agent_comms/turn_item.py +++ b/src/agent_comms/turn_item.py @@ -26,6 +26,10 @@ class NativeTurnIdText(NominalSpelling[NativeTurnId]): class FreshNativeId(NominalText): """An opaque harness key Core may mint, with the harness's free text spelling.""" + def require_present(self) -> Self: + """A present entry ID answers itself; NoEntryId owns the refusal.""" + return self + @classmethod def fresh(cls) -> Self: return cls(Id128.mint()) From c75293da520bb39c5e5189eeac79be0f70b6114d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 23:23:53 -0400 Subject: [PATCH 098/109] Ratchet cut keeps its typed tuple over the untyped client source Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- tools/agent_comms_project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index 9808641550..c5b1e8b779 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -263,7 +263,7 @@ def cut(repo: AgentCommsProject, base: str, head: str, side: type[audit.ratchet.Side]) -> tuple[StackClient, ...]: """The clients of the materialized cut that this compared side measures, selected by the side each cut declares, so equal base and head revisions still keep two cuts.""" - return repo.clients.at(repo.path, base, head, CutSide.materializing(side)) + return tuple(repo.clients.at(repo.path, base, head, CutSide.materializing(side))) @staticmethod def selected(client: StackClient) -> list[str]: From 20fbacd5caacdc4fc21709fb486bfcd31776c44b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 23:49:28 -0400 Subject: [PATCH 099/109] ClientTypeErrors measures AgentCommsProject through the audit's project parameter #3630 narrowed ClientTypeErrors.parts(repo: AgentCommsProject) to read repo.clients, and mypy rejected the override against Runner.parts(repo: Project) [override]; CI's debt-tools went red on #3566. The pinned audit now makes Runner, Compared and SideCounts generic in the project they read (refactor-audit fix/generic-project-runner b80510df), so the measure declares SideCounts[AgentCommsProject] and its parts takes the project it actually reads, with no narrowing. mypy (the TypeErrors measure's invocation) on tools/agent_comms_project.py: 1 error before, 0 after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/agent_comms/skills/refactor-audit | 2 +- tools/agent_comms_project.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/skills/refactor-audit b/src/agent_comms/skills/refactor-audit index c69c4daf17..b80510df32 160000 --- a/src/agent_comms/skills/refactor-audit +++ b/src/agent_comms/skills/refactor-audit @@ -1 +1 @@ -Subproject commit c69c4daf175aa469545186b46df0c38e9a27fb44 +Subproject commit b80510df326823a99e4bfe018ef484e44394b1e8 diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index c5b1e8b779..553bc8d858 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -251,7 +251,7 @@ def grew(self, grown: int) -> str: "that client file uses; run mypy on it to see which") -class ClientTypeErrors(audit.checkers.SideCounts): +class ClientTypeErrors(audit.checkers.SideCounts[AgentCommsProject]): """Mypy errors in each stack client file that imports agent_comms, checked against each revision's package. Both sides read the same client commit, so the client's own errors cancel and an error the head adds is a name or signature the client uses that the change took away: From 7924d42cc9f1dc0cc383552fa5e05e4c4f16ac8f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 00:08:36 -0400 Subject: [PATCH 100/109] Move notification acquisition to its captured source window Delete the presentation-to-reader cycle and its local imports. Decode packaging markers through declared MRO handlers; retain canonical-name validation without a nullable match probe. No author tests or runtime operations. --- .../installer/runtime_resolution.py | 31 ++++++++++++----- src/agent_comms/participant_identity.py | 2 +- src/agent_comms/thread_presentation.py | 34 ++----------------- src/agent_comms/transcripts.py | 27 ++++++++++++++- 4 files changed, 52 insertions(+), 42 deletions(-) diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index d157d29cc3..79ec1bbc1c 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Annotated, ClassVar, Self from urllib.parse import parse_qsl, urlsplit +from metaclass_registry.mro_dispatch import MroDispatch, handles from packaging.markers import Marker, default_environment from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet @@ -372,6 +373,25 @@ def materialize(self, original: Path, core: Path, self.tool.uv.sources[name] = source.materialize(original, core, projects, canonicalize_name(name)) +class RequirementMarker(MroDispatch): + """Decode packaging's optional marker only where its external value enters.""" + + @classmethod + @boundary_decoder + def active(cls, marker: object, environment: dict[str, str], extras: Iterable[str]) -> bool: + return bool(cls.handler_for(type(marker))(marker, environment, extras)) + + @handles(type(None)) + @staticmethod + def unconditional(marker: object, environment: dict[str, str], extras: Iterable[str]) -> bool: + return True + + @handles(Marker) + @staticmethod + def conditional(marker: Marker, environment: dict[str, str], extras: Iterable[str]) -> bool: + return any(marker.evaluate({**environment, "extra": extra}) for extra in extras) + + class StackSources: """Branch-tracking source declarations shared by installation and source checking.""" @@ -441,15 +461,9 @@ def virtual_requirements(cls, document: dict[str, object], environment: dict[str ProjectMetadata.require_python(python) return tuple(requirement for requirement in map( Requirement, FieldCodec.decode(tuple[str, ...], dependencies)) - if cls.requirement_active(requirement, environment, ("",))) + if RequirementMarker.active(requirement.marker, environment, ("",))) raise ValueError("A virtual runtime root requires tool.uv.package=false and project dependencies") - @staticmethod - @boundary_decoder - def requirement_active(requirement: Requirement, environment: dict[str, str], extras: Iterable[str]) -> bool: - return requirement.marker is None or any( - requirement.marker.evaluate({**environment, "extra": extra}) for extra in extras) - @classmethod def active_requirements(cls, document: dict[str, object], projects: tuple[ProjectMetadata, ...], environment: dict[str, str]) -> tuple[Requirement, ...]: @@ -479,7 +493,8 @@ def active_requirements(cls, document: dict[str, object], projects: tuple[Projec if not added: continue previous.update(added) - pending.extend(edge for edge in project.requirements if cls.requirement_active(edge, environment, added)) + pending.extend(edge for edge in project.requirements + if RequirementMarker.active(edge.marker, environment, added)) return tuple(sorted(active, key=str)) @staticmethod diff --git a/src/agent_comms/participant_identity.py b/src/agent_comms/participant_identity.py index e898bf1644..d8d2c0cbed 100644 --- a/src/agent_comms/participant_identity.py +++ b/src/agent_comms/participant_identity.py @@ -23,7 +23,7 @@ def require_text(value: str, label: str) -> None: @classmethod def require_canonical(cls, value: str, label: str) -> None: cls.require_text(value, label) - if re.fullmatch(r"[a-zA-Z0-9_-]+", value) is None: + if not re.fullmatch(r"[a-zA-Z0-9_-]+", value): raise MalformedRecord(f"{label} must be a canonical thread name") @property diff --git a/src/agent_comms/thread_presentation.py b/src/agent_comms/thread_presentation.py index ffb8400d36..cba9305100 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from .message_reference import MessageReference from .presentation import MessageNotification - from .transcripts import TranscriptReadIdentity, TranscriptSourceWindow + from .transcripts import TranscriptReadIdentity from agent_comms.registry_document import RegistrySnapshot from agent_comms.transcripts import Transcripts @@ -248,7 +248,7 @@ def read_sources(self, transcripts: Transcripts, snapshot: RegistrySnapshot, ) if not captured.read.identity.thread.incarnation.current(snapshot): raise StaleRevision("Thread source incarnation changed during presentation") - return self.with_sources(captured) + return captured.presentation(self) def same_observation(self, other: ThreadPresentation) -> bool: """Equal view and turn evidence, not merely equal global store revisions. @@ -264,33 +264,6 @@ def same_observation(self, other: ThreadPresentation) -> bool: return original is current 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, read - as the thread's owner may see them; the incoming outcomes carry public envelopes.""" - from .notification_assignment import NotificationAssignment - from .presentation import MessageNotification - from .notification_evidence import OwnerOutcomes - - transcripts = window.read.owner - sources = tuple({source.message.reference: source - for source in (*window.sources, *window.sent_sources)}.values()) - viewer = OwnerOutcomes.of(window.read.identity.thread.incarnation) - notification_read = NotificationAssignment.read(transcripts.root, viewer.selection(sources)) - projected = MessageNotification.delivery_window( - transcripts.root, transcripts.registry, notification_read) - incoming = tuple( - replace(notification, message=source.envelope) - for source in window.sources - for notification in projected.outcomes[source.message.seq, source.message.message_id] - ) - 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)) - @property def label(self) -> str: return f"{self.marker} {self.title}".strip() @@ -349,6 +322,3 @@ def read_sources(self, transcripts: Transcripts, snapshot: RegistrySnapshot, name: str, sent_source_limit: int) -> ThreadPresentation: return self - def with_sources(self, window: TranscriptSourceWindow) -> ThreadPresentation: - return self - diff --git a/src/agent_comms/transcripts.py b/src/agent_comms/transcripts.py index 30cb6c3852..b13f81692b 100644 --- a/src/agent_comms/transcripts.py +++ b/src/agent_comms/transcripts.py @@ -27,7 +27,10 @@ from .native_runtime_input import PublishedReplyRevision from .native_transcript import NativeTranscript, RecordsCut, SessionCut from .native_output import NativeRecordLocation -from .notification_evidence import NotificationWindow +from .notification_assignment import NotificationAssignment +from .notification_evidence import NotificationWindow, OwnerOutcomes +from .presentation import MessageNotification +from .thread_presentation import AcquiredThreadPresentation, ThreadPresentation from .private_path import FileObservation, FileRevision from .private_registry_guard import RegistryPrivacy from .recorded_bus import RecordedBusIdentity @@ -640,6 +643,28 @@ class TranscriptSourceWindow: sources: tuple[Delivered, ...] sent_sources: tuple[Delivered, ...] + def presentation(self, ordinary: ThreadPresentation) -> AcquiredThreadPresentation: + """Project incoming and sent outcomes from this one acquired source window.""" + transcripts = self.read.owner + sources = tuple({source.message.reference: source + for source in (*self.sources, *self.sent_sources)}.values()) + viewer = OwnerOutcomes.of(self.read.identity.thread.incarnation) + notification_read = NotificationAssignment.read(transcripts.root, viewer.selection(sources)) + projected = MessageNotification.delivery_window( + transcripts.root, transcripts.registry, notification_read) + incoming = tuple( + replace(notification, message=source.envelope) + for source in self.sources + for notification in projected.outcomes[source.message.seq, source.message.message_id] + ) + shown = replace(ordinary, notifications=incoming, read_identity=self.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 self.sources), + sent_references=tuple(source.message.reference for source in self.sent_sources)) + class Transcripts: def __init__(self, root: Path, registry: Registration, bus: MessageBus, messaging: Messaging): From 8ebfc1ee9e9bfe8b2aac388a604dedb5668512f6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 01:24:41 -0400 Subject: [PATCH 101/109] Expose native proof imports and use nominal recipient contracts --- src/agent_comms/agent_activity.py | 4 ++-- src/agent_comms/agent_backend.py | 2 +- src/agent_comms/backend.py | 13 ++++++------- src/agent_comms/commander/judge.py | 5 +++-- src/agent_comms/commander/order.py | 2 +- src/agent_comms/following_inputs.py | 2 +- src/agent_comms/input_disposition.py | 2 +- src/agent_comms/installer/runtime_resolution.py | 2 +- src/agent_comms/native_input_record.py | 2 +- src/agent_comms/native_prompt_send.py | 4 ++-- src/agent_comms/notification_read.py | 4 ++-- src/agent_comms/owned_send.py | 2 +- src/agent_comms/owned_turn.py | 2 +- src/agent_comms/owner_connection.py | 2 +- src/agent_comms/participant_identity.py | 5 +++++ src/agent_comms/pi_commands.py | 7 +++---- src/agent_comms/presentation.py | 6 +++--- src/agent_comms/private_sidecar.py | 2 +- src/agent_comms/prompt_state.py | 7 ++++--- src/agent_comms/read_basis.py | 1 - src/agent_comms/registry_document.py | 6 +++--- src/agent_comms/registry_provenance.py | 8 +++----- src/agent_comms/tracked_turn.py | 6 +++--- tests/test_native_transcript.py | 2 +- 24 files changed, 50 insertions(+), 48 deletions(-) diff --git a/src/agent_comms/agent_activity.py b/src/agent_comms/agent_activity.py index 92daf5aa68..0c703c61bc 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -32,7 +32,7 @@ NoDrainDiagnostic, ObservedActivity, ) -from .audience_manifest import FrozenRecipient +from .participant_identity import FrozenRecipientIdentity from .coordinator import Coordination from .declared_family import DeclaredFamily from .errors import RelationViolationError @@ -169,7 +169,7 @@ def captured(self, threads: Iterable[Thread], *, return ActivityCapture(observed, events.expires_at) def observe_recipients( - self, recipients: Iterable[tuple[Delivered, FrozenRecipient]], *, + self, recipients: Iterable[tuple[Delivered, FrozenRecipientIdentity]], *, snapshot: RegistrySnapshot, ) -> Mapping[ThreadLookup, RecipientActivity]: """Acquire one bounded window's recipients from the original sends.""" diff --git a/src/agent_comms/agent_backend.py b/src/agent_comms/agent_backend.py index f0269c7f56..5542a136b6 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -28,6 +28,7 @@ from .declared_family import DeclaredFamily from .field_codec import InputId from .image_input import ImageInput +from .native_input_proof import NativeContextProof from .pi_counts import PiCount from .pi_vocabulary import CompactionReason, ThinkingLevelField from .session_revision import NoSessionObservation, SessionObservation @@ -41,7 +42,6 @@ from .agent_events import AgentEvent from .launch_runtime import LaunchRuntime - from .native_input_proof import NativeContextProof from .owned_turn import TurnDelivery from .threads import Thread from .transcript_sources import TranscriptSource diff --git a/src/agent_comms/backend.py b/src/agent_comms/backend.py index 2609d3a0cc..0ca732e52b 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -48,6 +48,9 @@ from .acp_failure import UnrecordedFailureSource from .agent_backend import NO_INPUT_START_LISTENER, InputStartListener, TurnInbox from .coordinator import Coordination +from .cursor_publication import CursorDelivery +from .native_entries import NativeEvidenceScope +from .native_session_files import NativeSessionFiles from .extension_ui import ExtensionUiSession from .field_codec import InputId, MalformedRecord, boundary_decoder from .image_input import ImageInput @@ -55,9 +58,9 @@ from .native_child import PiSessionChild from .native_failures import NativePiUnavailable from .native_package import VerifiedNativePackage -from .native_input_proof import NativeContextProof +from .native_input_proof import NativeContextProof, NativeInputCommit from .native_pi import NativePiRpcLaunch, read_tracked_input_commitment -from .native_session_reopen import SessionRetention +from .native_session_reopen import PiSessionId, SessionRetention from .native_startup import NATIVE_STARTUP_POLICY, NativeStartup from .native_tool_call import NativeToolMode from .native_tool_transport import ToolTransport, UnboundToolTransport @@ -78,10 +81,6 @@ if TYPE_CHECKING: from .acp_failure import ProviderRetryResult - from .cursor_publication import CursorDelivery - from .native_entries import NativeEvidenceScope - from .native_session_files import NativeSessionFiles - from .native_session_reopen import PiSessionId from .pi_native_backend import PersistentPiSession from .pi_payloads import AssistantMessage, UserMessage @@ -693,7 +692,7 @@ def following_started(self, command: commands.FollowingPrompt) -> None: raise NativePiUnavailable("A tracked following input reused an in-scope native identity") self.contexts[command.input_id] = TrackedInputContext(command) - async def original_committed(self, event: pi.InputCommitted) -> None: + async def original_committed(self, event: NativeInputCommit) -> None: """An ordinary original already started through its matched MessageStart.""" async def original_context_committed( diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 19f9a034e2..cf1989294d 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -473,8 +473,9 @@ def head_evidence(self) -> tuple[FileLine, ...]: def review_head(self, ctx: order.CommanderContext, request: PullRequestHead, linked: HeadReviewOrder) -> HeadReviewed: - return ctx.call(CommsHeadVerdictTool(request=request, - operation=self.head_operation(), order=linked)) + return CommsHeadVerdictTool( + request=request, operation=self.head_operation(), order=linked, + ).apply(ctx.comms, ctx.actor.as_thread(ctx.commander)) @abstractmethod def head_operation(self) -> HeadVerdict: ... diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 8182ed62d5..4e8a7fa833 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -1142,7 +1142,7 @@ def run(self, ctx: Commander, todo: Todo, source: Repo, title: str, body_file: str) -> str: if not title or not body_file: raise ValueError("Creating a PR requires --title and --body-file.") - if not source.holds(todo): + if source.github != todo.repo: sys.exit(f'Refused: a new PR is created in {todo.repo.to_wire()}, not ' f'{source.github.to_wire()}; adopt a companion PR with --existing.') return todo.order.checked().open_pull_request( diff --git a/src/agent_comms/following_inputs.py b/src/agent_comms/following_inputs.py index 2b6d31ec8e..56f2c049aa 100644 --- a/src/agent_comms/following_inputs.py +++ b/src/agent_comms/following_inputs.py @@ -11,6 +11,7 @@ from .agent_backend import TurnInbox from .coordinator import Coordination from .queued_input import DeliveredInput, InputHandoffRefused +from .owned_send import OwnedSend from .thread_identity import ClaimedIdentity from .turn_input_source import AcceptedFollowingInput @@ -20,7 +21,6 @@ from .turn_runner import TurnRunner from .input_drain import InputDrain from .tracked_turn import TrackedTurnSession - from .owned_send import OwnedSend class FollowingInputs: diff --git a/src/agent_comms/input_disposition.py b/src/agent_comms/input_disposition.py index 84303d1135..bb12f16b9e 100644 --- a/src/agent_comms/input_disposition.py +++ b/src/agent_comms/input_disposition.py @@ -36,6 +36,7 @@ from .lifecycle_capability import Unresolved from .locked_store import DocumentChange from .message_reference import MessageReference +from .native_input_proof import NativeContextProof from .messages import MessageInput from .reported_turn import ReportedTurnState from .tool_result import ToolResult @@ -47,7 +48,6 @@ from .bus import BusReader from .channel_input_batch import SingleInputBatch from .private_registry_guard import RegistryPrivacy - from .native_input_proof import NativeContextProof from .registry_document import RegistryEntry, RegistrySnapshot from .threads import Thread from .turn_lease import TurnLeaseFence diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index 79ec1bbc1c..3db55595f7 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -30,6 +30,7 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec, GitCommitText, boundary_decoder from agent_comms.installer.probes.packages import ArchivePin, GitPin, PackagePin, PackageRecord +from agent_comms.installer.commands import InstallCommands from agent_comms.installer.probes.packages import DistributionVersionsProbe, InstalledPackagesProbe from agent_comms.launch_runtime import LaunchRuntime from agent_comms.repo import Repo @@ -37,7 +38,6 @@ from agent_comms.work_branches import WorkBranches if TYPE_CHECKING: - from agent_comms.installer.commands import InstallCommands from agent_comms.installer.upgrade import Stack diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index 6facbad92d..ff8e1bd017 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -32,6 +32,7 @@ NativeContext, ) from .native_integer import PositiveNativeIntegerBounds +from .native_input_proof import NativeContextProof from .native_session_reopen import PiSessionId from .transcript_routes import InputDisplay, NoDisplayText from .typed_table import SqlStorage, TextStorage @@ -48,7 +49,6 @@ from .message_reference import MessageReference from .native_entries import MessageEntry, RecordedEntry, TranscriptProjection from .native_runtime_input import NativeRuntimeInput - from .native_input_proof import NativeContextProof from .transcript_events import TranscriptEvent diff --git a/src/agent_comms/native_prompt_send.py b/src/agent_comms/native_prompt_send.py index f657a3be6a..201dfb6ae6 100644 --- a/src/agent_comms/native_prompt_send.py +++ b/src/agent_comms/native_prompt_send.py @@ -19,6 +19,8 @@ from contextlib import AbstractContextManager, ExitStack from typing import TYPE_CHECKING from .publication_measurements import PublicationMeasurements +from .native_input_proof import NativeContextProof +from .native_entries import NativeEvidenceScope from .field_codec import AcpSessionId from .coordination_errors import CoordinationError from agent_comms.errors import RelationViolationError @@ -26,8 +28,6 @@ if TYPE_CHECKING: from .input_drain import InputDrain from .owned_send import OwnedSend - from .native_input_proof import NativeContextProof - from .native_entries import NativeEvidenceScope from .tracked_turn import TrackedTurnSession _MAX_SEND_SECONDS = 5.0 diff --git a/src/agent_comms/notification_read.py b/src/agent_comms/notification_read.py index 9820c7b10a..46540498c6 100644 --- a/src/agent_comms/notification_read.py +++ b/src/agent_comms/notification_read.py @@ -8,14 +8,14 @@ from typing import TYPE_CHECKING from .notification_assignment import NotificationAssignment +from .activity import ObservedActivity +from .registry_provenance import DeletedThread 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) diff --git a/src/agent_comms/owned_send.py b/src/agent_comms/owned_send.py index 24e75cd9a3..a0e1be4447 100644 --- a/src/agent_comms/owned_send.py +++ b/src/agent_comms/owned_send.py @@ -28,6 +28,7 @@ from .field_codec import AcpSessionId, ClaimId, InputId, TurnId from .input_attempt import InputAlreadyCleared from .reservation_rules import ReservationRule, ReservationViolationError +from .native_input_proof import NativeContextProof from .tool_denial import SelectedToolDenied from .turn_input_source import ( OriginalTurnInput, @@ -40,7 +41,6 @@ from .goal_waits import GoalWait from .input_drain import InputDrain - from .native_input_proof import NativeContextProof from .registry_document import RegistrySnapshot from .threads import Thread from .turn_goal_permission import TurnGoalPermission diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index 73e66a99a4..c037b35f80 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -62,10 +62,10 @@ ) from .turn_phase import CancellingPhase, PromptAcceptancePhase from .turn_progress import TurnProgress +from .cursor_publication import CursorDelivery from .turn_terminal import EndedWithoutResult, TurnTerminal if TYPE_CHECKING: - from .cursor_publication import CursorDelivery from .turn_runner import TurnRunner diff --git a/src/agent_comms/owner_connection.py b/src/agent_comms/owner_connection.py index 54f2d386f7..ca8aebfc32 100644 --- a/src/agent_comms/owner_connection.py +++ b/src/agent_comms/owner_connection.py @@ -23,12 +23,12 @@ from .jsonl_stream import JsonlStreamReader from .owner_protocol import OwnerLine, OwnerResult from .private_path import PrivateSocketRole +from .thread_identity import OwnerIdentity, ThreadIncarnation if TYPE_CHECKING: from .comms import Comms from .child_process import ProcessIdentity from .registry_document import RegistryEntry - from .thread_identity import OwnerIdentity, ThreadIncarnation from .runtime import RuntimeServer, SocketClient from .threads import Thread diff --git a/src/agent_comms/participant_identity.py b/src/agent_comms/participant_identity.py index d8d2c0cbed..f94be788b5 100644 --- a/src/agent_comms/participant_identity.py +++ b/src/agent_comms/participant_identity.py @@ -45,3 +45,8 @@ def __post_init__(self) -> None: @property def lookup(self) -> ThreadLookup: return self.recipient_lookup + + def same_identity(self, other: FrozenRecipientIdentity) -> bool: + """Bus writers and detached readers name the same send-time participant.""" + return (self.recipient_lookup == other.recipient_lookup + and self.canonical_thread == other.canonical_thread) diff --git a/src/agent_comms/pi_commands.py b/src/agent_comms/pi_commands.py index 404b72246d..51e44be7b7 100644 --- a/src/agent_comms/pi_commands.py +++ b/src/agent_comms/pi_commands.py @@ -29,6 +29,7 @@ ) from .image_input import ImageInput from .native_failures import NativePiUnavailable +from .native_input_proof import NativeContextProof, NativeInputCommit from .native_package import VerifiedNativePackage from .native_pi import NativePiRpcLaunch from .native_session_reopen import NativeSessionIdentity @@ -59,8 +60,6 @@ from .pending_requests import PendingRequest from .launch_runtime import RestartEnvironment from .native_entries import NativeEvidenceScope - from .native_input_proof import NativeContextProof - from .pi_events import InputCommitted class SessionSnapshot: @@ -213,7 +212,7 @@ class Prompt(PiCommand): WireName("streamingBehavior"), ] = None - async def input_committed(self, session: TurnSession, event: InputCommitted) -> None: + async def input_committed(self, session: TurnSession, event: NativeInputCommit) -> None: await session.original_committed(event) async def context_committed( @@ -251,7 +250,7 @@ class FollowingPrompt(Prompt, declared_name=None): input: Annotated[InputRequest, WireTransient()] - async def input_committed(self, session: TurnSession, event: InputCommitted) -> None: + async def input_committed(self, session: TurnSession, event: NativeInputCommit) -> None: """This accepted following input cannot restart the original prompt or inbox.""" async def context_committed( diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index c1bcbc4c6b..26d5d2e984 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -18,7 +18,7 @@ AgentActivity, RecordedRecipientActivity, ) -from .audience_manifest import FrozenRecipient +from .participant_identity import FrozenRecipientIdentity from .bus import DisplayUnread from .channel_targets import CommandTarget from .channels import Channel, ChannelMemberIndex, Channels @@ -124,7 +124,7 @@ class MessageNotification: """Read-only display of one recipient's durable assignment decision.""" - recipient_identity: FrozenRecipient + recipient_identity: FrozenRecipientIdentity sender_lookup: ThreadLookup state: DeliveryProgress message: Message | None = None @@ -315,7 +315,7 @@ def direct_read_notification( return CapturedNotificationWindow({}, ()) peer = CommandTarget.named(message.target).direct_peer(snapshot) for target in () if peer is None else peer.role.human(peer): - recipient = FrozenRecipient(target.incarnation.lookup, target.name) + recipient = FrozenRecipientIdentity(target.incarnation.lookup, target.name) displayed_to = reads.displayed_recipient( source, recipient, snapshot, document=document ) diff --git a/src/agent_comms/private_sidecar.py b/src/agent_comms/private_sidecar.py index de282f7d8d..6be9eb585b 100644 --- a/src/agent_comms/private_sidecar.py +++ b/src/agent_comms/private_sidecar.py @@ -30,10 +30,10 @@ from agent_comms.locked_store import DurableFile, SQLiteFile from .field_codec import FieldCodec +from .image_input import ImageInput if TYPE_CHECKING: from .agent_backend import StreamingInputPolicy - from .image_input import ImageInput from .private_path import ObservedPath, PrivateFileRole, TrustedAncestorRole from .store_files import ExclusiveLock, StoreOwner diff --git a/src/agent_comms/prompt_state.py b/src/agent_comms/prompt_state.py index 362294be9d..525d2c84f4 100644 --- a/src/agent_comms/prompt_state.py +++ b/src/agent_comms/prompt_state.py @@ -8,10 +8,11 @@ from . import turn_failure as failures from .agent_backend import TurnInboxItem from .native_pi import NativePiInputNotSent +from .native_input_proof import NativeInputCommit if TYPE_CHECKING: from .backend import NativeSession - from .pi_events import AgentSettled, InputCommitted, MessageStart, Response + from .pi_events import AgentSettled, MessageStart, Response class PromptState: @@ -28,7 +29,7 @@ def raise_native_failure(self, error, attestation) -> Never: def acknowledge(self, response: Response) -> PromptState: return self - def start(self, event: MessageStart | InputCommitted) -> PromptState: + def start(self, event: MessageStart | NativeInputCommit) -> PromptState: return self def settle(self, event: AgentSettled) -> PromptState: @@ -93,7 +94,7 @@ def start(self, event): @dataclass(frozen=True) class StartedPrompt(AcknowledgedPrompt): - input_start: MessageStart | InputCommitted + input_start: MessageStart | NativeInputCommit started = True awaiting_start = False diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 9a9984c918..cb515e7132 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -348,7 +348,6 @@ def flush(self) -> None: if TYPE_CHECKING: - from .threads import ThreadProvenance import sqlite3 from .private_registry_guard import RegistryPrivacy diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index f9c6f22506..71ee242852 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -31,6 +31,7 @@ WireRequired, WireValue, ) +from .participant_identity import FrozenRecipientIdentity from .registration_change import ( InitialRegistration, RegistrationChange, @@ -79,7 +80,6 @@ if TYPE_CHECKING: from .activity import ObservedActivity from .agent_activity import AgentActivity - from .audience_manifest import FrozenRecipient from .bus_publication import Delivered from .rule_deal import DealtSources, RuleDeal @@ -1209,7 +1209,7 @@ def displayed_recipient_effect(self, reads, message, thread: Thread, *, document return thread.role.displayed_recipient_effect(reads, message, thread, self, document) def recipient( - self, recipient: FrozenRecipient, original: Delivered + self, recipient: FrozenRecipientIdentity, original: Delivered ) -> RegisteredRecipient | RetiredRecipient: """A missing live thread was retired only if the original send names it.""" lookup = recipient.recipient_lookup @@ -1218,7 +1218,7 @@ def recipient( if thread.name != self.canonical_name(recipient.canonical_thread): raise RelationViolationError("Frozen recipient name does not match its lookup.") return RegisteredRecipient(thread) - if recipient not in original.recipients: + if not any(recipient.same_identity(frozen) for frozen in original.recipients): raise UnregisteredThreadError("Recipient lookup is not in the original message.") return RetiredRecipient(lookup) diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index adfb14e5d2..2a35cc3b53 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, replace from functools import cached_property, partial from pathlib import Path -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import Generic, TypeVar from agent_comms.field_codec import FieldCodec, ThreadLookup @@ -19,9 +19,7 @@ from .routing import DeliveryScope from .thread_identity import ThreadIncarnation from .threads import Thread, ThreadProvenance - -if TYPE_CHECKING: - from .audience_manifest import FrozenRecipient +from .participant_identity import FrozenRecipientIdentity Member = TypeVar("Member", bound=ThreadProvenance, covariant=True) @@ -239,7 +237,7 @@ def unread_since( def displayed_recipient_effect(self, reads, message, thread: ThreadProvenance, *, document=None): return reads.displayed_incarnation(message, thread.incarnation, self, document=document) - def historical_recipient(self, recipient: FrozenRecipient) -> ArchivedRecipient | RetiredRecipient: + def historical_recipient(self, recipient: FrozenRecipientIdentity) -> ArchivedRecipient | RetiredRecipient: """Archives select by original name and birth; duplicate births stay distinct.""" thread = self.threads.get(self.canonical_name(recipient.canonical_thread)) if thread is not None and thread.incarnation.selects_lookup(recipient.recipient_lookup): diff --git a/src/agent_comms/tracked_turn.py b/src/agent_comms/tracked_turn.py index 724c9c1950..3e3e8291c0 100644 --- a/src/agent_comms/tracked_turn.py +++ b/src/agent_comms/tracked_turn.py @@ -27,7 +27,8 @@ from .native_attestation import AttestationError, ObservedAttestation from .native_failures import NativePiUnavailable from .native_package import VerifiedNativePackage -from .native_input_proof import NativeContextProof +from .native_input_proof import NativeContextProof, NativeInputCommit +from .native_entries import NativeEvidenceScope from .native_pi import ( NativeEmptyAnswer, NativePiPromptRejected, @@ -52,7 +53,6 @@ from .coordinated_runtime import NativeExecution from .cursor_publication import CursorDelivery from .owned_send import OwnedSend - from .native_entries import NativeEvidenceScope from .pi_native_backend import PersistentPiSession from .pi_payloads import UserMessage from .turn_failure import TurnFailure @@ -439,7 +439,7 @@ async def send_prompt(self) -> None: def reject_prompt(self, response): raise NativePiPromptRejected(response) - async def original_committed(self, event: pi.InputCommitted) -> None: + async def original_committed(self, event: NativeInputCommit) -> None: self.prompt_state = self.prompt_state.start(event) await self.following.open(self) diff --git a/tests/test_native_transcript.py b/tests/test_native_transcript.py index 11db39a0a8..b99a31ba79 100644 --- a/tests/test_native_transcript.py +++ b/tests/test_native_transcript.py @@ -14,7 +14,7 @@ ) from agent_comms.native_entries import MessageEntry, NativeEntry, TranscriptProjection, UnknownEntry from agent_comms.native_transcript import NativeTranscript -from agent_comms.native_pi import NativeContextProof +from agent_comms.native_input_proof import NativeContextProof from agent_comms.native_session_reopen import PiSessionId from agent_comms.pi_payloads import UserMessage from agent_comms.transcript_events import ( From ba453b2654a115af8f4053a57700b976e7878e54 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 01:37:59 -0400 Subject: [PATCH 102/109] Finish runtime evidence imports after removing the event dependency --- src/agent_comms/pi_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/pi_commands.py b/src/agent_comms/pi_commands.py index 51e44be7b7..1365fba52e 100644 --- a/src/agent_comms/pi_commands.py +++ b/src/agent_comms/pi_commands.py @@ -30,6 +30,7 @@ from .image_input import ImageInput from .native_failures import NativePiUnavailable from .native_input_proof import NativeContextProof, NativeInputCommit +from .native_entries import NativeEvidenceScope from .native_package import VerifiedNativePackage from .native_pi import NativePiRpcLaunch from .native_session_reopen import NativeSessionIdentity @@ -59,7 +60,6 @@ from .pi_events import Response, ResponseOutcome from .pending_requests import PendingRequest from .launch_runtime import RestartEnvironment - from .native_entries import NativeEvidenceScope class SessionSnapshot: From e2685d91cee61103698a0a383c9aa4ea2f3b0a42 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 01:52:44 -0400 Subject: [PATCH 103/109] Keep checker source identity independent of its executor and finish evidence callers --- src/agent_comms/notification_assignment.py | 2 +- tests/test_outbound_transcript_source.py | 6 +++--- tests/test_recipient_activity_index.py | 2 +- tools/module_layers.py | 5 +++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index 301514d828..e22f109f92 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -231,7 +231,7 @@ def capture_responses(cls, root: Path, read: NotificationAssignmentRead) -> Noti for answered in reply.message.answers: key = (reply.sender_lookup, answered) if key in keys: - responses[key] = responses.get(key, UnansweredResponse()).recorded(reply.message) + responses[key] = responses.get(key, UnansweredResponse()).recorded(reply) return replace(read, responses=MappingProxyType(responses)) @classmethod diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index fc064bfe71..851086d11f 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -89,8 +89,8 @@ def count_decodes(frame, event, argument): assert own.selection.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() + assert own.evidence[reference].consumption == { + recipient: attempts for recipient, attempts in everyone.evidence[reference].consumption.items() if recipient == lookup} sender = NotificationAssignment.read(root, OwnerOutcomes(lookups["sender"]).selection((initial,))) assert sender.rows == everyone.rows @@ -404,7 +404,7 @@ def test_a_forced_key_batch_captures_the_same_evidence_through_the_recipient_ind assert tuple(value for batch in batches for value in batch.bindings) == unlimited.bindings assert limited.selection.viewer == AllOutcomes() and limited.sources == sources assert limited.rows == ordinary.rows - assert limited.attempts == ordinary.attempts + assert limited.consumption == ordinary.consumption 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] diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 81baaa562b..cfd72102f0 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -130,7 +130,7 @@ def test_a_public_root_without_a_coordinator_keeps_its_viewer_and_projects_visib CoordinationStore.path_in(comms.root).unlink(missing_ok=True) viewer = OwnerOutcomes.of(comms.registry.require("peer-a").incarnation) read = NotificationAssignment.read(comms.root, viewer.selection((original,))) - assert (read.sources, read.rows, read.attempts, read.selection.viewer) == ((original,), (), (), viewer) + assert (read.sources, read.rows, read.consumption, read.selection.viewer) == ((original,), (), {}, viewer) window = MessageNotification.delivery_window(comms.root, comms.registry, read) assert {item.recipient for item in window.outcomes[(sent.seq, sent.message_id)]} == {"peer-a"} diff --git a/tools/module_layers.py b/tools/module_layers.py index 099aada51c..07d1cb2665 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -36,7 +36,7 @@ import typing from abc import ABC, abstractmethod from collections.abc import Iterable, Sequence -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from functools import cache, cached_property from pathlib import Path @@ -429,7 +429,8 @@ class MaterializedClients(ClientSources): inputs: IntegrationInput uv: Path python: Path - commands: InstallCommands + # The executor's lifetime is operational; source cuts and their interpreters identify this input. + commands: InstallCommands = field(compare=False, repr=False) @classmethod def read(cls, directory: Path, *, uv: Path, python: Path, From 01c903430cd3d12ab8d97d667dba200dd3cb7152 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 01:56:52 -0400 Subject: [PATCH 104/109] Compare detached evidence on both sides of the bounded capture control --- tests/test_outbound_transcript_source.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index 851086d11f..7cc28d799d 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -398,6 +398,7 @@ def test_a_forced_key_batch_captures_the_same_evidence_through_the_recipient_ind plans = [[row[3] for row in connection.execute(f"EXPLAIN QUERY PLAN {statement}")] for statement in claim_statements] + limited = NotificationAssignment.capture_detached(root, limited) assert len(batches) > 1 assert all(len(batch.bindings) <= bound for batch in batches) assert sum(len(batch.bindings) for batch in batches) == key_fields * len(distinct) From a16eee938fd93afb9cffba1452f7678ac9c8528e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 02:11:49 -0400 Subject: [PATCH 105/109] Own metadata acquisition lifetime without carrying executors into measured source --- tests/integration_source_fixture.py | 14 ++++---- tools/agent_comms_project.py | 5 ++- tools/checker_stack.py | 2 +- tools/module_layers.py | 50 ++++++++++++++--------------- tools/nra_gate.py | 6 ++-- 5 files changed, 35 insertions(+), 42 deletions(-) diff --git a/tests/integration_source_fixture.py b/tests/integration_source_fixture.py index 9235facbf9..df364d84b6 100644 --- a/tests/integration_source_fixture.py +++ b/tests/integration_source_fixture.py @@ -1,6 +1,6 @@ """One real Git/forge/Todo setup for matched source capture and promotion controls.""" -from collections.abc import Callable, Iterator +from collections.abc import Callable from dataclasses import dataclass, replace from pathlib import Path import sys @@ -12,7 +12,6 @@ from agent_comms.comms import Comms from agent_comms.goal_actions import CreateProblemGoalAction from agent_comms.head_review import CombinedInput, IntegrationInput, IntegrationSource, PullRequestHead -from agent_comms.installer.commands import InstallCommands from agent_comms.integration import IntegrationCheckout, IntegrationRun from agent_comms.recorded_time import RecordedEventTime from agent_comms.repo import Repo @@ -113,12 +112,11 @@ def source_pair( @pytest.fixture -def materialized_clients(test_uv: Path) -> Iterator[Callable[[Path, IntegrationInput], MaterializedClients]]: - """The root test run supplies the actual frontend; every test owns one command lifetime.""" - with InstallCommands() as commands: - def acquire(directory: Path, inputs: IntegrationInput) -> MaterializedClients: - return MaterializedClients(directory, inputs, test_uv, Path(sys.executable), commands) - yield acquire +def materialized_clients(test_uv: Path) -> Callable[[Path, IntegrationInput], MaterializedClients]: + """The root test run supplies the frontend; metadata acquisition owns its command lifetime.""" + def acquire(directory: Path, inputs: IntegrationInput) -> MaterializedClients: + return MaterializedClients(directory, inputs, test_uv, Path(sys.executable)) + return acquire @pytest.fixture diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index 0f1960043d..a1ddaae080 100644 --- a/tools/agent_comms_project.py +++ b/tools/agent_comms_project.py @@ -43,7 +43,6 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.head_review import Side as CutSide from agent_comms.field_codec import boundary_decoder -from agent_comms.installer.commands import InstallCommands from agent_comms.installer.runtime_resolution import StackSources from agent_comms.installer.carry import STEPS from agent_comms.installer.probes.carry import Carry @@ -94,7 +93,7 @@ class AgentCommsProject(audit.project.Project[PlanPhase]): cloned once, unless CI already materialized each side's exact cut.""" @classmethod - def from_arguments(cls, root: Path, commands: InstallCommands) -> tuple[AgentCommsProject, list[str]]: + def from_arguments(cls, root: Path) -> tuple[AgentCommsProject, list[str]]: """This checkout measured with the clients its command line names, and the arguments left for the audit's own command line. --ci-inputs names the integration entrypoint's already-materialized exact source input.""" @@ -103,7 +102,7 @@ def from_arguments(cls, root: Path, commands: InstallCommands) -> tuple[AgentCom "--ci-inputs", dest="clients", default=ClientSources(), type=lambda directory: MaterializedClients.read( Path(directory).resolve(), uv=StackSources.frontend(), - python=Path(sys.executable), commands=commands), + python=Path(sys.executable)), help="use the integration entrypoint's already-materialized exact source input") known, rest = parser.parse_known_args() return cls(root, clients=known.clients), rest diff --git a/tools/checker_stack.py b/tools/checker_stack.py index 2045bfde62..f4b0b10401 100644 --- a/tools/checker_stack.py +++ b/tools/checker_stack.py @@ -63,7 +63,7 @@ def require_uv(self) -> None: @cached_property def clients(self) -> MaterializedClients: - return MaterializedClients(self.source_directory, self.inputs, self.uv, self.python, self.commands) + return MaterializedClients(self.source_directory, self.inputs, self.uv, self.python) def requirements(self, side: type[Side]) -> tuple[Requirement, ...]: core = side.require_materialized(self.source_directory, self.inputs.source(CorePackage.repository)) diff --git a/tools/module_layers.py b/tools/module_layers.py index 07d1cb2665..884d6035f0 100644 --- a/tools/module_layers.py +++ b/tools/module_layers.py @@ -36,7 +36,7 @@ import typing from abc import ABC, abstractmethod from collections.abc import Iterable, Sequence -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, replace from functools import cache, cached_property from pathlib import Path @@ -429,13 +429,9 @@ class MaterializedClients(ClientSources): inputs: IntegrationInput uv: Path python: Path - # The executor's lifetime is operational; source cuts and their interpreters identify this input. - commands: InstallCommands = field(compare=False, repr=False) - @classmethod - def read(cls, directory: Path, *, uv: Path, python: Path, - commands: InstallCommands) -> MaterializedClients: - return cls(directory, IntegrationInput.at(directory), uv, python, commands) + def read(cls, directory: Path, *, uv: Path, python: Path) -> MaterializedClients: + return cls(directory, IntegrationInput.at(directory), uv, python) @cached_property def environment(self) -> dict[str, str]: @@ -447,24 +443,26 @@ def metadata(self) -> dict[type[Side], dict[RepositoryName, ProjectMetadata]]: _ = self.environment # Prove the selected interpreter once before using its Python version. result: dict[type[Side], dict[RepositoryName, ProjectMetadata]] = {} core_source = self.inputs.source(CorePackage.repository) - for side in Side.sides(): - core = side.require_materialized(self.directory, core_source) - packages = {core_source.repository: CorePackage.distribution, - **{Repo.repository_url(url): name - for name, (url, branch) in self.sources(core / "stack").items()}} - projects: dict[RepositoryName, ProjectMetadata] = {} - for identity in sorted(packages, key=lambda identity: identity.key): - source = self.inputs.source(identity) - checkout = side.require_materialized(self.directory, source) - try: - metadata = ProjectMetadata.acquire(checkout, uv=self.uv, python=self.python, - commands=self.commands, output=self.directory / "metadata") - self.require_package(metadata, packages[identity]) - ProjectMetadata.require_python(metadata.python) - projects[identity] = metadata - finally: - side.require_materialized(self.directory, source) - result[side] = projects + # Acquisition owns and retires its runner; the measured value carries no locks or workers. + with InstallCommands() as commands: + for side in Side.sides(): + core = side.require_materialized(self.directory, core_source) + packages = {core_source.repository: CorePackage.distribution, + **{Repo.repository_url(url): name + for name, (url, branch) in self.sources(core / "stack").items()}} + projects: dict[RepositoryName, ProjectMetadata] = {} + for identity in sorted(packages, key=lambda identity: identity.key): + source = self.inputs.source(identity) + checkout = side.require_materialized(self.directory, source) + try: + metadata = ProjectMetadata.acquire(checkout, uv=self.uv, python=self.python, + commands=commands, output=self.directory / "metadata") + self.require_package(metadata, packages[identity]) + ProjectMetadata.require_python(metadata.python) + projects[identity] = metadata + finally: + side.require_materialized(self.directory, source) + result[side] = projects return result def projects(self, side: type[Side]) -> dict[RepositoryName, ProjectMetadata]: @@ -865,7 +863,7 @@ def main() -> int: local = inputs.source(Repo.of(ROOT).github) core = HeadSide.require_materialized(arguments.ci_inputs, local) resource = MaterializedClients(arguments.ci_inputs, inputs, StackSources.frontend(), - Path(sys.executable), commands) + Path(sys.executable)) package = Package(core, resource.cuts[HeadSide]) base = BaseSide.require_materialized(arguments.ci_inputs, local) if arguments.base and Repo.git(ROOT, "rev-parse", arguments.base).strip() != local.baseline.commit: diff --git a/tools/nra_gate.py b/tools/nra_gate.py index 34bc093200..906065aea7 100644 --- a/tools/nra_gate.py +++ b/tools/nra_gate.py @@ -8,10 +8,8 @@ from pathlib import Path from agent_comms_project import AgentCommsProject -from agent_comms.installer.commands import InstallCommands from nominal_refactor_advisor import audit if __name__ == "__main__": - with InstallCommands() as commands: - project, arguments = AgentCommsProject.from_arguments(Path.cwd(), commands) - raise SystemExit(audit.gate.main(project, arguments)) + project, arguments = AgentCommsProject.from_arguments(Path.cwd()) + raise SystemExit(audit.gate.main(project, arguments)) From a201523220d0b81e5f6e823761a56824c1460fc8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 03:17:03 -0400 Subject: [PATCH 106/109] Compare frozen recipients by the dataclass they already are same_identity hand-compared recipient_lookup and canonical_thread, which FrozenRecipient already declares as its fields; it existed only because the direct-read path built a bare FrozenRecipientIdentity that dataclass equality would not match. Build the audience entry there, delete same_identity, and ask membership with not-in. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/participant_identity.py | 5 ----- src/agent_comms/presentation.py | 3 ++- src/agent_comms/registry_document.py | 6 +++--- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/agent_comms/participant_identity.py b/src/agent_comms/participant_identity.py index f94be788b5..d8d2c0cbed 100644 --- a/src/agent_comms/participant_identity.py +++ b/src/agent_comms/participant_identity.py @@ -45,8 +45,3 @@ def __post_init__(self) -> None: @property def lookup(self) -> ThreadLookup: return self.recipient_lookup - - def same_identity(self, other: FrozenRecipientIdentity) -> bool: - """Bus writers and detached readers name the same send-time participant.""" - return (self.recipient_lookup == other.recipient_lookup - and self.canonical_thread == other.canonical_thread) diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 26d5d2e984..38a1f1ebfc 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -19,6 +19,7 @@ RecordedRecipientActivity, ) from .participant_identity import FrozenRecipientIdentity +from .audience_manifest import FrozenRecipient from .bus import DisplayUnread from .channel_targets import CommandTarget from .channels import Channel, ChannelMemberIndex, Channels @@ -315,7 +316,7 @@ def direct_read_notification( return CapturedNotificationWindow({}, ()) peer = CommandTarget.named(message.target).direct_peer(snapshot) for target in () if peer is None else peer.role.human(peer): - recipient = FrozenRecipientIdentity(target.incarnation.lookup, target.name) + recipient = FrozenRecipient(target.incarnation.lookup, target.name) displayed_to = reads.displayed_recipient( source, recipient, snapshot, document=document ) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 33db491a65..6b911ebf62 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -31,7 +31,7 @@ WireRequired, WireValue, ) -from .participant_identity import FrozenRecipientIdentity +from .audience_manifest import FrozenRecipient from .registration_change import ( InitialRegistration, RegistrationChange, @@ -1223,7 +1223,7 @@ def displayed_recipient_effect(self, reads, message, thread: Thread, *, document return thread.role.displayed_recipient_effect(reads, message, thread, self, document) def recipient( - self, recipient: FrozenRecipientIdentity, original: Delivered + self, recipient: FrozenRecipient, original: Delivered ) -> RegisteredRecipient | RetiredRecipient: """A missing live thread was retired only if the original send names it.""" lookup = recipient.recipient_lookup @@ -1232,7 +1232,7 @@ def recipient( if thread.name != self.canonical_name(recipient.canonical_thread): raise RelationViolationError("Frozen recipient name does not match its lookup.") return RegisteredRecipient(thread) - if not any(recipient.same_identity(frozen) for frozen in original.recipients): + if recipient not in original.recipients: raise UnregisteredThreadError("Recipient lookup is not in the original message.") return RetiredRecipient(lookup) From ce1d3681a0f7f7291a8c436bcce42f45cccd82bd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 03:17:03 -0400 Subject: [PATCH 107/109] Declare the goal history reply and wake confirmation by their supertypes RecordedGoalHistory is the goal-history tool's reply once the GoalHistory wrapper is gone, so it is a ToolResult; WakeOnlyMonitor.confirm returns the MessageReference tuple MonitorEvidence declares. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/goal_history.py | 4 ++-- src/agent_comms/harness_delivery_states.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/goal_history.py b/src/agent_comms/goal_history.py index 93f617a8ed..211eb1cc97 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 .typed_table import ( Column, DurableTables, @@ -169,7 +169,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/harness_delivery_states.py b/src/agent_comms/harness_delivery_states.py index 23b71de774..918a4f1468 100644 --- a/src/agent_comms/harness_delivery_states.py +++ b/src/agent_comms/harness_delivery_states.py @@ -129,7 +129,7 @@ def for_parts(cls, total: int) -> WakeOnlyMonitor: return cls() async def confirm(self, bridge: LineMonitorBridge, delivery: HarnessDelivery, - notification: MonitorPart, record: ObservedMonitor) -> tuple[str, ...]: + notification: MonitorPart, record: ObservedMonitor) -> tuple[MessageReference, ...]: # Retire only the wake's transport reservation, never original-body proof. await bridge.part_observed(delivery.message_id, notification.number) return () From b553ecc8071b7466cb272a6ccda13a87d16f87be Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 04:01:02 -0400 Subject: [PATCH 108/109] Let a pull request's checks keep the bases they captured TestedSources.validate refused whenever live main differed from the tested merge's base, so on a fleet that merges every few minutes the PR's own base and debt jobs died with 'main moved; recapture' before measuring anything (two consecutive runs on #3566). The event owns the freshness answer: require_current_mains stays on TestedSources for the merge group and main push, and PullRequestSources answers that its captured bases stand, since the merge group re-proves currency before anything lands. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/integration.py | 17 ++++++++++++----- tests/test_integration.py | 7 ++++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index 0c2c0b937b..4182e36c20 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -344,6 +344,13 @@ def main_commits(self, inputs: IntegrationInput) -> dict[RepositoryName, str]: def require_local_candidate(self, inputs: IntegrationInput) -> None: inputs.require_local_candidate(self.repository, self.carrier) + def require_current_mains(self, inputs: IntegrationInput, repositories: tuple[Repo, ...]) -> None: + mains = self.main_commits(inputs) + for source in inputs.sources: + repository = Repo.selected(repositories, source.repository.to_wire()) + if IntegrationSource.main_head(repository) != mains[source.repository]: + raise IntegrationFailed(f"{source.repository.to_wire()} main moved; recapture") + def require_merge_review(self, repositories: tuple[Repo, ...]) -> IntegrationInput: """Source computation precedes promotion; its exact combined proof is still required.""" return MergeHeadReview.require_combined(self.carrier, repositories) @@ -446,11 +453,7 @@ def validate(self, inputs: IntegrationInput, repositories: tuple[Repo, ...]) -> else: for source in inputs.sources: source.require_ordinary(self.carrier) - mains = self.main_commits(inputs) - for source in inputs.sources: - repository = Repo.selected(repositories, source.repository.to_wire()) - if IntegrationSource.main_head(repository) != mains[source.repository]: - raise IntegrationFailed(f"{source.repository.to_wire()} main moved; recapture") + self.require_current_mains(inputs, repositories) self.require_local_candidate(inputs) for source in inputs.companions: source.require_candidate(Repo.selected(repositories, source.repository.to_wire()), self.carrier) @@ -470,6 +473,10 @@ def resolve(cls, repository: Repo, tested: str, document: object, *, graph.require_carrier() return graph + def require_current_mains(self, inputs: IntegrationInput, repositories: tuple[Repo, ...]) -> None: + """A pull request is measured against the bases its inputs captured; a later main + is the merge group's to prove, so a busy main never fails the PR's own checks.""" + class MergeGroupSources(TestedSources, declared_name="merge_group"): def require_merge_review(self, repositories: tuple[Repo, ...]) -> IntegrationInput: diff --git a/tests/test_integration.py b/tests/test_integration.py index 17724a0229..d8aa8d5d44 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -42,10 +42,10 @@ def heads() -> tuple[PullRequestHead, ...]: return (PullRequestHead(repo, 7, "a" * 40), PullRequestHead(repo, 8, "b" * 40)) -@pytest.mark.parametrize("event", ("pull_request", "merge_group")) +@pytest.mark.parametrize(("event", "moved_main_refuses"), (("pull_request", False), ("merge_group", True))) def test_actual_ci_cli_uses_verified_carrier_not_synthetic_message_and_revalidates( mutual_sources: MutualSources, github_cli: GitHubFixture, - tmp_path: Path, event: str, + tmp_path: Path, event: str, moved_main_refuses: bool, ) -> None: pair = mutual_sources inputs = pair.capture(("P2", "P3"), "CI source graph") @@ -94,7 +94,8 @@ def test_actual_ci_cli_uses_verified_carrier_not_synthetic_message_and_revalidat pair.client.git(pair.client.checkout, "push", "origin", "HEAD:main") stale = subprocess.run((*command, "--revalidate-ci-inputs", str(output)), cwd=checkout.path, env=environment, capture_output=True, text=True, timeout=120) - assert stale.returncode != 0 and "main moved" in stale.stderr + # A pull request keeps the bases it captured; only the merge group must be current. + assert (stale.returncode != 0 and "main moved" in stale.stderr) == moved_main_refuses, stale.stderr assert pair.core.git(pair.core.checkout, "rev-parse", "HEAD").strip() == original assert not any(call[:2] == ("pr", "merge") or "state=success" in call for call in github_cli.calls()) From e568e33ef933047fd5193b91a7e285c36db6ab0f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 04:11:46 -0400 Subject: [PATCH 109/109] Keep recipient lookups on the cycle-free identity type Narrowing RegistrySnapshot.recipient to FrozenRecipient broke the observe_recipients caller (agent_activity.py:181, TypeErrors +1), and narrowing the evidence path instead would import audience_manifest into notification_evidence, a cycle. FrozenRecipientIdentity is the declaration that stays free of the projection writers; every runtime recipient is the FrozenRecipient audience entry, so membership still compares dataclasses. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/registry_document.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 6b911ebf62..e925f83160 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -31,7 +31,7 @@ WireRequired, WireValue, ) -from .audience_manifest import FrozenRecipient +from .participant_identity import FrozenRecipientIdentity from .registration_change import ( InitialRegistration, RegistrationChange, @@ -1223,7 +1223,7 @@ def displayed_recipient_effect(self, reads, message, thread: Thread, *, document return thread.role.displayed_recipient_effect(reads, message, thread, self, document) def recipient( - self, recipient: FrozenRecipient, original: Delivered + self, recipient: FrozenRecipientIdentity, original: Delivered ) -> RegisteredRecipient | RetiredRecipient: """A missing live thread was retired only if the original send names it.""" lookup = recipient.recipient_lookup