diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index f1c4a57617..a260247c0f 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -6,10 +6,8 @@ from __future__ import annotations -import json from abc import abstractmethod from dataclasses import dataclass, field, replace -from hashlib import sha256 from typing import TYPE_CHECKING, Annotated, Callable, ClassVar @@ -201,7 +199,7 @@ def require_live(self): class LiveAttachmentScope(AttachmentScope): """Logical ACP attachment and executor evidence have separate identities.""" session_id: str - claim_scope: ClaimedIdentity + claimed_identity: ClaimedIdentity owner: ProcessIdentity @property @@ -210,11 +208,11 @@ def logical_key(self) -> tuple[str, ...]: ... @property def owner_created_at(self): - return self.claim_scope.incarnation.require_recorded().created_at + return self.claimed_identity.incarnation.require_recorded().created_at @property def claimed_generation(self): - return self.claim_scope.claimed_generation + return self.claimed_identity.claimed_generation def to_wire(self): return FieldCodec.encode_record(self) @@ -302,15 +300,15 @@ class NoCursorScope(CursorScope): class LiveCursorScope(CursorScope, LiveAttachmentScope): session_id: str wire_root_id: str - claim_scope: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: ClaimedIdentity owner: ProcessIdentity def __post_init__(self): Id128Text.decode(self.wire_root_id) - self.claim_scope.incarnation.require_recorded() + self.claimed_identity.incarnation.require_recorded() if ( not self.session_id - or self.claim_scope.claimed_generation <= 0 + or self.claimed_identity.claimed_generation <= 0 ): raise ValueError("Invalid native cursor scope") @@ -370,7 +368,7 @@ def validate_scope(self, scope: CursorScope) -> None: cursor = self.cursor if ( cursor.wire_root_id != scope.wire_root_id - or cursor.recipient_lookup != scope.claim_scope.incarnation.lookup + or cursor.recipient_lookup != scope.claimed_identity.incarnation.lookup or cursor.owner_claimed_generation != scope.claimed_generation or not 0 <= cursor.injected_seq <= cursor.covered_seq ): @@ -398,9 +396,7 @@ def same_observation(self, other: CursorEnvelope) -> bool: @property def digest(self) -> str: - return sha256( - json.dumps(FieldCodec.encode(self), sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() + return FieldCodec.digest(self) @dataclass(frozen=True) @@ -446,7 +442,7 @@ class NoQueueScope(QueueScope): @dataclass(frozen=True) class LiveQueueScope(QueueScope, LiveAttachmentScope): session_id: str - claim_scope: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: ClaimedIdentity owner: ProcessIdentity @property @@ -549,7 +545,7 @@ def without_scope(self) -> None: raise ValueError('Available input queue has no original attachment scope.') def capture_human_input(self, comms, acquire_scope: Callable[[], QueueScope]) -> HumanInputOrigin: - return HumanInputOrigin.capture(comms, acquire_scope().claim_scope) + return HumanInputOrigin.capture(comms, acquire_scope().claimed_identity) @dataclass(frozen=True) @@ -570,9 +566,7 @@ def status(self) -> str | None: @property def digest(self) -> str: - return sha256( - json.dumps(FieldCodec.encode(self), sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() + return FieldCodec.digest(self) @dataclass(frozen=True) diff --git a/src/agent_comms/agent_backend.py b/src/agent_comms/agent_backend.py index 23e5bf9407..71e49cc11b 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -22,7 +22,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass from types import MappingProxyType -from typing import TYPE_CHECKING, Any, ClassVar, Protocol, Self +from typing import TYPE_CHECKING, Any, ClassVar, Self from .declared_family import DeclaredFamily from .pi_counts import PiCount @@ -157,23 +157,25 @@ class InputContent: contributions: tuple[InputContributionCoordinates, ...] = () -class TurnInboxReceiver(Protocol): - """What a running turn does with each record its inbox carries.""" +class TurnInboxItem(ABC): + """An inbox item carries its input identities; the forwarding owner dispatches it.""" + + @property + def carried_inputs(self) -> tuple[InputId, ...]: + return () - def place(self, turn: object, item: InputRequest) -> Awaitable[bool]: ... - def send_now(self, turn: object, item: SendNow) -> Awaitable[bool]: ... - def use_settings(self, turn: object, item: UseSettings) -> Awaitable[bool]: ... - def clear_queue(self, turn: object, item: ClearQueue) -> Awaitable[bool]: ... + def refuse(self, inputs, turn): + inputs.refuse(turn, self.carried_inputs) -class TurnInbox(asyncio.Queue): +class TurnInbox(asyncio.Queue[TurnInboxItem]): """The live inbox parks its head until the existing observer sees READY.""" def __init__(self): super().__init__() self.ready = asyncio.Event() self.ready.set() - async def get(self): + async def get(self) -> TurnInboxItem: await self.ready.wait() return await super().get() @@ -186,30 +188,16 @@ def has_ready_work(self) -> bool: def all_written_started(self): return not any(item.carried_inputs for item in self._queue) - def begin(self, item): + def begin(self, item: TurnInboxItem) -> None: self._queue.appendleft(item) self._wakeup_next(self._getters) - def hold(self, item): + def hold(self, item: TurnInboxItem) -> None: # Park under the shared wire lock, before READY can be published. self.ready.clear() self.begin(item) -class TurnInboxItem(ABC): - """An inbox item owns its carried inputs and delivery to a running turn.""" - - @property - def carried_inputs(self) -> tuple[InputId, ...]: - return () - - def refuse(self, inputs, turn): - inputs.refuse(turn, self.carried_inputs) - - @abstractmethod - def deliver_to(self, receiver: TurnInboxReceiver, turn: object) -> Awaitable[bool]: ... - - @dataclass(frozen=True, kw_only=True) class InputRequest(TurnInboxItem): """One queued user input handed to a backend: identity, content and busy policy. @@ -225,9 +213,6 @@ class InputRequest(TurnInboxItem): def carried_inputs(self) -> tuple[InputId, ...]: return (self.input_id,) - def deliver_to(self, receiver: TurnInboxReceiver, turn: object) -> Awaitable[bool]: - """Hand this input to the running turn's receiver; False ends forwarding.""" - return receiver.place(turn, self) @dataclass(frozen=True) @@ -239,8 +224,6 @@ class SendNow(TurnInboxItem): input_ids: tuple[InputId, ...] - def deliver_to(self, receiver: TurnInboxReceiver, turn: object) -> Awaitable[bool]: - return receiver.send_now(turn, self) @dataclass(frozen=True) @@ -254,8 +237,6 @@ class UseSettings(TurnInboxItem): model: ThreadModel thinking_level: ThinkingLevelField - def deliver_to(self, receiver: TurnInboxReceiver, turn: object) -> Awaitable[bool]: - return receiver.use_settings(turn, self) @dataclass(frozen=True) @@ -264,8 +245,6 @@ class ClearQueue(TurnInboxItem): input_ids: tuple[InputId, ...] - def deliver_to(self, receiver: TurnInboxReceiver, turn: object) -> Awaitable[bool]: - return receiver.clear_queue(turn, self) class CompactionOutcome(DeclaredFamily, DeclaredReply, affix="Compaction"): diff --git a/src/agent_comms/assignment_states.py b/src/agent_comms/assignment_states.py index a0112fc050..024c4c216f 100644 --- a/src/agent_comms/assignment_states.py +++ b/src/agent_comms/assignment_states.py @@ -2,13 +2,13 @@ from __future__ import annotations -import json from abc import abstractmethod from dataclasses import dataclass, replace from typing import TYPE_CHECKING, ClassVar from .coordination_errors import IdentityConflict, IntegrityViolationError from .declared_family import DeclaredFamily +from .field_codec import FieldCodec from .lifecycle import LifecycleState from .lifecycle_capability import Active, Complete, Deferred, Failed, Pending, Terminal from .notification_progress import ( @@ -363,14 +363,12 @@ def wake_frame(self, source, obligation) -> tuple[str, str]: source.exact_reply_target, ): raise IdentityConflict("full wake frame requires the current response obligation") - return self.mode.full_expectation(), "you owe a response: " + json.dumps( + return self.mode.full_expectation(), "you owe a response: " + FieldCodec.compact( { "target": obligation.exact_target, "source_seq": source.seq, "execution_id": obligation.execution_id, - }, - ensure_ascii=True, - separators=(",", ":"), + }, ensure_ascii=True, ) def require_engagement(self) -> Engagement: diff --git a/src/agent_comms/audience_manifest.py b/src/agent_comms/audience_manifest.py index 4bc85f1492..a9d35212f7 100644 --- a/src/agent_comms/audience_manifest.py +++ b/src/agent_comms/audience_manifest.py @@ -9,7 +9,6 @@ from __future__ import annotations import hashlib -import json import math import re from collections.abc import Sequence @@ -19,7 +18,7 @@ from agent_comms.coordination_contracts import MAX_IDENTIFIER_CHARS from .channel_targets import exact_stored_target -from .field_codec import MalformedRecord, Sha256Text +from .field_codec import FieldCodec, MalformedRecord, Sha256Text from .messages import Message AUDIENCE_VERSION: Final = 1 @@ -43,8 +42,8 @@ def _bounded_text(value: str, field: str, *, thread_name: bool = False) -> None: def _canonical_json(value: object, *, limit: int) -> bytes: try: - encoded = json.dumps( - value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False + encoded = FieldCodec.compact( + value, sort_keys=True, ensure_ascii=False, allow_nan=False, ).encode("utf-8") except ValueError as error: # NaN, or a lone surrogate the UTF-8 encoding refuses raise MalformedRecord("audience data cannot be canonically encoded") from error diff --git a/src/agent_comms/channel_coding_tools.py b/src/agent_comms/channel_coding_tools.py index fda7f344e3..81f44818bf 100644 --- a/src/agent_comms/channel_coding_tools.py +++ b/src/agent_comms/channel_coding_tools.py @@ -100,7 +100,7 @@ def for_original(self, assignment, operation_id): @dataclass(frozen=True, slots=True) class SelectedCodingToolOwner(CodingToolOwner): store: Coordination - claim_scope: WakeClaim + resource_scope: WakeClaim session_dir: Path input_id: str goal_permission: TurnGoalPermission = field() @@ -115,8 +115,8 @@ def for_original(self, assignment, operation_id) -> CodingToolOwner: self.store.session._connection, self.input_id ): raise SelectedToolDenied("Tool original is absent from the reserved native batch") - return replace(self, claim_scope=replace( - self.claim_scope, + return replace(self, resource_scope=replace( + self.resource_scope, source_seq=assignment.wire_seq, source_message_id=assignment.message_id, wake_assignment_id=assignment.assignment_id, @@ -127,11 +127,11 @@ def for_original(self, assignment, operation_id) -> CodingToolOwner: def verify(self, store, turn, snapshot): if self.input_id != turn.command.input_id: raise SelectedToolDenied("Native tool input changed") - verify_sent_full_input(store, self.claim_scope, self.owner_name, self.input_id) - verify_selected_wake(self.comms, store, self.claim_scope, self.owner_name) + verify_sent_full_input(store, self.resource_scope, self.owner_name, self.input_id) + verify_selected_wake(self.comms, store, self.resource_scope, self.owner_name) def claim_resource(self, store, turn, resource, slot): - scope = replace(self.claim_scope, operation_id=slot) + scope = replace(self.resource_scope, operation_id=slot) return publish_selected_resource_claim(self.comms, store, scope, self.owner_name, resource) diff --git a/src/agent_comms/cli_bridges/shared.py b/src/agent_comms/cli_bridges/shared.py index c72a31679f..afcfead392 100644 --- a/src/agent_comms/cli_bridges/shared.py +++ b/src/agent_comms/cli_bridges/shared.py @@ -799,7 +799,7 @@ async def prepare_delivery(self) -> None: async def accept(self, message: messages.Message) -> None: conversation = message.type.conversation_message outcome = UnconfirmedOutcome if conversation else FilteredOutcome - delivery = DeliveryRow(message.message_id, json.dumps(message.to_wire(), separators=(',', ':')) + '\n', outcome) + delivery = DeliveryRow(message.message_id, FieldCodec.compact(message.to_wire()) + '\n', outcome) with self.db: first = delivery.insert_if_absent(self.db).rowcount if not first: diff --git a/src/agent_comms/cli_protocol.py b/src/agent_comms/cli_protocol.py index ac0fa08f16..c3aa226525 100644 --- a/src/agent_comms/cli_protocol.py +++ b/src/agent_comms/cli_protocol.py @@ -20,6 +20,7 @@ from .field_codec import TimestampText from .recorded_time import UnrecordedTimeError from .external_transcript import RequestedEarlierPage +from .turn_item import TurnItem if TYPE_CHECKING: from .child_process import AttachedChild @@ -222,11 +223,9 @@ async def handle(self, bridge: CliBridge, child: AttachedChild) -> None: await bridge.accept(self.message) -class EarlierItems(WatchLine): +class EarlierItems(WatchLine, TurnItem): """The pager's outstanding continuation, handled only by its socket-owning bridge.""" wire_tag = 'earlier-items' - turn_id: str - item_id: str request: RequestedEarlierPage @classmethod diff --git a/src/agent_comms/cohort_schema.py b/src/agent_comms/cohort_schema.py index 7af33339d1..2323b324e6 100644 --- a/src/agent_comms/cohort_schema.py +++ b/src/agent_comms/cohort_schema.py @@ -32,6 +32,7 @@ NoDelete, SharedFileTables, TypedTable, + WithoutRowid, ) if TYPE_CHECKING: @@ -75,7 +76,7 @@ class ClaimBatchAcknowledgements( GuardedColumns, NoDelete, CohortTable, - TypedTable, + WithoutRowid, declared_name="claim_batch_receipts", ): transition_column = "sealed" @@ -135,7 +136,6 @@ class ClaimBatchAcknowledgements( policy_version: Annotated[str, FrozenColumn(check="length(policy_version) BETWEEN 1 AND 256")] accepted_at_ms: Annotated[int, FrozenColumn(check="accepted_at_ms >= 0")] sealed: bool = False - without_rowid = True @classmethod def decision_hash(cls, delivery: Delivered) -> str: @@ -177,7 +177,7 @@ def require_selected_claim(self, assignment: WakeAssignment) -> None: raise ProjectionUnavailableError("selected source acknowledgement is unsealed or changed") @dataclass(frozen=True, kw_only=True) -class ClaimBatchMembers(GuardedInserts, ImmutableRows, CohortTable, TypedTable): +class ClaimBatchMembers(GuardedInserts, ImmutableRows, CohortTable, WithoutRowid): insert_condition = """((SELECT sealed FROM claim_batch_receipts r WHERE r.wire_root_id = NEW.wire_root_id AND r.wire_seq = NEW.wire_seq) != 0) @@ -198,7 +198,6 @@ class ClaimBatchMembers(GuardedInserts, ImmutableRows, CohortTable, TypedTable): ("claim_id",), ("wire_root_id", "wire_seq", "recipient_lookup"), ) - without_rowid = True @classmethod def references(cls): @@ -216,7 +215,7 @@ class CohortDeliveries( GuardedInserts, ImmutableRows, CohortTable, - TypedTable, + WithoutRowid, declared_name="cohort_delivery_receipts", ): """Sealed deliveries, including observers that receive no execution claim.""" @@ -255,7 +254,6 @@ class CohortDeliveries( ("wire_root_id", "wire_seq", "claim_id"), ) indexes = (Index(("recipient_lookup", "wire_seq")),) - without_rowid = True @classmethod def sealed_sequences_after( diff --git a/src/agent_comms/connector_store.py b/src/agent_comms/connector_store.py index 676874a2ac..0f8bb7fa31 100644 --- a/src/agent_comms/connector_store.py +++ b/src/agent_comms/connector_store.py @@ -2,7 +2,6 @@ from __future__ import annotations import hashlib -import json import secrets import time from collections.abc import Iterator @@ -66,7 +65,7 @@ class AuthorizationQuery: @property def request_id(self) -> str: """A repeated query reuses its wait, never another client's PKCE challenge.""" - return digest(json.dumps(FieldCodec.encode(self), sort_keys=True, separators=(",", ":"))) + return FieldCodec.digest(self) def redirect(self, code: str) -> str: return f"{self.redirect_uri}?{urlencode({'code': code, 'state': self.state})}" diff --git a/src/agent_comms/context_segments/awareness.py b/src/agent_comms/context_segments/awareness.py index d89f9815aa..8558894c3d 100644 --- a/src/agent_comms/context_segments/awareness.py +++ b/src/agent_comms/context_segments/awareness.py @@ -1,6 +1,5 @@ """Bounded sequence references to the canonical bus, never delivery authority.""" -import json from dataclasses import dataclass from ..field_codec import FieldCodec @@ -20,9 +19,9 @@ def capture(cls, path, records): bus=str(path), sources=sources) def values(self): - return {"pointers": json.dumps({ + return {"pointers": FieldCodec.compact({ "bus": self.bus, "sources": [FieldCodec.encode(source) for source in self.sources], - }, ensure_ascii=True, separators=(",", ":"))} + }, ensure_ascii=True)} @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/context_segments/optional_awareness.py b/src/agent_comms/context_segments/optional_awareness.py index 14157ae34a..7748a149f2 100644 --- a/src/agent_comms/context_segments/optional_awareness.py +++ b/src/agent_comms/context_segments/optional_awareness.py @@ -179,7 +179,7 @@ def context(self) -> dict: } def require_resource_budget(self, max_text_bytes: int) -> None: - text = json.dumps(FieldCodec.encode(self.context()), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + text = FieldCodec.compact(FieldCodec.encode(self.context()), ensure_ascii=False, sort_keys=True) if len(text.encode("utf-8")) > max_text_bytes: raise ProjectionUnavailableError("binding awareness exceeds the byte budget") diff --git a/src/agent_comms/context_segments/partner_activity.py b/src/agent_comms/context_segments/partner_activity.py index bc0d81e966..9c85e26191 100644 --- a/src/agent_comms/context_segments/partner_activity.py +++ b/src/agent_comms/context_segments/partner_activity.py @@ -57,4 +57,4 @@ def capture(cls, owner, relations, goals): return (super().capture((OwnerProvenance(owner.incarnation, revision),), changes=changes),) def values(self): - return {"changes": json.dumps(FieldCodec.encode(self.changes), separators=(",", ":"))} + return {"changes": FieldCodec.compact(FieldCodec.encode(self.changes))} diff --git a/src/agent_comms/context_segments/relationships.py b/src/agent_comms/context_segments/relationships.py index 3071588783..96c8e1b4db 100644 --- a/src/agent_comms/context_segments/relationships.py +++ b/src/agent_comms/context_segments/relationships.py @@ -23,4 +23,4 @@ def capture(cls, owner, relations): return (super().capture((OwnerProvenance(owner.incarnation, revision),), records=records),) def values(self): - return {"records": json.dumps(FieldCodec.encode(self.records), separators=(",", ":"))} + return {"records": FieldCodec.compact(FieldCodec.encode(self.records))} diff --git a/src/agent_comms/context_segments/wake.py b/src/agent_comms/context_segments/wake.py index 8b4ccc28c2..a9240cdd7f 100644 --- a/src/agent_comms/context_segments/wake.py +++ b/src/agent_comms/context_segments/wake.py @@ -8,7 +8,6 @@ from __future__ import annotations -import json from dataclasses import dataclass from agent_comms.coordination_tables.assignments import WakeAssignment @@ -94,16 +93,8 @@ def values(self): "body": initial.message.body, } ) - selected_line = json.dumps( - selected, - ensure_ascii=True, - separators=(",", ":"), - ) - work_context = json.dumps( - FieldCodec.encode(self.work_context), - ensure_ascii=True, - separators=(",", ":"), - ) + selected_line = FieldCodec.compact(selected, ensure_ascii=True) + work_context = FieldCodec.compact(FieldCodec.encode(self.work_context), ensure_ascii=True) sent_values = [] for outcome in self.sent_outcomes: message, reference = outcome.message, outcome.answer @@ -117,11 +108,7 @@ def values(self): FieldCodec.encode(reference) if reference is not None else None ), }) - sent_outcomes = json.dumps( - sent_values, - ensure_ascii=True, - separators=(",", ":"), - ) + sent_outcomes = FieldCodec.compact(sent_values, ensure_ascii=True) return dict( count=len(selected), selected=selected_line, @@ -148,15 +135,13 @@ def values(self): self.assignment.require_selected_source(self.source, self.owner) message = self.source.message return dict( - message=json.dumps( + message=FieldCodec.compact( { "source_seq": self.assignment.wire_seq, "sender": message.sender, "target": message.target, "audience": self.assignment.audience.value, "body": message.body, - }, - ensure_ascii=True, - separators=(",", ":"), + }, ensure_ascii=True, ) ) diff --git a/src/agent_comms/context_store.py b/src/agent_comms/context_store.py index 4462a72683..5700c8dcd4 100644 --- a/src/agent_comms/context_store.py +++ b/src/agent_comms/context_store.py @@ -2,7 +2,6 @@ from __future__ import annotations -import hashlib import json import sqlite3 import uuid @@ -11,7 +10,7 @@ from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import Annotated, ClassVar, Literal, Self, cast +from typing import Annotated, ClassVar, Self, cast from .errors import RelationViolationError, StoredHistoryUnreadable from .field_codec import ( @@ -35,7 +34,7 @@ UnrecordedSegment, ) -from .typed_table import Column, DurableStore, Generated, Index, TypedTable +from .typed_table import Column, DurableStore, Generated, Index, SingletonRow, TypedTable @dataclass(frozen=True) @@ -91,8 +90,7 @@ def page( @dataclass(frozen=True) -class ContextStoreInfo(ContextSourceState, TypedTable): - singleton: Annotated[Literal[1], Column(primary_key=True)] +class ContextStoreInfo(ContextSourceState, SingletonRow): source: ContextStoreIdentity @classmethod @@ -186,8 +184,7 @@ class ContextSegmentVersion(TypedTable): @classmethod def append(cls, db: sqlite3.Connection, segment: RecordedSegmentManifest) -> int: - encoded = json.dumps(FieldCodec.encode(segment), sort_keys=True, separators=(",", ":")) - digest = hashlib.sha256(encoded.encode()).hexdigest() + digest = FieldCodec.digest(segment) rows = cls.select(db, digest=digest) if rows: existing = rows[0] diff --git a/src/agent_comms/coordination_response.py b/src/agent_comms/coordination_response.py index 13308a1a5d..744b9364e2 100644 --- a/src/agent_comms/coordination_response.py +++ b/src/agent_comms/coordination_response.py @@ -53,7 +53,7 @@ ImmutableRows, SqlValue, TypedRow, - TypedTable, + WithoutRowid, ) from .locked_store import RegistryFile @@ -65,7 +65,7 @@ class ResponseTable(ImmutableRows, PrivateCoordinatorTable): schema_error = PublicationActivationBlocked @dataclass(frozen=True) -class PublicationAppendDispatches(ResponseTable, TypedTable): +class PublicationAppendDispatches(ResponseTable, WithoutRowid): execution_id: Annotated[str, Column(primary_key=True)] exact_target: Annotated[ str, Column(primary_key=True, check="length(exact_target) BETWEEN 1 AND 256") @@ -76,7 +76,6 @@ class PublicationAppendDispatches(ResponseTable, TypedTable): owner_generation: Annotated[int, Column(check="owner_generation>0")] attempt_ordinal: Annotated[int, Column(check="attempt_ordinal>0")] dispatched_at_ms: Annotated[int, Column(check="dispatched_at_ms>=0")] - without_rowid = True @classmethod def references(cls): diff --git a/src/agent_comms/coordination_snapshot.py b/src/agent_comms/coordination_snapshot.py index 077635b50a..8e391790c4 100644 --- a/src/agent_comms/coordination_snapshot.py +++ b/src/agent_comms/coordination_snapshot.py @@ -350,7 +350,7 @@ def release_resources(self, boundary: NativeBoundary, outcome: TerminalAttempt) current = bus.bus.claim_projection().live_in(boundary.registry) owned = tuple( claim for claim in current.values() - if claim.claim_scope.belongs_to(attempt) + if claim.resource_scope.belongs_to(attempt) ) if owned: bus.publisher.release_resources(boundary, owned, outcome.release_wording) diff --git a/src/agent_comms/envelope_claim_transitions.py b/src/agent_comms/envelope_claim_transitions.py index 87ffd2d288..04f9ebd8a2 100644 --- a/src/agent_comms/envelope_claim_transitions.py +++ b/src/agent_comms/envelope_claim_transitions.py @@ -169,9 +169,7 @@ def held_at(self, claimed_generation: int) -> bool: class RecordedScope(ClaimScope, declared_name=None): """Every acquisition published today records the input generation and operation it serves.""" - owner_claimed_generation: Annotated[ - int, WireName("owner_admission_generation") - ] + owner_claimed_generation: int operation_id: Annotated[str, Id128Text] turn_id: str @@ -274,7 +272,7 @@ class ClaimIdentity: @dataclass(frozen=True) class AcquiringClaim(ClaimIdentity): generation: Annotated[str, Id128Text] - claim_scope: Annotated[ClaimScope, WireName("admission")] + resource_scope: ClaimScope @dataclass(frozen=True) @@ -351,7 +349,7 @@ class Acquisition(ClaimTransition, declared_name=None): default=(), kw_only=True ) generation: Annotated[str, Id128Text] = field(kw_only=True) - claim_scope: ClassVar[ClaimScope] + resource_scope: ClassVar[ClaimScope] def __post_init__(self) -> None: super().__post_init__() @@ -372,7 +370,7 @@ def claimed(self, resource: str) -> ClaimOwner: @property def operation_ids(self) -> tuple[str, ...]: - return self.claim_scope.operation_ids + return self.resource_scope.operation_ids def require_holders(self, previous: ClaimProjection) -> None: for resource in self.claims: @@ -390,7 +388,7 @@ def project(self, previous: ClaimProjection) -> ClaimProjection: class WakeClaims(RecordMember, Acquisition, AcquiringClaim): """An acquisition published today; its row records the scope it serves.""" - claim_scope: Annotated[RecordedScope, WireName("admission")] + resource_scope: RecordedScope @dataclass(frozen=True) @@ -400,7 +398,7 @@ class UnscopedClaims(RecordMember, Acquisition): Only bus history holds this row; no writer publishes one. """ - claim_scope: ClassVar[ClaimScope] = UnrecordedScope() + resource_scope: ClassVar[ClaimScope] = UnrecordedScope() @dataclass(frozen=True) @@ -447,7 +445,7 @@ def __post_init__(self) -> None: def acquire( self, scope: RecordedScope, registry: RegistrySnapshot, publish: Callable[[], ClaimOwner] ) -> ClaimOwner: - return self if self.claim_scope.reuses(scope) and self.live(registry) else publish() + return self if self.resource_scope.reuses(scope) and self.live(registry) else publish() def require_holder(self, owner: str, incarnation: str) -> None: if (self.owner, self.incarnation) != (owner, incarnation): @@ -457,7 +455,7 @@ def live(self, registry: RegistrySnapshot) -> bool: incarnation = RecordedThreadIncarnation(self.owner, float(self.incarnation)) return any( thread.process_alive - and self.claim_scope.held_at(registry.claim_generation(thread.name)) + and self.resource_scope.held_at(registry.claim_generation(thread.name)) for thread in registry.active_threads(registry.threads_for(incarnation)) ) diff --git a/src/agent_comms/exporting.py b/src/agent_comms/exporting.py index a4960abea6..e63f80a52b 100644 --- a/src/agent_comms/exporting.py +++ b/src/agent_comms/exporting.py @@ -3,7 +3,6 @@ from __future__ import annotations import hashlib -import json import math import os from abc import abstractmethod @@ -42,7 +41,7 @@ def row(self, message: Message) -> bytes: @staticmethod def json_record(record: Mapping[str, object]) -> str: - return json.dumps(record, ensure_ascii=False, separators=(",", ":")) + return FieldCodec.compact(record, ensure_ascii=False) class SelectableWireExportFormat(WireExportFormat): diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 7cc42ffe9f..132f544165 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import hashlib import json import math import os @@ -48,6 +49,9 @@ T = TypeVar("T") +type WireRecordChange = Callable[[type, dict[str, object]], dict[str, object]] +type WireRecordFilter = Callable[[type], bool] + class MalformedRecord(ValueError): # noqa: N818 - names the refused data """Data that does not satisfy its declaration. @@ -508,6 +512,18 @@ def wire_keys(self) -> tuple[str, ...]: def encoded(self, value, *, key=None): return {key or self.key: value} + @property + def wire_children(self) -> tuple[ValueDecoder[object], ...]: + return (self.value,) if self.wire_keys else () + + def rewrite_wire( + self, data: dict[str, object], change: WireRecordChange, select: WireRecordFilter, + ) -> dict[str, object]: + if self.key not in data or not self.wire_keys: + return data + current = self.value.rewrite_records(data[self.key], change, select) + return data if current is data[self.key] else {**data, self.key: current} + def schema_fields(self): annotation, declared = self.annotation, self.declared base = FieldCodec._representation(annotation)[0] @@ -680,6 +696,11 @@ def wire_keys(self) -> tuple[str, ...]: def encoded(self, value, *, key=None): return value + def rewrite_wire( + self, data: dict[str, object], change: WireRecordChange, select: WireRecordFilter, + ) -> dict[str, object]: + return cast(dict[str, object], self.value.rewrite_records(data, change, select)) + def captured(self, data: object) -> dict[str, object]: if not isinstance(data, dict): raise MalformedRecord("Expected a record object.") @@ -796,6 +817,11 @@ class FieldRepresentation(MroDispatch, ABC): field_plan: ClassVar[type[FieldPlan]] = DeclaredFieldPlan + @classmethod + def wire_structure(cls, structural: ValueDecoder[object]) -> ValueDecoder[object]: + """A custom scalar form stays opaque; declared structural forms supply their plan.""" + return structural + @classmethod def decode_field(cls, data: object, structural: ValueDecoder[object]) -> object: handler = getattr(cls, next(cls.handler_names_for(type(data)))) @@ -894,6 +920,10 @@ def wire_field(cls): entry, = FieldCodec._record_plan(cls).fields return entry + @classmethod + def wire_structure(cls, structural: ValueDecoder[object]) -> ValueDecoder[object]: + return cls.wire_field().value + def to_wire(self): entry = self.wire_field() return FieldCodec.encode_document(getattr(self, entry.name), entry.annotation) @@ -923,6 +953,10 @@ class SubclassUnion(DeclaredFamily, AcceptsNull, ExternalJsonShape, WireValue): def decodes_tagged_records(cls) -> bool: return False + @classmethod + def wire_structure(cls, structural: ValueDecoder[object]) -> ValueDecoder[object]: + return ValueDecoder.for_members(cls.members_with(cls)) + @classmethod def from_wire(cls, data: object) -> Self: return ValueDecoder.for_members(cls.members_with(cls)).decode_bound(data, {}) @@ -962,6 +996,10 @@ def schema(cls) -> dict[str, object]: class RecordMember(WireValue): """A wire union's record case uses the declared record codec.""" + @classmethod + def wire_structure(cls, structural: ValueDecoder[object]) -> ValueDecoder[object]: + return _member_decoder(cls) + @classmethod def from_wire(cls, data: object) -> Self: return FieldCodec.decode_record(cls, data) @@ -983,6 +1021,10 @@ class InlineRecord(WireValue, ExternalJsonShape): def wire_record(cls): return cls + @classmethod + def wire_structure(cls, structural: ValueDecoder[object]) -> ValueDecoder[object]: + return _member_decoder(cls.wire_record()) + @classmethod def schema(cls): return FieldCodec.record_schema(cls.wire_record()) @@ -1352,6 +1394,26 @@ def encode(cls, value: object, annotation: object = None) -> Any: ... def encode(cls, value: object, annotation: object = None) -> Any: return compile_decoder(annotation).encode(value) + @staticmethod + def compact( + value: object, *, ensure_ascii: bool = True, + sort_keys: bool = False, allow_nan: bool = True, + ) -> str: + """Write an already encoded JSON value without layout whitespace. + + Callers retain their boundary's Unicode, ordering and numeric contract; + declared values pass through ``encode`` before this text boundary. + """ + return json.dumps( + value, ensure_ascii=ensure_ascii, sort_keys=sort_keys, + allow_nan=allow_nan, separators=(",", ":"), + ) + + @classmethod + def digest(cls, value: object) -> str: + """SHA-256 of a declared value's sorted compact encoding.""" + return hashlib.sha256(cls.compact(cls.encode(value), sort_keys=True).encode()).hexdigest() + @classmethod def encode_document(cls, value: object, declared: object) -> object: """A document whose declaration names its leaf member stores that member untagged.""" @@ -1736,6 +1798,45 @@ def decode(self, data: object) -> T: @abstractmethod def encode(self, value: T) -> object: ... + @property + def wire_children(self) -> tuple[ValueDecoder[object], ...]: + return () + + def selects_record(self, select: WireRecordFilter) -> bool: + return False + + def contains_records( + self, select: WireRecordFilter, seen: frozenset[object] = frozenset(), + ) -> bool: + """Whether this immutable plan reaches a selected declared record; no payload read.""" + if self in seen: + return False + return self.selects_record(select) or any( + child.contains_records(select, seen | {self}) for child in self.wire_children + ) + + def rewrite_records( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + """Transform declared raw records without decoding opaque JSON or losing other keys. + + The caller owns the record change. This decoder owns wire layout, child + membership and union selection. ``select`` is a pure declaration predicate. + """ + if data is None or not self.contains_records(select): + return data + return self._rewrite_records(data, change, select) + + def _rewrite_records( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + return data + + def rewrite_document( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + return self.rewrite_records(data, change, select) + def decode_external(self, data: object, record: Mapping[str, object]) -> T: for decode in self.external_decoders(data): return self.decode_resolved(cast(T, decode(data, record))) @@ -1907,6 +2008,19 @@ class _RepresentedDecoder(ValueDecoder[Any]): representation: type[FieldRepresentation] structural: ValueDecoder[object] + @property + def wire_structure(self) -> ValueDecoder[object]: + return self.representation.wire_structure(self.structural) + + @property + def wire_children(self) -> tuple[ValueDecoder[object], ...]: + return (self.wire_structure,) + + def _rewrite_records( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + return self.wire_structure.rewrite_records(data, change, select) + def external_decoders(self, data: object) -> Iterable[ExternalDecoder]: return FieldCodec.external_decoders(self.representation) @@ -2056,6 +2170,28 @@ class UnionDecoder[T](_ContextDecoder[T]): origins = (Union, types.UnionType) alternatives: tuple[ValueDecoder[T], ...] + @property + def wire_children(self) -> tuple[ValueDecoder[object], ...]: + return self.alternatives + + def _rewrite_records( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + answers = [] + for alternative in self.alternatives: + try: + current = alternative.rewrite_records(data, change, select) + alternative.decode_bound(current, {}) + except MalformedRecord: + continue + if current not in answers: + answers.append(current) + if len(answers) != 1: + raise MalformedRecord( + f"Record rewrite cannot select exactly one declared {self.target}: " + f"{len(answers)} different results") + return answers[0] + @classmethod def from_annotation(cls, target, args): return cls(target, tuple(compile_decoder(item) for item in args)) @@ -2118,6 +2254,17 @@ class _ArrayDecoder(ValueDecoder[Any]): container: ClassVar[type] items: tuple[ValueDecoder[object], ...] + @property + def wire_children(self) -> tuple[ValueDecoder[object], ...]: + return self.items + + def _rewrite_records( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + if not isinstance(data, list): + raise MalformedRecord("Expected a JSON array.") + return [plan.rewrite_records(value, change, select) for plan, value in self.plan_items(data)] + @classmethod def from_annotation(cls, target, args): return cls(target, tuple(compile_decoder(item) for item in (args or (Any,)))) @@ -2223,6 +2370,23 @@ class _DictDecoder(ValueDecoder[Any]): value: ValueDecoder[object] origins = (dict,) + @property + def wire_children(self) -> tuple[ValueDecoder[object], ...]: + return self.key, self.value + + @staticmethod + @abstractmethod + def wire_mapping(entries: Iterable[tuple[object, object]]) -> object: ... + + def _rewrite_records( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + return self.wire_mapping( + (self.key.rewrite_records(key, change, select), + self.value.rewrite_records(value, change, select)) + for key, value in self.entries(data) + ) + def argument_options(self) -> dict[str, object]: return {"type": FieldCodec.parse} @@ -2269,8 +2433,12 @@ def decode_keys(self, data, keys, selections): class _StringKeyDictDecoder(_DictDecoder): entries = staticmethod(_object_entries) + @staticmethod + def wire_mapping(entries: Iterable[tuple[object, object]]) -> dict: + return dict(entries) + def encode(self, value: dict[str, object]) -> dict[str, object]: - return {key: self.value.encode(item) for key, item in value.items()} + return self.wire_mapping((key, self.value.encode(item)) for key, item in value.items()) def encode_document(self, value: dict[str, object]) -> dict[str, object]: return {key: self.value.encode_document(item) for key, item in value.items()} @@ -2283,9 +2451,13 @@ def decode_document(self, data: object) -> dict[str, object]: class _PairKeyDictDecoder(_DictDecoder): entries = staticmethod(_object_or_pair_entries) + @staticmethod + def wire_mapping(entries: Iterable[tuple[object, object]]) -> list: + return [[key, value] for key, value in entries] + def encode(self, value: dict[object, object]) -> list[list[object]]: - return [[self.key.encode(key), self.value.encode(item)] - for key, item in value.items()] + return self.wire_mapping((self.key.encode(key), self.value.encode(item)) + for key, item in value.items()) @dataclass(frozen=True, slots=True) @@ -2294,6 +2466,16 @@ class _FamilyRecordDecoder[Member: DeclaredFamily](_ContextDecoder[Member]): target: type[Member] + @property + def wire_children(self) -> tuple[ValueDecoder[object], ...]: + return tuple(_member_decoder(member) for member in self.target.members_with(self.target)) + + def _rewrite_records( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + member, _ = self.member(data) + return _member_decoder(member).rewrite_records(data, change, select) + def encode(self, value: Member) -> dict[str, object]: return {**value.record_tag(), **_member_decoder(type(value)).encode(value)} @@ -2336,6 +2518,11 @@ def encode_document(self, value: DeclaredFamily) -> object: def decode_document(self, data: object) -> object: return _member_decoder(self.target).decode_bound(data, {}) + def rewrite_document( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + return _member_decoder(self.target).rewrite_records(data, change, select) + @dataclass(frozen=True, slots=True) class _RecordDecoder(_ContextDecoder[Any]): @@ -2344,6 +2531,24 @@ class _RecordDecoder(_ContextDecoder[Any]): target: type + @property + def wire_children(self) -> tuple[ValueDecoder[object], ...]: + return tuple(child for entry in FieldCodec._record_plan(self.target).bindings + for child in entry.wire_children) + + def selects_record(self, select: WireRecordFilter) -> bool: + return select(self.target) + + def _rewrite_records( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + if not isinstance(data, dict): + raise MalformedRecord(f"Expected {self.target.__name__} record") + result = change(self.target, data) + for entry in FieldCodec._record_plan(self.target).bindings: + result = entry.rewrite_wire(result, change, select) + return result + def encode(self, value: object) -> dict[str, object]: result = FieldCodec._record_plan(type(value)).encoded( value, "wire", lambda entry, current: entry.value.encode(current) diff --git a/src/agent_comms/following_inputs.py b/src/agent_comms/following_inputs.py index 503d49f244..172ddd18dd 100644 --- a/src/agent_comms/following_inputs.py +++ b/src/agent_comms/following_inputs.py @@ -90,7 +90,7 @@ async def open(self, session: TrackedTurnSession) -> None: session_id=self.session_id, thread=thread, turn=TurnId(lease.turn_id), - claim_scope=lease.claimed_generation, + claim_generation=lease.claimed_generation, original=original, goal_permit=NoLaunchPermit(), ) diff --git a/src/agent_comms/fresh_private_session.py b/src/agent_comms/fresh_private_session.py index 3f0d8ab332..c7e29b75a9 100644 --- a/src/agent_comms/fresh_private_session.py +++ b/src/agent_comms/fresh_private_session.py @@ -2,14 +2,13 @@ from __future__ import annotations -import json import os from datetime import UTC, datetime from pathlib import Path from uuid import uuid4 from agent_comms.child_process import Platform -from .field_codec import RecordedTimestamp +from .field_codec import FieldCodec, RecordedTimestamp from .native_entries import SessionEntry from .native_failures import NativePiUnavailable from agent_comms.native_session_files import NativeSessionFiles @@ -33,7 +32,7 @@ def create_fresh_private_session( id=session_id, version=3, cwd=str(worktree), timestamp=RecordedTimestamp.written(datetime.now(UTC)), ) - encoded = json.dumps(header.to_wire(), separators=(",", ":"), allow_nan=False).encode() + b"\n" + encoded = FieldCodec.compact(header.to_wire(), allow_nan=False).encode() + b"\n" try: descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) try: diff --git a/src/agent_comms/goal_attempts.py b/src/agent_comms/goal_attempts.py index 9341f10c50..eb7624d175 100644 --- a/src/agent_comms/goal_attempts.py +++ b/src/agent_comms/goal_attempts.py @@ -31,7 +31,7 @@ from .child_process import Platform from .declared_family import DeclaredFamily -from .field_codec import MalformedRecord +from .field_codec import FieldCodec, MalformedRecord from .goal_attempt_identity import FailureNotObserved, GoalAttemptIdentity from .goal_attempt_phase import ( ClaimedAttempt, @@ -598,7 +598,7 @@ def record_provider_usage( """Attribute one provider response to its claimed goal attempt exactly once.""" if not response_id: raise ValueError("Provider response ID is required.") - raw = json.dumps(usage.to_wire(), sort_keys=True, separators=(",", ":"), allow_nan=False) + raw = FieldCodec.compact(usage.to_wire(), sort_keys=True, allow_nan=False) reservation = self.reservation def write(conn: sqlite3.Connection) -> str: diff --git a/src/agent_comms/goal_failure_observation.py b/src/agent_comms/goal_failure_observation.py index 03f26f02d2..b396197f11 100644 --- a/src/agent_comms/goal_failure_observation.py +++ b/src/agent_comms/goal_failure_observation.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Annotated from .turn_failure import TurnFailure -from .field_codec import Id128Text, WireName +from .field_codec import Id128Text from .goal_attempt_identity import GoalAttemptIdentity from .thread_identity import ( ClaimedIdentity, @@ -56,8 +56,8 @@ class FailedTurnEvidence(GoalLedgerTable, TypedTable, GoalFailureEvidence): owner: str owner_created_at: float worktree: str - claim_scope: Annotated[ - int, WireName("admission"), Column(record_key=True, check="{claim_scope}>0") + claim_generation: Annotated[ + int, Column(record_key=True, check="{claim_generation}>0") ] turn_generation: Annotated[int, Column(check="turn_generation>0")] goal_revision: Annotated[int, Column(check="goal_revision>=0")] @@ -67,7 +67,7 @@ class FailedTurnEvidence(GoalLedgerTable, TypedTable, GoalFailureEvidence): def __post_init__(self) -> None: # These are the durable observation's constraints, independent of any # caller's authority. SQL/FieldCodec remains the single stored decoder. - if type(self.claim_scope) is not int or self.claim_scope <= 0: + if type(self.claim_generation) is not int or self.claim_generation <= 0: raise ValueError("Observation claim_scope must be positive") if type(self.turn_generation) is not int or self.turn_generation <= 0: raise ValueError("Observation turn generation must be positive") @@ -89,11 +89,11 @@ def incarnation(self) -> ThreadIncarnation: @property def claimed_identity(self) -> ClaimedIdentity: - return ClaimedIdentity(self.incarnation, self.claim_scope) + return ClaimedIdentity(self.incarnation, self.claim_generation) @property def turn(self) -> TurnFence: - return TurnFence(TurnIdentity(self.incarnation, self.turn_generation), self.turn_id, self.claim_scope) + return TurnFence(TurnIdentity(self.incarnation, self.turn_generation), self.turn_id, self.claim_generation) def matches_owner(self, owner: Thread, claim_scope: int | None) -> bool: if claim_scope is None: diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index 2e62c307a0..b288290142 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -395,18 +395,19 @@ def consume_reply_wait(self, owner: RegistryOwner, reference: MessageReference) with self.registry.store.locked(mode=SharedLock()): owner.require_registry(self.registry) review = GoalWaitReview.capture(self, owner.thread.incarnation.require_recorded()) - current = review.owner - goal = current.continuation_goal(owner.thread) - if not review.wait.owner_matches(current.incarnation.require_recorded()): + incarnation = review.owner.incarnation.require_recorded() + if not review.wait.owner_matches(incarnation): return False original, = self.bus.bus.deliveries_for_references((reference,)) - if not review.wait.matches(original, current.incarnation.require_recorded()): + if not review.wait.matches(original, incarnation): return False with self.registry.store.locked(): owner.require_registry(self.registry) if not review.current_for(self): return False - return any(review.wait.current_for(actual, current.incarnation.require_recorded()) + current, = self.registry.snapshot().threads_for(incarnation) + goal = current.continuation_goal(owner.thread) + return any(review.wait.current_for(actual, incarnation) and review.wait.clear(self.waits) for actual in goal) def direct(self, name: str, message: Message) -> None: diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index 7ff14d348c..60872b4682 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -273,13 +273,20 @@ def current_for(self, goals: Goals) -> bool: """Caller holds the publication lock; no reply decoding belongs in this check.""" snapshot = goals.registry.snapshot() return any( - owner.goal == self.owner.goal + self._current_goal(owner) and owner.turn_lease == self.turn and goals.waits.resolve(owner.goal) == self.wait for owner in snapshot.threads_for(self.owner.incarnation.require_recorded()) if self._same_thread(snapshot, owner, self.owner) ) + def _current_goal(self, owner: Thread) -> bool: + """A retained wait follows edits of its active goal, not a frozen goal value.""" + return any( + self.wait.current_for(goal, owner.incarnation.require_recorded()) + for goal in owner.goal.active() + ) + def _same_thread(self, snapshot: RegistrySnapshot, actual: Thread, expected: Thread) -> bool: """Ignore display metadata, but retain the original process and input/turn epochs.""" return ( @@ -298,6 +305,7 @@ def require_current(self, goals: Goals, owner: Thread) -> None: def publishing_unanswered(self, goals: Goals) -> Iterator[bool]: """Read without EX; hold it only after this owner's received cut still agrees.""" owner = self.owner.incarnation.require_recorded() + review = self while True: with goals.bus.bus.reading() as source: replied = self.wait.has_reply(source, owner) @@ -306,9 +314,18 @@ def publishing_unanswered(self, goals: Goals) -> Iterator[bool]: yield False return with goals.registry.store.locked(): - if not self.current_for(goals): - yield False - return + if not review.current_for(goals): + try: + review = GoalWaitReview.capture(goals, owner) + except UnregisteredThreadError: + yield False + return + if review.wait != self.wait or not review._current_goal(review.owner): + yield False + return + # A new owner/turn fence needs a fresh silence read for this + # same wait. Re-evaluate here; a terminal callback runs once. + continue # The existing indexed sequence query reads at most one metadata row. # Any new candidate needs another unlocked policy read, not a stale release. if goals.bus.bus.received_after(owner.lookup, through_seq): @@ -334,6 +351,10 @@ class GoalInputReview(GoalWaitReview): unknown: tuple[StoredInput, ...] eligible_keys: frozenset[str] + def _current_goal(self, owner: Thread) -> bool: + """Publishing an input review still requires the exact prepared goal.""" + return owner.goal == self.owner.goal + def current_for(self, goals: Goals) -> bool: if not super(GoalInputReview, self).current_for(goals): return False diff --git a/src/agent_comms/goals.py b/src/agent_comms/goals.py index 83266488b0..fc239e2c29 100644 --- a/src/agent_comms/goals.py +++ b/src/agent_comms/goals.py @@ -4,7 +4,6 @@ from agent_comms.lifecycle_capability import Active -import json import os import re import shlex @@ -471,7 +470,7 @@ def stored(cls, repository: str, sha: str) -> tuple[CheckedCommit, ...]: def store(self, repository: str, sha: str) -> None: self.git(repository, *self.NOTES, "append", "-m", - json.dumps(FieldCodec.encode(self), separators=(",", ":")), sha) + FieldCodec.compact(FieldCodec.encode(self)), sha) class GoalOrder(DeclaredFamily, affix="GoalOrder"): diff --git a/src/agent_comms/input_attempt.py b/src/agent_comms/input_attempt.py index 67e8a859dc..d2b5ea8c03 100644 --- a/src/agent_comms/input_attempt.py +++ b/src/agent_comms/input_attempt.py @@ -248,7 +248,7 @@ def require_original_provenance(self, source: InputProvenance): key: str sequence: int | None owner: str - claim_scope: Annotated[int, WireName("admission")] + claim_generation: int target: str source_text: str origin: Annotated[InputOrigin, WireOmitDefault()] = field( @@ -266,7 +266,7 @@ def __post_init__(self) -> None: for value in (self.key, self.owner, self.target, self.source_text): if not FieldCodec.decode(str, value): raise ValueError("Input identity text cannot be empty") - GenerationCounter.require_positive(self.claim_scope) + GenerationCounter.require_positive(self.claim_generation) except ValueError as error: raise ValueError("Invalid ACP input identity") from error if self.sequence is not None and ( @@ -288,7 +288,7 @@ def matches_owner(self, source_owner: ThreadIncarnation) -> bool: return self.origin.matches_owner(self.owner, source_owner) def matches_claim(self, claim_scope: int) -> bool: - return self.claim_scope == claim_scope + return self.claim_generation == claim_scope def _transition(self, target: type[StoredInput], **changes) -> StoredInput: values = {item.name: getattr(self, item.name) for item in fields(self)} @@ -334,7 +334,7 @@ def queued_for(self, owner: ThreadIncarnation, claim_scope: int, text: str) -> b def bind( self, *, claim_scope: int, turn_id: str, native_id: str, text: str ) -> InputTransition: - if self.claim_scope != claim_scope: + if self.claim_generation != claim_scope: return UnchangedInput(self) return MovedInput(self._transition( BoundUnknownInput, turn_id=turn_id, native_id=native_id, sent_digest=TextDigest.of(text) diff --git a/src/agent_comms/input_origin.py b/src/agent_comms/input_origin.py index b392102bf8..c0f85a9bfc 100644 --- a/src/agent_comms/input_origin.py +++ b/src/agent_comms/input_origin.py @@ -10,7 +10,7 @@ from .bus_publication import HumanOrigin from .declared_family import DeclaredFamily from .errors import RelationViolationError -from .field_codec import Id128Text, WireName +from .field_codec import Id128Text from .goals import GoalCheckpoint from .message_reference import MessageReference from .thread_identity import ClaimedIdentity @@ -120,7 +120,7 @@ class HumanInputOrigin(InputOrigin): root_id: Annotated[str, Id128Text] author: HumanOrigin - claim_scope: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: ClaimedIdentity project: str goal: GoalCheckpoint @@ -154,7 +154,7 @@ def require_ingress(self, comms, snapshot, claim_scope, controller): comms.registry.store.private_guard_unlocked().require_root( self.root_id, RelationViolationError("User input origin belongs to another native namespace"), ) - if self.claim_scope != claim_scope: + if self.claimed_identity != claim_scope: raise RelationViolationError("User input origin attachment changed") self.author.require_registered(snapshot) owner = snapshot.require_active(claim_scope.incarnation.name) @@ -170,12 +170,12 @@ def require_human(self) -> HumanInputOrigin: return self def applies(self, owner: Thread, snapshot: RegistrySnapshot) -> bool: - return (self.claim_scope.incarnation.resolved(snapshot) == owner.incarnation + return (self.claimed_identity.incarnation.resolved(snapshot) == owner.incarnation and (self.project, self.goal) == (owner.worktree, owner.goal.captured)) def matches_owner(self, recorded_name, incarnation): """The captured original birth survives a rename, unlike a neutral name.""" - authorized = self.claim_scope.incarnation.require_recorded() + authorized = self.claimed_identity.incarnation.require_recorded() original = incarnation.require_recorded() return (recorded_name == authorized.name and authorized.created_at == original.created_at) diff --git a/src/agent_comms/installer/carry.py b/src/agent_comms/installer/carry.py index 0ffc905be3..77df6f3a6e 100644 --- a/src/agent_comms/installer/carry.py +++ b/src/agent_comms/installer/carry.py @@ -13,6 +13,7 @@ from __future__ import annotations import inspect +import json import subprocess from abc import ABC, abstractmethod from collections.abc import Callable @@ -24,7 +25,7 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import PathText +from agent_comms.field_codec import FieldCodec, PathText from agent_comms.installer.interpreter_operation import InterpreterOperation STEPS = Path("tools/cutover") @@ -94,6 +95,13 @@ class CarryOperation[Answer](InterpreterOperation[Answer, Carry], DeclaredFamily @abstractmethod def run(self, step: Carry) -> Answer: ... + def encode_answers(self, answers: tuple[Answer, ...]) -> str: + """One pass's answers, in step order, under this operation's declared answer type.""" + return json.dumps(FieldCodec.encode(answers, tuple[self.answer_type(), ...])) + + def decode_answers(self, answers: str) -> tuple[Answer, ...]: + return FieldCodec.decode(tuple[self.answer_type(), ...], FieldCodec.parse(answers)) + @dataclass(frozen=True) class BackupFilesOperation(CarryOperation[tuple[Annotated[Path, PathText], ...]]): def run(self, step: Carry) -> tuple[Annotated[Path, PathText], ...]: diff --git a/src/agent_comms/installer/interpreter_operation.py b/src/agent_comms/installer/interpreter_operation.py index a41d7a4a5d..31b44e5f66 100644 --- a/src/agent_comms/installer/interpreter_operation.py +++ b/src/agent_comms/installer/interpreter_operation.py @@ -101,9 +101,14 @@ def run(self) -> None: class PinnedLocationProbe(RuntimeProbe[Path]): + """Where a checkout's pin puts its built package, once its sources still hash to the + inputs that pin declares: the pin is then the key of every build made from them.""" + def run(self) -> Annotated[Path, PathText]: - from agent_comms.native_package import pinned_location + from agent_comms.native_build_inputs import NativeBuildInputs + from agent_comms.native_package import MANIFEST, pinned_location + NativeBuildInputs(MANIFEST.parent.parent).require_pinned(MANIFEST) return pinned_location() diff --git a/src/agent_comms/installer/step_worker.py b/src/agent_comms/installer/step_worker.py index 180cae19a8..57906b4f61 100644 --- a/src/agent_comms/installer/step_worker.py +++ b/src/agent_comms/installer/step_worker.py @@ -1,7 +1,9 @@ -"""Run one carry step's operation with the staged stack's interpreter. +"""Run one carry operation over a pass of steps with the staged stack's interpreter. -``python -m agent_comms.installer.step_worker NAME INPUT OPERATION RESULT``, with the -target tree's ``src`` and ``tools`` on the path; writes the encoded result to ``RESULT``. +``python -m agent_comms.installer.step_worker OPERATION RESULT STEPS``, with the target +tree's ``src`` and ``tools`` on the path. ``STEPS`` lists each step's module name and +encoded input in pass order; one process answers the whole pass, so a pass pays the +interpreter's start once, not once per step. Writes the encoded answers to ``RESULT``. """ from __future__ import annotations @@ -15,12 +17,14 @@ def main() -> None: - name, encoded_step, encoded_operation, result_path = sys.argv[1:] - import_module(f"{STEPS.name}.{name}") - step = FieldCodec.decode(Carry.declared[name], FieldCodec.parse(encoded_step)) + encoded_operation, result_path, encoded_steps = sys.argv[1:] operation = FieldCodec.decode(CarryOperation, FieldCodec.parse(encoded_operation)) - result = operation.run(step) - Path(result_path).write_text(operation.encode_answer(result)) + steps = [] + for name, encoded_step in FieldCodec.parse(encoded_steps): + import_module(f"{STEPS.name}.{name}") + steps.append(FieldCodec.decode(Carry.declared[name], encoded_step)) + Path(result_path).write_text(operation.encode_answers( + tuple(operation.run(step) for step in steps))) if __name__ == "__main__": diff --git a/src/agent_comms/installer/upgrade.py b/src/agent_comms/installer/upgrade.py index e714f97ba9..c2774c8291 100644 --- a/src/agent_comms/installer/upgrade.py +++ b/src/agent_comms/installer/upgrade.py @@ -250,8 +250,8 @@ class CarriedPass(Extendable): def after_acceptance(self, pending: tuple[Carry, ...], stack: Stack, tree: TargetTree) -> Self: return replace(self, retired_inputs=tuple( - StepRun(step.name(), stack.call_step(step, tree, RetireInputsOperation())) - for step in pending + StepRun(step.name(), retired) for step, retired in + zip(pending, stack.call_steps(pending, tree, RetireInputsOperation()), strict=True) )) def require_read(self, carried_from: BusReadBack) -> None: @@ -464,6 +464,7 @@ def build(cls, checkout: Path, commands: InstallCommands, home: Path = HOME) -> if prefix.exists(): if not RuntimeResolution.at(prefix).matches(source.lock, built.python, core, commands): raise UpgradeRefused("Existing built runtime differs from its resolved inputs") + built.prepare_native() return built prefix.parent.mkdir(mode=0o700, parents=True, exist_ok=True) prefix.mkdir(mode=0o700) @@ -503,17 +504,42 @@ def probe[Answer](self, probe: RuntimeProbe[Answer], *, def imports(self) -> None: self.probe(ClientImportsProbe()) + @property + def prepared_natives(self) -> Path: + """Every runtime's built native package, laid out as ``stack/`` is and keyed by the + pin, so a runtime whose pin was already built copies it instead of rebuilding it.""" + return self.home / ".local/share/agent-comms/native" + def install_native(self) -> None: - """Build, copy and qualify this interpreter's native artifact before publication.""" - self.commands.run(self.checkout / "stack/bin/prepare-pi-native", "--python", self.python, - cwd=self.checkout, env=self.environment(self.checkout / "src")) + """Copy this pin's built native package into the interpreter, building it only when + no runtime has built this pin yet; qualifying the copy checks it against the pin.""" native = self.probe(PinnedLocationProbe(), source=(self.checkout / "src",)) + layout = native.relative_to(self.checkout / "stack") + prepared = self.prepared_natives / layout + if not prepared.is_dir(): + self.commands.run(self.checkout / "stack/bin/prepare-pi-native", "--python", + self.python, cwd=self.checkout, + env=self.environment(self.checkout / "src")) + self.keep_native(native, layout) package = self.probe(PackageRootProbe()) - destination = package / "_native" / native.relative_to(self.checkout / "stack") + destination = package / "_native" / layout destination.parents[2].mkdir(mode=0o700, parents=True) - shutil.copytree(native, destination) + shutil.copytree(prepared, destination) self.prepare_native() + def keep_native(self, native: Path, layout: Path) -> None: + """Publish a fresh build under its pin whole, and retire other pins' builds: each + runtime holds its own copy, so only the next runtime reads this one.""" + self.prepared_natives.mkdir(mode=0o700, parents=True, exist_ok=True) + stage = Path(tempfile.mkdtemp(dir=self.prepared_natives, prefix=".stage-")) + try: + shutil.copytree(native, stage / layout) + for retired in self.prepared_natives.glob(".pi-native-*"): + shutil.rmtree(retired) + stage.joinpath(layout.parts[0]).rename(self.prepared_natives / layout.parts[0]) + finally: + shutil.rmtree(stage, ignore_errors=True) + def prepare_native(self) -> None: self.probe(PrepareNativeProbe()) @@ -559,22 +585,31 @@ def cli[Answer: CliResult](self, root: Path, command: type[CliCommand[Answer]], time.sleep(5) raise UpgradeRefused(f"{verb}: still unavailable after 20 minutes: {failure.error}") - def call_step[Answer](self, step: Carry, tree: TargetTree, - operation: CarryOperation[Answer]) -> Answer: - """Run the step in the target tree with this stack's interpreter.""" + def call_steps[Answer](self, steps: tuple[Carry, ...], tree: TargetTree, + operation: CarryOperation[Answer]) -> tuple[Answer, ...]: + """Run one pass of steps, in order, in one worker in the target tree with this + stack's interpreter; each step's answer in step order.""" + if not steps: + return () with tempfile.TemporaryDirectory() as scratch: result = Path(scratch) / "result.json" - self.commands.run(self.python, "-m", step_worker.__name__, step.name(), - json.dumps(FieldCodec.encode(step)), - json.dumps(FieldCodec.encode(operation)), result, cwd=tree.path, - env=self.environment(*tree.source_paths())) - return operation.decode_answer(result.read_text()) + self.commands.run(self.python, "-m", step_worker.__name__, + json.dumps(FieldCodec.encode(operation)), result, + json.dumps([[step.name(), FieldCodec.encode(step)] + for step in steps]), + cwd=tree.path, env=self.environment(*tree.source_paths())) + return operation.decode_answers(result.read_text()) + + def call_step[Answer](self, step: Carry, tree: TargetTree, + operation: CarryOperation[Answer]) -> Answer: + (answer,) = self.call_steps((step,), tree, operation) + return answer def backup_files(self, pending: tuple[Carry, ...], tree: TargetTree) -> tuple[Path, ...]: return tuple(sorted({ - path for step in pending - for path in self.call_step(step, tree, BackupFilesOperation()) + path for paths in self.call_steps(pending, tree, BackupFilesOperation()) + for path in paths })) def running_owners(self, root: Path) -> list[str]: @@ -859,10 +894,8 @@ def capture(cls, hold: MaintenanceState, outgoing: Stack) -> PreparedMigration: def carry(self, pending: tuple[Carry, ...], staged: Stack, tree: TargetTree) -> CarriedPass: """One migration sequence for rehearsal and the backed-up live bus.""" - steps = [] - for step in pending: - steps.append(StepRun(step.name(), staged.call_step( - step, tree, MigrateOperation(apply=True)))) + steps = [StepRun(step.name(), carried) for step, carried in zip( + pending, staged.call_steps(pending, tree, MigrateOperation(apply=True)), strict=True)] reconciled = staged.cli(self.root, ReconcileStoresCliCommand) steps.append(StepRun(ReconcileStoresCliCommand.declared_name, Carried( len(reconciled.rewritten), rewritten=reconciled.rewritten))) @@ -891,7 +924,6 @@ def rehearsal(self, practice: PracticeBus) -> CarriedRehearsal: RehearsalHome(home).prepare() (home / ".claude").symlink_to(Path.home() / ".claude") stack = replace(self.staged, home=home) - stack.prepare_native() pending = tuple(replace(step, root=copied, spill_dir=practice.spill_dir) for step in self.pending) for destination in stack.backup_files(pending, self.tree): @@ -948,7 +980,6 @@ def live(self, rehearsal: RehearsedFiles, report: InstallReport) -> CarriedRun: try: self.staged.commands.run("git", "-C", self.checkout, "merge", "--ff-only", "-q", self.target) Checkout(self.checkout).prepare() - self.staged.prepare_native() passed = migration.carry(self.pending, self.staged, self.tree) passed.files.require_rehearsal(rehearsal.files) self.publish(migration.backup, selected_route) @@ -1041,7 +1072,6 @@ def live(self, rehearsal: FreshRehearsal, report: InstallReport) -> CreatedRun: self.staged.commands.run("git", "-C", self.checkout, "merge", "--ff-only", "-q", self.target) Checkout(self.checkout).prepare() - self.staged.prepare_native() self.root.mkdir(mode=0o700, parents=True, exist_ok=True) Comms(self.root).registry.prepare_native_root() Bus.install(Bus.path_in(self.root)) diff --git a/src/agent_comms/native_input_owner.py b/src/agent_comms/native_input_owner.py index 4e1d192a83..56d6c28c1f 100644 --- a/src/agent_comms/native_input_owner.py +++ b/src/agent_comms/native_input_owner.py @@ -73,7 +73,7 @@ def require_claim(self, expected: Thread, claim_scope: int) -> None: try: GoalRegistryIdentityCheck( expected=expected, actual=self.thread, - expected_claim=claim_scope, claim_scope=self.claimed_generation, + expected_claim=claim_scope, claim_generation=self.claimed_generation, process=ProcessIdentity.capture(os.getpid()), goal_permission=OwnerGoalPermission(expected.goal), ).require_valid() @@ -112,11 +112,11 @@ def _require_current( expected=self.thread, actual=actual, expected_claim=self.claimed_generation, - claim_scope=claim_scope, + claim_generation=claim_scope, process=ProcessIdentity.capture(os.getpid()), goal_permission=self.goal_permission, ).require_valid() - except ReservationViolationError as error: + except (ReservationViolationError, RelationViolationError) as error: raise StaleFence(f"{reason}: {error}") from error def require_snapshot(self, snapshot: RegistrySnapshot, reason: str) -> None: diff --git a/src/agent_comms/native_package.py b/src/agent_comms/native_package.py index 927c95ce81..9b7d2ea9ef 100644 --- a/src/agent_comms/native_package.py +++ b/src/agent_comms/native_package.py @@ -16,7 +16,8 @@ import os import stat import tempfile -from dataclasses import dataclass +from _hashlib import HASH +from dataclasses import dataclass, field from pathlib import Path from typing import Annotated, Literal @@ -77,13 +78,15 @@ def pinned_native_package() -> Path: return package -def package_tree_digest(root: Path) -> str: - """Hash sorted path/type/execute-mode/size/content records with bounded reads.""" - if os.name != "posix": - raise NativePackageError("Native package filesystem verification requires POSIX") - digest = hashlib.sha256() - count, total = 1, 0 +@dataclass +class PackageInventory: + """One bounded package walk and its ordered content commitment.""" + entries: int = 1 + bytes: int = 0 + digest: HASH = field(default_factory=hashlib.sha256, repr=False) + + @staticmethod def identity(info: os.stat_result) -> tuple[int, ...]: return ( info.st_dev, @@ -96,35 +99,39 @@ def identity(info: os.stat_result) -> tuple[int, ...]: info.st_ctime_ns if info.st_mode & 0o222 else 0, ) - def visit(path: str, relative: str, depth: int) -> None: - nonlocal count, total - if count > MAX_ENTRIES or depth > MAX_DEPTH: + def require_inventory_limit(self, depth: int) -> None: + if self.entries > MAX_ENTRIES or depth > MAX_DEPTH: raise NativePackageError("Native package inventory limit exceeded") + + def require_byte_limit(self) -> None: + if self.bytes > MAX_BYTES: + raise NativePackageError("Native package byte limit exceeded") + + def visit(self, path: str, relative: str, depth: int) -> None: + self.require_inventory_limit(depth) before = os.lstat(path) if before.st_uid not in (0, os.getuid()) or before.st_mode & 0o022: raise NativePackageError("Native package is not owner-controlled") if stat.S_ISDIR(before.st_mode): record = ["directory", relative, before.st_mode & 0o111] - digest.update((json.dumps(record, ensure_ascii=True) + "\n").encode()) + self.digest.update((json.dumps(record, ensure_ascii=True) + "\n").encode()) names = [] with os.scandir(path) as entries: for entry in entries: - count += 1 # Include pending siblings before recursive descent. - if count > MAX_ENTRIES: - raise NativePackageError("Native package inventory limit exceeded") + self.entries += 1 # Include pending siblings before recursive descent. + self.require_inventory_limit(depth) names.append(entry.name) for name in sorted(names): - visit(os.path.join(path, name), f"{relative}/{name}", depth + 1) + self.visit(os.path.join(path, name), f"{relative}/{name}", depth + 1) elif stat.S_ISREG(before.st_mode): if before.st_nlink != 1 and before.st_mode & 0o222: raise NativePackageError("Native package shared resource is writable") - total += before.st_size - if total > MAX_BYTES: - raise NativePackageError("Native package byte limit exceeded") + self.bytes += before.st_size + self.require_byte_limit() content = hashlib.sha256() fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) try: - if identity(os.fstat(fd)) != identity(before): + if self.identity(os.fstat(fd)) != self.identity(before): raise NativePackageError("Native package changed while opening") remaining = before.st_size while remaining: @@ -133,22 +140,28 @@ def visit(path: str, relative: str, depth: int) -> None: raise NativePackageError("Native package truncated while hashing") content.update(block) remaining -= len(block) - if os.read(fd, 1) or identity(os.fstat(fd)) != identity(before): + if os.read(fd, 1) or self.identity(os.fstat(fd)) != self.identity(before): raise NativePackageError("Native package changed while hashing") finally: os.close(fd) record = ["file", relative, before.st_mode & 0o111, before.st_size, content.hexdigest()] - digest.update((json.dumps(record, ensure_ascii=True) + "\n").encode()) + self.digest.update((json.dumps(record, ensure_ascii=True) + "\n").encode()) else: raise NativePackageError("Native package contains symlinks or special files") - if identity(os.lstat(path)) != identity(before): + if self.identity(os.lstat(path)) != self.identity(before): raise NativePackageError("Native package changed during verification") + +def package_tree_digest(root: Path) -> str: + """Hash sorted path/type/execute-mode/size/content records with bounded reads.""" + if os.name != "posix": + raise NativePackageError("Native package filesystem verification requires POSIX") + inventory = PackageInventory() try: - visit(os.fspath(root), ".", 0) + inventory.visit(os.fspath(root), ".", 0) except OSError as error: raise NativePackageError("Native package could not be verified") from error - return digest.hexdigest() + return inventory.digest.hexdigest() def share_native_resources(root: Path, deployments: Path) -> dict[str, int]: diff --git a/src/agent_comms/native_pi.py b/src/agent_comms/native_pi.py index f3de4c8b32..1586f90212 100644 --- a/src/agent_comms/native_pi.py +++ b/src/agent_comms/native_pi.py @@ -50,7 +50,7 @@ GuardedInserts, ImmutableRows, Index, - TypedTable, + WithoutRowid, ) if TYPE_CHECKING: @@ -215,7 +215,7 @@ def at(self, session_file: Path, *, session_entry_start: int) -> NativeContextPr @dataclass(frozen=True, slots=True) class NativeContextJournal( - GuardedInserts, ImmutableRows, NativeContextRecord, TypedTable, DurableTables + GuardedInserts, ImmutableRows, NativeContextRecord, WithoutRowid, DurableTables ): """One proof declaration owns wire facts and the indexed durable journal.""" @@ -230,7 +230,6 @@ class NativeContextJournal( schema: Literal[2] type: Literal["context_committed"] - without_rowid = True indexes = (Index(("request_generation",)), Index(("session_id",))) @classmethod diff --git a/src/agent_comms/native_prompt_binding.py b/src/agent_comms/native_prompt_binding.py index 2a94ad3f6a..8b7687cae5 100644 --- a/src/agent_comms/native_prompt_binding.py +++ b/src/agent_comms/native_prompt_binding.py @@ -38,7 +38,7 @@ IntegerStorage, TextStorage, TypedRow, - TypedTable, + WithoutRowid, ) if TYPE_CHECKING: @@ -52,7 +52,7 @@ def binding_store_path(store: Coordination) -> Path: @dataclass(frozen=True, slots=True) class PromptBinding( - ImmutableRows, DurableFile, SQLiteFile, NativeInputRecord, TypedTable, DurableTables + ImmutableRows, DurableFile, SQLiteFile, NativeInputRecord, WithoutRowid, DurableTables ): filename = "native_prompt_bindings.sqlite3" @@ -80,7 +80,6 @@ def execution(self) -> NativeInputExecution: except MalformedRecord as error: raise IdentityConflict("Prompt binding execution columns conflict with their stage") from error - without_rowid = True checks = ( "(stage='triage' AND execution_id IS NULL AND attempt_ordinal IS NULL) OR " "(stage='full' AND execution_id IS NOT NULL AND attempt_ordinal>0)", diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index c6537b9eb5..4d6d23623c 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -16,7 +16,7 @@ from agent_comms.private_runtime_schema import PrivateCoordinatorTable from .coordination_errors import IdentityConflict, PublicationActivationBlocked, StaleFence -from .field_codec import FieldCodec, MalformedRecord, NativeContext, WireName +from .field_codec import FieldCodec, MalformedRecord, NativeContext from .native_claim_epoch import NativeClaimEpoch, UnrecordedNativeClaim from .native_input_record import ( NativeColumn, @@ -41,7 +41,6 @@ from .typed_table import ( TableReference, Column, - DurableTables, FinalizedColumn, FrozenColumn, IntegerStorage, @@ -49,11 +48,10 @@ NoDelete, NonDecreasing, RequiredUpdateColumns, - SharedFileTables, StableReferenceColumns, TextStorage, TypedRow, - TypedTable, + WithoutRowid, WriteOnceColumns, sql_literal, ) @@ -87,7 +85,7 @@ class NativeRuntimeInput( NativeInputRecord, NativeInputContext, NativeRuntimeTable, - TypedTable, + WithoutRowid, ): @classmethod def notification_context_sql(cls) -> str: @@ -264,7 +262,6 @@ def published_replies(self, db, user, owner_lookup): owner_token_digest: Annotated[str, FrozenColumn(check="length(owner_token_digest)=64")] sent_owner_claimed_generation: Annotated[ NativeClaimEpoch, - WireName("sent_owner_admission_generation"), Column( record_key=True, check="{sent_owner_claimed_generation}>0", @@ -288,7 +285,6 @@ def published_replies(self, db, user, owner_lookup): Column(check=RecordedTriageText.sql_constraint("verdict"), storage=TextStorage, nullable=True), ] = AbsentTriageDecisionRecord() - without_rowid = True unique = (("execution_id", "attempt_ordinal"),) checks = ( "(stage='triage' AND execution_id IS NULL AND attempt_ordinal IS NULL) OR " @@ -342,7 +338,7 @@ class CurrentNativeCursor( NoDelete, NativeInputContext, NativeRuntimeTable, - TypedTable, + WithoutRowid, ): column_orders = (NonDecreasing(("covered_seq", "injected_seq")),) reference_column = 'injected_seq' @@ -365,7 +361,6 @@ def context_anchor(self) -> NativeColumn: owner_generation: Annotated[int, FrozenColumn(primary_key=True, check="owner_generation>0")] owner_claimed_generation: Annotated[ int, - WireName("owner_admission_generation"), FrozenColumn(record_key=True, primary_key=True, check="{owner_claimed_generation}>0"), ] @@ -394,7 +389,6 @@ def owner_identity(self): session_id: Annotated[NativeColumn, NativeContext(str), Column(storage=TextStorage, nullable=True)] request_generation: Annotated[NativeColumn, NativeContext(int), Column(storage=IntegerStorage, nullable=True)] - without_rowid = True checks = ( "(injected_seq=0 AND input_id IS NULL AND stage IS NULL " "AND session_id IS NULL AND request_generation IS NULL) OR " diff --git a/src/agent_comms/native_session_prepare.py b/src/agent_comms/native_session_prepare.py index d2f31411c6..1857eb0fb7 100644 --- a/src/agent_comms/native_session_prepare.py +++ b/src/agent_comms/native_session_prepare.py @@ -18,40 +18,15 @@ from .coordinator import Coordination from .pi_payloads import StateData from .session_fence import session_writer_fence -from .prompt_state import UnwrittenPrompt +from . import prompt_state if TYPE_CHECKING: from .pi_native_backend import PersistentPiSession - from .following_inputs import FollowingInputs - - -class PreparedSession(UnwrittenPrompt): - acknowledged = False - - def permits_retention(self, session: backend.NativeSession) -> bool: - return session.output.clean - - async def deliver_to( - self, inputs: FollowingInputs, session: backend.NativeSession, - ) -> bool: - session.native.forget_context() - state = session.native.attestation.require_state() - identity = session.native.attestation.require_identity() - session.launch.session.attest(identity) - if ( - state.is_streaming.value_or(True) - or state.is_compacting.value_or(True) - or not state.pending_message_count.matches(0) - ): - raise NativePiUnavailable("Native preparation did not attest the idle saved session") - session.active_session_file = identity.session_file - await session.stats.request(session) - return True class NativeSessionPreparation(backend.NativeSession): """Share native launch/attestation with turns; retain an idle child, send no prompt.""" - prompt_type = PreparedSession + prompt_type = prompt_state.PreparedSession def finish_result(self) -> Iterable[events.AgentEvent]: if not self.native_session.child_state.retained: diff --git a/src/agent_comms/nk_foreground.py b/src/agent_comms/nk_foreground.py index 3d2ea51478..16264ea4a9 100644 --- a/src/agent_comms/nk_foreground.py +++ b/src/agent_comms/nk_foreground.py @@ -13,7 +13,6 @@ import argparse import asyncio -import json import os import re import select @@ -116,7 +115,7 @@ def reserve_foreground_owner( def _emit(payload: dict[str, object]) -> None: - json.dump(payload, sys.stdout, separators=(",", ":")) + sys.stdout.write(FieldCodec.compact(payload)) sys.stdout.write("\n") sys.stdout.flush() diff --git a/src/agent_comms/ordinary_turn_rules.py b/src/agent_comms/ordinary_turn_rules.py index 8271643f4b..0c67b6b695 100644 --- a/src/agent_comms/ordinary_turn_rules.py +++ b/src/agent_comms/ordinary_turn_rules.py @@ -38,7 +38,7 @@ class OrdinaryOwnerCheck(RuleCheck): current: Thread registry: RegistrySnapshot canonical: str - claim_scope: int + claim_generation: int turn: TurnId @@ -63,7 +63,7 @@ class OrdinaryClaimRule(ReservationRule): explanation = "The ordinary owner claim_scope generation changed." def violated(self, check: OrdinaryOwnerCheck) -> bool: - return check.registry.claim_generation(check.canonical) != check.claim_scope + return check.registry.claim_generation(check.canonical) != check.claim_generation class OrdinaryProcessRule(ReservationRule): @@ -122,7 +122,7 @@ def violated(self, check: MissingAcceptedInputCheck) -> bool: class AcceptedInputCheck(RuleCheck): acknowledgement: QueuedInput current: Thread - claim_scope: int + claim_generation: int input_id: str keys: tuple[str, ...] inputs: InputDrain @@ -134,7 +134,7 @@ class AcceptedInputAuthorityRule(ReservationRule): explanation = "The live input's accepted owner or goal authority changed." def violated(self, check: AcceptedInputCheck) -> bool: - return not check.acknowledgement.current(check.current, check.claim_scope) + return not check.acknowledgement.current(check.current, check.claim_generation) class AcceptedInputKeyRule(ReservationRule): diff --git a/src/agent_comms/owned_send.py b/src/agent_comms/owned_send.py index b7c7e58a56..382699c77e 100644 --- a/src/agent_comms/owned_send.py +++ b/src/agent_comms/owned_send.py @@ -53,7 +53,7 @@ class OwnedSend(CodingToolOwner): session_id: str thread: Thread turn: TurnId - claim_scope: int + claim_generation: int original: OriginalTurnInput goal_permit: GoalTurnPermit @@ -80,7 +80,7 @@ def _check_owner( current=current, registry=snapshot, canonical=canonical, - claim_scope=self.claim_scope, + claim_generation=self.claim_generation, turn=self.turn, ).require_valid() InputKeysCheck(source=source, text=text).require_valid() @@ -119,7 +119,7 @@ def _prepare( AcceptedInputCheck( acknowledgement=accepted, current=current, - claim_scope=self.claim_scope, + claim_generation=self.claim_generation, input_id=source.accepted_id, keys=source.keys, inputs=self.inputs, @@ -138,7 +138,7 @@ def _prepare( # never reinterpret a failed durable operation as a retryable policy refusal. if not self.inputs.dispositions.bind_turn_input( source.keys, - claim_scope=self.claim_scope, + claim_scope=self.claim_generation, turn_id=self.turn.value, native_id=native_id, text=sent_text, @@ -191,7 +191,7 @@ def claim_resource(self, store, turn, resource, slot): self.verify(store, turn, snapshot) owner = snapshot.require(self.owner_name) canonical = resource.normalized(Path(owner.worktree)) - scope = NativeClaim(owner_claimed_generation=self.claim_scope, + scope = NativeClaim(owner_claimed_generation=self.claim_generation, operation_id=slot, turn_id=self.turn.value, input_id=turn.command.input_id) return boundary.bus.publisher._publish_acquisition_unlocked( diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index ced5311af1..37e849cc91 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -167,7 +167,7 @@ def begin(self, resources: AsyncExitStack, permits: ExitStack) -> bool: key=self.runner.inputs.dispositions.bus_key(origin, self.thread), sequence=origin.seq, owner=self.thread_name, - claim_scope=snapshot.claim_generation(self.thread_name), + claim_generation=snapshot.claim_generation(self.thread_name), target=origin.target, source_text=ScheduledTurn.incoming( origin, aliases=snapshot.aliases @@ -345,7 +345,7 @@ async def stream(self): session_id=self.session_id, thread=self.thread, turn=TurnId(self.turn_id), - claim_scope=self.turn_lease.claimed_generation, + claim_generation=self.turn_lease.claimed_generation, goal_permit=self.goal_permit, original=replace(self.original, prompt=rendered.text), ) diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index 26c1a19546..2cf6080375 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, Annotated from uuid import uuid4 -from agent_comms import field_codec from .active_route import resolve_comms_route from agent_comms.attempt_recovery import RecoveryMonitorCapability @@ -95,7 +94,7 @@ def record_first_input(self, record) -> None: class OwnerRestartSelection: identity: OwnerIdentity process: ProcessIdentity - claimed_generation: Annotated[int, field_codec.WireName("admission_generation")] + claimed_generation: int interpreter: str @classmethod diff --git a/src/agent_comms/pi_helper.py b/src/agent_comms/pi_helper.py index f0a41b53f8..0ef23ba345 100644 --- a/src/agent_comms/pi_helper.py +++ b/src/agent_comms/pi_helper.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import shutil from collections.abc import Mapping from dataclasses import dataclass @@ -95,7 +94,7 @@ def node_launch(cls, package: Path, environment: dict[str, str]): @classmethod async def run(cls, request, *, cwd: Path, env: Mapping[str, str] = INHERITED_ENVIRONMENT): command, environment = cls.launch(request, dict(env)) - payload = json.dumps(FieldCodec.encode(request), separators=(",", ":"), allow_nan=False) + payload = FieldCodec.compact(FieldCodec.encode(request), allow_nan=False) outcome = await BoundedRun.run( command, input=payload.encode(), diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index 6a355cdc22..14f46482f7 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -644,7 +644,11 @@ class OwnedDispatchPhase(RemainingSlopPhase): evidence = AllEvidence( ( PatternAbsent(r"class TurnInboxReceiver\b"), - PatternAbsent(r"separators=\(\",\", ?\":\"\)"), + PatternAbsent( + r"""separators\s*=\s*\(\s*["'],["']\s*,\s*["']:["']\s*\)""", + ("src", ":!src/agent_comms/field_codec.py"), + ), + PatternPresent(r"def compact\(", ("src/agent_comms/field_codec.py",)), ) ) diff --git a/src/agent_comms/private_send_rules.py b/src/agent_comms/private_send_rules.py index 0e3a4ccac1..16597129fe 100644 --- a/src/agent_comms/private_send_rules.py +++ b/src/agent_comms/private_send_rules.py @@ -25,7 +25,7 @@ class RegistryIdentityCheck(RuleCheck): expected: Thread actual: Thread expected_claim: int - claim_scope: int | None + claim_generation: int | None process: ProcessIdentity goal_permission: GoalPermission @@ -119,7 +119,7 @@ class RegistryClaimRule(ReservationRule): explanation = "The registry claim_scope generation changed." def violated(self, check: RegistryIdentityCheck) -> bool: - return check.claim_scope != check.expected_claim + return check.claim_generation != check.expected_claim class RegistryRoleRule(ReservationRule): diff --git a/src/agent_comms/private_sidecar.py b/src/agent_comms/private_sidecar.py index 0d24f362aa..3f6da45d64 100644 --- a/src/agent_comms/private_sidecar.py +++ b/src/agent_comms/private_sidecar.py @@ -15,7 +15,6 @@ from __future__ import annotations import hashlib -import json import os import secrets import sqlite3 @@ -25,7 +24,7 @@ from dataclasses import dataclass from functools import partial from pathlib import Path -from typing import Annotated, Literal, TYPE_CHECKING +from typing import TYPE_CHECKING from agent_comms.locked_store import SQLiteFile from agent_comms.coordination_errors import IdentityConflict @@ -39,8 +38,8 @@ from .store_files import StoreOwner from .typed_table import ( AllObjectsTables, - Column, ImmutableRows, + SingletonRow, SQLiteDatabase, SQLiteForeignKeys, SQLiteSchemaObject, @@ -209,8 +208,7 @@ def _connect(raw: bytes | None) -> sqlite3.Connection: @dataclass(frozen=True) -class SnapshotMeta(ImmutableRows, TypedTable): - singleton: Annotated[Literal[1], Column(primary_key=True)] +class SnapshotMeta(ImmutableRows, SingletonRow): ddl_digest: str @dataclass(frozen=True) @@ -223,7 +221,7 @@ def _schema(row_type: type[TypedTable]) -> dict[str, str]: def _digest(row_type: type[TypedTable]) -> str: - return hashlib.sha256(json.dumps(_schema(row_type), separators=(",", ":")).encode()).hexdigest() + return hashlib.sha256(FieldCodec.compact(_schema(row_type)).encode()).hexdigest() def _verify_schema(connection: sqlite3.Connection, row_type: type[TypedTable]) -> None: @@ -372,7 +370,7 @@ def sidecar_connection( def encode_request(text: str, *, streaming_behavior: type[StreamingInputPolicy] | None = None) -> str: """JSON-encode like the pinned native ``_claimNativeInput`` request.""" - return json.dumps( + return FieldCodec.compact( { "kind": "prompt", "text": text, @@ -380,9 +378,7 @@ def encode_request(text: str, *, streaming_behavior: type[StreamingInputPolicy] "streamingBehavior": FieldCodec.encode(streaming_behavior), "expandPromptTemplates": True, "source": "rpc", - }, - separators=(",", ":"), - ensure_ascii=False, + }, ensure_ascii=False, ) diff --git a/src/agent_comms/prompt_state.py b/src/agent_comms/prompt_state.py index 4248556a65..ae201a8b6a 100644 --- a/src/agent_comms/prompt_state.py +++ b/src/agent_comms/prompt_state.py @@ -8,11 +8,9 @@ from . import turn_failure as failures from .agent_backend import TurnInboxItem -from .locked_store import RegistryFile -from .maintenance_barrier import MaintenanceBarrier if TYPE_CHECKING: - from .backend import TurnSession + from .backend import NativeSession, TurnSession from .pi_events import AgentSettled, InputCommitted, MessageStart, Response @@ -64,15 +62,6 @@ class UnwrittenPrompt(PromptState, TurnInboxItem): def dispatch(self): return UnacknowledgedPrompt() - async def deliver_to(self, inputs, session): - def write(): - session.grant_prompt() - session.native.send(session.command) - async with MaintenanceBarrier(RegistryFile.path_in(session.startup.root)).ingress_async() as status: - with status.phase.send_new(session.send_boundary, None, session.command.input_id, session.command.message) as outcome: - completion = outcome.dispatch(inputs, session, self, write) - return await completion - def refuse(self, inputs, session): session.output.record_failure(failures.InputMissing("Original input was cleared before send.")) session.finish_request() @@ -84,6 +73,15 @@ def raise_native_failure(self, error, attestation) -> Never: raise NativePiInputNotSent(error, attestation) from error +class PreparedSession(UnwrittenPrompt): + """An idle-session preparation owns no user prompt.""" + + acknowledged = False + + def permits_retention(self, session: NativeSession) -> bool: + return session.output.clean + + @dataclass(frozen=True) class AcknowledgedPrompt(PromptState): response: Response diff --git a/src/agent_comms/publisher.py b/src/agent_comms/publisher.py index b9709367d6..892fa77672 100644 --- a/src/agent_comms/publisher.py +++ b/src/agent_comms/publisher.py @@ -173,7 +173,7 @@ def _publish_acquisition_unlocked( build = self._claim_builder( snapshot, ClaimNoticeKind.claimed(owner), partial(WakeClaims, incarnation=str(owner.created_at), claims=(resource,), - generation=Id128Text.mint(), claim_scope=claim), activity=activity, + generation=Id128Text.mint(), resource_scope=claim), activity=activity, ) return self._claim_result(lambda: self.bus.acquire_resource_claim(resource, claim, snapshot, build)) diff --git a/src/agent_comms/queued_input.py b/src/agent_comms/queued_input.py index ead40319d8..976b2bd312 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -11,7 +11,7 @@ from contextlib import AsyncExitStack, ExitStack, asynccontextmanager from dataclasses import dataclass, field, replace from functools import partial -from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar, Annotated +from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar from acp import RequestError @@ -24,7 +24,7 @@ from .coordination_session import ReadNativeCoordinationSession from .coordinator import Coordination from .errors import RelationViolationError -from .field_codec import Id128Text, WireName +from .field_codec import Id128Text from .goals import GoalDeclaration, NoGoal from .image_inputs import ImageInput from .input_attempt import InputAttempt, ReservedInput @@ -55,7 +55,7 @@ class InputHandoffRefused(RelationViolationError): @dataclass(frozen=True, slots=True) class QueuedInputContext: - claim_scope: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: ClaimedIdentity goal: GoalDeclaration = field(default_factory=NoGoal) @classmethod @@ -66,14 +66,14 @@ def named(self, name: str) -> QueuedInputContext: """Use the caller's canonical registry name without changing authority.""" return replace( self, - claim_scope=replace( - self.claim_scope, - incarnation=replace(self.claim_scope.incarnation, name=name), + claimed_identity=replace( + self.claimed_identity, + incarnation=replace(self.claimed_identity.incarnation, name=name), ), ) def owns(self, claim_scope: ClaimedIdentity) -> bool: - return self.named(claim_scope.incarnation.name).claim_scope == claim_scope + return self.named(claim_scope.incarnation.name).claimed_identity == claim_scope @property def active_goal_id(self) -> str | None: @@ -98,7 +98,7 @@ def queue_items(self, scope: QueueScope) -> tuple[QueueItem, ...]: """Show the item only when its recorded input generation matches the queue.""" from .acp_extension import QueueItem - return (QueueItem(self.input_id, self.text),) if self.context.owns(scope.claim_scope) else () + return (QueueItem(self.input_id, self.text),) if self.context.owns(scope.claimed_identity) else () def source(self) -> AcceptedFollowingInput: return AcceptedFollowingInput( @@ -130,7 +130,7 @@ def capture( owner, snapshot.claimed_identity(canonical), ) - origin.require_ingress(inputs.comms, snapshot, context.claim_scope, controller) + origin.require_ingress(inputs.comms, snapshot, context.claimed_identity, controller) item = cls( text or prompt or "[image prompt]", echo, @@ -141,7 +141,7 @@ def capture( controller, ) row = ReservedInput( - item.key, None, canonical, context.claim_scope.claimed_generation, + item.key, None, canonical, context.claimed_identity.claimed_generation, canonical, item.text, origin=origin, ) document = inputs.dispositions.reserve_originals(row, ownership=ownership) @@ -362,7 +362,7 @@ def reserve_pending( (DeliveredMessageSegment.capture(source, assignment, owner),) ) row = ReservedInput( - key, message.seq, canonical, context.claim_scope.claimed_generation, + key, message.seq, canonical, context.claimed_identity.claimed_generation, message.target, ScheduledTurn.incoming(message, aliases=snapshot.aliases).prompt, origin=WireInputOrigin(root_id, message.reference), diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 04a61d3f2c..97c7fd61fd 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -19,7 +19,6 @@ MalformedRecord, FieldPlan, SlicePolicy, - WireName, WireRequired, ) from .registration_change import ( @@ -323,7 +322,7 @@ class RegistryDocument(RegistryPresence[dict[str, ThreadStatus]]): default_factory=GenerationCounter ) claims: Annotated[ - GenerationCounter, WireName("admissions"), SlicePolicy(GenerationOf) + GenerationCounter, SlicePolicy(GenerationOf) ] = field(default_factory=GenerationCounter) _human: UserThreadLookup[str] = field(init=False, repr=False, compare=False) diff --git a/src/agent_comms/response_acknowledgement.py b/src/agent_comms/response_acknowledgement.py index f87729b996..6388f981ce 100644 --- a/src/agent_comms/response_acknowledgement.py +++ b/src/agent_comms/response_acknowledgement.py @@ -3,7 +3,6 @@ from __future__ import annotations import hashlib -import json import math from abc import abstractmethod from collections.abc import Mapping @@ -14,11 +13,18 @@ MAX_IDENTIFIER_CHARS, require_bounded, require_nonempty, validate_execution_id, ) from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import MalformedRecord, NullMember, RecordMember, SubclassUnion +from agent_comms.field_codec import ( + FieldCodec, + MalformedRecord, + NullMember, + RecordMember, + SubclassUnion, +) + def _canonical(value: object) -> bytes: - return json.dumps( - value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False + return FieldCodec.compact( + value, sort_keys=True, ensure_ascii=False, allow_nan=False, ).encode("utf-8") diff --git a/src/agent_comms/retained_task_facts.py b/src/agent_comms/retained_task_facts.py index 9068bb2fbb..ba7d21d1b6 100644 --- a/src/agent_comms/retained_task_facts.py +++ b/src/agent_comms/retained_task_facts.py @@ -246,8 +246,7 @@ def original_input_facts(self, inputs: InputDispositions) -> tuple[ExactTaskFact @staticmethod def canonical_bytes(record: object) -> bytes: - return json.dumps(record, sort_keys=True, separators=(",", ":"), - allow_nan=False).encode() + return FieldCodec.compact(record, sort_keys=True, allow_nan=False).encode() def original_text_source(self, reference: MessageReference) -> Message | StoredInput: originals = {key: source for fact in self.facts diff --git a/src/agent_comms/runtime_updates.py b/src/agent_comms/runtime_updates.py index 6b6d6cbe43..97a6658a81 100644 --- a/src/agent_comms/runtime_updates.py +++ b/src/agent_comms/runtime_updates.py @@ -24,6 +24,7 @@ ) from .thread_text import NoThreadText, ThreadText from .turn_phase import IdlePhase, ModelWaitPhase, TurnPhase +from .turn_item import TurnItem if TYPE_CHECKING: from .cli_bridges.shared import TranscriptBridge @@ -150,36 +151,20 @@ def append(self, update: AppendDraftItem) -> tuple[RuntimeUpdate, ...]: return () -@dataclass(frozen=True) -class ItemKey: - """One item's identity: the turn that holds it and its producer's item id.""" - - turn_id: str - item_id: str - - -class KeyedItem(ItemKey): - """An update about one item, named by the turn that holds it and its producer's item id.""" - - @property - def key(self) -> ItemKey: - return ItemKey(self.turn_id, self.item_id) - - @dataclass(frozen=True) class ThreadOutput(activity.ThreadOutput): """Where a CLI's output lands in Core: finished items and current activity.""" transcript: ExternalTranscript agents: AgentActivity - drafts: dict[ItemKey, DraftAccumulator] = field(default_factory=dict) + drafts: dict[TurnItem, DraftAccumulator] = field(default_factory=dict) def publish_batch(self, updates: list[RuntimeUpdate]) -> None: assembled = [part for update in updates for part in update.accumulate(self)] for update in RuntimeUpdate.coalesced(assembled): update.publish(self) - def draft(self, key: ItemKey) -> DraftAccumulator: + def draft(self, key: TurnItem) -> DraftAccumulator: return self.drafts.get(key, NO_PREFIX) def start_item(self, update: StartedItem) -> tuple[RuntimeUpdate, ...]: @@ -235,7 +220,7 @@ def accumulate(self, output: ThreadOutput) -> tuple[RuntimeUpdate, ...]: async def observed(self, bridge: TranscriptBridge) -> None: # noqa: B027 - default hook """A source-owned notification may confirm delivery before publication.""" - def retain_before(self, later: set[ItemKey]) -> bool: + def retain_before(self, later: set[TurnItem]) -> bool: """Whether this update still needs publication before later updates.""" return True @@ -248,7 +233,7 @@ def add_to(self, history: ImportBuffer) -> None: # noqa: B027 - default hook @staticmethod def coalesced(updates: list[RuntimeUpdate]) -> list[RuntimeUpdate]: - later: set[ItemKey] = set() + later: set[TurnItem] = set() kept = [update for update in reversed(updates) if update.retain_before(later)] return list(reversed(kept)) @@ -276,7 +261,7 @@ def publish(self, output: ThreadOutput) -> None: @dataclass(frozen=True) -class ActiveItem(KeyedItem, StatusUpdate): +class ActiveItem(TurnItem, StatusUpdate): """An item the CLI started producing: what it is doing now.""" phase: TurnPhase @@ -289,7 +274,7 @@ def accumulate(self, output: ThreadOutput) -> tuple[RuntimeUpdate, ...]: @dataclass(frozen=True) -class FinishedItem(KeyedItem, RuntimeUpdate): +class FinishedItem(TurnItem, RuntimeUpdate): """One CLI item's complete presentation, identified by its producer.""" events: tuple[TranscriptEvent, ...] @@ -297,7 +282,7 @@ class FinishedItem(KeyedItem, RuntimeUpdate): def publish(self, output: ThreadOutput) -> None: output.transcript.finish(turn_id=self.turn_id, item_id=self.item_id, events=self.events) - def retain_before(self, later: set[ItemKey]) -> bool: + def retain_before(self, later: set[TurnItem]) -> bool: later.add(self.key) return True @@ -309,7 +294,7 @@ def add_to(self, history: ImportBuffer) -> None: @dataclass(frozen=True) -class PageItem(ItemKey): +class PageItem(TurnItem): """An item-list identity survives even when its update is invisible in the live view.""" update: RuntimeUpdate @@ -412,7 +397,7 @@ def add_to(self, history: ImportBuffer) -> None: @dataclass(frozen=True) -class DraftItem(KeyedItem, RuntimeUpdate): +class DraftItem(TurnItem, RuntimeUpdate): """A pending item's events, materialized only if this preview survives coalescing.""" accumulator: PresentableDraft @@ -424,7 +409,7 @@ def events(self) -> tuple[TranscriptEvent, ...]: def publish(self, output: ThreadOutput) -> None: output.transcript.draft(turn_id=self.turn_id, item_id=self.item_id, events=self.events) - def retain_before(self, later: set[ItemKey]) -> bool: + def retain_before(self, later: set[TurnItem]) -> bool: keep = self.key not in later later.add(self.key) return keep @@ -455,7 +440,7 @@ def accumulate(self, output: ThreadOutput) -> tuple[RuntimeUpdate, ...]: @dataclass(frozen=True) -class AppendDraftItem(KeyedItem, DraftUpdate): +class AppendDraftItem(TurnItem, DraftUpdate): text: str section: int = 0 diff --git a/src/agent_comms/selected_actions.py b/src/agent_comms/selected_actions.py index 0e9bf49c2a..42c651628d 100644 --- a/src/agent_comms/selected_actions.py +++ b/src/agent_comms/selected_actions.py @@ -148,14 +148,14 @@ def apply(self, owner: CodingToolOwner) -> None: claimed = publish_selected_resource_claim( owner.comms, owner.store, - owner.claim_scope, + owner.resource_scope, owner.owner_name, self.resource, ) write_selected_claimed_file( owner.comms, owner.store, - owner.claim_scope, + owner.resource_scope, owner.owner_name, claimed, self.contents, diff --git a/src/agent_comms/selected_claim.py b/src/agent_comms/selected_claim.py index 74aac46a09..b1f821f131 100644 --- a/src/agent_comms/selected_claim.py +++ b/src/agent_comms/selected_claim.py @@ -268,7 +268,7 @@ def write_selected_claimed_file( comms.bus.bus.writing() as db, ): normalized = ExistingFileClaim(Path(claimed.resource)).normalized(Path(owner.worktree)) - if claimed.claim_scope != claim_scope or BusSnapshot.from_connection(db).resource_owner(normalized) != claimed: + if claimed.resource_scope != claim_scope or BusSnapshot.from_connection(db).resource_owner(normalized) != claimed: raise IdentityConflict("Selected write has no current exact resource claim") with _opened_selected_file(Path(owner.worktree), normalized) as (fd, stable): os.ftruncate(fd, 0) diff --git a/src/agent_comms/selected_source_batch.py b/src/agent_comms/selected_source_batch.py index c50065785c..d4aaaccce9 100644 --- a/src/agent_comms/selected_source_batch.py +++ b/src/agent_comms/selected_source_batch.py @@ -1,13 +1,13 @@ """Captured selected sources share one input; they do not create another inbox.""" import hashlib -import json from dataclasses import dataclass from typing import TYPE_CHECKING from .bus_publication import Delivered from .coordination_errors import IdentityConflict from .coordination_tables.assignments import WakeAssignment +from .field_codec import FieldCodec from .messages import InfoKind, Message from .native_failures import NativePiUnavailable from .turn_context import ReplyRouteSegment, WireProvenance @@ -85,7 +85,7 @@ def assignment_ids(self) -> tuple[str, ...]: @property def execution_id(self) -> str: return "wirev1" + hashlib.sha256( - json.dumps(self.assignment_ids, separators=(",", ":")).encode() + FieldCodec.compact(self.assignment_ids).encode() ).hexdigest() @property diff --git a/src/agent_comms/selected_tool_broker.py b/src/agent_comms/selected_tool_broker.py index d45c95b063..dbf814fecd 100644 --- a/src/agent_comms/selected_tool_broker.py +++ b/src/agent_comms/selected_tool_broker.py @@ -22,7 +22,6 @@ from agent_comms.coordinator import Coordination from .comms import Comms -from .child_process import Platform from agent_comms.native_runtime_input import NativeRuntimeTable from .coordination_errors import CoordinationReadUnavailable from .coordination_tables.participants import OwnerGenerations @@ -72,7 +71,7 @@ def mode(self, owner): return selected_tool_mode_for_owner( owner.comms, owner.store, - owner.claim_scope, + owner.resource_scope, owner.owner_name, owner.session_dir, owner.input_id, diff --git a/src/agent_comms/selected_turn.py b/src/agent_comms/selected_turn.py index 5e4bb45e47..f64e32b87c 100644 --- a/src/agent_comms/selected_turn.py +++ b/src/agent_comms/selected_turn.py @@ -165,7 +165,7 @@ def tool_owner(self, session, input_id, action) -> SelectedCodingToolOwner: return SelectedCodingToolOwner( comms=participant.comms, store=participant.store, - claim_scope=claim_scope, + resource_scope=claim_scope, owner_name=participant.owner.thread.name, session_dir=session.directory, input_id=input_id, diff --git a/src/agent_comms/selected_write_plan.py b/src/agent_comms/selected_write_plan.py index 2a6de8c60f..780538b84e 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -10,14 +10,12 @@ import base64 import hashlib -import json import os from contextlib import suppress from dataclasses import dataclass, fields from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Literal +from typing import TYPE_CHECKING, Literal -from agent_comms.child_process import Platform from .coordination_database import CoordinationStore from .locked_store import DurableFile, PatternFile from .store_files import AtomicFile @@ -29,12 +27,12 @@ from agent_comms.coordination_tables.assignments import WakeAssignment from agent_comms.coordinator import Coordination -from .comms import Comms from .child_process import Platform +from .comms import Comms from .coordination_cohort import sealed_cohort_assignments from .declared_family import DeclaredFamily from .envelope_claim_transitions import ExistingFileClaim -from .field_codec import FieldCodec, Id128Text, MalformedRecord, WireName +from .field_codec import FieldCodec, Id128Text, MalformedRecord from .message_reference import MessageReference from .private_path import PrivateDirectoryRole, PrivateFileRole from .thread_identity import ClaimedIdentity, RecordedThreadIncarnation @@ -55,7 +53,7 @@ class SelectedWriteBinding: root_id: str source: MessageReference assignment_id: str - claim_scope: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: ClaimedIdentity @dataclass(frozen=True, kw_only=True) @@ -70,7 +68,7 @@ class SelectedWriteIntent(DeclaredFamily, affix="WriteIntent"): claim_id: str owner: str incarnation: float - claim_epoch: Annotated[int, WireName("admission_epoch")] + claim_epoch: int expected_attempt_ordinal: Literal[1] operation_id: str resource: str @@ -220,7 +218,7 @@ def submit( expected_attempt_ordinal=1, operation_id=operation_id, resource=str(path), contents_b64=base64.b64encode(raw).decode("ascii"), digest=hashlib.sha256(raw).hexdigest(), ) - data = json.dumps(FieldCodec.encode(record), sort_keys=True, separators=(",", ":")).encode() + data = FieldCodec.compact(FieldCodec.encode(record), sort_keys=True).encode() try: fd = os.open( record_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600 @@ -270,6 +268,4 @@ def applied(self, assignment: WakeAssignment, owner: Thread, operation_id: str) if accepted.operation_id != operation_id: raise IdentityConflict("Selected write intent is no longer unique") completed = accepted.applied() - AtomicFile(path).write_bytes(json.dumps( - FieldCodec.encode(completed), sort_keys=True, separators=(",", ":") - ).encode()) + AtomicFile(path).write_bytes(FieldCodec.compact(FieldCodec.encode(completed), sort_keys=True).encode()) diff --git a/src/agent_comms/task_sources.py b/src/agent_comms/task_sources.py index 6681d940d3..1383c9737e 100644 --- a/src/agent_comms/task_sources.py +++ b/src/agent_comms/task_sources.py @@ -599,7 +599,7 @@ def require_wording_publication(self, registry, original_source): original_author = RecordedThreadIncarnation(origin.author.sender, origin.author.created_at) if original_author.resolved(registry) != self.source_user.resolved(registry): raise RelationViolationError("USER input pin differs from its original human author") - if origin.claim_scope.incarnation.resolved(registry) != self.recipient.resolved(registry): + if origin.claimed_identity.incarnation.resolved(registry) != self.recipient.resolved(registry): raise RelationViolationError("USER input pin recipient did not receive its original input") if origin.project != self.scope.project: raise RelationViolationError("USER input pin belongs to another project") diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 69e94c875a..0c4f65ea32 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -12,7 +12,7 @@ from collections.abc import Iterable from dataclasses import dataclass, field, replace from functools import cached_property -from typing import TYPE_CHECKING, Callable, ClassVar, Self, TypeVar, Annotated +from typing import TYPE_CHECKING, Callable, ClassVar, Self, TypeVar from metaclass_registry import AutoRegisterMeta, RegistryConfig @@ -23,7 +23,6 @@ from .field_codec import ( FieldCodec, WireValue, - WireName, ) from .response_policy import CollectivePolicy, InformationalPolicy, ResponsePolicy @@ -350,7 +349,7 @@ class ClaimedIdentity: """A registry-allocated input generation, never an owner lease.""" incarnation: ThreadIncarnation - claimed_generation: Annotated[int, WireName("admission_generation")] + claimed_generation: int def includes(self, previous: ClaimedIdentity) -> bool: """Compare order only within this registry's input-generation counter.""" @@ -381,7 +380,7 @@ class TurnFence: identity: TurnIdentity turn_id: str - claimed_generation: Annotated[int, WireName("admission_generation")] + claimed_generation: int def renamed(self, name: str) -> Self: """Registry alias resolution changes the name, never incarnation or epochs.""" diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 3466334e40..ede337ca43 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import os import time from abc import ABC, abstractmethod @@ -333,7 +332,7 @@ def native_launch_arguments( catalog = ThreadToolCatalogResult( tuple(NativeRequestTools.catalog()), self.service_tier, launch.arguments(), ) - AtomicFile(path).write_text(json.dumps(catalog.to_wire(), separators=(",", ":"))) + AtomicFile(path).write_text(FieldCodec.compact(catalog.to_wire())) return (*arguments.argv, "--comms-catalog", str(path)) def launch(self) -> ThreadLaunch: diff --git a/src/agent_comms/transcript_routes.py b/src/agent_comms/transcript_routes.py index 24fcc56e6f..b8dc3a4bf2 100644 --- a/src/agent_comms/transcript_routes.py +++ b/src/agent_comms/transcript_routes.py @@ -14,15 +14,14 @@ from .field_codec import MalformedRecord from .private_path import FileObservation, FileRevision from .routing import TurnRouting -from .typed_table import Column, DurableStore, TypedTable +from .typed_table import Column, DurableStore, TypedTable, WithoutRowid @dataclass(frozen=True) -class TranscriptRoute(TypedTable): +class TranscriptRoute(WithoutRowid): session_file: Annotated[str, Column(primary_key=True)] entry_id: Annotated[str, Column(primary_key=True)] routing: TurnRouting - without_rowid = True def require_publication(self, routing: TurnRouting) -> None: if self.routing.publications and self.routing != routing: @@ -30,14 +29,13 @@ def require_publication(self, routing: TurnRouting) -> None: @dataclass(frozen=True) -class InputDisplay(TypedTable): +class InputDisplay(WithoutRowid): """Owner-authored presentation for one native input; None text means internal.""" native_id: Annotated[str, Column(primary_key=True)] text: str | None routing: TurnRouting | None = None sent_text_digest: str | None = None - without_rowid = True checks = ("routing IS NULL OR sent_text_digest IS NOT NULL",) def matches(self, text: str) -> bool: diff --git a/src/agent_comms/triage_native_sources.py b/src/agent_comms/triage_native_sources.py index 9b4ab4aa87..68d8848331 100644 --- a/src/agent_comms/triage_native_sources.py +++ b/src/agent_comms/triage_native_sources.py @@ -14,7 +14,7 @@ Column, GuardedInserts, ImmutableRows, - TypedTable, + WithoutRowid, ) @@ -23,7 +23,7 @@ class TriageNativeSources( GuardedInserts, ImmutableRows, NativeRuntimeTable, - TypedTable, + WithoutRowid, declared_name="native_runtime_triage_sources", ): insert_condition = """(NOT EXISTS (SELECT 1 FROM native_runtime_input n @@ -39,7 +39,6 @@ class TriageNativeSources( ] assignment_ids: tuple[str, ...] - without_rowid = True checks = ("json_array_length(assignment_ids)>0",) def __post_init__(self): diff --git a/src/agent_comms/turn_context.py b/src/agent_comms/turn_context.py index d8afc93af9..aa73437dee 100644 --- a/src/agent_comms/turn_context.py +++ b/src/agent_comms/turn_context.py @@ -814,7 +814,7 @@ def provider_value(self): ... def render_into(self, prompt_parts, provider): ... def text(self): - return json.dumps(self.provider_value(), ensure_ascii=False, separators=(",", ":")) + return FieldCodec.compact(self.provider_value(), ensure_ascii=False) def measured_manifest(self): # The SDK measures/hashes its exact original JSON representation. @@ -993,7 +993,7 @@ def public_spans(self) -> tuple[ContextSpan, ...]: offset = 1 spans = [] for tool in self.tools: - text = json.dumps(tool, ensure_ascii=False, separators=(",", ":")) + text = FieldCodec.compact(tool, ensure_ascii=False) coordinates = ContributionCoordinates.capture(type(self), self.provenance, offset, text) spans.append(ContextSpan(self.sha256, coordinates)) offset += coordinates.length + 1 @@ -1104,7 +1104,7 @@ def values(self) -> dict[str, object]: name=self.owner.name, parent=self.parent, project=self.project, - peers=json.dumps(FieldCodec.encode(self.peers), separators=(",", ":")), + peers=FieldCodec.compact(FieldCodec.encode(self.peers)), ) diff --git a/src/agent_comms/turn_inputs.py b/src/agent_comms/turn_inputs.py index 3018966a8d..8b2aeb465c 100644 --- a/src/agent_comms/turn_inputs.py +++ b/src/agent_comms/turn_inputs.py @@ -1,7 +1,7 @@ """Input forwarding owns pending native IDs and never replays written inputs. -Each inbox member delivers itself to this receiver. This owner converts it to -its Pi command when it writes it to the running child. +Declared handlers on this owner select each inbox operation by its nominal +MRO, and convert it to its Pi command at the owning send boundary. """ from __future__ import annotations @@ -26,14 +26,17 @@ from .field_codec import Id128Text from .locked_store import RegistryFile from .maintenance_barrier import MaintenanceBarrier +from .mro_dispatch import MroDispatch, handles +from .native_failures import NativePiUnavailable +from .prompt_state import PreparedSession, UnwrittenPrompt if TYPE_CHECKING: - from .backend import TurnSession + from .backend import NativeSession, TurnSession from .pi_events import MessageStart, Response @dataclass -class InputForwarding: +class InputForwarding(MroDispatch): queue: TurnInbox = field(default_factory=TurnInbox) pending: dict[str, commands.Prompt] = field(default_factory=dict) accepted: set[str] = field(default_factory=set) @@ -77,19 +80,55 @@ def retire(self, native_id: str) -> commands.Prompt: self.changed.set() return item - async def forward(self, session: TurnSession) -> None: + async def forward(self, session: NativeSession) -> None: # Inputs that wait through one tool batch reach the model together, # not one model call apiece. await self.write(session, commands.SetSteeringMode(mode="all")) while True: self.current = await self.queue.get() try: - if not await self.current.deliver_to(self, session): + # An inbox item performs one operation. PreparedSession must not + # also run its UnwrittenPrompt ancestor and send a prompt. + handler = next(self.handlers_for(self.current)) + if not await handler(self.current, session): return finally: self.current = None - async def write(self, session: TurnSession, *written: commands.PiCommand) -> None: + @handles(TurnInboxItem) + async def unhandled(self, item: TurnInboxItem, session: NativeSession) -> bool: + raise TypeError(f"No input handler declared for {type(item).__name__}") + + @handles(UnwrittenPrompt) + async def original_prompt(self, item: UnwrittenPrompt, session: TurnSession) -> bool: + def write(): + session.grant_prompt() + session.native.send(session.command) + barrier = MaintenanceBarrier(RegistryFile.path_in(session.startup.root)) + async with barrier.ingress_async() as status: + with status.phase.send_new( + session.send_boundary, None, session.command.input_id, session.command.message, + ) as outcome: + completion = outcome.dispatch(self, session, item, write) + return await completion + + @handles(PreparedSession) + async def prepare_session(self, item: PreparedSession, session: NativeSession) -> bool: + session.native.forget_context() + state = session.native.attestation.require_state() + identity = session.native.attestation.require_identity() + session.launch.session.attest(identity) + if ( + state.is_streaming.value_or(True) + or state.is_compacting.value_or(True) + or not state.pending_message_count.matches(0) + ): + raise NativePiUnavailable("Native preparation did not attest the idle saved session") + session.active_session_file = identity.session_file + await session.stats.request(session) + return True + + async def write(self, session: NativeSession, *written: commands.PiCommand) -> None: for command in written: session.native.send(command) await session.native.drain() @@ -108,7 +147,8 @@ async def queued(self, input_ids: tuple[InputId, ...]) -> tuple[str, ...]: return candidates await self.changed.wait() - async def clear_queue(self, session: TurnSession, item: ClearQueue) -> bool: + @handles(ClearQueue) + async def clear_queue(self, item: ClearQueue, session: TurnSession) -> bool: command = commands.ClearQueue(input_ids=await self.queued(item.input_ids)) if not command.input_ids: return True @@ -126,7 +166,8 @@ async def clear_queue(self, session: TurnSession, item: ClearQueue) -> bool: session.rejected_signal.set() return True - async def use_settings(self, session: TurnSession, item: UseSettings) -> bool: + @handles(UseSettings) + async def use_settings(self, item: UseSettings, session: TurnSession) -> bool: # Pi resets thinking to the new model's default, so the level follows the model. await self.write( session, @@ -137,7 +178,8 @@ async def use_settings(self, session: TurnSession, item: UseSettings) -> bool: ) return True - async def send_now(self, session: TurnSession, item: SendNow) -> bool: + @handles(SendNow) + async def send_now(self, item: SendNow, session: TurnSession) -> bool: candidates = await self.queued(item.input_ids) if not candidates: return True @@ -151,7 +193,8 @@ async def send_now(self, session: TurnSession, item: SendNow) -> bool: )) return await completion - async def place(self, session: TurnSession, item: InputRequest) -> bool: + @handles(InputRequest) + async def place(self, item: InputRequest, session: TurnSession) -> bool: public_id = item.input_id.value native_id = Id128Text.mint() text = item.content.text diff --git a/src/agent_comms/turn_item.py b/src/agent_comms/turn_item.py new file mode 100644 index 0000000000..d5de80b5a8 --- /dev/null +++ b/src/agent_comms/turn_item.py @@ -0,0 +1,15 @@ +"""The producer's item identity within its native turn.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TurnItem: + turn_id: str + item_id: str + + @property + def key(self) -> TurnItem: + return TurnItem(self.turn_id, self.item_id) diff --git a/src/agent_comms/turn_lease.py b/src/agent_comms/turn_lease.py index 234f2f6b40..4f54e6b0a1 100644 --- a/src/agent_comms/turn_lease.py +++ b/src/agent_comms/turn_lease.py @@ -18,7 +18,6 @@ RecordMember, SingleFieldValue, SubclassUnion, - WireName, WireTransient, boundary_decoder, ) @@ -213,9 +212,7 @@ class ActiveTurn(Executing, RecordMember, TurnDeclaration): owner: ProcessIdentity started_at: float = field(default_factory=time.time) routing: TurnRouting | None = None - claimed_generation: Annotated[ - TurnGeneration, WireName("admission_generation") - ] = UnrecordedGeneration() + claimed_generation: TurnGeneration = UnrecordedGeneration() turn_generation: TurnGeneration = UnrecordedGeneration() phase: TurnPhase = field(default_factory=PreparingPhase) def execution(self, source: GoalExecutionSource) -> GoalExecution: diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 04c4777692..fbb8e8ca48 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -272,7 +272,7 @@ def accepts(cls, annotation: object) -> bool: @classmethod def to_sql(cls, value: object) -> str | None: - return None if value is None else json.dumps(value, separators=(",", ":"), allow_nan=False) + return None if value is None else FieldCodec.compact(value, allow_nan=False) @classmethod def from_sql(cls, value: object) -> object: @@ -2196,3 +2196,16 @@ def __init_subclass__(cls, **kwargs: object) -> None: cls.filename = ( f"{declared_words(cls.__name__)}.{hashlib.sha256(schema).hexdigest()[:12]}.sqlite3" ) + + +class WithoutRowid(TypedTable, declared_name=None): + """The table is stored directly by its declared primary key.""" + + without_rowid = True + + +@dataclass(frozen=True) +class SingletonRow(TypedTable, declared_name=None): + """The table has one fixed primary key, preceding its owned value fields.""" + + singleton: Annotated[Literal[1], Column(primary_key=True)] diff --git a/src/agent_comms/ui_model/observation.py b/src/agent_comms/ui_model/observation.py index 790db9f295..2d74e66a64 100644 --- a/src/agent_comms/ui_model/observation.py +++ b/src/agent_comms/ui_model/observation.py @@ -798,11 +798,19 @@ def settled(result: Observed, state: NotificationsRead) -> Observed: UnreadableNotificationsRead(revision))) +@dataclass(frozen=True) +class LocatedThreads: + activity: Mapping[str, ObservedActivity | DeletedThread] + names: frozenset[str] + reads: Mapping[str, Callable[[], ThreadPresentation]] + + class ThreadReading(Observation[ObserveThreads]): def __init__(self, request: ObserveThreads): super().__init__(request) self.observed = ThreadsObserved(WireRevision.empty(), {}, frozenset(), {}) self.initial = request.names + self.located_threads = LocatedThreads({}, request.names, {}) def continue_from(self, previous: Self) -> Self: self.observed = previous.observed @@ -812,31 +820,15 @@ def continue_from(self, previous: Self) -> Self: def read(self, comms: Comms, revision: WireRevision) -> Generator[ObservationStep]: previous = self.observed results: dict[str, ThreadReadResult] = {} - activity: dict[str, ObservedActivity | DeletedThread] = {} - names = self.request.names - - def located() -> dict[str, Callable[[], ThreadPresentation]]: - nonlocal activity, names - readable, _ = previous.revision_inputs() - watched = names | frozenset(actor for view in readable.values() - for actor in view.notification_activity_names()) - activity = comms.registry.snapshot().observed_activity(comms.agents, watched) - names = (revision.changed_threads(previous, comms, activity) | names - previous.results.keys()) & names - if not names: - return {} - batch = comms.views.thread_presentations(names) - return {name: partial(batch.read, name) for name in names} - - def failed(result: ThreadReadResult) -> Iterator[ObservationStep]: - results.update(dict.fromkeys(names, result)) - return iter(()) - - yield from UnreadableHistory.outcome(located).answer( - lambda reads: self.presented(reads, results), - lambda error: failed(ThreadBusy()), lambda error: failed(ThreadUnavailable(error))) + outcome = UnreadableHistory.outcome(partial(self.located, comms, revision, previous)) + located = self.located_threads + yield from outcome.answer( + lambda located: self.presented(located.reads, results), + lambda error: self.failed(located.names, ThreadBusy(), results), + lambda error: self.failed(located.names, ThreadUnavailable(error), results)) results.update((name, value) for name, value in previous.results.items() - if name in self.request.names and name not in names) - observed = ThreadsObserved(revision, results, frozenset(), activity) + if name in self.request.names and name not in located.names) + observed = ThreadsObserved(revision, results, frozenset(), located.activity) self.observed = observed changed = observed.changed_since(previous) | self.initial self.initial = frozenset() @@ -844,6 +836,30 @@ def failed(result: ThreadReadResult) -> Iterator[ObservationStep]: yield replace(observed, changed=changed, results={name: value for name, value in results.items() if name in changed}) + def located(self, comms: Comms, revision: WireRevision, previous: ThreadsObserved) -> LocatedThreads: + # Keep each completed stage when a later stored read fails, exactly as + # the original read did before its activity/names became declared state. + self.located_threads = LocatedThreads({}, self.request.names, {}) + readable, _ = previous.revision_inputs() + names = self.located_threads.names + watched = names | frozenset(actor for view in readable.values() + for actor in view.notification_activity_names()) + activity = comms.registry.snapshot().observed_activity(comms.agents, watched) + self.located_threads = replace(self.located_threads, activity=activity) + names = (revision.changed_threads(previous, comms, activity) | names - previous.results.keys()) & names + self.located_threads = replace(self.located_threads, names=names) + if names: + batch = comms.views.thread_presentations(names) + self.located_threads = replace( + self.located_threads, reads={name: partial(batch.read, name) for name in names}) + return self.located_threads + + @staticmethod + def failed(names: frozenset[str], result: ThreadReadResult, + results: dict[str, ThreadReadResult]) -> Iterator[ObservationStep]: + results.update(dict.fromkeys(names, result)) + return iter(()) + @staticmethod def presented(reads: Mapping[str, Callable[[], ThreadPresentation]], results: dict[str, ThreadReadResult]) -> Iterator[ObservationStep]: diff --git a/src/agent_comms/view_unread.py b/src/agent_comms/view_unread.py index 01838fe908..b341cd4dc0 100644 --- a/src/agent_comms/view_unread.py +++ b/src/agent_comms/view_unread.py @@ -23,7 +23,15 @@ from .read_ledger import ReadDocument, ReadLedger from .registry_document import RosterVisibility from .thread_owned_state import ThreadOwnedState -from .typed_table import Column, JsonStorage, SQLiteTableName, SQLiteUserVersion, TypedRow, TypedTable +from .typed_table import ( + Column, + JsonStorage, + SQLiteTableName, + SQLiteUserVersion, + TypedRow, + TypedTable, + WithoutRowid, +) from .ui_model.unread import CappedReplyUnread, IndexingUnread, ReplyUnread, UnreadPresentation from .watched import FileSource @@ -115,10 +123,9 @@ def prepare(self, cancelled: Event) -> ReplyIndexResult: @dataclass(frozen=True) -class TranscriptReply(ReplyIndexTable, TypedTable): +class TranscriptReply(ReplyIndexTable, WithoutRowid): source: Annotated[str, Column(primary_key=True)] end: Annotated[int, Column(primary_key=True)] - without_rowid = True @classmethod def after(cls, database: sqlite3.Connection, ends: Mapping[str, int]) -> dict[str, int]: diff --git a/src/agent_comms/working_memory_labels.py b/src/agent_comms/working_memory_labels.py index acce235be9..57a0b9a01d 100644 --- a/src/agent_comms/working_memory_labels.py +++ b/src/agent_comms/working_memory_labels.py @@ -44,9 +44,11 @@ class QuestionVersion: def current(cls, question: type[SpanQuestion], owner_rules: tuple[str, ...] = ()): # A relation to authored rules is a different question when that original # rule set changes. Other questions discard that unrelated input. - raw = json.dumps({"definition": question.declaration(), - "owner_rules": question.disclosed_owner_rules(owner_rules)}, - sort_keys=True, separators=(",", ":")).encode() + raw = FieldCodec.compact( + {"definition": question.declaration(), + "owner_rules": question.disclosed_owner_rules(owner_rules)}, + sort_keys=True, + ).encode() return cls(question, hashlib.sha256(raw).hexdigest()) diff --git a/tests/test_acp_queue_contract.py b/tests/test_acp_queue_contract.py index 4bebd429f6..fae8258976 100644 --- a/tests/test_acp_queue_contract.py +++ b/tests/test_acp_queue_contract.py @@ -62,7 +62,7 @@ async def test_real_acp_surrogate_queue_ingress_stays_unknown_and_attachable(tmp for f in decode_updates((await agent.sessions.metadata("beta", session_id="beta"))) if isinstance(f, QueueChangedUpdate) ) - assert state.scope.claim_scope.incarnation.name == "beta" + assert state.scope.claimed_identity.incarnation.name == "beta" assert isinstance(state.projection, UnavailableQueueProjection) assert exact in agent.inputs.state("beta").queued @@ -148,7 +148,7 @@ async def test_alias_maps_only_attachment_session_id(tmp_path): state = await agent.inputs.queue_state("beta") (rebased,) = decode_updates(present_session(encode_updates(state), "alias")) assert rebased.scope.session_id == "alias" - assert rebased.scope.claim_scope == state.scope.claim_scope + assert rebased.scope.claimed_identity == state.scope.claimed_identity assert rebased.projection is not None and rebased.projection == state.projection assert state.scope.session_id == "beta" @@ -160,12 +160,12 @@ def test_attachment_routing_rename_preserves_exact_executor_fences(): scopes = (LiveQueueScope("official-acp-id", original, ProcessIdentity(123, 1)), LiveCursorScope("official-acp-id", "a" * 32, original, ProcessIdentity(123, 1))) for scope in scopes: - assert scope.relation(replace(scope, claim_scope=renamed)).current + assert scope.relation(replace(scope, claimed_identity=renamed)).current assert scope.relation(replace(scope, session_id="new-route")).foreign assert scope.relation(replace(scope, owner=ProcessIdentity(124, 1))).ambiguous - assert scope.relation(replace(scope, claim_scope=replace(original, incarnation=replace(original.incarnation, created_at=1001.0)))).ambiguous - assert scope.relation(replace(scope, claim_scope=replace(original, claimed_generation=8))).newer - assert scope.relation(replace(scope, claim_scope=replace(original, claimed_generation=6))).older + assert scope.relation(replace(scope, claimed_identity=replace(original, incarnation=replace(original.incarnation, created_at=1001.0)))).ambiguous + assert scope.relation(replace(scope, claimed_identity=replace(original, claimed_generation=8))).newer + assert scope.relation(replace(scope, claimed_identity=replace(original, claimed_generation=6))).older assert scopes[1].relation(replace(scopes[1], wire_root_id="b" * 32)).foreign assert original == renamed # Birth owns identity; a name is only its route. assert original.incarnation.name != renamed.incarnation.name diff --git a/tests/test_bus_resource_claims.py b/tests/test_bus_resource_claims.py index 17589592ee..955b0e8485 100644 --- a/tests/test_bus_resource_claims.py +++ b/tests/test_bus_resource_claims.py @@ -63,7 +63,7 @@ def build(seq: int, db: sqlite3.Connection) -> ClaimRecord: def acquisition(self, name: str, resources: tuple[str, ...], scope: NativeClaim) -> RecordBuilder: return self.builder(name, partial( - WakeClaims, claims=resources, generation=scope.operation_id, claim_scope=scope, + WakeClaims, claims=resources, generation=scope.operation_id, resource_scope=scope, )) def acquire(self, name: str, resource: str, scope: NativeClaim) -> ClaimOwner: diff --git a/tests/test_carry_bus_history.py b/tests/test_carry_bus_history.py index 65c7cd2b3a..9b6ec411d6 100644 --- a/tests/test_carry_bus_history.py +++ b/tests/test_carry_bus_history.py @@ -205,15 +205,15 @@ def test_predecessor_publisher_history_round_trips_into_bus(tmp_path): result_path = tmp_path / "carried.json" with hold.holding(): completed = subprocess.run( - [sys.executable, "-m", "agent_comms.installer.step_worker", request.name(), - json.dumps(FieldCodec.encode(request)), json.dumps(FieldCodec.encode(operation)), - str(result_path)], cwd=REPOSITORY, + [sys.executable, "-m", "agent_comms.installer.step_worker", + json.dumps(FieldCodec.encode(operation)), str(result_path), + json.dumps([[request.name(), FieldCodec.encode(request)]])], cwd=REPOSITORY, env={**os.environ, "PYTHONPATH": os.pathsep.join( map(str, (REPOSITORY / "src", REPOSITORY / "tools")))}, capture_output=True, text=True, timeout=60, ) assert completed.returncode == 0, completed.stdout + completed.stderr - result = operation.decode_answer(result_path.read_text()) + (result,) = operation.decode_answers(result_path.read_text()) steps = (StepRun(requests.name(), request_result), StepRun(request.name(), result)) carried(root, saved, steps, RehearsalFiles.declared(steps)) maintenance.clear(hold) diff --git a/tests/test_claim_admission_verifier.py b/tests/test_claim_admission_verifier.py index e4e7d0e6ec..91bc358f00 100644 --- a/tests/test_claim_admission_verifier.py +++ b/tests/test_claim_admission_verifier.py @@ -128,7 +128,7 @@ def test_selected_wake_verifier_refuses_no_wake_and_stale_authority( comms, store, claim_scope, owner.name, ExistingFileClaim(Path(resource)) ) selected_owner = Comms(root).bus.bus.claim_projection()[str(resource)] - assert selected_owner.claim_scope == claim_scope + assert selected_owner.resource_scope == claim_scope assert selected_owner.resource == str(resource) assert ( publish_selected_resource_claim( diff --git a/tests/test_current_input_origin.py b/tests/test_current_input_origin.py index e4aa7f57c6..7cd757819e 100644 --- a/tests/test_current_input_origin.py +++ b/tests/test_current_input_origin.py @@ -93,7 +93,7 @@ async def test_original_human_followup_survives_forwarding_reservation_and_nativ assert row.context_provenance() in contribution.provenance native_id = "b" * 32 sent = "User follow-up:\n" + command.user_text - assert inputs.bind(key, claim_scope=row.claim_scope, turn_id="held-native", native_id=native_id, text=sent) + assert inputs.bind(key, claim_scope=row.claim_generation, turn_id="held-native", native_id=native_id, text=sent) assert inputs.started(key, turn_id="held-native", native_id=native_id, text=sent) # Existing terminal settlement never rewrites a started original as # unsent or grants a replay; the native completion owns that. @@ -130,7 +130,7 @@ async def test_foreign_or_changed_origin_refused_before_any_reservation(live_own async def test_historical_unknown_author_stays_neutral_and_human_scope_invalidates(live_owner): comms, agent = live_owner origin = capture(comms) - neutral = ReservedInput("old", None, "beta", origin.claim_scope.claimed_generation, + neutral = ReservedInput("old", None, "beta", origin.claimed_identity.claimed_generation, "beta", "Exact old user-looking text") payload = FieldCodec.encode(neutral) assert "origin" not in payload diff --git a/tests/test_envelope_bus_integration.py b/tests/test_envelope_bus_integration.py index c65959a003..f4958417d4 100644 --- a/tests/test_envelope_bus_integration.py +++ b/tests/test_envelope_bus_integration.py @@ -81,10 +81,10 @@ def test_scoped_acquisition_is_one_original_and_conflict_appends_nothing(tmp_pat transition = sent.claim_transition assert transition.claims == (str(worktree / "a.py"),) assert (transition.message_id, transition.seq) == (sent.message_id, sent.seq) - assert isinstance(transition.claim_scope, NativeClaim) - assert transition.claim_scope.input_id == "input-alice" + assert isinstance(transition.resource_scope, NativeClaim) + assert transition.resource_scope.input_id == "input-alice" assert comms.bus.bus.revision() == sent.seq - assert comms.bus.bus.claim(sent.seq).claim_scope.input_id == "input-alice" + assert comms.bus.bus.claim(sent.seq).resource_scope.input_id == "input-alice" assert read_history(comms) == [sent] assert comms.bus.bus.claim_projection()[str(worktree / "a.py")].owner == "alice" with pytest.raises(ClaimConflict) as loss: diff --git a/tests/test_envelope_claim_transitions.py b/tests/test_envelope_claim_transitions.py index 2380007d9e..2a864b76f3 100644 --- a/tests/test_envelope_claim_transitions.py +++ b/tests/test_envelope_claim_transitions.py @@ -33,7 +33,7 @@ def test_same_name_can_take_a_previous_birth_only_with_current_registry_evidence scope = NativeClaim(owner_claimed_generation=1, operation_id="c" * 32, turn_id="turn-owner", input_id="input-owner") held = WakeClaims("owner", "1.0", 1, "msg-1", claims=(resource,), - generation=G1, claim_scope=scope).project(ClaimProjection()) + generation=G1, resource_scope=scope).project(ClaimProjection()) original = held[resource] assert not original.live(registry) original.require_holder("owner", "1.0") @@ -41,7 +41,7 @@ def test_same_name_can_take_a_previous_birth_only_with_current_registry_evidence with pytest.raises(ClaimConflict): original.require_holder(owner, incarnation) successor = WakeClaims("owner", "2.0", 2, "msg-2", claims=(resource,), - generation=G2, claim_scope=scope) + generation=G2, resource_scope=scope) saved = FieldCodec.decode(ClaimTransition, FieldCodec.encode(successor)) moved = saved.apply(held, registry) assert moved[resource].incarnation == "2.0" and moved[resource].generation == G2 @@ -65,20 +65,20 @@ def test_selected_wake_binding_survives_claim_projection(tmp_path): attempt_ordinal=1, ) claimed = WakeClaims("owner", "epoch-1", 8, "msg-8", claims=(resource,), - generation=G1, claim_scope=claim_scope) + generation=G1, resource_scope=claim_scope) raw = FieldCodec.encode(claimed) decoded = FieldCodec.decode(ClaimTransition, raw) assert decoded == claimed - assert decoded.project(ClaimProjection())[resource].claim_scope == claim_scope + assert decoded.project(ClaimProjection())[resource].resource_scope == claim_scope message = Message("owner", "owner", "Claim file", ClaimNoticeKind, seq=8, timestamp=1.0) bound = ClaimMessage.of(message, replace(claimed, message_id=message.message_id)) assert Message.from_wire(bound.to_wire()) == bound - admission_key = FieldCodec.record_key(WakeClaims, "claim_scope") - invalid = {**raw, admission_key: {**raw[admission_key], "version": 99}} + scope_key = FieldCodec.record_key(WakeClaims, "resource_scope") + invalid = {**raw, scope_key: {**raw[scope_key], "version": 99}} with pytest.raises(ValueError): FieldCodec.decode(ClaimTransition, invalid) null_claim = raw.copy() - null_claim[admission_key] = None + null_claim[scope_key] = None with pytest.raises(ValueError): FieldCodec.decode(ClaimTransition, null_claim) diff --git a/tests/test_goal_input_review.py b/tests/test_goal_input_review.py index 6907c3ae05..7b6c8ad30d 100644 --- a/tests/test_goal_input_review.py +++ b/tests/test_goal_input_review.py @@ -106,7 +106,7 @@ def test_inspected_canonical_dependencies_allow_standby_without_replay(tmp_path, assert row.accepts_reservation assert not hasattr(row, "native_id") and not hasattr(row, "turn_id") assert row.source_text == message.body and not hasattr(row, "sent_text") - assert row.claim_scope == claim_scope and row.reviewed_for_goal(goal.id) + assert row.claim_generation == claim_scope and row.reviewed_for_goal(goal.id) assert not stored_inputs(ledger.root).rows["acp:owner-input"].goal_reviews generation = comms.goals.open_goal_store().snapshot(goal.id) assert generation is not None and generation.lifecycle.ready diff --git a/tests/test_goal_reply_lock_scope.py b/tests/test_goal_reply_lock_scope.py index daf0e22269..674bca7c86 100644 --- a/tests/test_goal_reply_lock_scope.py +++ b/tests/test_goal_reply_lock_scope.py @@ -21,11 +21,12 @@ SetGoalAction, StandbyGoalAction, ) -from agent_comms.goal_waits import GoalWaitReview, RecordedGoalWait +from agent_comms.goal_waits import GoalWaitReview, NoGoalWait, RecordedGoalWait from agent_comms.input_disposition import InputDispositions from agent_comms.messages import ConversationRole, NoticeRole from agent_comms.store_files import store_lock_path from agent_comms.thread_text import GivenThreadText +from agent_comms.thread_status import StoppedThreadStatus from agent_comms.tool_caller import ThreadCaller from agent_comms.tools import CommsGoalTool from goal_owner_fixture import canonical_goal_wire @@ -332,3 +333,102 @@ def test_display_edit_during_terminal_review_does_not_strand_wait(reviewing, ren ) assert reviewing.registry.require("owner").title == title assert reviewing.goals.goal_wait("owner") != captured.wait + + +def edit_waiting_goal(comms): + comms.goals.update_goal("owner", EditGoalAction(text="Keep this newer objective")) + + +def edit_waiting_progress(comms): + owner = comms.registry.require("owner") + goal = owner.goal.require() + comms.registry.register(replace( + owner, goal=replace(goal, progress="Keep this newer progress", revision=goal.revision + 1), + )) + + +def edit_waiting_title(comms): + owner = comms.registry.require("owner") + comms.registry.register(replace(owner, title=GivenThreadText("Keep this newer title"))) + + +def stop_unrelated_thread(comms): + comms.registry.register(comms.registry.require("other"), StoppedThreadStatus()) + + +def release_terminal_wait(comms): + fence = comms.agents.finish_turn(comms.registry.require("child").require_turn_lease()) + return partial(comms.goals.release_waits_after_terminal_turn, fence) + + +def recover_closed_wait(comms): + comms.agents.finish_turn(comms.registry.require("child").require_turn_lease()) + comms.registry.unregister("child") + return partial(comms.goals.recover_closed_goal_wait, "owner") + + +@pytest.mark.parametrize("release", (release_terminal_wait, recover_closed_wait)) +@pytest.mark.parametrize("change", ( + edit_waiting_goal, edit_waiting_progress, edit_waiting_title, stop_unrelated_thread, +)) +def test_retained_wait_releases_in_same_call_after_concurrent_edit(reviewing, release, change): + reviewing.registry.declare(reviewing.registry.new_thread("other", frozenset(), str(reviewing.root))) + reviewing.goals.update_goal("owner", standby(reviewing)) + wait = reviewing.goals.goal_wait("owner") + current_goal = reviewing.registry.require("owner").goal.require() + release_once = release(reviewing) + + def change_after_snapshot(): + nonlocal current_goal + change(reviewing) + assert reviewing.goals.goal_wait("owner") == wait + current_goal = reviewing.registry.require("owner").goal.require() + + reviewing.bus.bus = AfterReadBus(reviewing.bus.bus.path, change_after_snapshot) + assert release_once() == ("owner",) + assert reviewing.bus.bus.changed + assert reviewing.goals.goal_wait("owner") == NoGoalWait() + published = reviewing.registry.require("owner").goal.require() + assert published.revision == current_goal.revision + 1 + assert published.progress.startswith(current_goal.progress) + assert "Standby was released" in published.progress + assert replace(published, progress=current_goal.progress, revision=current_goal.revision) == current_goal + + +@pytest.mark.parametrize("change", (edit_waiting_goal, edit_waiting_progress)) +def test_completed_reply_consumes_retained_wait_after_concurrent_goal_edit(reviewing, change): + witness, reply, wait = completed_reply(reviewing) + reviewing.bus.bus = AfterReadBus(reviewing.bus.bus.path, partial(change, reviewing)) + assert reviewing.goals.consume_reply_wait(witness, reply.reference) + assert reviewing.bus.bus.changed + assert reviewing.goals.goal_wait("owner") == NoGoalWait() + assert reviewing.registry.require("owner").goal.require().revision == wait.revision + 1 + + +def test_new_dependency_turn_during_silence_read_keeps_wait(reviewing): + reviewing.goals.update_goal("owner", standby(reviewing)) + wait = reviewing.goals.goal_wait("owner") + fence = reviewing.agents.finish_turn(reviewing.registry.require("child").require_turn_lease()) + reviewing.bus.bus = AfterReadBus( + reviewing.bus.bus.path, partial(reviewing.agents.begin_turn, "child", "new-child-turn"), + ) + assert reviewing.goals.release_waits_after_terminal_turn(fence) == () + assert reviewing.bus.bus.changed + assert reviewing.goals.goal_wait("owner") == wait + + +def test_terminal_callback_reobserves_same_wait_after_waiter_turn_changes(reviewing): + reviewing.goals.update_goal("owner", standby(reviewing)) + wait = reviewing.goals.goal_wait("owner") + owner = reviewing.registry.require("owner") + fence = reviewing.agents.finish_turn(reviewing.registry.require("child").require_turn_lease()) + + def finish_waiter_turn(): + witness = reviewing.agents.begin_turn("owner", "intervening-owner-turn") + reviewing.agents.finish_turn(witness.thread.require_turn_lease()) + assert reviewing.goals.goal_wait("owner") == wait + + reviewing.bus.bus = AfterReadBus(reviewing.bus.bus.path, finish_waiter_turn) + assert reviewing.goals.release_waits_after_terminal_turn(fence) == ("owner",) + assert reviewing.registry.require("owner").turn_generation > owner.turn_generation + assert reviewing.goals.goal_wait("owner") == NoGoalWait() diff --git a/tests/test_input_documents.py b/tests/test_input_documents.py index 8c03303c70..6d6aebbf99 100644 --- a/tests/test_input_documents.py +++ b/tests/test_input_documents.py @@ -88,7 +88,7 @@ def test_saved_discriminator_and_optional_notices_roundtrip_without_replay(tmp_p from agent_comms.text_digest import TextDigest saved = FieldCodec.encode(BoundUnknownInput( - key="acp:old", sequence=None, owner="worker", claim_scope=1, + key="acp:old", sequence=None, owner="worker", claim_generation=1, target="worker", source_text="Retain me", turn_id="turn", native_id="a" * 32, sent_digest=TextDigest.of("Exact sent text"), notice_dismissed=True, goal_reviews={"goal": GoalInputDecision(goal_revision=7, turn_id="review")}, diff --git a/tests/test_interrupted_delivery_recovery.py b/tests/test_interrupted_delivery_recovery.py index 0f9b488cad..91998b5df5 100644 --- a/tests/test_interrupted_delivery_recovery.py +++ b/tests/test_interrupted_delivery_recovery.py @@ -59,7 +59,7 @@ async def test_interrupted_following_delivery_recovers_only_before_native_send( leased = comms.agents.begin_turn(owner.name, "interrupted-turn") assert agent.inputs.dispositions.bind( item.key, - claim_scope=row.claim_scope, + claim_scope=row.claim_generation, turn_id=leased.thread.require_turn_lease().turn_id, native_id="a" * 32, text=row.source_text, diff --git a/tests/test_registry_current_format.py b/tests/test_registry_current_format.py index d7d6225e76..8fbc1e7a72 100644 --- a/tests/test_registry_current_format.py +++ b/tests/test_registry_current_format.py @@ -58,7 +58,7 @@ def test_incomplete_saved_identity_cannot_authorize_an_owner(tmp_path): registry = Registration(path) registry.register(registry.new_thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) raw = json.loads(path.read_text()) - for field in ("owners", "admissions"): + for field in ("owners", "claims"): incomplete = dict(raw) incomplete.pop(field) path.write_text(json.dumps(incomplete)) diff --git a/tests/test_retained_context_operations.py b/tests/test_retained_context_operations.py index 852fca2b54..000eb41ded 100644 --- a/tests/test_retained_context_operations.py +++ b/tests/test_retained_context_operations.py @@ -147,7 +147,7 @@ async def original_input_consumer_journey(tmp_path, command): reservation.release() assert agent.inputs.dispositions.record( "neutral-original", seq=None, owner="beta", - claim_scope=origin.claim_scope.claimed_generation, target="beta", text=wording) + claim_scope=origin.claimed_identity.claimed_generation, target="beta", text=wording) inputs = agent.inputs.dispositions original_inputs = inputs.path.read_bytes() other = comms.registry.declare(comms.registry.new_thread('unaddressed', frozenset(), origin.project)) diff --git a/tests/test_stopped_claim_expiry.py b/tests/test_stopped_claim_expiry.py index 4f0fa09d22..f129e3683a 100644 --- a/tests/test_stopped_claim_expiry.py +++ b/tests/test_stopped_claim_expiry.py @@ -33,7 +33,7 @@ def acquire(name, resources, operation): return comms.bus.publisher._publish_claim_transition_unlocked( boundary.registry, ClaimNoticeKind.claimed(owner), partial(WakeClaims, incarnation=str(owner.created_at), claims=resources, - generation=operation, claim_scope=scope), activity=boundary.activity, + generation=operation, resource_scope=scope), activity=boundary.activity, ) old_paths = tuple(str(tmp_path / name) for name in ("untouched.py", "other.py")) diff --git a/tests/test_thread_identity.py b/tests/test_thread_identity.py index 72890f618f..656415bafc 100644 --- a/tests/test_thread_identity.py +++ b/tests/test_thread_identity.py @@ -211,7 +211,9 @@ def test_claim_scope_cannot_decode_a_registry_process_owner(tmp_path): LiveCursorScope("official-session", "a" * 32, claim_scope, owner.require_process())): encoded = FieldCodec.encode(scope) assert FieldCodec.decode(type(scope), encoded) == scope - encoded["claim_scope"] = FieldCodec.encode(registry_owner) + wire_key = FieldCodec.record_key(type(scope), "claimed_identity") + assert wire_key == "claimed_identity" + encoded[wire_key] = FieldCodec.encode(registry_owner) with pytest.raises(ValueError): FieldCodec.decode(type(scope), encoded) assert FieldCodec.decode(ClaimedIdentity, FieldCodec.encode(claim_scope)) == claim_scope diff --git a/tests/test_tool_output.py b/tests/test_tool_output.py index ff8f53959b..365e1fd3b4 100644 --- a/tests/test_tool_output.py +++ b/tests/test_tool_output.py @@ -25,7 +25,7 @@ def seed_unknown(comms, count, text): dispositions = InputDispositions(comms.root) rows = { f"bus:{index}": FieldCodec.encode(BoundUnknownInput( - key=f"bus:{index}", sequence=index, owner="b", claim_scope=index, + key=f"bus:{index}", sequence=index, owner="b", claim_generation=index, target="b", source_text=f"{index}: {text}", turn_id="private-turn", native_id="f" * 32, sent_digest=TextDigest.of("private-native-prompt"), )) diff --git a/tests/test_unscoped_claim_history.py b/tests/test_unscoped_claim_history.py index 727e2dc03f..07c550ed40 100644 --- a/tests/test_unscoped_claim_history.py +++ b/tests/test_unscoped_claim_history.py @@ -28,7 +28,7 @@ def test_unscoped_history_row_decodes_projects_never_live_and_releases(tmp_path) assert FieldCodec.encode(transition) == stored transition.require_new_operation(lambda operation: pytest.fail("no operation was recorded")) held = transition.project(ClaimProjection()) - assert held[resource].claim_scope == UnrecordedScope() + assert held[resource].resource_scope == UnrecordedScope() registry = comms.registry.snapshot() assert not held[resource].live(registry) @@ -37,7 +37,7 @@ def test_unscoped_history_row_decodes_projects_never_live_and_releases(tmp_path) taken = WakeClaims( owner="peer", incarnation=str(peer.created_at), seq=2, message_id="msg-2", claims=(resource,), generation=G2, - claim_scope=NativeClaim(owner_claimed_generation=1, operation_id="c" * 32, + resource_scope=NativeClaim(owner_claimed_generation=1, operation_id="c" * 32, turn_id="turn-2", input_id="input-2"), ).apply(held, registry) assert taken[resource].owner == "peer" @@ -49,6 +49,6 @@ def test_unscoped_history_row_decodes_projects_never_live_and_releases(tmp_path) # A row published today records its scope; history rows carry none. with pytest.raises(MalformedRecord): - FieldCodec.decode(ClaimTransition, {**stored, "admission": None}) + FieldCodec.decode(ClaimTransition, {**stored, "resource_scope": None}) scoped = FieldCodec.encode(taken[resource]) - assert scoped["admission"]["operation_id"] == "c" * 32 + assert scoped["resource_scope"]["operation_id"] == "c" * 32 diff --git a/tools/cutover/carry_bus_history.py b/tools/cutover/carry_bus_history.py index e22e44881d..0f3b03bb73 100644 --- a/tools/cutover/carry_bus_history.py +++ b/tools/cutover/carry_bus_history.py @@ -67,6 +67,8 @@ ) +from .retired_claim_fields import RetiredClaimFields + @dataclass(frozen=True) class HistoryMetadata(BusFile): """Only the retired stream boundary is needed, never its root ID or activation.""" @@ -403,7 +405,10 @@ def from_wire(cls, data: object) -> Self: class HistoryScope(SubclassUnion, ABC, affix="HistoryScope"): def restored(self, scope: ClaimScope) -> Self: - return FieldCodec.decode_record(type(self), FieldCodec.encode(scope)) + raw = FieldCodec.encode(scope) + old = {"owner_admission_generation" if key == "owner_claimed_generation" else key: value + for key, value in raw.items()} + return FieldCodec.decode_record(type(self), old) @dataclass(frozen=True, kw_only=True) @@ -486,11 +491,15 @@ class HistoryClaim(SubclassUnion, ClaimIdentity, ABC, affix="HistoryClaim"): """Retired field-group order is part of the original envelope's physical form.""" def current(self) -> ClaimTransition: - return ClaimTransition.from_wire(FieldCodec.encode(self)) + raw = RetiredClaimFields.convert(ClaimTransition, FieldCodec.encode(self)) + return ClaimTransition.from_wire(raw) + + def restored_wire(self, transition: ClaimTransition) -> dict[str, object]: + return FieldCodec.encode(transition) def restored(self, transition: ClaimTransition) -> Self: # The known old member owns layout; the new record supplies every value. - return FieldCodec.decode_record(type(self), FieldCodec.encode(transition)) + return FieldCodec.decode_record(type(self), self.restored_wire(transition)) class OrderedHistoryClaim(OrderedHistoryMember, HistoryClaim, declared_name=None): @@ -516,12 +525,13 @@ class ClaimGeneration: class ScopedClaimGeneration(ClaimGeneration, HistoryClaim, declared_name=None): scope: Annotated[HistoryScope, WireName("admission")] - def restored(self, transition: ClaimTransition) -> Self: + def restored_wire(self, transition: ClaimTransition) -> dict[str, object]: # This retired member decoded an acquiring scope, never a release. acquiring = cast(AcquiringClaim, transition) - return replace( - super().restored(transition), scope=self.scope.restored(acquiring.claim_scope) - ) + scope = FieldCodec.encode(self.scope.restored(acquiring.resource_scope)) + return {"admission" if key == "resource_scope" else key: + scope if key == "resource_scope" else value + for key, value in super().restored_wire(transition).items()} @dataclass(frozen=True, kw_only=True) diff --git a/tools/cutover/carry_claim_fields.py b/tools/cutover/carry_claim_fields.py new file mode 100644 index 0000000000..ed802ed42b --- /dev/null +++ b/tools/cutover/carry_claim_fields.py @@ -0,0 +1,182 @@ +"""Give retained claim fields their declared names, once, under install maintenance. + +The old spelling is selected by the record declaration, never by the JSON value. +Opaque provider JSON is not a record and is never searched for matching strings. +Delete this step after every retained root has crossed this format change. +""" + +from __future__ import annotations + +import json +from contextlib import closing +from pathlib import Path + +from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import FieldCodec, compile_decoder +from agent_comms.historical_views import HistoryArchive +from agent_comms.installer.carry import Carried, Carry +from agent_comms.locked_store import JsonDocument, RegistryFile, RootFile, SQLiteFile +from agent_comms.registry_document import RegistryDocument +from agent_comms.registry_store import RegistryStore +from agent_comms.restart_queue import DIRECTORY, QueuedRestart +from agent_comms.selected_write_plan import ( + SelectedWriteIntent, + SelectedWritePlans, +) +from agent_comms.store_files import AtomicFile, _store_lock +from agent_comms.typed_table import HeldBusFile, JsonStorage, TypedTable, _identifier + +from .carry_bus_history import CarryCatalogSource +from .carry_context_request_key import ContextRequestKey +from .carry_dead_turns import DeadTurns +from .carry_finished_turns import FinishedTurns +from .carry_owner_requests import OwnerRequests +from .carry_turn_owner import TurnOwner +from .retired_claim_fields import RetiredClaimFields + + +class ClaimFields(RetiredClaimFields, Carry): + precedes = (FinishedTurns, DeadTurns, OwnerRequests, TurnOwner, ContextRequestKey) + + @staticmethod + def documents(root: Path, files: dict[Path, type[RootFile]]) -> dict[Path, object]: + documents = {path: owner.record_type for path, owner in files.items() + if issubclass(owner, JsonDocument) and not issubclass(owner, RegistryStore)} + documents.update((path, SelectedWriteIntent) for path in SelectedWritePlans.matching_in( + SelectedWritePlans.directory_in(root))) + documents.update((path, QueuedRestart) for path in (root / DIRECTORY).rglob("*.json")) + return documents + + def roots(self) -> tuple[Path, ...]: + return (self.root, *(source.mapped_root(self.root, self.original_root) + for source in CarryCatalogSource.read(self.root, self.original_root))) + + def backup_files(self) -> tuple[Path, ...]: + paths = set() + for root in self.roots(): + files = RootFile.existing_in(root) + paths.update(path for path, owner in files.items() if issubclass(owner, SQLiteFile)) + paths.update(self.documents(root, files)) + paths.update(RegistryStore.at(root).written) + paths.add(HistoryArchive.path_in(self.root)) + for path in paths: + self.require_confined(self.root, path) + return tuple(sorted(path for path in paths if path.exists())) + + def carry(self, *, apply: bool) -> Carried: + if apply: + HeldBusFile(RegistryFile, RegistryFile.path_in(self.root)).require() + rewritten: list[Path] = [] + changed = 0 + with _store_lock(HistoryArchive.path_in(self.root)): + sources = CarryCatalogSource.read(self.root, self.original_root) + for root in self.roots(): + registry = RegistryStore.at(root) + if registry.path.exists(): + with registry.locked(): + registry.private_guard_unlocked() + raw = json.loads(registry.path.read_bytes()) + current = self.convert(RegistryDocument, raw) + if current != raw: + changed += 1 + if apply: + registry._write_unlocked(json.dumps(current, indent=2)) + rewritten.extend(path.relative_to(self.root) + for path in registry.written) + files = RootFile.existing_in(root) + for path, owner in files.items(): + self.require_confined(self.root, path) + if issubclass(owner, SQLiteFile): + count = self.database(path, apply=apply) + changed += count + if count and apply: + rewritten.append(path.relative_to(self.root)) + for path, declared in self.documents(root, files).items(): + self.require_confined(self.root, path) + plan = compile_decoder(declared) + if not plan.contains_records(self.selects_record): + continue + with _store_lock(path): + raw = json.loads(path.read_bytes()) + current = plan.rewrite_document(raw, self.rename_record, self.selects_record) + count = int(current != raw) + if count and apply: + AtomicFile(path).write_text(json.dumps(current)) + changed += count + if count and apply: + rewritten.append(path.relative_to(self.root)) + if apply and rewritten: + rebound = tuple(source.carried_registry(source.mapped_root(self.root, self.original_root)) + for source in sources) + if rebound: + AtomicFile(HistoryArchive.path_in(self.root)).write_text( + json.dumps(FieldCodec.encode(rebound))) + rewritten.append(Path(HistoryArchive.filename)) + return Carried(changed, rewritten=tuple(rewritten)) + + @classmethod + def database(cls, path: Path, *, apply: bool) -> int: + # Read only typed JSON columns that can contain these declarations. The + # multi-gigabyte message bodies are neither copied into RAM nor decoded. + pending = [] + rebuilds = [] + changed = 0 + with closing(SQLiteFile.read_snapshot(path)) as db: + tables = tuple(row for row in TypedTable.members_with(TypedTable) + if row.stored_columns(db)) + for row in tables: + stored = row.stored_columns(db) + renamed = cls.retired_columns(row, stored) + json_fields = tuple(field for field in row._fields() + if issubclass(field.storage, JsonStorage) and field.wire_name in stored + and field.value.contains_records(cls.selects_record)) + primary = tuple(field for field in row._fields() if field.column.primary_key) + if json_fields: + selected = (*primary, *json_fields) + columns = ",".join(renamed.get(field.name, _identifier(field.wire_name)) + for field in selected) + where = " AND ".join( + f"{renamed.get(field.name, _identifier(field.wire_name))} IS ?" + for field in primary) + if not primary: + raise RelationViolationError(f"{row.__name__} has no declared row key") + with closing(db.execute(f"SELECT {columns} FROM {_identifier(row.declared_name)}")) as rows: + for values in rows: + keys, payloads = values[:len(primary)], values[len(primary):] + updates = {} + for field, value in zip(json_fields, payloads, strict=True): + if value is None: + continue + raw = json.loads(value) + current = field.value.rewrite_records(raw, cls.rename_record, cls.selects_record) + if current != raw: + updates[field.wire_name] = json.dumps(current) + if updates: + assignments = ",".join(f"{_identifier(key)}=?" for key in updates) + updates_for_row = (f"UPDATE {_identifier(row.declared_name)} SET {assignments} WHERE {where}", + (*updates.values(), *keys)) + # Keep only changed rows, not the scanned source. + pending.append(updates_for_row) + changed += 1 + if renamed: + rebuilds.append((row, renamed)) + changed += 1 + if not apply or not changed: + return changed + with _store_lock(path), closing(SQLiteFile.writer(path, isolation_level=None)) as db: + triggers = db.execute("SELECT name,sql FROM sqlite_master WHERE type='trigger'").fetchall() + db.execute("PRAGMA foreign_keys=OFF") + with SQLiteFile.write_transaction(db): + for name, _ in triggers: + db.execute(f"DROP TRIGGER {_identifier(name)}") + for query, arguments in pending: + db.execute(query, arguments) + for row, renamed in rebuilds: + row.rebuild(db, renamed) + present = {name for (name,) in db.execute("SELECT name FROM sqlite_master WHERE type='trigger'")} + for name, sql in triggers: + if name not in present: + db.execute(sql) + if problems := db.execute("PRAGMA foreign_key_check").fetchall(): + raise RelationViolationError(f"Claim carry broke references in {path}: {problems}") + return changed diff --git a/tools/cutover/carry_declared_failures.py b/tools/cutover/carry_declared_failures.py index 6a4e3495a3..9081ad69c3 100644 --- a/tools/cutover/carry_declared_failures.py +++ b/tools/cutover/carry_declared_failures.py @@ -22,9 +22,15 @@ from agent_comms.typed_table import HeldBusFile, sql_literal from .carry_bus_history import CarryCatalogSource +from .carry_claim_fields import ClaimFields +from .retired_claim_fields import RetiredClaimFields -class DeclaredFailures(Carry): +class DeclaredFailures(RetiredClaimFields, Carry): + # Both changes affect one table. Rebuild it once with both source columns; + # ClaimFields then observes the already-carried SQL claim field. + precedes = (ClaimFields,) + old_names: ClassVar[dict[str, type[failures.TurnFailure]]] = { "backend_failed": failures.BackendFailed, "native_preflight_timeout": failures.NativePreflightTimeout, @@ -73,7 +79,7 @@ def carry(self, *, apply: bool) -> Carried: def carry_root(self, root: Path, *, apply: bool) -> Carried: changed = 0 - rewritten: list[str] = [] + rewritten: list[Path] = [] store = GoalAttemptStore.directory_in(root) path = GoalAttemptStore.path_in(store) if path.exists(): @@ -81,6 +87,7 @@ def carry_root(self, root: Path, *, apply: bool) -> Carried: with closing(SQLiteFile.read_snapshot(path)) as original: original.backup(prepared) columns = FailedTurnEvidence.stored_columns(prepared) + replacements = self.retired_columns(FailedTurnEvidence, columns) if "reason" in columns: for (reason,) in prepared.execute( f'SELECT DISTINCT reason FROM "{FailedTurnEvidence.declared_name}"' @@ -91,7 +98,9 @@ def carry_root(self, root: Path, *, apply: bool) -> Carried: f"WHEN {sql_literal(old)} THEN {sql_literal(kind)}" for old, kind in self.old_names.items() ) - FailedTurnEvidence.rebuild(prepared, {"failure": f"CASE reason {cases} END"}) + replacements["failure"] = f"CASE reason {cases} END" + if replacements: + FailedTurnEvidence.rebuild(prepared, replacements) changed += 1 if AttemptRecord.stored_columns(prepared): for old, kind in self.old_names.items(): @@ -108,7 +117,7 @@ def carry_root(self, root: Path, *, apply: bool) -> Carried: with closing(SQLiteFile.writer(path)) as destination: prepared.backup(destination) GoalAttemptStore.publish_change(path) - rewritten.extend(str(file.relative_to(self.root)) for file in ( + rewritten.extend(file.relative_to(self.root) for file in ( path, *GoalAttemptStore.source_paths(store), )) for diagnostic in DiagnosticDocument.matching_in(root): @@ -121,6 +130,6 @@ def carry_root(self, root: Path, *, apply: bool) -> Carried: document["failure"] = self.old_names[old].declared_name if apply: AtomicFile(diagnostic).write_text(json.dumps(document, indent=2) + "\n") - rewritten.append(str(diagnostic.relative_to(self.root))) + rewritten.append(diagnostic.relative_to(self.root)) changed += 1 return Carried(changed, rewritten=tuple(rewritten)) diff --git a/tools/cutover/retired_claim_fields.py b/tools/cutover/retired_claim_fields.py new file mode 100644 index 0000000000..e4baee179e --- /dev/null +++ b/tools/cutover/retired_claim_fields.py @@ -0,0 +1,94 @@ +"""Retired field-name conversion shared by the carry and physical JSONL read-back. + +This capability owns only the predecessor names. FieldCodec owns wire traversal. +Delete with the claim-field carry after all retained roots cross the format change. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import cache + +from agent_comms.acp_extension import LiveCursorScope, LiveQueueScope +from agent_comms.envelope_claim_transitions import AcquiringClaim, RecordedScope +from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import FieldCodec, compile_decoder +from agent_comms.goal_failure_observation import FailedTurnEvidence +from agent_comms.input_attempt import StoredInput +from agent_comms.input_origin import HumanInputOrigin +from agent_comms.native_runtime_input import CurrentNativeCursor, NativeRuntimeInput +from agent_comms.owner_lifecycle import OwnerRestartSelection +from agent_comms.queued_input import QueuedInputContext +from agent_comms.registry_document import RegistryDocument +from agent_comms.selected_write_plan import SelectedWriteBinding, SelectedWriteIntent +from agent_comms.thread_identity import ClaimedIdentity, TurnFence +from agent_comms.turn_lease import ActiveTurn +from agent_comms.typed_table import TypedTable, _identifier + + +@dataclass(frozen=True) +class RenamedField: + owner: type + name: str + old: str + + +# These are the retired declarations. Current spelling, JSON layout, SQL storage, +# descendants and consumers all come from FieldCodec/TypedTable, not this list. +RENAMED = ( + RenamedField(LiveCursorScope, "claimed_identity", "admission"), + RenamedField(LiveQueueScope, "claimed_identity", "admission"), + RenamedField(HumanInputOrigin, "claimed_identity", "admission"), + RenamedField(QueuedInputContext, "claimed_identity", "admission"), + RenamedField(SelectedWriteBinding, "claimed_identity", "admission"), + RenamedField(AcquiringClaim, "resource_scope", "admission"), + RenamedField(RecordedScope, "owner_claimed_generation", "owner_admission_generation"), + RenamedField(StoredInput, "claim_generation", "admission"), + RenamedField(FailedTurnEvidence, "claim_generation", "admission"), + RenamedField(ClaimedIdentity, "claimed_generation", "admission_generation"), + RenamedField(TurnFence, "claimed_generation", "admission_generation"), + RenamedField(ActiveTurn, "claimed_generation", "admission_generation"), + RenamedField(OwnerRestartSelection, "claimed_generation", "admission_generation"), + RenamedField(SelectedWriteIntent, "claim_epoch", "admission_epoch"), + RenamedField(RegistryDocument, "claims", "admissions"), + RenamedField(NativeRuntimeInput, "sent_owner_claimed_generation", "sent_owner_admission_generation"), + RenamedField(CurrentNativeCursor, "owner_claimed_generation", "owner_admission_generation"), +) + + +class RetiredClaimFields: + @classmethod + @cache + def retired_fields(cls, record: type) -> dict[str, str]: + return {item.name: item.old for item in RENAMED if issubclass(record, item.owner)} + + @classmethod + def selects_record(cls, record: type) -> bool: + return bool(cls.retired_fields(record)) + + @classmethod + def retired_columns(cls, record: type[TypedTable], stored: tuple[str, ...]) -> dict[str, str]: + fields = record._fields() + changed = {field.name: _identifier(old) + for field in fields if field.column.record_key + and (old := cls.retired_fields(record).get(field.name)) in stored} + if any(field.wire_name in stored for field in fields if field.name in changed): + raise RelationViolationError(f"{record.__name__} contains old and new claim columns") + return changed + + @classmethod + def rename_record(cls, record: type, raw: dict[str, object]) -> dict[str, object]: + result = raw + for entry in FieldCodec.field_plans(record): + old = cls.retired_fields(record).get(entry.name) + if old is not None and old in result: + if entry.key in result: + raise RelationViolationError( + f"{record.__name__} contains both {old} and {entry.key}") + result = {entry.key if key == old else key: value + for key, value in result.items()} + return result + + @classmethod + def convert(cls, declared: object, raw: object) -> object: + return compile_decoder(declared).rewrite_records(raw, cls.rename_record, cls.selects_record) diff --git a/tools/cutover/thread_name_owners.py b/tools/cutover/thread_name_owners.py index a63f61bd5d..4eace6ef03 100644 --- a/tools/cutover/thread_name_owners.py +++ b/tools/cutover/thread_name_owners.py @@ -54,9 +54,10 @@ def carry(self, *, apply: bool) -> Carried: db = lifetime.enter_context(closing(_connect(raw))) else: lifetime.enter_context(_store_lock(path)) - source = lifetime.enter_context(closing(SQLiteFile.read_snapshot(path))) db = lifetime.enter_context(closing(SQLiteFile.writer(":memory:", isolation_level=None))) - source.backup(db) + # The snapshot answers one copy; held longer, it blocks the write-back below. + with closing(SQLiteFile.read_snapshot(path)) as source: + source.backup(db) tables = ( (Participants, OwnerGenerations, ExecutionRecord, AttemptRecord, NativeRuntimeInput, CurrentNativeCursor, AwarenessClaimGenerations)