diff --git a/.github/workflows/nra-gate-cache.yml b/.github/workflows/nra-gate-cache.yml index 6389b45c4b..7445cec0de 100644 --- a/.github/workflows/nra-gate-cache.yml +++ b/.github/workflows/nra-gate-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: nra-gate-cache @@ -32,12 +44,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: @@ -49,8 +69,14 @@ jobs: - name: Measure main against its parent run: | PYTHONPATH=src:tools python tools/nra_gate.py \ + --ci-inputs "$RUNNER_TEMP/paired-sources" \ --root ${{ matrix.kind }} --base HEAD^1 --head HEAD || 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: path: ~/.cache/agent-comms diff --git a/.github/workflows/nra-gate.yml b/.github/workflows/nra-gate.yml index 8817e80221..6194c50a36 100644 --- a/.github/workflows/nra-gate.yml +++ b/.github/workflows/nra-gate.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 NRA gate, so one that # starts in the same second as ready_for_review must not cancel the ready run (#3164 and #3154 lost @@ -34,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 . + - 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: @@ -64,8 +84,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 '.[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 @@ -89,7 +119,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 @@ -116,7 +150,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: @@ -158,18 +192,29 @@ jobs: name: review-verdict needs: ready runs-on: ubuntu-latest - timeout-minutes: 2 + timeout-minutes: 5 permissions: + contents: read statuses: write 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. - # Main requires agent-comms/review directly, and a verdict names a pull request's - # head, so the group's own commit carries the status its verified head holds. + - 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 . + # 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" + # A verdict names the pull request head, not the merge group's own commit. - name: The commander's verdict on the queued head is success if: github.event_name == 'merge_group' shell: bash @@ -204,14 +249,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 nra-gate-cache.yml on main; entries are keyed by content, so any # older main's cache only misses where the code differs. @@ -228,10 +279,16 @@ jobs: set -o pipefail gate_status=0 PYTHONPATH=src:tools python tools/nra_gate.py \ + --ci-inputs "$RUNNER_TEMP/paired-sources" \ --root ${{ matrix.kind }} --base "$BASE" --head HEAD 2>gate-findings.txt | tee gate.txt || gate_status=$? cat gate-findings.txt >&2 exit "$gate_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 bf724a5d55..4c63690bca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dev = [ "ruff==0.16.0", "mypy==2.3.0", "uv==0.13.1", + "tomli-w==1.2.0", ] [project.urls] @@ -137,7 +138,15 @@ src = ["src", "tests"] select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"] [tool.ruff.lint.flake8-bugbear] -extend-immutable-calls = ["agent_comms.store_files.ExclusiveLock", "agent_comms.importing.ImportLimits"] +extend-immutable-calls = [ + "agent_comms.store_files.ExclusiveLock", + "agent_comms.importing.ImportLimits", + "agent_comms.transcripts.LatestPage", + "agent_comms.transcripts.AtFrontier", + "agent_comms.transcripts.LivePageSource", + "agent_comms.notification_evidence.FirstRecipient", + "agent_comms.notification_evidence.RecipientLimit", +] [tool.mypy] warn_return_any = true diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 602b2530a0..1f7c0a67f2 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,8 +53,10 @@ from .reported_turn import ReportedTurn, ReportedTurnState from .routing import MessageRoute, NoMessageRoute from .thread_identity import ClaimScoped, ThreadIncarnation -from .tool_result import DeclaredReply -from .transcripts import NativeCommittedPrefix, TranscriptCursor, TranscriptPage, TranscriptReadIdentity +from .tool_result import ToolResult +from .transcripts import ( + LivePageSource, NativeCommittedPrefix, PageSource, TranscriptCursor, TranscriptPage, TranscriptRead, TranscriptReadIdentity, +) from .turn_context import ContextManifest from .turn_lease import TurnState from .working_memory_labels import ModelLabel @@ -108,7 +110,7 @@ class TextNoticeUpdate(AgentCommsUpdate): @dataclass(frozen=True) class TranscriptChangedUpdate(AgentCommsUpdate): - cursor: TranscriptCursor | None + cursor: TranscriptCursor class ClaimBlockedFailure(BackendDeliveryFailure): @@ -139,7 +141,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")] @@ -699,20 +701,20 @@ class TranscriptDraftUpdate(AgentCommsUpdate): @dataclass(frozen=True) -class TranscriptSnapshotUpdate(AgentCommsUpdate): +class TranscriptSnapshotUpdate(AgentCommsUpdate, ToolResult): page: TranscriptPage identity: TranscriptReadIdentity 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()) @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/agent_activity.py b/src/agent_comms/agent_activity.py index e92ce1e979..201779b205 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -32,11 +32,11 @@ NoDrainDiagnostic, ObservedActivity, ) -from .audience_manifest import FrozenRecipient from .coordinator import Coordination from .declared_family import DeclaredFamily from .errors import RelationViolationError from .field_codec import ThreadLookup, TurnId +from .frozen_recipient import FrozenRecipient from .routing import NoTurnRouting, Routing from .runtime_info import RuntimeInfo, RuntimeInfoStore from .thread_identity import OwnerIdentity, ThreadIncarnation @@ -169,7 +169,9 @@ 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, FrozenRecipient]], + *, 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 189bde9b09..63983828d4 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -27,6 +27,7 @@ from .declared_family import DeclaredFamily from .field_codec import AcpSessionId, InputId +from .native_input_proof import NativeContextProof from .pi_counts import PiCount from .pi_commands import NativeQuery from .pi_payloads import PiResponseData @@ -173,6 +174,19 @@ def original(self, native_id: InputId, text: str) -> bool: ... @abstractmethod 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.""" + return None + + async def following_context_committed( + self, + public_id: InputId, + sent_text: str, + context: NativeContextProof, + ) -> None: + """An unowned following input has no durable ordinary disposition.""" + return None + class NoInputStartListener(InputStartListener): started_events = () @@ -187,8 +201,6 @@ def following(self, public_id: InputId, native_id: InputId, text: str) -> bool: NO_INPUT_START_LISTENER = NoInputStartListener() - - class RunsTurns(ABC): """Runs one input to settlement and reports what happened as AgentEvents. @@ -347,4 +359,3 @@ def selected(cls) -> type[Self]: f"exactly one backend must serve new threads, found " f"{[member.__name__ for member in members]}") return members[0] - diff --git a/src/agent_comms/assignment_states.py b/src/agent_comms/assignment_states.py index e824ee6eb8..6d19c91b32 100644 --- a/src/agent_comms/assignment_states.py +++ b/src/agent_comms/assignment_states.py @@ -26,11 +26,11 @@ 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 - from .audience_manifest import FrozenRecipient + from .frozen_recipient import FrozenRecipient from .bus_publication import Delivered from .coordination_tables.assignments import WakeAssignment from .coordination_tables.responses import ResponseObligation @@ -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/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index 2f4b1ce036..59c2f3cf4c 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_pi import NativeContextProof from .native_prompt_binding import ( expected_prompt_bindings, expected_prompt_matches_journal, @@ -475,10 +474,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/audience_manifest.py b/src/agent_comms/audience_manifest.py index cd40f8b3c0..c35487d893 100644 --- a/src/agent_comms/audience_manifest.py +++ b/src/agent_comms/audience_manifest.py @@ -8,26 +8,18 @@ from __future__ import annotations -from .bus_tables import ( - AwaitingAnswerSource, - ReceivedSource, -) - -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 .channel_targets import exact_stored_target from .field_codec import FieldCodec, MalformedRecord, ThreadLookup +from .frozen_recipient import FrozenRecipient +from .participant_identity import ParticipantIdentity from .messages import BusCapability, CarriesEnvelope, Message -from .projection_source import ProducedRow, ProjectionSource AUDIENCE_VERSION: Final = 1 MAX_RECIPIENTS: Final = 4096 @@ -36,18 +28,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( @@ -64,23 +44,6 @@ def _digest(domain: bytes, payload: bytes) -> str: return hashlib.sha256(domain + payload).hexdigest() -@dataclass(frozen=True, slots=True) -class FrozenRecipient(ProjectionSource): - """Stable coordination lookup paired with its send-time canonical name.""" - - @ClassFact - 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 +71,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 +142,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 f02f4ceba1..bcca5cee08 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -29,18 +29,19 @@ 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 from metaclass_registry.mro_dispatch import ( MroDispatch, handles, ) +from agent_comms.participant_identity import ParticipantIdentity +from agent_comms.pi_payloads import RequestId + from . import agent_events as events from . import pi_commands as commands from . import pi_events as pi @@ -49,19 +50,24 @@ from .agent_backend import NO_INPUT_START_LISTENER, InputStartListener from .turn_inbox import TurnInbox, TurnInboxItem from .coordinator import Coordination +from .cursor_publication import CursorDelivery from .extension_ui import ExtensionUiSession from .field_codec import InputId, MalformedRecord, boundary_decoder from .image_input import ImageInput from .native_attestation import AttestationError from .native_child import PiSessionChild +from .native_entries import NativeEvidenceScope 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 SessionRetention +from .native_pi import NativePiRpcLaunch, read_tracked_input_commitment +from .native_session_files import NativeSessionFiles +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 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 @@ -122,6 +128,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, @@ -159,13 +166,17 @@ async def stream_agent_events( yield events.Error(text=str(error), reason_code=failures.NativeLaunchInvalid.declared_name) yield events.Done(ok=False, failure=failures.NativeLaunchInvalid(str(error)), text=str(error)) return - async with launch.session.files(root).accept_input(): - async with aclosing( + async with ( + launch.session.files(root).accept_input(), + aclosing( TurnSession( launch, - partial(commands.Prompt, + partial( + commands.Prompt, id=RequestId(PiRequestKey.fresh()), - input_id=InputId.mint(), message=task, images=tuple(images), + input_id=InputId.mint(), + message=task, + images=tuple(images), context_contributions=context_contributions, ), tool_mode=tool_mode, @@ -175,14 +186,16 @@ 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, startup=NativeStartup.for_launch(launch), ).run() - ) as stream: - async for event in stream: - yield event + ) as stream, + ): + async for event in stream: + yield event class FinishSignal(ABC): @@ -438,7 +451,7 @@ async def open_native(self, *, reuse: bool) -> PiSessionChild: self.startup.native_process = native.proc.identity return native - async def run(self) -> AsyncGenerator[events.AgentEvent, None]: + async def run(self) -> AsyncGenerator[events.AgentEvent]: self.loop = asyncio.get_running_loop() self.owner = asyncio.current_task() async with AsyncExitStack() as resources: @@ -597,6 +610,98 @@ 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 = 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, + ) + 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.""" @@ -607,22 +712,69 @@ def provider_retry_ended(self, event: pi.AutoRetryEnd) -> tuple[events.AgentEven self.record_retry(event.outcome.result(event.attempt, self.output.redact)) return updates - def __init__(self, launch, make_command: Callable[..., commands.Prompt], *, - tool_mode: NativeToolMode, **options): + def __init__( + self, + launch, + make_command: Callable[..., commands.Prompt], + *, + 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: NativeInputCommit) -> 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/bus.py b/src/agent_comms/bus.py index 10e699598e..5b606587c6 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -454,6 +454,15 @@ def replies(self, references: tuple[MessageReference, ...]) -> tuple[BusRecord, headers = (BusRow.require(db, seq=seq) for seq in sorted(sequences)) return BusCapability.at_sequences(db, tuple(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..8799c36b30 100644 --- a/src/agent_comms/bus_publication.py +++ b/src/agent_comms/bus_publication.py @@ -31,8 +31,8 @@ from .field_codec import FieldCodec, MessageId, ThreadLookup from .repo import Checkout -from .read_basis import RepliesDisplayScope from .wake_policy import RecipientDecision +from .channel_targets import ThreadTarget if TYPE_CHECKING: from agent_comms.bus import Bus, BusReader, RecordBuilder @@ -51,17 +51,17 @@ def writes(cls) -> tuple[type[ProducedRow], ...]: decisions: tuple[RecipientDecision, ...] control: str - def projections(self): - from .bus_tables import AwaitingAnswerSource, ReceivedSource, SentSource + 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): yield from super().projections() 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(ThreadTarget(recipient.canonical_thread)): yield AwaitingAnswerSource( recipient.recipient_lookup, self.seq, recipient.canonical_thread, self.role.response_policy(self).starts_direct_turn, diff --git a/src/agent_comms/bus_read.py b/src/agent_comms/bus_read.py new file mode 100644 index 0000000000..9a190a1cbd --- /dev/null +++ b/src/agent_comms/bus_read.py @@ -0,0 +1,58 @@ +"""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 + +from .comms import Comms +from .owner_connection import ( + AttachedResultRuntimeRequest, + AttachedRuntimeRequestContext, + OwnerConnection, + OwnerReading, + OwnerReplyConnection, +) +from .read_basis import NoDisplaySelection, ReadAuthority +from .tool_declaration import ToolDeclaration +from .tool_request import ThreadReadTool +from .tool_result import ToolResult + + +class BusRead[Result](ToolDeclaration, ABC): + """One operation owns its arguments and its read; the boundary supplies authority.""" + + @abstractmethod + def apply(self, comms: Comms, caller: ReadAuthority) -> Result: ... + + +class UiRead[Result](BusRead[Result], ABC): + def read(self, comms: Comms) -> Result: + return self.apply(comms, NoDisplaySelection()) + + +class OwnerLiveRead[Result: ToolResult = ToolResult](BusRead[Result], ThreadReadTool, ABC): + """Only the selected thread's live owner answers, through its own request queue.""" + + def apply(self, comms: Comms, caller: ReadAuthority) -> Result: + owner = comms.registry.require(caller.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: AttachedRuntimeRequestContext) -> Result: ... + + @abstractmethod + def decode_reply(self, raw: object) -> Result: ... + + +@dataclass(frozen=True, kw_only=True) +class OwnerReadRuntimeRequest(OwnerReading[object], AttachedResultRuntimeRequest): + operation: OwnerLiveRead + + async def read(self, owner: OwnerConnection) -> object: + return await owner.request(self.declared_name, operation=self.operation) + + async def result(self, ctx: AttachedRuntimeRequestContext) -> ToolResult: + return await self.operation.owned(ctx) diff --git a/src/agent_comms/bus_tables.py b/src/agent_comms/bus_tables.py index 1b284d0f83..dce34879d0 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 @@ -20,7 +21,6 @@ RowNotFoundError, TableReference, TypedRow, - TypedTable, ) if TYPE_CHECKING: @@ -210,6 +210,40 @@ 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.""" + selected = [] + for batch in batched( + tuple(dict.fromkeys(keys)), + db.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER) // 2, + strict=False, + ): + 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/channel_targets.py b/src/agent_comms/channel_targets.py index cad7959829..d709459295 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -20,7 +20,8 @@ from .wake_policy import BoundedTriageWake, FullWake, MessageAudience if TYPE_CHECKING: - from .audience_manifest import AudiencedMessage, FrozenRecipient + from .audience_manifest import AudiencedMessage + from .frozen_recipient import FrozenRecipient from .catalog_document import CatalogAccess, CatalogDocument from .channels import Channel from .cli_command import CliCommand diff --git a/src/agent_comms/child_process.py b/src/agent_comms/child_process.py index 8c9a607a24..8d9f755423 100644 --- a/src/agent_comms/child_process.py +++ b/src/agent_comms/child_process.py @@ -43,11 +43,26 @@ from .declared_family import DeclaredFamily from .errors import RelationViolationError -from .field_codec import DisclosedRecord, FieldCodec, FieldDirective, Id128, NullMember, RecordMember, SubclassUnion, WireRequired +from .field_codec import ( + DisclosedRecord, + FieldCodec, + FieldDirective, + Id128, + NullMember, + RecordMember, + SubclassUnion, + WireRequired, +) from .launch_runtime import INHERITED_ENVIRONMENT, LaunchRuntime, RestoredRuntime from .native_build_inputs import ( - AsyncStdioOptions, ChildStdio, ConfiguredChildStdio, DetachedChildStdio, GitAcquisitionCancelled, - GitLaunch, GitOpening, StdioOptions, + AsyncStdioOptions, + ChildStdio, + ConfiguredChildStdio, + DetachedChildStdio, + GitAcquisitionCancelled, + GitLaunch, + GitOpening, + StdioOptions, ) from .sealed import Sealed from .systemd_unit import AgentsSlice, SystemdUnit @@ -533,7 +548,7 @@ def make_private_descriptor(self, descriptor: int, permissions: int) -> None: os.fchmod(descriptor, permissions) @contextmanager - def socket_address(self, path: Path, descriptor: int) -> Generator[Path, None, None]: + def socket_address(self, path: Path, descriptor: int) -> Generator[Path]: with TemporaryDirectory(prefix="ac-socket-", dir="/tmp") as directory: parent = Path(directory) / "d" parent.symlink_to(path.parent, target_is_directory=True) @@ -646,7 +661,7 @@ def require_watched_procfs(self, refusal: Exception) -> None: return None @contextmanager - def socket_address(self, path: Path, descriptor: int) -> Generator[Path, None, None]: + def socket_address(self, path: Path, descriptor: int) -> Generator[Path]: yield Path(f"/proc/{os.getpid()}/fd/{descriptor}") / path.name def parent_death_binding(self) -> int: @@ -2326,8 +2341,13 @@ def collect(self, *, timeout: float) -> subprocess.CompletedProcess[bytes]: @classmethod def run( - cls, command: tuple[str, ...], *, timeout: float, - cwd: str | Path, env: dict[str, str], + 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. @@ -2337,8 +2357,13 @@ 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, stdio=DetachedChildStdio( - stdout=subprocess.PIPE, stderr=subprocess.PIPE)) as child: + with cls.launch( + command, + cwd=cwd, + env=env, + runtime=runtime, + stdio=DetachedChildStdio(stdout=subprocess.PIPE, stderr=subprocess.PIPE), + ) as child: try: return child.collect(timeout=max(0, deadline - time.monotonic())) finally: @@ -2587,5 +2612,3 @@ class ControllingTerminalCommand(ChildCommand): def run(self) -> None: fcntl.ioctl(0, termios.TIOCSCTTY, 0) os.execvpe(self.command[0], self.command, os.environ) - - 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 2cea99afad..411f0c9dd1 100644 --- a/src/agent_comms/cli_bridges/shared.py +++ b/src/agent_comms/cli_bridges/shared.py @@ -1663,7 +1663,7 @@ async def read_export(self, read: ReadResultObserved) -> tuple[MessageReference, references.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(references) def new_attachment(self, peer: MonitorPeer, token: str) -> MonitorAttachment: diff --git a/src/agent_comms/cli_verbs/merge_review.py b/src/agent_comms/cli_verbs/merge_review.py index 8806e63181..e7d8dd7a8c 100644 --- a/src/agent_comms/cli_verbs/merge_review.py +++ b/src/agent_comms/cli_verbs/merge_review.py @@ -41,6 +41,9 @@ 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 953b4588db..3239235415 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -30,58 +30,63 @@ import os import re 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 +from typing import Annotated, ClassVar - -from agent_comms.comms import Comms from agent_comms.change_report import FileLine +from agent_comms.commander import order +from agent_comms.commander.order import CommanderVerb +from agent_comms.commander.publication_rules import ClosedUnmergedPullRequest +from agent_comms.comms import Comms +from agent_comms.declared_family import DeclaredFamily +from agent_comms.errors import TodoError from agent_comms.field_codec import ( - Argument, BoundedText, FieldCodec, MalformedRecord, MessageId, RecordProblems, + 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.order_tools import CommsHeadVerdictTool, CommsUnjudgedTool, CommsVerdictsTool from agent_comms.recorded_time import EventTime -from agent_comms.errors import TodoError +from agent_comms.slop import Derivation, Slop +from agent_comms.standing_rules import HeadReviewRule, StandingRule from agent_comms.todos import ( AcceptedVerdict, BluntText, DirectiveText, DroppedVerdict, EditSendBackVerdict, - PeerAcceptedVerdict, - RewrittenVerdict, - GrownHeadVerdict, GrowthReason, - ReviewedHeadVerdict, - RejectedHeadVerdict, + HeadReviewed, HeadVerdict, - OrderHeadReview, JudgedCall, LinkedHeadReview, + OrderHeadReview, OrderLink, OrderQuote, + PeerAcceptedVerdict, + QualifiedHeadVerdict, + RejectedHeadVerdict, + ReviewedHeadVerdict, + RewrittenVerdict, SendBackVerdict, StrikeReason, - SupersededVerdict, SupersededHeadVerdict, + SupersededVerdict, Todo, ToolCallVerdict, UnderReviewHeadVerdict, UnquotedHeadReview, VerdictReason, ) -from agent_comms.slop import Derivation, Slop -from agent_comms.order_tools import CommsHeadVerdictTool, CommsUnjudgedTool, CommsVerdictsTool from agent_comms.tool_caller import ThreadCaller from agent_comms.tool_declaration import EditCall, ReplacementEdit -from agent_comms.commander import order -from agent_comms.commander.order import CommanderVerb -from agent_comms.standing_rules import HeadReviewRule, StandingRule -from agent_comms.commander.publication_rules import ClosedUnmergedPullRequest EDIT_LINE = "required with edit IDs: an exact diff line each edit contains; repeat for each" BRANCH = re.compile(r"\bisinstance\(|\bhasattr\(|^\s*(if|elif|match|case)\b|\bif\b.*\belse\b") @@ -311,8 +316,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) @@ -382,8 +386,6 @@ class ReasonedVerb(JudgeVerb, declared_name=None): why: Annotated[str, ReasonText] - - @dataclass(frozen=True, kw_only=True) class VerdictVerb(JudgeVerb): """Every verdict, mine or a fork's, is made after reviewing the OOPSLA paper's @@ -471,6 +473,15 @@ 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 CommsHeadVerdictTool( + request=request, + operation=self.head_operation(), + order=linked, + ).apply(ctx.comms, ctx.actor.as_thread(ctx.commander)) + @abstractmethod def head_operation(self) -> HeadVerdict: ... @@ -491,13 +502,16 @@ 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 - else self.verdict(reason)) + return ( + self.verdict.grown(reason, GrowthReason.every(self.growth)) + if self.growth + else self.verdict(reason) + ) growth: Annotated[ tuple[str, ...], Argument(flags=(GrowthReason.option,), options={ @@ -543,18 +557,15 @@ def check_edits(self, edits: list[Edit]) -> None: edit.id, 'send this edit back with judge back')) 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() +@dataclass(frozen=True, kw_only=True) +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( - 'judge reviewing takes --pr and --head, never edit ids.').located( - 'ids', 'supply --pr and --head instead of edit IDs') + 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: """Unreachable: selecting edits is refused among this verb's argument problems.""" @@ -564,6 +575,41 @@ 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" + ) + + 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, ctx.repos)) + + +@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" + 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" + 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/commander/order.py b/src/agent_comms/commander/order.py index 8e7b426acc..edd96552e8 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -76,7 +76,8 @@ from agent_comms.nominal_census import NominalPlanReadings from agent_comms.phase_evidence import PlanReading from agent_comms.plan_phases import OrderPhase, PlanPhase -from agent_comms.presentation import AllOutcomes, MessageNotification +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 from agent_comms.repo import Checkout, Repo @@ -1166,11 +1167,11 @@ 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( - 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/commander_hud.py b/src/agent_comms/commander_hud.py index 17f3dee4c4..58adc8ff76 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -182,7 +182,7 @@ class CommanderOrderDetail(DisclosedRecord): @dataclass(frozen=True) -class CommanderUnitDetail(DisclosedRecord): +class CommanderUnitDetail(ToolResult, DisclosedRecord): """A lazy disclosure; activity uses the existing bounded turn wire projection.""" unit: Annotated[CommanderUnit, DisclosureIdentity()] diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 4c6a857971..940c34ec47 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -57,6 +57,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, @@ -462,7 +463,10 @@ async def result(self, connector: Connector, caller: ConnectorCaller) -> ToolsCa try: reply = await connector.call(tool, self.params.arguments, caller) return ToolsCallResult((TextContent(json.dumps(reply)),), False, reply) - except (ToolRefusedError, RelationViolationError, CoordinationError) as error: + except ( + ToolRefusedError, RelationViolationError, CoordinationError, WorkBranchError, + MalformedRecord, Refused, ConnectionError, TimeoutError, + ) as error: logging.exception("Connector tool %s failed", self.params.name) return ToolsCallResult.failed(error) diff --git a/src/agent_comms/connector_caller.py b/src/agent_comms/connector_caller.py index 69b4caf582..96b4172705 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 .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/assignments.py b/src/agent_comms/coordination_tables/assignments.py index 1deb2cc72f..8a75b07b00 100644 --- a/src/agent_comms/coordination_tables/assignments.py +++ b/src/agent_comms/coordination_tables/assignments.py @@ -6,7 +6,7 @@ from typing import Annotated, ClassVar from agent_comms.assignment_states import AssignmentState, EngagedAssignment -from agent_comms.audience_manifest import FrozenRecipient +from agent_comms.frozen_recipient import FrozenRecipient from agent_comms.coordination_contracts import ( MAX_IDENTIFIER_CHARS, POLICY_VERSION, diff --git a/src/agent_comms/coordination_tables/harness_deliveries.py b/src/agent_comms/coordination_tables/harness_deliveries.py index 79363a42fb..663d3b519f 100644 --- a/src/agent_comms/coordination_tables/harness_deliveries.py +++ b/src/agent_comms/coordination_tables/harness_deliveries.py @@ -77,7 +77,7 @@ def settle_claims(self, assignments: AssignmentStore, db: sqlite3.Connection) -> 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/following_inputs.py b/src/agent_comms/following_inputs.py index daba370467..854c556ff7 100644 --- a/src/agent_comms/following_inputs.py +++ b/src/agent_comms/following_inputs.py @@ -11,6 +11,7 @@ from .turn_inbox 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 @@ -56,7 +57,16 @@ class OwnerFollowingInputs(FollowingInputs): session_id: AcpSessionId 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 +92,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( diff --git a/src/agent_comms/frozen_recipient.py b/src/agent_comms/frozen_recipient.py new file mode 100644 index 0000000000..8df5bc78f6 --- /dev/null +++ b/src/agent_comms/frozen_recipient.py @@ -0,0 +1,32 @@ +"""A frozen audience's member: below the bus tables, so records can decode it during import.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .class_fact import ClassFact +from .field_codec import ThreadLookup +from .participant_identity import ParticipantIdentity +from .projection_source import ProducedRow, ProjectionSource + + +@dataclass(frozen=True, slots=True) +class FrozenRecipient(ParticipantIdentity, ProjectionSource): + """Stable coordination lookup paired with its send-time canonical name.""" + + 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 + + @ClassFact + def writes(cls: type[FrozenRecipient]) -> tuple[type[ProducedRow], ...]: + from .bus_tables import AwaitingAnswerSource, ReceivedSource + + return (ReceivedSource, AwaitingAnswerSource) 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/goal_management.py b/src/agent_comms/goal_management.py index 4d29909dfe..9d4a754f39 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/harness_delivery_states.py b/src/agent_comms/harness_delivery_states.py index ae58158de1..71822081a2 100644 --- a/src/agent_comms/harness_delivery_states.py +++ b/src/agent_comms/harness_delivery_states.py @@ -14,6 +14,9 @@ from .message_reference import MessageReference 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: from .assignment_states import AssignmentState @@ -26,11 +29,6 @@ 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,16 +36,6 @@ def settle(cls, successor: HarnessDelivery, assignments: AssignmentStore, successor.record(assignments, db) 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 - - @classmethod - def finished_with(cls, export: MonitorReadFile) -> None: - """An unconsumed body keeps its export for a later Read.""" - class FilteredOutcome(Outcome): @classmethod @@ -63,17 +51,19 @@ class UnconfirmedOutcome(Outcome): class DeliveredOutcome(FilteredOutcome): - @classmethod - def claim_steps(cls, state: AssignmentState) -> tuple[type[AssignmentState], ...]: - return state.harness_delivery() + """An original harness response, not evidence that a model took the body. - @classmethod - def consumed_progress(cls, progress: DeliveryProgress) -> DeliveryProgress: - return progress.harness_consumed() + Older generic responses retain this spelling and their actual monitor proof. + A producer name alone cannot upgrade their evidence to model context. + """ - @classmethod - def finished_with(cls, export: MonitorReadFile) -> None: - export.retire() + +class AcceptedOutcome(DeliveredOutcome): + pass + + +class PersistedOutcome(AcceptedOutcome): + pass class EmittedOutcome(UnconfirmedOutcome): @@ -81,7 +71,7 @@ class EmittedOutcome(UnconfirmedOutcome): @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, @@ -93,6 +83,15 @@ 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 finished_with(self, export: MonitorReadFile) -> None: + """A body without a complete original transcript proof keeps its export.""" + @dataclass(frozen=True) class UnrecordedMonitor(MonitorEvidence): @@ -125,10 +124,19 @@ class MismatchedMonitor(MonitorEvidence): @dataclass(frozen=True) -class ObservedMonitor(MonitorEvidence): +class ObservedMonitor(MonitorEvidence, Timestamped): record_id: TranscriptRecordId timestamp: float 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.model_consumed(HarnessConsumedProgress(self.at_ms)) + + def claim_steps(self, state: AssignmentState) -> tuple[type[AssignmentState], ...]: + return state.harness_delivery() + + def finished_with(self, export: MonitorReadFile) -> None: + export.retire() diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index 5e30777908..250ecda460 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -5,16 +5,22 @@ from __future__ import annotations +import hashlib import json -from abc import abstractmethod +from abc import ABC, abstractmethod +from collections.abc import Sequence from dataclasses import dataclass, field +from pathlib import Path from typing import TYPE_CHECKING, Annotated, ClassVar -from collections.abc import Sequence +from nominal_refactor_advisor import audit + +from .build_identity import RepositoryCommit from .declared_family import DeclaredFamily from .errors import ToolRefusedError from .field_codec import FieldCodec, GitCommitText, MalformedRecord, RecordProblems -from .repo import Repo +from .installer.runtime_resolution import PrivateUvProject, StackSources +from .repo import Checkout, GitCommandError, Repo from .repository_name import RepositoryName from .work_branches import WorkBranches @@ -40,6 +46,7 @@ class PullRequestChange: """Git's whitespace-preserving identity of a pull request's complete patch.""" patch: str + integration_digest: str = "" @dataclass(frozen=True) @@ -81,12 +88,14 @@ def among(cls, repositories: tuple[RepositoryName, ...], pr: int, head: str) -> @classmethod 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)') + read = repo.rest( + f"pulls/{pr}", + "--jq", + '.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() - 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. @@ -94,7 +103,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 +125,508 @@ 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: ... + + @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"): + """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 + + @staticmethod + def main_head(repository: Repo) -> str: + 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 != reference_name: + 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 + + @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 + + def prepare_candidate(self, checkout: SourceCheckout) -> None: + checkout.combine_sources(self.baseline.commit, self.heads) + + def require_origin(self, repository: Repo) -> None: + if Repo.of(repository.checkout).github != self.repository: + 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) + + @abstractmethod + def require_tree(self, repository: Repo) -> None: + """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.""" + self.require_origin(repository) + 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") + 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: + """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() + if current.base != WorkBranches.main: + 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: + 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() + 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 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.""" + + 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: SourceCheckout) -> 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. + + 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" + filename: ClassVar[str] = "input.json" + + 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.") + _ = 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.""" + 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] + + @staticmethod + def declared_repositories( + core: Repo, baseline: str, candidate: str + ) -> tuple[RepositoryName, ...]: + """Actual declarations on both sides own the universe, including added/removed clients.""" + 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( + PrivateUvProject.decode( + 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(): + 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: + """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 != WorkBranches.main: + raise ToolRefusedError("The integration carrier no longer targets main") + self.require_carrier(core, carrier) + self.require_local_candidate(core, carrier) + 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: + entry.validate(Repo.selected(repositories, entry.repository.to_wire()), carrier) + + 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." + ) + + @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") + + def require_local_candidate(self, core: Repo, carrier: PullRequestHead) -> None: + self.local.require_origin(core) + self.local.require_candidate(core, carrier) + + @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) + 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 + + @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: + 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 + + @property + def combined(self) -> bool: + return True + + def __post_init__(self) -> None: + super().__post_init__() + 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.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) + 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) + 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( + PrivateUvProject.decode( + Repo.git(core.checkout, "show", f"{source.baseline.commit}:stack/pyproject.toml") + ), + PrivateUvProject.decode( + 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. The + revision it holds is the audit side it materializes, which owns the base/head choice.""" + + measures: ClassVar[type[audit.gate.Side]] + + @classmethod + def sides(cls) -> tuple[type[Side], ...]: + return cls.members_with(cls) + + @classmethod + def materializing(cls, measured: type[audit.gate.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) + + @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): + measures = audit.gate.BaseSide + + @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): + measures = audit.gate.HeadSide + + @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): """The head as GitHub reported it, with the base branch the same read named.""" @@ -129,7 +643,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 () @@ -155,6 +669,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]: @@ -192,6 +717,12 @@ 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.""" @@ -203,19 +734,20 @@ 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, - actor: ThreadIncarnation) -> None: ... + def require_reviewer( + self, goals: Goals, request: PullRequestHead, actor: ThreadIncarnation + ) -> None: ... def director_identity(self, goals: Goals, actor: ThreadIncarnation) -> ThreadIncarnation: return actor @abstractmethod - def record_review(self, goals: Goals, request: PullRequestHead, verdict: HeadVerdict, - actor: ThreadIncarnation) -> HeadReviewRecord: ... + def record_review( + self, goals: Goals, request: PullRequestHead, verdict: HeadVerdict, actor: ThreadIncarnation + ) -> HeadReviewRecord: ... @dataclass(frozen=True) @@ -233,6 +765,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 @@ -267,3 +803,95 @@ 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" + + @classmethod + 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" + ) + + +@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") + inputs = self.require_combined(request, project.repos) + goals.todos.require_input_sources(inputs) + + 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() + + +@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/historical_native_inputs.py b/src/agent_comms/historical_native_inputs.py index 37195dce59..7fbe04713e 100644 --- a/src/agent_comms/historical_native_inputs.py +++ b/src/agent_comms/historical_native_inputs.py @@ -35,7 +35,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 @@ -200,8 +200,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 fc369c5358..d2da0b2cd8 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -1,27 +1,29 @@ """Source-bound historical display values. These carry no delivery authority.""" from __future__ import annotations -from agent_comms.repo import Checkout +import re import shutil 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 cache, partial from pathlib import Path from typing import TYPE_CHECKING +from agent_comms.repo import Checkout + from .bus import Bus -from .child_process import Platform from .catalog_store import ChannelCatalog from .channel_targets import NamedTarget, ThreadTarget from .channels import Channel +from .child_process import Platform 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 ( @@ -31,9 +33,17 @@ MessagePageRequest, PageBounds, ) +from .message_reference import MessageReference from .messages import CarriesEnvelope, Message +from .notification_evidence import ( + FirstRecipient, + NotificationWindow, + OutcomeViewer, + RecipientCursor, + RecipientLimit, +) from .page_traversal import HistoryCursor, OlderTraversal -from .presentation import MessageNotification, OutcomeViewer +from .presentation import MessageNotification from .private_path import FileObservation, FileRevision, ObservedPath, PrivateDirectoryRole from .read_basis import ( AnonymousViewer, @@ -253,6 +263,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.""" @@ -270,8 +288,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: @@ -345,15 +363,22 @@ 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") 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, + *, + after: RecipientCursor = FirstRecipient(), + limit: RecipientLimit = RecipientLimit(), + ) -> NotificationWindow: """Read this namespace within its archive's current catalogue read, as this viewer may see it.""" @@ -361,9 +386,16 @@ def notification_references(self, sources: tuple[HistorySource, ...], references raise HistoricalSourceChanged("Historical source changed; refresh history") root = Path(self.root) result = MessageNotification.read_references( - Bus(Bus.path_in(root)), references, - partial(MessageNotification.recorded_delivery_window, root, self.provenance, - viewer=viewer), + Bus(Bus.path_in(root)), + references, + partial( + MessageNotification.recorded_delivery_window, + root, + self.provenance, + 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 0228b9354e..0bae547d4a 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -2,11 +2,6 @@ from __future__ import annotations -from agent_comms.field_codec import PresenceExclude -from agent_comms.repo import Checkout - -from .activity import ActivityCapture, ObservedActivity -from .activity_clocks import ChannelActivity import logging import time from abc import abstractmethod @@ -15,6 +10,11 @@ from pathlib import Path from typing import TYPE_CHECKING, Annotated +from agent_comms.field_codec import PresenceExclude +from agent_comms.repo import Checkout + +from .activity import ActivityCapture, ObservedActivity +from .activity_clocks import ChannelActivity from .catalog_store import ChannelCatalog from .cli_result import CliResult from .context_segments.retained import RetainedSegment @@ -30,10 +30,9 @@ from .transcript_sources import AssignedSourceIdentity from .view_unread import transcript_read_state - if TYPE_CHECKING: - from .registry_provenance import DeletedThread from .historical_views import HistoricalThread, HistorySource + from .registry_provenance import DeletedThread from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation from .agent_activity import AgentActivity @@ -58,8 +57,14 @@ from .message_reference import MessageReference from .messages import Message from .messaging import Messaging +from .notification_evidence import ( + FirstRecipient, + NotificationWindow, + OutcomeViewer, + RecipientCursor, + RecipientLimit, +) from .page_traversal import LiveCursor -from .notification_evidence import NotificationWindow from .presentation import ( BusPresentation, ChannelView, @@ -68,7 +73,6 @@ InboxRead, MessageNotification, NoRevision, - OutcomeViewer, ReadState, ThreadRecord, ThreadView, @@ -224,10 +228,22 @@ def observed_activity( return {name: capture.activity[name] for name in wanted if name in capture.activity} def message_notifications_for_references( - self, viewer: OutcomeViewer, references: Sequence[MessageReference], + self, + viewer: OutcomeViewer, + references: Sequence[MessageReference], + *, + after: RecipientCursor = FirstRecipient(), + limit: RecipientLimit = RecipientLimit(), ) -> 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/input_attempt.py b/src/agent_comms/input_attempt.py index 82393903a7..819c09cd0c 100644 --- a/src/agent_comms/input_attempt.py +++ b/src/agent_comms/input_attempt.py @@ -20,6 +20,7 @@ WireOmitDefault, WireRequired, ) +from .input_key import InputKey, InputKeyText, PublicInputKeyText from .input_origin import ( InputDelivery, InputOrigin, @@ -27,8 +28,10 @@ UnattributedInputOrigin, UndeliveredInput, ) -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 @@ -90,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 @@ -131,6 +134,22 @@ 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 consumed_progress(self, progress: DeliveryProgress) -> DeliveryProgress: + return progress + + 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: @@ -327,7 +346,6 @@ def review(self, goal_id: GoalId, decision: GoalInputDecision) -> StoredInput: return replace(self, goal_reviews={**self.goal_reviews, goal_id: decision}) - class RecoverableUnboundInput(StoredInput): def unbound_delivery(self, root_id: RootId) -> InputDelivery: return self.origin.delivery_reference(root_id) @@ -446,6 +464,40 @@ 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 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) + 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 21080f0552..78da1f3f8d 100644 --- a/src/agent_comms/input_disposition.py +++ b/src/agent_comms/input_disposition.py @@ -11,14 +11,14 @@ from agent_comms import field_codec -from .channel_targets import CommandTarget from .bus import Bus from .bus_tables import MessageIdentitySource +from .channel_targets import CommandTarget from .coordination_database import CoordinationStore, ReadableStore from .coordination_tables.harness_deliveries import HarnessDelivery -from .harness_delivery_states import Outcome, UnconfirmedOutcome from .errors import RelationViolationError from .field_codec import GoalId, InputId, RootId, TurnId +from .harness_delivery_states import Outcome, UnconfirmedOutcome from .input_attempt import ( UNOBSERVED_DELIVERY_SCOPE, GoalInputDecision, @@ -37,10 +37,18 @@ from .locked_store import DocumentChange, PublishedProperty from .message_reference import MessageReference from .messages import MessageInput +from .native_input_proof import NativeContextProof from .reported_turn import ReportedTurnState from .tool_result import ToolResult from .typed_table import ( - Column, DurableStore, Generated, Index, StoreRows, TextStorage, TypedTable, sql_literal, + Column, + DurableStore, + Generated, + Index, + StoreRows, + TextStorage, + TypedTable, + sql_literal, ) if TYPE_CHECKING: @@ -331,7 +339,6 @@ def _unknown(self, owners: frozenset[str], attempted: tuple[ReservedInput, ...]) def in_turn(self, turn_id: TurnId) -> InputDocument: return self._select('"turn_id" = ?', (turn_id,)) - @staticmethod def bus_key(message: MessageInput, owner: Thread) -> InputKey: return message.role.response_policy(message).disposition_key(message, owner) @@ -456,6 +463,47 @@ 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/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index 0230571a40..dddc140624 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -2,32 +2,632 @@ 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 email import policy +from email.parser import BytesParser +from functools import cached_property from pathlib import Path -from typing import TYPE_CHECKING, ClassVar -from urllib.parse import parse_qs, urlsplit - -from packaging.markers import Marker -from packaging.utils import canonicalize_name -from pydantic import BaseModel, Field +from tempfile import TemporaryDirectory +from typing import TYPE_CHECKING, Annotated, ClassVar, Self +from urllib.parse import parse_qsl, urlsplit +from zipfile import ZipFile + +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 +from packaging.utils import canonicalize_name, parse_wheel_filename +from packaging.version import Version +from pydantic import BaseModel, ConfigDict, Field, RootModel from agent_comms.declared_family import DeclaredFamily -from agent_comms.installer.probes.packages import ArchivePin, GitPin, PackagePin, PackageRecord +from agent_comms.field_codec import FieldCodec, GitCommitText, boundary_decoder +from agent_comms.installer.commands import InstallCommands from agent_comms.installer.interpreter_operation import RuntimeProbe from agent_comms.installer.probes.packages import ( - DistributionVersionsProbe, InstalledPackagesProbe, PreparePackageProbe, + DistributionVersionsProbe, + GitPin, + InstalledPackagesProbe, + PackagePin, + PackageRecord, + PreparePackageProbe, ) from agent_comms.installer.refusals import UpgradeRefused 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.runtime_stack import RuntimeStack +@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 + 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}") + 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}") + + 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) -> 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 native UV subdirectory field.""" + + def fields(self) -> dict[str, 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} + + +@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 + ) + + +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 UvSourceDeclaration: + """Source selection shared by individual and conditional UV declarations.""" + + def local(self) -> bool: + return False + + def tracked_sources(self, name: str) -> tuple[tuple[str, str], ...]: + return () + + +class UvSourceOptions(UvRecord, UvSourceDeclaration): + """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 + + 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).model_validate({**options, **self.document()}) + if marker: + selected.marker = marker + return selected + + +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 local(self) -> bool: + return True + + def tracked_sources(self, name: str) -> tuple[tuple[str, str], ...]: + GitSource.require_branches(name, (self.branch,)) + return ((self.git, self.branch),) + + 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", + 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}) + + +class UvLocalSource(UvSourceOptions): + path: str + + def local(self) -> bool: + return True + + 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]], UvSourceDeclaration): + """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, + ] = Field(default_factory=dict) + 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) -> 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] = [] + 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 = None + result.append(str(requirement)) + else: + result.append(text) + if not found: + result.append(str(locked)) + self.overrides = result + + +class UvProjectRoot(UvRecord): + dependencies: list[str] + requires_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 decode(cls, text: str) -> PrivateUvProject: + return FieldCodec.decode(cls, tomllib.loads(text)) + + @classmethod + def read(cls, path: Path) -> PrivateUvProject: + return cls.decode(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 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.""" + + @classmethod + def sources(cls, stack: Path) -> dict[str, tuple[str, str]]: + return cls.declared_sources(PrivateUvProject.read(stack / "pyproject.toml")) + + @staticmethod + 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 + @boundary_decoder + 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 FieldCodec.decode(dict[str, str], default_environment()) + + @classmethod + def require_compatible(cls, baseline: PrivateUvProject, candidate: PrivateUvProject) -> 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 = (baseline, candidate) + 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 + ) + for document in documents + ) + if contracts[0] != contracts[1]: + raise ValueError( + "External runtime dependency migration is unsupported by this common environment" + ) + + @staticmethod + def local_packages(*documents: PrivateUvProject) -> frozenset[str]: + """The source declarations own which distribution names are local.""" + return frozenset( + canonicalize_name(name) + for document in documents + for name, source in document.tool.uv.sources.items() + if source.local() + ) + + @classmethod + def virtual_requirements( + cls, document: PrivateUvProject, environment: dict[str, str] + ) -> tuple[Requirement, ...]: + """Only package=false roots have a declarative (non-wheel) runtime contract.""" + if document.tool.uv.package: + raise ValueError( + "A virtual runtime root requires tool.uv.package=false and project dependencies" + ) + ProjectMetadata.require_python(SpecifierSet(document.project.requires_python)) + return tuple( + requirement + for requirement in map(Requirement, document.project.dependencies) + if RequirementMarker.active(requirement.marker, environment, ("",)) + ) + + @classmethod + def active_requirements( + cls, + document: PrivateUvProject, + 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) + selected: dict[str, set[str]] = {} + pending = list(cls.virtual_requirements(document, environment)) + active: set[Requirement] = set() + while pending: + requirement = pending.pop() + # 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) + 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 RequirementMarker.active(edge.marker, environment, added) + ) + return tuple(sorted(active, key=str)) + + @staticmethod + def declared_sources(document: PrivateUvProject) -> dict[str, tuple[str, str]]: + found: dict[str, tuple[str, str]] = {} + for name, source in document.tool.uv.sources.items(): + for location in source.tracked_sources(name): + identity = canonicalize_name(name) + if identity in found: + raise ValueError(f"The stack declares package {identity} twice") + found[identity] = location + return found + + class RuntimePackage(DeclaredFamily, affix="Package"): distribution: ClassVar[str] repository: ClassVar[RepositoryName] @@ -75,6 +675,12 @@ class LockSource(BaseModel): def tracked(self, name: str) -> tuple[str, ...]: return () + def project_source(self) -> UvSourceOptions: + raise ValueError("Local/virtual sources have no external retention projection") + + def retain(self, name: str, marker: str, uv: UvProjectSettings) -> None: + uv.retain(name, marker, self.project_source()) + class RegistrySource(LockSource): registry: str @@ -82,6 +688,15 @@ class RegistrySource(LockSource): def pin(self, version: str, core: str) -> PackagePin: return PackagePin(version) + def project_source(self) -> UvIndexSource: + return UvIndexSource(index="locked-" + hashlib.sha256(self.registry.encode()).hexdigest()) + + 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): """Our sibling packages track main, the ref InstallDue measures them against; a unit @@ -89,19 +704,36 @@ 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, ...]: - 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 = 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 ((), (WorkBranches.main,)): + raise ValueError(f"{name} tracks {branches}, not {WorkBranches.main}") + def pin(self, version: str, core: str) -> PackagePin: - commit = urlsplit(self.git).fragment - if not commit: - raise ValueError("A resolved Git lock entry must name its commit") - return GitPin(version, commit) + 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) class EditableSource(LockSource): @@ -124,10 +756,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): @@ -137,6 +773,25 @@ 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: 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) + uv.constraints = [*uv.constraints, str(requirement)] + self.source.retain(canonicalize_name(self.name), marker, uv) + class LocalPackage(LockedPackage): source: EditableSource | VirtualSource @@ -154,6 +809,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/src/agent_comms/integration.py b/src/agent_comms/integration.py index 9721c934b5..7925bb288e 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -4,26 +4,51 @@ import argparse import os +import re import subprocess import sys import tomllib 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 .native_build_inputs import DetachedChildStdio +from .active_route import resolve_comms_route +from .build_identity import RepositoryCommit from .child_process import STOP_GRACE_SECONDS, ResidentManager, SpawnedProcess -from .field_codec import FieldCodec, WireName -from .head_review import HeadReview, PullRequestHead +from .commander.order import CommanderContext +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, + LocalIntegrationSource, + MergedIntegrationSource, + MergeHeadReview, + OrdinaryInput, + PromotedHeadReview, + PullRequestHead, + ResolvedIntegrationSource, + Side, + SourceCheckout, + UnpromotedHeadReview, +) from .launch_runtime import CheckoutSource, RestoredRuntime -from .repo import Checkout, GitCommandError, Repo +from .native_build_inputs import DetachedChildStdio +from .repo import DetachedStart, GitCommandError, Repo +from .repository_name import RepositoryName from .systemd_unit import AgentsSlice from .test_lease import LeaseStart, TestLease -from .todos import PullRequests +from .todos import CarriedHeadVerdict, PullRequests, StoredHeadReview, VerdictReason +from .work_branches import WorkBranches -class IntegrationFailed(RuntimeError): +class IntegrationFailed(ToolRefusedError): pass @@ -63,6 +88,11 @@ 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.""" + return None + def require_heads(self, heads: tuple[PullRequestHead, ...]) -> None: pass @@ -95,6 +125,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") @@ -110,7 +143,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: @@ -127,7 +160,47 @@ 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: + if path.exists(): + raise IntegrationFailed(f"Preserve existing qualification checkout: {path}") + repository.acquire(commit) + path.parent.mkdir(parents=True, exist_ok=True) + created = cls.add(repository.checkout, path, DetachedStart(commit)) + 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) + 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 preserves every source member, including when their merge conflicts.""" + self.require_editable() + 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, ...]: self.require_editable() @@ -137,15 +210,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) @@ -217,6 +284,415 @@ 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. + + 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 + 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, cls.merge_option + + @property + def parents(self) -> tuple[str, ...]: + return WorkBranches(self.repository.checkout).commit_parents(self.tested) + + @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: + environment = GitHubEnvironment.read() + repository = Repo.of(checkout) + 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 environment.tested != tested: + raise IntegrationFailed("The runner checkout is not the event's actual tested SHA") + GitCommitText.decode(tested) + 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, *, 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 != WorkBranches.main: + raise IntegrationFailed("The tested carrier no longer targets main") + + 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, 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) + + 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) + 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: + self.withdraw_merge_review() + raise + 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.""" + 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( + WorkBranches(self.repository.checkout).commit_message(self.carrier.head) + ) + urls = IntegrationInput.source_urls(self.repository, self.baseline, self.tested) + companions: list[Repo] = [] + for name, url in sorted(urls.items(), key=lambda item: item[0].key): + 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") + companions.append(repository) + repositories = (self.repository, *companions) + if payload: + (inputs,) = payload + else: + sources: list[IntegrationSource] = [ + LocalIntegrationSource( + RepositoryCommit(self.repository.github, self.baseline), + (self.carrier,), + self.tree, + ) + ] + for repository in companions: + commit = IntegrationSource.main_head(repository) + 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, 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 + ) + 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, 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.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(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: + for source in inputs.sources: + source.require_ordinary(self.carrier) + 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 + ) + + +class PullRequestSources(TestedSources, declared_name="pull_request"): + @classmethod + 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), + environment=environment, + ) + 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 + + 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: + 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. + """ + 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 + 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: + 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), + environment=environment, + ) + 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)) + 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 == 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" + ) + (row,) = rows + return PullRequestHead(repository.github, row.number, row.head) + + @classmethod + def resolve( + cls, repository: Repo, tested: str, document: object, *, environment: RunnerEnvironment + ) -> TestedSources: + match document: + 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): + 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_commits(self, inputs: IntegrationInput) -> dict[RepositoryName, str]: + return {**super().main_commits(inputs), self.repository.github: self.tested} + + def require_local_candidate(self, inputs: IntegrationInput) -> None: + """Main owns the transition from exact open sources to exact merged ancestry.""" + 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,)) + 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 + ): + raise IntegrationFailed( + f"#{head.pr} is not the exact captured source merged into this main" + ) + + @dataclass(frozen=True) class IntegrationRun: checkout: IntegrationCheckout @@ -242,6 +718,186 @@ def accepted_heads(self) -> tuple[PullRequestHead, ...]: if HeadReview.accepted(repo.github, request.head) ) + def qualify( + self, + context: CommanderContext, + problems: tuple[str, ...], + *, + 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() + 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) + 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" + ) + 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") + 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 companion_repositories.items() + }, + } + pulls = PullRequests(todos) + heads: dict[RepositoryName, tuple[PullRequestHead, ...]] = {} + for repo in repositories.values(): + repo.acquire(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 != WorkBranches.main + ): + raise IntegrationFailed( + "A linked source is closed, ambiguous or targets another base" + ) + selected.append(PullRequestHead(repo.github, row.number, row.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): + 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) + ) + 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() + resolved = {companion.repo: companion for companion in companions} + 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] = [ + 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 + # take the source family and never rediscover this distinction. + source: IntegrationSource + if name in resolved: + companion = resolved[name] + companion.current() + 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 + ) + 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]) + source = MergedIntegrationSource( + RepositoryCommit(name, baseline), heads[name], tree + ) + sources.append(source) + 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() + current_todos = tuple(context.comms.goals.todos.get(todo.id) for todo in todos) + for todo in current_todos: + todo.require_commander(context.incarnation) + context.comms.goals.todos.require_input_sources(inputs) + 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): + raise IntegrationFailed( + "Capture requires a fresh substantive integration commit, not nested metadata" + ) + 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) + 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: @@ -332,24 +988,79 @@ 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)) +@boundary_decoder def run() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--checkout", type=Path, required=True) - 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, - required=True, help="new persistent directory for this run's two bounded logs", ) - parser.add_argument("--risk", required=True) + 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", + ) + 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", + ) parser.add_argument( - ExcludeAfterFailure.option, type=ExcludeAfterFailure.argument, default=StopAfterFailure() + "--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( + ExcludeAfterFailure.option, type=ExcludeAfterFailure.argument, default=StopAfterFailure() + ) + local_options.add_argument( "pytest_arguments", nargs=argparse.REMAINDER, help="pytest's full argument surface after --; touched files are appended", @@ -358,13 +1069,69 @@ def run() -> int: pytest_arguments = arguments.pytest_arguments if pytest_arguments[:1] == ["--"]: pytest_arguments = pytest_arguments[1:] - return IntegrationRun( + 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: + 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: + 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: + 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] + } + if missing: + parser.error("Local integration requires --checkout, --output and --risk") + 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) + 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), + companions=tuple(PullRequestHead(head.repo, head.pr, head.head) for head in companions), + ) + return 0 + if arguments.with_problem or arguments.carrier or arguments.companion_carrier: + parser.error("--with-problem, --carrier and --companion-carrier require --qualify") + return run.execute() if __name__ == "__main__": diff --git a/src/agent_comms/manual_compaction_bridge.py b/src/agent_comms/manual_compaction_bridge.py index 46a9077f07..12e25f394c 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 AcpSessionId, TurnId from .acp_extension import CompactionCommittedUpdate, TranscriptChangedUpdate, encode_updates +from .transcripts import UnreadTranscriptCursor from .agent_backend import ( CompactionOutcome, FailedCompaction, @@ -115,7 +116,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/messages.py b/src/agent_comms/messages.py index ead153c68b..9aba90e36c 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -34,7 +34,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 ( @@ -59,6 +59,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 @@ -900,7 +901,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 @@ -1107,6 +1108,11 @@ def from_wire(cls, data: object) -> Message: # The field decoder has already required a record; do not validate/copy it again. return kind.decode_message({"ts": 0.0, **cast(Mapping[str, object], data)}) + def 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_entries.py b/src/agent_comms/native_entries.py index adbb0d0224..1f45d5b5b1 100644 --- a/src/agent_comms/native_entries.py +++ b/src/agent_comms/native_entries.py @@ -2,19 +2,18 @@ from __future__ import annotations -from .native_failures import NativePiUnavailable - import json from abc import ABC, abstractmethod -from contextlib import ExitStack, contextmanager from collections.abc import Callable, Iterable, Iterator -from itertools import repeat +from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field, replace from datetime import UTC, datetime +from itertools import repeat 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_failures import NativePiUnavailable from .native_pi import ( NativeEvidencePrefixChanged, NativeInputUncommitted, @@ -24,7 +23,6 @@ from .store_files import AtomicFile if TYPE_CHECKING: - from .native_pi import NativeContextProof from .importing import ImportedConversation from .declared_family import DeclaredFamily, UnknownFamilyMember @@ -40,14 +38,19 @@ WireOmitDefault, ) from .importing import ImportedSessionMetadata -from .native_input_record import NativeInputLocation +from .native_input_proof import ( + NativeContextJournal, + NativeContextProof, + NativeContextRecord, + NativeInputLocation, +) +from .native_session_reopen import PiSessionId from .pi_payloads import Opaque, PiField, PiMessage, PiOmitted, PiPayload, ReportedText from .pi_vocabulary import PendingStopReason, ThinkingLevel from .recorded_time import GeneratedTimestamp from .routing import NoTurnRouting, Routing from .transcript_events import ImportedMessage, NoticeTranscript, TranscriptEvent from .transcript_routes import InputPresentation, NoInputDisplay, _SessionRoutes -from .native_session_reopen import PiSessionId from .turn_item import FreshNativeId, NativeItemId @@ -183,7 +186,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 @@ -398,6 +401,78 @@ def read_input(self, location): self.observe() return self.tracked(location.input_id) + def context_proof( + self, + files: NativeSessionFiles, + input_id: InputId, + *, + 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.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, + 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. @@ -590,8 +665,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.""" + return self.message.input_id.require() + def require_tracked_user(self) -> MessageEntry: - self.message.input_id.require() + self.require_input_id() if not self.message.user or not self.message.input_digest.reported: raise MalformedRecord("Native Pi session has ambiguous tracked user input") self.id.require_present() diff --git a/src/agent_comms/native_file_artifact.py b/src/agent_comms/native_file_artifact.py index ef50d8e1ac..76ba0671d2 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,9 @@ 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 +22,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 new file mode 100644 index 0000000000..a4c1723592 --- /dev/null +++ b/src/agent_comms/native_input_proof.py @@ -0,0 +1,265 @@ +"""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 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, +) +from .locked_store import DurableFile, PatternFile, SQLiteFile +from .native_failures import NativePiUnavailable +from .native_integer import PositiveNativeIntegerBounds +from .native_session_reopen import PiSessionId +from .private_path import FileIdentity, PrivateFileRole +from .turn_item import FreshNativeId +from .typed_table import ( + Column, + DurableTables, + GuardedInserts, + ImmutableRows, + Index, + IntegerStorage, + ReconciledFile, + TableSchema, + TypedTable, + WithoutRowid, +) + + +@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[FreshNativeId, 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, PositiveNativeIntegerBounds): + """Native context facts extend the original committed input declaration.""" + + request_generation: Annotated[ + int, + WireName("requestGeneration"), + Column(primary_key=True, check=PositiveNativeIntegerBounds.sql_check("request_generation")), + ] + 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=PositiveNativeIntegerBounds.sql_check("committed_at_ms")), + ] + + def __post_init__(self) -> None: + Sha256Text.decode(self.llm_context_digest) + 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.""" + 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": {name: statement for name, statement in cls.schema_objects().items()}, + "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, 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 + 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. + """ + 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, + generations: range = NativeContextRecord.interval(), + ) -> 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()))} " + f"FROM {cls.declared_name} " + "WHERE input_id=? AND request_generation>=? AND request_generation NativeContextProof: + if self.input_commit != location.input_commit: + raise NativePiUnavailable("Native Pi proof journal source lineage differs") + return self.at(session_file, session_entry_start=location.session_entry_start) + + +class NativeInputProof(DurableFile, ReconciledFile, PatternFile, DurableTables): + """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 cls + + @classmethod + def schema_rows(cls) -> tuple[type[TypedTable], ...]: + return (NativeContextJournal,) + + +@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 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 + 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") diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index 5c7a83ecff..992e4531a2 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 @@ -35,12 +31,12 @@ MalformedRecord, NativeContext, ThreadLookup, - WireName, ) +from .native_input_proof import NativeContextProof +from .native_integer import PositiveNativeIntegerBounds from .native_session_reopen import PiSessionId -from .turn_item import FreshNativeId 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 @@ -57,38 +53,6 @@ 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[FreshNativeId, 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.""" @@ -404,6 +368,12 @@ 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): @@ -411,7 +381,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 @@ -421,6 +391,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 @@ -428,21 +403,24 @@ def require_recorded(self) -> NativeInputReference: def acquire(cls, row: NativeInputContext) -> NativeInputReference: """Decode the complete original SQL reference, never a partial projection.""" try: - result = FieldCodec.construct(cls, dict( - input_id=row.context_value("input_id"), - stage=row.reference_stage, - session_id=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") + result = FieldCodec.construct( + cls, + dict( + input_id=row.context_value("input_id"), + stage=row.reference_stage, + session_id=row.reference_session_id, + request_generation=row.context_value("request_generation"), + ), + ) return result 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) @@ -469,7 +447,6 @@ class NativeInputContext: input_id: InputId | NativeColumn stage: type[NativeInputExecution] | NativeColumn - session_id: PiSessionId | NativeColumn request_generation: NativeColumn @property @@ -481,6 +458,19 @@ 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 { + 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) @@ -505,10 +495,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_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 8dfec00d4c..f66026a5fe 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,16 +27,12 @@ FieldCodec, InputId, MalformedRecord, - 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 from .native_package import MANIFEST, UNIT_INSTRUCTIONS, VerifiedNativePackage from .native_session_files import NativeAgentSettings, NativeSessionFiles from .native_tool_call import NativeToolMode @@ -51,21 +43,12 @@ 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 CAPABILITY = "pi-native-input-v1-live-only" @@ -159,208 +142,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: Path - - 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.""" @@ -890,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_prompt_send.py b/src/agent_comms/native_prompt_send.py index 5725a150bc..00f5ff5b3b 100644 --- a/src/agent_comms/native_prompt_send.py +++ b/src/agent_comms/native_prompt_send.py @@ -9,20 +9,24 @@ from __future__ import annotations import asyncio -from abc import ABC, abstractmethod import os import select import sqlite3 import threading import time +from abc import ABC, abstractmethod from collections.abc import Callable from contextlib import AbstractContextManager, ExitStack from typing import TYPE_CHECKING -from .publication_measurements import PublicationMeasurements -from .field_codec import AcpSessionId -from .coordination_errors import CoordinationError + from agent_comms.errors import RelationViolationError +from .coordination_errors import CoordinationError +from .field_codec import AcpSessionId +from .native_entries import NativeEvidenceScope +from .native_input_proof import NativeContextProof +from .publication_measurements import PublicationMeasurements + if TYPE_CHECKING: from .input_drain import InputDrain from .owned_send import OwnedSend @@ -35,6 +39,13 @@ class PromptBoundary(ABC): @abstractmethod async def send_prompt(self, turn: TrackedTurnSession) -> 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: 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 c0ef89fdb4..60e0b83d6a 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -15,6 +15,7 @@ from agent_comms.coordination_tables.participants import Participants from agent_comms.private_runtime_schema import PrivateCoordinatorTable +from .class_fact import ClassFact from .coordination_errors import IdentityConflict, PublicationActivationBlocked, StaleFence from .coordination_tables.participants import OwnerGenerations from .coordination_tables.responses import ResponseObligation @@ -30,23 +31,19 @@ ToolCallId, ) from .native_claim_epoch import NativeClaimEpoch, UnrecordedNativeClaim -from .turn_item import FreshNativeId +from .native_input_proof import NativeContextRecord, NativeInputLocation from .native_input_record import ( NativeColumn, NativeInputContext, NativeInputExecution, - NativeInputLocation, NativeInputPosition, NativeInputRecord, NativeStageStorage, - NumberNativeColumn, RecordedNativePosition, - TextNativeColumn, TriageNativeExecution, UnrecordedNativeColumn, UnrecordedNativePosition, ) -from .native_pi import NativeContextRecord from .native_session_reopen import ( NativeSessionIdentity, NoPiSessionId, @@ -59,9 +56,9 @@ TriageDecisionRecord, ) from .tool_denial import SelectedToolDenied +from .turn_item import FreshNativeId from .typed_table import ( Column, - FinalizedColumn, FrozenColumn, IntegerStorage, MonotoneColumns, @@ -78,7 +75,7 @@ ) if TYPE_CHECKING: - from .native_pi import NativeContextProof + from .native_input_proof import NativeContextProof from .native_session_reopen import NativeSessionIdentity @@ -100,7 +97,6 @@ class PublishedReplyRevision(TypedRow): @dataclass(frozen=True) class NativeRuntimeInput( WriteOnceColumns, - FinalizedColumn, RequiredUpdateColumns, NoDelete, NativeInputRecord, @@ -135,8 +131,22 @@ 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 + @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) + if NativeContext.single(plan.annotation) + ), + ) + required_update_columns = ('sent_owner_claimed_generation',) @classmethod @@ -152,6 +162,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.""" @@ -321,36 +335,41 @@ 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", parameters=(self.input_id,), - session_entry_id=TextNativeColumn(context.session_entry_id.spelled_by.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), - verdict=verdict, + **self.context_columns(context), ) 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/native_session_files.py b/src/agent_comms/native_session_files.py index 465b0b5192..478b5bff57 100644 --- a/src/agent_comms/native_session_files.py +++ b/src/agent_comms/native_session_files.py @@ -15,15 +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 NativeInputProof from .process_claim import ProcessSingleton -from agent_comms.typed_table import ReconciledFile, TableSchema class SessionFiles: @@ -247,20 +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]: - from .native_pi import NativeContextJournal - - return NativeContextJournal - - class BusTranscriptFile(PatternFile): """A bus location holding saved native transcripts and their writer locks.""" diff --git a/src/agent_comms/native_session_reopen.py b/src/agent_comms/native_session_reopen.py index dd9cc76c2d..86a96f677d 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 @@ -27,6 +27,9 @@ class NativeReopenError(ValueError): 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") + def selected_session(self, row: NativeRuntimeInput) -> NativeSessionIdentity | NoSessionRetention: FieldCodec.decode(type(None), row.session_file) return NoSessionRetention() @@ -35,6 +38,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/src/agent_comms/native_transcript.py b/src/agent_comms/native_transcript.py index 121ddaddec..1a8148064e 100644 --- a/src/agent_comms/native_transcript.py +++ b/src/agent_comms/native_transcript.py @@ -6,14 +6,15 @@ from collections import defaultdict from collections.abc import Generator, Sequence from contextlib import closing, contextmanager, nullcontext -from itertools import takewhile from dataclasses import dataclass +from itertools import takewhile from math import inf from pathlib import Path from typing import TYPE_CHECKING, BinaryIO, Never from .errors import RelationViolationError -from .native_pi import NativeContextJournal, NativeContextProof +from .native_failures import NativePiUnavailable +from .native_input_proof import NativeContextJournal, NativeContextProof, NativeInputLocation if TYPE_CHECKING: from .native_output import NativeRecordLocation @@ -21,10 +22,12 @@ from .thread_identity import RecordedThreadIncarnation from agent_comms.native_session_files import NativeSessionFiles + from .coordination_errors import TranscriptChanged from .declared_family import DeclaredFamily from .field_codec import MalformedRecord from .native_entries import ( + MessageEntry, NativeEntry, NoEntryId, RecordedEntry, @@ -42,7 +45,7 @@ def reverse_records( path: Path, before: int, *, floor: int = 0, stream=None -) -> Generator[tuple[int, int, bytes], None, None]: +) -> Generator[tuple[int, int, bytes]]: """Scan offsets in fixed chunks, then read each complete record exactly once. Record size is independent of scan-buffer size. In particular, images/tool @@ -371,7 +374,11 @@ 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. @@ -383,12 +390,21 @@ 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: - proof = NativeContextJournal.for_input(db, user.input_id) - if proof is None: - raise MalformedRecord("Native publication has no committed context inclusion") + with NativeContextJournal.open_evidence(files.input_proof) as db: + try: + 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(), header, user, session_entry_start=session_entry_start, + self.path.absolute(), + NativeInputLocation( + user.require_input_id(), + header.id, + user.id.require_present(), + session_entry_start=session_entry_start, + ), ) def inputs_for(self, records: Sequence[NativeRecord], *, floor: int = 0) -> tuple[NativeRecord, ...]: diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index 69ee7d757c..fec903f8a0 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -4,12 +4,13 @@ import sqlite3 from abc import abstractmethod -from collections.abc import Iterable, Iterator, Sequence +from collections.abc import Iterable, Iterator from dataclasses import dataclass, replace from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING, NoReturn -from .audience_manifest import FrozenRecipient +from .frozen_recipient import FrozenRecipient from .bus import Bus from .coordination_database import CoordinationStore from .coordination_tables.assignments import WakeAssignment @@ -17,23 +18,29 @@ 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, ThreadLookup, boundary_decoder +from .input_disposition import InputDispositions from .message_reference import MessageReference +from .notification_evidence import ( + AssignmentResponse, + ConsumptionEvidence, + NotificationAssignmentRead, + NotificationSelection, + NotificationWindow, + UnansweredResponse, +) from .notification_progress import ( - AnsweredProgress, AssignmentProgress, QueuedProgress, SentProgress, ShownToModelProgress, ) -from .notification_evidence import NotificationAssignmentRead, NotificationWindow, SourceEvidence from .private_registry_guard import RegistryPrivacy from .registry_store import RegistryStore from .typed_table import BooleanStorage, TypedRow if TYPE_CHECKING: from collections.abc import Mapping - from typing import Self from .agent_activity import RecipientActivity from .bus_publication import Delivered @@ -73,69 +80,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 replace( - notification, - state=AnsweredProgress(notification.state), - answer=self.reference, - answered_at_ms=self.received_at_ms, - ) - - @dataclass(frozen=True) class AssignmentActivity: triage_inflight: bool @@ -151,9 +95,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): @@ -234,36 +175,49 @@ 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.""" - 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.""" + """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) 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") @@ -271,99 +225,154 @@ 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 - ) - 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})) - replies: dict[MessageReference, list[Delivered]] = {} + def capture_responses( + cls, root: Path, read: NotificationAssignmentRead + ) -> NotificationAssignmentRead: + """Capture bounded independent replies after releasing the coordinator snapshot. + + 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. + """ + keys = frozenset(read.selection.keys) + if not keys: + return read + 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 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, - ) - if response is not None: - row = replace(row, activity=row.activity.recorded_response(response)) - resolved.append(row) - return tuple(resolved) + key = (reply.sender_lookup, answered) + if key in keys: + responses[key] = responses.get(key, UnansweredResponse()).recorded(reply) + 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): 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 + 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: + """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, 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 selects 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)})), " - f"context AS MATERIALIZED ({privacy.notification_context_sql(connection)}) " - f"SELECT {columns},context.triage_inflight," - "pointer.execution_id IS NOT NULL AS queued,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), + context = privacy.notification_context_sql(connection) + rows = [] + # Each batch's selected frozen keys select their assignments through the + # recipient/seq index; message_id corroborates the exact original, and native-input + # membership is selected once per batch by its assignment index. + for requested in WakeAssignment.requested_keys( + connection, + tuple( + { + "recipient_lookup": lookup, + "wire_seq": reference.seq, + "message_id": reference.message_id, + } + for lookup, reference in keys ), - AssignmentActivityEvidence, + ): + 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 IS NOT NULL AS queued,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, + ) + ) + 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))), + MappingProxyType(consumption), ) - 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) @classmethod - def read( - cls, root: Path, sources: Sequence[Delivered] - ) -> NotificationAssignmentRead: - """Capture assignment evidence through the single observing connection owner.""" + def read(cls, root: Path, selection: NotificationSelection) -> NotificationAssignmentRead: + """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, + selection, + privacy, ) as result: - return result + captured = result + return cls.capture_detached(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 9759d51f48..8a3f7e9308 100644 --- a/src/agent_comms/notification_evidence.py +++ b/src/agent_comms/notification_evidence.py @@ -3,56 +3,357 @@ 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 +from types import MappingProxyType from typing import TYPE_CHECKING, ClassVar +from pydantic import JsonValue + +from .declared_family import DeclaredFamily +from .frozen_recipient import FrozenRecipient +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 .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 .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 - from .notification_progress import DeliveryProgress + from .notification_progress import AssignmentProgress, DeliveryProgress from .presentation import MessageNotification, WireRevision - from .thread_identity import ThreadIncarnation + + +@dataclass(frozen=True) +class RecipientLimit(SingleFieldValue): + """Decode one bounded recipient-window size at the request boundary.""" + + minimum: ClassVar[int] = 1 + maximum: ClassVar[int] = 100 + value: int = maximum + + def __post_init__(self) -> None: + if not self.minimum <= self.value <= self.maximum: + raise ValueError(f"Recipient limit must be between {self.minimum} and {self.maximum}") + + @classmethod + def schema(cls) -> dict[str, JsonValue]: + return {"type": "integer", "minimum": cls.minimum, "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[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) + + +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: 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: + 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), + ) + + def detail_texts(self) -> tuple[str, ...]: + return () + + @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, message.at_ms) + + 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) + + def answered(self, notification: MessageNotification) -> MessageNotification: + return notification.state.answered_notification(notification, self) + + def detail_texts(self) -> tuple[str, ...]: + return (f"Answer #{self.reference.seq} · {self.reference.message_id}",) @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, 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) + 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: + return self.responses.get(recipient, UnansweredResponse()).answered(notification) 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) + """The recipient's progress after each of its own harness attempts and stored inputs + of this original.""" + for attempt in self.consumption.get(recipient, ()): + progress = attempt.consumed_progress(progress) return progress @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.""" - sources: tuple[Delivered, ...] + selection: NotificationSelection rows: tuple[tuple[WakeAssignment, AssignmentActivityEvidence], ...] - attempts: tuple[HarnessDelivery, ...] = () + consumption: Mapping[tuple[ThreadLookup, MessageReference], tuple[ConsumptionEvidence, ...]] = ( + field(default_factory=lambda: MappingProxyType({})) + ) + responses: Mapping[tuple[ThreadLookup, MessageReference], AssignmentResponse] = field( + default_factory=lambda: MappingProxyType({}) + ) - @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]: @@ -60,16 +361,26 @@ 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 } + for (lookup, source), response in self.responses.items(): + responses[source][lookup] = response + 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()}) - for source in claims} + 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: return any(previous != fresh.get(source, SourceEvidence()) @@ -105,6 +416,17 @@ 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/notification_progress.py b/src/agent_comms/notification_progress.py index 81925885a9..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 .message_reference import MessageReference +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)) @@ -72,9 +76,14 @@ 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 answered_notification(self, notification: MessageNotification, + response: AnsweredResponse) -> MessageNotification: + """An independent answer preserves this state's actual context proof.""" + 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.""" + return proof class ConsumedDelivery(SettledDelivery): @@ -84,8 +93,8 @@ class ConsumedDelivery(SettledDelivery): ignored_label: ClassVar[str] = "Seen, not answering" consumed: ClassVar[bool] = True - def harness_consumed(self) -> 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: @@ -95,11 +104,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 +125,20 @@ 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()) + @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)) 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) -> DeliveryProgress: - return self.proof.harness_consumed() + def model_consumed(self, proof: ShownToModelProgress) -> DeliveryProgress: + return self.proof.model_consumed(proof) class PassiveProgress(ContextualProgress): @@ -183,18 +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: AnsweredResponse) -> 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.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 @@ -210,11 +229,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): @@ -263,6 +277,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/notification_read.py b/src/agent_comms/notification_read.py index 22467ba40f..30a473f897 100644 --- a/src/agent_comms/notification_read.py +++ b/src/agent_comms/notification_read.py @@ -1,13 +1,17 @@ -"""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 .activity import ObservedActivity from .notification_assignment import NotificationAssignment -from .notification_evidence import NotificationWindow -from .revision_views import NotificationEvidenceView +from .notification_evidence import NoNotificationWindow, NotificationWindow +from .registry_provenance import DeletedThread +from .revision_views import ActivityView, NotificationEvidenceView if TYPE_CHECKING: from .comms import Comms @@ -24,3 +28,42 @@ 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 the participants + its notifications name, captured before the read for participants already known and right + 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] = 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[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.""" + 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) + + +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..91ce13ba9d --- /dev/null +++ b/src/agent_comms/notification_reads.py @@ -0,0 +1,69 @@ +"""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 .bus_read import UiRead +from .comms import Comms +from .historical_views import HistorySource, HistorySourceKey +from .message_reference import MessageReference +from .notification_evidence import ( + FirstRecipient, + NotificationWindow, + RecipientCursor, + RecipientLimit, +) +from .notification_read import NotificationsRead +from .presentation import WireRevision +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: RecipientLimit = field(default_factory=RecipientLimit, kw_only=True) + + @abstractmethod + def changed_since( + self, comms: Comms, revision: WireRevision, previous: NotificationsRead + ) -> bool: ... + + +@dataclass(frozen=True) +class LiveNotificationRead(NotificationRead): + def apply(self, comms: Comms, reader: ReadAuthority) -> NotificationWindow: + return comms.views.message_notifications_for_references( + reader.outcome_viewer(comms.registry), + self.references, + after=self.after, + limit=self.limit, + ) + + def changed_since( + self, comms: Comms, revision: WireRevision, previous: NotificationsRead + ) -> bool: + return previous.live_changed(comms, revision) + + +@dataclass(frozen=True) +class RecordedNotificationRead(NotificationRead): + source: HistorySourceKey + + def apply(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.registry), + after=self.after, + limit=self.limit, + ) + + def changed_since( + self, comms: Comms, revision: WireRevision, previous: NotificationsRead + ) -> bool: + return revision.history != previous.revision.history diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index 9b9314efc9..c89186f630 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -5,6 +5,7 @@ from abc import abstractmethod from collections.abc import Mapping from dataclasses import dataclass, field, replace +from functools import partial from typing import Annotated, Literal from pydantic import JsonValue @@ -14,33 +15,38 @@ CorrectCheckTodo, CorrectRunTodo, DeliveryReport, + HeadReviewed, + HeadVerdict, InstallTodo, + JudgedCall, + NoHeadOrder, OrderClaim, OrderCompletion, OrderProgress, - TodoProblems, PlainText, ProgressRead, PullRequestLink, ResumeClaimOrder, - HeadReviewed, - HeadVerdict, - NoHeadOrder, ServeTodo, StrikeReason, Todo, TodoOperation, - JudgedCall, + TodoProblems, ToolCallVerdict, ) -from .commander_hud import CommanderHud, CommanderLoad -from .service_passes import ServicePassStore + +from .acp_extension import ( + GoalSnapshotReply, + TranscriptSnapshotUpdate, + TurnChangedUpdate, + UpdateMetadata, +) +from .bus_read import OwnerLiveRead, UiRead +from .commander_hud import CommanderFrame, CommanderHud, CommanderLoad, CommanderUnitDetail from .comms import Comms +from .coordinator import Coordination from .errors import ToolRefusedError from .field_codec import FieldCodec, MessageId, OrderId, RecordProblems, WireName -from .problem_number import ProblemNumber -from .tool_declaration import EditCall -from .work_branches import WorkBranchError from .goal_actions import CreateProblemGoalAction, OrderGoalAction from .goals import Goal, OrderCheck from .head_review import HeadReviewOrder, PreparedHeadReview, PullRequestHead @@ -48,11 +54,16 @@ from .input_disposition import DeliveryOverview from .order_story import FleetStory, OrderStory from .order_work import OrderWork +from .owner_connection import AttachedRuntimeRequestContext from .plan_phases import OrderPhase +from .problem_number import ProblemNumber from .progress_claims import ProgressClaim +from .read_basis import ReadAuthority from .send_result import CorrectionResult, SendResult +from .service_passes import ServicePassStore from .thread_identity import ThreadIncarnation from .tool_caller import ThreadCaller, ToolCaller +from .tool_declaration import EditCall from .tool_request import ( PublishingToolRequest, ReadTool, @@ -63,18 +74,27 @@ ) from .tool_result import ToolResult from .transcript_events import SentTranscript +from .transcripts import ( + AtFrontier, + LatestPage, + LivePageSource, + PageLimit, + PagePosition, + PageSource, + TranscriptRead, +) from .turn_digests import ( ActivityTurnView, LastTurns, OrderEditsRead, ThreadTurns, TurnBound, - TurnView, TurnsSince, - UnjudgedEdits, + TurnView, UnavailableOrderEdits, + UnjudgedEdits, ) - +from .work_branches import WorkBranchError @dataclass(frozen=True) class TodoChanged(ToolResult): @@ -524,13 +544,137 @@ def apply(self, comms: Comms, caller: ToolCaller) -> ThreadTurns: @dataclass(frozen=True, kw_only=True) -class CommsInputDeliveryTool(ThreadReadTool): +class InputDeliveryRead( + OwnerLiveRead[DeliveryOverview], + 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.") + include_history: Annotated[ + bool, *tool_field("Include earlier delivery notices; current inputs are always read") + ] = True + + async def owned(self, ctx: AttachedRuntimeRequestContext) -> DeliveryOverview: + scope = await ctx.server.agent.inputs.delivery_scope(ctx.session) + 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) +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.""" + + 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: AttachedRuntimeRequestContext) -> 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, + ) + ) + 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 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 an earlier_than or later_than position for the " + "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() + ) + page_source: Annotated[ + PageSource, *tool_field("Live history or a recorded archive identity") + ] = LivePageSource() + + 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, + page_source=self.page_source, + ) + + def apply(self, comms: Comms, reader: ReadAuthority) -> 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", +): + """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 apply(self, comms: Comms, caller: ToolCaller) -> DeliveryOverview: - return comms.goals.input_delivery(caller.subject(self.thread), include_history=True) + def apply(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" + ) + hud = CommanderFrame.current(comms, self.commander.name) + hud.require_current() + 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) diff --git a/src/agent_comms/owned_send.py b/src/agent_comms/owned_send.py index 62808c3248..73b757288a 100644 --- a/src/agent_comms/owned_send.py +++ b/src/agent_comms/owned_send.py @@ -5,7 +5,7 @@ import logging from contextlib import AbstractContextManager from dataclasses import dataclass -from pathlib import Path +from functools import partial from typing import TYPE_CHECKING from agent_comms.ordinary_turn_rules import ( @@ -26,6 +26,7 @@ from .envelope_claim_transitions import NativeClaim from .field_codec import AcpSessionId, ClaimId, InputId, TurnId from .input_attempt import InputAlreadyCleared +from .native_input_proof import NativeContextProof from .reservation_rules import ReservationRule, ReservationViolationError from .tool_denial import SelectedToolDenied from .turn_input_source import ( @@ -172,6 +173,58 @@ 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 a4561290b6..722d6ae1d4 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -13,7 +13,6 @@ replace, ) from functools import partial -from pathlib import Path from typing import TYPE_CHECKING, NoReturn from agent_comms.following_inputs import OwnerFollowingInputs @@ -26,7 +25,8 @@ from .channel_input_batch import InputBatch from .coordination_errors import CoordinationError, Refused from .coordinator import Coordination -from .field_codec import AcpSessionId, GoalId, TurnId +from .cursor_publication import CursorDelivery +from .field_codec import AcpSessionId, FieldCodec, GoalId, TurnId from .goals import Goal from .image_input import ImageInput from .input_attempt import ReservedInput @@ -83,6 +83,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 @@ -132,7 +133,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 @@ -381,6 +382,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 @@ -415,11 +417,10 @@ async def acquire(self, resources: AsyncExitStack, permit_cleanup: AsyncExitStac return True async def run(self) -> None: - async with AsyncExitStack() as resources: - async with AsyncExitStack() as permit_cleanup: - if not await self.acquire(resources, permit_cleanup): - return - await self.run_native() + async with AsyncExitStack() as resources, AsyncExitStack() as permit_cleanup: + if not await self.acquire(resources, permit_cleanup): + return + await self.run_native() def settle_unbound(self): """Read the result of the input owner's exact retirement transaction.""" diff --git a/src/agent_comms/owner_connection.py b/src/agent_comms/owner_connection.py index aed3577c18..1d49d2e340 100644 --- a/src/agent_comms/owner_connection.py +++ b/src/agent_comms/owner_connection.py @@ -6,31 +6,30 @@ import json import os import socket -from functools import partial -from pathlib import Path -from typing import TYPE_CHECKING, Annotated, ClassVar, IO, cast -from abc import abstractmethod +from abc import ABC, abstractmethod from collections.abc import Awaitable, Iterator from contextlib import contextmanager from dataclasses import dataclass, replace -from .command import Command -from .declared_family import DeclaredFamily -from .tool_result import DeclaredReply -from .thread_identity import ThreadIncarnation +from functools import partial +from pathlib import Path +from typing import IO, TYPE_CHECKING, Annotated, ClassVar, Self, cast +from .command import Command from .coordinator import Coordination +from .declared_family import DeclaredFamily from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import AcpSessionId, FieldCodec, NullMember, WireOmitDefault -from .maintenance_barrier import MaintenanceHolder, NoMaintenanceHolder from .jsonl_stream import JsonlStreamReader +from .maintenance_barrier import MaintenanceHolder, NoMaintenanceHolder from .owner_protocol import OwnerLine, OwnerResult from .private_path import PrivateSocketRole +from .thread_identity import OwnerIdentity, ThreadIncarnation +from .tool_result import DeclaredReply if TYPE_CHECKING: - from .comms import Comms from .child_process import ProcessIdentity + from .comms import Comms from .registry_document import RegistryEntry - from .thread_identity import OwnerIdentity from .runtime import RuntimeServer, SocketClient from .threads import Thread @@ -177,6 +176,13 @@ def __init__(self, comms: Comms, identity: ThreadIncarnation, path: Path): self._requests: set[asyncio.Task] = set() 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") @@ -278,3 +284,56 @@ 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. + + 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, owner, socket_path(comms.root, thread.require_process().pid)) + connection.require_identity(owner) + 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/participant_identity.py b/src/agent_comms/participant_identity.py index 7e06bbf69e..c334919a58 100644 --- a/src/agent_comms/participant_identity.py +++ b/src/agent_comms/participant_identity.py @@ -1,8 +1,10 @@ """The stable participant lookup shared by registry and coordinator identities.""" +import re from abc import ABC, abstractmethod -from .field_codec import ThreadLookup +from .coordination_contracts import MAX_IDENTIFIER_CHARS +from .field_codec import MalformedRecord, ThreadLookup class ParticipantIdentity(ABC): @@ -10,6 +12,19 @@ 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 not re.fullmatch(r"[a-zA-Z0-9_-]+", value): + raise MalformedRecord(f"{label} must be a canonical thread name") + @property @abstractmethod def lookup(self) -> ThreadLookup: ... diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index 8dc10216b2..761505e868 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -25,7 +25,7 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec -from agent_comms.head_review import HeadReview +from agent_comms.head_review import MergeHeadReview from agent_comms.repo import Checkout, CheckoutHistory, GitCommandError, Repo if TYPE_CHECKING: @@ -546,10 +546,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 @@ -565,10 +566,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: @@ -576,9 +576,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)) @@ -591,18 +591,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: @@ -613,9 +614,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/pi_commands.py b/src/agent_comms/pi_commands.py index 96e03bdcd8..d93f8aa0b3 100644 --- a/src/agent_comms/pi_commands.py +++ b/src/agent_comms/pi_commands.py @@ -28,7 +28,9 @@ WireTransient, ) from .image_input import ImageInput +from .native_entries import NativeEvidenceScope 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 @@ -55,9 +57,9 @@ if TYPE_CHECKING: from .backend import TurnSession - from .pi_events import Response, ResponseOutcome - from .pending_requests import PendingRequest from .launch_runtime import RestartEnvironment + from .pending_requests import PendingRequest + from .pi_events import Response, ResponseOutcome class SessionSnapshot: @@ -210,6 +212,17 @@ class Prompt(PiCommand): WireName("streamingBehavior"), ] = None + async def input_committed(self, session: TurnSession, event: NativeInputCommit) -> None: + await session.original_committed(event) + + async def context_committed( + self, + session: TurnSession, + context: NativeContextProof, + source_reads: NativeEvidenceScope, + ) -> None: + await session.original_context_committed(context, source_reads) + @classmethod def response_type(cls) -> type[PiCommand]: return Prompt @@ -239,6 +252,19 @@ class FollowingPrompt(Prompt, declared_name=None): input: Annotated[InputRequest, WireTransient()] + 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( + self, + session: TurnSession, + context: NativeContextProof, + source_reads: NativeEvidenceScope, + ) -> None: + 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. return () diff --git a/src/agent_comms/pi_events.py b/src/agent_comms/pi_events.py index 53e3d8a9d1..077462c540 100644 --- a/src/agent_comms/pi_events.py +++ b/src/agent_comms/pi_events.py @@ -15,16 +15,20 @@ DeclaredWireType, FieldCodec, FieldPlan, + InputId, + MalformedRecord, ToolCallId, TurnId, WireName, WireNullDefault, WireOmitDefault, + boundary_decoder, ) from .native_failures import NativePiUnavailable -from .native_input_record import NativeInputCommit, NativeInputLocation +from .native_input_proof import NativeContextRecord, NativeInputCommit, NativeInputLocation from .native_output import NativeOutputLocation, NativeRecordLocation from .native_pi import NativePiOperationFailed +from .native_session_reopen import PiSessionId from .native_turn_context import NativeContextManifestData from .pi_commands import ExtensionUiResponse, PiCommand, UnknownCommand from .pi_payloads import ( @@ -273,7 +277,7 @@ def invalidates_stop(self) -> bool: @dataclass(frozen=True, kw_only=True) class ModelRequestProgress(NativePiEvent): - progress: "RequestProgress" + progress: RequestProgress def observe_request(self, observer: Callable[[RequestProgress], None]) -> bool: observer(self.progress) @@ -421,6 +425,35 @@ 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/pi_native_backend.py b/src/agent_comms/pi_native_backend.py index 77b18a79da..2e9cb921a8 100644 --- a/src/agent_comms/pi_native_backend.py +++ b/src/agent_comms/pi_native_backend.py @@ -279,6 +279,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/presentation.py b/src/agent_comms/presentation.py index 13724e8e86..6317c80901 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -10,7 +10,7 @@ from datetime import datetime from functools import partial 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 @@ -19,13 +19,12 @@ AgentActivity, RecordedRecipientActivity, ) -from .audience_manifest import FrozenRecipient +from .frozen_recipient import FrozenRecipient from .bus import DisplayUnread from .channel_targets import CommandTarget 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 @@ -36,10 +35,7 @@ FieldCodec, JsonShapeFamily, JsonShapeMember, - NullMember, PresenceExclude, - RecordMember, - SubclassUnion, ThreadLookup, WatchPaths, WatchViews, @@ -59,9 +55,25 @@ ) from .message_reference import MessageReference from .messages import Message +from .notification_assignment import NotificationAssignment +from .notification_evidence import ( + AssignmentResponse, + FirstRecipient, + NotificationAssignmentRead, + NotificationReadMark, + NotificationWindow, + OutcomeViewer, + RecipientCursor, + RecipientLimit, +) from .notification_progress import ( - ConsumedDelivery, ConsumingProgress, DeliveryProgress, HumanReadProgress, WorkingTakenProgress, + ConsumedDelivery, + ConsumingProgress, + DeliveryProgress, + HumanReadProgress, + WorkingTakenProgress, ) +from .notification_read import CapturedNotificationWindow from .private_path import FileRevision from .read_basis import ( AnonymousViewer, @@ -71,9 +83,6 @@ NamedViewer, ) from .read_ledger import NoReadNotice, ReadLedger, ReadNotice -from .notification_assignment import NotificationAssignment -from .notification_evidence import NotificationAssignmentRead, NotificationReadMark, NotificationWindow -from .notification_read import CapturedNotificationWindow from .recorded_bus import NoRecordedBus, RecordedBusIdentity from .registry_document import DEFAULT_ROSTER, RosterVisibility from .revision_views import ( @@ -119,62 +128,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 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 - - @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 includes(self, sender_lookup: ThreadLookup, recipient: FrozenRecipient) -> bool: - return True - - def sees_direct_read(self, source: Delivered) -> bool: - return True - - def sees(self, source: Delivered) -> bool: - return True - - -@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 includes(self, sender_lookup: ThreadLookup, recipient: FrozenRecipient) -> bool: - return self.lookup in (sender_lookup, recipient.recipient_lookup) - - 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) - - @dataclass(frozen=True, slots=True) class MessageNotification: """Read-only display of one recipient's durable assignment decision.""" @@ -184,10 +140,12 @@ class MessageNotification: state: DeliveryProgress message: Message | None = None displayed_to: tuple[ThreadIncarnation, ...] = () - answer: MessageReference | None = None - answered_at_ms: int | None = None changed: bool = False + @property + def answer(self) -> AssignmentResponse: + return self.state.answer + @property def recipient(self) -> str: return self.recipient_identity.canonical_thread @@ -202,9 +160,9 @@ def label(self) -> str: 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) + return SentMessageOutcome( + self.recipient_identity, self.sender_lookup, self.state, message=self.message + ) @classmethod def ordered(cls, notifications: Iterable[MessageNotification]) -> tuple[MessageNotification, ...]: @@ -215,8 +173,11 @@ 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" - # Each notification exists because the frozen publication addressed its recipient. + # 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) consumed = sum(notification.state.consumed for notification in ordered) read = sum(bool(notification.displayed_to) for notification in ordered) @@ -224,13 +185,15 @@ def summary(cls, notifications: Iterable[MessageNotification]) -> str: (WorkingTakenProgress.label, sum(item.state.seen_working for item in ordered)), (ConsumedDelivery.ignored_label, sum(item.state.seen_not_answering for item in ordered))) leading = ordered[0] - 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}", - )) + return " · ".join( + ( + *leading.state.summary_prefix(leading), + *(f"{label} {count}" for label, count in counted if count), + f"Observed {total} recipients (bounded)", + f"{ConsumedDelivery.label} {consumed} observed", + *((f"{HumanReadProgress.label} {read} observed",) if read else ()), + ) + ) @staticmethod def timed_state(label: str, timestamp_ms: int | None) -> str: @@ -246,14 +209,23 @@ def timed_state(label: str, timestamp_ms: int | None) -> str: @classmethod def references( - cls, root: Path, registry: Registration, log: Bus, - references: Sequence[MessageReference], *, viewer: OutcomeViewer, + cls, + root: Path, + registry: Registration, + log: Bus, + references: Sequence[MessageReference], + *, + viewer: OutcomeViewer, + after: RecipientCursor = FirstRecipient(), + limit: RecipientLimit = RecipientLimit(), ) -> 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) @@ -276,67 +248,93 @@ 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: + def recorded_delivery_window( + cls, + root: Path, + namespace: RegistryProvenance, + sources: Sequence[Delivered], + *, + viewer: OutcomeViewer, + after: RecipientCursor = FirstRecipient(), + limit: RecipientLimit = RecipientLimit(), + ) -> 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, + cls, + root: Path, + snapshot: RegistryProvenance, + 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( 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 ) @@ -344,7 +342,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), ) ) @@ -371,8 +369,20 @@ def direct_read_notification( source, recipient, snapshot, document=document ) mark = NotificationReadMark(source, recipient, displayed_to) - notification = (cls(recipient, source.sender_lookup, HumanReadProgress(), - displayed_to=displayed_to, changed=True),) if displayed_to else () + visible = viewer.visible_reads(displayed_to, document.bus_identity) + notification = ( + ( + cls( + recipient, + source.sender_lookup, + HumanReadProgress(), + displayed_to=visible, + changed=True, + ), + ) + if visible + else () + ) return CapturedNotificationWindow({(message.seq, message.message_id): notification}, (), (mark,)) return CapturedNotificationWindow({}, ()) @@ -392,17 +402,21 @@ def sent_sources( @classmethod def for_sources( - cls, root: Path, registry: Registration, viewer: OutcomeViewer, - sources: Sequence[Delivered], *, + cls, + root: Path, + registry: Registration, assignment_read: NotificationAssignmentRead, ) -> tuple[MessageNotification, ...]: - """Attach notification outcomes to the messages the caller already checked.""" + """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, viewer=viewer, + root, + registry, + assignment_read, ) return tuple( - replace(notification, message=source.message) - for source in sources + replace(notification, message=source.envelope) + for source in assignment_read.sources for notification in projected.outcomes[source.message.seq, source.message.message_id] ) @@ -413,44 +427,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 @@ -461,13 +437,13 @@ class SentMessageSummary: 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 + 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( - window.outcomes.get((reference.seq, reference.message_id), ()))) - for reference in presentation.sent_references + cls(reference, MessageNotification.summary(outcomes)) for reference, outcomes in sources ) @@ -879,7 +855,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/src/agent_comms/private_registry_guard.py b/src/agent_comms/private_registry_guard.py index c8797126bc..23e2308101 100644 --- a/src/agent_comms/private_registry_guard.py +++ b/src/agent_comms/private_registry_guard.py @@ -22,7 +22,7 @@ import stat import struct from abc import ABC, abstractmethod -from collections.abc import Callable, Sequence +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, NoReturn @@ -47,11 +47,11 @@ from .private_runtime_schema import PrivateCoordinatorTable from .thread_identity import ClaimedIdentity from .notification_evidence import NotificationAssignmentRead +from .notification_evidence import NotificationSelection if TYPE_CHECKING: from .acp_extension import CursorAdvancedUpdate, CursorScope - from .bus_publication import Delivered from .cursor_publication import CursorPublication from .historical_native_inputs import HistoricalNativeAnswers from .messages import MessageReference @@ -175,7 +175,7 @@ def context_entered_sql(self, assignment: str) -> str: @abstractmethod def missing_notification_assignments( - self, sources: Sequence[Delivered], + self, selection: NotificationSelection, ) -> NotificationAssignmentRead: ... @abstractmethod @@ -215,9 +215,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, @@ -306,7 +306,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/private_send_claim.py b/src/agent_comms/private_send_claim.py index a127861037..68ac5610d1 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 @@ -238,8 +239,25 @@ def settle(store: Coordination, terminal: NativePiTerminalFailure = error): ) return result + 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 proves context inclusion. + """ + 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..88953a8f89 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, @@ -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, @@ -151,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()), @@ -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/private_sidecar.py b/src/agent_comms/private_sidecar.py index 975ed61a1a..fcdb83a389 100644 --- a/src/agent_comms/private_sidecar.py +++ b/src/agent_comms/private_sidecar.py @@ -30,6 +30,7 @@ from agent_comms.locked_store import DurableFile, SQLiteFile from .field_codec import FieldCodec +from .image_input import ImageInput if TYPE_CHECKING: from .turn_inbox import StreamingInputPolicy @@ -506,13 +507,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", @@ -520,8 +524,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/prompt_state.py b/src/agent_comms/prompt_state.py index 8fcaa749e0..9f2bc88dc6 100644 --- a/src/agent_comms/prompt_state.py +++ b/src/agent_comms/prompt_state.py @@ -6,12 +6,13 @@ from typing import TYPE_CHECKING, Never from . import turn_failure as failures +from .native_input_proof import NativeInputCommit from .turn_inbox import TurnInboxItem from .native_pi import NativePiInputNotSent 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: @@ -88,7 +89,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/proven_source_coverage.py b/src/agent_comms/proven_source_coverage.py index a6dc20cc64..b255894a63 100644 --- a/src/agent_comms/proven_source_coverage.py +++ b/src/agent_comms/proven_source_coverage.py @@ -18,7 +18,7 @@ from agent_comms.lifecycle_capability import Active from agent_comms.native_runtime_input import NativeRuntimeTable -from .audience_manifest import FrozenRecipient +from .frozen_recipient import FrozenRecipient from .bus import Bus from .bus_publication import Delivered from .bus_source_page import CoveragePage diff --git a/src/agent_comms/publisher.py b/src/agent_comms/publisher.py index 98b46a5f9e..fbfd45c866 100644 --- a/src/agent_comms/publisher.py +++ b/src/agent_comms/publisher.py @@ -13,7 +13,8 @@ from pathlib import Path from typing import TYPE_CHECKING -from agent_comms.audience_manifest import FrozenRecipient, freeze_audience +from agent_comms.audience_manifest import freeze_audience +from agent_comms.frozen_recipient import FrozenRecipient from agent_comms.bus import Bus, BusReader, BusSnapshot, ClaimRecord, PrivateRecord, RecordBuilder from agent_comms.bus_publication import InitialOrigin from agent_comms.change_report import OrderRoots diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 7f908f21a9..a72f669b72 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -6,33 +6,38 @@ """ from __future__ import annotations -from agent_comms.repo import Checkout - -from .bus_tables import AwaitingAnswerSource -from .messages import MessageInput -from .class_fact import ClassFact import asyncio import json from abc import ABC, abstractmethod from bisect import bisect_left, bisect_right -from collections.abc import Collection, Iterable, Iterator, Mapping -from contextlib import contextmanager +from collections.abc import Collection, Iterable, Mapping 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 agent_comms.repo import Checkout from .activity_clocks import ActivityClocks, ChannelActivity from .channel_targets import CommandTarget, ThreadTarget from .coordination_errors import DMPaintedBoundChanged, DMScopeChanged, HistoricalSourceChanged from .declared_family import DeclaredFamily from .errors import RelationViolationError, UnregisteredThreadError -from .field_codec import FieldCodec, NullMember, PathText, SingleFieldValue, SubclassUnion +from .field_codec import ( + FieldCodec, + NullMember, + PathText, + SingleFieldValue, + SubclassUnion, +) +from .launch_runtime import CallingThreadUnavailable from .mentions import ThreadMention from .message_reference import MessageReference +from .messages import MessageInput +from .notification_evidence import AllOutcomes, DisplayOutcomes, OutcomeViewer from .private_path import FileObservation, MissingFile -from .projection_source import ProducedRow, ProjectionSource +from .projection_source import ProjectionSource from .recorded_bus import RecordedBus, RecordedBusIdentity from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation @@ -137,24 +142,70 @@ 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(ReadAuthority): + """An unscoped operator reader sees every recipient outcome.""" + + __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: ... + + @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 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, registry: Registration) -> OutcomeViewer: + 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 @@ -170,10 +221,16 @@ class SelectedDisplay(ABC): @property @abstractmethod - def identity(self) -> DisplayIdentity | NoDisplaySelection: ... + def identity(self) -> DisplayCut: ... + + @property + def notification_identity(self) -> DisplayCut: + return self.identity.notification_identity @abstractmethod - def acknowledge(self, views: HistoryViews, page: MessagePage, *, worktree: Checkout) -> None: ... + def acknowledge( + self, views: HistoryViews, page: MessagePage, *, worktree: Checkout + ) -> None: ... def select(self, page: MessagePage, sequences: Collection[int]) -> MessagePage: selected = frozenset(sequences) @@ -187,13 +244,17 @@ 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 def identity(self): return self + @property + def notification_identity(self) -> NoDisplaySelection: + return self + def acknowledge(self, views: HistoryViews, page: MessagePage, *, worktree: Checkout) -> None: raise DMPaintedBoundChanged() @@ -247,8 +308,11 @@ class HistoricalSelection(SelectedDisplay): @property def identity(self): - return DisplayIdentity(viewer=self.displayed.displayed.viewer_identity, - history_revision=self.history_revision) + return DisplayIdentity( + viewer=self.displayed.displayed.viewer_identity, + 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) @@ -296,7 +360,6 @@ def flush(self) -> None: if TYPE_CHECKING: import sqlite3 - from .private_registry_guard import RegistryPrivacy from .bus import Bus, BusReader, BusSnapshot from .catalog_document import CatalogDocument from .catalog_store import ChannelCatalog @@ -307,6 +370,7 @@ def flush(self) -> None: from .message_page import MessagePage, PageBounds from .messages import Message, MessageKind from .presentation import BusPresentation + from .private_registry_guard import RegistryPrivacy from .read_ledger import ReadDocument, ReadLedger from .registration import Registration from .registry_document import RegistrySnapshot, UserThreadLookup @@ -1016,11 +1080,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) @@ -1028,9 +1087,10 @@ 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/recorded_time.py b/src/agent_comms/recorded_time.py index aaa110907b..a04a98431e 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, DisclosedRecord, 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/registry_document.py b/src/agent_comms/registry_document.py index da6ddef71f..459052bf62 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -31,6 +31,7 @@ WireRequired, WireValue, ) +from .frozen_recipient import FrozenRecipient from .registration_change import ( InitialRegistration, RegistrationChange, @@ -80,7 +81,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 DealTrigger, DealtSources, RuleDeal diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index cfb9219477..d65df8b207 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -9,20 +9,18 @@ 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 from .agent_activity import UnavailableRecipientActivity from .errors import RelationViolationError, UnregisteredThreadError from .messages import PublicSender +from .frozen_recipient import FrozenRecipient from .routing import DeliveryScope from .thread_identity import ThreadIncarnation from .threads import Thread, ThreadProvenance -if TYPE_CHECKING: - from .audience_manifest import FrozenRecipient - Member = TypeVar("Member", bound=ThreadProvenance, covariant=True) @@ -57,6 +55,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): @@ -78,6 +80,9 @@ def activity_threads(self) -> tuple[Thread, ...]: 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): @@ -242,7 +247,9 @@ 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: FrozenRecipient, + ) -> 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/repo.py b/src/agent_comms/repo.py index b5876bef62..57187f6e07 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -12,7 +12,7 @@ from dataclasses import dataclass, field from functools import cached_property from pathlib import Path -from typing import TYPE_CHECKING, Annotated, ClassVar, Self +from typing import Annotated, ClassVar, Self from pydantic import BaseModel, ConfigDict, InstanceOf, PlainSerializer, PlainValidator @@ -23,9 +23,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.""" @@ -171,8 +168,6 @@ def checked_out(self) -> bool: that git would resolve to the enclosing repository instead.""" return (self.checkout / ".git").exists() - 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.""" @@ -216,6 +211,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) + @classmethod def unservable_commits(cls, checkout: Path | str, remote: str, *objects: str) -> tuple[str, ...]: @@ -337,8 +336,13 @@ def merge_conflicts(cls, checkout: Path | str, left: str, right: str) -> tuple[s def of(cls, checkout: Path) -> Repo: """Its GitHub name comes from the checkout's own origin remote.""" url = cls.git(checkout, "remote", "get-url", cls.remote).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/response_conversation.py b/src/agent_comms/response_conversation.py index 9f929d9c39..c897ae5e8f 100644 --- a/src/agent_comms/response_conversation.py +++ b/src/agent_comms/response_conversation.py @@ -15,7 +15,8 @@ ) from agent_comms.wake import OrdinaryControl -from .audience_manifest import FrozenRecipient, freeze_audience +from .audience_manifest import freeze_audience +from .frozen_recipient import FrozenRecipient from .bus_publication import Delivered from .private_registry_guard import RegistryPrivacy from .channel_targets import CommandTarget diff --git a/src/agent_comms/runtime.py b/src/agent_comms/runtime.py index f4a2d77367..2f37cc1de5 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 @@ -424,16 +424,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 - - 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 9052e2ba06..ada0758bdc 100644 --- a/src/agent_comms/runtime_requests.py +++ b/src/agent_comms/runtime_requests.py @@ -22,8 +22,6 @@ from .acp_extension import ( GoalSnapshotReply, - TurnChangedUpdate, - UpdateMetadata, ) from .agent_event_updates import AcpEventConsumer @@ -38,6 +36,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 ( AttachedResultRuntimeRequest, AttachedRuntimeRequest, @@ -83,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.""" @@ -271,19 +265,6 @@ async def result(self, ctx: AttachedRuntimeRequestContext) -> CompactionOutcome: return await compact_context(ctx.server.agent.turns, ctx.session, self.instructions) -@dataclass(frozen=True, kw_only=True) -class InputDispositionsRuntimeRequest(AttachedResultRuntimeRequest): - include_history: bool = False - - async def result(self, ctx: AttachedRuntimeRequestContext) -> 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), - - )) - - @dataclass(frozen=True, kw_only=True) class DismissHistoricalInputsRuntimeRequest(AttachedResultRuntimeRequest): async def result(self, ctx: AttachedRuntimeRequestContext) -> DeliveryOverview: @@ -299,10 +280,9 @@ async def result(self, ctx: AttachedRuntimeRequestContext) -> DeliveryOverview: class GoalHistoryRuntimeRequest(AttachedResultRuntimeRequest): goal_id: OneGoal | AllGoals = AllGoals() - async def result(self, ctx: AttachedRuntimeRequestContext) -> GoalHistoryReply: + async def result(self, ctx: AttachedRuntimeRequestContext) -> 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, )) @@ -312,19 +292,7 @@ async def change(self, ctx: AttachedRuntimeRequestContext) -> None: ... async def result(self, ctx: AttachedRuntimeRequestContext) -> 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, - )) - return GoalSnapshotReply.from_snapshot(snapshot, UpdateMetadata.of(TurnChangedUpdate(turn))) - - -@dataclass(frozen=True, kw_only=True) -class GoalSnapshotRuntimeRequest(GoalSnapshotResultRuntimeRequest): - async def change(self, ctx: AttachedRuntimeRequestContext) -> None: - pass + return await OwnerGoalRead().owned(ctx) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index cdf17be4a9..7a287faa3d 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator, Sequence +from collections.abc import Awaitable, Callable, Generator from contextlib import asynccontextmanager from dataclasses import dataclass, field from functools import partial @@ -14,7 +14,6 @@ ) from .agent_events import CompactionEvent, NativePhaseChanged -from .bus_publication import Delivered from .cohort_schema import CohortTable from .comms import Comms from agent_comms.native_runtime_input import NativeRuntimeTable @@ -38,10 +37,10 @@ from .notification_evidence import NotificationAssignmentRead, NotificationWindow from .participant_store import ParticipantSnapshot from .pi_events import TurnContextObserved -from .presentation import MessageNotification, OwnerOutcomes, SentMessageOutcome +from .notification_evidence import OwnerOutcomes +from .presentation import MessageNotification, 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,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 +250,15 @@ 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.incarnation).selection(sent_sources), + privacy, ) + sent_read = NotificationAssignment.capture_detached(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/selected_triage.py b/src/agent_comms/selected_triage.py index 345cf88922..0986fa74ba 100644 --- a/src/agent_comms/selected_triage.py +++ b/src/agent_comms/selected_triage.py @@ -30,8 +30,8 @@ from .assignment_states import AssignmentState from .coordinated_runtime import SelectedExecution from .historical_native_inputs import HistoricalNativeInput, TriageHistoricalNativeInput + from .native_input_proof import NativeContextProof from .native_input_record import TriageNativeExecution - from .native_pi 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/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 3995f7a1e3..a575a94610 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,12 +12,12 @@ 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 .transcripts import TranscriptReadIdentity, TranscriptSourceWindow + from .transcripts import TranscriptReadIdentity from agent_comms.registry_document import RegistrySnapshot from agent_comms.transcripts import Transcripts @@ -209,13 +209,20 @@ 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 published(self) -> ThreadPresentation: + """The value a viewer may receive: this presentation itself.""" + return self + + 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: """Require a presentation for a caller already bound to this thread.""" @@ -230,8 +237,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: @@ -240,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. @@ -256,37 +264,46 @@ 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.""" - 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) - projected = MessageNotification.delivery_window( - transcripts.root, transcripts.registry, sources, notification_read, - viewer=OwnerOutcomes.of(window.read.identity.thread), - ) - incoming = tuple( - replace(notification, message=source.message) - 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, - 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() +@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(**{declared.name: getattr(self, declared.name) + for declared in fields(ThreadPresentation)}) + + def sent_notifications( + self, + ) -> tuple[tuple[MessageReference, tuple[MessageNotification, ...]], ...]: + """Each recent sent source with its outcomes from this presentation's captured window.""" + return tuple( + (reference, + 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,13 +318,7 @@ 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 - def with_sources(self, window: TranscriptSourceWindow) -> ThreadPresentation: - return self - diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 0bfcbfec46..e9a044cf38 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -102,11 +102,14 @@ HeadReviewOrder, HeadReviewRelation, HeadReviewStatus, + IntegrationInput, MergeArm, + MergeHeadReview, OpenPullRequestHead, PreparedHeadReview, PullRequestHead, ReadPullRequestHead, + UnpromotedHeadReview, UntouchedMergeArm, ) from agent_comms.launch_runtime import LaunchRuntime @@ -1026,13 +1029,13 @@ def __init__(self, todos: Sequence[Todo] = ()) -> None: self._read: dict[tuple[RepositoryName, int], ListedPullRequest] = {} self._open: dict[RepositoryName, tuple[int, ...]] = {} self._worktrees: dict[Path, Mapping[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, ...]: @@ -1091,7 +1094,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]]: @@ -2606,7 +2609,12 @@ def claimed(self, claim: OrderClaim) -> ClaimedProblemOrder: def review_verdicts(self) -> tuple[type[HeadVerdict], ...]: """Any order that holds a head takes a decision on it, or a reviewer's mark first.""" - return (ReviewedHeadVerdict, RejectedHeadVerdict, UnderReviewHeadVerdict) + return ( + ReviewedHeadVerdict, + QualifiedHeadVerdict, + RejectedHeadVerdict, + UnderReviewHeadVerdict, + ) def under_review(self, record: OrderHeadReviewRecord) -> CheckedProblemOrder: """Only a claim owes a delivery verdict; elsewhere the head record is the whole mark.""" @@ -2803,23 +2811,23 @@ def observed_merges( Repo.require_commits(repo.checkout, repo.remote, *(merged.sha for merged in candidates)) return tuple(self.merged(merged.sha) for merged in candidates 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}. " @@ -4238,22 +4246,34 @@ 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: - """Only a verdict that allows integration lets the owner's admin bypass merge.""" + 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, repositories) + + @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 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) - recorded = order.record_review(goals, request, self, reviewer) - self.after_record(goals, recorded) - self.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: - """Only an acceptance moves the PR toward main; every other verdict leaves auto-merge be.""" + """Source verdicts never request merge; native explicit promotion owns enqueueing.""" return UntouchedMergeArm() def subject(self, goals: Goals, request: ReadPullRequestHead) -> str: @@ -4297,6 +4317,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) @@ -4307,8 +4330,18 @@ 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): + """Accept an exact source for combination, never enqueue its individual PR.""" + state = "success" verb = "Accepted" preparation_field = GrowthReason.option @@ -4329,18 +4362,17 @@ 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())) - # GitHub first: Core never records a carry whose status the forge refused. - verdict.post(current.repo, current.head, record.actor.name, f"#{current.pr}") - goals.todos.record_head(carried) + with verdict.recording(current, record.actor.name, f"#{current.pr}"): + goals.todos.record_head(carried) return True @classmethod def allows_integration(cls) -> bool: return True - 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() @@ -4362,13 +4394,19 @@ class CarriedHeadVerdict(ReviewedHeadVerdict): source: PullRequestHead + @contextmanager + def recording(self, request: PullRequestHead, reviewer: str, subject: str) -> Iterator[None]: + """Publish the unchanged-source carry first; a refused status records nothing in Core.""" + self.publish_recorded(request, reviewer, subject) + yield + def description(self, reviewer: str, subject: str) -> str: return f"carried from {self.source.head[:9]}: same change" @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 @@ -4379,6 +4417,11 @@ 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 RejectedHeadVerdict(DecidedHeadVerdict): reason: StrikeReason @@ -4440,6 +4483,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.""" @@ -4725,6 +4773,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.""" @@ -5206,6 +5263,8 @@ def changed(self, current: Todo) -> Todo: 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)) @@ -6268,6 +6327,21 @@ 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_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: + 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 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 any commander's board already owns.""" diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index a4d5a43703..e8ae6b6076 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -1,9 +1,9 @@ """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 +from typing import TYPE_CHECKING, Annotated from agent_comms_hooks.watch import WatchClaim @@ -13,8 +13,10 @@ from .errors import ToolRefusedError 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 OperatorOutcomes, ReadAuthority +from .registration import Registration +from .notification_evidence import OutcomeViewer, OwnerOutcomes +from .presentation import SentMessageSummary, ThreadRecord from .thread_execution import CommanderForkExecution from .thread_identity import ThreadIncarnation from .tool_result import ToolResult @@ -33,7 +35,7 @@ class ThreadListing(ToolResult): sent_messages: tuple[SentMessageSummary, ...] -class ToolCaller(ABC): +class ToolCaller(ReadAuthority): def acting_caller(self, grant: ToolCaller) -> ToolCaller: return self @@ -51,10 +53,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: ... @@ -62,13 +60,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) -> None: """An ordinary caller needs no commander-child registration.""" return None @@ -95,15 +86,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: @@ -121,18 +103,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.""" @@ -161,10 +137,10 @@ 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, 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).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 3ca05d8464..7f64472d1d 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -58,7 +58,9 @@ 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 .notification_evidence import AfterRecipient, FirstRecipient, RecipientCursor +from .presentation import ChannelView, MessageNotification +from .notification_evidence import RecipientLimit from .progress_claims import ProgressClaim from .read_basis import NamedViewer from .relationships import ( @@ -80,16 +82,13 @@ TaskChange, TaskScopeSelection, ) -from .pi_vocabulary import ServiceTier, ThinkingLevelField -from .progress_claims import ProgressClaim -from .relationships import RelationshipEdit from .thread_execution import ArchiveOwnerControl, NativeOwnerCommand, OwnerLifecycleControl, StartOwnerControl from .thread_management import InheritedTags, RenameThreadResult, TagChoice 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: @@ -164,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, ...] @@ -619,8 +603,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) @@ -704,24 +688,20 @@ 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[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: - 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.registry).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 = LiveNotificationRead( + (self.reference,), after=self.after, limit=self.limit).apply(comms, caller) + 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/tracked_turn.py b/src/agent_comms/tracked_turn.py index d0864a5484..81435e2c54 100644 --- a/src/agent_comms/tracked_turn.py +++ b/src/agent_comms/tracked_turn.py @@ -11,9 +11,7 @@ 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 @@ -29,13 +27,13 @@ from .native_attestation import AttestationError, ObservedAttestation from .native_failures import NativePiUnavailable from .native_package import VerifiedNativePackage +from .native_input_proof import NativeContextProof, NativeInputCommit +from .native_entries import NativeEvidenceScope from .native_pi import ( - NativeContextProof, NativeEmptyAnswer, NativePiPromptRejected, NativePiRpcLaunch, NativeTurnResult, - _verify_context, ) from .native_prompt_send import UNGUARDED_PROMPT, PromptBoundary, PromptSendFailure from .native_session_reopen import SessionRetention @@ -71,35 +69,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 - - class TrackedTerminal(ABC): """Relate completed assistant responses to their streamed text.""" @@ -252,20 +221,18 @@ 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.input_commit: NativeCommitObservation[pi.InputCommitted] = PendingNativeCommit() - self.context_commit: NativeCommitObservation[pi.ContextCommitted] = PendingNativeCommit() self.draft_written_at = 0.0 self.terminal: TrackedTerminal = PendingTrackedTerminal() @@ -472,16 +439,14 @@ async def send_prompt(self) -> None: def reject_prompt(self, response): raise NativePiPromptRejected(response) - @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) + async def original_committed(self, event: NativeInputCommit) -> None: + self.prompt_state = self.prompt_state.start(event) + await self.following.open(self) + + 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.""" @@ -503,11 +468,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 event.input_id == self.command.input_id: - self.context_commit = ObservedNativeCommit(event) - async def assistant_started(self) -> None: await super().assistant_started() self.draft_written_at = 0.0 @@ -542,17 +502,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(lambda: _verify_context( - 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), - )) + 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/transcript_updates.py b/src/agent_comms/transcript_updates.py index de435ec6a1..f38288d06a 100644 --- a/src/agent_comms/transcript_updates.py +++ b/src/agent_comms/transcript_updates.py @@ -17,6 +17,7 @@ ) from .coordinator import Coordination from .declared_family import DeclaredFamily +from .order_tools import TranscriptPageRead from .turn_lease import TurnState from .ui_model.observation import ObservationProcess, ReadTranscript, SubscribeTranscript @@ -75,13 +76,17 @@ 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/transcripts.py b/src/agent_comms/transcripts.py index d5222be4ce..f331a1d162 100644 --- a/src/agent_comms/transcripts.py +++ b/src/agent_comms/transcripts.py @@ -20,14 +20,17 @@ 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 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 @@ -47,6 +50,7 @@ NoWireFrontier, TranscriptDraft, TranscriptSource, + TranscriptTraversal, WireFrontier, ) from .turn_digests import InputAnswerRead, ThreadTurns, TurnBound, TurnDigestRead @@ -72,8 +76,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 +102,39 @@ 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 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 + + 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()) def covers_incoming(self, sequence: int) -> bool: # Callers hold a canonical recipient assignment, never an arbitrary seq. @@ -143,12 +171,24 @@ 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 painted_read[Result](self, acknowledge: Callable[[TranscriptCursor], Result], + nothing: Result) -> Result: + return nothing def at_offset(self, offset: int) -> TranscriptCursor: return self @@ -226,6 +266,104 @@ 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_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 + + 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_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) + + 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 accepts_runtime(self, runtime: RuntimeLocation) -> bool: + """An unpinned page uses the thread's first available transcript source.""" + return True + + +@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 accepts_runtime(self, runtime: RuntimeLocation) -> bool: + return self.cursor.matches_runtime(runtime) + + @dataclass(frozen=True, slots=True) class TranscriptPage: events: tuple[TranscriptEvent, ...] @@ -245,7 +383,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 @@ -259,6 +397,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.""" @@ -275,9 +451,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. @@ -294,13 +469,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: @@ -329,7 +499,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.""" @@ -364,17 +535,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() @@ -391,8 +551,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=ContentExclude, name="source") def content_source(self) -> tuple[str, str, RecordedBusIdentity, RecordedBusIdentity, int, FileObservation]: @@ -400,21 +560,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: @@ -432,7 +577,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 @@ -441,7 +586,8 @@ class TranscriptRead: @contextmanager def publication_source(cls, root: Path) -> Iterator[Connection | None]: privacy = RegistryStore.at(root).privacy() - with CoordinationStore.observed(CoordinationStore.path_in(root)).observing(cls, privacy) as db: + store = CoordinationStore.observed(CoordinationStore.path_in(root)) + with store.observing(cls, privacy) as db: yield db @classmethod @@ -470,8 +616,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, @@ -505,6 +650,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): @@ -526,23 +693,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 = 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, None, None) + page = LivePageSource().resolve(self, name, AtFrontier()) cut = page.records.committed_output_cut(location) original = NativeRecordLocation(**{ item.name: getattr(location, item.name) @@ -556,15 +722,14 @@ def capture_page_read( self, name: str, *, - before: TranscriptCursor | None = None, - after: TranscriptCursor | None = None, - through: TranscriptCursor | None = None, - historical_source: str | None = None, + position: PagePosition = LatestPage(), + limit: PageLimit = AtFrontier(), + page_source: PageSource = LivePageSource(), ) -> TranscriptRead: - page = self._page_source(name, through, historical_source) + page = page_source.resolve(self, name, limit) 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) @@ -574,10 +739,9 @@ 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, - historical_source: str | None = None, + position: PagePosition = LatestPage(), + limit: PageLimit = AtFrontier(), + page_source: PageSource = LivePageSource(), ) -> TranscriptSourceWindow: """Expose the source owner's complete identity without parsing its page. @@ -589,10 +753,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 = 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, 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 @@ -602,28 +766,24 @@ def recapture_page_identity( self, identity: TranscriptReadIdentity, ) -> TranscriptReadIdentity: """Recapture identity facts without reconstructing presentation source windows.""" - page = self._page_source( - identity.requested_name, identity.through, identity.historical_source - ) + page = identity.page_source.resolve(self, identity.requested_name, identity.limit) 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( self, - page: PageSource, + page: ResolvedPageSource, 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: @@ -641,9 +801,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: @@ -651,23 +810,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, None, None) + page = LivePageSource().resolve(self, name, AtFrontier()) return page.runtime.external_records(page.root, page.thread.incarnation) - def _page_source(self, name: str, through: TranscriptCursor | None, 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, - ) - 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() @@ -684,38 +836,29 @@ def _fork_start_events(thread: Thread) -> tuple[TranscriptEvent, ...]: def _thread_transcript_page( self, - page: PageSource, + page: ResolvedPageSource, *, 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() source = page.projection with page.records.reading() as reader: - size = reader.cut().offset - if cursor and not reader.valid_offset(cursor.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) + through.require_readable(runtime, reader, reader.cut().offset) + size = through.offset + position.require_readable(runtime, 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, @@ -728,7 +871,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/turn_input_source.py b/src/agent_comms/turn_input_source.py index 8488488a60..0864e2768d 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 .turn_inbox import BusyInputPolicy, SteerInputPolicy from .channel_input_batch import InputBatch, SingleInputBatch +from .errors import RelationViolationError from .field_codec import AcpSessionId, 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: AcpSessionId, source: TurnInputSource, + ) -> None: + raise RelationViolationError("An input without an acceptance cannot own following context") + @abstractmethod def release(self, inputs: InputDrain, session_id: AcpSessionId) -> 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: 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: AcpSessionId) -> 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/src/agent_comms/turn_inputs.py b/src/agent_comms/turn_inputs.py index 6a3260c537..98c5f27cbe 100644 --- a/src/agent_comms/turn_inputs.py +++ b/src/agent_comms/turn_inputs.py @@ -239,6 +239,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/src/agent_comms/turn_item.py b/src/agent_comms/turn_item.py index 4ad96702a1..9ce9ed16e2 100644 --- a/src/agent_comms/turn_item.py +++ b/src/agent_comms/turn_item.py @@ -67,6 +67,10 @@ def external_answers( 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()) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 17a5c1e688..23753e815b 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -318,6 +318,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.""" @@ -2388,9 +2398,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)}") @@ -2401,11 +2422,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 d138f316d2..e48d608498 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -35,16 +35,17 @@ from metaclass_registry.mro_dispatch import MroDispatch, handles from agent_comms.acp_extension import ( - GoalSnapshotReply, TranscriptDraftUpdate, TranscriptSnapshotUpdate, ) 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.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 from agent_comms.coordination_errors import ( CoordinationError, CoordinationReadUnavailable, @@ -54,19 +55,18 @@ StaleRevision, ) 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.read_basis import DisplayCut, NoDisplaySelection from agent_comms.presentation import ( - AllOutcomes, CoordinationSnapshot, MessageNotification, NoRevision, OutcomeViewer, ReadState, + CoordinationSnapshot, MessageNotification, NoRevision, ReadState, WireRevision, ) from agent_comms.registry_document import RosterVisibility from agent_comms.registry_provenance import DeletedThread from agent_comms.revision_views import ( - ActivityView, CommanderFramesView, NotificationsView, RegistrationsView, @@ -76,9 +76,9 @@ StoresView, ) 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.thread_presentation import AcquiredThreadPresentation, ThreadPresentation +from agent_comms.order_tools import TranscriptPageRead +from agent_comms.transcripts import TranscriptReadIdentity from agent_comms.ui_model.sidebar import ( ChannelRowModel, SidebarModel, @@ -94,7 +94,7 @@ 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 @@ -429,31 +429,21 @@ 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) - hud.require_current() - 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) -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 +477,8 @@ 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.incarnation, 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() - - @abstractmethod - async def read(self, owner: RuntimeConnection) -> object: ... + return await OwnerReplyConnection.answer(comms, self.owner, self) @dataclass(frozen=True) @@ -522,25 +495,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, NoDisplaySelection()) @abstractmethod def result(self, read: TranscriptRead) -> ResultT: ... @@ -603,103 +567,40 @@ 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 - return ReadTranscript( - self.root, identity.requested_name, before=identity.before, after=identity.after, - through=identity.through, historical_source=identity.historical_source, - ).read(comms) + operation = TranscriptPageRead( + thread=identity.requested_name, position=identity.position, limit=identity.limit, + page_source=identity.page_source, + ) + return UnreadableHistory.outcome(lambda: operation.read(comms)).raised() @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.""" + """The UI's root and request witness around one root-free notification operation.""" - references: tuple[MessageReference, ...] - viewer: OutcomeViewer = field(default_factory=AllOutcomes, kw_only=True) + operation: NotificationRead + display: DisplayCut = field( + default_factory=NoDisplaySelection, kw_only=True) + """The painted display this read serves; it decides whose read marks the window omits.""" def read(self, comms: Comms) -> NotificationWindow: - return comms.views.message_notifications_for_references(self.viewer, 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(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) - - 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 + return self.operation.apply(comms, self.display) @dataclass(frozen=True) -class ReadOwnerGoal(OwnerRead): - """The owner's goal snapshot and the turn it read with it.""" - - async def read(self, owner: RuntimeConnection) -> GoalSnapshotReply: - return FieldCodec.decode(GoalSnapshotReply, await owner.request("goal_snapshot")) +class ReadOwnerOperation(OwnerRead): + """The UI's envelope for an owner-live read: the operation's own reply, from the held owner.""" + operation: OwnerLiveRead -@dataclass(frozen=True) -class ReadDeliveryOverview(OwnerRead): - """The owner's delivery notices, checked against its live queue.""" - - include_history: bool = False - - async def read(self, owner: RuntimeConnection) -> ReadDeliveryView: - return ReadDeliveryView.from_wire( - await owner.request("input_dispositions", include_history=self.include_history) - ) + 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) @@ -861,7 +762,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) @@ -921,8 +822,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 @@ -1109,10 +1013,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: @@ -1130,10 +1039,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: @@ -1153,7 +1066,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) @@ -1165,8 +1079,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/src/agent_comms/wake.py b/src/agent_comms/wake.py index 52719aa5ed..c34c8ad90a 100644 --- a/src/agent_comms/wake.py +++ b/src/agent_comms/wake.py @@ -7,7 +7,8 @@ from __future__ import annotations -from .audience_manifest import AudiencedMessage, FrozenRecipient +from .audience_manifest import AudiencedMessage +from .frozen_recipient import FrozenRecipient from .channel_targets import CommandTarget, NamedTarget from .declared_family import DeclaredFamily from .messages import Message diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index 5b6e4dbe8e..aea6aaf230 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -428,6 +428,15 @@ def _pull_request_base(self, base: str) -> str: (base_head,) = published return base_head + def commit_parents(self, head: str) -> tuple[str, ...]: + Repo.require_commits(self.checkout, Repo.remote, 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.""" + Repo.require_commits(self.checkout, Repo.remote, head) + return self._git("show", "--no-patch", "--format=%B", head) + def prepare_pull_request(self, base: str, head: str) -> None: with Repo.acquiring(): self.remote_heads(Repo.remote, f"refs/heads/{base}") diff --git a/tests/bus_record_fixture.py b/tests/bus_record_fixture.py index 658427ec61..4309eaeebd 100644 --- a/tests/bus_record_fixture.py +++ b/tests/bus_record_fixture.py @@ -6,7 +6,8 @@ from multiprocessing.context import ForkServerContext from pathlib import Path -from agent_comms.audience_manifest import FrozenRecipient, freeze_audience +from agent_comms.audience_manifest import freeze_audience +from agent_comms.frozen_recipient import FrozenRecipient from agent_comms.bus import PrivateRecord from agent_comms.bus_publication import Delivered, KeyedPublication from agent_comms.child_process import ProcessIdentity diff --git a/tests/c4_coordination_installed_ui.py b/tests/c4_coordination_installed_ui.py index 513345022c..dc6f3de68a 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.notification_evidence 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/conftest.py b/tests/conftest.py index 89ce8d3204..78209aa081 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,7 +59,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/github_cli_stub.py b/tests/github_cli_stub.py index 5133c9f4e3..83ba23ba37 100755 --- a/tests/github_cli_stub.py +++ b/tests/github_cli_stub.py @@ -22,9 +22,22 @@ 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"]: - data = repositories[arguments[arguments.index("-R") + 1]] - request = data["requests"][arguments[2]] + 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"]: + 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) field = arguments[arguments.index("--json") + 1] assert arguments[arguments.index("--jq") + 1] == "." + field print({"number": request["listed"]["number"], "body": request["body"]}[field]) @@ -37,27 +50,31 @@ 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 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" - if refusal := data.get("status_refusal"): - sys.exit(refusal) + 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"]): + 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("{}") 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 e329a9940c..954ac3fdd7 100644 --- a/tests/github_fixture.py +++ b/tests/github_fixture.py @@ -36,16 +36,29 @@ 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_arm(self, repository: RepositoryName, number: int, refusal: str) -> None: - """GitHub's answer when enabling auto-merge on this pull request fails.""" + 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)]["arm_refusal"] = refusal + 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, refusal: str) -> None: - """GitHub's answer to every commit-status post in this repository, as when archived.""" + 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()]["status_refusal"] = 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: + """GitHub's answer when enabling auto-merge on this pull request fails.""" + data = json.loads(self.path.read_text()) + data[repository.to_wire()]["requests"][str(number)]["arm_refusal"] = refusal self.path.write_text(json.dumps(data)) def calls(self) -> tuple[tuple[str, ...], ...]: diff --git a/tests/integration_source_fixture.py b/tests/integration_source_fixture.py new file mode 100644 index 0000000000..df364d84b6 --- /dev/null +++ b/tests/integration_source_fixture.py @@ -0,0 +1,135 @@ +"""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 sys +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 CombinedInput, IntegrationInput, IntegrationSource, PullRequestHead +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, PullRequests, RecordPullRequest +from fixture_git import ENVIRONMENT +from github_fixture import GitHubFixture +from source_revision_fixture import SourceRepository +from module_layers import MaterializedClients + + +@dataclass(frozen=True) +class MutualSources: + context: CommanderContext + core: SourceRepository + client: SourceRepository + checkout: IntegrationCheckout + output: Path + github: GitHubFixture + carrier_number: int = 101 + + def publish_carrier(self, github: GitHubFixture) -> PullRequestHead: + """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.carrier_number}/head") + stamp = "2026-10-24T00:00:00Z" + github.put(self.core.github, DraftListedPullRequest( + self.carrier_number, "combined", commit, stamp, "main", reviewable_times=(stamp,), + )) + 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, *, + companions: tuple[PullRequestHead, ...] = ()) -> CombinedInput: + return IntegrationRun(self.checkout, self.output, risk).qualify( + self.context, problems, carrier=self.prepare_carrier(problems), companions=companions) + + +@pytest.fixture +def source_pair( + 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) + 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' + '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_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', + Path("src/agent_comms/__init__.py"): "", + Path("src/agent_comms/api.py"): 'def old_api() -> str:\n return "baseline"\n', + }) + for source in (core, client): + 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', + })) + 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) + 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", github_cli) + + +@pytest.fixture +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 +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/native_backend_fixture.py b/tests/native_backend_fixture.py index 146b80bead..2546740871 100644 --- a/tests/native_backend_fixture.py +++ b/tests/native_backend_fixture.py @@ -16,6 +16,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 AcpSessionId, FieldCodec, InputId from agent_comms.fresh_private_session import create_fresh_private_session from agent_comms.native_arguments import GivenArguments, GivenBinary, NativeArguments @@ -57,6 +58,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] = [] @@ -193,6 +195,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) @@ -299,6 +302,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/native_proof_cases.py b/tests/native_proof_cases.py index f39d59015c..f6a6cda9c4 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.native_pi import NativeContextJournal, NativeContextProof, NativeContextRecord +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 = 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: + 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)} @@ -46,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/observed_queue.py b/tests/observed_queue.py new file mode 100644 index 0000000000..e65a850d6d --- /dev/null +++ b/tests/observed_queue.py @@ -0,0 +1,38 @@ +"""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: + """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/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/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/reply_relevance_installed_journey.py b/tests/reply_relevance_installed_journey.py index dffcfd68af..dbd3985487 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.notification_evidence import AllOutcomes from agent_comms.repo import Checkout from agent_comms.channel_targets import CommandTarget diff --git a/tests/round2/test_typed_table.py b/tests/round2/test_typed_table.py index 73d2264295..b16b097f4d 100644 --- a/tests/round2/test_typed_table.py +++ b/tests/round2/test_typed_table.py @@ -182,12 +182,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) @@ -398,7 +405,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/source_revision_fixture.py b/tests/source_revision_fixture.py index 9829045da0..6ea902d1c3 100644 --- a/tests/source_revision_fixture.py +++ b/tests/source_revision_fixture.py @@ -1,10 +1,14 @@ """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 +from types import MappingProxyType 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 @@ -60,11 +64,42 @@ def git_input(repository: Path, text: str, *arguments: str) -> str: capture_output=True, check=True, timeout=30, ).stdout.strip() - def publish_change(self, message: str, *files: str) -> str: - """Commit fixture files and publish this PR's new head through real Git.""" + @staticmethod + 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": dict(build_system), + "project": {"name": name, "version": "0.0.1", "requires-python": python, + "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, ...]: + """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 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 @@ -76,7 +111,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' @@ -84,23 +122,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_acp_queue_contract.py b/tests/test_acp_queue_contract.py index a8d653992e..ebd754b549 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_audience_manifest.py b/tests/test_audience_manifest.py index 520f15aa68..c3646ef9d2 100644 --- a/tests/test_audience_manifest.py +++ b/tests/test_audience_manifest.py @@ -4,12 +4,8 @@ import pytest -from agent_comms.audience_manifest import ( - MAX_RECIPIENTS, - MAX_WIRE_ENVELOPE_BYTES, - FrozenRecipient, - freeze_audience, -) +from agent_comms.audience_manifest import MAX_RECIPIENTS, MAX_WIRE_ENVELOPE_BYTES, freeze_audience +from agent_comms.frozen_recipient import FrozenRecipient from agent_comms.mentions import ThreadMention from agent_comms.messages import AlertKind, InfoKind, Message from agent_comms.thread_identity import AgentThreadRole, UserThreadRole diff --git a/tests/test_bus_files.py b/tests/test_bus_files.py index 8b5672a02c..2c965a57fa 100644 --- a/tests/test_bus_files.py +++ b/tests/test_bus_files.py @@ -15,8 +15,8 @@ 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_session_files import NativeInputProof +from agent_comms.native_input_proof import NativeContextJournal +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 diff --git a/tests/test_busy_history_loading.py b/tests/test_busy_history_loading.py index db4643f23f..abf8aa9ecd 100644 --- a/tests/test_busy_history_loading.py +++ b/tests/test_busy_history_loading.py @@ -11,6 +11,8 @@ 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, ) @@ -57,15 +59,15 @@ 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) + 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"))) - independent = asyncio.create_task(process.read(ReadNotifications(str(comms.root), ()))) + 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 == {} + assert (await independent).outcomes == {} assert not waiting.done() assert len(process.reads) == 1 (request_id,) = process.reads @@ -84,16 +86,16 @@ 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"))) + 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): 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), LiveNotificationRead(())))).outcomes == {} assert process.reads == {} @@ -112,10 +114,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 @@ -187,7 +189,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: @@ -195,7 +198,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() @@ -206,8 +210,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), LiveNotificationRead(())))).outcomes == {} assert process.connection.closed and process.process.exitcode == 0 assert not process.reads diff --git a/tests/test_channel_bus_ownership.py b/tests/test_channel_bus_ownership.py index 80db5171b5..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.presentation 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_checker_stack.py b/tests/test_checker_stack.py new file mode 100644 index 0000000000..fb1f339b01 --- /dev/null +++ b/tests/test_checker_stack.py @@ -0,0 +1,302 @@ +"""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 +import tomllib + +from packaging.requirements import Requirement +import pytest +import tomli_w + +from agent_comms.field_codec import FieldCodec +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, PrivateUvProject, ResolvedLock, StackSources +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") + + +def test_local_package_membership_comes_from_both_source_declarations() -> None: + before = FieldCodec.decode(PrivateUvProject, {"project": {"dependencies": []}, "tool": {"uv": {"sources": { + "Agent_Comms": {"path": "..", "editable": True}, + "Source_Client": {"git": "https://github.com/example/client", "branch": "main"}, + "registry-only": {"index": "public"}, + }}}}) + after = FieldCodec.decode(PrivateUvProject, {"project": {"dependencies": []}, "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"), + 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, 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. + 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" + 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"}] + 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("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,))) + 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, + inputs=inputs, uv_program=test_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} + expected["tool"]["uv"]["sources"]["source-client"] = { + "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] + 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) + + +@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.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, "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)})) + stamp = "2026-10-24T00:00:00Z" + github_cli.put(pair.core.github, DraftListedPullRequest( + pair.core.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) + 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) + 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. + + +@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"), 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.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, "merge", "--no-edit", "main") + pair.core.git(pair.core.checkout, "switch", "main") + 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_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_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)}) + 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.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, "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") + github_cli.put(source.github, DraftListedPullRequest( + source.number, "feature", source.branch_commit, stamp, "main", reviewable_times=(stamp,))) + 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() + 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()) + 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 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"): + 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 + 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"), ("checker-plugin", "location"), +)) +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 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") + if change == "revision": + 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 + else: + source["subdirectory"] = "other package" # Same repo/SHA/version, another actual project. + 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/tests/test_codex_earlier_pages.py b/tests/test_codex_earlier_pages.py index 0adf4c91b6..8728cfca10 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 @@ -87,7 +88,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()) @@ -97,20 +98,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: @@ -175,7 +176,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 = [] @@ -212,7 +213,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_command_families.py b/tests/test_command_families.py index 33e8e59752..f87c135df1 100644 --- a/tests/test_command_families.py +++ b/tests/test_command_families.py @@ -1,5 +1,6 @@ from agent_comms.repo import Checkout, Repo -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.""" @@ -9,7 +10,6 @@ from acp.schema import PromptRequest, TextContentBlock from agent_comms.cli import build_parser, main -from agent_comms.cli_command import CliCommand from agent_comms.comms import wire from agent_comms.history_result import ( ChannelHistoryPageResult, @@ -85,10 +85,13 @@ 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"}, + {"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}, @@ -116,7 +119,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"}, @@ -137,6 +142,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_latency.py b/tests/test_commander_latency.py index 9dffda3ea1..b336101b29 100644 --- a/tests/test_commander_latency.py +++ b/tests/test_commander_latency.py @@ -12,7 +12,8 @@ import pytest -from agent_comms.audience_manifest import FrozenRecipient, freeze_audience +from agent_comms.audience_manifest import freeze_audience +from agent_comms.frozen_recipient import FrozenRecipient from agent_comms.bus import Bus, PrivateRecord from agent_comms.bus_tables import MessageIdentitySource from agent_comms.channel_targets import CommandTarget diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index cb7a04ba38..eacbbf120a 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -34,7 +34,12 @@ from agent_comms.todos import ProgressRead, UnitOccupied 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.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 ReviewTimerUp, ServicePassFailing, StandingRule, UndatedStandingRule from agent_comms.ui_model.unit_state import ( @@ -121,9 +126,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_fields(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: @@ -135,10 +147,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 @@ -148,6 +160,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_fields(operation), + caller=ThreadCaller("lead")) finally: metadata.unlink() saved.rename(metadata) diff --git a/tests/test_connector.py b/tests/test_connector.py index 9660760740..7106eedff8 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 @@ -22,6 +23,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 @@ -33,22 +35,27 @@ ConnectorVerb, ServeVerb, RevokeVerb, + ToolsCallResult, ) from agent_comms.connector_caller import CALLER 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, RecordProblems +from agent_comms.field_codec import FieldCodec, MalformedRecord, RecordProblems 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, @@ -268,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", position=EarlierThan(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_coordination_cohort.py b/tests/test_coordination_cohort.py index d2736370cc..f1cacc9586 100644 --- a/tests/test_coordination_cohort.py +++ b/tests/test_coordination_cohort.py @@ -232,7 +232,8 @@ def test_legacy_preexisting_claim_blocks_entire_batch_and_rollback(tmp_path: Pat def test_no_wake_observer_cannot_gain_legacy_claim_after_seal(tmp_path: Path) -> None: comms, store, root_id, lookups = _root(tmp_path) - from agent_comms.audience_manifest import FrozenRecipient, freeze_audience + from agent_comms.audience_manifest import freeze_audience + from agent_comms.frozen_recipient import FrozenRecipient from agent_comms.bus import PrivateRecord from agent_comms.mentions import ThreadMention from agent_comms.wake import OrdinaryControl diff --git a/tests/test_coordination_store.py b/tests/test_coordination_store.py index 699b0d6120..681979c44c 100644 --- a/tests/test_coordination_store.py +++ b/tests/test_coordination_store.py @@ -97,7 +97,8 @@ def store(path: Path) -> StandaloneCoordination: def wire_source(db: StandaloneCoordination, assignment_id: AssignmentId, original: Message, mode: WakePolicy) -> SelectedSource: - from agent_comms.audience_manifest import FrozenRecipient, freeze_audience + from agent_comms.audience_manifest import freeze_audience + from agent_comms.frozen_recipient import FrozenRecipient from agent_comms.bus import PrivateRecord from agent_comms.selected_source_batch import SelectedSource from agent_comms.wake_policy import WakeDecision diff --git a/tests/test_deleted_thread_observation.py b/tests/test_deleted_thread_observation.py index 490fa29e19..0abcc884ad 100644 --- a/tests/test_deleted_thread_observation.py +++ b/tests/test_deleted_thread_observation.py @@ -11,12 +11,13 @@ 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, ) 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 +81,34 @@ 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 + 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), LiveNotificationRead(())))).outcomes == {} def test_one_revision_captures_each_thread_activity_once(comms): diff --git a/tests/test_display_unread.py b/tests/test_display_unread.py index 1d47bb85dd..884c502112 100644 --- a/tests/test_display_unread.py +++ b/tests/test_display_unread.py @@ -1,6 +1,6 @@ from dataclasses import replace -from agent_comms.audience_manifest import FrozenRecipient +from agent_comms.frozen_recipient import FrozenRecipient from agent_comms.bus import Bus, DisplayUnread from agent_comms.field_codec import ThreadLookup from agent_comms.messages import InfoKind, Message, PublicRecord 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_following_context_notifications.py b/tests/test_following_context_notifications.py new file mode 100644 index 0000000000..f307f66447 --- /dev/null +++ b/tests/test_following_context_notifications.py @@ -0,0 +1,292 @@ +"""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.turn_inbox 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.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.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 +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, await recorded_input_context(claim)) + yield claim + + +@dataclass(frozen=True) +class FollowingContext: + claim: PrivateSendClaim + keys: tuple[InputKey, ...] + references: tuple[MessageReference, ...] + 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 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( + 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, + range(self.context_event.request_generation, self.context_event.request_generation + 1), + ) + 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 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. + + 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 = 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, + '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, 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, + 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 + 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 + 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() + 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_goal_execution.py b/tests/test_goal_execution.py index c84846de87..e262c82194 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 diff --git a/tests/test_harness_receipt_context.py b/tests/test_harness_receipt_context.py new file mode 100644 index 0000000000..b62c1390d9 --- /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.harness_delivery_states import ( + AcceptedOutcome, DeliveredOutcome, EmittedOutcome, FilteredOutcome, + ObservedMonitor, PersistedOutcome, UnconfirmedOutcome, UnrecordedMonitor, +) +from agent_comms.notification_progress import HarnessConsumedProgress +from agent_comms.notification_evidence import UnansweredResponse +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'), ( + (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, +): + """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 == UnansweredResponse() + assert not current.displayed_to + assert recorded.monitor == monitor + if consumed: + assert current.label == current.timed_state(HarnessConsumedProgress.label, 1791964800125) diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 25375ca8ea..558a904633 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -4,7 +4,8 @@ import subprocess import sys import time -from contextlib import nullcontext +from collections.abc import Callable +from contextlib import chdir, nullcontext from dataclasses import replace from datetime import UTC, datetime from pathlib import Path @@ -13,42 +14,51 @@ from agent_comms.commander import judge, order +from agent_comms.comms import Comms from agent_comms.closed_pull_requests import ClosedPullRequestWatch from agent_comms.commander.publication_rules import ClosedUnmergedPullRequest from agent_comms.commander_heartbeat import CommanderHeartbeat from agent_comms.goal_actions import CreateProblemGoalAction +from agent_comms.build_identity import RepositoryCommit +from agent_comms.head_review import ( + DraftMergeArm, HeadReview, IntegrationInput, LocalIntegrationSource, MergedIntegrationSource, MergeHeadReview, OrdinaryInput, PromotedHeadReview, PullRequestHead, ReadPullRequestHead, +) +from agent_comms.integration import IntegrationRun from commander_heartbeat_fixture import capture_frame -from agent_comms.head_review import HeadReview, PullRequestHead, ReadPullRequestHead from agent_comms.heartbeat_subjects import CommanderHistory, HeadSubject, UnlandedHeadSubject from agent_comms.recorded_time import RecordedEventTime +from agent_comms.repository_name import RepositoryName from agent_comms.standing_rules import ( HeadReviewTimerUp, IdlePullRequest, OrderlessPullRequest, OrderlessUnreviewedHead, LinkedUnreviewedHead, ReviewTimerUp, StuckMerge, ) from agent_comms.repo import Checkout -from agent_comms.repository_name import RepositoryName from agent_comms.task_attachments.commander_pings import CommanderDecisions, CommanderUpdate, RulePing from agent_comms.thread_status import StoppedThreadStatus 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, GoalId, MalformedRecord, RecordProblems from agent_comms.tool_caller import ThreadCaller +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.work_branches import ( SourceGrowth, ThreadBranchCheckout, UnregisteredBranchCheckout, WorkBranches, ) from agent_comms.order_tools import CommsHeadVerdictTool from agent_comms.todos import ( CarriedHeadVerdict, ClosedListedPullRequest, ConflictMergeBlock, DraftListedPullRequest, FailedCheckMergeBlock, GrownHeadVerdict, GrowthReason, OrderHeadReview, LinkedHeadReview, - NoHeadOrder, ProgressRead, PullRequestLink, RecordPullRequest, RejectedHeadVerdict, - ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, - StrikeReason, SupersededHeadVerdict, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, + NoHeadOrder, OrderClaim, ProgressRead, PullRequestLink, PullRequests, RecordPullRequest, RejectedHeadVerdict, + HeadVerdict, HeadReviewed, QualifiedHeadVerdict, ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, + StrikeReason, SupersededHeadVerdict, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, WithdrawTodo, ) +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") +QUALIFIED = VerdictReason("The source stage is reviewed while paired implementation remains incomplete") @pytest.mark.parametrize('roast,fields', ( @@ -88,11 +98,566 @@ 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 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] + 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.""" + 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, context.repos) + + 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 = review_posts(github, request.head) + 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()) + + +@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) + 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): + 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} + 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 = 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 + 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_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 + before = comms.goals.todos.get("P2") + context = order.Commander.read_root(comms.root) + with chdir(companion_checkout): + for arguments in ( + ["--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_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: + 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_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: + 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)) + 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="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) + assert tuple(call for call in github.calls() if call[:2] == ("pr", "merge")) == arms + posts = review_posts(github, request.head) + assert "state=pending" in posts[-1] + # Reaccepting this source permits combination, never an individual merge. + invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) + assert HeadReview.accepted(request.repo, request.head) + assert not any(call[:2] == ("pr", "merge") for call in github.calls()) + + +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()) + + +@pytest.fixture +def captured_input(reviewed_source: SourceRepository) -> IntegrationInput: + source = reviewed_source + 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(), + ),)) + + +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 + 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): + OrdinaryInput((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 = 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(), + ) + 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") + 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) + 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 + 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_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 = 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(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(ToolRefusedError, match="no accepted exact-head source review"): + promote(pair.context, carrier) + invoke(comms, original, ReviewedHeadVerdict(ACCEPTED)) + promote(pair.context, carrier) + assert HeadReview.accepted(carrier.repo, carrier.head) + 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 PromotedHeadReview.accepted(carrier.repo, carrier.head) + invoke(comms, carrier, ReviewedHeadVerdict(ACCEPTED)) + assert HeadReview.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()) + + +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") + 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", + })) + 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(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) + + +@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): + 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()) + + 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) @@ -123,9 +688,9 @@ 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]) - assert len(posts) == 2 - assert "state=success" in posts[0] and "state=failure" in posts[1] + 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=failure"] def test_judge_ok_and_back_dispatch_orderless_prs_without_positional_edit_ids(head_bus): @@ -144,43 +709,161 @@ 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 - - -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.""" + assert len(review_posts(github, request.head)) == 4 + + +@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 ReviewedHeadVerdict + assert PromotedHeadReview.accepted(request.repo, request.head) + 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) + + +@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 - 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 PromotedHeadReview.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): @@ -214,16 +897,17 @@ 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"] + "state=pending", "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()) @@ -248,9 +932,9 @@ 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]) - assert len(carried_posts) == 1 - assert f"description=carried from {request.head[:9]}: same change" in carried_posts[0] + carried_posts = review_posts(github, merged) + 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') @@ -297,7 +981,8 @@ def test_refused_carry_records_nothing_and_alarms_only_its_head(head_bus, review merged = 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, merged) - github.refuse_status(request.repo, "gh: Repository was archived so is read-only. (HTTP 403)") + github.refuse_status(request.repo, moved.head, HeadReview.context, + "gh: Repository was archived so is read-only. (HTTP 403)") pulls = ProgressRead() pulls.carry_reviews(comms.goals, request.repo) @@ -455,8 +1140,9 @@ 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]) - assert len(posts) == 1 and any(f"(src +{grown})" in part for part in posts[0]) + posts = review_posts(github, head) + 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. @@ -517,8 +1203,9 @@ 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]) - assert len(posts) == 1 and any("(src +1)" in part for part in posts[0]) + posts = review_posts(github, head) + 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(): diff --git a/tests/test_historical_notifications.py b/tests/test_historical_notifications.py index 6c18bb27d0..2d087076aa 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.notification_evidence 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 @@ -26,9 +28,11 @@ 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.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 from agent_comms.read_basis import HistoricalDisplay, ReadCursor from agent_comms.read_ledger import ReadLedger from agent_comms.thread_identity import AgentThreadRole, UserThreadRole @@ -72,7 +76,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() @@ -108,7 +111,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( + transcript_record('complete-original'), shown.timestamp)) assert attempt.begin(store.assignments, db) attempt.record(store.assignments, db) @@ -122,7 +126,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', 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. @@ -131,7 +136,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(): @@ -144,7 +148,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): @@ -155,7 +158,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 @@ -186,10 +188,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( @@ -225,11 +224,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 ( @@ -258,7 +253,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, @@ -302,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. @@ -323,8 +319,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.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. ignored = IgnoredAssignment.build(FullWake(), None, None) declined = ignored.notification(recipient, SENDER, observation=RecordedRecipientActivity(), @@ -334,11 +331,10 @@ 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 delivered_answer.answer == AnsweredResponse(MessageReference(99, reply_id), 3_000) assert sent.recipient_identity == consumed.recipient_identity == answered.recipient_identity @@ -369,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 @@ -417,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, response.at_ms) def test_recorded_response_resolution_keeps_other_collective_lifecycles( @@ -478,16 +473,29 @@ 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( + 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[ 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', 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 ] 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_historical_views.py b/tests/test_historical_views.py index 84f2c3bc21..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)) @@ -270,6 +271,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", 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) assert rebound.current_identity() == decoded assert any(event.text == "original recorded answer" for event in rebound.read().events) @@ -289,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_holder_standing_rules.py b/tests/test_holder_standing_rules.py index c6c7e7d601..f9eca88605 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -170,14 +170,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_input_delivery_history.py b/tests/test_input_delivery_history.py index 19c94ada35..e5172282e1 100644 --- a/tests/test_input_delivery_history.py +++ b/tests/test_input_delivery_history.py @@ -1,23 +1,35 @@ """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 ( + 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 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.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 +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 +115,80 @@ 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")) + + +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): diff --git a/tests/test_integration.py b/tests/test_integration.py index 2e91c7e33c..f3bb8615ef 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,22 +1,39 @@ """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 from pathlib import Path import subprocess +import sys +import tomllib import pytest +import tomli_w 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, 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, 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 PrivateUvProject, 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 agent_comms_project import AgentCommsProject, ClientTypeErrors +from nominal_refactor_advisor import audit +from module_layers import MaterializedClients, Package -pytest_plugins = ("github_fixture",) +pytest_plugins = ("github_fixture", "test_holder_standing_rules", "integration_source_fixture") @pytest.fixture @@ -25,6 +42,390 @@ def heads() -> tuple[PullRequestHead, ...]: return (PullRequestHead(repo, 7, "a" * 40), PullRequestHead(repo, 8, "b" * 40)) +@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, moved_main_refuses: bool, +) -> None: + pair = mutual_sources + 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. + 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}}}, + "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") + 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),) + 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) + 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) + # 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()) + + +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 + baseline = pair.core.main_commit + tree = pair.core.git(pair.core.checkout, "rev-parse", f"{head}^{{tree}}").strip() + 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" + 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")} + 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) + 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 = 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 == () + 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 + 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) + + +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, +) -> 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 + 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 + 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()) + + +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") + 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,))) + 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,), + check=replace(primary.order.check, directory=peer.checkout)), + review_at=RecordedEventTime(9999999999.0))) + 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) + 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, 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' + 'from performance.capture_live import capture\n' + 'def plugin(version: str) -> type[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"], "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 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,))) + 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) + 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")) + 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 = audit.gate.compare(project, source.baseline.commit, head, "src", 1, + (ClientTypeErrors,)) + 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, 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}}}, + 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"}}, + environment=integration.RunnerEnvironment(root=tmp_path)) + 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"}' + project = '[project]\ndependencies=[]\n[tool.uv.sources]\n' + assert StackSources.declared_sources(PrivateUvProject.decode( + project + f'Source_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(PrivateUvProject.decode( + project + f'Source_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' @@ -50,6 +451,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() == () @@ -59,6 +474,89 @@ 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 = 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,) + 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 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()) + + +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 + pair.capture(("P2", "P3"), "both mixed imports must fail") + 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 = 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"})) + 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]\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[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') + before, after = (PrivateUvProject.decode(text) for text in (baseline, candidate)) + StackSources.require_compatible(before, after) + StackSources.require_compatible(after, before) + + +def test_common_runtime_contract_refuses_unsupported_external_dependency_migration() -> None: + 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( + PrivateUvProject.decode(baseline), PrivateUvProject.decode(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/tests/test_live_context_notifications.py b/tests/test_live_context_notifications.py new file mode 100644 index 0000000000..78367d5458 --- /dev/null +++ b/tests/test_live_context_notifications.py @@ -0,0 +1,172 @@ +"""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.notification_evidence import UnansweredResponse +from agent_comms.native_input_proof import NativeContextJournal, NativeContextProof +from agent_comms.native_runtime_input import NativeRuntimeInput +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 +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, prepared_native_triage + + +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: + 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( + tmp_path, source_bodies=tuple(f'Grouped source {index}' for index in range(source_count - 1)), + ) as 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 + 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 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) + assert after_rows and all(row.state.consumed 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: + 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_triage_consumption_precedes_and_survives_later_verdict(tmp_path: Path): + async with prepared_native_triage(tmp_path) as 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) + 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 == 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) + 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 = 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) + 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 = 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. + with closing(sqlite3.connect(files.input_proof)) as db, db: + 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) + later_context = later.at(context.session_file, session_entry_start=context.session_entry_start) + 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) + 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 = await recorded_input_context(claim) + 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)) + retained = NativeRuntimeInput.require(store.session._connection, input_id=claim.input_id) + assert retained.require_context_proof() == context + + +@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 = await recorded_input_context(claim) + with pytest.raises(IdentityConflict): + 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 diff --git a/tests/test_message_notifications.py b/tests/test_message_notifications.py index 23d86badf8..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.presentation 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 @@ -15,7 +15,7 @@ def test_channel_assignments_are_visible_from_agent_view(tmp_path): # noqa: F81 receipts = comms.views.thread_presentations(('beta',)).require('beta').notifications assert len(receipts) == 1 acknowledgement = receipts[0] - assert acknowledgement.message == message + assert acknowledgement.message == initial.envelope assert acknowledgement.message.target == '#team' assert replace(acknowledgement, message=None) in per_message.outcomes[(message.seq, message.message_id)] assert acknowledgement.label == acknowledgement.timed_state('Sent', acknowledgement.state.sent_at_ms) @@ -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_module_layers_clients.py b/tests/test_module_layers_clients.py index 64b113fb92..57788fed81 100644 --- a/tests/test_module_layers_clients.py +++ b/tests/test_module_layers_clients.py @@ -1,13 +1,25 @@ """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 import sys +import tomllib from pathlib import Path -from module_layers import OutgoingWire, Package, StackClient +import pytest +import tomli_w + +from module_layers import MaterializedClients, OutgoingWire, Package, StackClient +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 +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 +27,141 @@ 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], +) -> None: + pair = mutual_sources + 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) + files = tuple(sorted(pair.output.rglob(".git"))) + 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 == () + 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()) + + +@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, + materialized_clients: Callable[[Path, IntegrationInput], MaterializedClients], +) -> 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.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, "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" + github_cli.put(pair.core.github, DraftListedPullRequest( + pair.core.number, "feature", head, stamp, "main", reviewable_times=(stamp,))) + 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() + 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(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( + 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") + 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 = 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): + 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"} + + +@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()) + 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 = 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() + 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/tests/test_monitor_delivery.py b/tests/test_monitor_delivery.py index 8bed1e9a7f..3e7ff3c7e0 100644 --- a/tests/test_monitor_delivery.py +++ b/tests/test_monitor_delivery.py @@ -48,13 +48,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, QuestionKind +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 @@ -127,8 +131,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') @@ -1024,10 +1028,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: @@ -1068,10 +1068,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')) @@ -1079,9 +1075,6 @@ 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 @@ -1099,8 +1092,6 @@ async def test_confirmed_reply_routes_follow_direct_and_channel_policy( @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)) diff --git a/tests/test_native_context_reference.py b/tests/test_native_context_reference.py index a45e53ac0d..7078de9f3f 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 @@ -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"): @@ -123,7 +130,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 +150,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 +158,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 +168,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 +214,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 +227,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 +250,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 +259,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 +273,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_native_evidence_custody.py b/tests/test_native_evidence_custody.py index 7c12f4e04e..d875519b40 100644 --- a/tests/test_native_evidence_custody.py +++ b/tests/test_native_evidence_custody.py @@ -13,8 +13,11 @@ 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, 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 ( ExclusiveLock, @@ -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", original.entry.require_entry_id(), session_entry_start=original.start) + location = NativeInputLocation(INPUT_ID, PiSessionId("sid"), original.entry.id.require_present(), session_entry_start=original.start) restored_location = NativeInputLocation( - restored_input, "new", location.session_entry_id, session_entry_start=original.start, + InputId(restored_input), PiSessionId("new"), location.session_entry_id, session_entry_start=original.start, ) with NativeEvidenceScope() as scope: evidence = scope.for_source(session) @@ -118,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 @@ -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_failure_recovery.py b/tests/test_native_failure_recovery.py index 30386eccb9..a4055a2526 100644 --- a/tests/test_native_failure_recovery.py +++ b/tests/test_native_failure_recovery.py @@ -21,13 +21,17 @@ 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 +from conftest import accept_initial, publish_initial from test_coordinated_runtime import _root, tmp_path # noqa: F401 @@ -114,33 +118,63 @@ def record(cls, claim, session, participant): @asynccontextmanager -async def prepared_native_input( - directory: Path, *, max_attempts: int = 1, -) -> 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', 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: with Coordination(root / "coordination.sqlite3") as store: async with SelectedParticipant.select( 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: diff --git a/tests/test_native_input_bus_path.py b/tests/test_native_input_bus_path.py index 9402251e03..1412cab9b1 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_pi 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 @@ -55,12 +56,12 @@ 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( 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 69b88a07d6..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_pi 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_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_native_pi.py b/tests/test_native_pi.py index e3ebdd6a9b..000884104c 100644 --- a/tests/test_native_pi.py +++ b/tests/test_native_pi.py @@ -14,23 +14,24 @@ 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 ( - NativeContextJournal, - NativeContextProof, NativePiRpcLaunch, 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 @@ -64,11 +65,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'): @@ -153,7 +154,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): @@ -164,7 +165,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}, @@ -175,7 +176,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 @@ -188,11 +189,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')]) @@ -235,7 +236,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',record.entry.require_entry_id(),1,DIGEST,1_791_964_800_001,session, + context = NativeContextProof(INPUT_ID,PiSessionId('sid'),record.entry.id.require_present(),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')) @@ -248,29 +249,30 @@ 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 _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=record.entry.require_entry_id(),session_entry_start=record.start) + 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=record.entry.id.require_present(),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 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): - _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): @@ -284,14 +286,14 @@ 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): 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_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 ( diff --git a/tests/test_notification_display_boundary.py b/tests/test_notification_display_boundary.py new file mode 100644 index 0000000000..8664f41c8d --- /dev/null +++ b/tests/test_notification_display_boundary.py @@ -0,0 +1,62 @@ +"""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.notification_evidence import AllOutcomes +from agent_comms.ui_model.observation import ReadNotifications +from test_notification_display_viewer import ArchivedReaders, archived_readers # noqa: F401 + + +def test_notification_request_retains_viewer_source_and_full_operator_access(archived_readers: ArchivedReaders) -> None: + 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(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} + 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 + 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: ArchivedReaders) -> None: + fixture = archived_readers + identity = fixture.selection('self').notification_identity + 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) + # 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..5ec79c6d39 --- /dev/null +++ b/tests/test_notification_display_viewer.py @@ -0,0 +1,165 @@ +"""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.message_page import MessagePage +from agent_comms.messages import Message +from agent_comms.notification_evidence import NotificationWindow +from agent_comms.notification_progress import DeliveryProgress +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 +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 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) + + @property + def reads(self) -> ReadLedger: + return ReadLedger.at(Path(self.source.root)) + + +@pytest.fixture +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): + 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: ArchivedReaders, painted: tuple[str, ...], +) -> None: + 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(fixture.live.registry)) + 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]} + 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: ArchivedReaders) -> None: + fixture = archived_readers + fixture.paint('self') + captured = fixture.selection('self').notification_identity + 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) + raw = fixture.notifications(AllOutcomes()) + assert any(mark.displayed_to for mark in raw.read_marks) + 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(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(fixture.live.registry)).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(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 + + +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)) + 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(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] == () + 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 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() diff --git a/tests/test_notification_read_state.py b/tests/test_notification_read_state.py index f9f1aa477e..7bf11daa3a 100644 --- a/tests/test_notification_read_state.py +++ b/tests/test_notification_read_state.py @@ -1,26 +1,55 @@ +from agent_comms.transcripts import RecordedPageSource +from agent_comms.historical_views import HistorySourceKey +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 ( + CommanderUnitRead, InputDeliveryRead, OwnerGoalRead, TranscriptPageRead, +) 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 +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 EarlierThan, NativeTranscriptCursor from agent_comms.ui_model.observation import ( - NotificationsRead, ObserveNotifications, ReadNotifications, ReadRecordedNotifications, - UnreadableNotificationsRead, + ObserveNotifications, ReadNotifications, ReadOperation, ReadOwnerOperation, + ReadTranscript, ReadTranscriptUpdate, RefreshTranscript, SubscribeTranscript, ) 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((), HistorySourceKey("archive")) settled = NotificationsRead(original, {}) assert not live.changed_since(comms, original, settled) assert not live.changed_since(comms, later, settled) @@ -29,13 +58,71 @@ 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 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)) + 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_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 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) + + +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 settled.notification_window.activity_names() - settled.activity.keys() == {"archived"} 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): @@ -48,7 +135,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) @@ -66,9 +153,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 +167,80 @@ 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, HistorySourceKey("archive")), +], ids=["live", "recorded"]) +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 + 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 + 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, HistorySourceKey("archive")), request_id=4) + distinct = ( + replace(request, root="other-root"), + replace(request, request_id=5), + 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, 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, HistorySourceKey("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) @@ -113,3 +274,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", position=EarlierThan(NativeTranscriptCursor("session.jsonl", 7)), + page_source=RecordedPageSource("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], position=EarlierThan(NativeTranscriptCursor("session.jsonl", 8)))), + replace(page, operation=replace(SHARED_UI_READS[1], page_source=RecordedPageSource("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} diff --git a/tests/test_notification_recipient_window.py b/tests/test_notification_recipient_window.py new file mode 100644 index 0000000000..946e7cd982 --- /dev/null +++ b/tests/test_notification_recipient_window.py @@ -0,0 +1,180 @@ +"""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 AnsweredResponse +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 +from agent_comms.threads import Thread +from agent_comms.notification_reads import LiveNotificationRead +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=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 + 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=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 tuple(changed.consumption) == ((source.recipients[0].recipient_lookup, source.reference),) + + +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), 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=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=RecipientLimit(1)) + assert tuple(row.recipient_identity for row in recipient.outcomes[key]) == (own,) + with pytest.raises(ValueError): + RecipientLimit(RecipientLimit.maximum + 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=RecipientLimit(1)) + 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,) + 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 == AnsweredResponse(response.reference, response.at_ms) + assert not row.state.consumed + 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, later.at_ms).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=RecipientLimit(1)) + recipient = selection.pages[0].recipients[0] + reads = ReadLedger.at(comms.root).read() + before = NotificationAssignment.read(comms.root, selection) + 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 == () and not after.consumption + assert after.responses[recipient.recipient_lookup, message.reference] == AnsweredResponse( + 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 == 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_observer_death.py b/tests/test_observer_death.py index 78379f87c3..2d90709887 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,62 +16,68 @@ 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.notification_reads import LiveNotificationRead from agent_comms.ui_model.observation import ( - ObservationProcess, ObserverDied, OwnerRead, ReadNotifications, StopSidebar, + ObservationProcess, ObserverDied, ReadNotifications, ReadOwnerOperation, StopSidebar, ) +from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.threads import Thread +from owner_socket_fixture import declare_owner -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 owner_reads(): + """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) + + +def empty_window(root) -> ReadNotifications: + return ReadNotifications(str(root), LiveNotificationRead(())) 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), ())) == {} + await process.read(ReadNotifications(str(comms.root), LiveNotificationRead( + (MessageReference(1, MessageId.mint("not-the-original")),) + ))) + assert (await process.read(empty_window(comms.root))).outcomes == {} -@pytest.mark.parametrize("read_type", OwnerRead.__subclasses__()) +@pytest.mark.parametrize("read_type", owner_reads()) @pytest.mark.parametrize("declared, refusal", [ (True, RelationViolationError), (False, UnregisteredThreadError), ]) @@ -80,25 +87,41 @@ 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() -@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)) 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() + + +@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) + 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(empty_window(comms.root))).outcomes == {} assert process.process.is_alive() process.status.raise_if_failed() @@ -106,14 +129,10 @@ async def test_stale_owner_reads_preserve_the_observer(comms, durable_home, read 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_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index 83dec2c377..7cc28d799d 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.notification_evidence 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 @@ -23,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 @@ -33,28 +34,108 @@ 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 +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, AllOutcomes().selection((initial,))) + 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, viewer.selection((initial,))) + finally: + sys.setprofile(profiling) + assert decoded == 1 + 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].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 + 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( + OwnerOutcomes(recipient.recipient_lookup).selection((initial,)), ()) + assert source is initial + assert [outcome.recipient() for outcome in outcomes] == [recipient] + (_source, all_outcomes), = NotificationAssignment.for_deliveries( + OwnerOutcomes(lookups["sender"]).selection((initial,)), ()) + 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, OwnerOutcomes(changed.recipient_lookup).selection((initial,))), + "foreign": NotificationAssignment.read( + root, OwnerOutcomes(unchanged.recipient_lookup).selection((initial,))), + "sender": NotificationAssignment.read( + root, OwnerOutcomes(lookups["sender"]).selection((initial,))), + "operator": NotificationAssignment.read(root, AllOutcomes().selection((initial,))), + } + 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 @@ -128,15 +209,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 @@ -240,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 @@ -257,3 +339,79 @@ 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.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" + 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 key_fields * len(distinct) > limit and len(keys) > len(distinct) + selection = AllOutcomes().selection(sources) + ordinary = NotificationAssignment.read(root, selection) + assert ordinary.rows + 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, limit) + assert limit < previous + 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, selection, privacy) + 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] + + 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) + 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.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] + 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 diff --git a/tests/test_phase_evidence.py b/tests/test_phase_evidence.py index 89b7b4d377..e317b53cd1 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, @@ -266,30 +266,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_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_project_metadata.py b/tests/test_project_metadata.py new file mode 100644 index 0000000000..56cfdf95b2 --- /dev/null +++ b/tests/test_project_metadata.py @@ -0,0 +1,190 @@ +"""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 +from packaging.specifiers import SpecifierSet +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.field_codec import FieldCodec +from agent_comms.installer.runtime_resolution import PrivateUvProject, ProjectMetadata, StackSources +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}"), 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 + + +@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(SpecifierSet("<1")) + environment = StackSources.marker_environment(Path(sys.executable)) + with pytest.raises(ValueError, match="selected common interpreter"): + StackSources.marker_environment(tmp_path / "different-interpreter") + root = FieldCodec.decode(PrivateUvProject, { + "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 = FieldCodec.decode(PrivateUvProject, { + "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 = pair.capture(("P2", "P3"), "backend metadata lifetime") + 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/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) diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index a335853017..a142ff9385 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -9,11 +9,12 @@ from agent_comms.registry_document import ThreadRows from agent_comms.activity import Activity, ActivityState, ActivityStore -from agent_comms.audience_manifest import FrozenRecipient +from agent_comms.frozen_recipient import FrozenRecipient 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.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 from agent_comms.read_basis import ConversationCursor, ReadCursor @@ -86,8 +87,62 @@ 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 + + +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).incarnation) + read = NotificationAssignment.read(comms.root, viewer.selection((original,))) + assert read.selection.viewer == viewer + window = MessageNotification.delivery_window(comms.root, comms.registry, 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").incarnation) + read = NotificationAssignment.read(comms.root, viewer.selection((original,))) + 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"} + + +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, AllOutcomes().selection((original,))) diff --git a/tests/test_recorded_board_health.py b/tests/test_recorded_board_health.py index a469e59515..170ef1db04 100644 --- a/tests/test_recorded_board_health.py +++ b/tests/test_recorded_board_health.py @@ -13,12 +13,17 @@ from agent_comms.errors import ToolRefusedError from agent_comms.field_codec import ToolCallId from agent_comms.locked_store import DocumentChange -from agent_comms.order_tools import CommsCommanderLoadTool, CommsHudTool, CommsUnjudgedTool +from agent_comms.order_tools import ( + CommanderUnitRead, + CommsCommanderLoadTool, + CommsHudTool, + CommsUnjudgedTool, +) from agent_comms.recorded_time import RecordedEventTime from agent_comms.service_passes import ServicePassStore from agent_comms.todos import ProgressRead from agent_comms.tool_caller import ThreadCaller -from agent_comms.ui_model.observation import ReadCommanderUnit +from agent_comms.ui_model.observation import ReadOperation from agent_comms.ui_model.unit_state import UnitState from commander_heartbeat_fixture import ObservedHeartbeat, capture_frame @@ -100,9 +105,9 @@ def test_stopped_heartbeat_without_failure_explains_stale_board_and_refuses_deta assert hud.header[0] == 'no failure recorded, so no heartbeat is running' assert 'STALE retained' in hud.retained_status() card = hud.units[0] - request = ReadCommanderUnit( - root=str(comms.root), commander=owner, unit=card.unit, - orders=tuple(dict.fromkeys(order.order for order in card.orders))) + request = ReadOperation(str(comms.root), CommanderUnitRead( + commander=owner, unit=card.unit, + orders=tuple(dict.fromkeys(order.order for order in card.orders)))) with pytest.raises(ToolRefusedError, match='no failure recorded, so no heartbeat is running'): request.read(comms) diff --git a/tests/test_route_counts.py b/tests/test_route_counts.py index 93b3154e78..e4dd238867 100644 --- a/tests/test_route_counts.py +++ b/tests/test_route_counts.py @@ -9,7 +9,8 @@ import pytest -from agent_comms.audience_manifest import FrozenRecipient, freeze_audience +from agent_comms.audience_manifest import freeze_audience +from agent_comms.frozen_recipient import FrozenRecipient from agent_comms.bus import Bus, PrivateRecord from agent_comms.comms import Comms from agent_comms.field_codec import ThreadLookup diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 42a871c090..b9b3cc619c 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -20,7 +20,16 @@ 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.bus_read import OwnerReadRuntimeRequest +from agent_comms.order_tools import InputDeliveryRead +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 AcpSessionId, FieldCodec, RootId from agent_comms.owner_protocol import OwnerAnnouncement, OwnerReady, OwnerResult, OwnerUpdate @@ -32,42 +41,18 @@ 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, comms.registry.require("reply-loss").incarnation, path) + owner = declare_owner(comms, "reply-loss", process) + async with silent_socket(comms, process) as received: + connection = RuntimeConnection( + comms, owner.incarnation, socket_path(comms.root, process.pid), + ) try: with pytest.raises(OwnerReplyLostError) as lost: await connection.request("cancel") @@ -76,9 +61,113 @@ 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) + reading = OwnerReadRuntimeRequest(thread="held", operation=InputDeliveryRead(thread="held")) + async with silent_socket(comms, process) as received: + 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 reading.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) + reading = OwnerReadRuntimeRequest(thread="held", operation=InputDeliveryRead(thread="held")) + async with silent_socket(comms, process) as received: + connection = OwnerReplyConnection.held(comms, old.incarnation, reading) + try: + comms.registry.unregister("held") + comms.registry.begin_delete("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 + with pytest.raises(OwnerIdentityChangedError): + async with asyncio.timeout(30): + await reading.read(connection) + with pytest.raises(OwnerIdentityChangedError): + OwnerReplyConnection.held(comms, old.incarnation, reading) + 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) + 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, reading) + try: + child.stop_sync() + assert not child.identity.alive() + with pytest.raises(ConnectionError): + async with asyncio.timeout(30): + await reading.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 +274,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) 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/tests/test_source_revision.py b/tests/test_source_revision.py index d4dc6a3b8d..56a4dcdb80 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_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_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_path, "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) diff --git a/tests/test_stack_native_compaction.py b/tests/test_stack_native_compaction.py index c58c5d2f18..2ceb5d0ebc 100644 --- a/tests/test_stack_native_compaction.py +++ b/tests/test_stack_native_compaction.py @@ -88,7 +88,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(), diff --git a/tests/test_thread_presentation.py b/tests/test_thread_presentation.py index 5679155b19..402e1a9b9c 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_notifications() == () 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 14356cfcbd..04c44f17b4 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 @@ -145,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 diff --git a/tests/test_todos.py b/tests/test_todos.py index ac9dd43795..ff3e80b2f9 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -399,7 +399,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),) 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 diff --git a/tests/test_tools.py b/tests/test_tools.py index 1cfac5f173..e990f57b88 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -164,7 +164,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 SentMessageSummary.recent(presentation)] == [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. diff --git a/tests/test_transcript_observation_lifetime.py b/tests/test_transcript_observation_lifetime.py index b4e75e8d80..2e2b01144e 100644 --- a/tests/test_transcript_observation_lifetime.py +++ b/tests/test_transcript_observation_lifetime.py @@ -11,6 +11,7 @@ from agent_comms.coordinator import Coordination from agent_comms.native_arguments import GivenBinary +from agent_comms.notification_reads import LiveNotificationRead from agent_comms.ui_model.observation import ( ObservationProcess, ObserverDied, @@ -89,7 +90,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_transcript_pages.py b/tests/test_transcript_pages.py index f9e86fbf87..eb4c380705 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 @@ -10,8 +11,14 @@ 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 +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): @@ -39,7 +46,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 @@ -52,11 +59,49 @@ 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)] +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, position=operation.position, limit=operation.limit, + 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), + 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", 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", 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))) + 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 @@ -71,9 +116,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): @@ -135,7 +180,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) @@ -143,7 +188,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] diff --git a/tests/test_unreadable_history_observation.py b/tests/test_unreadable_history_observation.py index 3791deea7a..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, @@ -45,7 +46,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 +223,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), LiveNotificationRead(())))).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() diff --git a/tests/test_wake.py b/tests/test_wake.py index 5fb3edeb9c..74d10e0a03 100644 --- a/tests/test_wake.py +++ b/tests/test_wake.py @@ -7,7 +7,8 @@ import pytest -from agent_comms.audience_manifest import AudiencedMessage, FrozenRecipient, freeze_audience +from agent_comms.audience_manifest import AudiencedMessage, freeze_audience +from agent_comms.frozen_recipient import FrozenRecipient from agent_comms.field_codec import ThreadLookup from agent_comms.mentions import ThreadMention from agent_comms.messages import ( diff --git a/tests/test_wake_candidate_index.py b/tests/test_wake_candidate_index.py index a22dbbb632..56ffc8f02a 100644 --- a/tests/test_wake_candidate_index.py +++ b/tests/test_wake_candidate_index.py @@ -5,26 +5,22 @@ 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 ( - AnsweredResponse, AssignmentActivity, NotificationAssignment, UnansweredResponse, -) -from agent_comms.notification_progress import ShownToModelProgress +from agent_comms.notification_assignment import NotificationAssignment +from agent_comms.notification_evidence import AnsweredResponse, NotificationAssignmentRead 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 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 @@ -131,24 +127,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, reply.at_ms)) def test_wait_finds_dependency_reply_after_its_recorded_sequence(bus, owner, peer): diff --git a/tools/agent_comms_project.py b/tools/agent_comms_project.py index ca4400a3b2..96386ca522 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 from nominal_refactor_advisor import audit import agent_comms @@ -42,8 +42,10 @@ from agent_comms.core_stamp import WheelStamp from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import boundary_decoder +from agent_comms.head_review import Side as CutSide from agent_comms.installer.carry import STEPS from agent_comms.installer.probes.carry import Carry +from agent_comms.installer.runtime_resolution import StackSources from agent_comms.nominal_census import NominalCensus from agent_comms.phase_evidence import MainTree, PlanReading from agent_comms.plan_phases import PlanPhase @@ -87,6 +89,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) -> 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)), + 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); " @@ -217,7 +237,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: @@ -225,18 +245,11 @@ 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="gate-clients-")) - atexit.register(shutil.rmtree, into, True) - (into / "stack").mkdir() - (into / stack).write_bytes(repo.blob(head, stack)) - return StackClient.fetched(into / "stack", into) + def cut(repo: AgentCommsProject, base: str, head: str, + side: type[audit.gate.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 tuple(repo.clients.at(repo.path, base, head, CutSide.materializing(side))) @staticmethod def selected(client: StackClient) -> list[str]: @@ -268,15 +281,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.gate.Side.sides()) + for side in audit.gate.Side.sides() + for client in cls.cut(repo, base, head, side) + if cls.selected(client)) @classmethod def measured( diff --git a/tools/checker_stack.py b/tools/checker_stack.py new file mode 100644 index 0000000000..4b7666332d --- /dev/null +++ b/tools/checker_stack.py @@ -0,0 +1,281 @@ +"""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 +from dataclasses import dataclass +from functools import cached_property +from pathlib import Path + +import tomli_w +from module_layers import MaterializedClients +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + +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, + 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 + + +@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 source_projects(self) -> dict[type[Side], PrivateUvProject]: + source = self.inputs.source(CorePackage.repository) + return { + side: PrivateUvProject.read( + side.require_materialized(self.source_directory, source) / "stack/pyproject.toml" + ) + for side in Side.sides() + } + + @cached_property + def local_names(self) -> frozenset[str]: + return self.local_packages(*self.source_projects.values()) + + 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") + + @cached_property + def clients(self) -> MaterializedClients: + return MaterializedClients(self.source_directory, self.inputs, self.uv, self.python) + + def requirements(self, side: type[Side]) -> tuple[Requirement, ...]: + # Identity validation and runtime selection borrow the SAME backend metadata objects. + projects = self.clients.projects(side) + return self.active_requirements( + self.source_projects[side], + tuple(projects.values()), + self.clients.environment, + ) + + def require_runtime_compatibility(self) -> None: + # Local package presence differs legitimately; only the real external contract agrees. + 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" + ) + + @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( + PrivateUvProject.read(self.checkout / "stack/pyproject.toml"), + tuple(self.clients.projects(HeadSide).values()), + self.clients.environment, + ) + + def write_project(self) -> None: + """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 = self.source_projects[HeadSide].model_copy(deep=True) + 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: + # 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) -> None: + locked = ResolvedLock.read(self.lock) + 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") + 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() + 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() + self.resolve() + # Both graphs belong to this solve; retaining stray files cannot substitute for roots. + self.commands.run( + self.uv, + "sync", + "--project", + self.checkout / "stack", + "--locked", + "--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) + 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 = 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=executable, + ).prepare() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/cutover/carry_delivered_audience.py b/tools/cutover/carry_delivered_audience.py index a4c9e58f75..d5db7215d3 100644 --- a/tools/cutover/carry_delivered_audience.py +++ b/tools/cutover/carry_delivered_audience.py @@ -5,12 +5,8 @@ from pathlib import Path from typing import ClassVar -from agent_comms.audience_manifest import ( - AUDIENCE_VERSION, - Audience, - AudiencedMessage, - FrozenRecipient, -) +from agent_comms.audience_manifest import AUDIENCE_VERSION, Audience, AudiencedMessage +from agent_comms.frozen_recipient import FrozenRecipient from agent_comms.bus import Bus from agent_comms.bus_publication import Delivered from agent_comms.errors import RelationViolationError diff --git a/tools/module_layers.py b/tools/module_layers.py index e429eff118..695e5d68d3 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,15 +35,23 @@ 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 io import StringIO from pathlib import Path -from agent_comms.installer.runtime_wire import ClientImport, CrossesRuntimes - +from mypy.config_parser import parse_config_file +from mypy.options import Options from nominal_refactor_advisor import audit from agent_comms.declared_family import DeclaredFamily +from agent_comms.field_codec import 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 +from agent_comms.installer.runtime_wire import ClientImport, CrossesRuntimes +from agent_comms.repo import Repo +from agent_comms.repository_name import RepositoryName ROOT = Path(__file__).resolve().parent.parent PACKAGE = "agent_comms" @@ -298,27 +308,67 @@ 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 + 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. + """ + 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 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") + def source_strict_flags() -> None: + """Strictness changes no source roots; the checker owns strict type checking.""" + + @property + def import_paths(self) -> tuple[str, ...]: + 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.""" + 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, 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 () + into = Path(tempfile.mkdtemp(prefix="debt-ratchet-clients-")) + atexit.register(shutil.rmtree, into, True) + (into / "stack").mkdir() + declared = Repo.git(repo, "show", f"{head}:stack/pyproject.toml") + (into / "stack/pyproject.toml").write_text(declared) + 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(): @@ -327,6 +377,26 @@ 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, + 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 + 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(metadata[source.repository], name) clients.append(cls(name, checkout)) return tuple(clients) @@ -341,6 +411,97 @@ def imports(self) -> tuple[ClientImport, ...]: return tuple(found) +@dataclass(frozen=True) +class ClientSources(StackSources): + """The comparison's acquisition policy, passed to every measure before workers fork.""" + + def requires_clients(self) -> bool: + return False + + def at(self, repo: Path, base: str, head: str, side: type[Side]) -> tuple[StackClient, ...]: + return StackClient.revision(repo, head, self.frontend(), Path(sys.executable)) + + +@dataclass(frozen=True) +class MaterializedClients(ClientSources): + directory: Path + inputs: IntegrationInput + uv: Path + python: Path + @classmethod + 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]: + 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.""" + _ = 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) + # 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]: + 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) -> bool: + # A companion can change while the local Core patch is unchanged. + return True + + 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") + 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} + return tuple(replace(client, + compared=tuple(peer.checkout for peer in clients + 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]) + + class OutgoingWire(StackClient): """Every declaration carrying the cross-runtime capability, from its owning registry.""" @@ -694,9 +855,27 @@ 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))) + 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) + resource = MaterializedClients(arguments.ci_inputs, inputs, StackSources.frontend(), + 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 + ): + raise ValueError("The outgoing wire must check this input's actual captured base") + else: + 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()) return 0 @@ -704,7 +883,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 diff --git a/tools/nra_gate.py b/tools/nra_gate.py index 254e5e99b8..906065aea7 100644 --- a/tools/nra_gate.py +++ b/tools/nra_gate.py @@ -11,4 +11,5 @@ from nominal_refactor_advisor import audit if __name__ == "__main__": - raise SystemExit(audit.gate.main(AgentCommsProject(Path.cwd()))) + project, arguments = AgentCommsProject.from_arguments(Path.cwd()) + raise SystemExit(audit.gate.main(project, arguments)) 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: diff --git a/uv.lock b/uv.lock index 44ffefd1c0..413e3e5598 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,7 @@ dev = [ { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "ruff" }, + { name = "tomli-w" }, { name = "uv" }, ] @@ -57,6 +58,7 @@ requires-dist = [ { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6" }, { name = "python-introspect", git = "https://github.com/OpenHCSDev/python-introspect?rev=main" }, { 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"] @@ -785,6 +787,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.26.0"