From 18c4c169d88c72ef1fa1ec2914fcf4de29419db3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 03:35:17 -0400 Subject: [PATCH 01/19] Own WITHOUT ROWID once on the table capability --- src/agent_comms/cohort_schema.py | 10 ++++------ src/agent_comms/coordination_response.py | 5 ++--- src/agent_comms/native_pi.py | 5 ++--- src/agent_comms/native_prompt_binding.py | 5 ++--- src/agent_comms/native_runtime_input.py | 8 +++----- src/agent_comms/transcript_routes.py | 8 +++----- src/agent_comms/triage_native_sources.py | 5 ++--- src/agent_comms/typed_table.py | 6 ++++++ src/agent_comms/view_unread.py | 13 ++++++++++--- 9 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/agent_comms/cohort_schema.py b/src/agent_comms/cohort_schema.py index e4954330f9..7116e8b932 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/coordination_response.py b/src/agent_comms/coordination_response.py index 2da5b96167..bfcb3d527a 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/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 92ccba6312..7b59afb3a6 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" @@ -81,7 +81,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 33d696970e..90974227e8 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -52,7 +52,7 @@ StableReferenceColumns, TextStorage, TypedRow, - TypedTable, + WithoutRowid, WriteOnceColumns, sql_literal, ) @@ -86,7 +86,7 @@ class NativeRuntimeInput( NativeInputRecord, NativeInputContext, NativeRuntimeTable, - TypedTable, + WithoutRowid, ): @classmethod def notification_context_sql(cls) -> str: @@ -288,7 +288,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 +341,7 @@ class CurrentNativeCursor( NoDelete, NativeInputContext, NativeRuntimeTable, - TypedTable, + WithoutRowid, ): column_orders = (NonDecreasing(("covered_seq", "injected_seq")),) reference_column = 'injected_seq' @@ -395,7 +394,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/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 4732af6952..6682041384 100644 --- a/src/agent_comms/triage_native_sources.py +++ b/src/agent_comms/triage_native_sources.py @@ -13,7 +13,7 @@ Column, GuardedInserts, ImmutableRows, - TypedTable, + WithoutRowid, ) @@ -22,7 +22,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 @@ -38,7 +38,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/typed_table.py b/src/agent_comms/typed_table.py index b08232fbf5..52d8b3acf8 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -2156,3 +2156,9 @@ 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 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]: From 5b1c98ae9eb9b67a8351e107a39d7dff7ef650e3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 03:43:48 -0400 Subject: [PATCH 02/19] Share fixed singleton keys and plain turn item identity --- src/agent_comms/cli_protocol.py | 5 ++-- src/agent_comms/context_store.py | 7 +++--- src/agent_comms/private_sidecar.py | 6 ++--- src/agent_comms/runtime_updates.py | 39 +++++++++--------------------- src/agent_comms/turn_item.py | 15 ++++++++++++ src/agent_comms/typed_table.py | 7 ++++++ 6 files changed, 41 insertions(+), 38 deletions(-) create mode 100644 src/agent_comms/turn_item.py 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/context_store.py b/src/agent_comms/context_store.py index 9ebb031928..b832e3a636 100644 --- a/src/agent_comms/context_store.py +++ b/src/agent_comms/context_store.py @@ -11,7 +11,7 @@ from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Annotated, ClassVar, Literal, Self, cast +from typing import TYPE_CHECKING, Annotated, ClassVar, Self, cast from .errors import RelationViolationError, StoredHistoryUnreadable from .field_codec import ( @@ -37,7 +37,7 @@ if TYPE_CHECKING: from .registry_document import RegistrySnapshot -from .typed_table import Column, DurableStore, Generated, Index, TypedTable +from .typed_table import Column, DurableStore, Generated, Index, SingletonRow, TypedTable @dataclass(frozen=True) @@ -95,8 +95,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 diff --git a/src/agent_comms/private_sidecar.py b/src/agent_comms/private_sidecar.py index 5c7c195f60..3049388fc4 100644 --- a/src/agent_comms/private_sidecar.py +++ b/src/agent_comms/private_sidecar.py @@ -25,7 +25,6 @@ from dataclasses import dataclass from functools import partial from pathlib import Path -from typing import Annotated, Literal from agent_comms.locked_store import SQLiteFile from agent_comms.coordination_errors import IdentityConflict @@ -34,8 +33,8 @@ from .store_files import StoreOwner from .typed_table import ( AllObjectsTables, - Column, ImmutableRows, + SingletonRow, SQLiteDatabase, SQLiteForeignKeys, SQLiteSchemaObject, @@ -204,8 +203,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) 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/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/typed_table.py b/src/agent_comms/typed_table.py index 52d8b3acf8..062e75c600 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -2162,3 +2162,10 @@ 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)] From 41123458987fcccad94212dada03ddcb366dfc0f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 03:56:45 -0400 Subject: [PATCH 03/19] Distinguish claimed identities, resource scopes and claim generations --- src/agent_comms/acp_extension.py | 18 +++++++++--------- src/agent_comms/channel_coding_tools.py | 12 ++++++------ src/agent_comms/coordination_snapshot.py | 2 +- src/agent_comms/envelope_claim_transitions.py | 14 +++++++------- src/agent_comms/following_inputs.py | 2 +- src/agent_comms/goal_failure_observation.py | 10 +++++----- src/agent_comms/input_attempt.py | 8 ++++---- src/agent_comms/input_origin.py | 8 ++++---- src/agent_comms/native_input_owner.py | 4 ++-- src/agent_comms/ordinary_turn_rules.py | 8 ++++---- src/agent_comms/owned_send.py | 10 +++++----- src/agent_comms/owned_turn.py | 4 ++-- src/agent_comms/private_send_rules.py | 4 ++-- src/agent_comms/publisher.py | 2 +- src/agent_comms/queued_input.py | 18 +++++++++--------- src/agent_comms/selected_actions.py | 4 ++-- src/agent_comms/selected_claim.py | 2 +- src/agent_comms/selected_tool_broker.py | 2 +- src/agent_comms/selected_turn.py | 2 +- src/agent_comms/selected_write_plan.py | 2 +- src/agent_comms/task_sources.py | 2 +- tests/test_acp_queue_contract.py | 12 ++++++------ tests/test_bus_resource_claims.py | 2 +- tests/test_claim_admission_verifier.py | 2 +- tests/test_current_input_origin.py | 4 ++-- tests/test_envelope_bus_integration.py | 6 +++--- tests/test_envelope_claim_transitions.py | 10 +++++----- tests/test_goal_input_review.py | 2 +- tests/test_input_documents.py | 2 +- tests/test_interrupted_delivery_recovery.py | 2 +- tests/test_retained_context_operations.py | 2 +- tests/test_stopped_claim_expiry.py | 2 +- tests/test_thread_identity.py | 4 +++- tests/test_tool_output.py | 2 +- tests/test_unscoped_claim_history.py | 4 ++-- 35 files changed, 98 insertions(+), 96 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 0d69abdcc5..8427f3cee0 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -200,7 +200,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_pid: int @property @@ -209,11 +209,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) @@ -301,16 +301,16 @@ class NoCursorScope(CursorScope): class LiveCursorScope(CursorScope, LiveAttachmentScope): session_id: str wire_root_id: str - claim_scope: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: Annotated[ClaimedIdentity, WireName("admission")] owner_pid: int 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.owner_pid <= 0 - or self.claim_scope.claimed_generation <= 0 + or self.claimed_identity.claimed_generation <= 0 ): raise ValueError("Invalid native cursor scope") @@ -370,7 +370,7 @@ def validate_scope(self, scope: CursorScope) -> None: cursor = self.cursor if ( cursor.wire_root_id != scope.wire_root_id - or cursor.owner_thread != scope.claim_scope.incarnation.name + or cursor.owner_thread != scope.claimed_identity.incarnation.name or cursor.owner_claimed_generation != scope.claimed_generation or not 0 <= cursor.injected_seq <= cursor.covered_seq ): @@ -446,7 +446,7 @@ class NoQueueScope(QueueScope): @dataclass(frozen=True) class LiveQueueScope(QueueScope, LiveAttachmentScope): session_id: str - claim_scope: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: Annotated[ClaimedIdentity, WireName("admission")] owner_pid: int @property @@ -549,7 +549,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) 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/coordination_snapshot.py b/src/agent_comms/coordination_snapshot.py index a9a8ca174e..26c176857c 100644 --- a/src/agent_comms/coordination_snapshot.py +++ b/src/agent_comms/coordination_snapshot.py @@ -357,7 +357,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..d3c14b724f 100644 --- a/src/agent_comms/envelope_claim_transitions.py +++ b/src/agent_comms/envelope_claim_transitions.py @@ -274,7 +274,7 @@ class ClaimIdentity: @dataclass(frozen=True) class AcquiringClaim(ClaimIdentity): generation: Annotated[str, Id128Text] - claim_scope: Annotated[ClaimScope, WireName("admission")] + resource_scope: Annotated[ClaimScope, WireName("admission")] @dataclass(frozen=True) @@ -351,7 +351,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 +372,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 +390,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: Annotated[RecordedScope, WireName("admission")] @dataclass(frozen=True) @@ -400,7 +400,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 +447,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 +457,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/following_inputs.py b/src/agent_comms/following_inputs.py index 8868056c03..e5320c8319 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/goal_failure_observation.py b/src/agent_comms/goal_failure_observation.py index 035bd0bdcc..31a8b211ba 100644 --- a/src/agent_comms/goal_failure_observation.py +++ b/src/agent_comms/goal_failure_observation.py @@ -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, WireName("admission"), 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") @@ -91,11 +91,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/input_attempt.py b/src/agent_comms/input_attempt.py index 67e8a859dc..b3b0c84993 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: Annotated[int, WireName("admission")] 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..a4555d7695 100644 --- a/src/agent_comms/input_origin.py +++ b/src/agent_comms/input_origin.py @@ -120,7 +120,7 @@ class HumanInputOrigin(InputOrigin): root_id: Annotated[str, Id128Text] author: HumanOrigin - claim_scope: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: Annotated[ClaimedIdentity, WireName("admission")] 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/native_input_owner.py b/src/agent_comms/native_input_owner.py index 3dbce8460a..4816ba6168 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,7 +112,7 @@ 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() 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/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/publisher.py b/src/agent_comms/publisher.py index 2f767e8c47..fb8b7e67fa 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 d0a0ca67a8..e5d2408acd 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -55,7 +55,7 @@ class InputHandoffRefused(RelationViolationError): @dataclass(frozen=True, slots=True) class QueuedInputContext: - claim_scope: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: Annotated[ClaimedIdentity, WireName("admission")] 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/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 9c330c27e6..496c1bc523 100644 --- a/src/agent_comms/selected_claim.py +++ b/src/agent_comms/selected_claim.py @@ -270,7 +270,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_tool_broker.py b/src/agent_comms/selected_tool_broker.py index 7b0b4cf3d8..3d29829f8b 100644 --- a/src/agent_comms/selected_tool_broker.py +++ b/src/agent_comms/selected_tool_broker.py @@ -70,7 +70,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 e2b7cf0949..65f7bf07f6 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 5e6d2ee2b3..0e5724f028 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -53,7 +53,7 @@ class SelectedWriteBinding: root_id: str source: MessageReference assignment_id: str - claim_scope: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: Annotated[ClaimedIdentity, WireName("admission")] @dataclass(frozen=True, kw_only=True) 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/tests/test_acp_queue_contract.py b/tests/test_acp_queue_contract.py index d2efcbb622..27f6130d9a 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, 123), LiveCursorScope("official-acp-id", "a" * 32, original, 123)) 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_pid=124)).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 0d4c13bf10..de2c0e64f3 100644 --- a/tests/test_bus_resource_claims.py +++ b/tests/test_bus_resource_claims.py @@ -56,7 +56,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_claim_admission_verifier.py b/tests/test_claim_admission_verifier.py index 7510471afd..a3db597acb 100644 --- a/tests/test_claim_admission_verifier.py +++ b/tests/test_claim_admission_verifier.py @@ -131,7 +131,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 14eb23cd3e..2e7c86c70e 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..fa0283a624 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,15 +65,15 @@ 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") + admission_key = FieldCodec.record_key(WakeClaims, "resource_scope") invalid = {**raw, admission_key: {**raw[admission_key], "version": 99}} with pytest.raises(ValueError): FieldCodec.decode(ClaimTransition, invalid) 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_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 555ae70c1e..69a30e7037 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_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 7bc6a58f8a..b11b940ea6 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().pid)): 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 == "admission" + 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..cc3b0bfcb8 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" From d5eac7a526328f18e13e1359d7a17d1703e39b46 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:21:31 -0400 Subject: [PATCH 04/19] Carry stored claim fields to their declared names --- src/agent_comms/acp_extension.py | 4 +- src/agent_comms/envelope_claim_transitions.py | 8 +- src/agent_comms/goal_failure_observation.py | 4 +- src/agent_comms/input_attempt.py | 2 +- src/agent_comms/input_origin.py | 4 +- src/agent_comms/native_runtime_input.py | 4 +- src/agent_comms/owner_lifecycle.py | 3 +- src/agent_comms/queued_input.py | 6 +- src/agent_comms/registry_document.py | 3 +- src/agent_comms/selected_write_plan.py | 8 +- src/agent_comms/thread_identity.py | 7 +- src/agent_comms/turn_lease.py | 5 +- tests/test_envelope_claim_transitions.py | 6 +- tests/test_registry_current_format.py | 2 +- tests/test_thread_identity.py | 2 +- tests/test_unscoped_claim_history.py | 4 +- tools/cutover/carry_bus_history.py | 25 +- tools/cutover/carry_claim_fields.py | 371 ++++++++++++++++++ 18 files changed, 420 insertions(+), 48 deletions(-) create mode 100644 tools/cutover/carry_claim_fields.py diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index cdd93cdca8..0e2df1139f 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -302,7 +302,7 @@ class NoCursorScope(CursorScope): class LiveCursorScope(CursorScope, LiveAttachmentScope): session_id: str wire_root_id: str - claimed_identity: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: ClaimedIdentity owner: ProcessIdentity def __post_init__(self): @@ -446,7 +446,7 @@ class NoQueueScope(QueueScope): @dataclass(frozen=True) class LiveQueueScope(QueueScope, LiveAttachmentScope): session_id: str - claimed_identity: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: ClaimedIdentity owner: ProcessIdentity @property diff --git a/src/agent_comms/envelope_claim_transitions.py b/src/agent_comms/envelope_claim_transitions.py index d3c14b724f..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] - resource_scope: Annotated[ClaimScope, WireName("admission")] + resource_scope: ClaimScope @dataclass(frozen=True) @@ -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.""" - resource_scope: Annotated[RecordedScope, WireName("admission")] + resource_scope: RecordedScope @dataclass(frozen=True) diff --git a/src/agent_comms/goal_failure_observation.py b/src/agent_comms/goal_failure_observation.py index 31a8b211ba..c185cc3f04 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 .diagnostics import FailureReason -from .field_codec import Id128Text, WireName +from .field_codec import Id128Text from .goal_attempt_identity import GoalAttemptIdentity from .thread_identity import ( ClaimedIdentity, @@ -57,7 +57,7 @@ class FailedTurnEvidence(GoalLedgerTable, TypedTable, GoalFailureEvidence): owner_created_at: float worktree: str claim_generation: Annotated[ - int, WireName("admission"), Column(record_key=True, check="{claim_generation}>0") + 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")] diff --git a/src/agent_comms/input_attempt.py b/src/agent_comms/input_attempt.py index b3b0c84993..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_generation: Annotated[int, WireName("admission")] + claim_generation: int target: str source_text: str origin: Annotated[InputOrigin, WireOmitDefault()] = field( diff --git a/src/agent_comms/input_origin.py b/src/agent_comms/input_origin.py index a4555d7695..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 - claimed_identity: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: ClaimedIdentity project: str goal: GoalCheckpoint diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index 90974227e8..077c9a8757 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, @@ -264,7 +264,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", @@ -365,7 +364,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"), ] diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index 4a02d8b060..0aafc481aa 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/queued_input.py b/src/agent_comms/queued_input.py index e5d2408acd..4c9ce672f6 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: - claimed_identity: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: ClaimedIdentity goal: GoalDeclaration = field(default_factory=NoGoal) @classmethod diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 2b3fb606db..769a943658 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/selected_write_plan.py b/src/agent_comms/selected_write_plan.py index 2ecbff2683..f4b3eac0f1 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -16,7 +16,7 @@ from contextlib import suppress from dataclasses import dataclass, fields from pathlib import Path -from typing import TYPE_CHECKING, Literal, Annotated +from typing import TYPE_CHECKING, Literal from .coordination_database import CoordinationStore from .locked_store import DurableFile, PatternFile @@ -33,7 +33,7 @@ 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 @@ -54,7 +54,7 @@ class SelectedWriteBinding: root_id: str source: MessageReference assignment_id: str - claimed_identity: Annotated[ClaimedIdentity, WireName("admission")] + claimed_identity: ClaimedIdentity @dataclass(frozen=True, kw_only=True) @@ -69,7 +69,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 diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index d6b96dfe14..35484efc26 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/turn_lease.py b/src/agent_comms/turn_lease.py index ad212ffe0c..8006c5319c 100644 --- a/src/agent_comms/turn_lease.py +++ b/src/agent_comms/turn_lease.py @@ -17,7 +17,6 @@ RecordMember, SingleFieldValue, SubclassUnion, - WireName, WireTransient, boundary_decoder, ) @@ -212,9 +211,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/tests/test_envelope_claim_transitions.py b/tests/test_envelope_claim_transitions.py index fa0283a624..2a864b76f3 100644 --- a/tests/test_envelope_claim_transitions.py +++ b/tests/test_envelope_claim_transitions.py @@ -73,12 +73,12 @@ def test_selected_wake_binding_survives_claim_projection(tmp_path): 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, "resource_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_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_thread_identity.py b/tests/test_thread_identity.py index a9bf26e2e3..3034c32b9c 100644 --- a/tests/test_thread_identity.py +++ b/tests/test_thread_identity.py @@ -212,7 +212,7 @@ def test_claim_scope_cannot_decode_a_registry_process_owner(tmp_path): encoded = FieldCodec.encode(scope) assert FieldCodec.decode(type(scope), encoded) == scope wire_key = FieldCodec.record_key(type(scope), "claimed_identity") - assert wire_key == "admission" + assert wire_key == "claimed_identity" encoded[wire_key] = FieldCodec.encode(registry_owner) with pytest.raises(ValueError): FieldCodec.decode(type(scope), encoded) diff --git a/tests/test_unscoped_claim_history.py b/tests/test_unscoped_claim_history.py index cc3b0bfcb8..07c550ed40 100644 --- a/tests/test_unscoped_claim_history.py +++ b/tests/test_unscoped_claim_history.py @@ -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 19ec5a9327..377b009fdf 100644 --- a/tools/cutover/carry_bus_history.py +++ b/tools/cutover/carry_bus_history.py @@ -44,6 +44,7 @@ WireName, WireOmitDefault, WireOrder, + compile_decoder, ) from agent_comms.historical_views import HistoryArchive, HistorySource from agent_comms.installer.carry import Carried, Carry @@ -403,7 +404,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 +490,17 @@ 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)) + from .carry_claim_fields import WIRE_NAMES + + raw = WIRE_NAMES.rewrite(compile_decoder(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 +526,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..b46cac6a57 --- /dev/null +++ b/tools/cutover/carry_claim_fields.py @@ -0,0 +1,371 @@ +"""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 dataclasses import dataclass +from functools import cache +from pathlib import Path + +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, + InlineFieldPlan, + InlineRecord, + MalformedRecord, + RecordMember, + SingleFieldValue, + SubclassUnion, + UnionDecoder, + ValueDecoder, + _ArrayDecoder, + _DictDecoder, + _FamilyRecordDecoder, + _RecordDecoder, + _RepresentedDecoder, + _StringKeyDictDecoder, + _PairKeyDictDecoder, + compile_decoder, +) +from agent_comms.goal_failure_observation import FailedTurnEvidence +from agent_comms.historical_views import HistoryArchive +from agent_comms.input_attempt import StoredInput +from agent_comms.input_origin import HumanInputOrigin +from agent_comms.installer.carry import Carried, Carry +from agent_comms.locked_store import JsonDocument, RegistryFile, RootFile, SQLiteFile +from agent_comms.mro_dispatch import MroDispatch, handles +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.registry_store import RegistryStore +from agent_comms.restart_queue import DIRECTORY, QueuedRestart +from agent_comms.selected_write_plan import ( + SelectedWriteBinding, + SelectedWriteIntent, + SelectedWritePlans, +) +from agent_comms.store_files import _atomic_write_text, _store_lock +from agent_comms.thread_identity import ClaimedIdentity, TurnFence +from agent_comms.turn_lease import ActiveTurn +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_owner_requests import OwnerRequests +from .carry_turn_owner import TurnOwner + + +@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"), +) + + +@cache +def renames(record: type) -> dict[str, str]: + return {item.name: item.old for item in RENAMED if issubclass(record, item.owner)} + + +class ClaimFieldNames(MroDispatch): + """Walk the existing codec's plans; only the retired field names change.""" + + @staticmethod + def represented(plan: _RepresentedDecoder) -> ValueDecoder: + owner = plan.representation + if issubclass(owner, RecordMember): + return _RecordDecoder(owner) + if issubclass(owner, SingleFieldValue): + return owner.wire_field().value + if issubclass(owner, SubclassUnion): + return ValueDecoder.for_members(owner.members_with(owner)) + if issubclass(owner, InlineRecord): + return _RecordDecoder(owner.wire_record()) + return plan.structural + + def children(self, plan: ValueDecoder) -> tuple[ValueDecoder, ...]: + return next(self.handlers_for(plan))(plan, None) + + @cache + def touches(self, plan: ValueDecoder, seen: frozenset[object] = frozenset()) -> bool: + if plan in seen: + return False + if isinstance(plan, _RecordDecoder) and renames(plan.target): + return True + return any(self.touches(child, seen | {plan}) for child in self.children(plan)) + + def rewrite(self, plan: ValueDecoder, raw: object) -> object: + if raw is None or not self.touches(plan): + return raw + return next(self.handlers_for(plan))(plan, raw) + + @handles(ValueDecoder) + def opaque(self, plan: ValueDecoder, raw: object) -> object: + return () if raw is None else raw + + @handles(_RepresentedDecoder) + def representation(self, plan: _RepresentedDecoder, raw: object) -> object: + child = self.represented(plan) + return (child,) if raw is None else self.rewrite(child, raw) + + @handles(_FamilyRecordDecoder) + def family(self, plan: _FamilyRecordDecoder, raw: object) -> object: + if raw is None: + return tuple(_RecordDecoder(member) for member in plan.target.members_with(plan.target)) + member, _ = plan.member(raw) + return self.rewrite(_RecordDecoder(member), raw) + + @handles(_RecordDecoder) + def record(self, plan: _RecordDecoder, raw: object) -> object: + entries = tuple(entry for entry in FieldCodec.field_plans(plan.target) + if entry.records and entry.declared.init) + if raw is None: + return tuple(entry.value for entry in entries) + if not isinstance(raw, dict): + raise MalformedRecord(f"Expected {plan.target.__name__} record") + result = dict(raw) + keys = renames(plan.target) + for entry in entries: + old = keys.get(entry.name) + if old is not None and old in result: + if entry.key in result: + raise RelationViolationError( + f"{plan.target.__name__} contains both {old} and {entry.key}") + result = {entry.key if key == old else key: value + for key, value in result.items()} + if isinstance(entry, InlineFieldPlan): + result = self.rewrite(entry.value, result) + elif entry.key in result: + result[entry.key] = self.rewrite(entry.value, result[entry.key]) + return result + + @handles(UnionDecoder) + def union(self, plan: UnionDecoder, raw: object) -> object: + if raw is None: + return plan.alternatives + answers = [] + for alternative in plan.alternatives: + try: + changed = self.rewrite(alternative, raw) + alternative.decode_bound(changed, {}) + except MalformedRecord: + continue + if changed not in answers: + answers.append(changed) + if len(answers) != 1: + raise RelationViolationError( + f"Claim field carry cannot select exactly one declared {plan.target}: " + f"{len(answers)} different results") + return answers[0] + + @handles(_ArrayDecoder) + def array(self, plan: _ArrayDecoder, raw: object) -> object: + if raw is None: + return plan.items + return [self.rewrite(child, item) for child, item in plan.plan_items(raw)] + + @handles(_DictDecoder) + def mapping(self, plan: _DictDecoder, raw: object) -> object: + return (plan.key, plan.value) + + @handles(_StringKeyDictDecoder) + def string_mapping(self, plan: _StringKeyDictDecoder, raw: object) -> object: + if raw is None: + return self.mapping(plan, raw) + return {key: self.rewrite(plan.value, item) for key, item in plan.entries(raw)} + + @handles(_PairKeyDictDecoder) + def pair_mapping(self, plan: _PairKeyDictDecoder, raw: object) -> object: + if raw is None: + return self.mapping(plan, raw) + return [[self.rewrite(plan.key, key), self.rewrite(plan.value, item)] + for key, item in plan.entries(raw)] + + +WIRE_NAMES = ClaimFieldNames() + + +class ClaimFields(Carry): + precedes = (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( + root / SelectedWritePlans.root_directory)) + 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 = [] + changed = 0 + visitor = WIRE_NAMES + 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 = visitor.rewrite(compile_decoder(RegistryDocument), raw) + if current != raw: + changed += 1 + if apply: + registry._write_unlocked(json.dumps(current, indent=2)) + rewritten.extend(str(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, visitor, apply=apply) + changed += count + if count and apply: + rewritten.append(str(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 visitor.touches(plan): + continue + with _store_lock(path): + raw = json.loads(path.read_bytes()) + current = visitor.rewrite(plan, raw) + count = int(current != raw) + if count and apply: + _atomic_write_text(path, json.dumps(current)) + changed += count + if count and apply: + rewritten.append(str(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: + _atomic_write_text(HistoryArchive.path_in(self.root), + json.dumps(FieldCodec.encode(rebound))) + rewritten.append(HistoryArchive.filename) + return Carried(changed, rewritten=tuple(rewritten)) + + @staticmethod + def database(path: Path, visitor: ClaimFieldNames, *, 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 = {field.name: _identifier(old) + for field in row._fields() + if field.column.record_key + and (old := renames(row).get(field.name)) in stored} + if any(field.wire_name in stored for field in row._fields() + if field.name in renamed): + raise RelationViolationError(f"{row.__name__} contains old and new claim columns") + json_fields = tuple(field for field in row._fields() + if issubclass(field.storage, JsonStorage) and field.wire_name in stored + and visitor.touches(field.value)) + 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 = visitor.rewrite(field.value, raw) + 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 From af6438aad5cddb1a79528ec59b28b8f3c234d661 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:30:27 -0400 Subject: [PATCH 05/19] Let input forwarding own nominal inbox dispatch through the existing MRO --- src/agent_comms/agent_backend.py | 28 ++------- src/agent_comms/native_session_prepare.py | 29 +--------- src/agent_comms/prompt_state.py | 22 ++++--- src/agent_comms/turn_inputs.py | 70 +++++++++++++++++++---- 4 files changed, 77 insertions(+), 72 deletions(-) diff --git a/src/agent_comms/agent_backend.py b/src/agent_comms/agent_backend.py index 743f8a25de..cf11ac8352 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -23,7 +23,7 @@ from dataclasses import dataclass from enum import Enum 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 @@ -136,15 +136,6 @@ class InputContent: contributions: tuple[InputContributionCoordinates, ...] = () -class TurnInboxReceiver(Protocol): - """What a running turn does with each record its inbox carries.""" - - 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]: ... - - class TurnInbox(asyncio.Queue): """The live inbox parks its head until the existing observer sees READY.""" def __init__(self): @@ -176,7 +167,7 @@ def hold(self, item): class TurnInboxItem(ABC): - """An inbox item owns its carried inputs and delivery to a running turn.""" + """An inbox item carries its input identities; the forwarding owner dispatches it.""" @property def carried_inputs(self) -> tuple[InputId, ...]: @@ -185,8 +176,6 @@ def carried_inputs(self) -> tuple[InputId, ...]: 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) @@ -204,9 +193,7 @@ 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) @@ -218,8 +205,7 @@ 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) @@ -233,8 +219,7 @@ 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) @@ -243,8 +228,7 @@ 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/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/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/turn_inputs.py b/src/agent_comms/turn_inputs.py index 3d35c99710..9e5c248e90 100644 --- a/src/agent_comms/turn_inputs.py +++ b/src/agent_comms/turn_inputs.py @@ -1,12 +1,13 @@ """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 import asyncio +from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -27,9 +28,12 @@ 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 @@ -38,7 +42,7 @@ @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) @@ -82,19 +86,59 @@ 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): + if not await self.dispatch(self.current, session): return finally: self.current = None - async def write(self, session: TurnSession, *written: commands.PiCommand) -> None: + async def consume_handlers( + self, item: TurnInboxItem, + handlers: Iterable[Callable[..., Awaitable[bool]]], session: NativeSession, + ) -> bool: + # One inbox item performs one operation. PreparedSession must not also + # execute its UnwrittenPrompt ancestor and accidentally send a prompt. + return await next(iter(handlers))(item, session) + + @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) + 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(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() @@ -113,7 +157,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 @@ -131,7 +176,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, @@ -142,7 +188,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 @@ -156,7 +203,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 From ab91e5d7ce05649a27e6ceeebc6fbbb701461e4b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:32:53 -0400 Subject: [PATCH 06/19] Combine failed-turn reason and claim column carry in one rebuild --- tools/cutover/carry_declared_failures.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tools/cutover/carry_declared_failures.py b/tools/cutover/carry_declared_failures.py index 830e1e2b22..123c8b75da 100644 --- a/tools/cutover/carry_declared_failures.py +++ b/tools/cutover/carry_declared_failures.py @@ -19,12 +19,17 @@ from agent_comms.installer.carry import Carried, Carry from agent_comms.locked_store import SQLiteFile from agent_comms.store_files import _atomic_write_text -from agent_comms.typed_table import HeldBusFile, sql_literal +from agent_comms.typed_table import HeldBusFile, _identifier, sql_literal from .carry_bus_history import CarryCatalogSource +from .carry_claim_fields import ClaimFields, renames class DeclaredFailures(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, @@ -89,7 +94,14 @@ 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"} + retired = renames(FailedTurnEvidence) + replacements.update({ + field.name: _identifier(retired[field.name]) + for field in FailedTurnEvidence._fields() + if field.name in retired and retired[field.name] in columns + }) + FailedTurnEvidence.rebuild(prepared, replacements) changed += 1 if AttemptRecord.stored_columns(prepared): for old, kind in self.old_names.items(): From e399a11542b88308922cf9c9b032a806ac9925c1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:39:19 -0400 Subject: [PATCH 07/19] Move stored-record traversal into its codec owners --- src/agent_comms/field_codec.py | 192 +++++++++++++++++++++++++++- tools/cutover/carry_bus_history.py | 5 +- tools/cutover/carry_claim_fields.py | 175 +++++-------------------- 3 files changed, 220 insertions(+), 152 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 7cc42ffe9f..f01d704cef 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -48,6 +48,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 +511,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 +695,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 +816,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 +919,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 +952,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 +995,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 +1020,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()) @@ -1736,6 +1777,46 @@ 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 + + @cache + 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 +1988,20 @@ class _RepresentedDecoder(ValueDecoder[Any]): representation: type[FieldRepresentation] structural: ValueDecoder[object] + @property + @cache + 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 +2151,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 +2235,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 +2351,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 +2414,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 +2432,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 +2447,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 +2499,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 +2512,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/tools/cutover/carry_bus_history.py b/tools/cutover/carry_bus_history.py index a59d133f89..ece90ef4c3 100644 --- a/tools/cutover/carry_bus_history.py +++ b/tools/cutover/carry_bus_history.py @@ -44,7 +44,6 @@ WireName, WireOmitDefault, WireOrder, - compile_decoder, ) from agent_comms.historical_views import HistoryArchive, HistorySource from agent_comms.installer.carry import Carried, Carry @@ -490,9 +489,9 @@ class HistoryClaim(SubclassUnion, ClaimIdentity, ABC, affix="HistoryClaim"): """Retired field-group order is part of the original envelope's physical form.""" def current(self) -> ClaimTransition: - from .carry_claim_fields import WIRE_NAMES + from .carry_claim_fields import ClaimFields - raw = WIRE_NAMES.rewrite(compile_decoder(ClaimTransition), FieldCodec.encode(self)) + raw = ClaimFields.convert(ClaimTransition, FieldCodec.encode(self)) return ClaimTransition.from_wire(raw) def restored_wire(self, transition: ClaimTransition) -> dict[str, object]: diff --git a/tools/cutover/carry_claim_fields.py b/tools/cutover/carry_claim_fields.py index a293d2f18f..e1930379c7 100644 --- a/tools/cutover/carry_claim_fields.py +++ b/tools/cutover/carry_claim_fields.py @@ -16,32 +16,13 @@ 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, - InlineFieldPlan, - InlineRecord, - MalformedRecord, - RecordMember, - SingleFieldValue, - SubclassUnion, - UnionDecoder, - ValueDecoder, - _ArrayDecoder, - _DictDecoder, - _FamilyRecordDecoder, - _RecordDecoder, - _RepresentedDecoder, - _StringKeyDictDecoder, - _PairKeyDictDecoder, - compile_decoder, -) +from agent_comms.field_codec import FieldCodec, compile_decoder from agent_comms.goal_failure_observation import FailedTurnEvidence from agent_comms.historical_views import HistoryArchive from agent_comms.input_attempt import StoredInput from agent_comms.input_origin import HumanInputOrigin from agent_comms.installer.carry import Carried, Carry from agent_comms.locked_store import JsonDocument, RegistryFile, RootFile, SQLiteFile -from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.native_runtime_input import CurrentNativeCursor, NativeRuntimeInput from agent_comms.owner_lifecycle import OwnerRestartSelection from agent_comms.queued_input import QueuedInputContext @@ -96,131 +77,34 @@ class RenamedField: ) -@cache -def renames(record: type) -> dict[str, str]: - return {item.name: item.old for item in RENAMED if issubclass(record, item.owner)} - - -class ClaimFieldNames(MroDispatch): - """Walk the existing codec's plans; only the retired field names change.""" - - @staticmethod - def represented(plan: _RepresentedDecoder) -> ValueDecoder: - owner = plan.representation - if issubclass(owner, RecordMember): - return _RecordDecoder(owner) - if issubclass(owner, SingleFieldValue): - return owner.wire_field().value - if issubclass(owner, SubclassUnion): - return ValueDecoder.for_members(owner.members_with(owner)) - if issubclass(owner, InlineRecord): - return _RecordDecoder(owner.wire_record()) - return plan.structural - - def children(self, plan: ValueDecoder) -> tuple[ValueDecoder, ...]: - return next(self.handlers_for(plan))(plan, None) +class ClaimFields(Carry): + precedes = (FinishedTurns, DeadTurns, OwnerRequests, TurnOwner, ContextRequestKey) + @classmethod @cache - def touches(self, plan: ValueDecoder, seen: frozenset[object] = frozenset()) -> bool: - if plan in seen: - return False - if isinstance(plan, _RecordDecoder) and renames(plan.target): - return True - return any(self.touches(child, seen | {plan}) for child in self.children(plan)) - - def rewrite(self, plan: ValueDecoder, raw: object) -> object: - if raw is None or not self.touches(plan): - return raw - return next(self.handlers_for(plan))(plan, raw) - - @handles(ValueDecoder) - def opaque(self, plan: ValueDecoder, raw: object) -> object: - return () if raw is None else raw - - @handles(_RepresentedDecoder) - def representation(self, plan: _RepresentedDecoder, raw: object) -> object: - child = self.represented(plan) - return (child,) if raw is None else self.rewrite(child, raw) - - @handles(_FamilyRecordDecoder) - def family(self, plan: _FamilyRecordDecoder, raw: object) -> object: - if raw is None: - return tuple(_RecordDecoder(member) for member in plan.target.members_with(plan.target)) - member, _ = plan.member(raw) - return self.rewrite(_RecordDecoder(member), raw) - - @handles(_RecordDecoder) - def record(self, plan: _RecordDecoder, raw: object) -> object: - entries = tuple(entry for entry in FieldCodec.field_plans(plan.target) - if entry.records and entry.declared.init) - if raw is None: - return tuple(entry.value for entry in entries) - if not isinstance(raw, dict): - raise MalformedRecord(f"Expected {plan.target.__name__} record") - result = dict(raw) - keys = renames(plan.target) - for entry in entries: - old = keys.get(entry.name) + 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 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"{plan.target.__name__} contains both {old} and {entry.key}") + f"{record.__name__} contains both {old} and {entry.key}") result = {entry.key if key == old else key: value for key, value in result.items()} - if isinstance(entry, InlineFieldPlan): - result = self.rewrite(entry.value, result) - elif entry.key in result: - result[entry.key] = self.rewrite(entry.value, result[entry.key]) return result - @handles(UnionDecoder) - def union(self, plan: UnionDecoder, raw: object) -> object: - if raw is None: - return plan.alternatives - answers = [] - for alternative in plan.alternatives: - try: - changed = self.rewrite(alternative, raw) - alternative.decode_bound(changed, {}) - except MalformedRecord: - continue - if changed not in answers: - answers.append(changed) - if len(answers) != 1: - raise RelationViolationError( - f"Claim field carry cannot select exactly one declared {plan.target}: " - f"{len(answers)} different results") - return answers[0] - - @handles(_ArrayDecoder) - def array(self, plan: _ArrayDecoder, raw: object) -> object: - if raw is None: - return plan.items - return [self.rewrite(child, item) for child, item in plan.plan_items(raw)] - - @handles(_DictDecoder) - def mapping(self, plan: _DictDecoder, raw: object) -> object: - return (plan.key, plan.value) - - @handles(_StringKeyDictDecoder) - def string_mapping(self, plan: _StringKeyDictDecoder, raw: object) -> object: - if raw is None: - return self.mapping(plan, raw) - return {key: self.rewrite(plan.value, item) for key, item in plan.entries(raw)} - - @handles(_PairKeyDictDecoder) - def pair_mapping(self, plan: _PairKeyDictDecoder, raw: object) -> object: - if raw is None: - return self.mapping(plan, raw) - return [[self.rewrite(plan.key, key), self.rewrite(plan.value, item)] - for key, item in plan.entries(raw)] - - -WIRE_NAMES = ClaimFieldNames() - - -class ClaimFields(Carry): - precedes = (FinishedTurns, DeadTurns, OwnerRequests, TurnOwner, ContextRequestKey) + @classmethod + def convert(cls, declared: object, raw: object) -> object: + return compile_decoder(declared).rewrite_records(raw, cls.rename_record, cls.selects_record) @staticmethod def documents(root: Path, files: dict[Path, type[RootFile]]) -> dict[Path, object]: @@ -252,7 +136,6 @@ def carry(self, *, apply: bool) -> Carried: HeldBusFile(RegistryFile, RegistryFile.path_in(self.root)).require() rewritten = [] changed = 0 - visitor = WIRE_NAMES with _store_lock(HistoryArchive.path_in(self.root)): sources = CarryCatalogSource.read(self.root, self.original_root) for root in self.roots(): @@ -261,7 +144,7 @@ def carry(self, *, apply: bool) -> Carried: with registry.locked(): registry.private_guard_unlocked() raw = json.loads(registry.path.read_bytes()) - current = visitor.rewrite(compile_decoder(RegistryDocument), raw) + current = self.convert(RegistryDocument, raw) if current != raw: changed += 1 if apply: @@ -272,18 +155,18 @@ def carry(self, *, apply: bool) -> Carried: for path, owner in files.items(): self.require_confined(self.root, path) if issubclass(owner, SQLiteFile): - count = self.database(path, visitor, apply=apply) + count = self.database(path, apply=apply) changed += count if count and apply: rewritten.append(str(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 visitor.touches(plan): + if not plan.contains_records(self.selects_record): continue with _store_lock(path): raw = json.loads(path.read_bytes()) - current = visitor.rewrite(plan, raw) + 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)) @@ -299,8 +182,8 @@ def carry(self, *, apply: bool) -> Carried: rewritten.append(HistoryArchive.filename) return Carried(changed, rewritten=tuple(rewritten)) - @staticmethod - def database(path: Path, visitor: ClaimFieldNames, *, apply: bool) -> int: + @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 = [] @@ -314,13 +197,13 @@ def database(path: Path, visitor: ClaimFieldNames, *, apply: bool) -> int: renamed = {field.name: _identifier(old) for field in row._fields() if field.column.record_key - and (old := renames(row).get(field.name)) in stored} + and (old := cls.retired_fields(row).get(field.name)) in stored} if any(field.wire_name in stored for field in row._fields() if field.name in renamed): raise RelationViolationError(f"{row.__name__} contains old and new claim columns") json_fields = tuple(field for field in row._fields() if issubclass(field.storage, JsonStorage) and field.wire_name in stored - and visitor.touches(field.value)) + 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) @@ -339,7 +222,7 @@ def database(path: Path, visitor: ClaimFieldNames, *, apply: bool) -> int: if value is None: continue raw = json.loads(value) - current = visitor.rewrite(field.value, raw) + current = field.value.rewrite_records(raw, cls.rename_record, cls.selects_record) if current != raw: updates[field.wire_name] = json.dumps(current) if updates: From 7df1a5e25d587bdd0b1f1ace851c3c07bfdaf7c7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:39:54 -0400 Subject: [PATCH 08/19] Declare the bounded native package inventory owner --- src/agent_comms/native_package.py | 61 +++++++++++++++++++------------ 1 file changed, 37 insertions(+), 24 deletions(-) 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]: From d40a65fdd9bd4b16ec6700e2672e95d9f054ea3c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:40:11 -0400 Subject: [PATCH 09/19] Make FieldCodec own compact JSON layout across its consumers --- src/agent_comms/acp_extension.py | 5 ++-- src/agent_comms/agent_backend.py | 13 +++------- src/agent_comms/assignment_states.py | 8 +++--- src/agent_comms/audience_manifest.py | 7 ++---- src/agent_comms/cli_bridges/shared.py | 2 +- src/agent_comms/connector_store.py | 3 +-- src/agent_comms/context_segments/awareness.py | 5 ++-- .../context_segments/optional_awareness.py | 2 +- .../context_segments/partner_activity.py | 2 +- .../context_segments/relationships.py | 2 +- src/agent_comms/context_segments/wake.py | 25 ++++--------------- src/agent_comms/context_store.py | 2 +- src/agent_comms/exporting.py | 3 +-- src/agent_comms/field_codec.py | 15 +++++++++++ src/agent_comms/fresh_private_session.py | 5 ++-- src/agent_comms/goal_attempts.py | 4 +-- src/agent_comms/goals.py | 3 +-- src/agent_comms/nk_foreground.py | 3 +-- src/agent_comms/pi_helper.py | 3 +-- src/agent_comms/plan_phases.py | 6 ++++- src/agent_comms/private_sidecar.py | 10 +++----- src/agent_comms/response_acknowledgement.py | 7 ++---- src/agent_comms/retained_task_facts.py | 3 +-- src/agent_comms/selected_source_batch.py | 4 +-- src/agent_comms/selected_write_plan.py | 7 ++---- src/agent_comms/threads.py | 3 +-- src/agent_comms/turn_context.py | 6 ++--- src/agent_comms/typed_table.py | 2 +- src/agent_comms/working_memory_labels.py | 8 +++--- 29 files changed, 73 insertions(+), 95 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 34fba48c9a..cf22972a85 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -6,7 +6,6 @@ from __future__ import annotations -import json from abc import abstractmethod from dataclasses import dataclass, field, replace from hashlib import sha256 @@ -399,7 +398,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() + FieldCodec.compact(FieldCodec.encode(self), sort_keys=True).encode() ).hexdigest() @@ -571,7 +570,7 @@ def status(self) -> str | None: @property def digest(self) -> str: return sha256( - json.dumps(FieldCodec.encode(self), sort_keys=True, separators=(",", ":")).encode() + FieldCodec.compact(FieldCodec.encode(self), sort_keys=True).encode() ).hexdigest() diff --git a/src/agent_comms/agent_backend.py b/src/agent_comms/agent_backend.py index cf11ac8352..868bba037a 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -136,14 +136,14 @@ class InputContent: contributions: tuple[InputContributionCoordinates, ...] = () -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() @@ -156,11 +156,11 @@ 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) @@ -177,7 +177,6 @@ def refuse(self, inputs, turn): inputs.refuse(turn, self.carried_inputs) - @dataclass(frozen=True, kw_only=True) class InputRequest(TurnInboxItem): """One queued user input handed to a backend: identity, content and busy policy. @@ -195,7 +194,6 @@ def carried_inputs(self) -> tuple[InputId, ...]: - @dataclass(frozen=True) class SendNow(TurnInboxItem): """Ask the running turn to place these queued steering inputs immediately. @@ -207,7 +205,6 @@ class SendNow(TurnInboxItem): - @dataclass(frozen=True) class UseSettings(TurnInboxItem): """Ask the running turn to use the thread's stored model and thinking level. @@ -221,7 +218,6 @@ class UseSettings(TurnInboxItem): - @dataclass(frozen=True) class ClearQueue(TurnInboxItem): """Clear only these queued user inputs, never delivered bus inputs.""" @@ -230,7 +226,6 @@ class ClearQueue(TurnInboxItem): - class CompactionOutcome(DeclaredFamily, DeclaredReply, affix="Compaction"): """The result of one requested 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..b3cb48f283 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,9 +42,7 @@ 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 - ).encode("utf-8") + 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 if len(encoded) > limit: 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/connector_store.py b/src/agent_comms/connector_store.py index 676874a2ac..03d817efef 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 digest(FieldCodec.compact(FieldCodec.encode(self), sort_keys=True)) 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..4747261c1c 100644 --- a/src/agent_comms/context_store.py +++ b/src/agent_comms/context_store.py @@ -186,7 +186,7 @@ class ContextSegmentVersion(TypedTable): @classmethod def append(cls, db: sqlite3.Connection, segment: RecordedSegmentManifest) -> int: - encoded = json.dumps(FieldCodec.encode(segment), sort_keys=True, separators=(",", ":")) + encoded = FieldCodec.compact(FieldCodec.encode(segment), sort_keys=True) digest = hashlib.sha256(encoded.encode()).hexdigest() rows = cls.select(db, digest=digest) if rows: 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..081583e7dc 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -1352,6 +1352,21 @@ 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 encode_document(cls, value: object, declared: object) -> object: """A document whose declaration names its leaf member stores that member untagged.""" 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/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/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/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 e8bd49d81d..a6a0f1bc91 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_sidecar.py b/src/agent_comms/private_sidecar.py index 5c7c195f60..4086e30043 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 @@ -29,6 +28,7 @@ from agent_comms.locked_store import SQLiteFile from agent_comms.coordination_errors import IdentityConflict +from agent_comms.field_codec import FieldCodec from .private_path import ObservedPath from .store_files import StoreOwner @@ -218,7 +218,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: @@ -367,7 +367,7 @@ def sidecar_connection( def encode_request(text: str, *, streaming_behavior: str | None = None) -> str: """JSON-encode like the pinned native ``_claimNativeInput`` request.""" - return json.dumps( + return FieldCodec.compact( { "kind": "prompt", "text": text, @@ -375,9 +375,7 @@ def encode_request(text: str, *, streaming_behavior: str | None = None) -> str: "streamingBehavior": streaming_behavior, "expandPromptTemplates": True, "source": "rpc", - }, - separators=(",", ":"), - ensure_ascii=False, + }, ensure_ascii=False, ) diff --git a/src/agent_comms/response_acknowledgement.py b/src/agent_comms/response_acknowledgement.py index f87729b996..5b554fdc9b 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,12 +13,10 @@ 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 - ).encode("utf-8") + return FieldCodec.compact(value, sort_keys=True, ensure_ascii=False, allow_nan=False).encode("utf-8") def public_envelope_digest(record: Mapping[str, object]) -> str: 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/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_write_plan.py b/src/agent_comms/selected_write_plan.py index 229c35f005..aa8623b633 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -10,7 +10,6 @@ import base64 import hashlib -import json import os from contextlib import suppress from dataclasses import dataclass, fields @@ -220,7 +219,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 +269,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/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/turn_context.py b/src/agent_comms/turn_context.py index 9097705679..274a84b49d 100644 --- a/src/agent_comms/turn_context.py +++ b/src/agent_comms/turn_context.py @@ -818,7 +818,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. @@ -1006,7 +1006,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 @@ -1117,7 +1117,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/typed_table.py b/src/agent_comms/typed_table.py index 04c4777692..0b4ba43f24 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: 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()) From 63e837e918cfa63dae061acbd1db7867ebc2af46 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:41:04 -0400 Subject: [PATCH 10/19] Declare located thread results and retain narrowing through read failures --- src/agent_comms/ui_model/observation.py | 64 +++++++++++++++---------- 1 file changed, 40 insertions(+), 24 deletions(-) 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]: From 47d168cf94fb4bf16285c10cd214cce87fbf0a7c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:44:51 -0400 Subject: [PATCH 11/19] Declare the canonical digest once and drop duplicate imports FieldCodec.digest owns the SHA-256 of a declared value's sorted compact encoding. CursorEnvelope.digest, QueueChangedUpdate.digest, AuthorizationQuery.request_id and ContextSegmentVersion.append each spelled that procedure by hand; they now derive it. Byte-identical output. private_sidecar.py imported FieldCodec twice, and selected_write_plan.py imported Platform twice (already on main). Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/acp_extension.py | 9 ++------- src/agent_comms/connector_store.py | 2 +- src/agent_comms/context_store.py | 4 +--- src/agent_comms/field_codec.py | 6 ++++++ src/agent_comms/private_sidecar.py | 1 - src/agent_comms/selected_write_plan.py | 1 - 6 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 3877147669..ae993e6664 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -8,7 +8,6 @@ from abc import abstractmethod from dataclasses import dataclass, field, replace -from hashlib import sha256 from typing import TYPE_CHECKING, Annotated, Callable, ClassVar @@ -397,9 +396,7 @@ def same_observation(self, other: CursorEnvelope) -> bool: @property def digest(self) -> str: - return sha256( - FieldCodec.compact(FieldCodec.encode(self), sort_keys=True).encode() - ).hexdigest() + return FieldCodec.digest(self) @dataclass(frozen=True) @@ -569,9 +566,7 @@ def status(self) -> str | None: @property def digest(self) -> str: - return sha256( - FieldCodec.compact(FieldCodec.encode(self), sort_keys=True).encode() - ).hexdigest() + return FieldCodec.digest(self) @dataclass(frozen=True) diff --git a/src/agent_comms/connector_store.py b/src/agent_comms/connector_store.py index 03d817efef..0f8bb7fa31 100644 --- a/src/agent_comms/connector_store.py +++ b/src/agent_comms/connector_store.py @@ -65,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(FieldCodec.compact(FieldCodec.encode(self), sort_keys=True)) + 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_store.py b/src/agent_comms/context_store.py index 4747261c1c..373190fd39 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 @@ -186,8 +185,7 @@ class ContextSegmentVersion(TypedTable): @classmethod def append(cls, db: sqlite3.Connection, segment: RecordedSegmentManifest) -> int: - encoded = FieldCodec.compact(FieldCodec.encode(segment), sort_keys=True) - 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/field_codec.py b/src/agent_comms/field_codec.py index 081583e7dc..abe601ad23 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 @@ -1367,6 +1368,11 @@ def compact( 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.""" diff --git a/src/agent_comms/private_sidecar.py b/src/agent_comms/private_sidecar.py index 7685ffc4cc..2b98dd043f 100644 --- a/src/agent_comms/private_sidecar.py +++ b/src/agent_comms/private_sidecar.py @@ -28,7 +28,6 @@ from agent_comms.locked_store import SQLiteFile from agent_comms.coordination_errors import IdentityConflict -from agent_comms.field_codec import FieldCodec from .field_codec import FieldCodec diff --git a/src/agent_comms/selected_write_plan.py b/src/agent_comms/selected_write_plan.py index 731bab18cf..322c29993c 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -29,7 +29,6 @@ from agent_comms.coordinator import Coordination from .comms import Comms -from .child_process import Platform from .coordination_cohort import sealed_cohort_assignments from .declared_family import DeclaredFamily from .envelope_claim_transitions import ExistingFileClaim From ed944f943161262b668e24f65f9cdef1ba556288 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:45:39 -0400 Subject: [PATCH 12/19] Carry either failed-turn column through the same rebuild --- tools/cutover/carry_claim_fields.py | 3 --- tools/cutover/carry_declared_failures.py | 5 +++-- tools/cutover/retired_claim_fields.py | 11 +++++++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tools/cutover/carry_claim_fields.py b/tools/cutover/carry_claim_fields.py index 73d89b2144..ed802ed42b 100644 --- a/tools/cutover/carry_claim_fields.py +++ b/tools/cutover/carry_claim_fields.py @@ -127,9 +127,6 @@ def database(cls, path: Path, *, apply: bool) -> int: for row in tables: stored = row.stored_columns(db) renamed = cls.retired_columns(row, stored) - if any(field.wire_name in stored for field in row._fields() - if field.name in renamed): - raise RelationViolationError(f"{row.__name__} contains old and new claim columns") 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)) diff --git a/tools/cutover/carry_declared_failures.py b/tools/cutover/carry_declared_failures.py index 45d2c60c77..9081ad69c3 100644 --- a/tools/cutover/carry_declared_failures.py +++ b/tools/cutover/carry_declared_failures.py @@ -87,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}"' @@ -97,8 +98,8 @@ 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() ) - replacements = {"failure": f"CASE reason {cases} END"} - replacements.update(self.retired_columns(FailedTurnEvidence, columns)) + replacements["failure"] = f"CASE reason {cases} END" + if replacements: FailedTurnEvidence.rebuild(prepared, replacements) changed += 1 if AttemptRecord.stored_columns(prepared): diff --git a/tools/cutover/retired_claim_fields.py b/tools/cutover/retired_claim_fields.py index 79062ee9e3..e4baee179e 100644 --- a/tools/cutover/retired_claim_fields.py +++ b/tools/cutover/retired_claim_fields.py @@ -68,10 +68,13 @@ def selects_record(cls, record: type) -> bool: @classmethod def retired_columns(cls, record: type[TypedTable], stored: tuple[str, ...]) -> dict[str, str]: - return {field.name: _identifier(old) - for field in record._fields() - if field.column.record_key - and (old := cls.retired_fields(record).get(field.name)) in stored} + 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]: From 0f4a395e4eeea9c6226129fa84adc54890ae710c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:47:23 -0400 Subject: [PATCH 13/19] Preserve retained goal waits across edits during reply review --- src/agent_comms/goal_management.py | 11 ++-- src/agent_comms/goal_waits.py | 13 ++++- tests/test_goal_reply_lock_scope.py | 85 ++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 7 deletions(-) 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..fcc33dbd1e 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 ( @@ -334,6 +341,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/tests/test_goal_reply_lock_scope.py b/tests/test_goal_reply_lock_scope.py index daf0e22269..6810162cb7 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,85 @@ 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 From 3efbdf071c2a3f05650a134406e4cb9b33e112ad Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:49:16 -0400 Subject: [PATCH 14/19] Reobserve the same wait after its owner fence changes --- src/agent_comms/goal_waits.py | 16 +++++++++++++--- tests/test_goal_reply_lock_scope.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index fcc33dbd1e..60872b4682 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -305,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) @@ -313,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): diff --git a/tests/test_goal_reply_lock_scope.py b/tests/test_goal_reply_lock_scope.py index 6810162cb7..674bca7c86 100644 --- a/tests/test_goal_reply_lock_scope.py +++ b/tests/test_goal_reply_lock_scope.py @@ -415,3 +415,20 @@ def test_new_dependency_turn_during_silence_read_keeps_wait(reviewing): 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() From 679f26190de8aa2364fa9f43552e876b43228960 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:49:28 -0400 Subject: [PATCH 15/19] Restore the one Platform import and drop dead table imports The batch dedupe and #2646 each deleted a different copy of Platform in selected_write_plan.py, leaving none. selected_tool_broker.py imported Platform twice; native_runtime_input.py kept DurableTables and SharedFileTables after #2646 stopped using them. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/native_runtime_input.py | 2 -- src/agent_comms/selected_tool_broker.py | 1 - src/agent_comms/selected_write_plan.py | 1 + 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index b3f500cdfc..4d6d23623c 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -41,7 +41,6 @@ from .typed_table import ( TableReference, Column, - DurableTables, FinalizedColumn, FrozenColumn, IntegerStorage, @@ -49,7 +48,6 @@ NoDelete, NonDecreasing, RequiredUpdateColumns, - SharedFileTables, StableReferenceColumns, TextStorage, TypedRow, diff --git a/src/agent_comms/selected_tool_broker.py b/src/agent_comms/selected_tool_broker.py index de78ad0525..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 diff --git a/src/agent_comms/selected_write_plan.py b/src/agent_comms/selected_write_plan.py index dafa78bc29..780538b84e 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -27,6 +27,7 @@ from agent_comms.coordination_tables.assignments import WakeAssignment from agent_comms.coordinator import Coordination +from .child_process import Platform from .comms import Comms from .coordination_cohort import sealed_cohort_assignments from .declared_family import DeclaredFamily From e4ddafa329c4bddbba96965ac20e3900c3bb8a38 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:53:28 -0400 Subject: [PATCH 16/19] Report vanished captured turn records as stale owner fences --- src/agent_comms/native_input_owner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/native_input_owner.py b/src/agent_comms/native_input_owner.py index 4e1d192a83..995e7ceef7 100644 --- a/src/agent_comms/native_input_owner.py +++ b/src/agent_comms/native_input_owner.py @@ -116,7 +116,7 @@ def _require_current( 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: From 73c96737082d1585b022e1779b28dd92fd260c27 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:54:21 -0400 Subject: [PATCH 17/19] Run each carry pass in one worker, reuse the built Pi pin, qualify once Install 8 spent its time on three things that do not need to happen: - Carry steps: 41 step_worker processes (1 dry run, then backup, migrate and retire on the practice copy, then backup and migrate live), each paying the ~2 s agent_comms import for milliseconds of work. The worker now takes a whole pass: STEPS lists each step in order and RESULT holds the pass's answers under the operation's declared answer type. One backup_files pass on a copy of the live bus: 14.8 s as 8 workers, 2.1 s as 1. - prepare-pi-native: install_native ran it in the per-install scratch tree, where .pi-native- never exists, so every install rebuilt the same pin from npm (20.2 s in install 8; 27 runtimes hold byte-identical copies of 9c5a3c174532e517). The built package is now kept under ~/.local/share/agent-comms/native/.pi-native-, keyed by the manifest, which pins the declared build inputs and the output tree. PinnedLocationProbe requires the checkout's sources to still hash to the manifest's inputs line before the pin is used as a key, and PrepareNativeProbe still qualifies the runtime's copy against the pinned tree digest. Reuse: 0.04 s inputs check + 4.1 s copy. - PrepareNativeProbe qualified one immutable runtime tree up to three times per install (build, rehearsal, live): 2.6 s, 6.8 s and 47.8 s in install 8, the last on a page cache the bus backup had just cold- evicted (qualify measured 10.8 s cold, 0.49 s warm). Qualification now happens once, where Stack.build selects the runtime, for a new and a reused runtime alike. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/installer/carry.py | 10 ++- .../installer/interpreter_operation.py | 7 +- src/agent_comms/installer/step_worker.py | 20 +++-- src/agent_comms/installer/upgrade.py | 78 +++++++++++++------ tests/test_carry_bus_history.py | 8 +- 5 files changed, 85 insertions(+), 38 deletions(-) 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/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) From c1a8e8fb174933d8e101d60231ad021c35c564bd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:55:50 -0400 Subject: [PATCH 18/19] Declare inbox types before use and select its single MRO operation directly --- src/agent_comms/agent_backend.py | 24 ++++++++++----------- src/agent_comms/audience_manifest.py | 4 +++- src/agent_comms/response_acknowledgement.py | 13 +++++++++-- src/agent_comms/turn_inputs.py | 17 ++++++--------- 4 files changed, 32 insertions(+), 26 deletions(-) diff --git a/src/agent_comms/agent_backend.py b/src/agent_comms/agent_backend.py index 868bba037a..241873fa34 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -136,7 +136,18 @@ class InputContent: contributions: tuple[InputContributionCoordinates, ...] = () -class TurnInbox(asyncio.Queue["TurnInboxItem"]): +class TurnInboxItem(ABC): + """An inbox item carries its input identities; the forwarding owner dispatches it.""" + + @property + def carried_inputs(self) -> tuple[InputId, ...]: + return () + + def refuse(self, inputs, turn): + inputs.refuse(turn, self.carried_inputs) + + +class TurnInbox(asyncio.Queue[TurnInboxItem]): """The live inbox parks its head until the existing observer sees READY.""" def __init__(self): super().__init__() @@ -166,17 +177,6 @@ def hold(self, item: TurnInboxItem) -> None: self.begin(item) -class TurnInboxItem(ABC): - """An inbox item carries its input identities; the forwarding owner dispatches it.""" - - @property - def carried_inputs(self) -> tuple[InputId, ...]: - return () - - def refuse(self, inputs, turn): - inputs.refuse(turn, self.carried_inputs) - - @dataclass(frozen=True, kw_only=True) class InputRequest(TurnInboxItem): """One queued user input handed to a backend: identity, content and busy policy. diff --git a/src/agent_comms/audience_manifest.py b/src/agent_comms/audience_manifest.py index b3cb48f283..a9d35212f7 100644 --- a/src/agent_comms/audience_manifest.py +++ b/src/agent_comms/audience_manifest.py @@ -42,7 +42,9 @@ def _bounded_text(value: str, field: str, *, thread_name: bool = False) -> None: def _canonical_json(value: object, *, limit: int) -> bytes: try: - encoded = FieldCodec.compact(value, sort_keys=True, ensure_ascii=False, allow_nan=False).encode("utf-8") + 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 if len(encoded) > limit: diff --git a/src/agent_comms/response_acknowledgement.py b/src/agent_comms/response_acknowledgement.py index 5b554fdc9b..6388f981ce 100644 --- a/src/agent_comms/response_acknowledgement.py +++ b/src/agent_comms/response_acknowledgement.py @@ -13,10 +13,19 @@ MAX_IDENTIFIER_CHARS, require_bounded, require_nonempty, validate_execution_id, ) from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import FieldCodec, MalformedRecord, NullMember, RecordMember, SubclassUnion +from agent_comms.field_codec import ( + FieldCodec, + MalformedRecord, + NullMember, + RecordMember, + SubclassUnion, +) + def _canonical(value: object) -> bytes: - return FieldCodec.compact(value, sort_keys=True, ensure_ascii=False, allow_nan=False).encode("utf-8") + return FieldCodec.compact( + value, sort_keys=True, ensure_ascii=False, allow_nan=False, + ).encode("utf-8") def public_envelope_digest(record: Mapping[str, object]) -> str: diff --git a/src/agent_comms/turn_inputs.py b/src/agent_comms/turn_inputs.py index 9e5c248e90..cd74008bab 100644 --- a/src/agent_comms/turn_inputs.py +++ b/src/agent_comms/turn_inputs.py @@ -7,7 +7,6 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -93,19 +92,14 @@ async def forward(self, session: NativeSession) -> None: while True: self.current = await self.queue.get() try: - if not await self.dispatch(self.current, 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 consume_handlers( - self, item: TurnInboxItem, - handlers: Iterable[Callable[..., Awaitable[bool]]], session: NativeSession, - ) -> bool: - # One inbox item performs one operation. PreparedSession must not also - # execute its UnwrittenPrompt ancestor and accidentally send a prompt. - return await next(iter(handlers))(item, session) - @handles(TurnInboxItem) async def unhandled(self, item: TurnInboxItem, session: NativeSession) -> bool: raise TypeError(f"No input handler declared for {type(item).__name__}") @@ -115,7 +109,8 @@ async def original_prompt(self, item: UnwrittenPrompt, session: TurnSession) -> def write(): session.grant_prompt() session.native.send(session.command) - async with MaintenanceBarrier(RegistryFile.path_in(session.startup.root)).ingress_async() as status: + 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: From a03451f54120f4f936394ed18bc3a4bbb8f041ef Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:56:40 -0400 Subject: [PATCH 19/19] Close the carry's read snapshot before writing the store back ThreadNameOwners kept SQLiteFile.read_snapshot(path) open for the whole carry, holding a read transaction on the file it later backs up into. Connection.backup retries SQLITE_BUSY forever, so applying the carry hung (tests/test_thread_name_owners_carry.py wedged at thread_name_owners.py:118). The snapshot now lives only for the copy into memory. Co-Authored-By: Claude Opus 5.5 --- tools/cutover/thread_name_owners.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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)