diff --git a/src/agent_comms/acp.py b/src/agent_comms/acp.py index 17c0a99a8c..986557b852 100644 --- a/src/agent_comms/acp.py +++ b/src/agent_comms/acp.py @@ -20,7 +20,6 @@ import asyncio import json import os -import re import sys import time from collections.abc import AsyncIterator, Iterator, Sequence @@ -60,8 +59,6 @@ manual_compaction_bridge, ) from .acp_extension import ( - CompactRequest, - decode_request, encode_updates, ) from .acp_failure import NativeTurnError @@ -79,7 +76,7 @@ from .coordination_database import CoordinationStore from .cursor_owner import CursorOwner from .cursor_publication import CursorPublication -from .field_codec import ClaimId, FieldCodec, MalformedRecord, RootId +from .field_codec import AcpSessionId, ClaimId, FieldCodec, RootId from .image_inputs import prompt_images from .input_drain import InputDrain from .config_options import ConfigOptions @@ -117,17 +114,17 @@ class SdkSessionConnection(AgentSideConnection, Controller, SessionUpdateSink): """The SDK connection and controller publish one Core acceptance answer.""" - async def publish_update(self, *, session_id: str, update: BaseModel) -> bool: + async def publish_update(self, *, session_id: AcpSessionId, update: BaseModel) -> bool: await self.session_update(session_id=session_id, update=update) return True - def require_current(self, authority: AcpSelectedWriteAuthority, session_id: str) -> None: + def require_current(self, authority: AcpSelectedWriteAuthority, session_id: AcpSessionId) -> None: if self is not authority.agent.sessions.client: raise IdentityConflict("Selected write ACP controller changed") async def request_ui_permission( - self, runner: TurnRunner, session_id: str, + self, runner: TurnRunner, session_id: AcpSessionId, request: tuple[ToolCallUpdate, list[PermissionOption]], ) -> dict[str, object] | None: tool_call, options = request @@ -143,7 +140,7 @@ async def request_ui_permission( ) return None - def departed(self, runtime: RuntimeServer, session_id: str) -> bool: + def departed(self, runtime: RuntimeServer, session_id: AcpSessionId) -> bool: return False @@ -198,9 +195,10 @@ def __init__( # remains attached. Never restore this binding after a process crash. self._selected_write_controllers: dict[tuple[str, int], tuple[ClaimId, Controller]] = {} - def _create_runtime_proxy(self, thread: Thread, session_id: str) -> RuntimeProxy: + def _create_runtime_proxy(self, thread: Thread, session_id: AcpSessionId) -> RuntimeProxy: return RuntimeProxy( - self, session_id, socket_path(self._comms.root, thread.require_process().pid), + self, thread.incarnation, session_id, + socket_path(self._comms.root, thread.require_process().pid), ) # Private attachment ABI consumed by runtime/compaction. State exists only @@ -287,19 +285,11 @@ async def prompt( return await self.receive_prompt(request) async def receive_prompt(self, request: PromptRequest) -> PromptResponse: - try: - command = decode_request(request.field_meta) - except MalformedRecord as error: - raise RequestError.invalid_params({"reason": str(error)}) from error - # Literal /compact is the external ACP text command; ours uses a record. - text = self._prompt_text(request).strip() - if re.match(r"^/compact(?:\s|$)", text): - self._require_compaction_text(request) - command = CompactRequest(text[len("/compact") :].strip()) with self.refusals(): - return await AcpRequestConsumer(self, request).run(command) + session_id = FieldCodec.decode(AcpSessionId, request.session_id) + return await AcpRequestConsumer(self, request, session_id).run() - async def _compact_request(self, session_id: str, instructions: str | None) -> PromptResponse: + async def _compact_request(self, session_id: AcpSessionId, instructions: str | None) -> PromptResponse: # The owner process's bridge alone owns the turn lock. if session_id in self.sessions.proxies: outcome = FieldCodec.decode( @@ -314,7 +304,7 @@ async def _compact_request(self, session_id: str, instructions: str | None) -> P ) return manual_compaction_bridge.CompactionReply().reply(outcome) - async def _selected_write_request(self, session_id: str, request) -> PromptResponse: + async def _selected_write_request(self, session_id: AcpSessionId, request) -> PromptResponse: owner = self.sessions.require(session_id) controller = self._runtime.controller.get() attached = controller.attached(self.sessions.client) if controller is not None else None @@ -341,9 +331,10 @@ async def _selected_write_request(self, session_id: str, request) -> PromptRespo return PromptResponse(stop_reason="end_turn", field_meta=encode_updates(acknowledgement)) async def _prompt_request( - self, request: PromptRequest, command: acp_extension.PromptRequest + self, ingress: AcpRequestConsumer, command: acp_extension.PromptRequest ) -> PromptResponse: - session_id = request.session_id + session_id = ingress.session_id + request = ingress.request text = self._prompt_text(request) display_text = command.user_text or text try: @@ -378,7 +369,8 @@ async def _prompt_request( ) try: return await self.turns.prompt_owned( - request, + session_id, + text=text, images=images, display_text=display_text, input_id=command.input_id, @@ -400,7 +392,7 @@ def _require_compaction_text(request: PromptRequest) -> None: ) async def emit_session_identity( - self, session_id: str, name: str, client: SessionUpdateSink, + self, session_id: AcpSessionId, name: str, client: SessionUpdateSink, ) -> None: """Let a subscriber identify its owner before potentially long replay.""" await client.publish_update( @@ -420,24 +412,24 @@ def _debug_log(self, message: str) -> None: async def cancel(self, session_id: str, **kwargs: JsonValue) -> None: with self.refusals(): - return await self.turns.cancel(session_id, **kwargs) + return await self.turns.cancel(FieldCodec.decode(AcpSessionId, session_id), **kwargs) async def close_session(self, session_id: str, **kwargs: JsonValue): with self.refusals(): - return await self.sessions.close_session(session_id) + return await self.sessions.close_session(FieldCodec.decode(AcpSessionId, session_id)) async def set_config_option( self, config_id: str, session_id: str, value: str | bool, **kwargs: JsonValue ) -> SetSessionConfigOptionResponse: with self.refusals(): - return await self.sessions.config.set_option(config_id, session_id, value) + return await self.sessions.config.set_option(config_id, FieldCodec.decode(AcpSessionId, session_id), value) async def authenticate(self, method_id: str, **kwargs: JsonValue) -> None: raise RequestError.auth_required({"reason": "agent-comms requires no authentication"}) # ─── Helpers ───────────────────────────────────────────────────────────── - async def _drain_private_nk(self, session_id: str, wire_root_id: RootId) -> int: + async def _drain_private_nk(self, session_id: AcpSessionId, wire_root_id: RootId) -> int: """Run a selected private wake for this ACP session. This explicitly configured path reuses the reviewed one-shot native diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 8d953813ec..a4c68e07b9 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -22,13 +22,14 @@ FailureSource, UnrecordedFailureSource, ) -from .agent_backend import SendNow, TurnInbox +from .turn_inbox import SendNow, TurnInbox from .agent_events import CompactionEvent from .child_process import ProcessIdentity from .context_reading import ContextReading from .declared_family import DeclaredFamily from .external_transcript import DraftRow from .field_codec import ( + AcpSessionId, AssignmentId, ClaimId, AcceptsNull, @@ -435,7 +436,7 @@ def projection(self, queued, restored) -> QueueProjection: def validate_scope(self, projection: QueueProjection) -> None: projection.without_scope() - def start_revision(self, inputs: InputDrain, session_id: str) -> int | None: + def start_revision(self, inputs: InputDrain, session_id: AcpSessionId) -> int | None: return None def accepts_request(self, projection: QueueProjection) -> bool: @@ -474,7 +475,7 @@ def projection(self, queued, restored) -> QueueProjection: def validate_scope(self, projection: QueueProjection) -> None: pass - def start_revision(self, inputs: InputDrain, session_id: str) -> int: + def start_revision(self, inputs: InputDrain, session_id: AcpSessionId) -> int: return inputs.next_revision(session_id) def accepts_request(self, projection: QueueProjection) -> bool: @@ -753,7 +754,7 @@ def draft_text(self) -> str | None: @abstractmethod def enqueue_control(self, inbox: TurnInbox, input_id: InputId) -> None: ... - async def publish_acceptance(self, inputs: InputDrain, session_id: str) -> None: + async def publish_acceptance(self, inputs: InputDrain, session_id: AcpSessionId) -> None: await inputs.emit_queue_state(session_id) diff --git a/src/agent_comms/acp_request_consumer.py b/src/agent_comms/acp_request_consumer.py index 6d5e8798f8..752fa492e1 100644 --- a/src/agent_comms/acp_request_consumer.py +++ b/src/agent_comms/acp_request_consumer.py @@ -1,5 +1,7 @@ """Declared owner commands at the ACP boundary; shared behavior is inherited.""" +import re + from metaclass_registry.mro_dispatch import ( MroDispatch, handles, @@ -14,17 +16,27 @@ CompactRequest, SelectedWriteRequest, SendNowRequest, + decode_request, encode_request, ) +from .field_codec import AcpSessionId, MalformedRecord class AcpRequestConsumer(MroDispatch): - - def __init__(self, agent, request: PromptRequest): - self.agent, self.request = agent, request + def __init__(self, agent, request: PromptRequest, session_id: AcpSessionId): + self.agent, self.request, self.session_id = agent, request, session_id self.response = None - async def run(self, request): + async def run(self): + try: + request = decode_request(self.request.field_meta) + except MalformedRecord as error: + raise Refused.invalid_params({"reason": str(error)}) from error + # Literal /compact is the external ACP text command; ours uses a record. + text = self.agent._prompt_text(self.request).strip() + if re.match(r"^/compact(?:\s|$)", text): + self.agent._require_compaction_text(self.request) + request = CompactRequest(text[len("/compact") :].strip()) await self.dispatch(request) if self.response is None: raise RuntimeError("Declared ACP request has no response handler") @@ -32,52 +44,54 @@ async def run(self, request): async def forward(self, request): forwarded = self.request.model_copy( - update={"field_meta": {**(self.request.field_meta or {}), **encode_request(request)}} + update={ + "session_id": self.session_id, + "field_meta": {**(self.request.field_meta or {}), **encode_request(request)}, + } ) - result = await self.agent.sessions.proxies[self.request.session_id].request( + result = await self.agent.sessions.proxies[self.session_id].request( "prompt", request=forwarded ) return PromptResponse.model_validate(result) @handles(acp_extension.PromptRequest) async def prompt_request(self, request): - if self.request.session_id in self.agent.sessions.proxies: + if self.session_id in self.agent.sessions.proxies: self.response = await self.forward(request) return - self.response = await self.agent._prompt_request(self.request, request) + self.response = await self.agent._prompt_request(self, request) @handles(CompactRequest) async def compact(self, request): self.agent._require_compaction_text(self.request) - if self.agent._prompt_text(self.request).strip() and not self.agent._prompt_text( - self.request - ).strip().startswith("/compact"): + if ( + self.agent._prompt_text(self.request).strip() + and not self.agent._prompt_text(self.request).strip().startswith("/compact") + ): raise Refused.invalid_params({"reason": "Compaction metadata requires blank text"}) - self.response = await self.agent._compact_request( - self.request.session_id, request.instructions - ) + self.response = await self.agent._compact_request(self.session_id, request.instructions) @handles(SelectedWriteRequest) async def selected_write(self, request): if self.request.prompt: raise Refused.invalid_params({"reason": "Selected write requires no prompt"}) - if self.request.session_id in self.agent.sessions.proxies: + if self.session_id in self.agent.sessions.proxies: self.response = await self.forward(request) return - self.response = await self.agent._selected_write_request(self.request.session_id, request) + self.response = await self.agent._selected_write_request(self.session_id, request) @handles(ClearQueueRequest) async def clear(self, request): - if self.request.session_id in self.agent.sessions.proxies: - await self.agent.sessions.proxies[self.request.session_id].request("clear_queue") + if self.session_id in self.agent.sessions.proxies: + await self.agent.sessions.proxies[self.session_id].request("clear_queue") else: - await self.agent.inputs.clear_queued_inputs(self.request.session_id) + await self.agent.inputs.clear_queued_inputs(self.session_id) self.response = PromptResponse(stop_reason="end_turn") @handles(SendNowRequest) async def send_now(self, request): - if self.request.session_id in self.agent.sessions.proxies: + if self.session_id in self.agent.sessions.proxies: self.response = await self.forward(request) return - await self.agent.inputs.send_now(self.request.session_id) + await self.agent.inputs.send_now(self.session_id) self.response = PromptResponse(stop_reason="end_turn") diff --git a/src/agent_comms/agent_activity.py b/src/agent_comms/agent_activity.py index 92daf5aa68..e92ce1e979 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -173,14 +173,17 @@ def observe_recipients( snapshot: RegistrySnapshot, ) -> Mapping[ThreadLookup, RecipientActivity]: """Acquire one bounded window's recipients from the original sends.""" - observations: dict[ThreadLookup, RecipientActivity] = {} + selected = {} for original, recipient in recipients: lookup = recipient.recipient_lookup - if lookup in observations: - continue - selected = snapshot.recipient(recipient, original) - observations[lookup] = selected.activity(self, snapshot) - return observations + if lookup not in selected: + selected[lookup] = snapshot.recipient(recipient, original) + captured = self.all_activity( + (thread for recipient in selected.values() for thread in recipient.activity_threads()), + snapshot=snapshot, + ) + return {lookup: recipient.activity(captured, snapshot) + for lookup, recipient in selected.items()} def _emit_activity(self, activity: Activity) -> None: snapshot = self.registry.snapshot() diff --git a/src/agent_comms/agent_backend.py b/src/agent_comms/agent_backend.py index 4dcc535cd6..189bde9b09 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -19,7 +19,6 @@ import asyncio from abc import ABC, abstractmethod -from collections import deque from collections.abc import AsyncIterator, Callable from dataclasses import dataclass from pathlib import Path @@ -27,19 +26,17 @@ from typing import TYPE_CHECKING, ClassVar, Self from .declared_family import DeclaredFamily -from .field_codec import InputId -from .image_input import ImageInput +from .field_codec import AcpSessionId, InputId from .pi_counts import PiCount -from .pi_vocabulary import CompactionReason, ThinkingLevelField +from .pi_commands import NativeQuery +from .pi_payloads import PiResponseData +from .pi_vocabulary import CompactionReason from .session_revision import NoSessionObservation, SessionObservation -from .thread_model import ThreadModel +from .turn_inbox import InputContent from .tool_result import DeclaredReply -from .turn_context import InputContributionCoordinates from .turn_outcome import TurnOutcome if TYPE_CHECKING: - from pathlib import Path - from .agent_events import AgentEvent from .launch_runtime import LaunchRuntime from .owned_turn import TurnDelivery @@ -122,145 +119,6 @@ def model_choice(self): # --------------------------------------------------------------------------- -class BusyInputPolicy(DeclaredFamily, affix="InputPolicy"): - """What the backend does with an input that arrives while a run is going.""" - - @classmethod - @abstractmethod - def streaming(cls) -> type[StreamingInputPolicy]: ... - - -class StreamingInputPolicy(BusyInputPolicy, declared_name=None): - """The busy policies Pi's streamingBehavior protocol accepts.""" - - @classmethod - def streaming(cls) -> type[StreamingInputPolicy]: - return cls - - -class FollowUpInputPolicy(StreamingInputPolicy, declared_name="followUp"): - """Run after the current run ends; preserve Pi's exact followUp spelling.""" - - -class SteerInputPolicy(StreamingInputPolicy): - """Place the input at the next boundary of the current run.""" - - -class RejectInputPolicy(BusyInputPolicy): - @classmethod - def streaming(cls) -> type[StreamingInputPolicy]: - raise ValueError("Pi cannot reject a prompt through streamingBehavior.") - - -@dataclass(frozen=True, kw_only=True) -class InputContent: - """What one input says: its text, attached images and context contributions.""" - - text: str - images: tuple[ImageInput, ...] = () - contributions: tuple[InputContributionCoordinates, ...] = () - - -class TurnInboxItem(ABC): - """An inbox item carries its input identities; the forwarding owner dispatches it.""" - - def return_to(self, inbox: TurnInbox) -> None: - """An operation not handed off still belongs at the inbox front.""" - inbox.begin(self) - - @property - def carried_inputs(self) -> tuple[InputId, ...]: - return () - - def refuse(self, inputs, turn): - inputs.refuse(turn, self.carried_inputs) - - -class TurnInbox(asyncio.Queue[TurnInboxItem]): - """The live inbox parks its head until the existing observer sees READY.""" - - _queue: deque[TurnInboxItem] - - def __init__(self): - super().__init__() - self.ready = asyncio.Event() - self.ready.set() - - async def get(self) -> TurnInboxItem: - await self.ready.wait() - return await super().get() - - @property - def has_ready_work(self) -> bool: - """Only an open inbox can give queued work to the running turn.""" - return self.ready.is_set() and not self.empty() - - @property - def ready_inputs(self) -> tuple[InputId, ...]: - """Runnable user inputs, excluding parked input and control operations.""" - return tuple( - input_id for item in self._queue for input_id in item.carried_inputs - ) if self.has_ready_work else () - - def begin(self, item: TurnInboxItem) -> None: - self._queue.appendleft(item) - self._wakeup_next(self._getters) - - def hold(self, item: TurnInboxItem) -> None: - # Park under the shared wire lock, before READY can be published. - self.ready.clear() - self.begin(item) - - -@dataclass(frozen=True, kw_only=True) -class InputRequest(TurnInboxItem): - """One queued user input handed to a backend: identity, content and busy policy. - - Routing, goal and bus-delivery provenance stay in Core, keyed by ``input_id``. - """ - - input_id: InputId - content: InputContent - when_busy: type[BusyInputPolicy] - - @property - def carried_inputs(self) -> tuple[InputId, ...]: - return (self.input_id,) - - - -@dataclass(frozen=True) -class SendNow(TurnInboxItem): - """Ask the running turn to place these queued steering inputs immediately. - - Only a backend with the :class:`Interrupts` role accepts it. - """ - - input_ids: tuple[InputId, ...] - - - -@dataclass(frozen=True) -class UseSettings(TurnInboxItem): - """Ask the running turn to use the thread's stored model and thinking level. - - The backend applies them from its next model call; the call in flight - finishes on the settings it started with. - """ - - model: ThreadModel - thinking_level: ThinkingLevelField - - - -@dataclass(frozen=True) -class ClearQueue(TurnInboxItem): - """Clear only these queued user inputs, never delivered bus inputs.""" - - input_ids: tuple[InputId, ...] - - - class CompactionOutcome(DeclaredFamily, DeclaredReply, TurnOutcome, affix="Compaction"): """The result of one requested compaction.""" @@ -408,6 +266,10 @@ async def abort[Result](self, owner: asyncio.Task[Result]) -> None: async def close_idle(self) -> None: """A backend without a retained process has nothing to retire.""" + async def inspect(self, thread: Thread, request: NativeQuery) -> PiResponseData: + """A runtime without native queries refuses this capability explicitly.""" + raise BackendDeclarationError(f"{type(self).__name__} does not support native inspection") + def __init_subclass__(cls, **options: str | None) -> None: if AgentBackend._sealed: raise BackendDeclarationError( @@ -418,7 +280,7 @@ def __init_subclass__(cls, **options: str | None) -> None: @classmethod @abstractmethod - def for_worker(cls, runner: TurnRunner, session_id: str) -> Self: + def for_worker(cls, runner: TurnRunner, session_id: AcpSessionId) -> Self: """An instance holding this runtime for one thread session in this worker.""" @classmethod diff --git a/src/agent_comms/agent_event_updates.py b/src/agent_comms/agent_event_updates.py index df4c86d927..65438e5a22 100644 --- a/src/agent_comms/agent_event_updates.py +++ b/src/agent_comms/agent_event_updates.py @@ -16,6 +16,7 @@ ToolCallStart, ToolKind, UsageUpdate, + SessionInfoUpdate, ) from metaclass_registry.mro_dispatch import ( MroDispatch, @@ -25,6 +26,7 @@ from . import agent_events as events from .acp_extension import ( CompactionChangedUpdate, + GoalChangedUpdate, McpClientAcknowledgementUpdate, NativeOutputCommittedUpdate, NativeRoundCommittedUpdate, @@ -93,7 +95,7 @@ def kind(cls, action: type[ToolAction]) -> ToolKind: @dataclass(eq=False, repr=False) class AcpEventConsumer(MroDispatch): agent: TurnEffects - session_id: str + session_id: AcpSessionId client: SessionUpdateSink @handles(TranscriptUpdate) @@ -144,7 +146,13 @@ async def publish_native_commit( @handles(events.GoalChanged) async def goal_changed(self, event: events.GoalChanged) -> None: - await self.agent.sessions.publish_metadata(self.session_id) + await self.client.publish_update( + session_id=self.session_id, + update=SessionInfoUpdate( + session_update="session_info_update", + field_meta=encode_updates(GoalChangedUpdate.from_snapshot(event.snapshot)), + ), + ) @handles(events.TextEvent) async def text(self, event: events.TextEvent) -> None: @@ -225,7 +233,7 @@ async def on_context_info(self, event: events.ContextInfo) -> None: @handles(ContextUsage) async def on_context_usage(self, usage: ContextUsage) -> None: - await self.agent.turns.observe_context(AcpSessionId(self.session_id), usage) + await self.agent.turns.observe_context(self.session_id, usage) await self.client.publish_update( session_id=self.session_id, update=UsageUpdate(session_update="usage_update", used=usage.used, size=usage.size), diff --git a/src/agent_comms/backend.py b/src/agent_comms/backend.py index 315730a22f..f02f4ceba1 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -46,7 +46,8 @@ from . import pi_events as pi from . import turn_failure as failures from .acp_failure import UnrecordedFailureSource -from .agent_backend import NO_INPUT_START_LISTENER, InputStartListener, TurnInbox +from .agent_backend import NO_INPUT_START_LISTENER, InputStartListener +from .turn_inbox import TurnInbox, TurnInboxItem from .coordinator import Coordination from .extension_ui import ExtensionUiSession from .field_codec import InputId, MalformedRecord, boundary_decoder @@ -266,6 +267,22 @@ def __init__( self.started_during_abort: list[events.FollowingInputStarted] = [] self.finished = self.skip = False + def refuse(self, item: TurnInboxItem) -> None: + """Refuse one unwritten operation through its most specific nominal handler.""" + next(self.handlers_for(item))(item) + + @handles(TurnInboxItem) + def refuse_queued_input(self, item: TurnInboxItem) -> None: + for input_id in item.carried_inputs: + self.rejected_commands.append(events.FollowingInputRefused(id=input_id)) + self.rejected_signal.set() + + @handles(UnwrittenPrompt) + def refuse_original_prompt(self, item: UnwrittenPrompt) -> None: + self.output.record_failure(failures.InputMissing("Original input was cleared before send.")) + self.finish_request() + self.rejected_signal.set() + def accept_tool_round(self, message: AssistantMessage) -> None: self.tool_socket.announce(message.parts) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index 357be7f46d..d905a17a2c 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -50,7 +50,7 @@ ) from agent_comms.errors import CommittedMessageError, RelationViolationError, UnclaimedError from agent_comms.field_codec import ClaimId, FieldCodec, MessageId, ThreadLookup -from agent_comms.locked_store import BusFile, FileStore, SQLiteFile, StoreCommittedError +from agent_comms.locked_store import BusFile, FileStore, SQLiteReader, StoreCommittedError from agent_comms.message_reference import MessageReference from agent_comms.private_path import FileIdentity, PresentPath from agent_comms.read_basis import CursorRow @@ -490,17 +490,12 @@ def selected_sequences( ) -class StoredBusReader(BusReader): +class StoredBusReader(BusReader, SQLiteReader): """File-backed readers use SQLite snapshots; held snapshots need no connection.""" - @property - @abstractmethod - def path(self) -> Path: - """The file named by this reader's owning declaration.""" - @contextmanager def observing(self) -> Iterator[sqlite3.Connection]: - with SQLiteFile.opened_reader(self.path) as db: + with self.reader_snapshot() as db: yield db diff --git a/src/agent_comms/child_process.py b/src/agent_comms/child_process.py index 2c15fa7f0f..d7847a1583 100644 --- a/src/agent_comms/child_process.py +++ b/src/agent_comms/child_process.py @@ -36,7 +36,7 @@ ) from contextvars import copy_context from dataclasses import dataclass -from functools import cached_property, partial +from functools import cache, cached_property, partial from pathlib import Path from tempfile import TemporaryDirectory from typing import IO, TYPE_CHECKING, Annotated, Any, ClassVar, NoReturn, Self @@ -290,6 +290,16 @@ def pid_argument(cls, value: str) -> dict[str, object]: @classmethod def capture(cls, pid: int) -> ProcessIdentity: + return cls.current() if pid == os.getpid() else Platform.current().identity(pid) + + @classmethod + def current(cls) -> ProcessIdentity: + """The caller's immutable birth; a fork selects its own PID, not its parent's.""" + return cls._current(os.getpid()) + + @staticmethod + @cache + def _current(pid: int) -> ProcessIdentity: return Platform.current().identity(pid) def allows_owner_start(self) -> bool: @@ -576,6 +586,8 @@ def wait_any_exited( """Wait for a member's exit or the caller's remaining deadline.""" def matches(self, identity: ProcessIdentity) -> bool: + if identity.runs_here(): + return identity == ProcessIdentity.current() try: return self.identity(identity.pid) == identity except ProcessLookupError: diff --git a/src/agent_comms/cli_bridges/shared.py b/src/agent_comms/cli_bridges/shared.py index 662473c05a..df26e61f7d 100644 --- a/src/agent_comms/cli_bridges/shared.py +++ b/src/agent_comms/cli_bridges/shared.py @@ -1095,7 +1095,7 @@ def write() -> Result: async def coordinator_read[Result](self, operation: Callable[[sqlite3.Connection], Result]) -> Result: def read() -> Result: - with CoordinationStore.opened_reader(CoordinationStore.path_in(Path(self.binding.root))) as db: + with self.comms.registry.store.coordinator_reader.reader_snapshot() as db: return operation(db) return await self.comms.bus.when_open(read) diff --git a/src/agent_comms/cli_context.py b/src/agent_comms/cli_context.py index bd5b4cdc8e..146f1269c3 100644 --- a/src/agent_comms/cli_context.py +++ b/src/agent_comms/cli_context.py @@ -14,7 +14,7 @@ from agent_comms.coordination_errors import Refused from agent_comms.field_codec import Argument from agent_comms.field_codec import FieldCodec -from agent_comms.native_turn_context import NativeContextData +from agent_comms.pi_payloads import NativeContextData from agent_comms.owner_connection import OwnerConnection from agent_comms.owner_connection import socket_path from agent_comms.turn_context import ContextManifest @@ -53,7 +53,7 @@ def manifests(self, ctx: Comms) -> Iterator[ContextManifest]: def apply(self, ctx: Comms) -> CliResult: owner = ctx.registry.require(self.thread) - connection = OwnerConnection(ctx, owner.name, socket_path(ctx.root, owner.require_process().pid)) + connection = OwnerConnection(ctx, owner.incarnation, socket_path(ctx.root, owner.require_process().pid)) async def inspect() -> CliResult: try: @@ -71,7 +71,7 @@ async def inspect() -> CliResult: False, context, segments, str(error)) finally: await connection.close() - native_context = native.for_turn(owner, NextContextTurn()) + native_context = native.for_turn(owner.incarnation, NextContextTurn()) return NativeContextInspection( "next-native-base-and-core-contributors; before future input and provider hooks", False, context, segments, native_context.segments, native_context.render().provider, diff --git a/src/agent_comms/cli_verbs/annotations.py b/src/agent_comms/cli_verbs/annotations.py index 98134878e5..4d333e5fea 100644 --- a/src/agent_comms/cli_verbs/annotations.py +++ b/src/agent_comms/cli_verbs/annotations.py @@ -3,7 +3,6 @@ from agent_comms.cli_command import CliCommand from agent_comms.cli_result import CliResult from agent_comms.comms import Comms -from agent_comms.coordination_database import CoordinationStore from agent_comms.field_codec import Argument from agent_comms.field_codec import Editor from agent_comms.target_fields import ChoiceTargetField @@ -49,4 +48,4 @@ class AnnotationsCalibrationCliCommand(CliCommand, declared_name="annotations"): def apply(self, ctx: Comms) -> CliResult: return WorkingMemoryAnnotations.calibration( - CoordinationStore.path_in(ctx.root), self.question, self.classifier) + ctx.registry.store.coordinator_reader, self.question, self.classifier) diff --git a/src/agent_comms/config_options.py b/src/agent_comms/config_options.py index a4b86578d1..e657887fcc 100644 --- a/src/agent_comms/config_options.py +++ b/src/agent_comms/config_options.py @@ -314,12 +314,12 @@ def configured(cls) -> ConfigOptions: return cls(RetainedOwnerLaunch.configured(), resolve_comms_route(LaunchRoot.current().path)) async def observe_authentication( - self, session_id: AcpSessionId, publish: Callable[[str], Awaitable[object]], + self, session_id: AcpSessionId, publish: Callable[[AcpSessionId], Awaitable[object]], ) -> None: """The session retires this configuration's watch with its other producers.""" async with aclosing(Bus.watch_sources(FileSource(self.launch.configuration.native_config / "auth.json"))) as observations: async for _ in observations: - await publish(str(session_id)) + await publish(session_id) def native_environment( self, thread: Thread, snapshot: RegistrySnapshot, diff --git a/src/agent_comms/controller.py b/src/agent_comms/controller.py index 4a4536a76d..312254f390 100644 --- a/src/agent_comms/controller.py +++ b/src/agent_comms/controller.py @@ -8,6 +8,7 @@ from pydantic import BaseModel from .coordination_errors import IdentityConflict +from .field_codec import AcpSessionId if TYPE_CHECKING: from acp.schema import PermissionOption, ToolCallUpdate @@ -21,7 +22,7 @@ class SessionUpdateSink(ABC): @abstractmethod - async def publish_update(self, *, session_id: str, update: BaseModel) -> bool: + async def publish_update(self, *, session_id: AcpSessionId, update: BaseModel) -> bool: """True when someone accepted the update.""" @@ -30,16 +31,16 @@ def attached(self, client: Controller | None) -> Controller | None: return self @abstractmethod - def require_current(self, authority: AcpSelectedWriteAuthority, session_id: str) -> None: ... + def require_current(self, authority: AcpSelectedWriteAuthority, session_id: AcpSessionId) -> None: ... @abstractmethod async def request_ui_permission( - self, runner: TurnRunner, session_id: str, + self, runner: TurnRunner, session_id: AcpSessionId, request: tuple[ToolCallUpdate, list[PermissionOption]], ) -> dict[str, object] | None: ... @abstractmethod - def departed(self, runtime: RuntimeServer, session_id: str) -> bool: ... + def departed(self, runtime: RuntimeServer, session_id: AcpSessionId) -> bool: ... @@ -50,16 +51,16 @@ class UnboundController(Controller): def attached(self, client: Controller | None) -> Controller | None: return client - def require_current(self, authority: AcpSelectedWriteAuthority, session_id: str) -> None: + def require_current(self, authority: AcpSelectedWriteAuthority, session_id: AcpSessionId) -> None: raise IdentityConflict("Selected write ACP controller changed") async def request_ui_permission( - self, runner: TurnRunner, session_id: str, + self, runner: TurnRunner, session_id: AcpSessionId, request: tuple[ToolCallUpdate, list[PermissionOption]], ) -> dict[str, object] | None: return None - def departed(self, runtime: RuntimeServer, session_id: str) -> bool: + def departed(self, runtime: RuntimeServer, session_id: AcpSessionId) -> bool: return False diff --git a/src/agent_comms/coordination_database.py b/src/agent_comms/coordination_database.py index f700eb24a7..2edb13aa6a 100644 --- a/src/agent_comms/coordination_database.py +++ b/src/agent_comms/coordination_database.py @@ -15,7 +15,7 @@ from agent_comms.coordination_errors import CoordinationReadUnavailable, IntegrityViolationError from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.locked_store import FileStore +from agent_comms.locked_store import FileStore, SQLiteReader from agent_comms.typed_table import ( CarriedWriter, ReconciledFile, @@ -59,11 +59,11 @@ def _capture(self, consumer, *arguments): @dataclass(frozen=True) -class ReadableStore(StoreFile): - path: Path +class ReadableStore(FileStore, StoreFile, SQLiteReader): + """Read-only coordinator view; its lifetime owns the thread-local handles.""" def _capture(self, consumer, *arguments): - with CoordinationStore.opened_reader(self.path) as connection: + with self.reader_snapshot() as connection: yield consumer.capture(connection, *arguments) diff --git a/src/agent_comms/following_inputs.py b/src/agent_comms/following_inputs.py index 4234cd38bc..daba370467 100644 --- a/src/agent_comms/following_inputs.py +++ b/src/agent_comms/following_inputs.py @@ -6,9 +6,9 @@ from functools import partial from typing import TYPE_CHECKING -from .field_codec import InputId +from .field_codec import AcpSessionId, InputId from .acp_extension import LiveQueueScope -from .agent_backend import TurnInbox +from .turn_inbox import TurnInbox from .coordinator import Coordination from .queued_input import DeliveredInput, InputHandoffRefused from .thread_identity import ClaimedIdentity @@ -26,7 +26,7 @@ class FollowingInputs: """A selected turn run outside an owner process: nothing can follow its first input.""" @staticmethod - async def ready(inputs: InputDrain, session_id: str, after_seq: int = 0) -> None: + async def ready(inputs: InputDrain, session_id: AcpSessionId, after_seq: int = 0) -> None: """Take accepted work when a running turn opens or frees following capacity.""" async with inputs.state(session_id).lock: inputs.state(session_id).forget_observation() @@ -53,7 +53,7 @@ class OwnerFollowingInputs(FollowingInputs): """The owner's inbox for one selected turn, read by that turn once its first input is in.""" inputs: InputDrain - session_id: str + session_id: AcpSessionId owner: Thread inbox: TurnInbox scope: LiveQueueScope = field(init=False) diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 83baf5a3d8..82a20c9a57 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -24,7 +24,6 @@ from .field_codec import FieldCodec from .input_key import InputKey, InputKeyText -from agent_comms.input_disposition import InputDispositions from agent_comms.todos import ( ExpectedProblem, PendingProblemOrder, RecordOrderHeadVerdict, SendBackVerdict, Todo, TodoCommand, TodoOperation, @@ -323,7 +322,7 @@ def before_goal_publish(self, goal: Goal, ctx: GoalActionContext) -> None: reported_turn = ctx.reported_turn wait_targets = review.targets aliases = review.owners - dispositions = InputDispositions(ctx.goals.root) + dispositions = ctx.goals.dispositions unknown = {row.key: row for row in review.unknown} # An input delivered into this recipient's turn after the inbox cut is already handled. delivered = dispositions.read( diff --git a/src/agent_comms/goal_attempts.py b/src/agent_comms/goal_attempts.py index 1c8339338c..cf674f98ee 100644 --- a/src/agent_comms/goal_attempts.py +++ b/src/agent_comms/goal_attempts.py @@ -25,7 +25,7 @@ from dataclasses import dataclass, field from decimal import Decimal from pathlib import Path -from typing import TYPE_CHECKING, Annotated, ClassVar, TypeVar +from typing import TYPE_CHECKING, Annotated, ClassVar, Self, TypeVar from agent_comms.goal_turn_permit import GoalTurnPermit @@ -67,7 +67,7 @@ ReservedGeneration, ) from .lifecycle_capability import Failed -from .locked_store import FileStore, WireWakeFile +from .locked_store import FileStore, GoalWakeFile, SQLiteReader from .maintenance_barrier import DrainSettledFile from .pi_counts import PiCount, UnreportedCount from .pi_payloads import PiUsage @@ -86,6 +86,7 @@ Column, Generated, ReconciledFile, + RowNotFoundError, SQLiteJournalMode, StoreWriter, TableReference, @@ -143,13 +144,12 @@ def failure_identity(self) -> GoalAttemptIdentity: self.lifecycle.require(Failed, FailureNotObserved()) return self.attempt_identity - def failure_evidence(self, store: GoalAttemptStore) -> GoalFailureEvidence: + def failure_evidence(self, conn: sqlite3.Connection) -> GoalFailureEvidence: identity = self.failure_identity() - with closing(store._connect()) as conn: - return next(FailedTurnEvidence.stream( - conn, attempt_id=identity.attempt_id, - goal_id=identity.goal_id, generation=identity.generation, - ), NoFailedTurnEvidence()) + return next(FailedTurnEvidence.stream( + conn, attempt_id=identity.attempt_id, + goal_id=identity.goal_id, generation=identity.generation, + ), NoFailedTurnEvidence()) def has_ready_grant(self, number: int, digest: str) -> bool: return self.lifecycle.ready and self.number == number and self.ready_digest == digest @@ -160,12 +160,19 @@ def __post_init__(self) -> None: raise ValueError("Generation must be positive.") @classmethod - def read_in(cls, conn: sqlite3.Connection, goal_id: GoalId) -> Generation | None: + def _keyed(cls, db: sqlite3.Connection, key: dict[str, object]) -> list[Self]: try: - return cls.one(conn, goal_id=goal_id) - except MalformedRecord as error: + return super()._keyed(db, key) + except (sqlite3.Error, MalformedRecord) as error: raise StorageUncertainError("Invalid persisted goal generation.") from error + @classmethod + def require_in(cls, db: sqlite3.Connection, goal_id: GoalId) -> Self: + try: + return cls.require(db, goal_id=goal_id) + except RowNotFoundError as error: + raise MissingGoalGenerationError(goal_id) from error + def advance( self, conn: sqlite3.Connection, successor: Generation, *, digest: str = "" ) -> Generation: @@ -216,7 +223,7 @@ def fail( """Block even when the model outcome is uncertain; never auto-replay.""" reservation = self def write(conn: sqlite3.Connection) -> Generation: - goal = Generation.read_in(conn, reservation.goal_id) + goal = Generation.one(conn, goal_id=reservation.goal_id) late = errors.StaleAttemptError("Late failure cannot modify this goal generation.") attempt = AttemptRecord.read_in(conn, reservation.attempt_id).reserved_by( reservation, late @@ -247,7 +254,7 @@ def write(conn: sqlite3.Connection) -> Generation: ) def require_current(self, conn: sqlite3.Connection, phase: GoalAttemptPhase) -> AttemptRecord: - goal = Generation.read_in(conn, self.goal_id) + goal = Generation.one(conn, goal_id=self.goal_id) stale = errors.StaleAttemptError("Attempt is no longer current or is already claimed.") attempt = AttemptRecord.read_in(conn, self.attempt_id).reserved_by(self, stale) if ( @@ -659,7 +666,15 @@ def for_goal(cls, store: GoalAttemptStore, goal_id: GoalId) -> ProviderUsageTota _T = TypeVar("_T") -class GoalAttemptStore(FileStore, DrainSettledFile, StoreWriter, ReconciledFile, WireWakeFile): +class GoalAttemptFile(FileStore, SQLiteReader): + """An unopened ledger can observe its counter without creating launch state.""" + + def open(self) -> GoalAttemptStore: + self.path.parent.mkdir(mode=0o700, exist_ok=True) + return GoalAttemptStore.initialize(self.path.parent) + + +class GoalAttemptStore(GoalAttemptFile, DrainSettledFile, StoreWriter, ReconciledFile, GoalWakeFile): """Owner-private SQLite authority; no synthetic model or context claims. The caller creates a private directory and explicitly initializes it in a @@ -680,7 +695,7 @@ def table_schema(cls) -> type[GoalLedgerTable]: def __init__(self, path: Path) -> None: super().__init__(path) - self._require_root() + self.require_path() if not self.path.is_file(): raise StorageUncertainError("Goal attempt store is not initialized.") self._owned: set[AttemptId] = set() @@ -734,7 +749,10 @@ def initialize(cls, root: str | Path) -> GoalAttemptStore: def root(self) -> Path: return self.path.parent - def _require_root(self) -> None: + def open(self) -> GoalAttemptStore: + return self + + def require_path(self) -> None: if not self.root.is_dir() or not Platform.current().is_private(self.root.stat(), 0o700): raise StorageUncertainError("Goal attempt root must remain owner-0700.") if ( @@ -744,7 +762,7 @@ def _require_root(self) -> None: raise StorageUncertainError("Goal attempt database must remain owner-0600.") def _connect(self) -> sqlite3.Connection: - self._require_root() + self.require_path() return self.database(self.path) @classmethod @@ -754,14 +772,25 @@ def database(cls, path: Path) -> sqlite3.Connection: conn = cls.writer(path, isolation_level=None) conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA synchronous=EXTRA") - modes = SQLiteJournalMode.read(conn.execute("PRAGMA journal_mode")) - if modes != [SQLiteJournalMode("delete")]: + try: + cls.require_database_mode(conn) + except BaseException: conn.close() - raise StorageUncertainError("Unexpected SQLite journal mode.") + raise return conn except sqlite3.Error as error: raise StorageUncertainError("Goal attempt database unavailable.") from error + @staticmethod + def require_database_mode(conn: sqlite3.Connection) -> None: + if SQLiteJournalMode.read(conn.execute("PRAGMA journal_mode")) != [SQLiteJournalMode("delete")]: + raise StorageUncertainError("Unexpected SQLite journal mode.") + + @classmethod + def begin_snapshot(cls, conn: sqlite3.Connection) -> None: + super().begin_snapshot(conn) + cls.require_database_mode(conn) + def _sync(self) -> None: AtomicFile(self.path).sync() @@ -796,16 +825,11 @@ def attest() -> None: def snapshot(self, goal_id: GoalId) -> Generation | None: with closing(self._connect()) as conn: - try: - return Generation.read_in(conn, goal_id) - except sqlite3.Error as error: - raise StorageUncertainError("Cannot read goal attempt state.") from error + return Generation.one(conn, goal_id=goal_id) def require_generation(self, goal_id: GoalId) -> Generation: - generation = self.snapshot(goal_id) - if generation is None: - raise MissingGoalGenerationError(goal_id) - return generation + with closing(self._connect()) as conn: + return Generation.require_in(conn, goal_id) def read_attempt(self, attempt_id: AttemptId) -> LedgerAttempt: with closing(self._connect()) as conn: @@ -874,7 +898,7 @@ def recover_unreserved_ready(self, goal_id: GoalId, expected_generation: int) -> """ def write(conn: sqlite3.Connection, digest: str) -> Generation: - current = Generation.read_in(conn, goal_id) + current = Generation.one(conn, goal_id=goal_id) if current != Generation(goal_id, expected_generation, ReadyGeneration(), NoAttemptId()): raise ReservationConflictError( "Only an unreserved READY generation can be recovered." @@ -925,7 +949,7 @@ def reserve( ) def write(conn: sqlite3.Connection) -> Reservation: - current = Generation.read_in(conn, goal_id) + current = Generation.one(conn, goal_id=goal_id) if current != Generation(goal_id, expected_generation, ReadyGeneration(), NoAttemptId()): raise ReservationConflictError("Goal generation is not ready or already reserved.") if not hmac.compare_digest(current.ready_digest, digest): @@ -1004,7 +1028,7 @@ def authorize_ready_recovery( is overwritten. Reusing a decision ID cannot mint another grant. """ def write(conn: sqlite3.Connection, digest: str) -> Generation: - current = Generation.read_in(conn, goal_id) + current = Generation.one(conn, goal_id=goal_id) if current != Generation(goal_id, expected_generation, ReadyGeneration(), NoAttemptId()): raise errors.UnresolvedAttemptError( "Goal is not ready; an attempt may already be unresolved." @@ -1038,7 +1062,7 @@ def authorize_retry( same-UID user authentication is not claimed by the ledger. """ def write(conn: sqlite3.Connection, digest: str) -> Generation: - current = Generation.read_in(conn, goal_id) + current = Generation.one(conn, goal_id=goal_id) if current != Generation(goal_id, expected_generation, BlockedGeneration(), attempt_id): raise errors.UnresolvedAttemptError("Blocked attempt changed or is not resolved.") unrecorded = errors.UnresolvedAttemptError( @@ -1069,7 +1093,7 @@ def retire_goal( """Revoke READY or an unresolved attempt before clearing the registry goal.""" def write(conn: sqlite3.Connection) -> Generation: - current = Generation.read_in(conn, goal_id) + current = Generation.one(conn, goal_id=goal_id) if ( current is None or current.number != expected_generation diff --git a/src/agent_comms/goal_generation.py b/src/agent_comms/goal_generation.py index a86e07d6f9..8bb67ba070 100644 --- a/src/agent_comms/goal_generation.py +++ b/src/agent_comms/goal_generation.py @@ -2,6 +2,8 @@ from __future__ import annotations +from sqlite3 import Connection + from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar @@ -48,7 +50,7 @@ def execution(self, source: GoalExecutionSource) -> GoalExecution: def idle_execution(self, source: GoalExecutionSource) -> GoalExecution: return AttemptUnresolvedExecution(source.goal.id) - def evidence(self, store: GoalAttemptStore, generation: Generation) -> GoalFailureEvidence: + def evidence(self, connection: Connection, generation: Generation) -> GoalFailureEvidence: return NoFailedTurnEvidence() def continue_work( @@ -160,8 +162,8 @@ def resume_status(self, generation: Generation, goal: Goal, ctx: GoalActionConte "Retry to authorize a new attempt. Your messages can still be sent." ) - def evidence(self, store: GoalAttemptStore, generation: Generation) -> GoalFailureEvidence: - return generation.failure_evidence(store) + def evidence(self, connection: Connection, generation: Generation) -> GoalFailureEvidence: + return generation.failure_evidence(connection) def execution(self, source: GoalExecutionSource) -> GoalExecution: """No permit launches a blocked attempt, so a running turn is never the goal's own.""" diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index d7ac9c1d05..a7e7adba0f 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -29,7 +29,7 @@ SentBackOrderGoalAction, SetGoalAction, ) -from .goal_attempts import Generation, GoalAttemptStore +from .goal_attempts import Generation, GoalAttemptFile, GoalAttemptStore from .errors import UnresolvedAttemptError from .goal_history import GoalHistoryEntry, RecordedGoalHistory from .goal_scope import AllGoals, GoalScope, OneGoal @@ -82,7 +82,9 @@ def __init__(self, root: Path, registry: Registration, bus: MessageBus, agents: self.bus = bus self.agents = agents self.waits = GoalWaits(root) - self.goal_store: GoalAttemptStore | None = None + self.dispositions = InputDispositions(root) + self.goal_store: GoalAttemptFile = GoalAttemptFile(GoalAttemptStore.path_in( + GoalAttemptStore.directory_in(root))) def fork_goal(self, parent: Thread, child: Thread) -> Goal: """Create a commander's child goal from its parent's active work.""" @@ -157,11 +159,9 @@ def fire(self, unit: str, reason: StrikeReason, actor: ThreadIncarnation) -> tup return store.fire(owner, actor, reason) def open_goal_store(self) -> GoalAttemptStore: - if self.goal_store is None: - private = GoalAttemptStore.directory_in(self.root) - private.mkdir(mode=0o700, exist_ok=True) - self.goal_store = GoalAttemptStore.initialize(private) - return self.goal_store + store = self.goal_store.open() + self.goal_store = store + return store def ready_goal_grant_locked( self, owner: Thread, claim_scope: int, generation: Generation @@ -186,7 +186,7 @@ def ready_goal_grant_locked( def unresolved_inputs(self, name: str) -> tuple[UnresolvedInput, ...]: """Project durable unresolved inputs; reading never schedules another attempt.""" entry = self.registry.entry(name).entry - rows = InputDispositions(self.root).unresolved((entry,)).unknown(entry.names) + rows = self.dispositions.unresolved((entry,)).unknown(entry.names) return tuple(UnresolvedInput.of(row) for row in rows) def input_delivery( @@ -198,14 +198,14 @@ def input_delivery( ) -> DeliveryOverview: """Project durable notices using current owner queue facts when available.""" entry = self.registry.entry(name).entry - return InputDispositions(self.root).unresolved((entry,)).delivery_overview( + return self.dispositions.unresolved((entry,)).delivery_overview( entry.names, include_history=include_history, scope=scope) def dismiss_historical_inputs( self, name: str, *, scope: InputDeliveryScope = UNOBSERVED_DELIVERY_SCOPE ) -> DeliveryOverview: """Dismiss only historical notices; UNKNOWN remains unresolved and unreplayable.""" - return InputDispositions(self.root).dismiss_historical( + return self.dispositions.dismiss_historical( (self.registry.entry(name).entry,), scope=scope) def goal_input_review( @@ -231,7 +231,7 @@ def _goal_input_review( dict.fromkeys(GoalWaitTarget(target.incarnation.require_recorded()) for target in resolved) ) owners = captured.owners - dispositions = InputDispositions(self.root) + dispositions = self.dispositions unknown = {row.key: row for row in dispositions.unresolved((snapshot.entry(thread.name),)).unknown(owners)} eligible = set() @@ -308,11 +308,7 @@ def goal_execution(self, name: str) -> GoalExecution: return self._goal_snapshot(name).execution def goal_changed(self, name: str, previous: GoalPublicationSnapshot) -> GoalChange: - """Announce a durable change through S1's event vocabulary. - - Existing registry/history and wait persistence are the cross-process - authority. Each observer supplies its last successfully published view. - """ + """Compare a rebuilt view after its publication owner observed a source change.""" current = self.goal_snapshot(name) return GoalChanged(current) if previous.changed_by(current) else GoalUnchanged() @@ -330,12 +326,16 @@ def snapshot_for(self, owner: Thread, snapshot: RegistrySnapshot, """All goal readers build the same source from the original records.""" for goal in owner.goal: store = self.open_goal_store() - generation = store.require_generation(goal.id) - source = GoalExecutionSource( - goal, GoalWaits.for_goal(goal, waits), generation, - generation.lifecycle.evidence(store, generation), owner, snapshot, self.agents, - ) - return GoalSnapshot(goal, source.execution()) + # Generation and its failure evidence come from one cut. Permit + # attestation keeps GoalAttemptStore's independent fresh readback. + with store.reader_snapshot() as connection: + generation = Generation.require_in(connection, goal.id) + source = GoalExecutionSource( + goal, GoalWaits.for_goal(goal, waits), generation, + generation.lifecycle.evidence(connection, generation), + owner, snapshot, self.agents, + ) + return GoalSnapshot(goal, source.execution()) return GoalSnapshot(owner.goal, IdleGoalExecution()) def direct(self, name: str, message: Message) -> None: diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 0431559ddb..149abfc60b 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -266,6 +266,10 @@ def reason(self) -> str: class GoalPublicationSnapshot(ABC): + @abstractmethod + def goal_changed(self, goal: GoalDeclaration) -> bool: + """Whether an owner declaration requests a fresh projection, not its complete inputs.""" + @abstractmethod def changed_by(self, current: GoalSnapshot) -> bool: ... @@ -274,6 +278,9 @@ def standby(self) -> bool: ... class UnpublishedGoalSnapshot(NullMember, GoalPublicationSnapshot): + def goal_changed(self, goal: GoalDeclaration) -> bool: + return True + def changed_by(self, current: GoalSnapshot) -> bool: return True @@ -286,6 +293,9 @@ class GoalSnapshot(GoalPublicationSnapshot): goal: GoalDeclaration execution: GoalExecution + def goal_changed(self, goal: GoalDeclaration) -> bool: + return self.goal != goal + def changed_by(self, current: GoalSnapshot) -> bool: return self != current diff --git a/src/agent_comms/goal_scheduler.py b/src/agent_comms/goal_scheduler.py index e35a607114..6129921bfa 100644 --- a/src/agent_comms/goal_scheduler.py +++ b/src/agent_comms/goal_scheduler.py @@ -9,11 +9,13 @@ from .errors import GoalAttemptError import asyncio import os +from collections.abc import Iterable from dataclasses import dataclass, field from functools import partial +from pathlib import Path from typing import TYPE_CHECKING -from agent_comms.field_codec import GoalId +from agent_comms.field_codec import AcpSessionId, GoalId from .agent_event_updates import AcpEventConsumer from .child_process import ProcessIdentity from .coordinator import Coordination @@ -28,14 +30,15 @@ SetGoalAction, ) from .goal_presentation import GoalPublicationSnapshot, UnpublishedGoalSnapshot +from .locked_store import GoalWakeFile from .goal_scope import OneGoal from .goals import Goal, GoalDeclaration, GoalRevision +from .registry_document import RegistryDocument, RegistrySnapshot from .reservation_rules import ReservationViolationError from .routing import ScheduledTurn from .schedule_rules import OwnerTurnScheduleCheck, WakeScheduleCheck if TYPE_CHECKING: - from .registry_document import RegistrySnapshot from .threads import Thread from .comms import Comms from .input_drain import InputDrain @@ -45,6 +48,21 @@ GOAL_CONTINUE_PROMPT = "Continue working toward the active goal." +class GoalRefresh: + """An explicit refresh projects the live owners before comparing publication.""" + + def needed(self, snapshot: GoalPublicationSnapshot) -> bool: + return True + + +@dataclass(frozen=True) +class DeclaredGoalRefresh(GoalRefresh): + goal: GoalDeclaration + + def needed(self, snapshot: GoalPublicationSnapshot) -> bool: + return snapshot.goal_changed(self.goal) + + @dataclass(eq=False, slots=True) class GoalSessionPublication: """One session's last published goal and its publication lifetime.""" @@ -61,16 +79,16 @@ def __init__(self, comms: Comms, effects: TurnEffects): self.comms = comms self.effects = effects self.pending_goal_origins: dict[str, GoalId] = {} - self.publications: dict[str, GoalSessionPublication] = {} + self.publications: dict[AcpSessionId, GoalSessionPublication] = {} - def publication(self, session_id: str) -> GoalSessionPublication: + def publication(self, session_id: AcpSessionId) -> GoalSessionPublication: return self.publications.setdefault(session_id, GoalSessionPublication()) def bind(self, sessions: SessionLifecycle, inputs: InputDrain) -> None: self.sessions = sessions self.inputs = inputs - async def _retain_current_goal_turns(self, session_id: str) -> None: + async def _retain_current_goal_turns(self, session_id: AcpSessionId) -> None: """Keep only turns still owned by the currently active goal.""" current = await Coordination.run_worker(lambda: self.comms.registry.require( self.sessions.require(session_id) @@ -79,7 +97,7 @@ async def _retain_current_goal_turns(self, session_id: str) -> None: self.inputs.state(session_id).pending_turns, current, ) - async def schedule_goal(self, session_id: str) -> None: + async def schedule_goal(self, session_id: AcpSessionId) -> None: """Finish recording the goal's start permission before binding its loop queue.""" await self._retain_current_goal_turns(session_id) check = OwnerTurnScheduleCheck( @@ -103,7 +121,7 @@ async def schedule_goal(self, session_id: str) -> None: self.inputs.state(session_id).pending_turns.append(scheduled) WakeScheduleCheck(session_id=session_id, inputs=self.inputs).schedule() - def prepare_goal(self, session_id: str) -> ScheduledTurn | None: + def prepare_goal(self, session_id: AcpSessionId) -> ScheduledTurn | None: """Read/recover the original READY grant in its joined storage worker.""" with self.comms.registry.store.current_snapshot() as cut: snapshot = cut.snapshot @@ -133,7 +151,7 @@ def _prepare_declared_goal(self, goal: Goal, thread: Thread, self.comms.goals.ready_goal_grant_locked(thread, snapshot.claim_generation(thread.name), generation) return (ScheduledTurn(GOAL_CONTINUE_PROMPT, goal_id=OneGoal(goal.id)),) - async def set_goal(self, session_id: str, text: str) -> Goal: + async def set_goal(self, session_id: AcpSessionId, text: str) -> Goal: """Commit a UI goal through its executing owner and private launch ledger.""" if not isinstance(text, str) or not text.strip(): raise ValueError("A goal requires text.") @@ -147,7 +165,7 @@ async def set_goal(self, session_id: str, text: str) -> Goal: return goal async def edit_goal( - self, session_id: str, goal_id: GoalId, expected_revision: int, text: str + self, session_id: AcpSessionId, goal_id: GoalId, expected_revision: int, text: str ) -> Goal: """Edit the current objective without replacing its identity or execution state.""" name = self.sessions.require(session_id) @@ -171,7 +189,7 @@ async def edit_goal( return edited async def update_goal( - self, session_id: str, action: type[GoalAction], goal_id: GoalId, expected_revision: int + self, session_id: AcpSessionId, action: type[GoalAction], goal_id: GoalId, expected_revision: int ) -> GoalDeclaration: """Apply an explicit UI pause, resume, or clear through the current owner.""" if not issubclass(action, OwnerControlInvocable): @@ -199,7 +217,7 @@ async def update_goal( await self.schedule_goal(session_id) return updated - async def retry_goal(self, session_id: str, goal_id: GoalId, expected_revision: int) -> Goal: + async def retry_goal(self, session_id: AcpSessionId, goal_id: GoalId, expected_revision: int) -> Goal: """Record an explicit UI retry in the executing owner's private ledger.""" name = self.sessions.require(session_id) thread = await Coordination.run_worker(partial(self.comms.registry.require, name)) @@ -222,19 +240,39 @@ async def retry_goal(self, session_id: str, goal_id: GoalId, expected_revision: # turn. The scheduler's existing busy fences defer launch until that # turn finishes; reserved/claimed attempts remain unretryable above. await self.schedule_goal(session_id) - await self.sync_goal_execution(session_id, name) + await self.sync_goal_execution(session_id) return resumed - def in_standby(self, session_id: str) -> bool: + def in_standby(self, session_id: AcpSessionId) -> bool: return self.publication(session_id).in_standby() - async def sync_goal_execution(self, session_id: str, thread_name: str) -> None: - # A publication advances this session's snapshot only after delivery. + async def sync_goal_execution( + self, session_id: AcpSessionId, request: GoalRefresh = GoalRefresh(), + ) -> None: record = self.publication(session_id) async with record.lock: + if not request.needed(record.snapshot): + return + thread_name = self.sessions.require(session_id) event = await Coordination.run_worker(partial( self.comms.goals.goal_changed, thread_name, record.snapshot, )) + # Failed delivery leaves the published snapshot unchanged. record.snapshot = await event.publish( AcpEventConsumer(self.effects, session_id, self.effects._runtime), record.snapshot ) + + def observe_root( + self, document: RegistryDocument, session_ids: Iterable[AcpSessionId], paths: frozenset[Path], + ) -> None: + """Request independent publications without holding up the root's inbox wake. + + RuntimeServer owns each task's retirement and original failure; these + projections are not work on a nonexistent inbox input. + """ + execution_changed = not paths.isdisjoint(GoalWakeFile.execution_paths_in(self.comms.root)) + for session_id in session_ids: + thread_name = self.sessions.require(session_id) + request = (GoalRefresh() if execution_changed else + DeclaredGoalRefresh(document.entry(thread_name).thread.goal)) + self.effects._runtime.background(self.sync_goal_execution(session_id, request)) diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index b30985132a..08683d730b 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -18,7 +18,7 @@ from .goals import Goal, GoalDeclaration from .input_key import InputKey, InputKeyText from .input_attempt import StoredInput, UnresolvedInput -from .locked_store import WireWakeFile +from .locked_store import GoalWakeFile from .thread_identity import RecordedThreadIncarnation from .thread_owned_state import ThreadOwnedState from .turn_lease import TurnCompletion, TurnLease @@ -544,7 +544,7 @@ def references(cls) -> tuple[ForeignKey, ...]: @dataclass(frozen=True, slots=True) -class GoalWaits(ThreadOwnedState, DurableStore, WireWakeFile): +class GoalWaits(ThreadOwnedState, DurableStore, GoalWakeFile): root: Path filename: ClassVar[str] = "goal_waits.sqlite3" tables = GoalWaitsRows diff --git a/src/agent_comms/input_disposition.py b/src/agent_comms/input_disposition.py index 72419a4c84..21080f0552 100644 --- a/src/agent_comms/input_disposition.py +++ b/src/agent_comms/input_disposition.py @@ -14,7 +14,7 @@ from .channel_targets import CommandTarget from .bus import Bus from .bus_tables import MessageIdentitySource -from .coordination_database import CoordinationStore +from .coordination_database import CoordinationStore, ReadableStore from .coordination_tables.harness_deliveries import HarnessDelivery from .harness_delivery_states import Outcome, UnconfirmedOutcome from .errors import RelationViolationError @@ -34,7 +34,7 @@ from .input_key import InputKey, InputKeyText, TurnInputKey from .input_origin import HarnessAttemptOrigin, InputOrigin, UnattributedInputOrigin from .lifecycle_capability import Unresolved -from .locked_store import DocumentChange +from .locked_store import DocumentChange, PublishedProperty from .message_reference import MessageReference from .messages import MessageInput from .reported_turn import ReportedTurnState @@ -275,12 +275,16 @@ def read(self, keys: tuple[InputKey, ...]) -> InputDocument: def lookup(self, key: InputKey) -> InputAttempt: return self.read((key,)).lookup(key) + @PublishedProperty + def coordinator_reader(self) -> ReadableStore: + return ReadableStore(CoordinationStore.path_in(self.root)) + def harness_attempts(self, entries: tuple[RegistryEntry, ...]) -> tuple[ReservedInput, ...]: """Uncertain harness attempts for these entries' frozen incarnations, as the inputs they are. Derived on every read and never stored by reading.""" owners = {entry.thread.incarnation.require_recorded().lookup: entry for entry in entries} outcomes = Outcome.members_with(UnconfirmedOutcome) - with CoordinationStore.opened_reader(CoordinationStore.path_in(self.root)) as db: + with self.coordinator_reader.reader_snapshot() as db: attempts = HarnessDelivery.select( db, where=(f'"recipient_lookup" IN ({", ".join("?" for _ in owners)}) ' diff --git a/src/agent_comms/input_drain.py b/src/agent_comms/input_drain.py index 51afa58593..0100c69ab8 100644 --- a/src/agent_comms/input_drain.py +++ b/src/agent_comms/input_drain.py @@ -24,7 +24,8 @@ encode_updates, ) from .activity import FailedWakeDiagnostic -from .agent_backend import AgentBackend, ClearQueue, InputContent, SendNow, TurnInbox, TurnInboxItem +from .agent_backend import AgentBackend +from .turn_inbox import ClearQueue, InputContent, SendNow, TurnInbox, TurnInboxItem from .bus import Bus from .child_process import join_retirement from .cohort_schema import AcceptanceFrontier @@ -52,7 +53,8 @@ from .input_disposition import InputDispositions, InputDocument from .input_effects import InputEffects from .lifecycle_capability import Executing -from .maintenance_barrier import MaintenanceBarrier, MaintenanceIngress +from .maintenance_barrier import MaintenanceIngress +from .locked_store import GoalWakeFile from .native_input_owner import RegistryOwner from .proven_source_coverage import SourcePrefix from .queued_input import DeliveredInput, InputHandoffRefused, QueuedInput @@ -105,14 +107,14 @@ def has_unobserved_inputs(self, bus: Bus, lookup: ThreadLookup) -> bool: return bool(bus.received( lookup, after=self.source_prefix.through_seq, limit=1, descending=False)) - async def drain(self, inputs, session_id: str, root_id: RootId) -> int: + async def drain(self, inputs, session_id: AcpSessionId, root_id: RootId) -> int: return await inputs.effects._drain_private_nk(session_id, root_id) class ExecutionWaitingObservation(PrivateObservation): """A previous execution blocks new input until its stored state changes.""" - async def drain(self, inputs, session_id: str, root_id: RootId) -> int: + async def drain(self, inputs, session_id: AcpSessionId, root_id: RootId) -> int: return 0 @@ -125,14 +127,14 @@ class InputObservation(ABC): runtime: RuntimeServer auto_wake: bool closing: bool - drained: dict[str, DrainedSession] + drained: dict[AcpSessionId, DrainedSession] root_observer: BackgroundTask observed_registry: asyncio.Task[RegistryDocument] @abstractmethod - def state(self, session_id: str) -> DrainedSession: ... + def state(self, session_id: AcpSessionId) -> DrainedSession: ... - def turn_live(self, session_id: str) -> bool: + def turn_live(self, session_id: AcpSessionId) -> bool: state = self.state(session_id) return state.turn_open() or state.turn_task.busy @@ -140,11 +142,11 @@ def _retire_observer(self, state: DrainedSession, task: asyncio.Task[None]) -> N if state.observer.owns(task): state.observation_watcher = None - def wake_observer(self, session_id: str) -> None: + def wake_observer(self, session_id: AcpSessionId) -> None: self.ensure_live_drain(session_id) self.state(session_id).wake_observer() - def register_wake(self, session_id: str, task: asyncio.Task[WakeOutcome]) -> None: + def register_wake(self, session_id: AcpSessionId, task: asyncio.Task[WakeOutcome]) -> None: state = self.state(session_id) state.wake_task = task @@ -161,7 +163,7 @@ def finished(completed: asyncio.Task[WakeOutcome]) -> None: task.add_done_callback(finished) - def ensure_live_drain(self, session_id: str) -> None: + def ensure_live_drain(self, session_id: AcpSessionId) -> None: """Share root observation, retaining an independent consumer for each session.""" if self.closing: return @@ -174,14 +176,15 @@ def ensure_live_drain(self, session_id: str) -> None: task = self.runtime.background(self.observe(session_id)) state.observer = RunningBackgroundTask(task) task.add_done_callback(partial(self._retire_observer, state)) + state.wake_observer() self.root_observer.wake() - async def _own_entry(self, session_id: str) -> RegistryEntryRevision: + async def _own_entry(self, session_id: AcpSessionId) -> RegistryEntryRevision: return await Coordination.run_worker(partial( self.comms.registry.entry, self.sessions.require(session_id), )) - def _quiescent(self, session_id: str) -> bool: + def _quiescent(self, session_id: AcpSessionId) -> bool: """No process-local work or retry is waiting on the next tick.""" state = self.state(session_id) turn_live = self.turn_live(session_id) @@ -207,13 +210,17 @@ def _read_observed_registry(self, names: tuple[str, ...]) -> RegistryDocument: async def observe_root(self) -> None: """One kernel registration and captured registry read feed the root's drains.""" bus = self.comms.bus.bus - sources = ( + inbox_sources = ( *(FileSource(path) for path in ( *bus.source_paths(self.comms.root), *CoordinationStore.source_paths(self.comms.root), )), FileLifetimeSource(bus.path), FileLifetimeSource(CoordinationStore.path_in(self.comms.root)), ) + sources = ( + *inbox_sources, + *(FileSource(path) for path in GoalWakeFile.watched_paths_in(self.comms.root)), + ) try: async with aclosing(bus.watch_sources(*sources)) as observations: async for watcher in observations: @@ -227,15 +234,25 @@ async def observe_root(self) -> None: self._read_observed_registry, names, ))) try: - await read + document = await read except UNAVAILABLE_DRAIN_ERRORS: # Each consumer records failure against its own original owner. - pass + for state in targets.values(): + state.observation_ready.set() + else: + self.effects.turns.goals.observe_root( + document, (owner.identifier for state in targets.values() + for owner in state.executors()), watcher.observed_paths, + ) self.observed_registry = read + inbox_changed = any(source.path in watcher.observed_paths for source in inbox_sources) for state in targets.values(): if state.observing(): + # Pending producers have their own completion wake; + # only an explicit local request owes another tick. + if inbox_changed or state.repeat_observation: + state.observation_ready.set() state.observation_watcher = watcher - state.observation_ready.set() finally: self.root_observer = BackgroundTask() # A failed native registration cannot leave consumers waiting forever. @@ -243,7 +260,7 @@ async def observe_root(self) -> None: for state in tuple(self.drained.values()): state.observer.cancel() - async def observe(self, session_id: str) -> None: + async def observe(self, session_id: AcpSessionId) -> None: initial = await self._own_entry(session_id) state = self.state(session_id) entry = await self.begin_observation(state, initial.entry) @@ -273,21 +290,22 @@ async def observe(self, session_id: str) -> None: await self._observe_entry(session_id, entry) async def _observe_entry( - self, session_id: str, entry: RegistryEntry, + self, session_id: AcpSessionId, entry: RegistryEntry, ) -> None: thread = self.sessions.require(session_id) try: async with drain_failures(self.comms, thread, entry.owner_identity): - await self.sessions.config.apply_stored_settings(session_id) + attachment = self.state(session_id).owned().identifier + await self.sessions.config.apply_stored_settings(attachment) drained = await self.drain_inbox(session_id) - if self.effects.turns.goals.in_standby(session_id): + if self.effects.turns.goals.in_standby(attachment): await Coordination.run_worker(partial( self.comms.goals.recover_closed_goal_wait, session_id, )) await ProjectResumeCheck( session_id=session_id, inputs=self, turns=self.effects.turns, ).schedule() - await self.effects.turns.goals.schedule_goal(session_id) + await self.effects.turns.goals.schedule_goal(attachment) current = await Coordination.run_worker(partial(self.comms.registry.require, thread)) if (current.turn_record.items_with(Executing, current) or self.turn_live(session_id) or self._quiescent(session_id)): @@ -302,7 +320,7 @@ async def _observe_entry( pass def _private_observation_revision( - self, session_id: str, root_id: RootId, source_prefix: SourcePrefix, entry: RegistryEntry, + self, session_id: AcpSessionId, root_id: RootId, source_prefix: SourcePrefix, entry: RegistryEntry, store: Coordination, ) -> PrivateObservation: """Original owner/work observations, never delivery or replay authority. @@ -333,12 +351,12 @@ def _private_observation_revision( and not self.owns_execution(session_id, owner.thread) ): observation = ExecutionWaitingObservation - maintenance = MaintenanceBarrier(self.comms.registry.store.path)._current_unlocked() + maintenance = self.comms.owners.maintenance._current_unlocked() return observation(source_prefix, entry, ( root_id, self.auto_wake, self.sessions.runtime_enabled, - self.sessions.published.get(AcpSessionId(session_id)), + self.sessions.published.get(session_id), self.state(session_id).turn_open(), self.state(session_id).turn_task.busy, participant, @@ -350,7 +368,7 @@ def _private_observation_revision( )) async def _observe_private_revision( - self, session_id: str, root_id: RootId + self, session_id: AcpSessionId, root_id: RootId ) -> PrivateObservation: # Pending acquisition is not an inbox failure or a completed read. source_prefix = await Coordination.run_worker(partial( @@ -730,7 +748,7 @@ async def begin_observation( self.comms.registry.entry, entry.thread.name, ))).entry - def owns_execution(self, session_id: str, owner: Thread) -> bool: + def owns_execution(self, session_id: AcpSessionId, owner: Thread) -> bool: """An attached turn can take follow-ups; a predecessor cannot.""" return ( owner.turn_record.read().phase.busy @@ -751,14 +769,14 @@ def __init__( self.runtime = runtime self.effects = effects self.configuration = configuration - self.drained: dict[str, DrainedSession] = {} + self.drained: dict[AcpSessionId, DrainedSession] = {} self.root_observer: BackgroundTask = BackgroundTask() self.queue_lock = asyncio.Lock() self.dispositions = InputDispositions(comms.root) self.auto_wake = auto_wake self.closing = False - def state(self, session_id: str) -> DrainedSession: + def state(self, session_id: AcpSessionId) -> DrainedSession: """This session's state, created on its first use.""" state = self.drained.get(session_id) if state is None: @@ -784,7 +802,7 @@ def acquire_owner( raise RelationViolationError("Another allocation already owns this session") return owned - def end_session(self, session_id: str) -> None: + def end_session(self, session_id: AcpSessionId) -> None: """Ending a session drops its state, its live producers with it.""" state = self.drained.pop(session_id, None) if state is not None: @@ -795,7 +813,7 @@ def end_sessions(self) -> None: for session_id in tuple(self.drained): self.end_session(session_id) - async def clear_queued_inputs(self, session_id: str) -> None: + async def clear_queued_inputs(self, session_id: AcpSessionId) -> None: """Drop prompts queued against the backend for this session. Pi's own queue is cleared, then the local deferred-display list is @@ -821,7 +839,7 @@ async def clear_queued_inputs(self, session_id: str) -> None: state.restored = {} await self.emit_queue_state(session_id) - async def queue_binding(self, session_id: str) -> QueueScope: + async def queue_binding(self, session_id: AcpSessionId) -> QueueScope: try: owner, claim_scope = await Coordination.run_worker(partial( self.comms.registry.live_owner_with_claim, @@ -836,12 +854,12 @@ async def queue_binding(self, session_id: str) -> QueueScope: owner=owner.require_process(), ) - def next_revision(self, session_id: str) -> int: + def next_revision(self, session_id: AcpSessionId) -> int: state = self.state(session_id) state.queue_revision += 1 return state.queue_revision - async def queue_state(self, session_id: str) -> QueueChangedUpdate: + async def queue_state(self, session_id: AcpSessionId) -> QueueChangedUpdate: """The producer owns the complete exact-ID queue projection.""" scope = await self.queue_binding(session_id) state = self.state(session_id) @@ -850,7 +868,7 @@ async def queue_state(self, session_id: str) -> QueueChangedUpdate: )) async def emit_queue_state( - self, session_id: str, *, client: SessionUpdateSink | None = None, + self, session_id: AcpSessionId, *, client: SessionUpdateSink | None = None, ) -> None: await (client or self.runtime).publish_update( session_id=session_id, @@ -858,7 +876,7 @@ async def emit_queue_state( ) async def emit_input_delivery_changed( - self, session_id: str, *, + self, session_id: AcpSessionId, *, client: SessionUpdateSink | None = None, ) -> None: await (client or self.runtime).publish_update( @@ -866,7 +884,7 @@ async def emit_input_delivery_changed( update=InputDeliveryChangedUpdate().acp_chunk(), ) - def input_keys(self, session_id: str) -> frozenset[InputKey]: + def input_keys(self, session_id: AcpSessionId) -> frozenset[InputKey]: """Live captured source membership, never rebuilt from durable notices.""" state = self.state(session_id) following = frozenset( @@ -875,7 +893,7 @@ def input_keys(self, session_id: str) -> frozenset[InputKey]: ) return following.union(state.turn.keys) - async def delivery_scope(self, session_id: str) -> InputDeliveryScope: + async def delivery_scope(self, session_id: AcpSessionId) -> InputDeliveryScope: """Derive delivery notices from existing owner queues; never create new authority.""" entry = (await self._own_entry(session_id)).entry owner = entry.thread @@ -889,12 +907,12 @@ async def delivery_scope(self, session_id: str) -> InputDeliveryScope: ) return OwnerQueueInputDeliveryScope(frozenset(keys)) - async def drain_inbox(self, session_id: str) -> int: + async def drain_inbox(self, session_id: AcpSessionId) -> int: """Push undelivered messages to the client; returns count pushed.""" async with self.state(session_id).lock: return await self.drain_owned_inbox(session_id) - async def _drain_private_if_changed(self, session_id: str, root_id: RootId) -> int: + async def _drain_private_if_changed(self, session_id: AcpSessionId, root_id: RootId) -> int: # The caller still validates the current private marker each time. The # ordinary loop still synchronizes configuration and schedules goals. state = self.state(session_id) @@ -917,7 +935,7 @@ async def _drain_private_if_changed(self, session_id: str, root_id: RootId) -> i await self.effects.cursors.refresh(session_id, self.sessions.require(session_id)) return 0 - async def drain_owned_inbox(self, session_id: str) -> int: + async def drain_owned_inbox(self, session_id: AcpSessionId) -> int: # The revision read below checks this root against the bus marker and # the checkpoint, so no separate exclusive bus read is needed here. await self.state(session_id).turn.resume(self.comms.owners.maintenance) @@ -932,7 +950,7 @@ def background_wakes_disabled(self) -> bool: async def accept_followup( self, - session_id: str, + session_id: AcpSessionId, *, text: str, display_text: str, @@ -966,19 +984,19 @@ async def accept_followup( field_meta=encode_updates(InputDeliveryChangedUpdate()), ) - def require_inbox(self, session_id: str, inbox: TurnInbox) -> None: + def require_inbox(self, session_id: AcpSessionId, inbox: TurnInbox) -> None: if not self.state(session_id).turn.matches(inbox): raise InputHandoffRefused("Running turn inbox retired before input placement") def bind_native_turn( - self, session_id: str, owner: Thread, + self, session_id: AcpSessionId, owner: Thread, ) -> TurnInbox: """Open the live turn inbox and capture its configured settings.""" inbox = self.state(session_id).open_inbox() self.sessions.config.turn_started(session_id, owner) return inbox - async def send_now(self, session_id: str) -> None: + async def send_now(self, session_id: AcpSessionId) -> None: async with self.queue_lock: state = self.state(session_id) if state.queued: @@ -998,7 +1016,7 @@ async def close_sessions(self, states: tuple[DrainedSession, ...]) -> None: task.cancel() await join_retirement(asyncio.gather(*tasks, return_exceptions=True)) - async def input_refused(self, session_id: str, input_id: InputId) -> None: + async def input_refused(self, session_id: AcpSessionId, input_id: InputId) -> None: state = self.state(session_id) source = state.following.pop(input_id, None) if source is not None: @@ -1009,7 +1027,7 @@ async def input_refused(self, session_id: str, input_id: InputId) -> None: await self.emit_queue_state(session_id) async def input_cleared( - self, session_id: str, input_id: InputId, native_id: InputId, text: str, + self, session_id: AcpSessionId, input_id: InputId, native_id: InputId, text: str, ) -> None: source = self.state(session_id).following.get(input_id) if source is not None: diff --git a/src/agent_comms/input_effects.py b/src/agent_comms/input_effects.py index 6fa121b3ea..8e1efb65d1 100644 --- a/src/agent_comms/input_effects.py +++ b/src/agent_comms/input_effects.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from agent_comms.field_codec import RootId +from agent_comms.field_codec import AcpSessionId, RootId if TYPE_CHECKING: from .cursor_publication import CursorPublication @@ -20,4 +20,4 @@ class InputEffects(ABC): def _debug_log(self, message: str) -> None: ... @abstractmethod - async def _drain_private_nk(self, session_id: str, wire_root_id: RootId) -> int: ... + async def _drain_private_nk(self, session_id: AcpSessionId, wire_root_id: RootId) -> int: ... diff --git a/src/agent_comms/locked_store.py b/src/agent_comms/locked_store.py index 2713c674f1..2b57fcbddd 100644 --- a/src/agent_comms/locked_store.py +++ b/src/agent_comms/locked_store.py @@ -14,10 +14,12 @@ from collections.abc import Callable, Collection, Iterable, Iterator, Mapping from contextlib import AbstractContextManager, closing, contextmanager, nullcontext from dataclasses import dataclass, field -from functools import cache +from functools import cache, cached_property, partial from importlib import import_module from pathlib import Path from pkgutil import walk_packages +from threading import local +from weakref import finalize from typing import TYPE_CHECKING, Annotated, ClassVar, Generic, Self, TypeVar from pydantic import JsonValue @@ -708,12 +710,34 @@ def watched_paths_in(cls, root: Path) -> frozenset[Path]: ) -class RegistryFile(RollbackRetained, WireWakeFile): +class GoalWakeFile(WireWakeFile): + """Coarse publication signals, never a fingerprint of every live execution input.""" + + __slots__ = () + + @classmethod + def execution_paths(cls, root: Path) -> tuple[Path, ...]: + return cls.source_paths(root) + + @classmethod + def execution_paths_in(cls, root: Path) -> frozenset[Path]: + return frozenset( + path for owner, directory in cls.declarations_in(root) + for path in owner.execution_paths(directory) + ) + + +class RegistryFile(RollbackRetained, GoalWakeFile): """Registry location usable by record types without importing their decoder.""" __slots__ = () filename = "registry.json" + @classmethod + def execution_paths(cls, root: Path) -> tuple[Path, ...]: + # Its captured owner declaration selects the session; a phase edit does not. + return () + @classmethod def copy_group(cls, source: Path, target: Path) -> frozenset[Path]: """The copy names no live process: a signal, stop or wake aimed through it would reach @@ -733,6 +757,49 @@ def copy_group(cls, source: Path, target: Path) -> frozenset[Path]: +class SQLiteReadConnection: + """A thread's read handle, retired with its store or when the file is replaced.""" + + def borrowed(self) -> tuple[sqlite3.Connection, ...]: + return () + + def acquire( + self, source: SQLiteReader, revision: FileObservation, contention: StoreContention, + ) -> OpenSQLiteReadConnection: + return OpenSQLiteReadConnection(source, revision, contention) + + +class OpenSQLiteReadConnection(SQLiteReadConnection): + def __init__( + self, source: SQLiteReader, revision: FileObservation, contention: StoreContention, + ) -> None: + self.pid = os.getpid() + self.identity = revision.file_identity() + # Queries remain thread-local. Finalization may run in the store owner's + # retirement thread, so closing the handle is not thread-affine. + self.db = source._open_reader(source.path, contention, check_same_thread=False) + self.retire = finalize(self, self.db.close) + if FileRevision.capture(source.path).file_identity() != self.identity: + self.retire() + raise RelationViolationError(f"SQLite file replaced while opening {source.path}") + + def borrowed(self) -> tuple[sqlite3.Connection, ...]: + return (self.db,) if self.pid == os.getpid() and self.db.in_transaction else () + + def acquire( + self, source: SQLiteReader, revision: FileObservation, contention: StoreContention, + ) -> OpenSQLiteReadConnection: + if self.pid == os.getpid() and self.identity == revision.file_identity(): + return self + self.retire() + return super().acquire(source, revision, contention) + + +class SQLiteReaders(local): + def __init__(self) -> None: + self.connection: SQLiteReadConnection = SQLiteReadConnection() + + class SQLiteStorage(RootFile, SqliteStore): """SQLite connections, transactions and companion membership shared by file policies.""" @@ -785,10 +852,11 @@ def _configure_reader(cls, db: sqlite3.Connection) -> sqlite3.Connection: @classmethod def _open_reader( - cls, path: Path, contention: StoreContention + cls, path: Path, contention: StoreContention, *, check_same_thread: bool = True, ) -> sqlite3.Connection: return cls._configure_reader( - contention.reader(path.resolve().as_uri() + "?mode=ro") + contention.reader(path.resolve().as_uri() + "?mode=ro", + check_same_thread=check_same_thread) ) @classmethod @@ -813,28 +881,6 @@ def recovering_snapshot(cls, path: Path) -> sqlite3.Connection: uri = path.resolve().as_uri() + "?mode=rw" return cls._acquire_snapshot(cls._configure_reader(cls.writer(uri, uri=True))) - @classmethod - @contextmanager - def opened_reader( - cls, path: Path, contention: StoreContention = DEFAULT_STORE_CONTENTION - ) -> Iterator[sqlite3.Connection]: - """Read existing committed data without initializing storage or permitting writes.""" - with CoordinationReadUnavailable.checking(): - try: - with closing(cls.read_snapshot(path, contention)) as db: - try: - yield db - finally: - if db.in_transaction: - db.execute("ROLLBACK") - except sqlite3.OperationalError as error: - error.add_note( - f"SQLite observation store: {path}; " - f"SQLite error: {error.sqlite_errorname} ({error.sqlite_errorcode})" - ) - raise - - @classmethod def files(cls, path: Path) -> tuple[Path, ...]: return (path, cls.wal_path(path), @@ -884,6 +930,66 @@ def write_transaction(cls, connection: sqlite3.Connection) -> Iterator[sqlite3.C raise +class PublishedProperty[Value](cached_property[Value]): + """Publish a resource once into the slot Python binds to its declaration.""" + + def __set_name__(self, owner: type[object], name: str) -> None: + super().__set_name__(owner, name) + self.func = partial(self._published, name, self.func) + + def _published( + self, name: str, create: Callable[[object], Value], source: object, + ) -> Value: + value: Value = source.__dict__.setdefault(name, create(source)) + return value + + +class SQLiteReader(SQLiteStorage): + """A physical instance owns readers; class-only file policies do not.""" + + @property + @abstractmethod + def path(self) -> Path: + """The physical file whose connection lifetime this instance owns.""" + + def require_path(self) -> None: + """Physical stores can require their existing path/privacy contract.""" + + @PublishedProperty + def readers(self) -> SQLiteReaders: + """One lazy resource owner per store, with distinct handles in each thread.""" + return SQLiteReaders() + + @contextmanager + def reader_snapshot( + self, contention: StoreContention = DEFAULT_STORE_CONTENTION, + ) -> Iterator[sqlite3.Connection]: + for borrowed in self.readers.connection.borrowed(): + yield borrowed + return + self.require_path() + revision = FileRevision.capture(self.path) + revision.require(FileNotFoundError(self.path)) + with CoordinationReadUnavailable.checking(): + try: + held = self.readers.connection.acquire(self, revision, contention) + self.readers.connection = held + db = held.db + try: + contention.configure(db) + self.begin_snapshot(db) + yield db + finally: + if db.in_transaction: + db.execute("ROLLBACK") + except sqlite3.OperationalError as error: + error.add_note( + f"SQLite observation store: {self.path}; " + f"SQLite error: {error.sqlite_errorname} ({error.sqlite_errorcode})" + ) + raise + + class SQLiteFile(SQLiteStorage): """A bus SQLite file retains logical snapshots after its writers enter maintenance.""" diff --git a/src/agent_comms/maintenance_barrier.py b/src/agent_comms/maintenance_barrier.py index 8cb15e1bde..ed17d4da3f 100644 --- a/src/agent_comms/maintenance_barrier.py +++ b/src/agent_comms/maintenance_barrier.py @@ -20,7 +20,7 @@ from .child_process import ProcessIdentity from .coordination_errors import DrainUnavailable -from .private_path import PrivateFileRole +from .private_path import FileObservation, FileRevision, PrivateFileRole from .declared_family import DeclaredFamily from .errors import RelationViolationError, ToolRefusedError from .field_codec import ( @@ -384,8 +384,51 @@ def current(cls) -> Self: return cls(identity.pid, identity.start_time) +class NoMaintenanceWitness(NullMember): + """An absent file, not a malformed or incomplete enabled witness.""" + + def read_state(self, state: MaintenanceState | NoMaintenanceWitness, root: Path) -> MaintenanceStatus: + return state.without_marker() + + def without_marker(self) -> MaintenanceStatus: + return MaintenanceStatus() + + def require_marker(self, marker: MaintenanceMarker, root: Path) -> MaintenanceState: + raise RelationViolationError("Maintenance witness incomplete; new turns held") + + def applied_to(self, state: MaintenanceState) -> MaintenanceState: + return state + + +class MaintenanceWitness: + """Private witness decoding shared by the marker, state and target declarations.""" + + @classmethod + def observe_file(cls, path: Path) -> Self | NoMaintenanceWitness: + try: + descriptor = PrivateFileRole.read( + path, RelationViolationError("Maintenance witness is not private and regular")) + except FileNotFoundError: + return NoMaintenanceWitness() + except OSError as error: + raise RelationViolationError("Maintenance witness is inaccessible") from error + try: + info = os.fstat(descriptor) + if info.st_size > 4096: + raise RelationViolationError("Maintenance witness is not private and regular") + with os.fdopen(descriptor, "rb") as stream: + descriptor = -1 + value = FieldCodec.decode(cls, FieldCodec.parse(stream.read())) + except (OSError, MalformedRecord) as error: + raise RelationViolationError("Maintenance witness is invalid") from error + finally: + if descriptor >= 0: + os.close(descriptor) + return value + + @dataclass(frozen=True, kw_only=True) -class MaintenanceMarker(DurableFile): +class MaintenanceMarker(DurableFile, MaintenanceWitness): filename = ".maintenance-enabled" version: Literal[2] root: str @@ -395,6 +438,10 @@ def __post_init__(self): if not 0 < self.generation < 1 << 63: raise ValueError("Maintenance generation is outside its allocation domain") + def read_state(self, state: MaintenanceState | NoMaintenanceWitness, root: Path) -> MaintenanceStatus: + held = state.require_marker(self, root) + return HoldTarget.observe_file(HoldTarget.path_in(root)).applied_to(held) + @dataclass(frozen=True, kw_only=True) class MaintenanceState(MaintenanceMarker, MaintenanceStatus): @@ -419,6 +466,9 @@ async def observed_async(self, barrier: MaintenanceBarrier) -> MaintenanceStatus return await barrier.release_dead_async(self) return self + def without_marker(self) -> MaintenanceStatus: + raise RelationViolationError("Maintenance witness incomplete; new turns held") + def require_marker(self, marker: MaintenanceMarker, root: Path) -> MaintenanceState: if ( marker @@ -466,7 +516,7 @@ def require_draining(self) -> None: @dataclass(frozen=True, kw_only=True) -class HoldTarget(DurableFile): +class HoldTarget(DurableFile, MaintenanceWitness): """The runtime a hold admits, kept beside the witness. Only runtimes that know targets read this file, so the witness keeps the shape every runtime that may still observe, restore or recover this root reads. It applies only to the witness write that stamped it: @@ -520,49 +570,32 @@ def __init__(self, registry_path: Path): self.marker_path = MaintenanceMarker.path_in(self.registry_path.parent) self.state_path = MaintenanceState.path_in(self.registry_path.parent) self.target_path = HoldTarget.path_in(self.registry_path.parent) + self.observations: dict[tuple[FileObservation, ...], MaintenanceStatus] = {} @property def witness_paths(self) -> tuple[Path, ...]: return self.marker_path, self.state_path, self.target_path - @staticmethod - def _read(path: Path, declaration): - try: - descriptor = PrivateFileRole.read( - path, RelationViolationError("Maintenance witness is not private and regular")) - except FileNotFoundError: - return None - except OSError as error: - raise RelationViolationError("Maintenance witness is inaccessible") from error - try: - info = os.fstat(descriptor) - if info.st_size > 4096: - raise RelationViolationError("Maintenance witness is not private and regular") - with os.fdopen(descriptor, "rb") as stream: - descriptor = -1 - value = FieldCodec.decode(declaration, FieldCodec.parse(stream.read())) - except (OSError, MalformedRecord) as error: - raise RelationViolationError("Maintenance witness is invalid") from error - finally: - if descriptor >= 0: - os.close(descriptor) - return value - def _current_unlocked(self) -> MaintenanceStatus: - """Read the paired witness; incomplete publication always refuses. + """Reuse an unchanged witness, never a timed permission or a writable mirror. - Admission reads under the registry lock that phase transitions own. - An unlocked observation can refuse work, never authorize a mutation. + All three physical revisions matter: loss/corruption of a companion + must still refuse even if the generation marker itself did not move. + Checks remain under the registry lock that excludes publication. """ - marker = self._read(self.marker_path, MaintenanceMarker) - state = self._read(self.state_path, MaintenanceState) - if marker is None and state is None: - return MaintenanceStatus() # Never-enabled roots have the same READY phase. - if marker is None or state is None: - raise RelationViolationError("Maintenance witness incomplete; new turns held") - state = state.require_marker(marker, self.registry_path.parent) - target = self._read(self.target_path, HoldTarget) - return state if target is None else target.applied_to(state) + revision = tuple(FileRevision.capture(path, follow_symlinks=False) for path in self.witness_paths) + if revision not in self.observations: + status = self._read_witness() + if revision != tuple(FileRevision.capture(path, follow_symlinks=False) for path in self.witness_paths): + raise RelationViolationError("Maintenance witness changed during observation") + self.observations = {revision: status} + return self.observations[revision] + + def _read_witness(self) -> MaintenanceStatus: + """Decode one changed paired witness; incomplete publication always refuses.""" + marker = MaintenanceMarker.observe_file(self.marker_path) + state = MaintenanceState.observe_file(self.state_path) + return marker.read_state(state, self.registry_path.parent) def current_unlocked(self) -> MaintenanceStatus: return self._current_unlocked().observed(self) diff --git a/src/agent_comms/manual_compaction_bridge.py b/src/agent_comms/manual_compaction_bridge.py index f02ef0029a..46a9077f07 100644 --- a/src/agent_comms/manual_compaction_bridge.py +++ b/src/agent_comms/manual_compaction_bridge.py @@ -15,7 +15,7 @@ from acp.schema import AgentMessageChunk, PromptResponse, TextContentBlock from . import agent_events as events -from .field_codec import TurnId +from .field_codec import AcpSessionId, TurnId from .acp_extension import CompactionCommittedUpdate, TranscriptChangedUpdate, encode_updates from .agent_backend import ( CompactionOutcome, @@ -68,7 +68,7 @@ def stale(self, outcome: StaleCompaction) -> PromptResponse: async def compact_context( - runner: TurnRunner, session_id: str, instructions: str | None = None + runner: TurnRunner, session_id: AcpSessionId, instructions: str | None = None ) -> CompactionOutcome: """Run the backend's own compaction now; called from a UI /compact only.""" if instructions is not None and not isinstance(instructions, str): diff --git a/src/agent_comms/native_attestation.py b/src/agent_comms/native_attestation.py index 3de6aa4b94..5cffa89aa4 100644 --- a/src/agent_comms/native_attestation.py +++ b/src/agent_comms/native_attestation.py @@ -2,7 +2,7 @@ from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -15,7 +15,7 @@ from .native_failures import NativePiUnavailable from .native_session_reopen import NativeSessionIdentity from .pi_events import PiEvent -from .pi_payloads import StateData +from .pi_payloads import NativeSessionSnapshot, SnapshotAttestation, StateData from .turn_item import PiRequestKey if TYPE_CHECKING: @@ -38,7 +38,7 @@ async def refuse(self, session: TurnSession) -> None: await super().refuse(session) -class NativeAttestation(ABC): +class NativeAttestation(SnapshotAttestation): @property @abstractmethod def observed(self) -> bool: ... @@ -71,7 +71,7 @@ def invalidate(self) -> NativeAttestation: def allows_extension_input(self, inputs) -> bool: return False - def conflicts(self, data) -> bool: + def conflicts(self, data: NativeSessionSnapshot) -> bool: return False @@ -129,7 +129,7 @@ def require_identity(self) -> NativeSessionIdentity: def allows_extension_input(self, inputs) -> bool: return inputs.permits_interaction - def conflicts(self, data) -> bool: + def conflicts(self, data: NativeSessionSnapshot) -> bool: """Reject every reported component that differs from the known identity.""" return any( observed != expected @@ -169,7 +169,7 @@ def require_identity(self) -> NativeSessionIdentity: def allows_extension_input(self, inputs) -> bool: return self.identity is not None and inputs.permits_interaction - def conflicts(self, data): + def conflicts(self, data: NativeSessionSnapshot) -> bool: return self.state.conflicts(data) diff --git a/src/agent_comms/native_turn_context.py b/src/agent_comms/native_turn_context.py index 201ff5eaba..c715f1aa0e 100644 --- a/src/agent_comms/native_turn_context.py +++ b/src/agent_comms/native_turn_context.py @@ -8,20 +8,14 @@ from .coordinator import Coordination -from .native_session_reopen import NativeSessionIdentity from .pi_payloads import PiResponseData, StrictFields -from .tool_result import DeclaredReply from .thread_identity import OfferedTools from .turn_context import ( ContextManifest, - ContextSourceText, DispatchRequest, MeasuredNativeSegment, - PreviewProvenance, - Provenance, RecordedContextTurn, SegmentManifest, - TurnContext, UnobservedDispatchRequest, ) @@ -54,51 +48,3 @@ async def record(self, contexts, prior, thread, lease) -> CapturedContext: return await Coordination.run_worker( partial(contexts.record_after, prior, manifest) ) - - -@dataclass(frozen=True) -class NativeContextData(StrictFields, PiResponseData, DeclaredReply): - counter: str - identity: NativeSessionIdentity - segments: tuple[MeasuredNativeSegment, ...] - - def observation(self) -> PreviewProvenance: - """Return the SDK's original observation, without deriving its digest.""" - originals = {observation for segment in self.segments - for source in segment.provenance - for observation in source.preview_observations()} - if len(originals) != 1: - raise ValueError("Native context has no unambiguous current preview observation") - (original,) = originals - self.identity.require_same_session(original.identity) - return original - - def public_source_text(self, comms, observation: PreviewProvenance, - segment: int, source: Provenance) -> ContextSourceText: - if observation != self.observation(): - raise ValueError("Current native preview changed since the selected observation") - if not 0 <= segment < len(self.segments): - raise ValueError("Source has no selected context segment") - selected = self.segments[segment].require_source(source) - return ContextSourceText(selected.public_description(), selected.public_text(comms)) - - def recorded_public_text(self, expected: SegmentManifest) -> str: - self.identity.require_same_session(expected.sources.native_identity()) - originals = {} - for original in expected.original_values(): - originals.setdefault(original.sha256, []).append(original) - values = {} - for segment in self.segments: - if not any(segment.matches_recorded(original) - for original in originals.get(segment.sha256, ())): - raise ValueError("Recorded SDK source is outside the selected original value") - values.setdefault(segment.sha256, []).append(segment) - return expected.recorded_public_text(values) - - def for_turn(self, owner, turn): - return TurnContext(owner.incarnation, turn, self.segments) - - def require_session_file(self, session_file): - if self.identity.session_file != session_file: - raise ValueError("Context inspection belongs to a different selected native session") - return self diff --git a/src/agent_comms/optional_awareness_projection.py b/src/agent_comms/optional_awareness_projection.py index cb1ed64bbe..2b4855e323 100644 --- a/src/agent_comms/optional_awareness_projection.py +++ b/src/agent_comms/optional_awareness_projection.py @@ -17,10 +17,8 @@ import time from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field -from pathlib import Path from typing import ClassVar -from agent_comms.coordination_database import CoordinationStore from agent_comms.coordination_errors import CoordinationError from agent_comms.coordination_tables.assignments import ExecutionAssignmentLink, WakeAssignment from agent_comms.coordination_tables.executions import ExecutionRecord @@ -198,8 +196,8 @@ def _build( if page.has_more: raise ProjectionUnavailableError("binding candidate decisions exceed the row budget") - path = CoordinationStore.path_in(self.index.root) - with CoordinationStore.opened_reader(path) as db: + path = self.registry.store.coordinator_reader.path + with self.registry.store.coordinator_reader.reader_snapshot() as db: self._verify_schema_and_owner(db, initial, assignment, owner, privacy) decisions = self._selected_decisions(db, root_id, lookup, page.through_seq) candidates = tuple( @@ -227,7 +225,7 @@ def _build( # Do not hold the SQL read lock while consulting the registry. A newer # SQL generation or owner turn omits awareness rather than borrowing # a stale result; the native sender checks permission again before writing. - self._verify_live_inclusion(path, lookup, owner) + self._verify_live_inclusion(lookup, owner) instruction = InstructionFile.read("selected-awareness.md") content_instruction = InstructionFile.read("selected-awareness-content.md") # This digest names the captured typed SQL projection, not the whole file @@ -260,9 +258,7 @@ def _build( result.require_resource_budget(self.max_text_bytes) return result - def _verify_live_inclusion( - self, path: os.PathLike[str], lookup: ThreadLookup, owner: Thread - ) -> None: + def _verify_live_inclusion(self, lookup: ThreadLookup, owner: Thread) -> None: registry = self.registry snapshot = registry.store.snapshot_now() RegistryOwner( @@ -271,7 +267,7 @@ def _verify_live_inclusion( ).require_snapshot(snapshot, "current registry owner turn changed") # A new read transaction, not the earlier candidate/decision snapshot, # observes a supported SQL generation advance during the optional read. - with CoordinationStore.opened_reader(Path(path), StoreBusy()) as db: + with registry.store.coordinator_reader.reader_snapshot(StoreBusy()) as db: rows = _ParticipantOwner.read( db.execute( "SELECT g.generation,p.committed FROM owner_generations g " diff --git a/src/agent_comms/owned_send.py b/src/agent_comms/owned_send.py index 98f9ea2b3d..62808c3248 100644 --- a/src/agent_comms/owned_send.py +++ b/src/agent_comms/owned_send.py @@ -24,7 +24,7 @@ from .coordination_session import NativeCapture, NativeCommit from .coordinator import Coordination from .envelope_claim_transitions import NativeClaim -from .field_codec import ClaimId, InputId, TurnId +from .field_codec import AcpSessionId, ClaimId, InputId, TurnId from .input_attempt import InputAlreadyCleared from .reservation_rules import ReservationRule, ReservationViolationError from .tool_denial import SelectedToolDenied @@ -52,7 +52,7 @@ class OwnedSend(CodingToolOwner, InputStartListener, SendBoundary): inputs: InputDrain goal_store: GoalAttemptStore - session_id: str + session_id: AcpSessionId turn: TurnId claim_generation: int original_source: OriginalTurnInput diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index 78f48ff91c..a4561290b6 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -20,16 +20,13 @@ from agent_comms.goal_turn_permit import GoalTurnPermit, NoLaunchPermit from . import agent_events as events -from .agent_backend import ( - InputContent, - InputStartListener, - TurnInbox, -) +from .agent_backend import InputStartListener +from .turn_inbox import InputContent, TurnInbox from .channel_coding_tools import CodingToolMode from .channel_input_batch import InputBatch from .coordination_errors import CoordinationError, Refused from .coordinator import Coordination -from .field_codec import GoalId, TurnId +from .field_codec import AcpSessionId, GoalId, TurnId from .goals import Goal from .image_input import ImageInput from .input_attempt import ReservedInput @@ -120,7 +117,7 @@ async def attach_native_session(self, session_file): def __init__( self, runner: TurnRunner, - session_id: str, + session_id: AcpSessionId, thread_name: str, task: str, *, diff --git a/src/agent_comms/owner_connection.py b/src/agent_comms/owner_connection.py index 608f438db4..aed3577c18 100644 --- a/src/agent_comms/owner_connection.py +++ b/src/agent_comms/owner_connection.py @@ -16,6 +16,7 @@ from .command import Command from .declared_family import DeclaredFamily from .tool_result import DeclaredReply +from .thread_identity import ThreadIncarnation from .coordinator import Coordination from .errors import RelationViolationError, UnregisteredThreadError @@ -56,21 +57,20 @@ class RuntimeRequestContext: server: RuntimeServer reader: asyncio.StreamReader client: SocketClient - session: AcpSessionId name: str - @property - def session_id(self) -> str: - """The ACP transport's text representation of the original session.""" - return str(self.session) - async def send(self, line: OwnerLine) -> None: await self.client.send(line) +@dataclass(frozen=True) +class AttachedRuntimeRequestContext(RuntimeRequestContext): + session: AcpSessionId + + @dataclass(frozen=True, kw_only=True) -class RuntimeRequest( - DeclaredFamily, Command[RuntimeRequestContext, Awaitable[None]], affix="RuntimeRequest" +class RuntimeRequest[Ctx: RuntimeRequestContext]( + DeclaredFamily, Command[Ctx, Awaitable[None]], affix="RuntimeRequest" ): family_discriminator: ClassVar[str] = "action" thread: str @@ -82,28 +82,43 @@ def proxy_payload( ) -> dict[str, object]: return OwnerConnection.wire_request(cls.declared_name, thread, parameters) + @abstractmethod + async def bind( + self, server: RuntimeServer, reader: asyncio.StreamReader, client: SocketClient, + ) -> Ctx: ... + + +class AttachedRuntimeRequest(RuntimeRequest[AttachedRuntimeRequestContext]): def require_owner(self, entry: RegistryEntry) -> Thread: owner = entry.thread if not owner.process_identity.runs_here() or not entry.status.running: raise RelationViolationError("This process no longer owns the thread.") return owner - async def bind(self, server: RuntimeServer, reader: asyncio.StreamReader, client: SocketClient) -> RuntimeRequestContext: + async def bind( + self, server: RuntimeServer, reader: asyncio.StreamReader, client: SocketClient, + ) -> AttachedRuntimeRequestContext: entry = (await Coordination.run_worker(partial( server.agent._comms.registry.entry, self.thread, ))).entry owner = self.require_owner(entry) session_id = server.agent.sessions.require_owned_session(entry) - return RuntimeRequestContext(server, reader, client, session_id, owner.name) + return AttachedRuntimeRequestContext(server, reader, client, owner.name, session_id) -class ResultRuntimeRequest(RuntimeRequest): - async def apply(self, ctx: RuntimeRequestContext) -> None: +class ResultRuntimeRequest[Ctx: RuntimeRequestContext](RuntimeRequest[Ctx]): + async def apply(self, ctx: Ctx) -> None: reply = await self.result(ctx) await ctx.send(OwnerResult(result=FieldCodec.encode(reply))) @abstractmethod - async def result(self, ctx: RuntimeRequestContext) -> DeclaredReply: ... + async def result(self, ctx: Ctx) -> DeclaredReply: ... + + +class AttachedResultRuntimeRequest( + ResultRuntimeRequest[AttachedRuntimeRequestContext], AttachedRuntimeRequest +): + pass class OwnerConnection: @@ -152,15 +167,14 @@ def reply(response: IO[bytes]) -> object: return OwnerLine.read(line).reply() def request_payload(self, action: str, parameters: dict[str, object]) -> dict[str, object]: - return self.wire_request(action, self.session_id, parameters) + return self.wire_request(action, self._identity.name, parameters) - def __init__(self, comms: Comms, session_id: str, path: Path): - self.session_id = session_id + def __init__(self, comms: Comms, identity: ThreadIncarnation, path: Path): + self._identity = identity self.path = path self._comms = comms self._closed = False self._requests: set[asyncio.Task] = set() - self._identity = comms.registry.entry(session_id).entry.thread.incarnation self._root_identity = comms.root.stat() def _owner_entry(self) -> RegistryEntry: @@ -173,7 +187,7 @@ def _owner_entry(self) -> RegistryEntry: if not same_root: raise OwnerIdentityChangedError("Wire root changed; open a new attachment.") try: - entry = self._comms.registry.entry(self.session_id).entry + entry = self._comms.registry.entry(self._identity.name).entry except UnregisteredThreadError as error: raise OwnerIdentityChangedError( "Thread identity changed; open a new attachment." @@ -187,10 +201,10 @@ def _require_connect_owner(self, owner: OwnerIdentity, process: ProcessIdentity) try: self._owner_entry().require_owner_process(owner, process) except RelationViolationError as error: - raise ConnectionError(f"Cannot attach {self.session_id!r}: {error}") from error + raise ConnectionError(f"Cannot attach {self._identity.name!r}: {error}") from error if not process.alive(): raise ConnectionError( - f"Owner {process.pid} for {self.session_id!r} exited before attachment; " + f"Owner {process.pid} for {self._identity.name!r} exited before attachment; " f"request not sent. Startup output: {self._comms.root / 'diagnostics'}" ) @@ -202,7 +216,7 @@ async def _connect_current(self) -> tuple[asyncio.StreamReader, asyncio.StreamWr entry.status.require_active() process = entry.thread.require_process() except RelationViolationError as error: - raise ConnectionError(f"Cannot attach {self.session_id!r}: {error}") from error + raise ConnectionError(f"Cannot attach {self._identity.name!r}: {error}") from error owner = entry.owner_identity path = socket_path(self._comms.root, process.pid) diff --git a/src/agent_comms/owner_launch.py b/src/agent_comms/owner_launch.py index c9e1ffd22a..9c03529480 100644 --- a/src/agent_comms/owner_launch.py +++ b/src/agent_comms/owner_launch.py @@ -15,8 +15,14 @@ from . import launch_runtime from .native_arguments import ConfiguredBinary, InheritedArguments, LaunchArguments, LaunchBinary, NativeArguments from .registry_document import RegistryEntry, RegistrySnapshot -from .field_codec import AcpSessionId, FieldCodec -from .owner_connection import OwnerConnection, ResultRuntimeRequest, RuntimeRequestContext +from .field_codec import FieldCodec +from .owner_connection import ( + AttachedResultRuntimeRequest, + AttachedRuntimeRequestContext, + OwnerConnection, + ResultRuntimeRequest, + RuntimeRequestContext, +) from .thread_identity import ClaimedIdentity, OwnerIdentity from .tool_result import DeclaredReply from .tool_result import ToolResult @@ -121,8 +127,8 @@ def read(cls, root: Path, thread: Thread) -> HostResources: @dataclass(frozen=True, kw_only=True) -class ReadHostResourcesRuntimeRequest(ResultRuntimeRequest): - async def result(self, ctx: RuntimeRequestContext) -> HostResources: +class ReadHostResourcesRuntimeRequest(AttachedResultRuntimeRequest): + async def result(self, ctx: AttachedRuntimeRequestContext) -> HostResources: owners = sum(len(state.executors()) for state in ctx.server.agent.inputs.drained.values()) def capture() -> HostResources: comms = ctx.server.agent._comms @@ -139,7 +145,7 @@ def capture() -> HostResources: @dataclass(frozen=True, kw_only=True) -class ReadOwnerLaunchRuntimeRequest(ResultRuntimeRequest): +class ReadOwnerLaunchRuntimeRequest(AttachedResultRuntimeRequest): owner: OwnerIdentity def require_owner(self, entry: RegistryEntry) -> Thread: @@ -147,15 +153,15 @@ def require_owner(self, entry: RegistryEntry) -> Thread: raise RelationViolationError("Launch request's owner lease changed") return super().require_owner(entry) - async def result(self, ctx: RuntimeRequestContext) -> RetainedOwnerLaunch: - state = ctx.server.agent.inputs.state(ctx.session_id).owned() + async def result(self, ctx: AttachedRuntimeRequestContext) -> RetainedOwnerLaunch: + state = ctx.server.agent.inputs.state(ctx.session).owned() if state.owner != self.owner: raise RelationViolationError("Launch request names another original session") return state.configuration.launch @dataclass(frozen=True, kw_only=True) -class StartOwnerRuntimeRequest(ResultRuntimeRequest): +class StartOwnerRuntimeRequest(ResultRuntimeRequest[RuntimeRequestContext]): launch: RetainedOwnerLaunch owner: OwnerIdentity @@ -166,7 +172,7 @@ async def bind( raise RelationViolationError("Start request belongs to another host process") if self.thread != self.owner.incarnation.name: raise RelationViolationError("Start request names another owner") - return RuntimeRequestContext(server, reader, client, AcpSessionId(self.thread), self.thread) + return RuntimeRequestContext(server, reader, client, self.thread) async def result(self, ctx: RuntimeRequestContext) -> OwnerAttachedReply: await ctx.server.agent.sessions.start_owner(self.launch, self.owner) @@ -174,7 +180,7 @@ async def result(self, ctx: RuntimeRequestContext) -> OwnerAttachedReply: @dataclass(frozen=True, kw_only=True) -class StopOwnerRuntimeRequest(ResultRuntimeRequest): +class StopOwnerRuntimeRequest(AttachedResultRuntimeRequest): owner: OwnerIdentity claim: ClaimedIdentity @@ -186,8 +192,8 @@ def require_owner(self, entry: RegistryEntry) -> Thread: raise RelationViolationError("Stop request belongs to another host process") return entry.thread - async def result(self, ctx: RuntimeRequestContext) -> OwnerRetiredReply: - return await ctx.server.agent.inputs.state(ctx.session_id).retire_for_stop( + async def result(self, ctx: AttachedRuntimeRequestContext) -> OwnerRetiredReply: + return await ctx.server.agent.inputs.state(ctx.session).retire_for_stop( ctx.server.agent.sessions, self) async def retire(self, sessions: SessionLifecycle, state: OwnerSession) -> OwnerRetiredReply: diff --git a/src/agent_comms/pi_commands.py b/src/agent_comms/pi_commands.py index c74484d587..96e03bdcd8 100644 --- a/src/agent_comms/pi_commands.py +++ b/src/agent_comms/pi_commands.py @@ -15,7 +15,7 @@ from . import agent_events as events from . import turn_failure as failures from .acp_failure import UnrecordedFailureSource -from .agent_backend import InputRequest, StreamingInputPolicy +from .turn_inbox import InputRequest, StreamingInputPolicy from .child_process import BoundedRun from .declared_family import DeclaredFamily, UnknownFamilyMember from .field_codec import ( @@ -33,11 +33,11 @@ from .native_pi import NativePiRpcLaunch from .native_session_reopen import NativeSessionIdentity from .native_tool_call import OwnerToolConnection -from .native_turn_context import NativeContextData from .pi_payloads import ( CompactionData, EmptyData, ModelsData, + NativeContextData, PiOmitted, PiRequestId, PiResponseData, diff --git a/src/agent_comms/pi_native_backend.py b/src/agent_comms/pi_native_backend.py index c730c4824d..77b18a79da 100644 --- a/src/agent_comms/pi_native_backend.py +++ b/src/agent_comms/pi_native_backend.py @@ -19,20 +19,12 @@ from typing import TYPE_CHECKING, ClassVar, Self from agent_comms.native_session_files import ForkedSession, NativeSessionFiles, NativeSessionLocks -from agent_comms.pi_payloads import PiOmitted +from agent_comms.pi_payloads import PiOmitted, PiResponseData -from .agent_backend import ( - CompactionOutcome, - FailedCompaction, - Forks, - InputContent, - Interrupts, - NewThreadBackend, - NothingToCompaction, - PlacedCompaction, - RuntimeLocation, -) +from .agent_backend import CompactionOutcome, FailedCompaction, Forks, Interrupts, NewThreadBackend, NothingToCompaction, PlacedCompaction, RuntimeLocation +from .turn_inbox import InputContent from .coordinator import Coordination +from .field_codec import AcpSessionId from .fresh_private_session import create_fresh_private_session from .native_arguments import NativeArguments from .native_failures import NativePiUnavailable @@ -43,7 +35,7 @@ from .native_pi import NativePiRpcLaunch from .native_session_reopen import NativeSessionIdentity from .native_transcript import NativeTranscript -from .pi_commands import Compact +from .pi_commands import Compact, NativeQuery from .selected_session import SavedSelectedSession from .session_revision import ( SessionObservation, @@ -217,7 +209,7 @@ def __init__( self.observe = observe @classmethod - def for_worker(cls, runner: TurnRunner, session_id: str) -> Self: + def for_worker(cls, runner: TurnRunner, session_id: AcpSessionId) -> Self: return cls( runner.inputs.state(session_id).owned().configuration, runner.comms, partial(runner.observe_selected_preparation, session_id), @@ -260,7 +252,7 @@ async def prepare(self, thread: Thread, *, open_native=None) -> StateData: observe=partial(self.observe, thread), ) - async def inspect(self, thread: Thread, request): + async def inspect(self, thread: Thread, request: NativeQuery) -> PiResponseData: """Answer one Pi query on the idle child, preparing it first when needed.""" return await self.child_state.inspect(self, partial(self.prepare, thread), request) @@ -309,7 +301,7 @@ async def compact(self, thread: Thread, instructions: str | None) -> CompactionO """Ask Pi's own engine to compact through its ``compact`` RPC.""" try: command = Compact(custom_instructions=PiOmitted.from_input(instructions)) - data = await self.inspect(thread, command) + data = (await self.inspect(thread, command)).require_compaction() except NativePiUnavailable as error: return FailedCompaction(str(error)) if data.summary is None: diff --git a/src/agent_comms/pi_payloads.py b/src/agent_comms/pi_payloads.py index 41692fa8ab..7974f6a1d3 100644 --- a/src/agent_comms/pi_payloads.py +++ b/src/agent_comms/pi_payloads.py @@ -8,7 +8,7 @@ import re import typing -from abc import abstractmethod +from abc import ABC, abstractmethod from collections.abc import Iterable, Mapping from dataclasses import dataclass, field, fields, replace from decimal import Decimal @@ -62,8 +62,14 @@ ) from .recorded_time import EventMilliseconds, EventTime, UnrecordedEventTime from .send_result import CorrectionResult, CorrectionUnreported +from .thread_identity import ThreadIncarnation from .thread_model import ThreadModel, UndeclaredModel from .tool_declaration import ToolDeclaration +from .tool_result import DeclaredReply +from .turn_context import ( + ContextTurn, MeasuredNativeSegment, PreviewProvenance, Provenance, + SegmentManifest, TurnContext, +) from .tool_results import NoSentMessage, NoToolDiff, ToolDiff from .transcript_events import ( AssistantTranscript, @@ -1163,6 +1169,13 @@ def token_total(self) -> PiCount: return self.tokens +class SnapshotAttestation(ABC): + """The identity-conflict contract a native snapshot can ask its attestation.""" + + @abstractmethod + def conflicts(self, data: NativeSessionSnapshot) -> bool: ... + + @dataclass(frozen=True) class PiResponseData(ExternalNull, PiPayload, DeclaredFamily, affix="Data"): @@ -1177,13 +1190,21 @@ def require_state(self) -> StateData: """Only Pi's session-state reply answers a state query.""" raise ValueError(f"{type(self).__name__} is not Pi's session state") + def require_compaction(self) -> CompactionData: + """Only Pi's compact reply supplies a compaction result.""" + raise ValueError(f"{type(self).__name__} is not Pi's compaction result") + + def require_context(self) -> NativeContextData: + """Only Pi's context inspection reply supplies native context sources.""" + raise ValueError(f"{type(self).__name__} is not Pi's native context") + def native_updates(self, session: NativeSession) -> Iterable[AgentEvent]: return () def cleared_inputs(self, requested: tuple[InputId, ...]) -> tuple[InputId, ...]: return () - def conflicts_attestation(self, attestation) -> bool: + def conflicts_attestation(self, attestation: SnapshotAttestation) -> bool: return False @classmethod @@ -1191,6 +1212,57 @@ def wire_member(cls, value): return cls if cls is not PiResponseData else cls.member_named(value.get("kind")) +@dataclass(frozen=True) +class NativeContextData(StrictFields, PiResponseData, DeclaredReply): + counter: str + identity: NativeSessionIdentity + segments: tuple[MeasuredNativeSegment, ...] + + def require_context(self) -> NativeContextData: + return self + + def observation(self) -> PreviewProvenance: + """Return the SDK's original observation, without deriving its digest.""" + originals = {observation for segment in self.segments + for source in segment.provenance + for observation in source.preview_observations()} + if len(originals) != 1: + raise ValueError("Native context has no unambiguous current preview observation") + (original,) = originals + self.identity.require_same_session(original.identity) + return original + + def public_source( + self, observation: PreviewProvenance, segment: int, source: Provenance, + ) -> Provenance: + if observation != self.observation(): + raise ValueError("Current native preview changed since the selected observation") + if not 0 <= segment < len(self.segments): + raise ValueError("Source has no selected context segment") + return self.segments[segment].require_source(source) + + def recorded_public_text(self, expected: SegmentManifest) -> str: + self.identity.require_same_session(expected.sources.native_identity()) + originals: dict[str, list[SegmentManifest]] = {} + for original in expected.original_values(): + originals.setdefault(original.sha256, []).append(original) + values: dict[str, list[MeasuredNativeSegment]] = {} + for segment in self.segments: + if not any(segment.matches_recorded(original) + for original in originals.get(segment.sha256, ())): + raise ValueError("Recorded SDK source is outside the selected original value") + values.setdefault(segment.sha256, []).append(segment) + return expected.recorded_public_text(values) + + def for_turn(self, owner: ThreadIncarnation, turn: ContextTurn) -> TurnContext: + return TurnContext(owner, turn, self.segments) + + def require_session_file(self, session_file: str) -> NativeContextData: + if self.identity.session_file != session_file: + raise ValueError("Context inspection belongs to a different selected native session") + return self + + @dataclass(frozen=True) class QueueClearedData(PiResponseData): """Native IDs actually removed, never the texts of possible candidates.""" @@ -1250,7 +1322,7 @@ def conflicts(self, other: NativeSessionSnapshot) -> bool: known, observed = self._known_identity(), other._known_identity() return any(known[key] != observed[key] for key in known.keys() & observed.keys()) - def conflicts_attestation(self, attestation) -> bool: + def conflicts_attestation(self, attestation: SnapshotAttestation) -> bool: return attestation.conflicts(self) @@ -1336,6 +1408,9 @@ class ThinkingLevelsData(PiResponseData): @dataclass(frozen=True) class CompactionData(PiResponseData): + def require_compaction(self) -> CompactionData: + return self + summary: str | None = None usage: Annotated[PiField[PiUsage], WireNullDefault()] = UnreportedUsage() first_kept_entry_id: Annotated[ @@ -1560,13 +1635,13 @@ def validate(cls, calls: type[McpCallPolicy], counts: tuple[int, ...]): ... class ReadyMcpServerState(McpServerState): @classmethod - def validate(cls, calls, counts): + def validate(cls, calls: type[McpCallPolicy], counts: tuple[int, ...]) -> None: calls.require_available() class ErrorMcpServerState(McpServerState): @classmethod - def validate(cls, calls, counts): + def validate(cls, calls: type[McpCallPolicy], counts: tuple[int, ...]) -> None: calls.require_unavailable() if any(counts): raise ValueError("Inactive MCP server counts must be zero") diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 59c21da202..e912ba12ec 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -47,9 +47,8 @@ WireOmitDefault, projected, ) -from .goal_attempts import GoalAttemptStore from .goal_presentation import GoalExecution -from .goal_waits import GoalWaits +from .locked_store import GoalWakeFile from .mentions import MentionCandidate from .message_page import ( NEWEST_PAGE, @@ -875,13 +874,6 @@ def room_ledger_paths(views: HistoryViews) -> tuple[Path, ...]: return RoomLedger.source_paths(views.registry.storage_rule.room_ledger.root) -def goal_execution_paths(views: HistoryViews) -> tuple[Path, ...]: - return ( - *GoalWaits.source_paths(views.root), - *GoalAttemptStore.source_paths(GoalAttemptStore.directory_in(views.root)), - ) - - def relationship_paths(views: HistoryViews) -> tuple[Path, ...]: return (relationships.RelationshipStore.path_in(views.root),) @@ -968,7 +960,7 @@ class WireRevision(ReadState): ] goal_execution: Annotated[ tuple[FileRevision, ...], - *revision_source(goal_execution_paths, *StatusSourced.views()), + *revision_source(lambda views: GoalWakeFile.watched_paths_in(views.root), *StatusSourced.views()), ] relationships: Annotated[ tuple[FileRevision, ...], diff --git a/src/agent_comms/private_path.py b/src/agent_comms/private_path.py index 01c0fe5586..d2c9557a13 100644 --- a/src/agent_comms/private_path.py +++ b/src/agent_comms/private_path.py @@ -155,9 +155,9 @@ def from_file(cls, stream: BinaryIO) -> FileRevision: return cls.from_stat(os.fstat(stream.fileno())) @classmethod - def capture(cls, path: Path) -> FileObservation: + def capture(cls, path: Path, *, follow_symlinks: bool = True) -> FileObservation: try: - return cls.from_stat(path.stat()) + return cls.from_stat(path.stat(follow_symlinks=follow_symlinks)) except FileNotFoundError: return MissingFile() diff --git a/src/agent_comms/private_sidecar.py b/src/agent_comms/private_sidecar.py index ab3321c7b4..3cded7e1bb 100644 --- a/src/agent_comms/private_sidecar.py +++ b/src/agent_comms/private_sidecar.py @@ -32,7 +32,7 @@ from .field_codec import FieldCodec if TYPE_CHECKING: - from .agent_backend import StreamingInputPolicy + from .turn_inbox import StreamingInputPolicy from .private_path import ObservedPath, PrivateFileRole, TrustedAncestorRole from .store_files import ExclusiveLock, StoreOwner diff --git a/src/agent_comms/prompt_state.py b/src/agent_comms/prompt_state.py index 362294be9d..8fcaa749e0 100644 --- a/src/agent_comms/prompt_state.py +++ b/src/agent_comms/prompt_state.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Never from . import turn_failure as failures -from .agent_backend import TurnInboxItem +from .turn_inbox import TurnInboxItem from .native_pi import NativePiInputNotSent if TYPE_CHECKING: @@ -62,11 +62,6 @@ class UnwrittenPrompt(QueuedOperation): def dispatch(self): return UnacknowledgedPrompt() - def refuse(self, inputs, session): - session.output.record_failure(failures.InputMissing("Original input was cleared before send.")) - session.finish_request() - session.rejected_signal.set() - def raise_native_failure(self, error, attestation) -> Never: raise NativePiInputNotSent(error, attestation) from error diff --git a/src/agent_comms/queued_input.py b/src/agent_comms/queued_input.py index 737a0fd587..0f6e9c0471 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -18,10 +18,7 @@ from .acp_extension import QueueItem -from .agent_backend import ( - InputContent, - InputRequest, -) +from .turn_inbox import InputContent, InputRequest from .assignment_states import DeferredAssignment, DeliveredAssignment from .child_process import ProcessIdentity from .context_segments.wake import DeliveredMessageSegment @@ -34,7 +31,7 @@ ) from .coordinator import Coordination from .errors import RelationViolationError -from .field_codec import InputId, GoalId, RootId +from .field_codec import AcpSessionId, InputId, GoalId, RootId from .goals import GoalDeclaration, NoGoal from .image_input import ImageInput from .input_attempt import InputAttempt, ReservedInput @@ -50,7 +47,7 @@ if TYPE_CHECKING: from .acp_extension import QueueItem, QueueScope - from .agent_backend import InputContent, TurnInbox + from .turn_inbox import InputContent, TurnInbox from .bus_publication import Delivered from .comms import Comms from .coordination_tables.assignments import WakeAssignment @@ -174,7 +171,7 @@ def require_capacity(cls, inputs: InputDrain, sources: tuple[TurnInputSource, .. @classmethod @asynccontextmanager async def reserve( - cls, inputs: InputDrain, session_id: str, name: str, *, text: str, prompt: str, + cls, inputs: InputDrain, session_id: AcpSessionId, name: str, *, text: str, prompt: str, echo: bool, images: tuple[ImageInput, ...], controller: Controller | None, input_id: InputId, origin: InputOrigin = UnattributedInputOrigin(), @@ -199,7 +196,7 @@ def capture(): yield InputReservation(item, row, rollback) def place( - self, inputs: InputDrain, session_id: str, + self, inputs: InputDrain, session_id: AcpSessionId, inbox: TurnInbox, content: InputContent, ) -> None: """The accepted input owns transfer into this turn's existing native queue.""" @@ -211,7 +208,7 @@ def place( input_id=self.input_id, content=content, when_busy=source.when_busy, )) - async def dispatch(self, turns: TurnRunner, session_id: str, name: str) -> None: + async def dispatch(self, turns: TurnRunner, session_id: AcpSessionId, name: str) -> None: """Transfer this live acceptance under current authority; never recover disk work.""" inputs = turns.inputs try: @@ -242,10 +239,10 @@ def verify(): acceptance=AcceptedTurn(self.input_id), ) - def is_live(self, inputs: InputDrain, session_id: str) -> bool: + def is_live(self, inputs: InputDrain, session_id: AcpSessionId) -> bool: return inputs.state(session_id).following.get(self.input_id) == self.source() - def require_live_source(self, inputs: InputDrain, session_id: str) -> None: + def require_live_source(self, inputs: InputDrain, session_id: AcpSessionId) -> None: if not self.is_live(inputs, session_id): raise RelationViolationError("Accepted input source changed") @@ -389,7 +386,7 @@ def reserve_pending( @staticmethod async def _restore_unplaced( - inputs: InputDrain, session_id: str, + inputs: InputDrain, session_id: AcpSessionId, reserved: list[tuple[DeliveredInput, InputContent]], ) -> None: # Capture the loop-owned transfer before the first await; a turn may @@ -461,7 +458,7 @@ def _recover_locked( @classmethod async def deliver_pending( - cls, inputs: InputDrain, session_id: str, owner: Thread, bus: MessageBus, + cls, inputs: InputDrain, session_id: AcpSessionId, owner: Thread, bus: MessageBus, root_id: RootId, after_seq: int, ) -> None: """Hand this owner's pending bus messages to its running turn. @@ -504,7 +501,7 @@ def read(store: Coordination): except InputHandoffRefused: break - async def dispatch(self, turns: TurnRunner, session_id: str, name: str) -> None: + async def dispatch(self, turns: TurnRunner, session_id: AcpSessionId, name: str) -> None: raise InputHandoffRefused("A bus message its turn never took wakes its owner instead") def after_clear(self) -> DeliveredInput: @@ -542,7 +539,7 @@ def require_capacity(cls, inputs: InputDrain, sources: tuple[TurnInputSource, .. @classmethod async def run( - cls, inputs: InputDrain, session_id: str, thread_name: str, task: str, *, + cls, inputs: InputDrain, session_id: AcpSessionId, thread_name: str, task: str, *, images: tuple[ImageInput, ...] = (), display_text: str | None = None, input_id: InputId, origin: InputOrigin = UnattributedInputOrigin(), @@ -566,7 +563,7 @@ async def run( await inputs.emit_queue_state(session_id) await item.dispatch(inputs.effects.turns, session_id, thread_name) - async def finish(self, inputs: InputDrain, session_id: str) -> None: + async def finish(self, inputs: InputDrain, session_id: AcpSessionId) -> None: """Retire only this captured original after its dispatch resources join.""" queued = inputs.state(session_id).queued if queued.get(self.input_id) is self: @@ -580,7 +577,7 @@ async def finish(self, inputs: InputDrain, session_id: str) -> None: queued.pop(self.input_id) await inputs.emit_queue_state(session_id) - def is_live(self, inputs: InputDrain, session_id: str) -> bool: + def is_live(self, inputs: InputDrain, session_id: AcpSessionId) -> bool: return inputs.state(session_id).queued.get(self.input_id) is self def after_clear(self) -> InitialInput: diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index b64031fed8..2c1ca45d69 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -44,6 +44,9 @@ def incarnation(self) -> ThreadIncarnation: class RecipientSelection(ABC): """One registry selection owns live, archived and retired recipient behavior.""" + def activity_threads(self) -> tuple[Thread, ...]: + return () + @abstractmethod def activity(self, observations, snapshot): ... @@ -65,6 +68,9 @@ def read_effect(self, reads, message, snapshot, *, document=None): class RegisteredRecipient(KnownRecipient): thread: Thread + def activity_threads(self) -> tuple[Thread, ...]: + return (self.thread,) + def activity(self, observations, snapshot): return self.thread.execution.observe_recipient(observations, snapshot, self.thread) diff --git a/src/agent_comms/registry_store.py b/src/agent_comms/registry_store.py index 32ac6a92e5..08c7378002 100644 --- a/src/agent_comms/registry_store.py +++ b/src/agent_comms/registry_store.py @@ -14,11 +14,11 @@ from pathlib import Path from typing import ClassVar, Self -from .coordination_database import CoordinationStore +from .coordination_database import CoordinationStore, ReadableStore from .coordination_errors import StaleFence from .errors import RelationViolationError from .field_codec import FieldCodec, NullMember, TurnId -from .locked_store import DocumentBytes, LockedStore, RegistryFile +from .locked_store import DocumentBytes, LockedStore, PublishedProperty, RegistryFile from .maintenance_barrier import CarriedDocument from .native_input_owner import RegistryOwner from .private_path import FileObservation, FileRevision, MissingFile @@ -564,6 +564,10 @@ def private_guard_unlocked(self) -> RegistryPrivacy: self.cache.guard = key, guard return guard + @PublishedProperty + def coordinator_reader(self) -> ReadableStore: + return ReadableStore(CoordinationStore.path_in(self.path.parent)) + def _check_guard_unlocked(self) -> RegistryPrivacy: if self.guard_path.exists() or self.guard_path.is_symlink(): return PrivateRegistryGuard.load(self.path) @@ -571,7 +575,7 @@ def _check_guard_unlocked(self) -> RegistryPrivacy: if coordinator.exists() or coordinator.is_symlink(): from .private_runtime_schema import PrivateCoordinatorTable - with CoordinationStore.opened_reader(coordinator) as db: + with self.coordinator_reader.reader_snapshot() as db: if PrivateCoordinatorTable.stored_schema(db): raise RelationViolationError("Native runtime has no registry authority") return PublicRegistry() diff --git a/src/agent_comms/runtime.py b/src/agent_comms/runtime.py index 89dfdde624..f4a2d77367 100644 --- a/src/agent_comms/runtime.py +++ b/src/agent_comms/runtime.py @@ -55,6 +55,7 @@ from .owner_connection import NoControllerToken, OwnerConnection, OwnerIdentityChangedError, socket_path from .owner_connection import RuntimeRequest from .runtime_requests import SubscribeRuntimeRequest +from .thread_identity import ThreadIncarnation from .errors import RelationViolationError from .coordination_errors import CoordinationError, IdentityConflict from .native_failures import NativeRequestFailure @@ -67,7 +68,7 @@ class UnboundClient(SessionUpdateSink): - async def publish_update(self, *, session_id: str, update: BaseModel) -> bool: + async def publish_update(self, *, session_id: AcpSessionId, update: BaseModel) -> bool: raise RuntimeError("An unbound client cannot publish session updates") @@ -121,12 +122,12 @@ def __init__(self, writer: asyncio.StreamWriter): self.token = secrets.token_hex(32) self.pending: dict[str, asyncio.Future[RequestPermissionResponse]] = {} - def require_current(self, authority: AcpSelectedWriteAuthority, session_id: str) -> None: + def require_current(self, authority: AcpSelectedWriteAuthority, session_id: AcpSessionId) -> None: if not authority.agent._runtime.is_controller(session_id, self): raise IdentityConflict("Selected write controller disconnected") async def request_ui_permission( - self, runner: TurnRunner, session_id: str, + self, runner: TurnRunner, session_id: AcpSessionId, request: tuple[ToolCallUpdate, list[PermissionOption]], ) -> dict[str, object] | None: tool_call, options = request @@ -140,7 +141,7 @@ async def request_ui_permission( return None return reply.outcome.model_dump(by_alias=True, exclude_none=True) - def departed(self, runtime: RuntimeServer, session_id: str) -> bool: + def departed(self, runtime: RuntimeServer, session_id: AcpSessionId) -> bool: return not runtime.is_controller(session_id, self) async def send(self, line: OwnerLine) -> None: @@ -208,12 +209,12 @@ def disconnected(self) -> None: ) self.pending.clear() - async def publish_update(self, *, session_id: str, update: BaseModel) -> bool: + async def publish_update(self, *, session_id: AcpSessionId, update: BaseModel) -> bool: await self.send(OwnerUpdate.of(update)) return True -class SessionClients(dict[str, set[SocketClient]]): +class SessionClients(dict[AcpSessionId, set[SocketClient]]): """The subscriber collection owns removal and the last-disconnect event.""" def __init__(self, runtime: RuntimeServer): @@ -289,7 +290,7 @@ async def start(self) -> None: async def publish_update( self, *, - session_id: str, + session_id: AcpSessionId, update: BaseModel, _expected_client: SessionUpdateSink | None = _UNBOUND_PUBLICATION_CLIENT, _expected_thread: str | None = None, @@ -340,12 +341,12 @@ def sockets_bound() -> bool: self.clients.disconnect(socket_client) return delivered - def is_controller(self, session_id: str, controller: SocketClient) -> bool: + def is_controller(self, session_id: AcpSessionId, controller: SocketClient) -> bool: return controller in self.clients.get(session_id, ()) and not controller.writer.is_closing() async def request_permission( self, - session_id: str, + session_id: AcpSessionId, controller: SocketClient, tool_call: dict[str, object], options: list[dict[str, object]], @@ -402,7 +403,7 @@ def retire_session(self, session_id: AcpSessionId) -> None: self.clients.disconnect(client) -def present_session(metadata: dict[str, object], session_id: str) -> dict[str, object]: +def present_session(metadata: dict[str, object], session_id: AcpSessionId) -> dict[str, object]: """Rebind presentation coordinates through their declared owners.""" return { **metadata, @@ -411,7 +412,7 @@ def present_session(metadata: dict[str, object], session_id: str) -> dict[str, o class RuntimeConnection(OwnerConnection): - """ACP attachments select declared request behavior and their controller token.""" + """Owner requests select declared behavior independently of ACP attachment identity.""" @property def controller_token(self) -> str | NoControllerToken: @@ -419,7 +420,7 @@ def controller_token(self) -> str | NoControllerToken: def request_payload(self, action: str, parameters: dict[str, object]) -> dict[str, object]: return RuntimeRequest.member_named(action).proxy_payload( - self.session_id, self.controller_token, parameters, + self._identity.name, self.controller_token, parameters, ) @@ -443,8 +444,12 @@ class OwnerAttachment: class RuntimeProxy(RuntimeConnection): - def __init__(self, agent: CommsAgent, session_id: str, path: Path): - super().__init__(agent._comms, session_id, path) + def __init__( + self, agent: CommsAgent, identity: ThreadIncarnation, + session_id: AcpSessionId, path: Path, + ): + super().__init__(agent._comms, identity, path) + self.session_id = session_id self.agent = agent self.writer: asyncio.StreamWriter | None = None self.task: asyncio.Task[None] | None = None @@ -470,7 +475,7 @@ async def _subscribe_once( try: writer.write( ( - json.dumps(FieldCodec.encode(subscription(thread=self.session_id))) + json.dumps(FieldCodec.encode(subscription(thread=self._identity.name))) + "\n" ).encode() ) @@ -482,8 +487,8 @@ async def _subscribe_once( first = OwnerLine.read(announcement) except MalformedRecord as error: # An owner older than the declared lines writes an undeclared first line. - raise IncompatibleOwnerError(UnannouncedOwner(thread=self.session_id)) from error - first.announced(self.session_id) + raise IncompatibleOwnerError(UnannouncedOwner(thread=self._identity.name)) from error + first.announced(self._identity.name) while line := await reader.readline(): ready = await OwnerLine.read(line).attach(self) if ready is None: @@ -513,7 +518,7 @@ async def present(self, line: OwnerUpdate) -> None: "session_id": self.session_id, "update": {**line.update, "_meta": metadata}, }) await client.publish_update( - session_id=notification.session_id, update=notification.update + session_id=self.session_id, update=notification.update ) def ask(self, request: OwnerPermissionRequest) -> None: diff --git a/src/agent_comms/runtime_requests.py b/src/agent_comms/runtime_requests.py index e27e56237e..9052e2ba06 100644 --- a/src/agent_comms/runtime_requests.py +++ b/src/agent_comms/runtime_requests.py @@ -9,10 +9,9 @@ from .repo import Checkout -import asyncio import json from abc import abstractmethod -from collections.abc import AsyncIterator, Awaitable +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass from functools import partial @@ -28,21 +27,25 @@ ) from .agent_event_updates import AcpEventConsumer +from .acp_request_consumer import AcpRequestConsumer from agent_comms.cli_verbs.correct_annotation import CorrectAnnotationCliCommand -from .command import Command from .context_store import ContextManifestReference -from .coordination_database import CoordinationStore from .coordinator import Coordination -from .declared_family import DeclaredFamily from .errors import RelationViolationError -from .field_codec import AcpSessionId, FieldCodec, WireName, WireValue, GoalId +from .field_codec import FieldCodec, WireName, WireValue, GoalId from .goal_actions import GoalAction from .goal_history import RecordedGoalHistory from .goal_scope import AllGoals, OneGoal from .goals import Goal from .manual_compaction_bridge import compact_context -from .owner_connection import NoControllerToken, OwnerConnection, ResultRuntimeRequest, RuntimeRequest, RuntimeRequestContext -from .owner_protocol import ClientLine, OwnerAnnouncement, OwnerLine, OwnerReady, OwnerResult +from .owner_connection import ( + AttachedResultRuntimeRequest, + AttachedRuntimeRequest, + AttachedRuntimeRequestContext, + NoControllerToken, +) +from .owner_protocol import ClientLine, OwnerAnnouncement, OwnerReady +from .pi_payloads import NativeContextData from .thread_identity import ThreadIncarnation from .thread_presentation import LiveThreadOwnerBinding from .tool_result import DeclaredReply @@ -61,10 +64,8 @@ from .turn_item import PiRequestKey if TYPE_CHECKING: - from .acp_extension import GoalSnapshotReply from .agent_backend import CompactionOutcome from .input_disposition import DeliveryOverview - from .runtime import RuntimeServer, SocketClient @dataclass(frozen=True) @@ -125,7 +126,7 @@ def from_wire(cls, data: list[JsonValue]) -> Self: @dataclass(frozen=True, kw_only=True) -class ProjectRuntimeRequest(ResultRuntimeRequest): +class ProjectRuntimeRequest(AttachedResultRuntimeRequest): """Current project observation through the original native launch binding. This command grants no input or tool execution. A retained native process @@ -157,7 +158,7 @@ def require_owner(self, entry): self.require_original(entry) return super().require_owner(entry) - async def result(self, ctx): + async def result(self, ctx: AttachedRuntimeRequestContext) -> ProjectReply: entry = (await Coordination.run_worker(partial( ctx.server.agent._comms.registry.entry, ctx.name, ))).entry @@ -166,44 +167,44 @@ async def result(self, ctx): @dataclass(frozen=True, kw_only=True) -class SubscribeRuntimeRequest(RuntimeRequest): +class SubscribeRuntimeRequest(AttachedRuntimeRequest): @asynccontextmanager - async def observing(self, ctx: RuntimeRequestContext) -> AsyncIterator[None]: + async def observing(self, ctx: AttachedRuntimeRequestContext) -> AsyncIterator[None]: agent = ctx.server.agent - ctx.server.clients.setdefault(ctx.session_id, set()).add(ctx.client) - await agent.emit_session_identity(ctx.session_id, ctx.name, client=ctx.client) + ctx.server.clients.setdefault(ctx.session, set()).add(ctx.client) + await agent.emit_session_identity(ctx.session, ctx.name, client=ctx.client) async with agent.sessions.transcript.subscribe(self.thread, client=ctx.client): await agent.turns.publish_turn_state( - AcpEventConsumer(agent, ctx.session_id, ctx.client) + AcpEventConsumer(agent, ctx.session, ctx.client) ) - await agent.inputs.emit_input_delivery_changed(ctx.session_id, client=ctx.client) + await agent.inputs.emit_input_delivery_changed(ctx.session, client=ctx.client) yield - async def ready(self, ctx: RuntimeRequestContext) -> None: + async def ready(self, ctx: AttachedRuntimeRequestContext) -> None: await ctx.send(OwnerReady( - ready=await ctx.server.agent.sessions.metadata(ctx.name), + ready=await ctx.server.agent.sessions.metadata(ctx.name, session_id=ctx.session), controller_token=ctx.client.token, )) @asynccontextmanager - async def subscribed(self, ctx: RuntimeRequestContext) -> AsyncIterator[None]: + async def subscribed(self, ctx: AttachedRuntimeRequestContext) -> AsyncIterator[None]: """A load replays history before its response, under its already-known ID.""" async with self.observing(ctx): await self.ready(ctx) yield - async def apply(self, ctx: RuntimeRequestContext) -> None: + async def apply(self, ctx: AttachedRuntimeRequestContext) -> None: agent = ctx.server.agent await ctx.send(OwnerAnnouncement()) async with self.subscribed(ctx): - async with agent.sessions.config.observe_configuration(ctx.session_id, ctx.client): + async with agent.sessions.config.observe_configuration(ctx.session, ctx.client): while line := await ctx.reader.readline(): ClientLine.read(line).answer(ctx.client) class FreshSubscribeRuntimeRequest(SubscribeRuntimeRequest): @asynccontextmanager - async def subscribed(self, ctx: RuntimeRequestContext) -> AsyncIterator[None]: + async def subscribed(self, ctx: AttachedRuntimeRequestContext) -> AsyncIterator[None]: """A new ID is announced before its first publication can reach the ACP attachment.""" await self.ready(ctx) async with self.observing(ctx): @@ -211,7 +212,7 @@ async def subscribed(self, ctx: RuntimeRequestContext) -> AsyncIterator[None]: @dataclass(frozen=True, kw_only=True) -class PromptRuntimeRequest(ResultRuntimeRequest): +class PromptRuntimeRequest(AttachedResultRuntimeRequest): request: PromptRequest controller_token: Annotated[str | NoControllerToken, WireName("controllerToken")] = ( NoControllerToken() @@ -225,115 +226,115 @@ def proxy_payload( thread, controller_token, {"controllerToken": controller_token, **parameters} ) - async def result(self, ctx: RuntimeRequestContext) -> PromptReply: + async def result(self, ctx: AttachedRuntimeRequestContext) -> PromptReply: controller = next( ( subscriber - for subscriber in ctx.server.clients.get(ctx.session_id, ()) + for subscriber in ctx.server.clients.get(ctx.session, ()) if subscriber.token == self.controller_token and not subscriber.writer.is_closing() ), None, ) token = ctx.server.controller.set(controller) try: - result = await ctx.server.agent.receive_prompt( - self.request.model_copy(update={"session_id": ctx.session_id}) - ) + with ctx.server.agent.refusals(): + result = await AcpRequestConsumer(ctx.server.agent, self.request, ctx.session).run() finally: ctx.server.controller.reset(token) return PromptReply(result) @dataclass(frozen=True, kw_only=True) -class CancelRuntimeRequest(ResultRuntimeRequest): - async def result(self, ctx: RuntimeRequestContext) -> CancelReply: - await ctx.server.agent.turns.cancel(ctx.session_id) +class CancelRuntimeRequest(AttachedResultRuntimeRequest): + async def result(self, ctx: AttachedRuntimeRequestContext) -> CancelReply: + await ctx.server.agent.turns.cancel(ctx.session) return CancelReply() @dataclass(frozen=True, kw_only=True) -class SetConfigOptionRuntimeRequest(ResultRuntimeRequest): +class SetConfigOptionRuntimeRequest(AttachedResultRuntimeRequest): config_id: str value: str | bool - async def result(self, ctx: RuntimeRequestContext) -> ConfigOptionReply: + async def result(self, ctx: AttachedRuntimeRequestContext) -> ConfigOptionReply: result = await ctx.server.agent.sessions.config.set_option( - self.config_id, ctx.session_id, self.value + self.config_id, ctx.session, self.value ) return ConfigOptionReply(result) @dataclass(frozen=True, kw_only=True) -class CompactRuntimeRequest(ResultRuntimeRequest): +class CompactRuntimeRequest(AttachedResultRuntimeRequest): instructions: str | None = None - async def result(self, ctx: RuntimeRequestContext) -> CompactionOutcome: - return await compact_context(ctx.server.agent.turns, ctx.session_id, self.instructions) + 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(ResultRuntimeRequest): +class InputDispositionsRuntimeRequest(AttachedResultRuntimeRequest): include_history: bool = False - async def result(self, ctx: RuntimeRequestContext) -> DeliveryOverview: + 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_id), + scope=await ctx.server.agent.inputs.delivery_scope(ctx.session), )) @dataclass(frozen=True, kw_only=True) -class DismissHistoricalInputsRuntimeRequest(ResultRuntimeRequest): - async def result(self, ctx: RuntimeRequestContext) -> DeliveryOverview: - result = await Coordination.run_worker(partial(ctx.server.agent._comms.goals.dismiss_historical_inputs, - ctx.name, scope=await ctx.server.agent.inputs.delivery_scope(ctx.session_id) +class DismissHistoricalInputsRuntimeRequest(AttachedResultRuntimeRequest): + async def result(self, ctx: AttachedRuntimeRequestContext) -> DeliveryOverview: + result = await Coordination.run_worker(partial( + ctx.server.agent._comms.goals.dismiss_historical_inputs, + ctx.name, scope=await ctx.server.agent.inputs.delivery_scope(ctx.session), )) - await ctx.server.agent.inputs.emit_input_delivery_changed(ctx.session_id) + await ctx.server.agent.inputs.emit_input_delivery_changed(ctx.session) return result @dataclass(frozen=True, kw_only=True) -class GoalHistoryRuntimeRequest(ResultRuntimeRequest): +class GoalHistoryRuntimeRequest(AttachedResultRuntimeRequest): goal_id: OneGoal | AllGoals = AllGoals() - async def result(self, ctx: RuntimeRequestContext) -> GoalHistoryReply: + async def result(self, ctx: AttachedRuntimeRequestContext) -> GoalHistoryReply: return await Coordination.run_worker(partial( ctx.server.agent._comms.goals.recorded_history, GoalHistoryReply, ctx.name, self.goal_id, )) -class GoalSnapshotResultRuntimeRequest(ResultRuntimeRequest): +class GoalSnapshotResultRuntimeRequest(AttachedResultRuntimeRequest): @abstractmethod - async def change(self, ctx: RuntimeRequestContext) -> None: ... + async def change(self, ctx: AttachedRuntimeRequestContext) -> None: ... - async def result(self, ctx: RuntimeRequestContext) -> GoalSnapshotReply: + 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_id, + 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: RuntimeRequestContext) -> None: + async def change(self, ctx: AttachedRuntimeRequestContext) -> None: pass @dataclass(frozen=True, kw_only=True) -class GoalRevisionRuntimeRequest(ResultRuntimeRequest): +class GoalRevisionRuntimeRequest(AttachedResultRuntimeRequest): goal_id: GoalId expected_revision: int @dataclass(frozen=True, kw_only=True) -class GoalTextRuntimeRequest(ResultRuntimeRequest): +class GoalTextRuntimeRequest(AttachedResultRuntimeRequest): text: str = "" def __post_init__(self) -> None: @@ -345,9 +346,9 @@ def __post_init__(self) -> None: class EditGoalRuntimeRequest( GoalTextRuntimeRequest, GoalRevisionRuntimeRequest, GoalSnapshotResultRuntimeRequest ): - async def change(self, ctx: RuntimeRequestContext) -> None: + async def change(self, ctx: AttachedRuntimeRequestContext) -> None: await ctx.server.agent.turns.goals.edit_goal( - ctx.session_id, self.goal_id, self.expected_revision, self.text + ctx.session, self.goal_id, self.expected_revision, self.text ) @@ -355,37 +356,37 @@ async def change(self, ctx: RuntimeRequestContext) -> None: class UpdateGoalRuntimeRequest(GoalRevisionRuntimeRequest, GoalSnapshotResultRuntimeRequest): status: type[GoalAction] - async def change(self, ctx: RuntimeRequestContext) -> None: + async def change(self, ctx: AttachedRuntimeRequestContext) -> None: await ctx.server.agent.turns.goals.update_goal( - ctx.session_id, self.status, self.goal_id, self.expected_revision + ctx.session, self.status, self.goal_id, self.expected_revision ) @dataclass(frozen=True, kw_only=True) class RetryGoalRuntimeRequest(GoalRevisionRuntimeRequest): - async def result(self, ctx: RuntimeRequestContext) -> GoalReply: + async def result(self, ctx: AttachedRuntimeRequestContext) -> GoalReply: goal = await ctx.server.agent.turns.goals.retry_goal( - ctx.session_id, self.goal_id, self.expected_revision + ctx.session, self.goal_id, self.expected_revision ) return GoalReply(goal) @dataclass(frozen=True, kw_only=True) class SetGoalRuntimeRequest(GoalTextRuntimeRequest): - async def result(self, ctx: RuntimeRequestContext) -> GoalReply: - goal = await ctx.server.agent.turns.goals.set_goal(ctx.session_id, self.text) + async def result(self, ctx: AttachedRuntimeRequestContext) -> GoalReply: + goal = await ctx.server.agent.turns.goals.set_goal(ctx.session, self.text) return GoalReply(goal) @dataclass(frozen=True, kw_only=True) -class ContextRuntimeRequest(ResultRuntimeRequest): - async def inspect(self, ctx): +class ContextRuntimeRequest(AttachedResultRuntimeRequest): + async def inspect(self, ctx: AttachedRuntimeRequestContext) -> NativeContextData: agent = ctx.server.agent owner = await Coordination.run_worker(partial(agent._comms.registry.require, ctx.name)) - context = await agent.turns.inspect_context(ctx.session_id, owner) + context = await agent.turns.inspect_context(ctx.session, owner) return context.require_session_file(owner.runtime.path) - async def result(self, ctx): + async def result(self, ctx: AttachedRuntimeRequestContext): return await self.inspect(ctx) @@ -395,26 +396,30 @@ class ContextSourceRuntimeRequest(ContextRuntimeRequest): segment: int source: Provenance - async def result(self, ctx): + def read_source( + self, ctx: AttachedRuntimeRequestContext, context: NativeContextData, + ) -> ContextSourceText: + source = context.public_source(self.observation, self.segment, self.source) + return ContextSourceText( + source.public_description(), source.public_text(ctx.server.agent._comms), + ) + + async def result(self, ctx: AttachedRuntimeRequestContext) -> ContextSourceText: context = await self.inspect(ctx) - text = await Coordination.run_worker(partial( - context.public_source_text, ctx.server.agent._comms, - self.observation, self.segment, self.source, - )) - return text + return await Coordination.run_worker(partial(self.read_source, ctx, context)) @dataclass(frozen=True, kw_only=True) -class ContextCoreRuntimeRequest(ResultRuntimeRequest): - def read_context(self, ctx): +class ContextCoreRuntimeRequest(AttachedResultRuntimeRequest): + def read_context(self, ctx: AttachedRuntimeRequestContext): comms = ctx.server.agent._comms owner = self.require_owner(comms.registry.entry(ctx.name).entry) return TurnContext.for_inspection(comms, owner) - async def inspect(self, ctx): + async def inspect(self, ctx: AttachedRuntimeRequestContext): return await Coordination.run_worker(partial(self.read_context, ctx)) - async def result(self, ctx): + async def result(self, ctx: AttachedRuntimeRequestContext): return await self.inspect(ctx) @@ -425,28 +430,28 @@ class ContextCoreSourceRuntimeRequest(ContextCoreRuntimeRequest): manifest: SegmentManifest source: Provenance - def read_source(self, ctx): + def read_source(self, ctx: AttachedRuntimeRequestContext): context = self.read_context(ctx) return context.public_source_text(ctx.server.agent._comms, self.owner, self.segment, self.manifest, self.source) - async def result(self, ctx): + async def result(self, ctx: AttachedRuntimeRequestContext): return await Coordination.run_worker(partial(self.read_source, ctx)) @dataclass(frozen=True, kw_only=True) -class RecordedContextRuntimeRequest(ResultRuntimeRequest): +class RecordedContextRuntimeRequest(AttachedResultRuntimeRequest): turn: RecordedContextTurn request_id: PiRequestKey record: ContextManifestReference segment: int - def read_manifest(self, ctx): + def read_manifest(self, ctx: AttachedRuntimeRequestContext): comms = ctx.server.agent._comms manifest = self.record.read(comms.contexts, ctx.name, comms.registry) return ContextManifest.for_request((manifest,), self.turn, self.request_id) - async def manifest(self, ctx): + async def manifest(self, ctx: AttachedRuntimeRequestContext): return await Coordination.run_worker(partial(self.read_manifest, ctx)) @@ -454,7 +459,7 @@ async def manifest(self, ctx): class ContextReferenceRuntimeRequest(RecordedContextRuntimeRequest): source: Provenance - async def result(self, ctx): + async def result(self, ctx: AttachedRuntimeRequestContext): manifest = await self.manifest(ctx) text = await Coordination.run_worker(partial( manifest.public_source_text, ctx.server.agent._comms, self.segment, self.source, @@ -466,17 +471,17 @@ async def result(self, ctx): class ContextRecordedSegmentRuntimeRequest(RecordedContextRuntimeRequest): contributors: tuple[int, ...] = () - async def selected_segment(self, ctx): + async def selected_segment(self, ctx: AttachedRuntimeRequestContext): manifest = await self.manifest(ctx) return manifest.selected_segment(self.segment, self.contributors) - async def result(self, ctx): + async def result(self, ctx: AttachedRuntimeRequestContext): segment = await self.selected_segment(ctx) agent = ctx.server.agent owner = await Coordination.run_worker(partial(agent._comms.registry.require, ctx.name)) async def read_reference(original): - return await agent.turns.inspect_context_segment(ctx.session_id, owner, original) + return await agent.turns.inspect_context_segment(ctx.session, owner, original) text = await segment.public_text(read_reference) return ContextSourceText(segment.public_description(), text) @@ -486,10 +491,10 @@ async def read_reference(original): class ContextAnnotationsRuntimeRequest(ContextRecordedSegmentRuntimeRequest): classifier: ClassifierVersion - async def result(self, ctx): + async def result(self, ctx: AttachedRuntimeRequestContext): segment = await self.selected_segment(ctx) labels = await Coordination.run_worker(partial(WorkingMemoryAnnotations.for_segment, - CoordinationStore.path_in(ctx.server.agent._comms.root), + ctx.server.agent._comms.registry.store.coordinator_reader, segment, self.classifier)) return AnnotationsReply(labels) @@ -500,7 +505,7 @@ class ContextAnnotationCorrectionRuntimeRequest(ContextRecordedSegmentRuntimeReq answer: type[SpanAnswer] worktree: Checkout - async def result(self, ctx): + async def result(self, ctx: AttachedRuntimeRequestContext): segment = await self.selected_segment(ctx) if not segment.contains_span(self.label.span): raise ValueError("Correction belongs to another original context source") diff --git a/src/agent_comms/schedule_rules.py b/src/agent_comms/schedule_rules.py index c8f08d7e7d..3c0413da36 100644 --- a/src/agent_comms/schedule_rules.py +++ b/src/agent_comms/schedule_rules.py @@ -12,6 +12,7 @@ from .drain_failures import drain_failures from .activity import FailedWakeDiagnostic from .errors import RelationViolationError +from .field_codec import AcpSessionId from .maintenance_barrier import MaintenanceStatus from .reservation_rules import ReservationRule, RuleCheck, ReservationViolationError from .routing import ScheduledTurn @@ -30,19 +31,19 @@ class WakeOutcome(DeclaredFamily, TurnOutcome): """A completed wake owns whether observation should run again.""" @abstractmethod - def settle(self, observer: Callable[[str], None], session_id: str) -> None: ... + def settle(self, observer: Callable[[AcpSessionId], None], session_id: AcpSessionId) -> None: ... @dataclass(frozen=True) class WakeHeld(WakeOutcome): hold: MaintenanceStatus - def settle(self, observer: Callable[[str], None], session_id: str) -> None: + def settle(self, observer: Callable[[AcpSessionId], None], session_id: AcpSessionId) -> None: """Maintenance reopening supplies the next wake.""" class WakeDue(WakeOutcome): - def settle(self, observer: Callable[[str], None], session_id: str) -> None: + def settle(self, observer: Callable[[AcpSessionId], None], session_id: AcpSessionId) -> None: observer(session_id) @@ -56,7 +57,7 @@ class BackgroundWake(RuleCheck): @dataclass(frozen=True, kw_only=True) class SessionScheduleCheck(RuleCheck): - session_id: str + session_id: AcpSessionId inputs: "InputDrain" diff --git a/src/agent_comms/send_outcome.py b/src/agent_comms/send_outcome.py index 0bdece9d64..6951ec4046 100644 --- a/src/agent_comms/send_outcome.py +++ b/src/agent_comms/send_outcome.py @@ -11,7 +11,7 @@ from .field_codec import InputId if TYPE_CHECKING: - from .agent_backend import TurnInbox, TurnInboxItem + from .turn_inbox import TurnInbox, TurnInboxItem from .backend import NativeSession from .turn_inputs import InputForwarding @@ -65,7 +65,7 @@ def apply(self, inputs: InputForwarding, session: NativeSession, class RefusedSend(SendOutcome): def apply(self, inputs: InputForwarding, session: NativeSession, item: TurnInboxItem, write: Callable[[], None]) -> Awaitable[bool]: - item.refuse(inputs, session) + session.refuse(item) return asyncio.sleep(0, result=True) diff --git a/src/agent_comms/session_config_options.py b/src/agent_comms/session_config_options.py index aa666aa367..70b3c010c2 100644 --- a/src/agent_comms/session_config_options.py +++ b/src/agent_comms/session_config_options.py @@ -13,13 +13,13 @@ from acp.schema import (ConfigOptionUpdate, SessionConfigOptionSelect, SessionConfigSelectOption, SetSessionConfigOptionResponse) -from .agent_backend import UseSettings +from .turn_inbox import UseSettings from .bus import Bus from .child_process import join_retirement from .config_options import (CatalogConfigOption, ConfigOptions, InvalidConfigChoice) from .coordinator import Coordination -from .field_codec import FieldCodec +from .field_codec import AcpSessionId, FieldCodec from .native_failures import NativePiUnavailable from .threads import ChosenModel, NextTurnConfiguration from .thread_model import CatalogSelection @@ -48,13 +48,13 @@ def __init__( ): self.comms, self.runtime, self.sessions, self.effects = comms, runtime, sessions, effects self.catalog_publish_lock = asyncio.Lock() - self.publications: dict[str, ConfigOptionUpdate] = {} - self._publication_sessions: set[str] = set() + self.publications: dict[AcpSessionId, ConfigOptionUpdate] = {} + self._publication_sessions: set[AcpSessionId] = set() self._tasks: set[asyncio.Task[None]] = set() self.observer = self.runtime.background(self._observe_configuration()) # The stored settings each session's owner last saw, to notice a change. # The model and thinking level each running turn currently uses. - self.settings_in_use: dict[str, UseSettings] = {} + self.settings_in_use: dict[AcpSessionId, UseSettings] = {} async def describe(self, option: CatalogConfigOption, thread: CatalogSelection) -> SessionConfigOptionSelect: choices = await option.display_choices(thread) @@ -79,7 +79,7 @@ async def options( for member in CatalogConfigOption.members_with(CatalogConfigOption)] async def _publish( - self, session_id: str, + self, session_id: AcpSessionId, options: list[SessionConfigOptionSelect | SessionConfigOptionBoolean], ) -> None: """Publish within the configuration owner's acquisition lifetime.""" @@ -94,7 +94,7 @@ async def _publish( self.publications[session_id] = update async def set_option( - self, config_id: str, session_id: str, value: str | bool + self, config_id: str, session_id: AcpSessionId, value: str | bool ) -> SetSessionConfigOptionResponse: try: member = CatalogConfigOption.member_named(config_id) @@ -145,10 +145,10 @@ async def configure_thread( def stored_settings(thread: Thread) -> UseSettings: return UseSettings(thread.model, thread.thinking_level) - def turn_started(self, session_id: str, thread: Thread) -> None: + def turn_started(self, session_id: AcpSessionId, thread: Thread) -> None: self.settings_in_use.setdefault(session_id, self.stored_settings(thread)) - async def use_stored_settings(self, session_id: str, name: str) -> None: + async def use_stored_settings(self, session_id: AcpSessionId, name: str) -> None: """A running turn switches at its next model call, whichever process stored the change.""" thread = await Coordination.run_worker(partial(self.comms.registry.require, name)) settings = self.stored_settings(thread) @@ -159,13 +159,13 @@ async def use_stored_settings(self, session_id: str, name: str) -> None: self.settings_in_use[session_id] = settings turn.send(settings) - async def apply_stored_settings(self, session_id: str) -> None: + async def apply_stored_settings(self, session_id: AcpSessionId) -> None: """Apply execution state without discovering presentation catalogs.""" name = await self.sessions.sync_identity(session_id) - await self.effects.turns.goals.sync_goal_execution(session_id, name) + await self.effects.turns.goals.sync_goal_execution(session_id) await self.use_stored_settings(session_id, name) - def start_menu(self, session_id: str, client: SessionUpdateSink) -> asyncio.Task[None]: + def start_menu(self, session_id: AcpSessionId, client: SessionUpdateSink) -> asyncio.Task[None]: """Retain initial publication until it finishes or its consumer retires.""" self._publication_sessions.add(session_id) task = self.runtime.background(self.send_menu(client, session_id)) @@ -175,7 +175,7 @@ def start_menu(self, session_id: str, client: SessionUpdateSink) -> asyncio.Task @asynccontextmanager async def observe_configuration( - self, session_id: str, client: SessionUpdateSink, + self, session_id: AcpSessionId, client: SessionUpdateSink, ) -> AsyncIterator[None]: """An attached socket owns its initial catalog wait through disconnect.""" task = self.start_menu(session_id, client) @@ -190,7 +190,7 @@ async def retire_observations(self, tasks: Iterable[asyncio.Task[None]]) -> None task.cancel() await join_retirement(asyncio.gather(*pending, return_exceptions=True)) - async def send_menu(self, client: SessionUpdateSink, session_id: str) -> None: + async def send_menu(self, client: SessionUpdateSink, session_id: AcpSessionId) -> None: options = await self.options_for_session(session_id) async with self.catalog_publish_lock: update = ConfigOptionUpdate( @@ -200,7 +200,7 @@ async def send_menu(self, client: SessionUpdateSink, session_id: str) -> None: self.publications[session_id] = update async def options_for_session( - self, session_id: str, + self, session_id: AcpSessionId, ) -> list[SessionConfigOptionSelect | SessionConfigOptionBoolean]: name = self.sessions.require(session_id) return await self.options( @@ -208,7 +208,7 @@ async def options_for_session( await Coordination.run_worker(partial(self.comms.threads.running_selection, name)), ) - def stop_observing_configuration(self, session_id: str) -> None: + def stop_observing_configuration(self, session_id: AcpSessionId) -> None: self._publication_sessions.discard(session_id) self.publications.pop(session_id, None) @@ -224,7 +224,7 @@ async def close(self) -> None: self._publication_sessions.clear() async def publish_configuration( - self, session_id: str, + self, session_id: AcpSessionId, ) -> list[SessionConfigOptionSelect | SessionConfigOptionBoolean]: options = await self.options_for_session(session_id) async with self.catalog_publish_lock: diff --git a/src/agent_comms/session_effects.py b/src/agent_comms/session_effects.py index 72959014c5..9787f64f40 100644 --- a/src/agent_comms/session_effects.py +++ b/src/agent_comms/session_effects.py @@ -5,6 +5,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING +from .field_codec import AcpSessionId from .runtime import RuntimeProxy from .threads import Thread @@ -20,7 +21,7 @@ class SessionEffects(ABC): turns: TurnRunner @abstractmethod - def _create_runtime_proxy(self, thread: Thread, session_id: str) -> RuntimeProxy: ... + def _create_runtime_proxy(self, thread: Thread, session_id: AcpSessionId) -> RuntimeProxy: ... @abstractmethod def _debug_log(self, message: str) -> None: ... diff --git a/src/agent_comms/session_lifecycle.py b/src/agent_comms/session_lifecycle.py index 36f84f3dd2..91f7421057 100644 --- a/src/agent_comms/session_lifecycle.py +++ b/src/agent_comms/session_lifecycle.py @@ -91,7 +91,7 @@ def __init__( self.runtime, self.runtime_enabled, self.effects = runtime, runtime_enabled, effects self.published: dict[AcpSessionId, PublishedSession] = {} self.client: SdkSessionConnection | None = None - self.proxies: dict[str, RuntimeProxy | IncompatibleOwner] = {} + self.proxies: dict[AcpSessionId, RuntimeProxy | IncompatibleOwner] = {} self._attachment_lock = asyncio.Lock() self._identity_lock = asyncio.Lock() self.transcript = TranscriptReplay(comms) @@ -132,19 +132,19 @@ async def initialize( def session_capabilities(self) -> SessionCapabilities: return SessionCapabilities() - async def close_session(self, session_id: str) -> CloseSessionResponse: + async def close_session(self, session_id: AcpSessionId) -> CloseSessionResponse: # An executing owner is not an attachment. Its turn/input retirement # remains with shutdown, rather than treating tab close as owner stop. raise Refused.method_not_found("session/close") - def require(self, session_id: str) -> str: + def require(self, session_id: AcpSessionId) -> str: if session_id not in self.published: raise Refused.invalid_params({"reason": f"Unknown sessionId: {session_id!r}"}) - return self.published[AcpSessionId(session_id)].name + return self.published[session_id].name - def binds(self, session_id: str, name: object) -> bool: + def binds(self, session_id: AcpSessionId, name: object) -> bool: """Whether this session is published as the named thread.""" - return session_id in self.published and self.published[AcpSessionId(session_id)].name == name + return session_id in self.published and self.published[session_id].name == name def require_owned_session(self, entry: RegistryEntry) -> AcpSessionId: """Resolve an original loaded resource; registration grants no attachment.""" @@ -188,7 +188,7 @@ def acquire_declared_thread(self, thread: Thread) -> Thread: """An owning ACP lifecycle acquires its calling process after publication.""" return self.comms.owners.acquire_thread(thread.name) - def validated_thread(self, cwd: Checkout, session_id: str) -> Thread: + def validated_thread(self, cwd: Checkout, session_id: AcpSessionId) -> Thread: thread = self.comms.registry.require(session_id) thread.execution.require_native() if not thread.contains_worktree(cwd, self.comms.registry.storage_rule): @@ -197,11 +197,11 @@ def validated_thread(self, cwd: Checkout, session_id: str) -> Thread: ) return thread - async def bind_owned(self, thread: Thread, session_id: str) -> None: + async def bind_owned(self, thread: Thread, session_id: AcpSessionId) -> None: owner = (await Coordination.run_worker(partial( self.comms.registry.entry, thread.name, ))).entry.owner_identity - await self.bind_owner(thread, AcpSessionId(session_id), self.effects.inputs.configuration, owner) + await self.bind_owner(thread, session_id, self.effects.inputs.configuration, owner) async def bind_owner( self, thread: Thread, session_id: AcpSessionId, @@ -209,8 +209,10 @@ async def bind_owner( ) -> OwnerSession: async with self._attachment_lock: await self.retire_proxy(session_id) - state = self.effects.inputs.acquire_owner(session_id, configuration, owner) await Coordination.run_worker(PiRedeal.of(self.comms, thread).start) + # Acquisition starts the authentication producer. Publish its session + # in the same loop turn, before any await can let that producer read it. + state = self.effects.inputs.acquire_owner(session_id, configuration, owner) self.published[session_id] = PublishedSession.of(thread) if self.runtime_enabled: await self.runtime.start() @@ -220,19 +222,20 @@ async def new_session(self, request: NewSessionRequest) -> NewSessionResponse: self.reject_foreign_mcp(request) await Coordination.run_worker(self.comms.owners.require_private_launch) thread = await Coordination.run_worker(partial(self.declare_thread, request.cwd)) - await self.bind_owned(thread, thread.name) - self.effects.inputs.ensure_live_drain(thread.name) + session_id = AcpSessionId(thread.name) + await self.bind_owned(thread, session_id) + self.effects.inputs.ensure_live_drain(session_id) response = NewSessionResponse( - session_id=thread.name, - field_meta=await self.metadata(thread.name, session_id=thread.name), + session_id=session_id, + field_meta=await self.metadata(thread.name, session_id=session_id), ) - self.config.start_menu(thread.name, self.runtime) + self.config.start_menu(session_id, self.runtime) return response async def load_session(self, request: LoadSessionRequest) -> LoadSessionResponse: self.reject_foreign_mcp(request) - session_id = request.session_id - thread = await self._acquire_loaded(FieldCodec.decode(Checkout, request.cwd), request.session_id) + session_id = FieldCodec.decode(AcpSessionId, request.session_id) + thread = await self._acquire_loaded(FieldCodec.decode(Checkout, request.cwd), session_id) if not thread.process_identity.runs_here(): return await self.attach_owner(thread, session_id) await self._bind_loaded(thread, session_id) @@ -258,14 +261,14 @@ async def start_owner(self, launch: RetainedOwnerLaunch, owner: OwnerIdentity) - session_id = AcpSessionId(thread.name) state = await self.bind_owner( thread, session_id, ConfigOptions(launch, self.comms.route), owner) - self.effects.inputs.ensure_live_drain(str(session_id)) + self.effects.inputs.ensure_live_drain(session_id) key = selected.take_startup_input(launch.environment) if key: self.runtime.background(self.run_startup_input(state, InputKeyText.decode(key))) async def run_startup_input(self, state: OwnerSession, key: InputKey) -> None: """Consume only the explicitly authorized first input; never scan or replay.""" - session_id = str(state.identifier) + session_id = state.identifier async with state.turn_lock: async with state.own_turn_task(): def capture() -> str: @@ -307,7 +310,7 @@ async def retire_owner(self, state: OwnerSession, expected: OwnerIdentity, claim self.effects.inputs.end_session(state.identifier) state.retired.set_result(None) - async def _acquire_loaded(self, cwd: Checkout, session_id: str) -> Thread: + async def _acquire_loaded(self, cwd: Checkout, session_id: AcpSessionId) -> Thread: await Coordination.run_worker(self.comms.owners.require_private_launch) thread = await Coordination.run_worker(partial(self.validated_thread, cwd, session_id)) if not ( @@ -321,12 +324,12 @@ async def _acquire_loaded(self, cwd: Checkout, session_id: str) -> Thread: )) return thread - async def _bind_loaded(self, thread: Thread, session_id: str) -> None: + async def _bind_loaded(self, thread: Thread, session_id: AcpSessionId) -> None: await self.bind_owned(thread, session_id) await self.effects.inputs.emit_input_delivery_changed(session_id) async def attach_owner( - self, thread: Thread, session_id: str, + self, thread: Thread, session_id: AcpSessionId, subscription: type[SubscribeRuntimeRequest] = SubscribeRuntimeRequest, ) -> LoadSessionResponse: async with self._attachment_lock: @@ -360,7 +363,7 @@ async def attach_owner( field_meta={**metadata, **ExistingSessionLoadPlan(binding).metadata()}, ) - async def sync_identity(self, session_id: str) -> str: + async def sync_identity(self, session_id: AcpSessionId) -> str: """Publish each observed identity generation once across concurrent consumers.""" async with self._identity_lock: cached_name = self.require(session_id) @@ -376,14 +379,14 @@ async def sync_identity(self, session_id: str) -> str: ): await self.effects.turns.close_idle_backend(session_id, thread) current = PublishedSession.of(thread) - if self.published[AcpSessionId(session_id)] != current: + if self.published[session_id] != current: await self.publish_metadata(session_id, title=thread.title) - self.published[AcpSessionId(session_id)] = current + self.published[session_id] = current return name @boundary_decoder async def publish_metadata( - self, session_id: str, *, title: ThreadText = NoThreadText(), + self, session_id: AcpSessionId, *, title: ThreadText = NoThreadText(), ) -> None: """Encode the thread's title once for the ACP metadata boundary.""" await self.runtime.publish_update( @@ -396,7 +399,7 @@ async def publish_metadata( ) async def observe_native_configuration( - self, session_id: str, thread_name: str, event: events.NativeMetadataObservation + self, session_id: AcpSessionId, thread_name: str, event: events.NativeMetadataObservation ) -> None: """Initialize only unset configuration from its actual native producer.""" await Coordination.run_worker(partial( @@ -411,17 +414,17 @@ def configuration_updates( snapshot = GoalChangedUpdate.from_snapshot(self.comms.goals.goal_snapshot(thread_name)) return self.comms.configuration.answer(thread_name), snapshot - async def metadata(self, thread_name: str, *, session_id: str | None = None) -> dict[str, JsonValue]: + async def metadata(self, thread_name: str, *, session_id: AcpSessionId) -> dict[str, JsonValue]: configuration = await Coordination.run_worker(partial( self.configuration_updates, thread_name, )) return encode_updates( *configuration, - await self.effects.inputs.queue_state(session_id or thread_name), - *await self.effects.cursors.trusted_metadata(thread_name, session_id or thread_name), + await self.effects.inputs.queue_state(session_id), + *await self.effects.cursors.trusted_metadata(thread_name, session_id), ) - async def retire_proxy(self, session_id: str) -> None: + async def retire_proxy(self, session_id: AcpSessionId) -> None: """Keep the proxy registered until its reader is closed and awaited.""" proxy = self.proxies.get(session_id) if proxy is not None: @@ -479,7 +482,7 @@ def session_capabilities(self) -> SessionCapabilities: self.effects.use_unstable_protocol ) else super().session_capabilities() - async def close_session(self, session_id: str) -> CloseSessionResponse: + async def close_session(self, session_id: AcpSessionId) -> CloseSessionResponse: if not self.effects.use_unstable_protocol: raise Refused.method_not_found("session/close") async with self._attachment_lock: @@ -495,22 +498,23 @@ def acquire_declared_thread(self, thread: Thread) -> Thread: async def new_session(self, request: NewSessionRequest) -> NewSessionResponse: self.reject_foreign_mcp(request) thread = await Coordination.run_worker(partial(self.declare_thread, request.cwd)) + session_id = AcpSessionId(thread.name) loaded = await self._attach_requested_session( thread, - thread.name, + session_id, (request.field_meta or {}).get("agentCommsLoad"), FreshSubscribeRuntimeRequest, ) return NewSessionResponse( - session_id=thread.name, + session_id=session_id, field_meta=loaded.field_meta, ) async def load_session(self, request: LoadSessionRequest) -> LoadSessionResponse: self.reject_foreign_mcp(request) - session_id = request.session_id + session_id = FieldCodec.decode(AcpSessionId, request.session_id) thread = await Coordination.run_worker( - partial(self.validated_thread, FieldCodec.decode(Checkout, request.cwd), request.session_id) + partial(self.validated_thread, FieldCodec.decode(Checkout, request.cwd), session_id) ) return await self._attach_requested_session( thread, @@ -520,7 +524,7 @@ async def load_session(self, request: LoadSessionRequest) -> LoadSessionResponse ) async def _attach_requested_session( - self, thread: Thread, session_id: str, load_metadata: object, + self, thread: Thread, session_id: AcpSessionId, load_metadata: object, subscription: type[SubscribeRuntimeRequest], ) -> LoadSessionResponse: try: diff --git a/src/agent_comms/storage_room.py b/src/agent_comms/storage_room.py index 56c160ac89..76fbe85918 100644 --- a/src/agent_comms/storage_room.py +++ b/src/agent_comms/storage_room.py @@ -20,6 +20,7 @@ from .child_process import ProcessIdentity, ResidentMemory from .declared_family import DeclaredFamily from .field_codec import RoomTicket +from .locked_store import PublishedProperty from .store_files import StoreContention from .thread_identity import ThreadIncarnation from .typed_table import ( @@ -123,11 +124,13 @@ class RoomLedger(TypedStore, DurableTables, RuntimeWriter): tables = RoomLedgerRows @classmethod - def _open_reader(cls, path: Path, contention: StoreContention) -> Connection: + def _open_reader( + cls, path: Path, contention: StoreContention, *, check_same_thread: bool = True, + ) -> Connection: """Carry this host-owned file before its first snapshot; then prohibit writes.""" connection = cls.writer( path.resolve().as_uri() + "?mode=rw", contention, - isolation_level=None, uri=True, + isolation_level=None, uri=True, check_same_thread=check_same_thread, ) try: cls.install_schema(connection, WriterOwnedFile()) @@ -248,8 +251,8 @@ class RoomAllocation: # freed by jobs outside Core, which cannot notify this ledger. room_refresh_seconds = 1.0 - @property - def room_ledger(self): + @PublishedProperty + def room_ledger(self) -> RoomLedger: return RoomLedger(self.volume / '.cache' / 'agent-comms') def free_room(self): diff --git a/src/agent_comms/store_files.py b/src/agent_comms/store_files.py index 1dd57480e9..ffba442d47 100644 --- a/src/agent_comms/store_files.py +++ b/src/agent_comms/store_files.py @@ -503,10 +503,14 @@ class StoreContention(ABC): @abstractmethod def timeout(self) -> float: ... - def reader(self, uri: str) -> sqlite3.Connection: + def configure(self, connection: sqlite3.Connection) -> None: + """Apply this acquisition's wait budget to a retained SQLite handle.""" + connection.execute(f"PRAGMA busy_timeout={round(self.timeout * 1000)}") + + def reader(self, uri: str, *, check_same_thread: bool = True) -> sqlite3.Connection: return sqlite3.connect( uri, uri=True, isolation_level=None, timeout=self.timeout, - factory=LendingConnection) + check_same_thread=check_same_thread, factory=LendingConnection) def writer( self, path: str | os.PathLike[str], *, @@ -530,9 +534,9 @@ class StoreBusy(StoreContention): timeout: ClassVar[float] = 0.0 - def reader(self, uri: str) -> sqlite3.Connection: + def reader(self, uri: str, *, check_same_thread: bool = True) -> sqlite3.Connection: with CoordinationReadUnavailable.checking(): - return super().reader(uri) + return super().reader(uri, check_same_thread=check_same_thread) DEFAULT_STORE_CONTENTION = StoreWait() diff --git a/src/agent_comms/thread_presentation.py b/src/agent_comms/thread_presentation.py index 975703cae8..3995f7a1e3 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -143,7 +143,7 @@ def replaces(self, original: LiveThreadOwnerBinding) -> bool: def superseded_by(self, replacement: ThreadOwnerBinding) -> bool: return False - def recipient_activity(self, agents, snapshot, thread): + def recipient_activity(self, activity, snapshot, thread): from .agent_activity import UnavailableRecipientActivity return UnavailableRecipientActivity() @@ -167,10 +167,10 @@ class LiveThreadOwnerBinding(ThreadOwnerBinding): def native_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentation: return ordinary - def recipient_activity(self, agents, snapshot, thread): + def recipient_activity(self, activity, snapshot, thread): from .agent_activity import LiveRecipientActivity - return LiveRecipientActivity(thread, agents.activity_of(thread.name, snapshot=snapshot)) + return LiveRecipientActivity(thread, activity[thread.name]) def superseded_by(self, replacement: ThreadOwnerBinding) -> bool: return replacement.replaces(self) diff --git a/src/agent_comms/tracked_turn.py b/src/agent_comms/tracked_turn.py index b5156617e6..d0864a5484 100644 --- a/src/agent_comms/tracked_turn.py +++ b/src/agent_comms/tracked_turn.py @@ -51,7 +51,7 @@ if TYPE_CHECKING: from .acp_failure import ProviderRetryResult - from .agent_backend import TurnInbox + from .turn_inbox import TurnInbox from .coordinated_runtime import NativeExecution from .cursor_publication import CursorDelivery from .owned_send import OwnedSend diff --git a/src/agent_comms/turn_context.py b/src/agent_comms/turn_context.py index 378ee30f8d..4ee03d91bf 100644 --- a/src/agent_comms/turn_context.py +++ b/src/agent_comms/turn_context.py @@ -839,6 +839,8 @@ class MeasuredNativeSegment(ContextSegment): """An SDK-owned input observation, not a second native message vocabulary.""" tokens: int + sha256: str + utf8_bytes: int contributors: tuple[SegmentManifest, ...] = () @abstractmethod @@ -881,9 +883,6 @@ def contributor_manifests(self) -> tuple[SegmentManifest, ...]: @dataclass(frozen=True, kw_only=True) class SystemLayerSegment(PublicInstructionAnnotations, MeasuredNativeSegment): content: str - tokens: int - sha256: str - utf8_bytes: int source_spans: Annotated[ tuple[ContributionCoordinates, ...], WireOmitDefault() ] = () @@ -934,9 +933,6 @@ class NativeMessages: # These are the SDK's exact *provider* messages, including opaque extension # and provider fields. Core does not decode them as its event/message subset. messages: tuple[dict[str, JsonValue], ...] - tokens: int - sha256: str - utf8_bytes: int @classmethod def recorded_parts(cls, original: SegmentManifest) -> tuple[SegmentManifest, ...]: @@ -1020,9 +1016,6 @@ class InjectionMessageSegment(NativeAnnotations, MeasuredNativeSegment): @dataclass(frozen=True, kw_only=True) class ToolCatalogSegment(MeasuredNativeSegment): tools: tuple[dict[str, JsonValue], ...] - tokens: int - sha256: str - utf8_bytes: int def provider_value(self): return list(self.tools) diff --git a/src/agent_comms/turn_inbox.py b/src/agent_comms/turn_inbox.py new file mode 100644 index 0000000000..38ae3f5916 --- /dev/null +++ b/src/agent_comms/turn_inbox.py @@ -0,0 +1,153 @@ +"""Backend input records, busy policies and the inbox that owns queued work.""" + +from __future__ import annotations + +import asyncio +from abc import ABC, abstractmethod +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass + +from .declared_family import DeclaredFamily +from .field_codec import InputId +from .image_input import ImageInput +from .pi_vocabulary import ThinkingLevelField +from .thread_model import ThreadModel +from .turn_context import InputContributionCoordinates + + +class BusyInputPolicy(DeclaredFamily, affix="InputPolicy"): + """What the backend does with an input that arrives while a run is going.""" + + @classmethod + @abstractmethod + def streaming(cls) -> type[StreamingInputPolicy]: ... + + +class StreamingInputPolicy(BusyInputPolicy, declared_name=None): + """The busy policies Pi's streamingBehavior protocol accepts.""" + + @classmethod + def streaming(cls) -> type[StreamingInputPolicy]: + return cls + + +class FollowUpInputPolicy(StreamingInputPolicy, declared_name="followUp"): + """Run after the current run ends; preserve Pi's exact followUp spelling.""" + + +class SteerInputPolicy(StreamingInputPolicy): + """Place the input at the next boundary of the current run.""" + + +class RejectInputPolicy(BusyInputPolicy): + @classmethod + def streaming(cls) -> type[StreamingInputPolicy]: + raise ValueError("Pi cannot reject a prompt through streamingBehavior.") + + +@dataclass(frozen=True, kw_only=True) +class InputContent: + """What one input says: its text, attached images and context contributions.""" + + text: str + images: tuple[ImageInput, ...] = () + contributions: tuple[InputContributionCoordinates, ...] = () + + +class TurnInboxItem(ABC): + """An inbox item carries its input identities; the forwarding owner dispatches it.""" + + def return_to(self, inbox: TurnInbox) -> None: + """An operation not handed off still belongs at the inbox front.""" + inbox.begin(self) + + @property + def carried_inputs(self) -> tuple[InputId, ...]: + return () + + +class TurnInbox(asyncio.Queue[TurnInboxItem]): + """The live inbox parks its head until the existing observer sees READY.""" + + # CPython owns these queue hooks; its public typeshed stub omits them. + _queue: deque[TurnInboxItem] + _getters: deque[asyncio.Future[None]] + _wakeup_next: Callable[[deque[asyncio.Future[None]]], None] + + def __init__(self) -> None: + super().__init__() + self.ready = asyncio.Event() + self.ready.set() + + async def get(self) -> TurnInboxItem: + await self.ready.wait() + return await super().get() + + @property + def has_ready_work(self) -> bool: + """Only an open inbox can give queued work to the running turn.""" + return self.ready.is_set() and not self.empty() + + @property + def ready_inputs(self) -> tuple[InputId, ...]: + """Runnable user inputs, excluding parked input and control operations.""" + return ( + tuple(input_id for item in self._queue for input_id in item.carried_inputs) + if self.has_ready_work + else () + ) + + def begin(self, item: TurnInboxItem) -> None: + self._queue.appendleft(item) + self._wakeup_next(self._getters) + + def hold(self, item: TurnInboxItem) -> None: + # Park under the shared wire lock, before READY can be published. + self.ready.clear() + self.begin(item) + + +@dataclass(frozen=True, kw_only=True) +class InputRequest(TurnInboxItem): + """One queued user input handed to a backend: identity, content and busy policy. + + Routing, goal and bus-delivery provenance stay in Core, keyed by ``input_id``. + """ + + input_id: InputId + content: InputContent + when_busy: type[BusyInputPolicy] + + @property + def carried_inputs(self) -> tuple[InputId, ...]: + return (self.input_id,) + + +@dataclass(frozen=True) +class SendNow(TurnInboxItem): + """Ask the running turn to place these queued steering inputs immediately. + + Only a backend with the :class:`Interrupts` role accepts it. + """ + + input_ids: tuple[InputId, ...] + + +@dataclass(frozen=True) +class UseSettings(TurnInboxItem): + """Ask the running turn to use the thread's stored model and thinking level. + + The backend applies them from its next model call; the call in flight + finishes on the settings it started with. + """ + + model: ThreadModel + thinking_level: ThinkingLevelField + + +@dataclass(frozen=True) +class ClearQueue(TurnInboxItem): + """Clear only these queued user inputs, never delivered bus inputs.""" + + input_ids: tuple[InputId, ...] diff --git a/src/agent_comms/turn_input_source.py b/src/agent_comms/turn_input_source.py index 2b66f8643f..8488488a60 100644 --- a/src/agent_comms/turn_input_source.py +++ b/src/agent_comms/turn_input_source.py @@ -12,9 +12,9 @@ from . import input_attempt from .acp_extension import InputStartedUpdate, NoQueueScope, UnidentifiedInputStarted -from .agent_backend import BusyInputPolicy, SteerInputPolicy +from .turn_inbox import BusyInputPolicy, SteerInputPolicy from .channel_input_batch import InputBatch, SingleInputBatch -from .field_codec import InputId, TurnId +from .field_codec import AcpSessionId, InputId, TurnId from .input_attempt import InputAlreadyCleared, MissingInput from .input_key import InputKey from .messages import Message @@ -54,12 +54,12 @@ def controller(self, state: DrainedSession, runtime: RuntimeServer) -> Controlle @abstractmethod def require_current( - self, inputs: InputDrain, session_id: str, source: TurnInputSource, + self, inputs: InputDrain, session_id: AcpSessionId, source: TurnInputSource, current: Thread, claim_generation: int, ) -> None: ... async def started_update( - self, source: TurnInputSource, inputs: InputDrain, session_id: str, + self, source: TurnInputSource, inputs: InputDrain, session_id: AcpSessionId, row: input_attempt.StartedInput, scope: QueueScope, ) -> InputStartedUpdate: current = await inputs.queue_binding(session_id) @@ -72,24 +72,24 @@ async def started_update( return source.started_update_of(row, accepted) async def accepted_start( - self, update: InputStartedUpdate, inputs: InputDrain, session_id: str, + self, update: InputStartedUpdate, inputs: InputDrain, session_id: AcpSessionId, ) -> InputStartedUpdate: return update @abstractmethod - def release(self, inputs: InputDrain, session_id: str) -> None: ... + def release(self, inputs: InputDrain, session_id: AcpSessionId) -> None: ... class NoTurnAcceptance(TurnAcceptance): """A scheduled or selected turn has no queued ACP acceptance.""" def require_current( - self, inputs: InputDrain, session_id: str, source: TurnInputSource, + self, inputs: InputDrain, session_id: AcpSessionId, source: TurnInputSource, current: Thread, claim_generation: int, ) -> None: return - def release(self, inputs: InputDrain, session_id: str) -> None: + def release(self, inputs: InputDrain, session_id: AcpSessionId) -> None: return @@ -101,7 +101,7 @@ def controller(self, state: DrainedSession, runtime: RuntimeServer) -> Controlle return state.queued[self.input_id].controller def require_current( - self, inputs: InputDrain, session_id: str, source: TurnInputSource, + self, inputs: InputDrain, session_id: AcpSessionId, source: TurnInputSource, current: Thread, claim_generation: int, ) -> None: try: @@ -117,14 +117,14 @@ def require_current( ).require_valid() async def accepted_start( - self, update: InputStartedUpdate, inputs: InputDrain, session_id: str, + self, update: InputStartedUpdate, inputs: InputDrain, session_id: AcpSessionId, ) -> InputStartedUpdate: queued = inputs.state(session_id).queued if self.input_id in queued: update = replace(update, text=await queued[self.input_id].started(inputs)) return update.identified(self.input_id) - def release(self, inputs: InputDrain, session_id: str) -> None: + def release(self, inputs: InputDrain, session_id: AcpSessionId) -> None: inputs.state(session_id).queued.pop(self.input_id, None) diff --git a/src/agent_comms/turn_inputs.py b/src/agent_comms/turn_inputs.py index 4a3af2e7e8..6a3260c537 100644 --- a/src/agent_comms/turn_inputs.py +++ b/src/agent_comms/turn_inputs.py @@ -19,7 +19,7 @@ from . import pi_commands as commands from . import turn_failure as failures from .acp_failure import UnrecordedFailureSource -from .agent_backend import ClearQueue, InputRequest, SendNow, TurnInbox, TurnInboxItem, UseSettings +from .turn_inbox import ClearQueue, InputRequest, SendNow, TurnInbox, TurnInboxItem, UseSettings from .field_codec import InputId from .locked_store import RegistryFile from .maintenance_barrier import MaintenanceBarrier @@ -224,11 +224,6 @@ def write(): completion = outcome.dispatch(self, session, item, write) return await completion - def refuse(self, session: TurnSession, input_ids: tuple[InputId, ...]) -> None: - for input_id in input_ids: - session.rejected_commands.append(events.FollowingInputRefused(id=input_id)) - session.rejected_signal.set() - def mark_started( self, session: TurnSession, payload: MessageStart, ) -> tuple[InputId] | tuple[()]: diff --git a/src/agent_comms/turn_progress.py b/src/agent_comms/turn_progress.py index a148f9d1bf..1636756196 100644 --- a/src/agent_comms/turn_progress.py +++ b/src/agent_comms/turn_progress.py @@ -21,12 +21,12 @@ from .acp_extension import LiveQueueScope, TranscriptChangedUpdate, encode_updates from .acp_failure import NativeTurnOutcome from .agent_event_updates import RoutedTurnEventConsumer, TurnEventConsumer -from .comms import Comms from .context_store import UNREAD_CONTEXT, ContextPrior from .coordinator import Coordination from .diagnostics import record_request_progress, record_terminal_failure from .publication_measurements import PublicationMeasurements from .messages import AlertKind, NoticeRole +from .field_codec import AcpSessionId from .send_result import SendResult from .thread_identity import ClaimedIdentity from .transcript_updates import TurnTranscriptUpdate @@ -35,22 +35,17 @@ if TYPE_CHECKING: from .owned_turn import OwnedTurn - from .session_lifecycle import SessionLifecycle - from .turn_effects import TurnEffects from .turn_failure import TurnFailure from .turn_goal_account import TurnGoalAccount @dataclass(kw_only=True) class TurnEventPublication(MroDispatch): - comms: Comms - sessions: SessionLifecycle - effects: TurnEffects - sync_goal_execution: Callable[[str, str], Awaitable[None]] - session_id: str - @handles(events.InputStarted, events.Done, events.StreamSettled, events.ToolEnd) + sync_goal_execution: Callable[[AcpSessionId], Awaitable[None]] + session_id: AcpSessionId + @handles(events.InputStarted, events.Done) async def sync_goals(self, event: events.InputStarted) -> None: - await self.sync_goal_execution(self.session_id, self.sessions.require(self.session_id)) + await self.sync_goal_execution(self.session_id) @@ -64,11 +59,11 @@ def __init__( inputs, effects, runtime, - session_id, + session_id: AcpSessionId, turn: OwnedTurn, finish_event, goals: TurnGoalAccount, - sync_goals, + sync_goals: Callable[[AcpSessionId], Awaitable[None]], ): self._comms = comms self.sessions, self.inputs, self.effects = sessions, inputs, effects @@ -86,9 +81,6 @@ def __init__( if route else TurnEventConsumer(effects, session_id, runtime, self.turn_lease.turn_id) ) self.publication = TurnEventPublication( - comms=comms, - sessions=sessions, - effects=effects, sync_goal_execution=sync_goals, session_id=session_id, ) diff --git a/src/agent_comms/turn_runner.py b/src/agent_comms/turn_runner.py index 906284f25d..02b559c05a 100644 --- a/src/agent_comms/turn_runner.py +++ b/src/agent_comms/turn_runner.py @@ -9,10 +9,7 @@ from typing import TYPE_CHECKING from acp.exceptions import RequestError -from acp.schema import ( - PromptRequest, - PromptResponse, -) +from acp.schema import PromptResponse from metaclass_registry.mro_dispatch import ( MroDispatch, handles, @@ -46,10 +43,11 @@ from .native_input_owner import RegistryOwner from .owned_turn import OwnedTurn from .pi_commands import ( + NativeQuery, AgentCommsInspectContext, AgentCommsInspectContextSegment, ) -from .pi_payloads import StateData +from .pi_payloads import NativeContextData, StateData from .queued_input import InitialInput from .pi_rule_gate import PiRedeal from .routing import NoTurnRouting, Routing @@ -59,6 +57,7 @@ ) from .threads import Thread from .transcript_updates import TurnTranscriptUpdate +from .turn_context import SegmentManifest from .turn_effects import TurnEffects from .turn_lease import TurnLeaseFence, TurnState from .turn_input_source import InputDependency, NoInputDependency, TurnAcceptance @@ -80,18 +79,21 @@ class CompactionObservation(MroDispatch): """All selected/native compaction callers update the existing turn owner.""" - def __init__(self, runner, session_id, lease, phase): + def __init__( + self, runner: TurnRunner, session_id: AcpSessionId, + lease: TurnLeaseFence, phase: TurnPhase, + ) -> None: self.runner, self.session_id, self.lease, self.phase = runner, session_id, lease, phase - async def publish(self, phase): + async def publish(self, phase: TurnPhase) -> None: await self.runner.transition_turn(self.session_id, self.lease, phase) @handles(events.CompactionStart) - async def start(self, event): + async def start(self, event: events.CompactionStart) -> None: await self.publish(self.phase.compacting()) @handles(events.CompactionFinish) - async def end(self, event): + async def end(self, event: events.CompactionFinish) -> None: await self.publish(self.phase.compaction_ended()) @@ -108,7 +110,7 @@ def __init__( self.runtime = runtime self.effects = effects # One backend per ACP session, of the bound thread's declared kind. - self.persistent_backends: dict[str, AgentBackend] = {} + self.persistent_backends: dict[AcpSessionId, AgentBackend] = {} self.goals = GoalScheduler(comms, effects) def bind(self, sessions: SessionLifecycle, inputs: InputDrain) -> None: @@ -116,19 +118,21 @@ def bind(self, sessions: SessionLifecycle, inputs: InputDrain) -> None: self.inputs = inputs self.goals.bind(sessions, inputs) - def session_busy(self, session_id: str) -> bool: + def session_busy(self, session_id: AcpSessionId) -> bool: return self.inputs.state(session_id).turn_task.busy or self.turn_state(session_id).active.phase.busy - def turn_state(self, session_id: str) -> TurnState: + def turn_state(self, session_id: AcpSessionId) -> TurnState: if session_id not in self.sessions.published: return TurnState() return self.comms.registry.require(self.sessions.require(session_id)).turn_state - def owns_turn(self, session_id: str, turn_id: TurnId) -> bool: + def owns_turn(self, session_id: AcpSessionId, turn_id: TurnId) -> bool: state = self.turn_state(session_id) return state.active.phase.busy and state.active.id == turn_id - async def transition_turn(self, session_id: str, lease: TurnLeaseFence, phase: TurnPhase) -> None: + async def transition_turn( + self, session_id: AcpSessionId, lease: TurnLeaseFence, phase: TurnPhase, + ) -> None: states = await Coordination.run_worker(partial( self.comms.agents.transition_turn, lease, phase )) @@ -138,7 +142,7 @@ async def transition_turn(self, session_id: str, lease: TurnLeaseFence, phase: T ) async def on_published_thread( - self, session_id: str, observe: Callable[[Thread], Awaitable[None]], + self, session_id: AcpSessionId, observe: Callable[[Thread], Awaitable[None]], ) -> None: """Hand a published session's thread to `observe`; an unpublished session has none.""" if session_id not in self.sessions.published: @@ -146,13 +150,19 @@ async def on_published_thread( await observe(await Coordination.run_worker(partial( self.comms.registry.require, self.sessions.require(session_id)))) - async def observe_compaction(self, session_id: str, event) -> None: + async def observe_compaction( + self, session_id: AcpSessionId, event: events.CompactionEvent, + ) -> None: await self.on_published_thread(session_id, partial(self.compacted, session_id, event)) - async def compacted(self, session_id: str, event, thread: Thread) -> None: + async def compacted( + self, session_id: AcpSessionId, event: events.CompactionEvent, thread: Thread, + ) -> None: await Coordination.run_worker(partial(self.deal_rules, thread, event)) for lease in thread.turn_lease.held(): - await CompactionObservation(self, session_id, lease, thread.turn_record.read().phase).dispatch(event) + await CompactionObservation( + self, session_id, lease, thread.turn_record.read().phase, + ).dispatch(event) async def observe_context( self, session_id: AcpSessionId, usage: ContextUsage, @@ -168,7 +178,7 @@ def deal_rules(self, thread: Thread, event: events.CompactionEvent) -> None: rules its role follows; its tool transports refuse other calls until they are read.""" event.deal_rules(PiRedeal.of(self.comms, thread)) - def backend_for(self, session_id: str, thread: Thread) -> AgentBackend: + def backend_for(self, session_id: AcpSessionId, thread: Thread) -> AgentBackend: """This session's backend, created for the thread's declared backend on first use.""" backend = thread.runtime.backend current = self.persistent_backends.get(session_id) @@ -183,40 +193,44 @@ def backend_for(self, session_id: str, thread: Thread) -> AgentBackend: return current async def observe_selected_preparation( - self, session_id: str, thread: Thread, state: StateData, info: events.AgentInfo, + self, session_id: AcpSessionId, thread: Thread, state: StateData, info: events.AgentInfo, ) -> None: """Publish this attested saved owner through the shared info consumer.""" state.model.require_selection(thread.model) await events.AgentEventConsumer(comms=self.comms, thread_name=thread.name).dispatch(info) await AcpEventConsumer(self.effects, session_id, self.runtime).dispatch(info) - async def inspect_context(self, session_id, thread): + async def inspect_context(self, session_id: AcpSessionId, thread: Thread) -> NativeContextData: return await self.inspect_native_request(session_id, thread, AgentCommsInspectContext()) - async def inspect_context_segment(self, session_id, thread, manifest): + async def inspect_context_segment( + self, session_id: AcpSessionId, thread: Thread, manifest: SegmentManifest, + ) -> str: request = AgentCommsInspectContextSegment.for_manifest(manifest) context = await self.inspect_native_request(session_id, thread, request) return context.recorded_public_text(manifest) - async def inspect_native_request(self, session_id, thread, request): - context = await self.backend_for(session_id, thread).inspect(thread, request) + async def inspect_native_request( + self, session_id: AcpSessionId, thread: Thread, request: NativeQuery, + ) -> NativeContextData: + response = await self.backend_for(session_id, thread).inspect(thread, request) + context = response.require_context() return context.require_session_file(thread.runtime.path) async def prompt_owned( self, - request: PromptRequest, + session_id: AcpSessionId, *, + text: str, images: tuple[ImageInput, ...], display_text: str | None = None, input_id: InputId, origin: InputOrigin = UnattributedInputOrigin(), ) -> PromptResponse: - session_id = request.session_id async with self.inputs.state(session_id).own_turn_task(): try: thread_name = await self.sessions.sync_identity(session_id) await Coordination.run_worker(partial(self.comms.registry.require, thread_name)) - text = self.effects._prompt_text(request) agent_task: str | None = None relay_text: str | None = None if text.startswith(AGENT_PREFIX): @@ -262,7 +276,7 @@ def pause_active_goal(self, thread: Thread) -> None: actor=OwnerInvocable, ) - async def cancel(self, session_id: str, **kwargs: JsonValue) -> None: + async def cancel(self, session_id: AcpSessionId, **kwargs: JsonValue) -> None: if session_id in self.sessions.proxies: await self.sessions.proxies[session_id].request("cancel") return @@ -287,7 +301,7 @@ async def cancelling() -> None: async def extension_ui_permission( self, - session_id: str, + session_id: AcpSessionId, turn_id: TurnId, controller: Controller | None, request: pi.DialogUiRequest, @@ -320,7 +334,7 @@ async def extension_ui_permission( return request.choice(selected) def acquire_turn( - self, resources: AsyncExitStack, session_id: str, thread_name: str, + self, resources: AsyncExitStack, session_id: AcpSessionId, thread_name: str, turn_id: TurnId, detail: str, routing: Routing = NoTurnRouting(), ) -> RegistryOwner: """Bind the exact installed lease to cleanup before worker delivery. @@ -336,7 +350,7 @@ def acquire_turn( return owner def finish_turn( - self, resources: AsyncExitStack, session_id: str, lease: TurnLeaseFence, + self, resources: AsyncExitStack, session_id: AcpSessionId, lease: TurnLeaseFence, ) -> TurnTranscriptUpdate: """Release the original CAS lease and retain its waiter cleanup.""" terminal_fence = self.comms.agents.finish_turn(lease) @@ -348,7 +362,7 @@ def finish_turn( async def settle_turn( self, - session_id: str, + session_id: AcpSessionId, lease: TurnLeaseFence, ) -> None: """Settle after terminal publication, releasing waiters even if the UI fails. @@ -362,7 +376,7 @@ async def settle_turn( )) await AcpEventConsumer(self.effects, session_id, self.runtime).dispatch(update) - def current_turn_update(self, session_id: str): + def current_turn_update(self, session_id: AcpSessionId): return TurnTranscriptUpdate(state=self.turn_state(session_id)) async def publish_turn_state(self, consumer: AcpEventConsumer) -> None: @@ -372,18 +386,18 @@ async def publish_turn_state(self, consumer: AcpEventConsumer) -> None: ) await consumer.dispatch(update) - async def active_backend_turn(self, session_id: str) -> DrainTurn: + async def active_backend_turn(self, session_id: AcpSessionId) -> DrainTurn: name = self.sessions.require(session_id) thread = await Coordination.run_worker(partial(self.comms.registry.require, name)) return self.inputs.state(session_id).turn.for_owner(thread) - async def close_idle_backend(self, session_id: str, thread: Thread) -> None: + async def close_idle_backend(self, session_id: AcpSessionId, thread: Thread) -> None: async with self.idle_backend(session_id, thread) as persistent: if persistent is not None: await persistent.close() @asynccontextmanager - async def idle_backend(self, session_id: str, thread: Thread): + async def idle_backend(self, session_id: AcpSessionId, thread: Thread): """Hold this session's turn and child acquisition until an idle effect ends. Configuration retirement remains conditional on an idle owner. Source @@ -402,7 +416,7 @@ async def idle_backend(self, session_id: str, thread: Thread): async with persistent.lock: yield persistent - async def run_selected(self, session_id: str, execution: SelectedExecution): + async def run_selected(self, session_id: AcpSessionId, execution: SelectedExecution): """Selected work and fresh ACP input share this session's one turn lifetime. Only the live inbox transfers to the next native turn. Durable UNKNOWN @@ -419,7 +433,9 @@ async def run_selected(self, session_id: str, execution: SelectedExecution): ) try: result = await execution.run( - on_compaction=AcpEventConsumer(self.effects, session_id, self.runtime).dispatch, + on_compaction=AcpEventConsumer( + self.effects, session_id, self.runtime, + ).dispatch, pi_session=self.backend_for(session_id, owner), following=following, ) @@ -436,7 +452,7 @@ async def run_selected(self, session_id: str, execution: SelectedExecution): async def run_agent_turn( self, - session_id: str, + session_id: AcpSessionId, thread_name: str, task: str, *, diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 0425581918..89fc98303e 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -75,6 +75,7 @@ RootFile, RuntimeFile, SQLiteFile, + SQLiteReader, SQLiteStorage, StoreCommittedError, WrittenFile, @@ -2909,16 +2910,11 @@ class RuntimeWriter(RuntimeFile, StoreWriter): maintenance included.""" -class WalStore(StoreWriter, StoreLockFile): +class WalStore(SQLiteReader, StoreWriter, StoreLockFile): """One WAL file owns connection setup, attachment and retirement.""" synchronous: ClassVar[str] = "FULL" - @property - @abstractmethod - def path(self) -> Path: - """The physical file, supplied by a root declaration or file constructor.""" - def _create_file(self) -> None: """Create only a new inode; never close a descriptor for a live SQLite inode.""" try: @@ -3064,7 +3060,7 @@ def _open_writer(self, *, check_same_thread: bool = True) -> Iterator[sqlite3.Co def snapshot(self) -> Iterator[sqlite3.Connection]: """Read a committed store, or its declared empty schema before its first write.""" with self.committed_or_empty( - type(self), lambda: self.opened_reader(self.path) + type(self), self.reader_snapshot ) as connection: self.require_schema(connection) yield connection diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index 42de3b8a77..d138f316d2 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -494,7 +494,7 @@ def connect(self, comms: Comms) -> RuntimeConnection: if owner.incarnation != self.owner: raise OwnerIdentityChangedError("The owner incarnation changed while preparing the request.") return OwnerReplyConnection( - comms, owner.name, socket_path(comms.root, owner.require_process().pid), + comms, owner.incarnation, socket_path(comms.root, owner.require_process().pid), ) async def ask(self, comms: Comms) -> object: diff --git a/src/agent_comms/watched.py b/src/agent_comms/watched.py index 3be36532e8..349cf0592a 100644 --- a/src/agent_comms/watched.py +++ b/src/agent_comms/watched.py @@ -345,6 +345,7 @@ class ChangeSignal(ChangedSources, asyncio.Queue[SourceChange]): def __init__(self, loop: asyncio.AbstractEventLoop, *sources: ChangeSource): asyncio.Queue.__init__(self, maxsize=1) self.loop = loop + self.observed_paths: frozenset[Path] = frozenset() super().__init__(*sources) def _publish(self, change: SourceChange) -> None: @@ -373,7 +374,7 @@ def _on_ready(self) -> None: def prepare(self) -> None: self._drain() - self._take_paths() + self.observed_paths = self._take_paths() try: change = self.get_nowait() except asyncio.QueueEmpty: diff --git a/src/agent_comms/working_memory_annotations.py b/src/agent_comms/working_memory_annotations.py index 617fa5e1d3..e85dca11b4 100644 --- a/src/agent_comms/working_memory_annotations.py +++ b/src/agent_comms/working_memory_annotations.py @@ -2,9 +2,7 @@ from __future__ import annotations from contextlib import contextmanager -from pathlib import Path - -from .coordination_database import CoordinationStore +from .coordination_database import CoordinationStore, ReadableStore from .errors import RelationViolationError from .coordination_tables.annotations import AnnotationRequestsRow, SpanAnnotationsRow from .working_memory_labels import CalibrationReport, ClassifierVersion, HumanLabel, ModelLabel, QuestionVersion @@ -27,23 +25,25 @@ def address(span, question: QuestionVersion, classifier: ClassifierVersion): @staticmethod @contextmanager - def reading(path): - """Borrow one original committed snapshot; readers never install a store.""" - schema = CoordinationStore.schema_at(Path(path)) - with CoordinationStore.opened_reader(Path(path)) as db: + def reading(source: ReadableStore): + """Borrow the caller's retained coordinator reader; never install a store.""" + schema = CoordinationStore.schema_at(source.path) + with source.reader_snapshot() as db: schema.require_schema(db) yield db @classmethod - def labels(cls, path, span, question, classifier): + def labels(cls, source: ReadableStore, span, question, classifier): key = cls.address(span, question, classifier) - with cls.reading(path) as db: + with cls.reading(source) as db: return tuple(SpanAnnotationsRow.select(db, order_by=("id",), **key)) @classmethod - def calibration(cls, path, question: QuestionVersion, classifier: ClassifierVersion) -> CalibrationReport: - with cls.reading(path) as db: + def calibration( + cls, source: ReadableStore, question: QuestionVersion, classifier: ClassifierVersion, + ) -> CalibrationReport: + with cls.reading(source) as db: originals = SpanAnnotationsRow.select(db, order_by=("id",), question=question.question, question_version=question.sha256, classifier=classifier.classifier, classifier_pin=classifier.pin, label_kind=ModelLabel) @@ -60,32 +60,36 @@ def calibration(cls, path, question: QuestionVersion, classifier: ClassifierVers return CalibrationReport(question, classifier, tuple(cases)) @classmethod - def for_segment(cls, path, segment, classifier: ClassifierVersion) -> tuple[ModelLabel, ...]: + def for_segment( + cls, source: ReadableStore, segment, classifier: ClassifierVersion, + ) -> tuple[ModelLabel, ...]: """Read the complete effective answer family at its original addresses. A kind answer does not replace its obligation, scope or fulfillment evidence. Different question versions remain different recorded facts; reading a segment never substitutes today's question definition. """ - return cls.for_segments(path, (segment,), classifier) + return cls.for_segments(source, (segment,), classifier) @classmethod - def for_context(cls, path, manifests, classifier: ClassifierVersion) -> tuple[ModelLabel, ...]: + def for_context( + cls, source: ReadableStore, manifests, classifier: ClassifierVersion, + ) -> tuple[ModelLabel, ...]: """Acquire one context's original answers through the segment reader. Repeated observations can name the same stored answer. Keep that answer once; matching text in another source does not supply its provenance. """ - return cls.for_segments(path, tuple(segment for manifest in manifests + return cls.for_segments(source, tuple(segment for manifest in manifests for root in manifest.segments for segment in root.original_values()), classifier) @classmethod - def for_segments(cls, path, segments, classifier) -> tuple[ModelLabel, ...]: + def for_segments(cls, source: ReadableStore, segments, classifier) -> tuple[ModelLabel, ...]: """One acquisition serves context and selected-segment readers alike.""" originals = {} for segment in segments: originals.setdefault(segment.sha256, []).append(segment) - with cls.reading(path) as db: + with cls.reading(source) as db: rows = SpanAnnotationsRow.for_digests(db, tuple(originals), classifier) grouped = {} for row in rows: diff --git a/src/agent_comms/working_memory_worker.py b/src/agent_comms/working_memory_worker.py index 82c009ad76..8ae287c72a 100644 --- a/src/agent_comms/working_memory_worker.py +++ b/src/agent_comms/working_memory_worker.py @@ -60,7 +60,7 @@ async def answer(self, policy, manifest, segment, span, question): grant.require_segment(segment) version = QuestionVersion.current(question, rules) rows = await Coordination.run_worker(partial(WorkingMemoryAnnotations.labels, - CoordinationStore.path_in(self.comms.root), span, version, grant.classifier)) + self.comms.registry.store.coordinator_reader, span, version, grant.classifier)) if rows: from .coordination_tables.annotations import SpanAnnotationsRow diff --git a/tests/config_option_authority_installed_pilot.py b/tests/config_option_authority_installed_pilot.py index 059b6cb19e..bb79a3d1a7 100644 --- a/tests/config_option_authority_installed_pilot.py +++ b/tests/config_option_authority_installed_pilot.py @@ -23,7 +23,7 @@ from agent_comms.acp import CommsAgent from agent_comms.comms import wire from agent_comms.pi_vocabulary import OffThinkingLevel, HighThinkingLevel -from agent_comms.runtime import RuntimeProxy, socket_path +from agent_comms.field_codec import AcpSessionId from agent_comms.threads import Thread from agent_comms.native_arguments import GivenArguments, GivenBinary from agent_comms.thread_text import GivenThreadText @@ -131,8 +131,8 @@ async def run(stage, package, models, ui, menu_contention=False): async with AsyncExitStack() as lifetime: client = CommsAgent(service, auto_wake=False) lifetime.push_async_callback(client.shutdown) - path = socket_path(service.root, owner.pid) - proxy = RuntimeProxy(client, name, path) + identifier = AcpSessionId(name) + proxy = client._create_runtime_proxy(owner, identifier) lifetime.push_async_callback(proxy.close) async with asyncio.timeout(15): ready = await proxy.subscribe() @@ -151,7 +151,7 @@ async def run(stage, package, models, ui, menu_contention=False): assert service.registry.require(name).thinking_level is OffThinkingLevel before = (service.root / 'registry.json').read_bytes() await proxy.close() - repeated = RuntimeProxy(client, name, path) + repeated = client._create_runtime_proxy(owner, identifier) lifetime.push_async_callback(repeated.close) async with asyncio.timeout(15): again = await repeated.subscribe() @@ -171,7 +171,7 @@ async def run(stage, package, models, ui, menu_contention=False): await repeated.request('set_config_option', config_id='thinking_level', value='high') assert service.registry.require(name).thinking_level is HighThinkingLevel await repeated.close() - refreshed = RuntimeProxy(client, name, path) + refreshed = client._create_runtime_proxy(owner, identifier) lifetime.push_async_callback(refreshed.close) async with asyncio.timeout(15): final = await refreshed.subscribe() diff --git a/tests/goal_owner_fixture.py b/tests/goal_owner_fixture.py index 0aae62f0a6..e31b8bc0e5 100644 --- a/tests/goal_owner_fixture.py +++ b/tests/goal_owner_fixture.py @@ -6,11 +6,16 @@ import pytest from agent_comms.comms import wire +from agent_comms.storage_rules import StorageRule def canonical_goal_wire(root): """Create an empty real checked source for goal/wait fixture operations.""" comms = wire(root) + comms.registry.storage_rule = StorageRule( + volume=root.parent, checkout_base=root.parent, + scratch_base=root.parent / "scratch", + ) return comms @@ -19,6 +24,6 @@ def copied_goal_bus(tmp_path): source = canonical_goal_wire(tmp_path / "source") target = tmp_path / "copy" shutil.copytree(source.root, target) - return wire(target) + return canonical_goal_wire(target) diff --git a/tests/native_backend_fixture.py b/tests/native_backend_fixture.py index bc354d1ac5..146b80bead 100644 --- a/tests/native_backend_fixture.py +++ b/tests/native_backend_fixture.py @@ -11,17 +11,12 @@ import agent_comms.agent_events as events import agent_comms.backend as backend -from agent_comms.agent_backend import ( - InputContent, - InputRequest, - NoInputStartListener, - SteerInputPolicy, - TurnInbox, -) +from agent_comms.agent_backend import NoInputStartListener +from agent_comms.turn_inbox import InputContent, InputRequest, SteerInputPolicy, TurnInbox from agent_comms.child_process import BoundedRun, join_retirement from agent_comms.comms import wire from agent_comms.coordinator import Coordination -from agent_comms.field_codec import FieldCodec, InputId +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 from agent_comms.native_child import PiSessionChild @@ -98,9 +93,10 @@ def acquire(): return comms.threads.attach_session(owned, str(session), process=owned.process_identity) thread = await Coordination.run_worker(acquire) - await agent.sessions.bind_owned(thread, thread.name) - agent.inputs.ensure_live_drain(thread.name) - return thread.name + identifier = AcpSessionId(thread.name) + await agent.sessions.bind_owned(thread, identifier) + agent.inputs.ensure_live_drain(identifier) + return identifier async def bind_saved_owner(self, agent, *, project, session): """Acquire the restored SDK source's original attested native child.""" diff --git a/tests/shared_bus_restart_native.py b/tests/shared_bus_restart_native.py index e2ea79a78c..c9d3a8f7b9 100644 --- a/tests/shared_bus_restart_native.py +++ b/tests/shared_bus_restart_native.py @@ -37,7 +37,7 @@ from agent_comms.acp_extension import decode_updates from agent_comms.messages import Message, InfoKind from agent_comms.owner_cutover import StoppedOwnerInstallation -from agent_comms.runtime import RuntimeProxy, socket_path +from agent_comms.field_codec import AcpSessionId from agent_comms.thread_identity import UserThreadRole from agent_comms.bus_publication import HumanOrigin, AgentOrigin from agent_comms.coordination_tables.assignments import WakeAssignment @@ -684,7 +684,7 @@ async def attach(service, name): async with asyncio.timeout(30), AsyncExitStack() as lifetime: client = CommsAgent(service, auto_wake=False) lifetime.push_async_callback(client.shutdown) - proxy = RuntimeProxy(client, name, socket_path(service.root, owner.pid)) + proxy = client._create_runtime_proxy(owner, AcpSessionId(name)) lifetime.push_async_callback(proxy.close) await proxy.subscribe() diff --git a/tests/test_absence_owners.py b/tests/test_absence_owners.py index 1df5626bb7..27eb6db423 100644 --- a/tests/test_absence_owners.py +++ b/tests/test_absence_owners.py @@ -5,7 +5,8 @@ import pytest from agent_comms import agent_events as events -from agent_comms.agent_backend import InputStartListener, NoInputStartListener, SendNow, TurnInbox +from agent_comms.agent_backend import InputStartListener, NoInputStartListener +from agent_comms.turn_inbox import SendNow, TurnInbox from agent_comms.backend import EventFinishSignal, FinishSignal, NoFinishSignal from agent_comms.change_report import FileLine, NoSourceReference from agent_comms.errors import TodoError diff --git a/tests/test_acp.py b/tests/test_acp.py index b4bcfa8351..58a0764f63 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -34,7 +34,7 @@ from agent_comms.context_reading import ContextUsage, UnmeasuredContext from agent_comms.errors import UnregisteredThreadError from agent_comms.coordination_errors import Refused -from agent_comms.field_codec import TurnId +from agent_comms.field_codec import AcpSessionId, FieldCodec, TurnId from agent_comms.native_arguments import GivenArguments from agent_comms.pi_counts import PiCount from agent_comms.read_basis import NamedViewer @@ -95,7 +95,8 @@ async def test_attach_metadata_restores_saved_context_without_reading_transcript self, tmp_path, native_runtime_package ): agent = self._agent(tmp_path) - await agent.new_session(cwd="/wt/proj", mcp_servers=[]) + session = await agent.new_session(cwd="/wt/proj", mcp_servers=[]) + session_id = FieldCodec.decode(AcpSessionId, session.session_id) agent._comms.agents.observe_runtime_info( "proj", ae.ContextUsageObservation(context_used=PiCount.from_wire(38723)) ) @@ -103,14 +104,14 @@ async def test_attach_metadata_restores_saved_context_without_reading_transcript "proj", ae.AgentInfo(model=DeclaredModel('test/model'), context_size=PiCount.from_wire(272000)) ) reopened = canonical_agent(wire(agent._comms.root)) - assert facts((await reopened.sessions.metadata("proj")), CoordinationChangedUpdate)[ + assert facts((await reopened.sessions.metadata("proj", session_id=session_id)), CoordinationChangedUpdate)[ 0 ].context_usage == ContextUsage(38723, 272000) agent._comms.agents.observe_runtime_info( "proj", ae.ContextInfo(context_used=PiCount.from_wire(None), context_size=PiCount.from_wire(272000)) ) assert ( - facts((await reopened.sessions.metadata("proj")), CoordinationChangedUpdate)[0].context_usage + facts((await reopened.sessions.metadata("proj", session_id=session_id)), CoordinationChangedUpdate)[0].context_usage == UnmeasuredContext() ) diff --git a/tests/test_acp_private_nk_delivery.py b/tests/test_acp_private_nk_delivery.py index baa1762c30..6d6cfa1e37 100644 --- a/tests/test_acp_private_nk_delivery.py +++ b/tests/test_acp_private_nk_delivery.py @@ -51,8 +51,9 @@ def _private_comms(tmp_path): def _session(tmp_path): comms, root_id = _private_comms(tmp_path) agent = CommsAgent(comms, runtime_enabled=True) - agent.sessions.published[AcpSessionId("beta")] = PublishedSession.of(comms.registry.require("beta")) - return comms, agent, root_id + session_id = AcpSessionId("beta") + agent.sessions.published[session_id] = PublishedSession.of(comms.registry.require("beta")) + return comms, agent, root_id, session_id def cursor_fact(metadata): @@ -118,9 +119,9 @@ def test_private_rename_preserves_independent_sql_generation(tmp_path): async def test_missing_native_schema_refuses_cursor_metadata(tmp_path, native_runtime_package): - comms, agent, _ = _session(tmp_path) - assert cursor_envelope((await agent.sessions.metadata("beta"))) is not None + comms, agent, _, session_id = _session(tmp_path) + assert cursor_envelope((await agent.sessions.metadata("beta", session_id=session_id))) is not None with Coordination(str(comms.root / "coordination.sqlite3")) as store: store.session._connection.execute("DROP TABLE native_runtime_triage_members") with pytest.raises(PublicationActivationBlocked): - await agent.sessions.metadata("beta") + await agent.sessions.metadata("beta", session_id=session_id) diff --git a/tests/test_acp_queue_contract.py b/tests/test_acp_queue_contract.py index b58729414b..65f1cda35a 100644 --- a/tests/test_acp_queue_contract.py +++ b/tests/test_acp_queue_contract.py @@ -71,7 +71,7 @@ async def test_real_acp_surrogate_queue_ingress_stays_unknown_and_attachable(tmp is ReservedInput) state = next( f - for f in decode_updates((await agent.sessions.metadata("beta", session_id="beta"))) + for f in decode_updates((await agent.sessions.metadata("beta", session_id=AcpSessionId("beta")))) if isinstance(f, QueueChangedUpdate) ) assert state.scope.claimed_identity.incarnation.name == "beta" @@ -144,7 +144,7 @@ async def test_real_acp_rejects_nonstring_user_text_before_unknown_or_enqueue(tm (17, "x" * 4096), ], ids=["long-unicode", "many-inputs", "large-total"]) async def test_queue_preview_preserves_long_input_identity_body_and_actions(tmp_path, count, text): - from agent_comms.agent_backend import ClearQueue, SendNow + from agent_comms.turn_inbox import ClearQueue, SendNow from agent_comms.acp_extension import AvailableQueueProjection, QueueItem comms, agent, _, _ = _owner(tmp_path) diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index 1f7e04b2ec..690586cfd8 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -14,7 +14,8 @@ import pytest import agent_comms.agent_backend as ab -from agent_comms.field_codec import InputId +from agent_comms.turn_inbox import InputContent, InputRequest, SendNow, SteerInputPolicy +from agent_comms.field_codec import AcpSessionId, InputId from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec from agent_comms.native_arguments import NativeArguments @@ -276,7 +277,7 @@ async def test_runner_obtains_one_backend_per_session_from_the_thread_declaratio name, frozenset(), Checkout(comms.root.parent), model=DeclaredModel('p/m'), process_identity=ProcessIdentity.capture(os.getpid()), )) - await agent.sessions.bind_owned(thread, name) + await agent.sessions.bind_owned(thread, AcpSessionId(name)) session = agent.inputs.state("session").owned() other = agent.inputs.state("other").owned() assert agent.inputs.state("attachment").executors() == () @@ -326,8 +327,8 @@ def _session_lines(path: Path) -> None: def test_inbox_records_say_which_inputs_they_carry(): input_id = InputId.mint() - request = ab.InputRequest( - input_id=input_id, content=ab.InputContent(text="t"), when_busy=ab.SteerInputPolicy, + request = InputRequest( + input_id=input_id, content=InputContent(text="t"), when_busy=SteerInputPolicy, ) assert request.carried_inputs == (input_id,) - assert ab.SendNow((input_id,)).carried_inputs == () + assert SendNow((input_id,)).carried_inputs == () diff --git a/tests/test_backend.py b/tests/test_backend.py index 74fe6d0e02..8b4865feed 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -9,7 +9,7 @@ import agent_comms.agent_events as ae import agent_comms.backend as backend -from agent_comms.agent_backend import InputContent, InputRequest, SteerInputPolicy +from agent_comms.turn_inbox import InputContent, InputRequest, SteerInputPolicy from agent_comms.field_codec import InputId from agent_comms.pi_rpc import PiRpcChannel from agent_comms.turn_watchdog import ModelWait diff --git a/tests/test_command_families.py b/tests/test_command_families.py index e241ada8bb..33e8e59752 100644 --- a/tests/test_command_families.py +++ b/tests/test_command_families.py @@ -24,10 +24,8 @@ InheritedArguments, ) from agent_comms.pi_vocabulary import ServiceTier -from agent_comms.runtime_requests import ( - PromptRuntimeRequest, - RuntimeRequest, -) +from agent_comms.owner_connection import RuntimeRequest +from agent_comms.runtime_requests import PromptRuntimeRequest from agent_comms.threads import Thread diff --git a/tests/test_context_source_inspection.py b/tests/test_context_source_inspection.py index 96d13326f8..09e739311a 100644 --- a/tests/test_context_source_inspection.py +++ b/tests/test_context_source_inspection.py @@ -11,8 +11,10 @@ from agent_comms.session_file import NoSessionFile from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.native_session_reopen import NativeSessionIdentity -from agent_comms.native_turn_context import NativeContextData, NativeContextManifestData -from agent_comms.runtime_requests import (RuntimeRequest, ContextRecordedSegmentRuntimeRequest, +from agent_comms.native_turn_context import NativeContextManifestData +from agent_comms.pi_payloads import NativeContextData +from agent_comms.owner_connection import RuntimeRequest +from agent_comms.runtime_requests import (ContextRecordedSegmentRuntimeRequest, ContextCoreRuntimeRequest, ContextCoreSourceRuntimeRequest) from agent_comms.thread_identity import TurnIdentity from agent_comms.thread_identity import RecordedThreadIncarnation, ReportedTools @@ -68,16 +70,18 @@ def test_current_native89_shape_and_authenticated_file_read(tmp_path): }) assert data.segments == (segment,) assert data.observation() == original - result = data.public_source_text(None, original, 0, source) + selected = data.public_source(original, 0, source) + assert selected == source + result = ContextSourceText(selected.public_description(), selected.public_text(None)) assert FieldCodec.decode(ContextSourceText, FieldCodec.encode(result)) == result assert result.text == path.read_text() with pytest.raises(ValueError): - data.public_source_text(None, original, 0, replace(source, path=str(tmp_path / "foreign"))) + data.public_source(original, 0, replace(source, path=str(tmp_path / "foreign"))) with pytest.raises(ValueError): - data.public_source_text(None, replace(original, context_digest="b" * 64), 0, source) + data.public_source(replace(original, context_digest="b" * 64), 0, source) path.write_text("Today's different instruction") with pytest.raises(ValueError): - data.public_source_text(None, original, 0, source) + data.public_source(original, 0, source).public_text(None) def test_current_core_context_is_independent_and_source_selection_is_current(tmp_path): @@ -115,7 +119,7 @@ async def test_core_context_socket_reads_without_native_session_or_preview(tmp_p async with canonical_delivery_owner(tmp_path) as (comms, agent, _, _): owner = comms.registry.require("alpha") assert owner.session_file == NoSessionFile() - connection = RuntimeConnection(comms, owner.name, + connection = RuntimeConnection(comms, owner.incarnation, socket_path(comms.root, owner.require_process().pid)) try: context = FieldCodec.decode(TurnContext, await connection.request("context_core")) diff --git a/tests/test_coordination_store.py b/tests/test_coordination_store.py index 4a8317aa25..699b0d6120 100644 --- a/tests/test_coordination_store.py +++ b/tests/test_coordination_store.py @@ -1267,25 +1267,30 @@ def test_public_read_exception_releases_transaction_and_mutators_reuse_it(db_pat assert db.participants.get(RECIPIENT_LOOKUP).lookup == RECIPIENT_LOOKUP -def test_observation_closes_failed_reads_and_preserves_schema_disposition(db_path: Path) -> None: - from agent_comms.coordination_database import CoordinationStore +def test_observation_rolls_back_failed_reads_and_preserves_schema_disposition(db_path: Path) -> None: + from agent_comms.coordination_database import ReadableStore with store(db_path) as writer: writer.participants.register(SENDER_LOOKUP, committed=True) + source = ReadableStore(db_path) for statement in ( "UPDATE participants SET committed=0", "SELECT * FROM nonexistent_original_table", ): with pytest.raises(sqlite3.OperationalError): - with CoordinationStore.opened_reader(db_path) as reader: + with source.reader_snapshot() as reader: reader.execute(statement) - with pytest.raises(sqlite3.ProgrammingError): - reader.execute("SELECT 1") + assert not reader.in_transaction + with source.reader_snapshot() as following: + assert following is reader + assert following.execute("SELECT 1").fetchone()[0] == 1 with pytest.raises(RuntimeError): - with CoordinationStore.opened_reader(db_path) as reader: + with source.reader_snapshot() as reader: raise RuntimeError("consumer failed") - with pytest.raises(sqlite3.ProgrammingError): - reader.execute("SELECT 1") + assert not reader.in_transaction + with source.reader_snapshot() as following: + assert following is reader + assert following.execute("SELECT 1").fetchone()[0] == 1 with store(db_path) as writer: assert writer.participants.get(SENDER_LOOKUP).committed diff --git a/tests/test_goal_execution.py b/tests/test_goal_execution.py index 75ce9cfb1a..c84846de87 100644 --- a/tests/test_goal_execution.py +++ b/tests/test_goal_execution.py @@ -16,7 +16,7 @@ from agent_comms.acp_failure import NativeTurnError from agent_comms.child_process import ChildOutcome, ProcessIdentity from agent_comms.comms import wire -from agent_comms.field_codec import FieldCodec, GoalId +from agent_comms.field_codec import AcpSessionId, FieldCodec, GoalId from agent_comms.goal_actions import ( PausedGoalAction, RetryGoalAction, @@ -71,7 +71,7 @@ def test_execution_identity_survives_wire_and_change_detection(execution_bus, ex async def turn_on(comms, *, original_owner_input=False): agent = CommsAgent(comms, auto_wake=False) owner = comms.registry.require("worker") - session = "goal-execution-session" + session = AcpSessionId("goal-execution-session") await agent.sessions.bind_owned(owner, session) turn = OwnedTurn( agent.turns, session, owner.name, "Continue original work", diff --git a/tests/test_goal_failure_observation.py b/tests/test_goal_failure_observation.py index e0641512d4..750ec924e3 100644 --- a/tests/test_goal_failure_observation.py +++ b/tests/test_goal_failure_observation.py @@ -13,7 +13,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.field_codec import TurnId, GoalId from agent_comms.turn_failure import BackendFailed, FinalStopMissing -from agent_comms.goal_attempts import AttemptRecord, GoalAttemptStore +from agent_comms.goal_attempts import AttemptRecord, Generation, GoalAttemptStore from agent_comms.errors import UnresolvedAttemptError from agent_comms.goal_failure_observation import FailedTurnObservation, NoFailedTurnEvidence from agent_comms.goal_generation import BlockedGeneration @@ -70,10 +70,8 @@ def test_duplicate_callback_and_restart_preserve_failure_bytes(bound): ) with pytest.raises(UnresolvedAttemptError): current.resume(owner.goal.id, 1) - assert ( - current.require_generation(owner.goal.id).failure_evidence(current) - == observation.evidence - ) + with current.reader_snapshot() as connection: + assert Generation.require(connection, goal_id=owner.goal.id).failure_evidence(connection) == observation.evidence assert store.path.read_bytes() == before assert len(rows(store, "failed_turn_evidence")) == 1 encoded = json.dumps(rows(store, "failed_turn_evidence")) @@ -96,7 +94,8 @@ def test_mismatched_turn_lease_is_not_bound(bound, mutation): assert rejected is None observation.reservation.fail(store, FailedTurnResolution(BackendFailed), observation=rejected) assert store.snapshot(owner.goal.id).lifecycle == BlockedGeneration() - assert store.require_generation(owner.goal.id).failure_evidence(store) == NoFailedTurnEvidence() + with store.reader_snapshot() as connection: + assert Generation.require(connection, goal_id=owner.goal.id).failure_evidence(connection) == NoFailedTurnEvidence() @pytest.mark.parametrize("binding", ["token", "evidence_identity"]) @@ -111,7 +110,8 @@ def test_mismatched_permit_cannot_attach_observation_or_weaken_failure(bound, bi observation.reservation.fail(store, FailedTurnResolution(BackendFailed), observation=forged) assert rows(store, "failed_turn_evidence") == [] assert store.snapshot(owner.goal.id).lifecycle == BlockedGeneration() - assert store.require_generation(owner.goal.id).failure_evidence(store) == NoFailedTurnEvidence() + with store.reader_snapshot() as connection: + assert Generation.require(connection, goal_id=owner.goal.id).failure_evidence(connection) == NoFailedTurnEvidence() def test_unclaimed_attempt_failure_has_no_backend_incident(bound): diff --git a/tests/test_goal_nominal.py b/tests/test_goal_nominal.py index 61231e3f18..9e2d1c14dc 100644 --- a/tests/test_goal_nominal.py +++ b/tests/test_goal_nominal.py @@ -8,7 +8,7 @@ from agent_comms.field_codec import FieldCodec from agent_comms.field_codec import GoalId -from agent_comms.agent_events import GoalUnchanged +from agent_comms.agent_events import GoalChanged, GoalUnchanged from agent_comms.comms import wire from agent_comms.goal_actions import ( ActiveGoalAction, @@ -99,6 +99,28 @@ def test_goal_changed_compares_goal_snapshots(owner): assert isinstance(comms.goals.goal_changed("worker", event.snapshot), GoalUnchanged) +def test_owner_declaration_routes_without_claiming_complete_projection_equality(owner): + from agent_comms.goal_presentation import UnpublishedGoalSnapshot + + comms, goal = owner + assert UnpublishedGoalSnapshot().goal_changed(goal) + published = comms.goals.goal_snapshot("worker") + assert not published.goal_changed(goal) + changed = wire(comms.root).goals.update_goal("worker", ActiveGoalAction(progress="external")) + assert published.goal_changed(changed) + assert comms.goals.goal_changed("worker", published) == GoalChanged( + comms.goals.goal_snapshot("worker")) + + +def test_unrelated_attempt_commit_requests_a_real_comparison_not_a_second_revision(owner): + comms, _ = owner + published = comms.goals.goal_snapshot("worker") + before = comms.registry.store.path.read_bytes() + comms.goals.open_goal_store().create_goal(GoalId.mint()) + assert comms.registry.store.path.read_bytes() == before + assert isinstance(comms.goals.goal_changed("worker", published), GoalUnchanged) + + def test_original_proxy_decodes_typed_goal_control_at_json_boundary(): original_goal_id = GoalId.mint() from agent_comms.owner_connection import RuntimeRequest diff --git a/tests/test_goal_publication_sources.py b/tests/test_goal_publication_sources.py new file mode 100644 index 0000000000..4693f2f53f --- /dev/null +++ b/tests/test_goal_publication_sources.py @@ -0,0 +1,191 @@ +"""Real goal owners and ACP consumers; authored UNRUN for root qualification.""" + +import asyncio +from dataclasses import dataclass, field, replace +import sqlite3 + +from acp.schema import SessionInfoUpdate +from pydantic import BaseModel +import pytest + +from agent_comms.acp_extension import GoalChangedUpdate, decode_updates +from agent_comms.child_process import ProcessIdentity +from agent_comms.comms import wire +from agent_comms.controller import SessionUpdateSink +from agent_comms.external_transcript import ExternalTranscript +from agent_comms.runtime_updates import ThreadOutput +from agent_comms.field_codec import AcpSessionId, TurnId +from agent_comms.goal_actions import EditGoalAction, SetGoalAction, StandbyGoalAction +from agent_comms.goal_attempts import Generation, GoalAttemptStore, StorageUncertainError +from agent_comms.goal_presentation import StandbyExecution +from agent_comms.goal_waits import GoalWaits +from agent_comms.locked_store import GoalWakeFile, RegistryFile +from agent_comms.repo import Checkout +from agent_comms.thread_execution import ExternalThreadExecution +from agent_comms.thread_text import GivenThreadText +from agent_comms.threads import Thread +from agent_comms.turn_phase import IdlePhase, ToolRunningPhase +from delivery_owner_fixture import canonical_agent +from goal_owner_fixture import canonical_goal_wire + + +@dataclass +class GoalLog(SessionUpdateSink): + published: list[tuple[str, GoalChangedUpdate]] = field(default_factory=list) + + async def publish_update(self, *, session_id: str, update: BaseModel) -> bool: + # ACP is the foreign boundary; the production GoalChanged consumer and + # protocol codec still do all publication work. + if isinstance(update, SessionInfoUpdate): + self.published.extend((session_id, item) for item in decode_updates(update.field_meta) + if isinstance(item, GoalChangedUpdate)) + return True + + +class CountedWake(asyncio.Event): + def __init__(self): + super().__init__() + self.calls = 0 + + def set(self) -> None: + self.calls += 1 + super().set() + + +async def until(predicate): + async with asyncio.timeout(8): + while not predicate(): + await asyncio.sleep(0.01) + + +@pytest.fixture +async def publication(tmp_path): + comms = canonical_goal_wire(tmp_path / "wire") + agent = canonical_agent(comms, auto_wake=False) + client = GoalLog() + agent.on_connect(client) + thread = agent.sessions.declare_thread(str(tmp_path)) + await agent.sessions.bind_owned(thread, AcpSessionId(thread.name)) + comms.goals.update_goal(thread.name, SetGoalAction(text="Keep this objective")) + try: + yield comms, agent, agent.inputs.state(thread.name).owned().identifier, client + finally: + await agent.shutdown() + + +def projection_files(comms): + return (comms.registry.store.path, GoalWaits.path_in(comms.root), + GoalAttemptStore.path_in(GoalAttemptStore.directory_in(comms.root))) + + +async def test_requested_refresh_observes_activity_with_unchanged_durable_goal_files(publication): + comms, agent, session, client = publication + child = comms.registry.declare(Thread( + "child", frozenset(), Checkout(comms.root), execution=ExternalThreadExecution, + process_identity=ProcessIdentity.current(), + )) + output = ThreadOutput(ExternalTranscript(comms.root, child.incarnation), comms.agents) + output.enter(ToolRunningPhase("delegated work")) + comms.goals.update_goal(session, StandbyGoalAction(wait_for=(child.name,))) + scheduler = agent.turns.goals + await scheduler.sync_goal_execution(session) + first = client.published[-1][1] + assert isinstance(first.execution, StandbyExecution) + assert first.execution.inactive_wait_for == () + before = {path: path.read_bytes() for path in projection_files(comms)} + output.enter(IdlePhase()) + assert {path: path.read_bytes() for path in before} == before + await scheduler.sync_goal_execution(session) + second = client.published[-1][1] + assert second.goal == first.goal + assert isinstance(second.execution, StandbyExecution) + assert tuple(target.incarnation.name for target in second.execution.inactive_wait_for) == (child.name,) + assert second.execution.presentation(session).summary == "Standby · waiting for @child; no active turn: @child" + assert len(client.published) == 2 + reads = comms.registry.store.cache.read_counts().requests + await scheduler.sync_goal_execution(session) + assert len(client.published) == 2 + assert comms.registry.store.cache.read_counts().requests > reads + + +async def test_registry_routing_uses_only_the_shared_document_and_published_goal(publication): + comms, agent, session, client = publication + scheduler = agent.turns.goals + await scheduler.sync_goal_execution(session) + document = comms.registry.store.read() + original = scheduler.publication(session).snapshot + reads = comms.registry.store.cache.read_counts().requests + await scheduler.observe_root(document, (session,), frozenset((RegistryFile.path_in(comms.root),))) + assert comms.registry.store.cache.read_counts().requests == reads + assert scheduler.publication(session).snapshot is original + assert len(client.published) == 1 + assert GoalWaits.source_paths(comms.root)[0] in GoalWakeFile.execution_paths_in(comms.root) + assert RegistryFile.path_in(comms.root) not in GoalWakeFile.execution_paths_in(comms.root) + + +async def test_external_mid_turn_goal_reaches_actual_consumer_without_inbox_wake(publication): + comms, agent, session, client = publication + # The durable native turn is real; there is deliberately no provider and no + # accepted bus input. Its busy fence prevents an autonomous model launch. + lease = comms.agents.begin_turn(session, TurnId.mint()).turn_lease + unrelated = comms.registry.declare(Thread("unrelated", frozenset(), Checkout(comms.root))) + state = agent.inputs.state(session) + wake = CountedWake() + state.observation_ready = wake + agent.inputs.ensure_live_drain(session) + scheduler = agent.turns.goals + try: + await until(lambda: state.observation_watcher is not None and state.settled() + and not state.observation_ready.is_set() and not state.repeat_observation + and scheduler.publication(session).snapshot.goal_changed( + comms.registry.require(session).goal) is False) + before_messages = comms.bus.bus.revision() + before_wakes, before_events = wake.calls, len(client.published) + previous_read = agent.inputs.observed_registry + wire(comms.root).registry.register(replace(unrelated, title=GivenThreadText("unrelated edit"))) + await until(lambda: agent.inputs.observed_registry is not previous_read) + assert wake.calls == before_wakes + assert len(client.published) == before_events + changed = wire(comms.root).goals.update_goal(session, EditGoalAction(text="Changed externally")) + await until(lambda: client.published[-1][1].goal == changed) + assert len(client.published) == before_events + 1 + assert wake.calls == before_wakes + assert comms.bus.bus.revision() == before_messages + assert comms.registry.require(session).turn_lease == lease + finally: + comms.agents.finish_turn(lease) + + +async def test_attempt_signal_refreshes_complete_projection_but_does_not_publish_an_identical_one(publication): + comms, agent, session, client = publication + scheduler = agent.turns.goals + await scheduler.sync_goal_execution(session) + store = comms.goals.open_goal_store() + document = comms.registry.store.read() + before = scheduler.publication(session).snapshot + reads = comms.registry.store.cache.read_counts().requests + await scheduler.observe_root(document, (session,), frozenset(store.source_paths(store.path.parent))) + assert comms.registry.store.cache.read_counts().requests > reads + assert scheduler.publication(session).snapshot is before + assert len(client.published) == 1 + + +async def test_failed_goal_read_keeps_cause_and_never_creates_input_diagnostic(publication, caplog): + comms, agent, session, client = publication + scheduler = agent.turns.goals + await scheduler.sync_goal_execution(session) + before = scheduler.publication(session).snapshot + diagnostics = tuple((comms.root / "diagnostics").glob("drain-*.json")) + store = comms.goals.open_goal_store() + path = store.path + document = comms.registry.store.read() + with sqlite3.connect(path) as db: + db.execute(f'DROP TABLE "{Generation.declared_name}"') + with pytest.raises(StorageUncertainError): + await scheduler.sync_goal_execution(session) + await scheduler.observe_root(document, (session,), frozenset(store.source_paths(path.parent))) + assert any(record.exc_info and isinstance(record.exc_info[1], StorageUncertainError) + for record in caplog.records) + assert scheduler.publication(session).snapshot is before + assert len(client.published) == 1 + assert tuple((comms.root / "diagnostics").glob("drain-*.json")) == diagnostics diff --git a/tests/test_goal_scheduler.py b/tests/test_goal_scheduler.py index 3d7e910e77..efb5babd8d 100644 --- a/tests/test_goal_scheduler.py +++ b/tests/test_goal_scheduler.py @@ -1,5 +1,6 @@ """Ledger readiness fences scheduling without rewriting the declared goal.""" +from agent_comms.field_codec import AcpSessionId from agent_comms.goal_actions import SetGoalAction from agent_comms.goal_generation import ReadyGeneration, ReservedGeneration from agent_comms.goal_scope import OneGoal @@ -11,7 +12,7 @@ async def test_reserved_goal_is_unchanged_and_schedules_after_operator_stop(comms, tmp_path): owner = canonical_agent(comms, auto_wake=False) thread = owner.sessions.declare_thread(str(tmp_path)) - session = thread.name + session = AcpSessionId(thread.name) await owner.sessions.bind_owned(thread, session) try: goal = comms.goals.update_goal(thread.name, SetGoalAction(text="Finish the private work")) diff --git a/tests/test_input_delivery_latency.py b/tests/test_input_delivery_latency.py index 87ed093094..3c47d7b67f 100644 --- a/tests/test_input_delivery_latency.py +++ b/tests/test_input_delivery_latency.py @@ -9,7 +9,7 @@ from time import perf_counter from agent_comms import backend -from agent_comms.agent_backend import SteerInputPolicy +from agent_comms.turn_inbox import SteerInputPolicy from agent_comms.comms import Comms from agent_comms.coordinator import Coordination from agent_comms.field_codec import InputId diff --git a/tests/test_locked_store.py b/tests/test_locked_store.py index e4c13304d0..729a221c16 100644 --- a/tests/test_locked_store.py +++ b/tests/test_locked_store.py @@ -14,7 +14,8 @@ import pytest -from agent_comms.locked_store import DocumentChange, LockedStore +from agent_comms.locked_store import DocumentBytes, DocumentChange, LockedStore +from agent_comms.private_path import FileRevision, MissingFile from agent_comms.coordination_errors import StaleRevision from agent_comms.pi_counts import PiCount from agent_comms.registry_store import RegistryStore @@ -31,6 +32,21 @@ ) +@pytest.mark.parametrize("existing", (False, True)) +def test_document_bytes_captures_original_missing_or_existing_file(tmp_path, existing): + path = tmp_path / "document" / "value.json" + content = b'{"original":"bytes"}\n' + if existing: + path.parent.mkdir() + path.write_bytes(content) + captured = DocumentBytes.capture(path) + if existing: + assert captured == DocumentBytes(FileRevision.capture(path), content) + else: + assert captured == DocumentBytes(MissingFile(), b"") + assert not path.exists() and not path.parent.exists() + + def observation(thread="goal"): return AgentRuntimeInfo(thread=thread, timestamp=122.0, context_used=PiCount.from_wire(2)) diff --git a/tests/test_mcp_acceptance.py b/tests/test_mcp_acceptance.py index 3cd9f6e128..59bd999298 100644 --- a/tests/test_mcp_acceptance.py +++ b/tests/test_mcp_acceptance.py @@ -37,9 +37,8 @@ from agent_comms.child_process import SpawnedProcess from agent_comms.comms import wire from agent_comms.coordinator import Coordination -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import AcpSessionId, FieldCodec from agent_comms.native_arguments import GivenArguments, GivenBinary -from agent_comms.runtime import RuntimeProxy from agent_comms.thread_identity import NoObservedTurn, TurnFence from delivery_owner_fixture import canonical_agent @@ -481,11 +480,12 @@ async def request_permission(self, **kwargs): owner.on_connect(Audit()) # Passive evidence sink, never a permission controller. session = await owner.new_session(cwd=str(project), mcp_servers=[]) - session_id = session.session_id + session_id = FieldCodec.decode(AcpSessionId, session.session_id) attached = canonical_agent(owner._comms, auto_wake=False) retirement.push_async_callback(attached.shutdown) attached.on_connect(Attachment()) - proxy = RuntimeProxy(attached, session_id, owner._runtime.path) + proxy = attached._create_runtime_proxy( + owner._comms.registry.require(session_id), session_id) retirement.push_async_callback(proxy.close) await proxy.subscribe() assert not attachment_settled.is_set() # Initial idle replay is not this turn. diff --git a/tests/test_native_input_bus_path.py b/tests/test_native_input_bus_path.py index d6c39b0356..9402251e03 100644 --- a/tests/test_native_input_bus_path.py +++ b/tests/test_native_input_bus_path.py @@ -11,7 +11,7 @@ import agent_comms.native_package as native_package import pytest -from agent_comms.agent_backend import ClearQueue, InputContent, InputRequest, SteerInputPolicy, TurnInbox +from agent_comms.turn_inbox import ClearQueue, InputContent, InputRequest, SteerInputPolicy, TurnInbox from agent_comms.backend import NativeSession from agent_comms.child_process import Platform from agent_comms.field_codec import InputId diff --git a/tests/test_owned_turn_goal_replacement.py b/tests/test_owned_turn_goal_replacement.py index a4fde9d1fa..ceb5987fc4 100644 --- a/tests/test_owned_turn_goal_replacement.py +++ b/tests/test_owned_turn_goal_replacement.py @@ -9,10 +9,10 @@ from agent_comms import backend from agent_comms.acp import CommsAgent -from agent_comms.agent_backend import InputContent +from agent_comms.turn_inbox import InputContent from agent_comms.channel_input_batch import InputBatch from agent_comms.child_process import ProcessIdentity -from agent_comms.field_codec import InputId, OrderId, TurnId +from agent_comms.field_codec import AcpSessionId, InputId, OrderId, TurnId from agent_comms.goal_actions import ( BlockedGoalAction, CreateProblemGoalAction, @@ -117,6 +117,9 @@ async def test_turn_after_order_replacement_is_allowed(tmp_path, native_runtime_ await asyncio.gather(task, return_exceptions=True) +WORKER_SESSION = AcpSessionId('worker-session') + + @pytest.fixture async def prepared_owner(tmp_path): comms = installed_comms(tmp_path) @@ -127,7 +130,7 @@ async def prepared_owner(tmp_path): )) agent = CommsAgent(comms, auto_wake=False) try: - await agent.sessions.bind_owned(comms.registry.require('worker'), 'worker-session') + await agent.sessions.bind_owned(comms.registry.require('worker'), WORKER_SESSION) yield comms, agent finally: await agent.shutdown() @@ -146,7 +149,7 @@ async def test_goal_published_during_preparation_is_current_at_reservation(tmp_p repo=RepositoryName.from_wire('org/repo'), text='Repair the turn owner', )) actor = comms.registry.require('lead').incarnation - old = OwnedTurn(agent.turns, 'worker-session', 'worker', 'first input', + old = OwnedTurn(agent.turns, WORKER_SESSION, 'worker', 'first input', original_owner_input=True, original_goal_id=initial.id, acceptance=NoTurnAcceptance()) async with AsyncExitStack() as resources, AsyncExitStack() as permits: @@ -162,7 +165,7 @@ async def test_goal_published_during_preparation_is_current_at_reservation(tmp_p # Exercise the real lease-to-reservation boundary without replacing any methods. # A second commander publication during preparation makes the leased snapshot stale. - current = OwnedTurn(agent.turns, 'worker-session', 'worker', 'next input', + current = OwnedTurn(agent.turns, WORKER_SESSION, 'worker', 'next input', original_owner_input=True, original_goal_id=replaced.id, acceptance=NoTurnAcceptance()) assert current.authorize() @@ -210,7 +213,7 @@ async def test_order_reactivating_a_blocked_goal_keeps_the_running_turn(tmp_path ) actor = comms.registry.require('lead').incarnation # QueuedInputContext.active_goal_id of a blocked goal, as queued_input.py passes it. - turn = OwnedTurn(agent.turns, 'worker-session', 'worker', 'retry order message', + turn = OwnedTurn(agent.turns, WORKER_SESSION, 'worker', 'retry order message', original_owner_input=True, original_goal_id=blocked.active().reported_id(), acceptance=NoTurnAcceptance()) async with AsyncExitStack() as resources, AsyncExitStack() as permits: diff --git a/tests/test_owner_process.py b/tests/test_owner_process.py index 0bff6cc07e..b8e5722745 100644 --- a/tests/test_owner_process.py +++ b/tests/test_owner_process.py @@ -24,14 +24,14 @@ from agent_comms.config_options import ThinkingLevelConfigOption from agent_comms.coordinator import Coordination from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import GoalId, TurnId, compile_decoder +from agent_comms.field_codec import AcpSessionId, GoalId, TurnId, compile_decoder from agent_comms.launch_root import LaunchRoot from agent_comms.launch_runtime import LaunchRuntime +from agent_comms.runtime import socket_path from agent_comms.native_arguments import GivenArguments, GivenBinary from agent_comms.owner_lifecycle import NoRelease from agent_comms.pi_vocabulary import ThinkingLevelField from agent_comms.restart_scope import OwnersNamed -from agent_comms.runtime import RuntimeProxy, socket_path from agent_comms.session_file import NoSessionFile from agent_comms.thread_model import DeclaredModel from agent_comms.thread_text import GivenThreadText @@ -354,7 +354,7 @@ async def attach(): async with asyncio.timeout(20), AsyncExitStack() as lifetime: client = canonical_agent(comms, auto_wake=False) lifetime.push_async_callback(client.shutdown) - proxy = RuntimeProxy(client, owner.name, socket_path(comms.root, owner.require_process().pid)) + proxy = client._create_runtime_proxy(owner, AcpSessionId(owner.name)) lifetime.push_async_callback(proxy.close) await proxy.subscribe() diff --git a/tests/test_project.py b/tests/test_project.py index 54f9fa57af..b3d260e09e 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -9,6 +9,8 @@ from pathlib import Path import pytest + +from agent_comms.field_codec import AcpSessionId, FieldCodec from acp import RequestError from delivery_owner_fixture import canonical_agent @@ -64,14 +66,12 @@ async def publish_update(self, **kwargs): @pytest.mark.skipif(os.name == "nt", reason="UNIX runtime subscriptions") async def test_project_changes_reach_all_subscribed_clients(tmp_path, native_runtime_package): - from agent_comms.runtime import RuntimeProxy, socket_path - old, new = tmp_path / "old", tmp_path / "new" old.mkdir() new.mkdir() comms = wire(tmp_path / "wire") owner = canonical_agent(comms, agent_bin=GivenBinary("/bin/echo"), agent_args=GivenArguments(()), runtime_enabled=True) - session = (await owner.new_session(str(old))).session_id + session = FieldCodec.decode(AcpSessionId, (await owner.new_session(str(old))).session_id) proxies, updates = [], [[], []] class Client(SessionUpdateSink): @@ -86,7 +86,7 @@ async def publish_update(self, session_id, update): for values in updates: client = canonical_agent(comms) client.on_connect(Client(values)) - proxy = RuntimeProxy(client, session, socket_path(comms.root, os.getpid())) + proxy = client._create_runtime_proxy(comms.registry.require(session), session) proxies.append(proxy) assert next( update.worktree diff --git a/tests/test_prompt_response.py b/tests/test_prompt_response.py index e370ec3657..0665449a37 100644 --- a/tests/test_prompt_response.py +++ b/tests/test_prompt_response.py @@ -5,7 +5,7 @@ import pytest -from agent_comms.agent_backend import FollowUpInputPolicy, InputContent, InputRequest +from agent_comms.turn_inbox import FollowUpInputPolicy, InputContent, InputRequest from agent_comms.field_codec import FieldCodec, InputId from agent_comms.image_input import ImageInput from agent_comms.native_tool_call import OwnerToolConnection diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 0a609af0cb..33303d7e0f 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -22,10 +22,10 @@ ) from agent_comms.child_process import ProcessIdentity, SpawnedProcess from agent_comms.comms import wire -from agent_comms.field_codec import RootId +from agent_comms.field_codec import AcpSessionId, FieldCodec, RootId from agent_comms.owner_protocol import OwnerAnnouncement, OwnerReady, OwnerResult, OwnerUpdate from agent_comms.runtime import ( - RuntimeConnection, RuntimeProxy, present_session, socket_path, + RuntimeConnection, present_session, socket_path, ) from agent_comms.private_path import PrivateSocketRole from agent_comms.thread_identity import RecordedThreadIncarnation @@ -67,7 +67,7 @@ async def disconnect_after_request(reader, writer): with PrivateSocketRole.address(path) as address: server = await asyncio.start_unix_server(disconnect_after_request, path=address) - connection = RuntimeConnection(comms, "reply-loss", path) + connection = RuntimeConnection(comms, comms.registry.require("reply-loss").incarnation, path) try: with pytest.raises(OwnerReplyLostError) as lost: await connection.request("cancel") @@ -133,7 +133,7 @@ async def publish_update(self, session_id, update): proxy = None try: response = await owner.new_session(str(tmp_path)) - original = response.session_id + original = FieldCodec.decode(AcpSessionId, response.session_id) timestamp = "2026-10-20T00:00:00.000Z" journal = tmp_path / "history.jsonl" journal.write_text("".join(json.dumps(record) + "\n" for record in ( @@ -147,8 +147,8 @@ async def publish_update(self, session_id, update): store.participants.register(thread.incarnation.lookup, committed=True) comms.threads.attach_session(thread, str(journal), process=thread.process_identity) comms.threads.rename_managed_thread(original, "renamed", owner=ProcessIdentity.capture(os.getpid())) - requested = original if request_original else "renamed" - proxy = RuntimeProxy(client, requested, socket_path(comms.root, os.getpid())) + requested = original if request_original else AcpSessionId("renamed") + proxy = client._create_runtime_proxy(comms.registry.require(requested), requested) await proxy.subscribe() snapshots = [(session, fact) for session, update in updates for fact in decode_updates(update.get("_meta")) @@ -244,7 +244,7 @@ async def receive(reader, writer): new_address = addresses.enter_context(PrivateSocketRole.address(new_path)) old_server = await asyncio.start_unix_server(handler("old"), path=old_address) new_server = None - proxy = RuntimeProxy(client, "worker", old_path) + proxy = client._create_runtime_proxy(comms.registry.require("worker"), AcpSessionId("worker")) try: await proxy.subscribe() await until( diff --git a/tests/test_runtime_goal_edit.py b/tests/test_runtime_goal_edit.py index 31a9c8305f..661c76857e 100644 --- a/tests/test_runtime_goal_edit.py +++ b/tests/test_runtime_goal_edit.py @@ -9,14 +9,13 @@ from delivery_owner_fixture import canonical_agent from agent_comms.acp_extension import GoalChangedUpdate, decode_updates from agent_comms.comms import wire -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import AcpSessionId, FieldCodec from agent_comms.goal_presentation import GoalExecution from agent_comms.goal_actions import ( OwnerInvocable, PausedGoalAction, SetGoalAction, ) -from agent_comms.runtime import RuntimeProxy, socket_path from agent_comms.native_arguments import GivenArguments, GivenBinary @@ -32,7 +31,7 @@ async def test_goal_edit_and_history_over_owner_socket(tmp_path, monkeypatch, na owner = canonical_agent(comms, agent_bin=GivenBinary("pi"), agent_args=GivenArguments(()), runtime_enabled=True) # Keep this non-provider test's goal paused. Edits must preserve that state. response = await owner.new_session(str(tmp_path / "project")) - session = response.session_id + session = FieldCodec.decode(AcpSessionId, response.session_id) goal = comms.goals.update_goal(session, SetGoalAction(text="Review @child's implementation")) paused = comms.goals.update_goal(session, PausedGoalAction(), actor=OwnerInvocable) updates = [] @@ -44,7 +43,7 @@ async def publish_update(self, session_id, update): observer = canonical_agent(comms) observer.on_connect(Client()) - proxy = RuntimeProxy(observer, session, socket_path(comms.root, os.getpid())) + proxy = observer._create_runtime_proxy(comms.registry.require(session), session) try: (presented,) = goal_updates(await proxy.subscribe()) assert presented.goal == paused diff --git a/tests/test_runtime_owned_session_admission.py b/tests/test_runtime_owned_session_admission.py index 557db7db49..55dc5b987e 100644 --- a/tests/test_runtime_owned_session_admission.py +++ b/tests/test_runtime_owned_session_admission.py @@ -17,10 +17,10 @@ from agent_comms.comms import wire from agent_comms.child_process import ProcessIdentity from agent_comms.errors import UnregisteredThreadError -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import AcpSessionId, FieldCodec from agent_comms.owner_protocol import OwnerFailure, OwnerLine from agent_comms.private_path import PrivateSocketRole -from agent_comms.runtime import RuntimeProxy, socket_path +from agent_comms.runtime import socket_path from agent_comms.runtime_requests import GoalHistoryRuntimeRequest from agent_comms.threads import Thread @@ -63,7 +63,7 @@ async def test_cli_registration_refuses_unbound_then_uses_original_owned_session assert json.loads(registered.stdout) == {"registered": "registered"} thread = comms.registry.require("registered") await owner._runtime.start() - proxy = RuntimeProxy(client, "registered", socket_path(root, os.getpid())) + proxy = client._create_runtime_proxy(thread, AcpSessionId("registered")) try: for action, parameters in ( ( @@ -94,7 +94,7 @@ async def test_cli_registration_refuses_unbound_then_uses_original_owned_session retired = Thread('retired-owner', frozenset(), Checkout(project), process_identity=ProcessIdentity.capture(os.getpid())) comms.registry.declare(retired) - await owner.sessions.bind_owned(retired, "retired-acp-session") + await owner.sessions.bind_owned(retired, AcpSessionId("retired-acp-session")) await asyncio.to_thread(comms.owners.stop, retired.name) comms.registry.remove(retired.name) snapshot = comms.registry.snapshot() @@ -105,7 +105,7 @@ async def test_cli_registration_refuses_unbound_then_uses_original_owned_session # Use the original production publisher before another independent read. # The failed prompt is never resent or turned into a binding. - await owner.sessions.bind_owned(thread, "original-acp-session") + await owner.sessions.bind_owned(thread, AcpSessionId("original-acp-session")) assert await proxy.request("goal_history") == {"history": [], "failures": []} comms.registry.rename("registered", "renamed-owner") assert await proxy.request("goal_history") == {"history": [], "failures": []} diff --git a/tests/test_runtime_subscription_handshake.py b/tests/test_runtime_subscription_handshake.py index 3fc2ac88c5..a29f03c2d3 100644 --- a/tests/test_runtime_subscription_handshake.py +++ b/tests/test_runtime_subscription_handshake.py @@ -16,10 +16,10 @@ from agent_comms.comms import wire from agent_comms.coordination_database import CoordinationStore from agent_comms.coordination_errors import CoordinationReadUnavailable -from agent_comms.field_codec import MalformedRecord +from agent_comms.field_codec import AcpSessionId, MalformedRecord from agent_comms.owner_protocol import OwnerAnnouncement from agent_comms.private_path import PrivateSocketRole -from agent_comms.runtime import RuntimeProxy, socket_path +from agent_comms.runtime import socket_path from agent_comms.threads import Thread pytestmark = pytest.mark.skipif(os.name == "nt", reason="POSIX owner socket") @@ -37,10 +37,10 @@ async def test_subscription_busy_read_retains_original_traceback(tmp_path, caplo session_file.chmod(0o600) thread = Thread('owner', frozenset(), Checkout(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()), session_file=RegistrySessionLocation.from_path(str(session_file), comms.root)) comms.registry.declare(thread) - await owner.sessions.bind_owned(thread, thread.name) + await owner.sessions.bind_owned(thread, AcpSessionId(thread.name)) database = comms.root / "coordination.sqlite3" CoordinationStore(database).close() - proxy = RuntimeProxy(client, thread.name, socket_path(comms.root, os.getpid())) + proxy = client._create_runtime_proxy(thread, AcpSessionId(thread.name)) holder = sqlite3.connect(database, isolation_level=None) try: with caplog.at_level(logging.ERROR, logger="agent_comms.runtime"): @@ -119,7 +119,7 @@ async def reply(reader, writer): with PrivateSocketRole.address(path) as address: server = await asyncio.start_unix_server(reply, path=address) client = CommsAgent(comms, auto_wake=False) - proxy = RuntimeProxy(client, "owner", path) + proxy = client._create_runtime_proxy(comms.registry.require("owner"), AcpSessionId("owner")) try: async with asyncio.timeout(3): with pytest.raises(MalformedRecord): diff --git a/tests/test_session_close.py b/tests/test_session_close.py index 9d27c29d03..b5cead58a7 100644 --- a/tests/test_session_close.py +++ b/tests/test_session_close.py @@ -15,8 +15,8 @@ from agent_comms.acp import CommsAgent, CommsClient from agent_comms.comms import wire from conftest import installed_comms -from agent_comms.runtime import RuntimeProxy, socket_path from agent_comms.child_process import ProcessIdentity +from agent_comms.field_codec import AcpSessionId from agent_comms.threads import Thread @@ -56,7 +56,7 @@ async def test_close_retires_one_real_subscription_and_permission(tmp_path, monk project = tmp_path / name project.mkdir() thread = owner.sessions.declare_thread(str(project)) - await owner.sessions.bind_owned(thread, name) + await owner.sessions.bind_owned(thread, AcpSessionId(name)) await client.sessions.attach_owner(thread, name) await other.sessions.attach_owner(comms.registry.require("alpha"), "alpha") alpha = client.sessions.proxies["alpha"] @@ -109,8 +109,9 @@ async def test_close_joins_unsent_request_and_capability_requires_claim(tmp_path comms = wire(tmp_path / "w") comms.registry.declare(Thread('cold', frozenset(), Checkout(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) client = CommsClient(comms, auto_wake=False, use_unstable_protocol=True) - proxy = RuntimeProxy(client, "cold", socket_path(comms.root, os.getpid())) - client.sessions.proxies["cold"] = proxy + identifier = AcpSessionId("cold") + proxy = client._create_runtime_proxy(comms.registry.require("cold"), identifier) + client.sessions.proxies[identifier] = proxy request = asyncio.create_task(proxy.request("clear_queue")) try: async with asyncio.timeout(5): diff --git a/tests/test_sqlite_reader_lifetime.py b/tests/test_sqlite_reader_lifetime.py new file mode 100644 index 0000000000..1537bb66e2 --- /dev/null +++ b/tests/test_sqlite_reader_lifetime.py @@ -0,0 +1,131 @@ +"""Physical reader ownership; authored for root qualification, not author-run.""" + +import gc +import os +import sqlite3 +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing + +import pytest + +from agent_comms.coordination_errors import CoordinationReadUnavailable +from agent_comms.locked_store import FileStore, SQLiteReader, SQLiteStorage +from agent_comms.store_files import StoreBusy, StoreWait + + +class ReaderFile(FileStore, SQLiteReader): + """Compose the real physical-path and read-transaction owners without overrides.""" + + +def database(path, value): + with closing(SQLiteStorage.writer(path)) as db: + db.execute("CREATE TABLE value (number INTEGER NOT NULL)") + db.execute("INSERT INTO value VALUES (?)", (value,)) + db.commit() + path.chmod(0o600) + return ReaderFile(path) + + +def value(db): + return db.execute("SELECT number FROM value").fetchone()[0] + + +@pytest.mark.parametrize("change", ("replace", "unlink")) +def test_nested_cut_survives_physical_path_change(tmp_path, change): + path = tmp_path / "read.sqlite3" + store = database(path, 1) + replacement = database(tmp_path / "replacement.sqlite3", 2) + with store.reader_snapshot() as outer: + assert value(outer) == 1 + if change == "replace": + replacement.path.replace(path) + else: + path.unlink() + with store.reader_snapshot() as nested: + assert nested is outer and value(nested) == 1 + assert outer.in_transaction and value(outer) == 1 + assert not outer.in_transaction + if change == "replace": + with store.reader_snapshot() as later: + assert later is not outer and value(later) == 2 + else: + with pytest.raises(FileNotFoundError): + with store.reader_snapshot(): + pytest.fail("A new outer read cannot borrow the retired cut") + + +def test_same_thread_reuses_handle_and_only_outer_scope_ends_the_cut(tmp_path): + store = database(tmp_path / "read.sqlite3", 1) + with store.reader_snapshot() as outer: + with store.reader_snapshot() as nested: + assert nested is outer and value(nested) == 1 + assert outer.in_transaction + with pytest.raises(sqlite3.OperationalError): + outer.execute("INSERT INTO value VALUES (2)") + with store.reader_snapshot() as later: + assert later is outer and value(later) == 1 + + +def test_retained_handle_uses_each_outer_reads_contention_and_fresh_cut(tmp_path): + store = database(tmp_path / "read.sqlite3", 1) + with store.reader_snapshot(StoreWait()) as first: + assert first.execute("PRAGMA busy_timeout").fetchone()[0] == 5000 + assert value(first) == 1 + with closing(SQLiteStorage.writer(store.path)) as writer: + writer.execute("UPDATE value SET number=2") + writer.commit() + with store.reader_snapshot(StoreBusy()) as fresh: + assert fresh is first and value(fresh) == 2 + assert fresh.execute("PRAGMA busy_timeout").fetchone()[0] == 0 + with closing(SQLiteStorage.writer(store.path, isolation_level=None)) as writer: + writer.execute("BEGIN EXCLUSIVE") + with pytest.raises(CoordinationReadUnavailable): + with store.reader_snapshot(StoreBusy()): + pytest.fail("An optional read must refuse the held writer") + assert not first.in_transaction + writer.execute("ROLLBACK") + with store.reader_snapshot(StoreWait()) as restored: + assert restored is first and value(restored) == 2 + assert restored.execute("PRAGMA busy_timeout").fetchone()[0] == 5000 + + +def test_threads_and_store_instances_do_not_share_physical_handles(tmp_path): + store = database(tmp_path / "read.sqlite3", 1) + other_store = ReaderFile(store.path) + with store.reader_snapshot() as main, other_store.reader_snapshot() as other: + assert main is not other + with ThreadPoolExecutor(max_workers=1) as worker: + def observe(): + with store.reader_snapshot() as foreign: + assert foreign is not main and foreign is not other + return value(foreign) + assert worker.submit(observe).result(timeout=3) == 1 + assert value(main) == value(other) == 1 + + +def test_store_lifetime_retires_the_physical_resource(tmp_path): + store = database(tmp_path / "read.sqlite3", 1) + with store.reader_snapshot() as db: + assert value(db) == 1 + del store + gc.collect() + with pytest.raises(sqlite3.ProgrammingError): + value(db) + + +@pytest.mark.skipif(not hasattr(os, "fork"), reason="Requires actual fork identity") +def test_child_does_not_borrow_parents_active_cut(tmp_path): + store = database(tmp_path / "read.sqlite3", 1) + with store.reader_snapshot() as parent: + assert value(parent) == 1 + child = os.fork() + if child == 0: + try: + with store.reader_snapshot() as own: + assert own is not parent and value(own) == 1 + os._exit(0) + except BaseException: + os._exit(1) + _, status = os.waitpid(child, 0) + assert os.waitstatus_to_exitcode(status) == 0 + assert parent.in_transaction and value(parent) == 1 diff --git a/tests/test_stack_goal_cancel_resume.py b/tests/test_stack_goal_cancel_resume.py index 08c58579dd..373fac7b7d 100644 --- a/tests/test_stack_goal_cancel_resume.py +++ b/tests/test_stack_goal_cancel_resume.py @@ -16,7 +16,6 @@ from agent_comms.goals import Goal from agent_comms.goal_states import ActiveGoal, PausedGoal from agent_comms.input_attempt import StartedInput -from agent_comms.runtime import RuntimeProxy, socket_path from compaction_loopback import LoopbackProvider from native_backend_fixture import native_backend_fixture from stored_inputs import stored_inputs @@ -60,7 +59,7 @@ async def publish_update(self, session_id, update): requests = provider.requests async with native.open_owner(runtime_enabled=True, auto_wake=True, client=Client()) as (owner, session): comms = owner._comms - proxy = RuntimeProxy(owner, session, socket_path(comms.root, os.getpid())) + proxy = owner._create_runtime_proxy(comms.registry.require(session), session) await proxy.subscribe() owner.inputs.dispositions.record( UNCERTAIN_SENTINEL_KEY, seq=None, owner=session, claim_scope=1, diff --git a/tests/test_toad_contract.py b/tests/test_toad_contract.py index b8cf90759d..085838c50c 100644 --- a/tests/test_toad_contract.py +++ b/tests/test_toad_contract.py @@ -13,7 +13,7 @@ from agent_comms.acp_extension import CoordinationChangedUpdate, TextRouteUpdate, decode_updates from agent_comms.comms import Comms, wire from agent_comms.goals import Goal -from agent_comms.field_codec import TurnId +from agent_comms.field_codec import AcpSessionId, TurnId from agent_comms.routing import MessageRoute from agent_comms.threads import Thread from agent_comms.transcripts import TranscriptCursor, TranscriptPage @@ -36,8 +36,9 @@ async def test_toad_public_types_and_acp_agent_comms_metadata(tmp_path, consumer comms.registry.declare(Thread('worker', frozenset(), Checkout(tmp_path))) agent = CommsAgent(comms, agent_bin=GivenBinary("nonexistent-pi"), runtime_enabled=False, auto_wake=False) + session_id = AcpSessionId("worker") session = NewSessionResponse( - session_id="worker", field_meta=(await agent.sessions.metadata("worker")) + session_id=str(session_id), field_meta=(await agent.sessions.metadata("worker", session_id=session_id)) ).model_dump(by_alias=True, exclude_none=True) configuration = next(update for update in decode_updates(session["_meta"]) if isinstance(update, CoordinationChangedUpdate)) diff --git a/tests/test_tracked_native_retry.py b/tests/test_tracked_native_retry.py index dc372800b7..116d644c92 100644 --- a/tests/test_tracked_native_retry.py +++ b/tests/test_tracked_native_retry.py @@ -2,7 +2,7 @@ import pytest -from agent_comms.agent_backend import SteerInputPolicy +from agent_comms.turn_inbox import SteerInputPolicy from agent_comms.field_codec import InputId, TurnId from agent_comms.input_disposition import InputDispositions from agent_comms.input_key import TurnInputKey diff --git a/tests/test_transcript_observation_lifetime.py b/tests/test_transcript_observation_lifetime.py index 3d04b673f7..b4e75e8d80 100644 --- a/tests/test_transcript_observation_lifetime.py +++ b/tests/test_transcript_observation_lifetime.py @@ -11,7 +11,6 @@ from agent_comms.coordinator import Coordination from agent_comms.native_arguments import GivenBinary -from agent_comms.runtime import RuntimeProxy, socket_path from agent_comms.ui_model.observation import ( ObservationProcess, ObserverDied, @@ -56,11 +55,11 @@ async def test_headless_bind_has_no_observation_process(headless_owner, tmp_path async def test_subscriptions_share_process_and_last_close_joins_it(headless_owner): owner = headless_owner session, = owner.sessions.published - session = owner.sessions.require(session) + thread = owner._comms.registry.require(owner.sessions.require(session)) frontend = canonical_agent(owner._comms, auto_wake=False) - first = RuntimeProxy(frontend, session, socket_path(owner._comms.root, os.getpid())) - second = RuntimeProxy(frontend, session, socket_path(owner._comms.root, os.getpid())) - reopened = RuntimeProxy(frontend, session, socket_path(owner._comms.root, os.getpid())) + first = frontend._create_runtime_proxy(thread, session) + second = frontend._create_runtime_proxy(thread, session) + reopened = frontend._create_runtime_proxy(thread, session) try: assert observation_children() == () await first.subscribe() diff --git a/tests/test_turn_inbox.py b/tests/test_turn_inbox.py index 72c598d676..74699cec23 100644 --- a/tests/test_turn_inbox.py +++ b/tests/test_turn_inbox.py @@ -4,10 +4,7 @@ import pytest -from agent_comms.agent_backend import ( - ClearQueue, FollowUpInputPolicy, InputContent, InputRequest, - SendNow, TurnInbox, TurnInboxItem, UseSettings, -) +from agent_comms.turn_inbox import ClearQueue, FollowUpInputPolicy, InputContent, InputRequest, SendNow, TurnInbox, TurnInboxItem, UseSettings from agent_comms.native_session_prepare import NativeSessionPreparation from agent_comms.pi_vocabulary import OffThinkingLevel from agent_comms.thread_model import UndeclaredModel diff --git a/tests/test_working_memory_read.py b/tests/test_working_memory_read.py index 817b6e9b46..a6b78d3ad7 100644 --- a/tests/test_working_memory_read.py +++ b/tests/test_working_memory_read.py @@ -1,10 +1,10 @@ """Read/correct original annotation addresses without reader-side installation.""" from dataclasses import replace import hashlib -import sqlite3 - import pytest +from agent_comms.coordination_database import ReadableStore + from agent_comms.turn_context import ( ContextSpan, ContributionCoordinates, FileProvenance, SystemLayerSegment, UnattributedProvenance, @@ -26,8 +26,9 @@ def local_system(text, sources=()): def test_readers_do_not_create_a_missing_store(tmp_path): path = tmp_path / "missing" / "coordination.sqlite3" segment = local_system("Keep the original instructions.") - with pytest.raises(sqlite3.OperationalError): - WorkingMemoryAnnotations.for_segment(path, segment.measured_manifest(), JevClassifier.version()) + with pytest.raises(FileNotFoundError): + WorkingMemoryAnnotations.for_segment( + ReadableStore(path), segment.measured_manifest(), JevClassifier.version()) assert not path.exists() diff --git a/tools/cutover/carry_hunt_record_ids.py b/tools/cutover/carry_hunt_record_ids.py index 9fecfc2711..59d8962f51 100644 --- a/tools/cutover/carry_hunt_record_ids.py +++ b/tools/cutover/carry_hunt_record_ids.py @@ -27,7 +27,7 @@ def carry(self, *, apply: bool) -> Carried: self.require_confined(path) if not path.exists(): return Carried(0) - with TodoStore.opened_reader(path) as db: + with TodoStore(self.root).reader_snapshot() as db: if not HuntRecord.present(db): return Carried(0) refused = tuple(stored for (stored,) in db.execute( diff --git a/tools/cutover/carry_order_corrections.py b/tools/cutover/carry_order_corrections.py index 8fad2a0ca5..9ed9b289e0 100644 --- a/tools/cutover/carry_order_corrections.py +++ b/tools/cutover/carry_order_corrections.py @@ -59,7 +59,7 @@ def carry(self, *, apply: bool) -> Carried: MaintenanceHolder.current(), "Carry stored order check corrections" ) marker = json.dumps(RETIRED) - with TodoStore.opened_reader(path) as db: + with TodoStore(self.root).reader_snapshot() as db: if "prior_orders" not in Todo.stored_columns(db): return Carried(0) rows = StoredOrders.read( diff --git a/tools/cutover/carry_todo_stories.py b/tools/cutover/carry_todo_stories.py index b4f90e1aef..2bb798d3bb 100644 --- a/tools/cutover/carry_todo_stories.py +++ b/tools/cutover/carry_todo_stories.py @@ -28,7 +28,7 @@ def carry(self, *, apply: bool) -> Carried: self.require_confined(path) if not path.exists(): return Carried(0) - with TodoStore.opened_reader(path) as db: + with TodoStore(self.root).reader_snapshot() as db: present = set(Todo.stored_columns(db)).intersection(INLINE_FIELDS) if not present: return Carried(0)