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 "
+ "ORDER BY request_generation DESC LIMIT 1",
+ (input_id, generations.start, generations.stop),
+ )
+ )
+ try:
+ return next(iter(rows))
+ except StopIteration as missing:
+ raise NativePiUnavailable("The input has no assembled-context proof") from missing
+
+ def corroborate(
+ self,
+ session_file: Path,
+ location: NativeInputLocation,
+ ) -> 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"