From 9504912def6235d97bdf0489720079948dca1631 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 02:45:20 -0400 Subject: [PATCH 01/18] Index snapshot recipients and project only requested activity --- src/agent_comms/acp.py | 5 +-- src/agent_comms/activity.py | 6 +-- src/agent_comms/agent_activity.py | 30 +++++++------- src/agent_comms/attempt_recovery.py | 3 +- src/agent_comms/claim_admission.py | 4 +- src/agent_comms/cli_commands.py | 9 ++++- src/agent_comms/cohort_foreground.py | 3 +- src/agent_comms/coordination_response.py | 3 +- .../coordination_tables/assignments.py | 3 +- src/agent_comms/cursor_owner.py | 3 +- src/agent_comms/diagnostics.py | 4 +- src/agent_comms/external_transcript.py | 3 +- src/agent_comms/goal_management.py | 3 +- src/agent_comms/history_views.py | 14 ++++++- src/agent_comms/input_drain.py | 5 +-- src/agent_comms/message_bus.py | 5 +-- src/agent_comms/native_input_owner.py | 3 +- src/agent_comms/native_input_record.py | 3 +- src/agent_comms/native_source_cursor.py | 5 +-- src/agent_comms/native_transcript.py | 3 +- .../optional_awareness_projection.py | 4 +- src/agent_comms/owner_lifecycle.py | 6 +-- src/agent_comms/presentation.py | 17 ++++---- src/agent_comms/private_bus_checkpoint.py | 4 +- src/agent_comms/publisher.py | 5 +-- src/agent_comms/queued_input.py | 5 +-- src/agent_comms/read_ledger.py | 4 +- src/agent_comms/registry_document.py | 13 ++++++ src/agent_comms/routing.py | 4 +- src/agent_comms/selected_participant.py | 4 +- src/agent_comms/selected_write_plan.py | 7 ++-- src/agent_comms/task_sources.py | 6 +-- src/agent_comms/thread_identity.py | 7 ++++ src/agent_comms/thread_management.py | 7 +--- src/agent_comms/thread_presentation.py | 3 +- src/agent_comms/transcript_receipts.py | 16 ++++---- src/agent_comms/transcript_routes.py | 3 +- src/agent_comms/turn_runner.py | 10 +++-- tests/shared_bus_restart_native.py | 5 ++- tests/test_concurrency.py | 3 +- tests/test_recipient_activity_index.py | 40 +++++++++++++++++++ 41 files changed, 173 insertions(+), 117 deletions(-) create mode 100644 tests/test_recipient_activity_index.py diff --git a/src/agent_comms/acp.py b/src/agent_comms/acp.py index e27985c51b..ab347ae733 100644 --- a/src/agent_comms/acp.py +++ b/src/agent_comms/acp.py @@ -52,7 +52,6 @@ encode_updates, ) from .agent_event_updates import AcpEventConsumer -from .bus_publication import stable_thread_lookup from .image_inputs import prompt_images from .cohort_foreground import _accept_visible_deliveries from .comms import Comms, wire @@ -420,7 +419,7 @@ def read_admission(): ), ) await _accept_visible_deliveries( - bus, wire_root_id, store_path, stable_thread_lookup(owner.created_at), 0, + bus, wire_root_id, store_path, owner.incarnation.stable_thread_lookup, 0, owner_name=owner.name, acquire_package=execution.validate, ) if self.inputs.owns_execution(session_id, owner): @@ -433,7 +432,7 @@ def read_admission(): return 0 def select(store): with store.session.read(): - participant = store.participants.get(stable_thread_lookup(owner.created_at)) + participant = store.participants.get(owner.incarnation.stable_thread_lookup) candidate = next_sealed_assignment( store, participant.lookup, owner.name, after_seq=admission_after_seq, ) diff --git a/src/agent_comms/activity.py b/src/agent_comms/activity.py index cebe5af372..bef26f8ad5 100644 --- a/src/agent_comms/activity.py +++ b/src/agent_comms/activity.py @@ -406,13 +406,13 @@ def _observed(self, thread: str, events: Mapping[str, Activity], *, return latest def all_current(self, *, active: frozenset[str] = frozenset(), - threads: Iterable[str] = ()) -> dict[str, Activity]: - """Latest activity per thread (idle included for known threads).""" + threads: Iterable[str] | None = None) -> dict[str, Activity]: + """Latest activity for requested threads, or all events when unspecified.""" result = self._read_snapshot().latest now = time.time() return { thread: self._observed(thread, result, active=thread in active, now=now) - for thread in dict.fromkeys((*result, *threads)) + for thread in dict.fromkeys(result if threads is None else threads) } def _read_snapshot(self) -> ActivitySnapshot: diff --git a/src/agent_comms/agent_activity.py b/src/agent_comms/agent_activity.py index b5fe4d1b63..3c9b89671a 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -127,30 +127,32 @@ def _observed(self, participant: Thread, owner: OwnerIdentity, activity = ObservedActivity.acquire(event, owner) return participant.execution.project_activity(participant, activity) - def all_activity(self, *, snapshot: RegistrySnapshot | None = None) -> Mapping[str, ObservedActivity]: - snapshot = snapshot or self.registry.snapshot() + def all_activity(self, threads: Iterable[Thread], *, snapshot: RegistrySnapshot) -> Mapping[str, ObservedActivity]: + requested = {thread.name: thread for thread in threads} + if not requested: + return {} events = self.activity.all_current( - active=frozenset(name for name, thread in snapshot.threads.items() if thread.executing), - threads=snapshot.threads, + active=frozenset(name for name, thread in requested.items() if thread.executing), + threads=requested, ) return { name: self._observed(thread, snapshot.owner_identity(name), events[name]) - for name, thread in snapshot.threads.items() + for name, thread in requested.items() } def observe_recipients(self, recipients: Iterable[FrozenRecipient], *, snapshot: RegistrySnapshot) -> Mapping[str, RecipientActivity]: """Acquire one bounded window's recipients from the original source snapshot.""" - from .bus_publication import stable_thread_lookup - snapshot.require_unambiguous_ownership() - observations: dict[str, RecipientActivity] = { - recipient.recipient_lookup: UnavailableRecipientActivity() for recipient in recipients - } - for thread in snapshot.threads.values(): - lookup = stable_thread_lookup(thread.created_at) - if lookup not in observations: + observations: dict[str, RecipientActivity] = {} + for recipient in recipients: + lookup = recipient.recipient_lookup + if lookup in observations: continue - observations[lookup] = thread.execution.observe_recipient(self, snapshot, thread) + thread = snapshot.thread_for_lookup(lookup) + observations[lookup] = ( + thread.execution.observe_recipient(self, snapshot, thread) + if thread is not None else UnavailableRecipientActivity() + ) return observations def _emit_activity(self, activity: Activity) -> None: diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index ca2dc6038e..0c826f7e00 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -224,7 +224,6 @@ def settle_stopped_owner(cls, root: Path, owner) -> None: @classmethod def recover_owner(cls, root: Path, owner) -> None: """Resolve an exited predecessor once, before this worker begins draining.""" - from .bus_publication import stable_thread_lookup from .coordination_tables.executions import CurrentExecutions path = root / "coordination.sqlite3" @@ -232,7 +231,7 @@ def recover_owner(cls, root: Path, owner) -> None: return with Coordination(str(path)) as store: pointer = CurrentExecutions.one( - store.session._connection, owner_lookup=stable_thread_lookup(owner.created_at) + store.session._connection, owner_lookup=owner.incarnation.stable_thread_lookup ) if pointer is not None and pointer.unresolved: cls.recover_released_native_attempt(store, pointer.execution_id) diff --git a/src/agent_comms/claim_admission.py b/src/agent_comms/claim_admission.py index f3f67f801d..aa015c9a8e 100644 --- a/src/agent_comms/claim_admission.py +++ b/src/agent_comms/claim_admission.py @@ -16,7 +16,7 @@ from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordinator import Coordination -from .bus_publication import CommittedDelivery, stable_thread_lookup +from .bus_publication import CommittedDelivery from .channel_targets import BuiltinChannel from .cohort_schema import assert_cohort_schema from .comms import Comms @@ -80,7 +80,7 @@ def _verify_selected_wake_state( raise IdentityConflict("Wake source or owner turn does not match") from error if initial.message.reference != admission.source: raise IdentityConflict("Wake source message changed") - if stable_thread_lookup(owner.created_at) != admission.recipient_lookup: + if owner.incarnation.stable_thread_lookup != admission.recipient_lookup: raise IdentityConflict("Wake recipient incarnation changed") if generation != admission.owner_admission_generation or turn.id != admission.turn_id: raise IdentityConflict("Wake source or owner turn does not match") diff --git a/src/agent_comms/cli_commands.py b/src/agent_comms/cli_commands.py index 6c9d6f632f..3a9f5b05fe 100644 --- a/src/agent_comms/cli_commands.py +++ b/src/agent_comms/cli_commands.py @@ -966,6 +966,11 @@ class StatusCliCommand(CliCommand): help = "Live activity: what each thread is doing right now" def apply(self, ctx: Comms) -> Any: + snapshot = ctx.registry.snapshot() + running = ( + thread for name, thread in snapshot.threads.items() + if snapshot.statuses[name].active + ) return { "status": [ { @@ -974,7 +979,9 @@ def apply(self, ctx: Comms) -> Any: "detail": activity.detail, "ts": activity.timestamp, } - for thread, activity in sorted(ctx.agents.all_activity().items()) + for thread, activity in sorted( + ctx.agents.all_activity(running, snapshot=snapshot).items() + ) ] } diff --git a/src/agent_comms/cohort_foreground.py b/src/agent_comms/cohort_foreground.py index b2bc51ae29..4af5d15485 100644 --- a/src/agent_comms/cohort_foreground.py +++ b/src/agent_comms/cohort_foreground.py @@ -30,7 +30,6 @@ from agent_comms.coordination_errors import IdentityConflict, PublicationActivationBlocked from agent_comms.coordinator import Coordination -from .bus_publication import stable_thread_lookup from .child_process import ProcessIdentity from .cohort_schema import CohortDeliveryReceipts, assert_cohort_schema from .comms import Comms @@ -197,7 +196,7 @@ def admit(resources): comms.registry.register(thread) with Coordination(str(root / "coordination.sqlite3")) as store: store.install_private_runtime() - lookup = stable_thread_lookup(comms.registry.require(name).created_at) + lookup = comms.registry.require(name).incarnation.stable_thread_lookup store.participants.register(lookup, name, name, committed=True) return thread, lookup, action diff --git a/src/agent_comms/coordination_response.py b/src/agent_comms/coordination_response.py index fdab912189..ba152a85b7 100644 --- a/src/agent_comms/coordination_response.py +++ b/src/agent_comms/coordination_response.py @@ -24,7 +24,6 @@ from pathlib import Path from typing import Literal, Type -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.cohort_schema import assert_cohort_schema from agent_comms.coordination_errors import ( IdentityConflict, @@ -249,7 +248,7 @@ class LiveResponseOwner(RegistryOwner): @property def recipient_lookup(self) -> str: - return stable_thread_lookup(self.thread.created_at) + return self.thread.incarnation.stable_thread_lookup def require_live(self, snapshot: RegistrySnapshot, fence: OwnerFence) -> None: self.require_snapshot(snapshot, "response owner turn stopped or changed before publication") diff --git a/src/agent_comms/coordination_tables/assignments.py b/src/agent_comms/coordination_tables/assignments.py index 221ffb9842..cbd146e69c 100644 --- a/src/agent_comms/coordination_tables/assignments.py +++ b/src/agent_comms/coordination_tables/assignments.py @@ -44,11 +44,10 @@ def recipient_identity(self): def require_selected_source(self, initial, owner) -> None: from agent_comms.audience_manifest import FrozenRecipient - from agent_comms.bus_publication import stable_thread_lookup from agent_comms.coordination_errors import IdentityConflict if self.recipient_identity != FrozenRecipient( - stable_thread_lookup(owner.created_at), owner.name + owner.incarnation.stable_thread_lookup, owner.name ): raise IdentityConflict("wake frame does not match a committed recipient") self.require_committed_source(initial) diff --git a/src/agent_comms/cursor_owner.py b/src/agent_comms/cursor_owner.py index 9445be9049..151adbf570 100644 --- a/src/agent_comms/cursor_owner.py +++ b/src/agent_comms/cursor_owner.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from .bus_publication import stable_thread_lookup from .coordinated_runtime_schema import assert_native_runtime_schema from .coordination_errors import IdentityConflict, StaleFence from .coordinator import Coordination @@ -39,7 +38,7 @@ def participant_identity(self): @property def lookup(self) -> str: - return stable_thread_lookup(self.thread.created_at) + return self.thread.incarnation.stable_thread_lookup def require_live(self, bus: MessageBus, registry: RegistrySnapshot, reason: str) -> None: if bus.log._private_marker_unlocked().root_id != self.wire_root_id: diff --git a/src/agent_comms/diagnostics.py b/src/agent_comms/diagnostics.py index 30dde51bbb..4da68ea749 100644 --- a/src/agent_comms/diagnostics.py +++ b/src/agent_comms/diagnostics.py @@ -35,11 +35,9 @@ def owner_process_output(root: Path, thread: Thread): capture. Each launch gets its own private file, without changing thread or input disposition state. """ - from .bus_publication import stable_thread_lookup - directory = root / "diagnostics" directory.mkdir(mode=0o700, exist_ok=True) - path = directory / f"owner-{stable_thread_lookup(thread.created_at)}-{uuid4().hex}.log" + path = directory / f"owner-{thread.incarnation.stable_thread_lookup}-{uuid4().hex}.log" descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(descriptor, "w") as output: output.write(f"Owner launch: {thread.name}\n") diff --git a/src/agent_comms/external_transcript.py b/src/agent_comms/external_transcript.py index 9b240a1c74..1ab78443fa 100644 --- a/src/agent_comms/external_transcript.py +++ b/src/agent_comms/external_transcript.py @@ -17,7 +17,6 @@ from pathlib import Path from typing import ClassVar -from .bus_publication import stable_thread_lookup from .native_runtime_input import PublishedReplyRevision from .native_transcript import RecordsCut from .thread_identity import ThreadIncarnation @@ -153,7 +152,7 @@ def source_paths(cls, root: Path) -> tuple[Path, ...]: @property def lookup(self) -> str: - return stable_thread_lookup(self.incarnation.created_at) + return self.incarnation.stable_thread_lookup def finish(self, *, turn_id: str, item_id: str, events: tuple[TranscriptEvent, ...]) -> None: diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index ebf8762f5e..d3a81967f9 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -15,7 +15,6 @@ from .errors import RelationViolationError from .native_input_owner import GoalLaunchOwner from .goal_history import GoalHistoryEntry -from .bus_publication import stable_thread_lookup from .goal_waits import GoalInputReview, GoalWait, GoalWaits from .registration import Registration @@ -141,7 +140,7 @@ def _goal_input_review( snapshot = self.registry.snapshot() from .input_attempt import ReservedInput - lookup = stable_thread_lookup(thread.created_at) + lookup = thread.incarnation.stable_thread_lookup # Original certification supplies both sides of the reply. Display ACKs # and today's sender aliases cannot admit a different incarnation. replies = { diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index dae8ed08ed..9bffd76fed 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -313,7 +313,14 @@ def channel_views( channels, catalog.pinned_members(), catalog.list_order, - self.agents.all_activity(snapshot=snapshot), + self.agents.all_activity( + (thread for thread in snapshot.threads.values() + if ThreadView.visible( + thread, snapshot, show_stopped=show_stopped, + show_archived=show_archived, + )), + snapshot=snapshot, + ), self.last_sent_timestamps(), ChannelActivity.for_views(channels, self.bus.channel_activity()), show_stopped=show_stopped, @@ -538,7 +545,10 @@ def list_threads(self, active_only: bool = False) -> Sequence[Mapping]: for name, thread in snapshot.threads.items() if not active_only or snapshot.statuses[name].active } - activities = self.agents.all_activity(snapshot=snapshot) + activities = self.agents.all_activity( + (thread for name, thread in threads.items() if snapshot.statuses[name].active), + snapshot=snapshot, + ) pending = self.bus.pending_counts_all(tuple(threads)) waits = GoalWaits(self.root / GoalWaits.filename).read() return [ diff --git a/src/agent_comms/input_drain.py b/src/agent_comms/input_drain.py index 5f79ebdb71..abea414e20 100644 --- a/src/agent_comms/input_drain.py +++ b/src/agent_comms/input_drain.py @@ -28,7 +28,6 @@ from .drain_failures import drain_failures from .coordination_cohort import next_sealed_assignment from .coordinator import Coordination -from .bus_publication import stable_thread_lookup from .cursor_owner import CursorOwner from .child_process import join_retirement from .native_input_owner import RegistryOwner @@ -426,7 +425,7 @@ async def _shared_changes_concern_other_owners( self._indexed_changes_for_owner, settled, revision, - stable_thread_lookup(entry.thread.created_at), + entry.thread.incarnation.stable_thread_lookup, )) if marker.admission_after_seq != settled.admission_after_seq or addressed: return None @@ -458,7 +457,7 @@ def _private_observation_revision( """ owner = RegistryOwner(thread=entry.thread, admission_generation=entry.admission_generation) with store.session.read(): - participant = store.participants.get(stable_thread_lookup(owner.thread.created_at)) + participant = store.participants.get(owner.thread.incarnation.stable_thread_lookup) pending = next_sealed_assignment( store, participant.lookup, owner.thread.name, after_seq=wire[-1], ) diff --git a/src/agent_comms/message_bus.py b/src/agent_comms/message_bus.py index 09d31c847e..6487513fe9 100644 --- a/src/agent_comms/message_bus.py +++ b/src/agent_comms/message_bus.py @@ -277,7 +277,6 @@ def awareness_segments( Input preparation may omit this optional supplement on contention; inspection waits in its existing resource worker for the actual read. """ - from .bus_publication import stable_thread_lookup from .errors import RelationViolationError from .private_bus_checkpoint import addressed_source_pointers_unlocked from .context_segments.awareness import AwarenessSegment, UnavailableAwarenessSegment @@ -287,7 +286,7 @@ def awareness_segments( with self.log.certified_read(blocking=blocking) as source: marker = source.marker rows = addressed_source_pointers_unlocked( - self.log, marker, stable_thread_lookup(owner.created_at) + self.log, marker, owner.incarnation.stable_thread_lookup ) except (BlockingIOError, RelationViolationError): # Contention or a checkpoint/source change during the read; any @@ -296,7 +295,7 @@ def awareness_segments( if not rows: return () first = { - stable_thread_lookup(relation.peer.created_at) + relation.peer.incarnation.stable_thread_lookup for relation in relations if relation.relevance.grants(Prioritized) } diff --git a/src/agent_comms/native_input_owner.py b/src/agent_comms/native_input_owner.py index 1948719d8f..7ad260d00d 100644 --- a/src/agent_comms/native_input_owner.py +++ b/src/agent_comms/native_input_owner.py @@ -10,7 +10,6 @@ from dataclasses import dataclass, replace from typing import TYPE_CHECKING, ClassVar -from .bus_publication import stable_thread_lookup from .child_process import ProcessIdentity from .coordination_errors import StaleFence from .coordination_tables.participants import OwnerGenerations @@ -194,7 +193,7 @@ def require(self, store: Coordination, lookup: str) -> None: participant = store.participants.get(lookup) if not participant.committed or participant.owner_identity != self.coordinator_identity(lookup): raise StaleFence("cohort participant generation changed") - if stable_thread_lookup(self.thread.created_at) != lookup: + if self.thread.incarnation.stable_thread_lookup != lookup: raise StaleFence("cohort participant incarnation changed") if self.thread.process_identity != ProcessIdentity.capture(os.getpid()): raise StaleFence("cohort recipient is not this live registered owner generation") diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index 494ac36888..656e683e3b 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -319,10 +319,9 @@ def owner_identity(self) -> OwnerGenerations: def require_recorded_owner(self, thread): """Join original coordinator naming/birth; this grants no live ownership.""" - from .bus_publication import stable_thread_lookup from .coordination_errors import IdentityConflict - expected = OwnerGenerations(owner_lookup=stable_thread_lookup(thread.created_at), owner_thread=thread.name, generation=self.owner_generation) + expected = OwnerGenerations(owner_lookup=thread.incarnation.stable_thread_lookup, owner_thread=thread.name, generation=self.owner_generation) if self.owner_identity != expected: raise IdentityConflict("Native input belongs to another recorded owner") diff --git a/src/agent_comms/native_source_cursor.py b/src/agent_comms/native_source_cursor.py index a750241bc8..95d61716e7 100644 --- a/src/agent_comms/native_source_cursor.py +++ b/src/agent_comms/native_source_cursor.py @@ -11,7 +11,6 @@ from typing import TYPE_CHECKING from functools import partial -from .bus_publication import stable_thread_lookup from .coordinated_runtime_schema import assert_native_runtime_schema from .coordination_errors import IdentityConflict, StaleFence from .coordinator import Coordination @@ -89,7 +88,7 @@ async def refresh_async( @classmethod def _refresh_owned(cls, bus, wire_root_id, owner_name, source_reads, store): owner, admission = bus._registry.live_owner_with_admission(owner_name) - person = store.participants.get(stable_thread_lookup(owner.created_at)) + person = store.participants.get(owner.incarnation.stable_thread_lookup) return cls._advance_owned( bus, wire_root_id, owner, admission, person.participant_generation, None, store, source_reads=source_reads, @@ -229,7 +228,7 @@ def read( db = self.store.session._connection assert_native_runtime_schema(db) person = self.store.participants.get( - stable_thread_lookup(captured.thread.created_at) + captured.thread.incarnation.stable_thread_lookup ) if not person.committed or person.owner_thread != owner_name: raise StaleFence("current native cursor recipient is not committed") diff --git a/src/agent_comms/native_transcript.py b/src/agent_comms/native_transcript.py index 4326a22d4b..c0eff44ca7 100644 --- a/src/agent_comms/native_transcript.py +++ b/src/agent_comms/native_transcript.py @@ -167,13 +167,12 @@ def cut(self) -> SessionCut: return SessionCut(FileRevision.capture(self.path)) def publication_revision(self, root: Path, thread): - from .bus_publication import stable_thread_lookup from .native_runtime_input import NativeRuntimeInput, PublishedReplyRevision if not self.path.is_file(): return PublishedReplyRevision(0, 0) return NativeRuntimeInput.publication_revision( - root, self, stable_thread_lookup(thread.created_at)) + root, self, thread.incarnation.stable_thread_lookup) @contextmanager def projection(self, receipts, routes): diff --git a/src/agent_comms/optional_awareness_projection.py b/src/agent_comms/optional_awareness_projection.py index 5124a413ef..adeb1a7239 100644 --- a/src/agent_comms/optional_awareness_projection.py +++ b/src/agent_comms/optional_awareness_projection.py @@ -32,7 +32,7 @@ from agent_comms.coordination_tables.responses import ResponseObligation from .audience_manifest import FrozenRecipient -from .bus_publication import CommittedDelivery, stable_thread_lookup +from .bus_publication import CommittedDelivery from .child_process import ProcessIdentity from .cohort_schema import ( COHORT_SCHEMA_VERSION, @@ -214,7 +214,7 @@ def _build( if turn is None or turn.admission_generation != self.expected_admission_generation: raise ProjectionUnavailableError("selected owner turn changed") if FrozenRecipient(assignment.recipient_lookup, assignment.recipient) != FrozenRecipient( - stable_thread_lookup(owner.created_at), + owner.incarnation.stable_thread_lookup, owner.name, ): raise ProjectionUnavailableError("selected recipient changed") diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index 02f10eb0cf..9d5f6950b7 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -149,7 +149,6 @@ def remove_threads(self, threads: Sequence[Thread]) -> None: class OwnerLifecycle: def require_restart_idle(self, thread: Thread) -> None: """Both the process and its durable execution must have finished.""" - from .bus_publication import stable_thread_lookup from .coordination_errors import StaleFence from .coordination_session import ReadNativeCoordinationSession from .coordination_tables.executions import CurrentExecutions @@ -163,7 +162,7 @@ def require_restart_idle(self, thread: Thread) -> None: with Coordination(str(path), session_type=ReadNativeCoordinationSession) as store: pointer = CurrentExecutions.one( store.session._connection, - owner_lookup=stable_thread_lookup(thread.created_at), + owner_lookup=thread.incarnation.stable_thread_lookup, ) if pointer is not None: pointer.require_idle() @@ -404,13 +403,12 @@ def _launch_owner_unlocked( agent_bin = str(Path(shutil.which(agent_bin) or agent_bin).resolve(strict=True)) # Explicit launch installs the current runtime before registering # this owner. Readers never create or repair runtime schemas. - from .bus_publication import stable_thread_lookup from .coordinator import Coordination with Coordination(str(self.root / "coordination.sqlite3")) as store: store.install_private_runtime() store.participants.register( - stable_thread_lookup(thread.created_at), + thread.incarnation.stable_thread_lookup, thread.name, thread.name, committed=True, diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index f9597583c1..1586710bef 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -258,7 +258,6 @@ def direct_read_notification( cls, source, snapshot, reads, document, *, owner_lookup ) -> MessageNotification | None: """Project a human DM read even though humans have no wake assignment.""" - from .bus_publication import stable_thread_lookup from .channel_targets import is_channel_target message = source.message @@ -272,7 +271,7 @@ def direct_read_notification( target = snapshot.threads.get(name) if target is None or not reads.human(target.role): return None - recipient = FrozenRecipient(stable_thread_lookup(target.created_at), target.name) + recipient = FrozenRecipient(target.incarnation.stable_thread_lookup, target.name) displayed_to = reads.displayed_recipient( message, recipient, snapshot, document=document ) @@ -331,11 +330,9 @@ def for_sources( assignment_read: NotificationAssignmentRead, ) -> tuple[MessageNotification, ...]: """Project outcomes from the caller's original certified source window.""" - from .bus_publication import stable_thread_lookup - projected = cls.delivery_window( root, registry, sources, - owner_lookup=stable_thread_lookup(owner.created_at), + owner_lookup=owner.incarnation.stable_thread_lookup, assignment_read=assignment_read, ) return tuple( @@ -709,7 +706,7 @@ def roster( runtime = agents.runtime_info.read() waits = goal_waits.read() if activities is None: - activities = agents.all_activity(snapshot=snapshot) + activities = agents.all_activity(threads, snapshot=snapshot) return tuple( cls.capture(thread, snapshot, activities[thread.name], runtime.get(thread.name), waits) for thread in threads @@ -780,7 +777,13 @@ def capture( show_stopped: bool, show_archived: bool, read_marker_notice: str | None = None, ) -> CoordinationSnapshot: """Assemble both rosters from the caller's acquired identity and read cut.""" - activities = agents.all_activity(snapshot=registry) + visible = ( + thread for thread in registry.threads.values() + if ThreadView.visible( + thread, registry, show_stopped=show_stopped, show_archived=show_archived + ) + ) + activities = agents.all_activity(visible, snapshot=registry) return cls( channels=ChannelView.roster( registry, declarations, catalog.pinned_members(), catalog.list_order, diff --git a/src/agent_comms/private_bus_checkpoint.py b/src/agent_comms/private_bus_checkpoint.py index fbad950f68..691e56ae46 100644 --- a/src/agent_comms/private_bus_checkpoint.py +++ b/src/agent_comms/private_bus_checkpoint.py @@ -463,9 +463,7 @@ def retained_task_facts(self, incarnation: ThreadIncarnation) -> Iterator[ExactT now; its decoder may run after publication custody closes. No unrelated record is decoded and no fact is stored in the checkpoint. """ - from .bus_publication import stable_thread_lookup - - lookup = stable_thread_lookup(incarnation.created_at) + lookup = incarnation.stable_thread_lookup originals = self.conversation_sources( lookup, "1", (), limit=-1, ascending=True, ) diff --git a/src/agent_comms/publisher.py b/src/agent_comms/publisher.py index bea4b45cbc..a23eb137c4 100644 --- a/src/agent_comms/publisher.py +++ b/src/agent_comms/publisher.py @@ -20,7 +20,6 @@ CommittedDelivery, HumanOrigin, initial_sideband, - stable_thread_lookup, ) from .channel_targets import BuiltinChannel, is_channel_target from .envelope_claim_transitions import ( @@ -405,10 +404,10 @@ def publish_initial_cohort( and channel.matches(thread.tags) ] selected = [snapshot.threads[name] for name in names] - lookups = [stable_thread_lookup(thread.created_at) for thread in selected] + lookups = [thread.incarnation.stable_thread_lookup for thread in selected] if len(set(lookups)) != len(lookups): raise RelationViolationError("Recipient creation identities collide.") - sender_lookup = stable_thread_lookup(snapshot.threads[sender].created_at) + sender_lookup = snapshot.threads[sender].incarnation.stable_thread_lookup if sender_lookup in lookups: raise RelationViolationError("Sender creation identity collides with recipient.") stored = self._prepare_message_unlocked( diff --git a/src/agent_comms/queued_input.py b/src/agent_comms/queued_input.py index 773a4b1a4f..1b03d45b9a 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -20,7 +20,6 @@ from .goals import Goal from .image_inputs import ImageInput from .assignment_states import DeferredAssignment, DeliveredAssignment -from .bus_publication import stable_thread_lookup from .coordination_cohort import pending_sealed_assignments from .coordination_errors import IdentityConflict, StaleRevision from .input_attempt import ACPInputIdText, InputAttempt, ReservedInput @@ -445,7 +444,7 @@ def _recover_locked( path = str(comms.root / "coordination.sqlite3") with Coordination(path, session_type=ReadNativeCoordinationSession) as store: assignments = store.assignments.recoverable_unbound_deliveries( - stable_thread_lookup(owner.created_at), frozenset(rows), + owner.incarnation.stable_thread_lookup, frozenset(rows), ) if not assignments: return 0 @@ -474,7 +473,7 @@ async def deliver_pending( if inbox is None or session_id not in inputs.original_sources: return store_path = inputs.comms.root / "coordination.sqlite3" - lookup = stable_thread_lookup(owner.created_at) + lookup = owner.incarnation.stable_thread_lookup def read(store: Coordination): with store.session.read(): diff --git a/src/agent_comms/read_ledger.py b/src/agent_comms/read_ledger.py index 877088344f..3ee7db7f1f 100644 --- a/src/agent_comms/read_ledger.py +++ b/src/agent_comms/read_ledger.py @@ -185,11 +185,9 @@ def historical_displayed_recipient(self, message, recipient, snapshot, *, docume ) def _displayed_recipient(self, message, recipient, snapshot, threads, *, document): - from .bus_publication import stable_thread_lookup - for thread in threads: if ( - stable_thread_lookup(thread.created_at) == recipient.recipient_lookup + thread.incarnation.stable_thread_lookup == recipient.recipient_lookup and snapshot.canonical_name(recipient.canonical_thread) == thread.name and message.seq in self.seen_sequences(thread.name, snapshot, document=document) ): diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index e11daf462f..581f8ea1c7 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass, field, replace +from types import MappingProxyType from .child_process import ProcessIdentity from .errors import RelationViolationError, UnregisteredThreadError @@ -531,6 +532,18 @@ class RegistrySnapshot(RegistryPresence, RegistryProvenance): aliases: Mapping[str, str] owner_generations: Mapping[str, int] admission_generations: Mapping[str, int] + _threads_by_lookup: Mapping[str, Thread] = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + # A snapshot is immutable; derive this index once from its original thread + # incarnations instead of hashing every unrelated thread per DM paint. + object.__setattr__(self, "_threads_by_lookup", MappingProxyType({ + thread.incarnation.stable_thread_lookup: thread + for thread in self.threads.values() + })) + + def thread_for_lookup(self, lookup: str) -> Thread | None: + return self._threads_by_lookup.get(lookup) def retired(self, name: str) -> bool: """Removal keeps the name's generations; they are the deletion record.""" diff --git a/src/agent_comms/routing.py b/src/agent_comms/routing.py index 10dcc079d9..f0f6a078e3 100644 --- a/src/agent_comms/routing.py +++ b/src/agent_comms/routing.py @@ -216,11 +216,9 @@ def sender_lookups( The unstamped format matches before a birth is required. An index must probe for an assigned stamp; admission must refuse that unproven stamp. """ - from .bus_publication import stable_thread_lookup - yield "" if source.has_recorded_birth: - yield stable_thread_lookup(source.created_at) + yield source.stable_thread_lookup else: on_unrecorded() diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index 5b1ea011b2..78e6d0a5fa 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -9,7 +9,7 @@ from functools import partial from .agent_events import CompactionEvent, NativePhaseChanged -from .bus_publication import CommittedDelivery, stable_thread_lookup +from .bus_publication import CommittedDelivery from .cohort_schema import assert_cohort_schema from .comms import Comms from .coordinated_runtime_schema import assert_native_runtime_schema @@ -201,7 +201,7 @@ def prepare( raise StaleFence("recipient registry identity stopped or changed") from error owner = RegistryOwner(thread=thread, admission_generation=generation) owner.require_registry(comms.registry) - lookup = stable_thread_lookup(thread.created_at) + lookup = thread.incarnation.stable_thread_lookup sent_owner, sent_sources = MessageNotification.sent_sources( comms.root, comms.registry, comms.bus.log, name, limit=MessageNotification.window_limit, diff --git a/src/agent_comms/selected_write_plan.py b/src/agent_comms/selected_write_plan.py index ba87151ea4..bdb911a9ea 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -25,7 +25,6 @@ from agent_comms.coordination_tables.assignments import WakeAssignment from agent_comms.coordinator import Coordination -from .bus_publication import stable_thread_lookup from .comms import Comms from .coordination_cohort import sealed_cohort_assignments from .envelope_claim_transitions import ExistingFileClaim @@ -202,7 +201,7 @@ def submit( initial = self.comms.bus.log.read_delivery_cohort(self.root_id, source_seq) if initial.message.message_id != source_message_id: raise IdentityConflict("Selected write source identity changed") - lookup = stable_thread_lookup(owner.created_at) + lookup = owner.incarnation.stable_thread_lookup with Coordination(str(self.comms.root / "coordination.sqlite3")) as store: assignments = sealed_cohort_assignments(store, lookup, after_seq=source_seq - 1) selected = [ @@ -251,7 +250,7 @@ def submit( def load( self, assignment: WakeAssignment, owner: Thread, admission_generation: int ) -> PlannedWrite | None: - lookup = stable_thread_lookup(owner.created_at) + lookup = owner.incarnation.stable_thread_lookup path = self._path(assignment.wire_seq, lookup) row = self._read(path) if row is None: @@ -266,7 +265,7 @@ def load( return row.planned(owner.worktree) def applied(self, assignment: WakeAssignment, owner: Thread, operation_id: str) -> None: - path = self._path(assignment.wire_seq, stable_thread_lookup(owner.created_at)) + path = self._path(assignment.wire_seq, owner.incarnation.stable_thread_lookup) row = self._read(path) if row is None: raise IdentityConflict("Selected write intent is missing") diff --git a/src/agent_comms/task_sources.py b/src/agent_comms/task_sources.py index 9ccb6dc02c..0f33c0bae2 100644 --- a/src/agent_comms/task_sources.py +++ b/src/agent_comms/task_sources.py @@ -503,13 +503,11 @@ def require_publication(self, sender, registry, original_source): self.change.require_publication(self, registry, original_source) def require_wording_publication(self, registry, original_source): - from .bus_publication import stable_thread_lookup - delivery, = original_source.references((self.subject,)) delivery.message.sender_role.require_user() - if delivery.audience.sender_lookup != stable_thread_lookup(self.source_user.created_at): + if delivery.audience.sender_lookup != self.source_user.stable_thread_lookup: raise RelationViolationError("USER pin differs from its original human author") - if not delivery.audience.includes_lookup(stable_thread_lookup(self.recipient.created_at)): + if not delivery.audience.includes_lookup(self.recipient.stable_thread_lookup): raise RelationViolationError("USER pin recipient did not receive its original message") def user_task_facts(self, message): diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index f7e191757d..055e429079 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -42,6 +42,13 @@ def __post_init__(self) -> None: if not math.isfinite(FieldCodec.decode(float, self.created_at)): raise ValueError("Thread incarnation requires a finite birth") + @property + def stable_thread_lookup(self) -> str: + """The durable lookup of this recorded incarnation, across renames.""" + from .bus_publication import stable_thread_lookup + + return stable_thread_lookup(self.created_at) + @property def has_recorded_birth(self) -> bool: return self.created_at > 0 diff --git a/src/agent_comms/thread_management.py b/src/agent_comms/thread_management.py index eadde21bc2..5392dff894 100644 --- a/src/agent_comms/thread_management.py +++ b/src/agent_comms/thread_management.py @@ -262,7 +262,6 @@ def restore_stopped(self, source: RegistrySnapshot, names: Sequence[str]) -> tup Repeating this operation repairs an interrupted coordinator registration without replacing existing owners or importing historical delivery state. """ - from .bus_publication import stable_thread_lookup from .coordinator import Coordination selected = tuple(dict.fromkeys(names)) @@ -275,7 +274,7 @@ def restore_stopped(self, source: RegistrySnapshot, names: Sequence[str]) -> tup for name in selected: thread = self.registry.require(name) store.participants.register( - stable_thread_lookup(thread.created_at), + thread.incarnation.stable_thread_lookup, thread.name, thread.name, committed=True, @@ -373,8 +372,6 @@ def _rename_thread( def _rename_thread_unlocked( self, name: str, new_name: str, *, title: str | None = None ) -> RenameThreadResult: - - from .bus_publication import stable_thread_lookup from .coordinator import Coordination before = self.registry.require(name) @@ -411,7 +408,7 @@ def _rename_thread_unlocked( raise ValueError("Private coordinator owner name exceeds its bound.") with Coordination(str(coordinator)) as store: try: - person = store.participants.get(stable_thread_lookup(before.created_at)) + person = store.participants.get(before.incarnation.stable_thread_lookup) except IdentityConflict as error: if str(error) != "participant aggregate is not registered": raise diff --git a/src/agent_comms/thread_presentation.py b/src/agent_comms/thread_presentation.py index c8c400b97b..d4f20ff0ea 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -147,7 +147,6 @@ def same_observation(self, other: ThreadPresentation | None) -> bool: def with_sources(self, window: TranscriptSourceWindow) -> ThreadPresentation: """Incoming and sent outcomes are views of one captured notification window.""" - from .bus_publication import stable_thread_lookup from .notification_assignment import NotificationAssignment from .presentation import MessageNotification @@ -157,7 +156,7 @@ def with_sources(self, window: TranscriptSourceWindow) -> ThreadPresentation: projected = MessageNotification.delivery_window( transcripts.root, transcripts.registry, sources, NotificationAssignment.read(transcripts.root, sources), - owner_lookup=stable_thread_lookup(window.read.identity.thread.created_at), + owner_lookup=window.read.identity.thread.incarnation.stable_thread_lookup, ) incoming, sent = ( tuple(replace(notification, message=source.message) diff --git a/src/agent_comms/transcript_receipts.py b/src/agent_comms/transcript_receipts.py index 1acaf98de0..2318b6ef7f 100644 --- a/src/agent_comms/transcript_receipts.py +++ b/src/agent_comms/transcript_receipts.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from .transcript_events import TranscriptEvent -from .bus_publication import CommittedDelivery, stable_thread_lookup +from .bus_publication import CommittedDelivery from .routing import MessageRoute, TurnRouting from .thread_identity import ThreadIncarnation from .transcript_events import AssistantTranscript, IncomingTranscript, SentTranscript, UserTranscript @@ -300,7 +300,7 @@ def resolve_sent(self, messages): messages = tuple(messages) if not messages: return messages - lookup = stable_thread_lookup(self.recipient.created_at) + lookup = self.recipient.stable_thread_lookup marks = ",".join("?" for _ in messages) originals = { original.message.reference: original.message @@ -332,7 +332,7 @@ def rows( self, predicate="1", parameters=(), *, limit=1, ascending=False, ) -> Iterator[CommittedDelivery]: return self.log.conversation_sources( - stable_thread_lookup(self.recipient.created_at), + self.recipient.stable_thread_lookup, predicate, parameters, limit=limit, @@ -341,7 +341,7 @@ def rows( @property def frontier(self): - lookup = stable_thread_lookup(self.recipient.created_at) + lookup = self.recipient.stable_thread_lookup return AssignedSourceCursor( str(self.root), self.recipient, ConversationSourceWindowRequest.frontier(self.log, lookup), @@ -349,7 +349,7 @@ def frontier(self): def window(self, *, limit=1, sent_limit=0) -> AssignedSourceWindow: """One certified cut owns conversation, sent rows and their frontier.""" - lookup = stable_thread_lookup(self.recipient.created_at) + lookup = self.recipient.stable_thread_lookup conversation_request = ConversationSourceWindowRequest("1", (), limit, False) sent_request = ConversationSourceWindowRequest( "w.sender_lookup=?", (lookup,), sent_limit, False @@ -371,7 +371,7 @@ def native_events(self, record, routes, project_events, references): entry = record.entry published = False routing = routes.get(entry.id) - lookup = stable_thread_lookup(self.recipient.created_at) + lookup = self.recipient.stable_thread_lookup events = super().native_events(record, routes, project_events, references) if entry.final_reply: if routing is not None and routing.publications: @@ -425,7 +425,7 @@ def native_events(self, record, routes, project_events, references): def events(self, original): message = original.message - if original.audience.sender_lookup == stable_thread_lookup(self.recipient.created_at): + if original.audience.sender_lookup == self.recipient.stable_thread_lookup: return ( SentTranscript.from_message(message), ) @@ -451,7 +451,7 @@ def native_records(self, fragments, routes, reader): from .native_runtime_input import NativeRuntimeInput from .transcripts import TranscriptRead - lookup = stable_thread_lookup(self.recipient.created_at) + lookup = self.recipient.stable_thread_lookup for fragment in fragments: fragment = tuple(fragment) selected = tuple(zip(fragment, reader.inputs_for(fragment))) diff --git a/src/agent_comms/transcript_routes.py b/src/agent_comms/transcript_routes.py index 6c1483f7aa..4ed2105854 100644 --- a/src/agent_comms/transcript_routes.py +++ b/src/agent_comms/transcript_routes.py @@ -201,7 +201,6 @@ def record_turn_publication(self, registry, log, lease, checkpoint, routing, pub registry and input owners remain held until durable annotations commit. A partial successful send subset remains published if a later send fails. """ - from .bus_publication import stable_thread_lookup from .input_disposition import InputDispositions from .native_transcript import NativeTranscript from .native_input_owner import RegistryOwner @@ -225,7 +224,7 @@ def record_turn_publication(self, registry, log, lease, checkpoint, routing, pub raise RelationViolationError("Native publication evidence is invalid") from error ids = routing.annotation_ids(reader, checkpoint, str(path), final.end) inputs = InputDispositions(log.path.parent) - lookup = stable_thread_lookup(incarnation.created_at) + lookup = incarnation.stable_thread_lookup with log.certified_read() as source, registry.store.reading() as document: snapshot = document.snapshot() incarnation = lease.identity.incarnation.resolved(snapshot) diff --git a/src/agent_comms/turn_runner.py b/src/agent_comms/turn_runner.py index 542c2242b7..958d912a1b 100644 --- a/src/agent_comms/turn_runner.py +++ b/src/agent_comms/turn_runner.py @@ -296,11 +296,15 @@ async def collect_replies(self, session_id: str, thread_name: str, sent_seq: int def peer_progress(self, thread_name: str, sent_seq: int) -> bool: """True when a peer is active on, or has read, our message.""" - for name, activity in self.comms.agents.all_activity().items(): - if name != thread_name and activity.state.busy: + snapshot = self.comms.registry.snapshot() + running = ( + thread for name, thread in snapshot.threads.items() + if name != thread_name and snapshot.statuses[name].active + ) + for activity in self.comms.agents.all_activity(running, snapshot=snapshot).values(): + if activity.state.busy: return True if sent_seq: - snapshot = self.comms.registry.snapshot() document = self.comms.bus.reads.read() for name in snapshot.threads: if name != thread_name and sent_seq in self.comms.bus.reads.seen_sequences( diff --git a/tests/shared_bus_restart_native.py b/tests/shared_bus_restart_native.py index f21d675dc1..7f4880858f 100644 --- a/tests/shared_bus_restart_native.py +++ b/tests/shared_bus_restart_native.py @@ -1024,7 +1024,10 @@ def hold_cursor_publication(): async with asyncio.timeout(90 if arguments.saved_source else 45): while True: diagnostics = list((service.root/'diagnostics').glob('*.json')) - for activity in service.agents.all_activity().values(): + snapshot = service.registry.snapshot() + for activity in service.agents.all_activity( + snapshot.threads.values(), snapshot=snapshot + ).values(): activity.readiness.require_available() if diagnostics: observed = json.loads(diagnostics[0].read_text()) diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index 0954e7d3d8..36025b6d91 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -156,7 +156,8 @@ def test_concurrent_snapshot_updates_do_not_lose_peers_or_state(self, tmp_path: ) comms = wire(root) assert set(comms.agents.runtime_info.read()) == set(names) - assert set(comms.agents.all_activity()) == set(names) + snapshot = comms.registry.snapshot() + assert set(comms.agents.all_activity(snapshot.threads.values(), snapshot=snapshot)) == set(names) assert {name: comms.ledger.read()[name] for name in names} == { name: "ready" for name in names } diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py new file mode 100644 index 0000000000..798eeb03e4 --- /dev/null +++ b/tests/test_recipient_activity_index.py @@ -0,0 +1,40 @@ +"""Registry snapshots own recipient lookup; activity reads own their requested set.""" + +from unittest.mock import patch + +from agent_comms.activity import Activity, ActivityLog, ActivityState +from agent_comms.agent_activity import UnavailableRecipientActivity +from agent_comms.audience_manifest import FrozenRecipient +from agent_comms.comms import Comms +from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.threads import Thread + + +def test_recipient_index_is_built_once_and_reused_for_small_window(tmp_path): + comms = Comms(tmp_path / "bus") + for index in range(12): + comms.registry.declare(Thread(f"peer-{index}", frozenset(), str(tmp_path), created_at=1_700_000_000.0 + index)) + snapshot = comms.registry.snapshot() + wanted = snapshot.threads["peer-7"] + lookup = wanted.incarnation.stable_thread_lookup + assert snapshot.thread_for_lookup(lookup) is wanted + assert snapshot.thread_for_lookup("not-a-recipient") is None + with patch.object(ThreadIncarnation, "stable_thread_lookup", property( + lambda self: (_ for _ in ()).throw(AssertionError("rehashing unrelated registry")) + )): + observations = comms.agents.observe_recipients( + (FrozenRecipient("not-a-recipient", "gone"), + FrozenRecipient(lookup, wanted.name), FrozenRecipient(lookup, wanted.name)), + snapshot=snapshot, + ) + assert set(observations) == {"not-a-recipient", lookup} + assert isinstance(observations["not-a-recipient"], UnavailableRecipientActivity) + + +def test_activity_log_projects_only_requested_threads(tmp_path): + log = ActivityLog(tmp_path / "activity.jsonl") + for index in range(10): + log.emit(Activity(f"peer-{index}", ActivityState.THINKING, "working")) + assert set(log.all_current(threads=("peer-7",))) == {"peer-7"} + assert log.all_current(threads=()) == {} + assert len(log.all_current()) == 10 From 574f5eb02ee0452630675f17308c0afcad8150f2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 02:57:02 -0400 Subject: [PATCH 02/18] Keep snapshot membership immutable and skip stopped list activity --- src/agent_comms/registry_document.py | 5 +++-- tests/test_recipient_activity_index.py | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 581f8ea1c7..7150124e9b 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -535,8 +535,9 @@ class RegistrySnapshot(RegistryPresence, RegistryProvenance): _threads_by_lookup: Mapping[str, Thread] = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: - # A snapshot is immutable; derive this index once from its original thread - # incarnations instead of hashing every unrelated thread per DM paint. + # RegistryDocument.snapshot supplies the detached mapping. Neither its + # membership nor the derived index can change after this read cut. + object.__setattr__(self, "threads", MappingProxyType(self.threads)) object.__setattr__(self, "_threads_by_lookup", MappingProxyType({ thread.incarnation.stable_thread_lookup: thread for thread in self.threads.values() diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 798eeb03e4..7453630e80 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from agent_comms.activity import Activity, ActivityLog, ActivityState from agent_comms.agent_activity import UnavailableRecipientActivity from agent_comms.audience_manifest import FrozenRecipient @@ -16,6 +18,8 @@ def test_recipient_index_is_built_once_and_reused_for_small_window(tmp_path): comms.registry.declare(Thread(f"peer-{index}", frozenset(), str(tmp_path), created_at=1_700_000_000.0 + index)) snapshot = comms.registry.snapshot() wanted = snapshot.threads["peer-7"] + with pytest.raises(TypeError): + snapshot.threads["peer-7"] = snapshot.threads["peer-0"] lookup = wanted.incarnation.stable_thread_lookup assert snapshot.thread_for_lookup(lookup) is wanted assert snapshot.thread_for_lookup("not-a-recipient") is None From 0c666a19790922b0982cb5c51c05e2b6b547b0da Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 03:00:31 -0400 Subject: [PATCH 03/18] Preserve snapshot wire encoding while indexing lookups --- src/agent_comms/registry_document.py | 5 ++--- tests/test_recipient_activity_index.py | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 7150124e9b..b3b85d2716 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -535,9 +535,8 @@ class RegistrySnapshot(RegistryPresence, RegistryProvenance): _threads_by_lookup: Mapping[str, Thread] = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: - # RegistryDocument.snapshot supplies the detached mapping. Neither its - # membership nor the derived index can change after this read cut. - object.__setattr__(self, "threads", MappingProxyType(self.threads)) + # RegistryDocument.snapshot supplies detached membership. Derive the + # immutable lookup projection once; the codec still owns the wire dict. object.__setattr__(self, "_threads_by_lookup", MappingProxyType({ thread.incarnation.stable_thread_lookup: thread for thread in self.threads.values() diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 7453630e80..0e30136581 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -2,12 +2,11 @@ from unittest.mock import patch -import pytest - from agent_comms.activity import Activity, ActivityLog, ActivityState from agent_comms.agent_activity import UnavailableRecipientActivity from agent_comms.audience_manifest import FrozenRecipient from agent_comms.comms import Comms +from agent_comms.field_codec import FieldCodec from agent_comms.thread_identity import ThreadIncarnation from agent_comms.threads import Thread @@ -17,9 +16,8 @@ def test_recipient_index_is_built_once_and_reused_for_small_window(tmp_path): for index in range(12): comms.registry.declare(Thread(f"peer-{index}", frozenset(), str(tmp_path), created_at=1_700_000_000.0 + index)) snapshot = comms.registry.snapshot() + assert FieldCodec.encode(snapshot)["threads"] wanted = snapshot.threads["peer-7"] - with pytest.raises(TypeError): - snapshot.threads["peer-7"] = snapshot.threads["peer-0"] lookup = wanted.incarnation.stable_thread_lookup assert snapshot.thread_for_lookup(lookup) is wanted assert snapshot.thread_for_lookup("not-a-recipient") is None From 80feaeece9133eeb8ef5d5f999c8fe050caa4f48 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 11:18:14 -0400 Subject: [PATCH 04/18] Own creation lookup in cached thread incarnations and registry snapshots --- src/agent_comms/acp.py | 4 +- src/agent_comms/agent_activity.py | 2 +- src/agent_comms/attempt_recovery.py | 2 +- src/agent_comms/bus_publication.py | 15 ----- src/agent_comms/claim_admission.py | 2 +- src/agent_comms/cohort_foreground.py | 2 +- src/agent_comms/coordination_response.py | 2 +- .../coordination_tables/assignments.py | 2 +- src/agent_comms/cursor_owner.py | 2 +- src/agent_comms/diagnostics.py | 2 +- src/agent_comms/external_transcript.py | 2 +- src/agent_comms/goal_management.py | 2 +- src/agent_comms/goal_presentation.py | 11 ++-- src/agent_comms/goal_waits.py | 15 +++-- src/agent_comms/input_drain.py | 4 +- src/agent_comms/message_bus.py | 4 +- src/agent_comms/native_input_owner.py | 2 +- src/agent_comms/native_input_record.py | 2 +- src/agent_comms/native_source_cursor.py | 4 +- src/agent_comms/native_transcript.py | 2 +- .../optional_awareness_projection.py | 2 +- src/agent_comms/owner_lifecycle.py | 4 +- src/agent_comms/presentation.py | 4 +- src/agent_comms/private_bus_checkpoint.py | 2 +- src/agent_comms/publisher.py | 8 +-- src/agent_comms/queued_input.py | 4 +- src/agent_comms/read_basis.py | 6 +- src/agent_comms/read_ledger.py | 23 ++++---- src/agent_comms/registry_document.py | 56 +++++++++++++------ src/agent_comms/routing.py | 2 +- src/agent_comms/selected_participant.py | 2 +- src/agent_comms/selected_write_plan.py | 6 +- src/agent_comms/task_sources.py | 4 +- src/agent_comms/thread_identity.py | 35 +++++++++--- src/agent_comms/thread_management.py | 4 +- src/agent_comms/thread_presentation.py | 2 +- src/agent_comms/thread_provenance.py | 13 ++++- src/agent_comms/transcript_receipts.py | 14 ++--- src/agent_comms/transcript_routes.py | 2 +- tests/native_context_reader_journey.py | 4 +- ...ative_evidence_reader_installed_journey.py | 4 +- tests/reply_relevance_installed_journey.py | 4 +- tests/shared_bus_restart_native.py | 10 ++-- tests/test_acp_channel_disposition.py | 10 ++-- tests/test_acp_input_disposition.py | 10 ++-- tests/test_acp_private_nk_delivery.py | 18 +++--- tests/test_acp_selected_write_plan.py | 16 +++--- tests/test_candidate_maintenance.py | 10 ++-- .../test_channel_participant_registration.py | 4 +- tests/test_checkpoint_seals.py | 7 ++- tests/test_claim_admission_baseline.py | 8 +-- tests/test_claim_admission_verifier.py | 6 +- tests/test_claim_bus_read.py | 4 +- tests/test_cohort_foreground.py | 6 +- tests/test_concurrency.py | 6 +- tests/test_coordinated_runtime.py | 50 ++++++++--------- tests/test_coordination_cohort.py | 5 +- tests/test_cursor_load_recovery.py | 4 +- tests/test_goal_wait_certified_reply.py | 6 +- tests/test_historical_views.py | 4 +- tests/test_interrupted_delivery_recovery.py | 10 ++-- tests/test_keyed_response_delivery.py | 6 +- tests/test_maintenance_barrier.py | 11 ++-- tests/test_native_channel_reply_roundtrip.py | 6 +- tests/test_native_prompt_binding.py | 36 ++++++------ tests/test_native_send_admission.py | 12 ++-- .../test_native_source_cursor_certificate.py | 4 +- tests/test_native_source_cursor_scale.py | 12 ++-- tests/test_native_transcript.py | 4 +- tests/test_optional_awareness_projection.py | 9 +-- tests/test_ordinary_nk_delivery.py | 7 ++- tests/test_outbound_transcript_source.py | 17 +++--- tests/test_private_bus_checkpoint.py | 40 ++++++------- ...t_private_checkpoint_cursor_integration.py | 4 +- tests/test_private_human_ingress.py | 5 +- tests/test_private_idle_drain.py | 12 ++-- tests/test_private_nk_entrypoint.py | 4 +- tests/test_project_resume.py | 4 +- tests/test_recipient_activity_index.py | 50 +++++++++++++++-- tests/test_retained_native_tool_latency.py | 4 +- tests/test_roster_restoration.py | 8 +-- tests/test_selected_answer_forms.py | 4 +- tests/test_selected_execution_native.py | 6 +- tests/test_selected_source_batch.py | 8 +-- tests/test_selected_tool_runner_hook.py | 4 +- tests/test_session_components.py | 6 +- tests/test_turn_context_wire.py | 6 +- tests/test_wake_candidate_index.py | 4 +- .../test_wake_candidate_index_incremental.py | 4 +- tests/wire_row_application_journey.py | 4 +- .../thread_retirement_installed_pilot.py | 3 +- 91 files changed, 432 insertions(+), 354 deletions(-) diff --git a/src/agent_comms/acp.py b/src/agent_comms/acp.py index ab347ae733..628b45d57a 100644 --- a/src/agent_comms/acp.py +++ b/src/agent_comms/acp.py @@ -419,7 +419,7 @@ def read_admission(): ), ) await _accept_visible_deliveries( - bus, wire_root_id, store_path, owner.incarnation.stable_thread_lookup, 0, + bus, wire_root_id, store_path, owner.incarnation.lookup, 0, owner_name=owner.name, acquire_package=execution.validate, ) if self.inputs.owns_execution(session_id, owner): @@ -432,7 +432,7 @@ def read_admission(): return 0 def select(store): with store.session.read(): - participant = store.participants.get(owner.incarnation.stable_thread_lookup) + participant = store.participants.get(owner.incarnation.lookup) candidate = next_sealed_assignment( store, participant.lookup, owner.name, after_seq=admission_after_seq, ) diff --git a/src/agent_comms/agent_activity.py b/src/agent_comms/agent_activity.py index 3c9b89671a..e8f2290eb3 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -148,7 +148,7 @@ def observe_recipients(self, recipients: Iterable[FrozenRecipient], *, snapshot: lookup = recipient.recipient_lookup if lookup in observations: continue - thread = snapshot.thread_for_lookup(lookup) + thread = snapshot.by_lookup.get(lookup) observations[lookup] = ( thread.execution.observe_recipient(self, snapshot, thread) if thread is not None else UnavailableRecipientActivity() diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index 0c826f7e00..02e97a99e1 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -231,7 +231,7 @@ def recover_owner(cls, root: Path, owner) -> None: return with Coordination(str(path)) as store: pointer = CurrentExecutions.one( - store.session._connection, owner_lookup=owner.incarnation.stable_thread_lookup + store.session._connection, owner_lookup=owner.incarnation.lookup ) if pointer is not None and pointer.unresolved: cls.recover_released_native_attempt(store, pointer.execution_id) diff --git a/src/agent_comms/bus_publication.py b/src/agent_comms/bus_publication.py index 95bc4634ed..51cf2a4e9c 100644 --- a/src/agent_comms/bus_publication.py +++ b/src/agent_comms/bus_publication.py @@ -87,21 +87,6 @@ def reject_private_wire_fields(record: Mapping[str, object]) -> None: raise ValueError("Bus-private wire fields cannot be exported.") -def stable_thread_lookup(created_at: float) -> str: - """Immutable creation identity; duplicate timestamps fail at capture. - - The registry preserves Thread.created_at across renames and restarts. Its - canonical name, aliases, mutable tags, PID, and session attachment are NOT - lookup identities. An unknown creation time (zero) cannot authorize delivery. - """ - if isinstance(created_at, bool) or not isinstance(created_at, (float, int)): - raise ValueError("Thread creation identity is invalid.") - if not math.isfinite(created_at) or created_at <= 0: - raise ValueError("Thread creation identity is unavailable.") - identity = f"agent-comms:thread-creation:v1\0{float(created_at)!r}".encode() - return hashlib.sha256(identity).hexdigest()[:32] - - def _decision_wire(decision: WakeDecision | NoWakeDecision) -> dict[str, str]: from .wake import NoWakeDecision, WakeDecision diff --git a/src/agent_comms/claim_admission.py b/src/agent_comms/claim_admission.py index bac1687470..4afb1e91d2 100644 --- a/src/agent_comms/claim_admission.py +++ b/src/agent_comms/claim_admission.py @@ -79,7 +79,7 @@ def _verify_selected_wake_state( raise IdentityConflict("Wake source or owner turn does not match") from error if initial.message.reference != admission.source: raise IdentityConflict("Wake source message changed") - if owner.incarnation.stable_thread_lookup != admission.recipient_lookup: + if owner.incarnation.lookup != admission.recipient_lookup: raise IdentityConflict("Wake recipient incarnation changed") if generation != admission.owner_admission_generation or turn.id != admission.turn_id: raise IdentityConflict("Wake source or owner turn does not match") diff --git a/src/agent_comms/cohort_foreground.py b/src/agent_comms/cohort_foreground.py index 4af5d15485..22cef66a95 100644 --- a/src/agent_comms/cohort_foreground.py +++ b/src/agent_comms/cohort_foreground.py @@ -196,7 +196,7 @@ def admit(resources): comms.registry.register(thread) with Coordination(str(root / "coordination.sqlite3")) as store: store.install_private_runtime() - lookup = comms.registry.require(name).incarnation.stable_thread_lookup + lookup = comms.registry.require(name).incarnation.lookup store.participants.register(lookup, name, name, committed=True) return thread, lookup, action diff --git a/src/agent_comms/coordination_response.py b/src/agent_comms/coordination_response.py index 4bc0712e0b..14ba2bf630 100644 --- a/src/agent_comms/coordination_response.py +++ b/src/agent_comms/coordination_response.py @@ -248,7 +248,7 @@ class LiveResponseOwner(RegistryOwner): @property def recipient_lookup(self) -> str: - return self.thread.incarnation.stable_thread_lookup + return self.thread.incarnation.lookup def require_live(self, snapshot: RegistrySnapshot, fence: OwnerFence) -> None: self.require_snapshot(snapshot, "response owner turn stopped or changed before publication") diff --git a/src/agent_comms/coordination_tables/assignments.py b/src/agent_comms/coordination_tables/assignments.py index 7473444b74..fa4eeba804 100644 --- a/src/agent_comms/coordination_tables/assignments.py +++ b/src/agent_comms/coordination_tables/assignments.py @@ -47,7 +47,7 @@ def require_selected_source(self, initial, owner) -> None: from agent_comms.coordination_errors import IdentityConflict if self.recipient_identity != FrozenRecipient( - owner.incarnation.stable_thread_lookup, owner.name + owner.incarnation.lookup, owner.name ): raise IdentityConflict("wake frame does not match a committed recipient") self.require_committed_source(initial) diff --git a/src/agent_comms/cursor_owner.py b/src/agent_comms/cursor_owner.py index 151adbf570..8288ea4398 100644 --- a/src/agent_comms/cursor_owner.py +++ b/src/agent_comms/cursor_owner.py @@ -38,7 +38,7 @@ def participant_identity(self): @property def lookup(self) -> str: - return self.thread.incarnation.stable_thread_lookup + return self.thread.incarnation.lookup def require_live(self, bus: MessageBus, registry: RegistrySnapshot, reason: str) -> None: if bus.log._private_marker_unlocked().root_id != self.wire_root_id: diff --git a/src/agent_comms/diagnostics.py b/src/agent_comms/diagnostics.py index 4da68ea749..6116ef7388 100644 --- a/src/agent_comms/diagnostics.py +++ b/src/agent_comms/diagnostics.py @@ -37,7 +37,7 @@ def owner_process_output(root: Path, thread: Thread): """ directory = root / "diagnostics" directory.mkdir(mode=0o700, exist_ok=True) - path = directory / f"owner-{thread.incarnation.stable_thread_lookup}-{uuid4().hex}.log" + path = directory / f"owner-{thread.incarnation.lookup}-{uuid4().hex}.log" descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(descriptor, "w") as output: output.write(f"Owner launch: {thread.name}\n") diff --git a/src/agent_comms/external_transcript.py b/src/agent_comms/external_transcript.py index f982818bcb..4de5258940 100644 --- a/src/agent_comms/external_transcript.py +++ b/src/agent_comms/external_transcript.py @@ -152,7 +152,7 @@ def source_paths(cls, root: Path) -> tuple[Path, ...]: @property def lookup(self) -> str: - return self.incarnation.stable_thread_lookup + return self.incarnation.lookup def finish(self, *, turn_id: str, item_id: str, events: tuple[TranscriptEvent, ...]) -> None: diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index d3a81967f9..06bc85e260 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -140,7 +140,7 @@ def _goal_input_review( snapshot = self.registry.snapshot() from .input_attempt import ReservedInput - lookup = thread.incarnation.stable_thread_lookup + lookup = thread.incarnation.lookup # Original certification supplies both sides of the reply. Display ACKs # and today's sender aliases cannot admit a different incarnation. replies = { diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index e98007503f..9dac99200d 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum from typing import TYPE_CHECKING @@ -18,6 +18,7 @@ UnrecordedBlockGoal, ) from .goals import Goal +from .thread_identity import ThreadIncarnation from .thread_presentation import ThreadPresentation if TYPE_CHECKING: @@ -86,12 +87,14 @@ def for_domain_state(cls, state: GoalState) -> GoalExecutionState: class GoalWaitTarget: name: str created_at: float + _incarnation: ThreadIncarnation = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "_incarnation", ThreadIncarnation(self.name, self.created_at)) def sent(self, original: CommittedDelivery) -> bool: """Only this declared incarnation can supply a dependency reply.""" - from .bus_publication import stable_thread_lookup - - return original.audience.sender_lookup == stable_thread_lookup(self.created_at) + return original.audience.sender_lookup == self._incarnation.lookup @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index 690c2aa96e..0646b6889c 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -4,18 +4,17 @@ import math from collections.abc import Sequence -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, ClassVar -from .bus_publication import CommittedDelivery, stable_thread_lookup - +from .bus_publication import CommittedDelivery from .goal_presentation import GoalExecution, GoalExecutionState, GoalWaitTarget from .goals import Goal from .input_attempt import InputAttempt from .locked_store import LockedStore from .registry_document import RegistrySnapshot +from .thread_identity import ThreadIncarnation from .thread_owned_state import ThreadOwnedState - from .wire_log import WireLog if TYPE_CHECKING: @@ -30,6 +29,7 @@ class GoalWait: after_seq: int targets: tuple[GoalWaitTarget, ...] owner_created_at: float + _owner_lookup: str = field(init=False, repr=False, compare=False) # An idle dependency has no turn generation at the moment of declaration. target_turn_generations: tuple[int | None, ...] # Runtime declarations outside a turn explicitly carry no reporting turn. @@ -48,6 +48,9 @@ def __post_init__(self) -> None: ) ): raise ValueError("A wait requires its owner incarnation and aligned target turns.") + object.__setattr__( + self, "_owner_lookup", ThreadIncarnation._lookup_for_birth(self.owner_created_at) + ) if (self.report_turn_id is None) != (self.report_turn_generation is None) or ( self.report_turn_id is not None and ( @@ -73,7 +76,7 @@ def matches(self, original: CommittedDelivery) -> bool: """Join original sender and addressed owner through the certified source.""" return ( original.message.seq > self.after_seq - and original.direct_for(stable_thread_lookup(self.owner_created_at)) + and original.direct_for(self._owner_lookup) and any(target.sent(original) for target in self.targets) ) @@ -81,7 +84,7 @@ def has_reply(self, bus: WireLog) -> bool: return any( self.matches(original) for original in bus.addressed_sources( - stable_thread_lookup(self.owner_created_at), self.after_seq + self._owner_lookup, self.after_seq ) ) diff --git a/src/agent_comms/input_drain.py b/src/agent_comms/input_drain.py index abea414e20..181d9e637d 100644 --- a/src/agent_comms/input_drain.py +++ b/src/agent_comms/input_drain.py @@ -425,7 +425,7 @@ async def _shared_changes_concern_other_owners( self._indexed_changes_for_owner, settled, revision, - entry.thread.incarnation.stable_thread_lookup, + entry.thread.incarnation.lookup, )) if marker.admission_after_seq != settled.admission_after_seq or addressed: return None @@ -457,7 +457,7 @@ def _private_observation_revision( """ owner = RegistryOwner(thread=entry.thread, admission_generation=entry.admission_generation) with store.session.read(): - participant = store.participants.get(owner.thread.incarnation.stable_thread_lookup) + participant = store.participants.get(owner.thread.incarnation.lookup) pending = next_sealed_assignment( store, participant.lookup, owner.thread.name, after_seq=wire[-1], ) diff --git a/src/agent_comms/message_bus.py b/src/agent_comms/message_bus.py index 6487513fe9..91ee1210a6 100644 --- a/src/agent_comms/message_bus.py +++ b/src/agent_comms/message_bus.py @@ -286,7 +286,7 @@ def awareness_segments( with self.log.certified_read(blocking=blocking) as source: marker = source.marker rows = addressed_source_pointers_unlocked( - self.log, marker, owner.incarnation.stable_thread_lookup + self.log, marker, owner.incarnation.lookup ) except (BlockingIOError, RelationViolationError): # Contention or a checkpoint/source change during the read; any @@ -295,7 +295,7 @@ def awareness_segments( if not rows: return () first = { - relation.peer.incarnation.stable_thread_lookup + relation.peer.incarnation.lookup for relation in relations if relation.relevance.grants(Prioritized) } diff --git a/src/agent_comms/native_input_owner.py b/src/agent_comms/native_input_owner.py index 7ad260d00d..1ae2c55b3e 100644 --- a/src/agent_comms/native_input_owner.py +++ b/src/agent_comms/native_input_owner.py @@ -193,7 +193,7 @@ def require(self, store: Coordination, lookup: str) -> None: participant = store.participants.get(lookup) if not participant.committed or participant.owner_identity != self.coordinator_identity(lookup): raise StaleFence("cohort participant generation changed") - if self.thread.incarnation.stable_thread_lookup != lookup: + if self.thread.incarnation.lookup != lookup: raise StaleFence("cohort participant incarnation changed") if self.thread.process_identity != ProcessIdentity.capture(os.getpid()): raise StaleFence("cohort recipient is not this live registered owner generation") diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index 656e683e3b..00e90f0356 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -321,7 +321,7 @@ def require_recorded_owner(self, thread): """Join original coordinator naming/birth; this grants no live ownership.""" from .coordination_errors import IdentityConflict - expected = OwnerGenerations(owner_lookup=thread.incarnation.stable_thread_lookup, owner_thread=thread.name, generation=self.owner_generation) + expected = OwnerGenerations(owner_lookup=thread.incarnation.lookup, owner_thread=thread.name, generation=self.owner_generation) if self.owner_identity != expected: raise IdentityConflict("Native input belongs to another recorded owner") diff --git a/src/agent_comms/native_source_cursor.py b/src/agent_comms/native_source_cursor.py index 95d61716e7..2cd3c92ed8 100644 --- a/src/agent_comms/native_source_cursor.py +++ b/src/agent_comms/native_source_cursor.py @@ -88,7 +88,7 @@ async def refresh_async( @classmethod def _refresh_owned(cls, bus, wire_root_id, owner_name, source_reads, store): owner, admission = bus._registry.live_owner_with_admission(owner_name) - person = store.participants.get(owner.incarnation.stable_thread_lookup) + person = store.participants.get(owner.incarnation.lookup) return cls._advance_owned( bus, wire_root_id, owner, admission, person.participant_generation, None, store, source_reads=source_reads, @@ -228,7 +228,7 @@ def read( db = self.store.session._connection assert_native_runtime_schema(db) person = self.store.participants.get( - captured.thread.incarnation.stable_thread_lookup + captured.thread.incarnation.lookup ) if not person.committed or person.owner_thread != owner_name: raise StaleFence("current native cursor recipient is not committed") diff --git a/src/agent_comms/native_transcript.py b/src/agent_comms/native_transcript.py index c0eff44ca7..be6f55101e 100644 --- a/src/agent_comms/native_transcript.py +++ b/src/agent_comms/native_transcript.py @@ -172,7 +172,7 @@ def publication_revision(self, root: Path, thread): if not self.path.is_file(): return PublishedReplyRevision(0, 0) return NativeRuntimeInput.publication_revision( - root, self, thread.incarnation.stable_thread_lookup) + root, self, thread.incarnation.lookup) @contextmanager def projection(self, receipts, routes): diff --git a/src/agent_comms/optional_awareness_projection.py b/src/agent_comms/optional_awareness_projection.py index adeb1a7239..e6a8659a32 100644 --- a/src/agent_comms/optional_awareness_projection.py +++ b/src/agent_comms/optional_awareness_projection.py @@ -214,7 +214,7 @@ def _build( if turn is None or turn.admission_generation != self.expected_admission_generation: raise ProjectionUnavailableError("selected owner turn changed") if FrozenRecipient(assignment.recipient_lookup, assignment.recipient) != FrozenRecipient( - owner.incarnation.stable_thread_lookup, + owner.incarnation.lookup, owner.name, ): raise ProjectionUnavailableError("selected recipient changed") diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index 9d5f6950b7..37f7b572c9 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -162,7 +162,7 @@ def require_restart_idle(self, thread: Thread) -> None: with Coordination(str(path), session_type=ReadNativeCoordinationSession) as store: pointer = CurrentExecutions.one( store.session._connection, - owner_lookup=thread.incarnation.stable_thread_lookup, + owner_lookup=thread.incarnation.lookup, ) if pointer is not None: pointer.require_idle() @@ -408,7 +408,7 @@ def _launch_owner_unlocked( with Coordination(str(self.root / "coordination.sqlite3")) as store: store.install_private_runtime() store.participants.register( - thread.incarnation.stable_thread_lookup, + thread.incarnation.lookup, thread.name, thread.name, committed=True, diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 9fffed2242..2fc956092c 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -271,7 +271,7 @@ def direct_read_notification( target = snapshot.threads.get(name) if target is None or not reads.human(target.role): return None - recipient = FrozenRecipient(target.incarnation.stable_thread_lookup, target.name) + recipient = FrozenRecipient(target.incarnation.lookup, target.name) displayed_to = reads.displayed_recipient( message, recipient, snapshot, document=document ) @@ -332,7 +332,7 @@ def for_sources( """Project outcomes from the caller's original certified source window.""" projected = cls.delivery_window( root, registry, sources, - owner_lookup=owner.incarnation.stable_thread_lookup, + owner_lookup=owner.incarnation.lookup, assignment_read=assignment_read, ) return tuple( diff --git a/src/agent_comms/private_bus_checkpoint.py b/src/agent_comms/private_bus_checkpoint.py index 66fe50f110..72c12061b6 100644 --- a/src/agent_comms/private_bus_checkpoint.py +++ b/src/agent_comms/private_bus_checkpoint.py @@ -496,7 +496,7 @@ def retained_task_facts(self, incarnation: ThreadIncarnation) -> Iterator[ExactT now; its decoder may run after publication custody closes. No unrelated record is decoded and no fact is stored in the checkpoint. """ - lookup = incarnation.stable_thread_lookup + lookup = incarnation.lookup originals = self.conversation_sources( lookup, "1", (), limit=-1, ascending=True, ) diff --git a/src/agent_comms/publisher.py b/src/agent_comms/publisher.py index 6d197970ba..b506067cdc 100644 --- a/src/agent_comms/publisher.py +++ b/src/agent_comms/publisher.py @@ -393,12 +393,8 @@ def publish_initial_cohort( and channel.matches(thread.tags) ] selected = [snapshot.threads[name] for name in names] - lookups = [thread.incarnation.stable_thread_lookup for thread in selected] - if len(set(lookups)) != len(lookups): - raise RelationViolationError("Recipient creation identities collide.") - sender_lookup = snapshot.threads[sender].incarnation.stable_thread_lookup - if sender_lookup in lookups: - raise RelationViolationError("Sender creation identity collides with recipient.") + lookups = [thread.incarnation.lookup for thread in selected] + sender_lookup = snapshot.threads[sender].incarnation.lookup stored = self._prepare_message_unlocked( message, sender=sender, diff --git a/src/agent_comms/queued_input.py b/src/agent_comms/queued_input.py index 1b03d45b9a..e1ce21d9b3 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -444,7 +444,7 @@ def _recover_locked( path = str(comms.root / "coordination.sqlite3") with Coordination(path, session_type=ReadNativeCoordinationSession) as store: assignments = store.assignments.recoverable_unbound_deliveries( - owner.incarnation.stable_thread_lookup, frozenset(rows), + owner.incarnation.lookup, frozenset(rows), ) if not assignments: return 0 @@ -473,7 +473,7 @@ async def deliver_pending( if inbox is None or session_id not in inputs.original_sources: return store_path = inputs.comms.root / "coordination.sqlite3" - lookup = owner.incarnation.stable_thread_lookup + lookup = owner.incarnation.lookup def read(store: Coordination): with store.session.read(): diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index daf1b8441f..e08eb2598f 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -9,7 +9,7 @@ from abc import ABC, abstractmethod from collections.abc import Collection -from dataclasses import astuple, dataclass, field, replace +from dataclasses import dataclass, field, replace from pathlib import Path from typing import TYPE_CHECKING @@ -40,7 +40,9 @@ def current(self, snapshot: RegistryProvenance) -> bool: def to_wire(self) -> dict: """Encode the persisted participant pair format.""" data = FieldCodec.encode(replace(self, participants=())) - data["participants"] = FieldCodec.encode(tuple(astuple(p) for p in self.participants)) + data["participants"] = FieldCodec.encode( + tuple((p.name, p.created_at) for p in self.participants) + ) return data @classmethod diff --git a/src/agent_comms/read_ledger.py b/src/agent_comms/read_ledger.py index 3ee7db7f1f..ac6d3b9b2f 100644 --- a/src/agent_comms/read_ledger.py +++ b/src/agent_comms/read_ledger.py @@ -166,9 +166,12 @@ def displayed_recipient(self, message, recipient, snapshot, *, document=None): Processing, assignment acceptance and a reply cannot grant this fact. A reused name with a different creation identity cannot inherit it. """ + thread = snapshot.thread_for_recipient( + recipient.recipient_lookup, recipient.canonical_thread + ) return self._displayed_recipient( message, recipient, snapshot, - (thread for thread in snapshot.threads.values() if self.human(thread.role)), + thread if thread is not None and self.human(thread.role) else None, document=document, ) @@ -181,17 +184,17 @@ def historical_displayed_recipient(self, message, recipient, snapshot, *, docume different frozen recipient or replace the recorded namespace. """ return self._displayed_recipient( - message, recipient, snapshot, snapshot.threads.values(), document=document + message, recipient, snapshot, + snapshot.thread_for_recipient(recipient.recipient_lookup, recipient.canonical_thread), + document=document, ) - def _displayed_recipient(self, message, recipient, snapshot, threads, *, document): - for thread in threads: - if ( - thread.incarnation.stable_thread_lookup == recipient.recipient_lookup - and snapshot.canonical_name(recipient.canonical_thread) == thread.name - and message.seq in self.seen_sequences(thread.name, snapshot, document=document) - ): - return (thread.incarnation,) + def _displayed_recipient(self, message, recipient, snapshot, thread, *, document): + if ( + thread is not None + and message.seq in self.seen_sequences(thread.name, snapshot, document=document) + ): + return (thread.incarnation,) return () def remove_threads(self, threads: Sequence[Thread]) -> None: diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index b3b85d2716..324bd80973 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -4,21 +4,27 @@ import math import time -from typing import TYPE_CHECKING from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass, field, replace from types import MappingProxyType +from typing import TYPE_CHECKING from .child_process import ProcessIdentity from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import FieldCodec -from .registration_change import InitialRegistration, NativeSourcePublication, RegistrationChange, UpdatedRegistration -from .routing import TurnRouting +from .registration_change import ( + InitialRegistration, + NativeSourcePublication, + RegistrationChange, + UpdatedRegistration, +) +from .registry_provenance import RegistryNames, RegistryProvenance from .restart_refusals import ( OwnerBusyRefusal, OwnerChangedBeforeFenceRefusal, OwnerGenerationChangedRefusal, ) +from .routing import TurnRouting from .thread_identity import AdmissionIdentity, GenerationCounter, OwnerIdentity from .thread_presentation import ThreadOwnerBinding from .thread_status import ( @@ -27,7 +33,6 @@ ThreadStatus, ) from .threads import Thread -from .registry_provenance import RegistryNames, RegistryProvenance from .turn_lease import ActiveTurn, FinishedTurnFence, TurnLeaseFence, TurnState from .turn_phase import TurnPhase @@ -532,18 +537,38 @@ class RegistrySnapshot(RegistryPresence, RegistryProvenance): aliases: Mapping[str, str] owner_generations: Mapping[str, int] admission_generations: Mapping[str, int] - _threads_by_lookup: Mapping[str, Thread] = field(init=False, repr=False, compare=False) + by_lookup: Mapping[str, Thread] = field(init=False, repr=False, compare=False) + _ambiguous_lookups: frozenset[str] = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: - # RegistryDocument.snapshot supplies detached membership. Derive the - # immutable lookup projection once; the codec still owns the wire dict. - object.__setattr__(self, "_threads_by_lookup", MappingProxyType({ - thread.incarnation.stable_thread_lookup: thread - for thread in self.threads.values() - })) + # An archive may retain two names with the same birth. Neither may be + # selected by lookup alone; live publication refuses that snapshot. + indexed: dict[str, Thread] = {} + ambiguous: set[str] = set() + for thread in self.threads.values(): + lookup = thread.incarnation.lookup + if lookup in indexed: + del indexed[lookup] + ambiguous.add(lookup) + elif lookup not in ambiguous: + indexed[lookup] = thread + object.__setattr__(self, "by_lookup", MappingProxyType(indexed)) + object.__setattr__(self, "_ambiguous_lookups", frozenset(ambiguous)) - def thread_for_lookup(self, lookup: str) -> Thread | None: - return self._threads_by_lookup.get(lookup) + def require_unambiguous_ownership(self) -> None: + """Archived collisions stay readable; publication requires unique owners.""" + if self._ambiguous_lookups: + raise RelationViolationError("Registry creation identities collide.") + + def thread_for_recipient(self, lookup: str, name: str) -> Thread | None: + """Resolve a frozen recipient, including same-birth historical aliases.""" + canonical = self.canonical_name(name) + thread = self.by_lookup.get(lookup) + if thread is None and lookup in self._ambiguous_lookups: + thread = self.threads.get(canonical) + if thread is not None and thread.incarnation.lookup != lookup: + return None + return thread if thread is not None and thread.name == canonical else None def retired(self, name: str) -> bool: """Removal keeps the name's generations; they are the deletion record.""" @@ -562,11 +587,6 @@ def restorable_aliases( alias: self.aliases[alias] for alias in unoccupied if self.aliases[alias] in matching } - def require_unambiguous_ownership(self) -> None: - """Archived identities remain readable; publication requires unique owners.""" - if len({thread.created_at for thread in self.threads.values()}) != len(self.threads): - raise RelationViolationError("Registry creation identities collide.") - def owner_identity(self, name: str) -> OwnerIdentity: canonical = self.canonical_name(name) return OwnerIdentity(self.threads[canonical].incarnation, self.owner_generations[canonical]) diff --git a/src/agent_comms/routing.py b/src/agent_comms/routing.py index f0f6a078e3..64665adf3d 100644 --- a/src/agent_comms/routing.py +++ b/src/agent_comms/routing.py @@ -218,7 +218,7 @@ def sender_lookups( """ yield "" if source.has_recorded_birth: - yield source.stable_thread_lookup + yield source.lookup else: on_unrecorded() diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index 78e6d0a5fa..56c3e05093 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -201,7 +201,7 @@ def prepare( raise StaleFence("recipient registry identity stopped or changed") from error owner = RegistryOwner(thread=thread, admission_generation=generation) owner.require_registry(comms.registry) - lookup = thread.incarnation.stable_thread_lookup + lookup = thread.incarnation.lookup sent_owner, sent_sources = MessageNotification.sent_sources( comms.root, comms.registry, comms.bus.log, name, limit=MessageNotification.window_limit, diff --git a/src/agent_comms/selected_write_plan.py b/src/agent_comms/selected_write_plan.py index bdb911a9ea..6fab5046e9 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -201,7 +201,7 @@ def submit( initial = self.comms.bus.log.read_delivery_cohort(self.root_id, source_seq) if initial.message.message_id != source_message_id: raise IdentityConflict("Selected write source identity changed") - lookup = owner.incarnation.stable_thread_lookup + lookup = owner.incarnation.lookup with Coordination(str(self.comms.root / "coordination.sqlite3")) as store: assignments = sealed_cohort_assignments(store, lookup, after_seq=source_seq - 1) selected = [ @@ -250,7 +250,7 @@ def submit( def load( self, assignment: WakeAssignment, owner: Thread, admission_generation: int ) -> PlannedWrite | None: - lookup = owner.incarnation.stable_thread_lookup + lookup = owner.incarnation.lookup path = self._path(assignment.wire_seq, lookup) row = self._read(path) if row is None: @@ -265,7 +265,7 @@ def load( return row.planned(owner.worktree) def applied(self, assignment: WakeAssignment, owner: Thread, operation_id: str) -> None: - path = self._path(assignment.wire_seq, owner.incarnation.stable_thread_lookup) + path = self._path(assignment.wire_seq, owner.incarnation.lookup) row = self._read(path) if row is None: raise IdentityConflict("Selected write intent is missing") diff --git a/src/agent_comms/task_sources.py b/src/agent_comms/task_sources.py index 0f33c0bae2..07feb153ca 100644 --- a/src/agent_comms/task_sources.py +++ b/src/agent_comms/task_sources.py @@ -505,9 +505,9 @@ def require_publication(self, sender, registry, original_source): def require_wording_publication(self, registry, original_source): delivery, = original_source.references((self.subject,)) delivery.message.sender_role.require_user() - if delivery.audience.sender_lookup != self.source_user.stable_thread_lookup: + if delivery.audience.sender_lookup != self.source_user.lookup: raise RelationViolationError("USER pin differs from its original human author") - if not delivery.audience.includes_lookup(self.recipient.stable_thread_lookup): + if not delivery.audience.includes_lookup(self.recipient.lookup): raise RelationViolationError("USER pin recipient did not receive its original message") def user_task_facts(self, message): diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 055e429079..64c711a8c4 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -6,6 +6,7 @@ from __future__ import annotations +import hashlib import math from dataclasses import dataclass, field, replace from enum import StrEnum @@ -21,6 +22,7 @@ class ThreadIncarnation: name: str created_at: float + _lookup: str | None = field(default=None, init=False, repr=False, compare=False) # The persisted birth of a deleted participant: its declaration and real # birth are gone, its name stays on the bus. See DeletedThread. @@ -43,19 +45,38 @@ def __post_init__(self) -> None: raise ValueError("Thread incarnation requires a finite birth") @property - def stable_thread_lookup(self) -> str: - """The durable lookup of this recorded incarnation, across renames.""" - from .bus_publication import stable_thread_lookup + def lookup(self) -> str: + """The creation lookup of a recorded birth, computed once per incarnation.""" + if (cached := self._lookup) is not None: + return cached + lookup = self._lookup_for_birth(self.created_at) + object.__setattr__(self, "_lookup", lookup) + return lookup - return stable_thread_lookup(self.created_at) + @staticmethod + def _require_recorded_birth(created_at: float) -> float: + if ( + isinstance(created_at, bool) + or not isinstance(created_at, (int, float)) + or not math.isfinite(created_at) + or created_at <= 0 + ): + raise ValueError("Thread incarnation requires a positive recorded birth") + return float(created_at) + + @staticmethod + def _lookup_for_birth(created_at: float) -> str: + """The retained birth-only GoalWait format uses this same identity rule.""" + birth = ThreadIncarnation._require_recorded_birth(created_at) + identity = f"agent-comms:thread-creation:v1\0{birth!r}".encode() + return hashlib.sha256(identity).hexdigest()[:32] @property def has_recorded_birth(self) -> bool: return self.created_at > 0 def require_recorded(self) -> None: - if not self.has_recorded_birth: - raise ValueError("Thread incarnation requires a positive recorded birth") + self._require_recorded_birth(self.created_at) def resolved(self, snapshot: RegistryProvenance) -> ThreadIncarnation: """Follow retained rename aliases only for this exact historical owner.""" @@ -131,7 +152,7 @@ class TurnIdentity: incarnation: ThreadIncarnation generation: int - def resolved(self, snapshot: RegistrySnapshot) -> TurnIdentity: + def resolved(self, snapshot: RegistryProvenance) -> TurnIdentity: """Resolve the original incarnation; retain this turn allocation.""" return replace(self, incarnation=self.incarnation.resolved(snapshot)) diff --git a/src/agent_comms/thread_management.py b/src/agent_comms/thread_management.py index 5392dff894..6b3653bfc2 100644 --- a/src/agent_comms/thread_management.py +++ b/src/agent_comms/thread_management.py @@ -274,7 +274,7 @@ def restore_stopped(self, source: RegistrySnapshot, names: Sequence[str]) -> tup for name in selected: thread = self.registry.require(name) store.participants.register( - thread.incarnation.stable_thread_lookup, + thread.incarnation.lookup, thread.name, thread.name, committed=True, @@ -408,7 +408,7 @@ def _rename_thread_unlocked( raise ValueError("Private coordinator owner name exceeds its bound.") with Coordination(str(coordinator)) as store: try: - person = store.participants.get(before.incarnation.stable_thread_lookup) + person = store.participants.get(before.incarnation.lookup) except IdentityConflict as error: if str(error) != "participant aggregate is not registered": raise diff --git a/src/agent_comms/thread_presentation.py b/src/agent_comms/thread_presentation.py index f52856ac3a..dac11134c6 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -198,7 +198,7 @@ def with_sources(self, window: TranscriptSourceWindow) -> ThreadPresentation: projected = MessageNotification.delivery_window( transcripts.root, transcripts.registry, sources, NotificationAssignment.read(transcripts.root, sources), - owner_lookup=window.read.identity.thread.incarnation.stable_thread_lookup, + owner_lookup=window.read.identity.thread.incarnation.lookup, ) incoming, sent = ( tuple(replace(notification, message=source.message) diff --git a/src/agent_comms/thread_provenance.py b/src/agent_comms/thread_provenance.py index 114734ea59..35b971fe4d 100644 --- a/src/agent_comms/thread_provenance.py +++ b/src/agent_comms/thread_provenance.py @@ -14,13 +14,16 @@ class ThreadProvenance: tags: frozenset[str] worktree: str created_at: float = field(metadata={"wire_required": True}) + _incarnation: ThreadIncarnation = field(init=False, repr=False, compare=False) session_file: str | None = None title: str | None = None def __post_init__(self) -> None: # Historical evidence retains finite recorded values, including zero. # Positive-birth admission belongs to the existing live capability. - self.incarnation + object.__setattr__( + self, "_incarnation", ThreadIncarnation(self.name, self.created_at) + ) allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") if not self.name or not set(self.name) <= allowed: raise ValueError(f"Thread name {self.name!r} must be alphanumeric with hyphens/underscores.") @@ -33,9 +36,13 @@ def __post_init__(self) -> None: def capture(thread: ThreadProvenance) -> ThreadProvenance: """Freeze only determining provenance from the original typed declaration.""" return ThreadProvenance( - **{item.name: getattr(thread, item.name) for item in fields(ThreadProvenance)} + **{ + item.name: getattr(thread, item.name) + for item in fields(ThreadProvenance) + if item.init + } ) @property def incarnation(self) -> ThreadIncarnation: - return ThreadIncarnation(self.name, self.created_at) + return self._incarnation diff --git a/src/agent_comms/transcript_receipts.py b/src/agent_comms/transcript_receipts.py index 2318b6ef7f..6fa31a6f89 100644 --- a/src/agent_comms/transcript_receipts.py +++ b/src/agent_comms/transcript_receipts.py @@ -300,7 +300,7 @@ def resolve_sent(self, messages): messages = tuple(messages) if not messages: return messages - lookup = self.recipient.stable_thread_lookup + lookup = self.recipient.lookup marks = ",".join("?" for _ in messages) originals = { original.message.reference: original.message @@ -332,7 +332,7 @@ def rows( self, predicate="1", parameters=(), *, limit=1, ascending=False, ) -> Iterator[CommittedDelivery]: return self.log.conversation_sources( - self.recipient.stable_thread_lookup, + self.recipient.lookup, predicate, parameters, limit=limit, @@ -341,7 +341,7 @@ def rows( @property def frontier(self): - lookup = self.recipient.stable_thread_lookup + lookup = self.recipient.lookup return AssignedSourceCursor( str(self.root), self.recipient, ConversationSourceWindowRequest.frontier(self.log, lookup), @@ -349,7 +349,7 @@ def frontier(self): def window(self, *, limit=1, sent_limit=0) -> AssignedSourceWindow: """One certified cut owns conversation, sent rows and their frontier.""" - lookup = self.recipient.stable_thread_lookup + lookup = self.recipient.lookup conversation_request = ConversationSourceWindowRequest("1", (), limit, False) sent_request = ConversationSourceWindowRequest( "w.sender_lookup=?", (lookup,), sent_limit, False @@ -371,7 +371,7 @@ def native_events(self, record, routes, project_events, references): entry = record.entry published = False routing = routes.get(entry.id) - lookup = self.recipient.stable_thread_lookup + lookup = self.recipient.lookup events = super().native_events(record, routes, project_events, references) if entry.final_reply: if routing is not None and routing.publications: @@ -425,7 +425,7 @@ def native_events(self, record, routes, project_events, references): def events(self, original): message = original.message - if original.audience.sender_lookup == self.recipient.stable_thread_lookup: + if original.audience.sender_lookup == self.recipient.lookup: return ( SentTranscript.from_message(message), ) @@ -451,7 +451,7 @@ def native_records(self, fragments, routes, reader): from .native_runtime_input import NativeRuntimeInput from .transcripts import TranscriptRead - lookup = self.recipient.stable_thread_lookup + lookup = self.recipient.lookup for fragment in fragments: fragment = tuple(fragment) selected = tuple(zip(fragment, reader.inputs_for(fragment))) diff --git a/src/agent_comms/transcript_routes.py b/src/agent_comms/transcript_routes.py index 4ed2105854..b9b735676f 100644 --- a/src/agent_comms/transcript_routes.py +++ b/src/agent_comms/transcript_routes.py @@ -224,7 +224,7 @@ def record_turn_publication(self, registry, log, lease, checkpoint, routing, pub raise RelationViolationError("Native publication evidence is invalid") from error ids = routing.annotation_ids(reader, checkpoint, str(path), final.end) inputs = InputDispositions(log.path.parent) - lookup = incarnation.stable_thread_lookup + lookup = incarnation.lookup with log.certified_read() as source, registry.store.reading() as document: snapshot = document.snapshot() incarnation = lease.identity.incarnation.resolved(snapshot) diff --git a/tests/native_context_reader_journey.py b/tests/native_context_reader_journey.py index d2715ca26d..4876b17996 100644 --- a/tests/native_context_reader_journey.py +++ b/tests/native_context_reader_journey.py @@ -8,6 +8,7 @@ import time from pathlib import Path +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.comms import Comms from agent_comms.child_process import ProcessIdentity from agent_comms.field_codec import FieldCodec @@ -56,13 +57,12 @@ async def read_original_context( process_identity=ProcessIdentity.capture(os.getpid()), session_file=str(fixture.session), model="response-local/fixture") owner._comms.registry.declare(thread) - from agent_comms.bus_publication import stable_thread_lookup from agent_comms.coordinator import Coordination # An in-process fixture registration has the same private participant # relation as the original owner launcher; a registry row alone does not. with Coordination(str(owner._comms.root / 'coordination.sqlite3')) as store: - store.participants.register(stable_thread_lookup(thread.created_at), + store.participants.register(ThreadIncarnation("fixture", thread.created_at).lookup, thread.name, thread.name, committed=True) await owner.load_session(str(fixture.project), thread.name) connection = RuntimeConnection(owner._comms, thread.name, diff --git a/tests/native_evidence_reader_installed_journey.py b/tests/native_evidence_reader_installed_journey.py index 2ba89b504b..a35ac9f38c 100644 --- a/tests/native_evidence_reader_installed_journey.py +++ b/tests/native_evidence_reader_installed_journey.py @@ -8,13 +8,13 @@ REPO = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO/'tests')) from compaction_loopback import LoopbackProvider +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.comms import Comms from agent_comms.threads import Thread from agent_comms.child_process import ProcessIdentity from agent_comms.coordinator import Coordination from agent_comms.coordination_cohort import accept_delivery_cohort from agent_comms.coordinated_runtime import SelectedExecution -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.selected_turn import SelectedPrompt, SelectedAttempt, SelectedConsideration from agent_comms.selected_request import SelectedRequest from agent_comms.selected_session import SelectedSession @@ -141,7 +141,7 @@ async def main(): for audience in initial.audience.recipients: store.participants.register(audience.recipient_lookup,audience.canonical_thread,audience.canonical_thread,committed=True) accept_delivery_cohort(comms.bus,rid,message.seq,store) - owner=comms.registry.require('beta');directory=root/'native-sessions'/stable_thread_lookup(owner.created_at) + owner=comms.registry.require('beta');directory=root/'native-sessions'/ThreadIncarnation("fixture", owner.created_at).lookup directory.mkdir(parents=True,mode=0o700);saved=directory/SOURCE.name;shutil.copyfile(SOURCE,saved);saved.chmod(0o600) with sqlite3.connect(Path(str(SOURCE)+'.input-proof').as_uri()+'?mode=ro',uri=True) as original: with sqlite3.connect(str(saved)+'.input-proof') as target:original.backup(target) diff --git a/tests/reply_relevance_installed_journey.py b/tests/reply_relevance_installed_journey.py index b16674f421..668382b576 100644 --- a/tests/reply_relevance_installed_journey.py +++ b/tests/reply_relevance_installed_journey.py @@ -8,6 +8,7 @@ import time import acp +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.comms import Comms, wire from agent_comms.coordinator import Coordination from agent_comms.coordination_tables.assignments import WakeAssignment @@ -22,7 +23,6 @@ from agent_comms.presentation import MessageNotification from agent_comms.selected_triage import FullSelectedTriage from agent_comms.threads import Thread -from agent_comms.bus_publication import stable_thread_lookup from shared_bus_restart_native import attach @@ -143,7 +143,7 @@ def persist(state): actual = service.bus.log.message_by_id(reply.message_id) assert actual.sender == child.name and actual.body.strip() history = read_historical_native_inputs(store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(service.registry.require(child.name).created_at), + recipient_lookup=ThreadIncarnation("fixture", service.registry.require(child.name).created_at).lookup, source_seq=original.seq) assert len(history) == 2 and all(row.expected_prompt_equality_established for row in history) handling = MessageNotification.window(service.root, service.registry, diff --git a/tests/shared_bus_restart_native.py b/tests/shared_bus_restart_native.py index 7f4880858f..a0a96c8577 100644 --- a/tests/shared_bus_restart_native.py +++ b/tests/shared_bus_restart_native.py @@ -18,6 +18,7 @@ import threading from compaction_loopback import LoopbackProvider +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.comms import Comms from agent_comms.acp import CommsClient from agent_comms.acp_extension import decode_updates @@ -27,7 +28,7 @@ from agent_comms.runtime_requests import SubscribeRuntimeRequest from agent_comms.threads import Thread from agent_comms.thread_identity import ThreadRole -from agent_comms.bus_publication import HumanOrigin, stable_thread_lookup +from agent_comms.bus_publication import HumanOrigin from agent_comms.coordination_tables.assignments import WakeAssignment from agent_comms.native_runtime_input import NativeRuntimeInput, CurrentNativeCursor from agent_comms.native_input_record import TriageNativeExecution @@ -40,7 +41,6 @@ from agent_comms.private_sidecar import sidecar_connection from agent_comms.coordinator import Coordination from agent_comms.historical_native_inputs import read_historical_native_inputs -from agent_comms.bus_publication import stable_thread_lookup def configured_stage(arguments, configured, snapshot, *, continue_existing=False): @@ -541,7 +541,7 @@ async def session_update(self, **kwargs): parameters=(late_assignment.lifecycle.execution_id,)) assert len(late_receipts) == 1 and late_receipts[0].exact_target == 'human' assert '23' in service.bus.log.message_by_id(late_receipts[0].message_id).body - lookup = stable_thread_lookup(service.registry.require(name).created_at) + lookup = ThreadIncarnation("fixture", service.registry.require(name).created_at).lookup for original in (*originals,late): history = read_historical_native_inputs(store,wire_root_id=root_id, recipient_lookup=lookup,source_seq=original.seq) @@ -833,7 +833,7 @@ async def serve(reader, writer): if arguments.saved_source: for name in names: owner = service.registry.require(name) - directory = service.root/'native-sessions'/stable_thread_lookup(owner.created_at) + directory = service.root/'native-sessions'/ThreadIncarnation("fixture", owner.created_at).lookup directory.mkdir(mode=0o700, parents=True) saved = directory/'retained.jsonl' shutil.copyfile(arguments.saved_source, saved); saved.chmod(0o600) @@ -1107,7 +1107,7 @@ def hold_cursor_publication(): f'10+{index}={10+index}' for index in range(arguments.wave_size)) + '.' assert replies[1].body == 'Separate late-wave answer: 20+3=23.' assert provider.posts == 4 - lookup = stable_thread_lookup(service.registry.require(names[0]).created_at) + lookup = ThreadIncarnation("fixture", service.registry.require(names[0]).created_at).lookup with Coordination(str(service.root/'coordination.sqlite3')) as store: inputs = NativeRuntimeInput.select(store.session._connection) assert len(inputs) == 4 diff --git a/tests/test_acp_channel_disposition.py b/tests/test_acp_channel_disposition.py index d6ca49229b..f6b13b083f 100644 --- a/tests/test_acp_channel_disposition.py +++ b/tests/test_acp_channel_disposition.py @@ -10,8 +10,8 @@ import pytest from agent_comms import cohort_foreground +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.activity import NativeInputDrainDiagnostic, UnavailableDrainDiagnostic -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.native_input_record import FullNativeExecution, TriageNativeExecution from agent_comms.selected_triage import FullSelectedTriage, IgnoreSelectedTriage from agent_comms.coordinator import Coordination @@ -66,8 +66,8 @@ def source_sequences(row): ) ) - alpha_lookup = stable_thread_lookup(comms.registry.require("alpha").created_at) - beta_lookup = stable_thread_lookup(comms.registry.require("beta").created_at) + alpha_lookup = ThreadIncarnation("fixture", comms.registry.require("alpha").created_at).lookup + beta_lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup by_source = {} for row in rows: by_source.setdefault((row.owner_lookup, source_sequences(row)), []).append(row) @@ -133,9 +133,7 @@ async def test_uncertain_channel_input_keeps_notification_and_never_replays(tmp_ read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup( - comms.registry.require("alpha").created_at - ), + recipient_lookup=ThreadIncarnation("fixture", comms.registry.require("alpha").created_at).lookup, source_seq=message.seq, ) == () diff --git a/tests/test_acp_input_disposition.py b/tests/test_acp_input_disposition.py index 3ff9a54a75..78134f2ebe 100644 --- a/tests/test_acp_input_disposition.py +++ b/tests/test_acp_input_disposition.py @@ -15,11 +15,11 @@ from agent_comms import backend from agent_comms import pi_commands as commands from agent_comms import pi_events as pi +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.acp_failure import RequestFailure from agent_comms.acp_extension import InputDeliveryChangedUpdate, QueuePromptRequest, SteerPromptRequest, decode_updates, encode_request from agent_comms.child_process import BoundedRun, ExitedOutcome from agent_comms.assignment_states import DeliveredAssignment -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms from agent_comms.config_options import ModelConfigOption @@ -227,7 +227,7 @@ async def test_bus_message_arriving_mid_turn_is_taken_by_the_running_turn( comms = owner._comms root_id = owner._private_nk_wire_root_id receiver = await Coordination.run_worker(partial(comms.registry.require, session)) - lookup = stable_thread_lookup(receiver.created_at) + lookup = ThreadIncarnation("fixture", receiver.created_at).lookup def prepare(): comms.registry.register( @@ -326,7 +326,7 @@ async def delayed_started(following, input_id): ) as (owner, session): comms = owner._comms receiver = await Coordination.run_worker(partial(comms.registry.require, session)) - lookup = stable_thread_lookup(receiver.created_at) + lookup = ThreadIncarnation("fixture", receiver.created_at).lookup def prepare(): comms.registry.register( @@ -409,7 +409,7 @@ async def test_waiting_messages_follow_the_original_on_the_changed_model( async with native.open_owner(runtime_enabled=True, auto_wake=True) as (owner, session): comms = owner._comms receiver = await Coordination.run_worker(partial(comms.registry.require, session)) - lookup = stable_thread_lookup(receiver.created_at) + lookup = ThreadIncarnation("fixture", receiver.created_at).lookup def prepare(): comms.registry.register( @@ -545,7 +545,7 @@ async def test_bus_message_arriving_during_a_reply_turn_is_taken_by_that_turn(na async with native.open_owner(runtime_enabled=True, auto_wake=True) as (owner, session): comms = owner._comms receiver = await Coordination.run_worker(partial(comms.registry.require, session)) - lookup = stable_thread_lookup(receiver.created_at) + lookup = ThreadIncarnation("fixture", receiver.created_at).lookup comms.threads.claim_thread("sender", tags=frozenset(), worktree=str(native.project)) try: first = comms.messaging.send_message("sender", session, "which parser is slow?") diff --git a/tests/test_acp_private_nk_delivery.py b/tests/test_acp_private_nk_delivery.py index d6c586d5c8..6c881c48de 100644 --- a/tests/test_acp_private_nk_delivery.py +++ b/tests/test_acp_private_nk_delivery.py @@ -13,13 +13,13 @@ import pytest from agent_comms import cohort_foreground, coordinated_runtime, native_pi +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.acp import CommsAgent from agent_comms.acp_extension import ( CursorAdvancedUpdate, VerifiedCursorObservation, decode_updates, ) -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.child_process import ProcessIdentity from agent_comms.activity import NativeInputDrainDiagnostic, UnavailableDrainDiagnostic from agent_comms.cohort_foreground import _accept_visible_deliveries @@ -68,7 +68,7 @@ def _session(tmp_path, *, package=True): root_id = comms.messaging.initialize_private_initial_protocol() with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - stable_thread_lookup(owner.created_at), "beta", "beta", committed=True + ThreadIncarnation("fixture", owner.created_at).lookup, "beta", "beta", committed=True ) agent = CommsAgent( comms, @@ -132,7 +132,7 @@ async def no_options(*args, **kwargs): session = await agent.new_session(cwd=str(project), mcp_servers=[]) owner = comms.registry.require(session.session_id) with Coordination(str(root / "coordination.sqlite3")) as store: - participant = store.participants.get(stable_thread_lookup(owner.created_at)) + participant = store.participants.get(ThreadIncarnation("fixture", owner.created_at).lookup) assert participant.committed and participant.owner_thread == owner.name invoke_tool( comms, @@ -149,7 +149,7 @@ async def test_private_owner_rename_migrates_generation_before_canonical_selecte tmp_path, monkeypatch, managed ): comms, agent, _ = _session(tmp_path) - lookup = stable_thread_lookup(comms.registry.require("beta").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup with Coordination(str(comms.root / "coordination.sqlite3")) as store: before = store.participants.get(lookup) assert before.owner_thread == "beta" and before.participant_generation == 1 @@ -182,7 +182,7 @@ async def test_private_rename_does_not_replay_unserved_old_name_selected_source( tmp_path, monkeypatch, seal_old ): comms, agent, root_id = _session(tmp_path) - lookup = stable_thread_lookup(comms.registry.require("beta").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup invoke_tool(comms, "comms_send", {"from": "sender", "to": "beta", "body": "old-before-rename"}) if seal_old: bus = MessageBus(comms.root / "bus.jsonl", comms.registry, private_response_writes=True) @@ -222,7 +222,7 @@ async def test_private_rename_old_name_backlog_does_not_exhaust_new_recipient_sc def test_private_rename_refuses_mismatched_sql_owner_before_registry_mutation(tmp_path): comms, _, _ = _session(tmp_path) - lookup = stable_thread_lookup(comms.registry.require("beta").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup with Coordination(str(comms.root / "coordination.sqlite3")) as store: store.participants.advance_generation(lookup, "unexpected", expected_generation=1) with pytest.raises(RelationViolationError, match="Private coordinator owner differs"): @@ -265,7 +265,7 @@ async def test_private_rename_compensates_registry_failure_with_new_old_owner_ge tmp_path, monkeypatch ): comms, _, root_id = _session(tmp_path) - lookup = stable_thread_lookup(comms.registry.require("beta").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup original = comms.registry.rename def fail_before_registry_write(name, new_name): @@ -695,7 +695,7 @@ async def test_acp_private_no_wake_has_delivery_receipt_but_no_model(tmp_path, m comms.registry.declare(alpha) with Coordination(str(comms.root / "coordination.sqlite3")) as store: store.participants.register( - stable_thread_lookup(alpha.created_at), "alpha", "alpha", committed=True + ThreadIncarnation("fixture", alpha.created_at).lookup, "alpha", "alpha", committed=True ) monkeypatch.setattr(cohort_foreground, "_trusted_package", lambda _: None) monkeypatch.setattr(native_pi, "_trusted_package", lambda _: None) @@ -724,7 +724,7 @@ async def test_acp_private_no_wake_has_delivery_receipt_but_no_model(tmp_path, m ( root_id, original.seq, - stable_thread_lookup(comms.registry.require("beta").created_at), + ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, ), ).fetchone() assert tuple(receipt) == ("unmentioned_observer", None) diff --git a/tests/test_acp_selected_write_plan.py b/tests/test_acp_selected_write_plan.py index 93d03703c5..822e10c8fa 100644 --- a/tests/test_acp_selected_write_plan.py +++ b/tests/test_acp_selected_write_plan.py @@ -16,6 +16,7 @@ import pytest from agent_comms import cohort_foreground, coordinated_runtime +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.activity import NativeInputDrainDiagnostic, UnavailableDrainDiagnostic from agent_comms.acp import CommsAgent from agent_comms.acp_extension import ( @@ -24,7 +25,6 @@ decode_updates, encode_request, ) -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_foreground import _accept_visible_deliveries from agent_comms.cohort_schema import install_private_cohort_schema @@ -96,7 +96,7 @@ async def test_public_acp_preplan_one_selected_write_after_verified_fake_native( install_prompt_binding_schema(store) for name in ("alpha", "beta"): store.participants.register( - stable_thread_lookup(comms.registry.require(name).created_at), + ThreadIncarnation("fixture", comms.registry.require(name).created_at).lookup, name, name, committed=True, @@ -129,12 +129,12 @@ async def session_update(self, **_kwargs): if prior_seq: with Coordination(str(root / "coordination.sqlite3")) as store: await _accept_visible_deliveries( - bus, root_id, store.session.path, stable_thread_lookup(51003.0), 0, owner_name="beta" + bus, root_id, store.session.path, ThreadIncarnation("fixture", 51003.0).lookup, 0, owner_name="beta" ) message = comms.messaging.send_message("sender", "#team", "@beta inspect module.py") with Coordination(str(root / "coordination.sqlite3")) as store: await _accept_visible_deliveries( - bus, root_id, store.session.path, stable_thread_lookup(51003.0), prior_seq, owner_name="beta" + bus, root_id, store.session.path, ThreadIncarnation("fixture", 51003.0).lookup, prior_seq, owner_name="beta" ) request = SelectedWriteRequest( message.seq, message.message_id, str(resource), "after selected\n" @@ -289,7 +289,7 @@ async def test_second_pid_public_acp_owner_ipc_preplan(monkeypatch): install_native_runtime_schema(store) install_prompt_binding_schema(store) store.participants.register( - stable_thread_lookup(61002.0), "alpha", "alpha", committed=True + ThreadIncarnation("fixture", 61002.0).lookup, "alpha", "alpha", committed=True ) env = dict(os.environ) source_root = Path(__file__).resolve().parents[1] @@ -327,7 +327,7 @@ async def test_second_pid_public_acp_owner_ipc_preplan(monkeypatch): ) with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - stable_thread_lookup(61003.0), "beta", "beta", committed=True + ThreadIncarnation("fixture", 61003.0).lookup, "beta", "beta", committed=True ) (base / "registered").touch() @@ -362,7 +362,7 @@ def event(expected: str) -> dict: bus = MessageBus(root / "bus.jsonl", comms.registry, private_response_writes=True) with Coordination(str(root / "coordination.sqlite3")) as store: await _accept_visible_deliveries( - bus, root_id, store.session.path, stable_thread_lookup(61003.0), 0, owner_name="beta" + bus, root_id, store.session.path, ThreadIncarnation("fixture", 61003.0).lookup, 0, owner_name="beta" ) options = encode_request( SelectedWriteRequest( @@ -390,7 +390,7 @@ def event(expected: str) -> dict: ).fetchone()[0] == 1 ) - assert sealed_cohort_assignments(store, stable_thread_lookup(61002.0)) == () + assert sealed_cohort_assignments(store, ThreadIncarnation("fixture", 61002.0).lookup) == () finally: if attached is not None: await attached.shutdown() diff --git a/tests/test_candidate_maintenance.py b/tests/test_candidate_maintenance.py index b6b09577ce..f7edcaa9de 100644 --- a/tests/test_candidate_maintenance.py +++ b/tests/test_candidate_maintenance.py @@ -10,7 +10,7 @@ import pytest from agent_comms import candidate_maintenance as maintenance -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.store_files import _store_lock @@ -69,7 +69,7 @@ def observe(root): assert indexed.wait(timeout=5), "deferred checkpoint did not catch up" page = WakeCandidateIndex(comms.bus.log).page( root_id=root_id, - recipient_lookup=stable_thread_lookup(comms.registry.require("beta").created_at), + recipient_lookup=ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, after_seq=0, required_through_seq=message.seq, ) @@ -161,9 +161,7 @@ def test_one_background_drain_catches_up_a_backlog_of_oversized_records( assert index.catch_up_committed_append(hint).caught_up page = index.page( root_id=root_id, - recipient_lookup=stable_thread_lookup( - comms.registry.require("beta").created_at - ), + recipient_lookup=ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, after_seq=0, required_through_seq=comms.bus.log.full_history()[-1].seq, limit=100, @@ -202,7 +200,7 @@ def retired(): index = WakeCandidateIndex(comms.bus.log) assert index._verified_checkpoint(root_id).last_seq == messages[-1].seq page = index.page(root_id=root_id, - recipient_lookup=stable_thread_lookup(comms.registry.require("beta").created_at), + recipient_lookup=ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, after_seq=0, required_through_seq=messages[-1].seq, limit=32) assert len(page.entries) == 32 and page.has_more assert comms.bus.pending_counts("beta") == {"sender": len(messages)} diff --git a/tests/test_channel_participant_registration.py b/tests/test_channel_participant_registration.py index c6ce58fbbe..2f85fbe879 100644 --- a/tests/test_channel_participant_registration.py +++ b/tests/test_channel_participant_registration.py @@ -2,7 +2,7 @@ import pytest -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.cohort_foreground import _accept_visible_deliveries from agent_comms.comms import Comms from agent_comms.coordination_cohort import sealed_cohort_assignments @@ -21,7 +21,7 @@ async def test_channel_with_unstarted_and_stopped_subscribers_delivers_whole_coh stopped = comms.registry.require("stopped-reviewer") comms.registry.register(stopped, StoppedThreadStatus()) receiver = comms.registry.require("receiver") - lookup = stable_thread_lookup(receiver.created_at) + lookup = ThreadIncarnation("fixture", receiver.created_at).lookup with Coordination(str(comms.root / "coordination.sqlite3")) as store: with store.session.read(): assert Participants.select(store.session._connection) == [] diff --git a/tests/test_checkpoint_seals.py b/tests/test_checkpoint_seals.py index a55ee10aab..6ae0e18084 100644 --- a/tests/test_checkpoint_seals.py +++ b/tests/test_checkpoint_seals.py @@ -5,10 +5,11 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.checkpoint_seals import FinalSeal, PendingSeal from agent_comms.errors import RelationViolationError from agent_comms.wire_log import WireLog -from test_private_bus_checkpoint import _page, _root, stable_thread_lookup +from test_private_bus_checkpoint import _page, _root @pytest.mark.parametrize( @@ -116,13 +117,13 @@ def write(self, marker): with pytest.raises(RelationViolationError, match="UNKNOWN"): comms.messaging.send_initial_cohort("sender", "Alice", "durable bus, uncertain seal") bus = comms.bus.log.path.read_bytes() - witness, rows, more = _page(comms, stable_thread_lookup(17002.0)) + witness, rows, more = _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) assert [row.message.seq for row in rows] == [1, 2] and not more assert witness.through_seq == 2 assert comms.bus.log.path.read_bytes() == bus assert isinstance(comms.bus.log._private_marker_unlocked().seal, FinalSeal) # Reopened canonical readers agree; no replay or new publication. - assert [row.message.seq for row in _page(comms, stable_thread_lookup(17002.0))[1]] == [1, 2] + assert [row.message.seq for row in _page(comms, ThreadIncarnation("fixture", 17002.0).lookup)[1]] == [1, 2] def test_absent_public_marker_never_substitutes_for_required_or_redirected_marker(tmp_path): diff --git a/tests/test_claim_admission_baseline.py b/tests/test_claim_admission_baseline.py index 55788a0671..0f261499f8 100644 --- a/tests/test_claim_admission_baseline.py +++ b/tests/test_claim_admission_baseline.py @@ -14,7 +14,7 @@ import pytest -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms from agent_comms.coordination_cohort import accept_delivery_cohort, sealed_cohort_assignments @@ -52,7 +52,7 @@ def test_selected_wake_and_file_claims_have_no_common_admission_receipt() -> Non install_private_cohort_schema(coordinator) for name in ("Alice", "Bob"): coordinator.participants.register( - stable_thread_lookup(people[name]), name, name, committed=True + ThreadIncarnation("fixture", people[name]).lookup, name, name, committed=True ) message = comms.messaging.send_initial_cohort( "sender", "#team", "Please investigate @Alice" @@ -60,8 +60,8 @@ def test_selected_wake_and_file_claims_have_no_common_admission_receipt() -> Non receipt = accept_delivery_cohort(comms.bus, root_id, message.seq, coordinator).value assert receipt.member_count == 2 assert receipt.assignment_count == 1 - assert sealed_cohort_assignments(coordinator, stable_thread_lookup(people["Alice"])) - assert sealed_cohort_assignments(coordinator, stable_thread_lookup(people["Bob"])) == () + assert sealed_cohort_assignments(coordinator, ThreadIncarnation("fixture", people["Alice"]).lookup) + assert sealed_cohort_assignments(coordinator, ThreadIncarnation("fixture", people["Bob"]).lookup) == () # Existing explicit resource claims are valid independent operations. # They neither consult nor reference the sealed N/K receipt. A diff --git a/tests/test_claim_admission_verifier.py b/tests/test_claim_admission_verifier.py index 5f58210d9a..d31722f082 100644 --- a/tests/test_claim_admission_verifier.py +++ b/tests/test_claim_admission_verifier.py @@ -11,10 +11,10 @@ import pytest from agent_comms import claim_admission +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.assignment_states import FullPendingAssignment from agent_comms.attempt_start import AttemptStart from agent_comms.attempt_states import ModelRunningAttempt, PromptAcceptedAttempt, SettlingAttempt -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.child_process import ProcessIdentity from agent_comms.claim_admission import ( publish_selected_resource_claim, @@ -75,8 +75,8 @@ def test_selected_wake_verifier_refuses_no_wake_and_stale_authority( committed=True, ) accept_delivery_cohort(comms.bus, root_id, message.seq, store) - alice_lookup = stable_thread_lookup(comms.registry.require("Alice").created_at) - bob_lookup = stable_thread_lookup(comms.registry.require("Bob").created_at) + alice_lookup = ThreadIncarnation("fixture", comms.registry.require("Alice").created_at).lookup + bob_lookup = ThreadIncarnation("fixture", comms.registry.require("Bob").created_at).lookup assert sealed_cohort_assignments(store, bob_lookup) == () assignment = sealed_cohort_assignments(store, alice_lookup)[0] assert type(assignment.lifecycle) is FullPendingAssignment diff --git a/tests/test_claim_bus_read.py b/tests/test_claim_bus_read.py index 152dec6e2f..4074223c5e 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -18,8 +18,8 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.attempt_start import AttemptStart -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.child_process import ProcessIdentity from agent_comms.claim_admission import ( publish_selected_resource_claim, @@ -97,7 +97,7 @@ def selected_turn(): recipient.canonical_thread, committed=True, ) accept_delivery_cohort(comms.bus, root_id, message.seq, store) - lookup = stable_thread_lookup(comms.registry.require("Alice").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("Alice").created_at).lookup assignment = sealed_cohort_assignments(store, lookup)[0] owner, generation = comms.registry.live_owner_with_admission("Alice") owner, generation = comms.registry.lease_live_turn_with_admission( diff --git a/tests/test_cohort_foreground.py b/tests/test_cohort_foreground.py index b9da76285e..4bb80787f9 100644 --- a/tests/test_cohort_foreground.py +++ b/tests/test_cohort_foreground.py @@ -19,7 +19,7 @@ from agent_comms import cohort_foreground as foreground from agent_comms import cohort_send from agent_comms import coordinated_runtime as runtime -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -420,7 +420,7 @@ async def test_foreground_explicit_selected_write_never_mutates_no_wake( def ready(thread: Thread) -> None: with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - stable_thread_lookup(comms.registry.require("beta").created_at), + ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, "beta", "beta", committed=True, @@ -526,7 +526,7 @@ async def test_foreground_refuses_takeover_and_cosmetic_subprocess_pid( install_private_response_schema(store) install_native_runtime_schema(store) store.participants.register( - stable_thread_lookup(comms.registry.require("beta").created_at), + ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, "beta", "beta", committed=True, diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index 36025b6d91..50b6130f91 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -12,9 +12,9 @@ import pytest from agent_comms import store_files +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.activity import ActivityState from agent_comms.agent_events import AgentInfo -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.comms import wire from agent_comms.errors import RelationViolationError, UnregisteredThreadError from agent_comms.threads import Thread @@ -94,7 +94,7 @@ def test_default_same_tick_owners_get_distinct_durable_claim_identities( second_time = stored.require("second").created_at assert first_time == first.created_at assert first_time != second_time - assert stable_thread_lookup(first_time) != stable_thread_lookup(second_time) + assert ThreadIncarnation("fixture", first_time).lookup != ThreadIncarnation("fixture", second_time).lookup assert "_generated_created_at" not in stored.require("second").to_wire() with pytest.raises(RelationViolationError, match="creation identities collide"): wire(root).registry.declare( @@ -117,7 +117,7 @@ def test_six_spawned_same_tick_owners_register_distinct_incarnations( stored = wire(root).registry times = [stored.require(name).created_at for name in names] assert len(set(times)) == len(names) - assert len({stable_thread_lookup(created_at) for created_at in times}) == len(names) + assert len({ThreadIncarnation("fixture", created_at).lookup for created_at in times}) == len(names) def test_concurrent_senders_preserve_every_message_and_sequence(self, tmp_path: Path) -> None: root = tmp_path / "wire" diff --git a/tests/test_coordinated_runtime.py b/tests/test_coordinated_runtime.py index 95e21edc16..bf0b6ddaf8 100644 --- a/tests/test_coordinated_runtime.py +++ b/tests/test_coordinated_runtime.py @@ -19,6 +19,7 @@ from agent_comms import coordinated_runtime as runtime from agent_comms import coordination_response +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.historical_native_inputs import FullHistoricalNativeInput, TriageHistoricalNativeInput from agent_comms.selected_triage import IgnoreSelectedTriage, FullSelectedTriage from agent_comms.native_input_record import TriageNativeExecution, FullNativeExecution @@ -28,7 +29,6 @@ IgnoredAssignment, TriagePendingAssignment, ) -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -339,7 +339,7 @@ async def test_unmentioned_agent_channel_real_sqlite_two_distinct_mocked_decisio ignored = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert len(ignored) == 1 and isinstance(ignored[0], TriageHistoricalNativeInput) @@ -371,7 +371,7 @@ async def test_unmentioned_agent_channel_real_sqlite_two_distinct_mocked_decisio is None ) with Coordination(str(root / "coordination.sqlite3")) as store: - lookups = {person.name: stable_thread_lookup(person.created_at) for person in people} + lookups = {person.name: ThreadIncarnation("fixture", person.created_at).lookup for person in people} assert store.participants.get(lookups["alpha"]).pointer.execution_id is None assert store.participants.get(lookups["beta"]).pointer.execution_id is None assert ( @@ -424,8 +424,8 @@ async def test_initial_no_wake_observer_never_enters_model_or_claim_page( def test_wake_frame_rejects_no_wake_forgery_and_unengaged_full(tmp_path: Path) -> None: root, _root_id, comms, initial, people = _root(tmp_path, mentioned=True) with Coordination(str(root / "coordination.sqlite3")) as store: - alpha_lookup = stable_thread_lookup(people[1].created_at) - beta_lookup = stable_thread_lookup(people[2].created_at) + alpha_lookup = ThreadIncarnation("fixture", people[1].created_at).lookup + beta_lookup = ThreadIncarnation("fixture", people[2].created_at).lookup assert sealed_cohort_assignments(store, alpha_lookup) == () selected = sealed_cohort_assignments(store, beta_lookup)[0] # A pending FULL claim has no response obligation, and no frame may grant one. @@ -440,7 +440,7 @@ def test_wake_frame_rejects_no_wake_forgery_and_unengaged_full(tmp_path: Path) - def test_triage_frame_is_read_only_and_does_not_promote_message_body(tmp_path: Path) -> None: root, _root_id, comms, initial, people = _root(tmp_path) with Coordination(str(root / "coordination.sqlite3")) as store: - lookup = stable_thread_lookup(people[1].created_at) + lookup = ThreadIncarnation("fixture", people[1].created_at).lookup assignment = sealed_cohort_assignments(store, lookup)[0] frame = TurnContext.render_segments((SelectedWakeSegment.capture(((initial, assignment),), people[1]),)).text assert f'"source_seq":{initial.message.seq}' in frame @@ -455,7 +455,7 @@ def test_wake_frame_includes_bounded_changed_sent_outcomes(tmp_path: Path) -> No root, _root_id, _comms, initial, people = _root(tmp_path) with Coordination(str(root / "coordination.sqlite3")) as store: - lookup = stable_thread_lookup(people[1].created_at) + lookup = ThreadIncarnation("fixture", people[1].created_at).lookup assignment = sealed_cohort_assignments(store, lookup)[0] notification = MessageNotification( initial.audience.recipients[0], "Read", "", @@ -516,7 +516,7 @@ async def test_direct_selected_reply_goes_to_original_sender(tmp_path: Path, mon one = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[2].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) assert len(one) == 1 and isinstance(one[0].execution, FullNativeExecution) @@ -543,9 +543,7 @@ async def test_explicit_fresh_enrollment_precedes_fake_private_raw_send( assert result is not None and result.disposition is CompletedAssignment fresh = result.fresh_session assert fresh is not None and len(calls) == 1 - assert fresh.path.parent == root / "native-sessions" / stable_thread_lookup( - people[2].created_at - ) + assert fresh.path.parent == root / "native-sessions" / ThreadIncarnation("fixture", people[2].created_at).lookup fresh.verify_saved_identity() assert len(fresh.path.read_text().splitlines()) == 6 @@ -640,7 +638,7 @@ async def test_fresh_creation_fsync_unknown_never_enters_fake_model( monkeypatch.setattr("agent_comms.native_pi._trusted_package", lambda _: None) runner, calls = _fake_model() monkeypatch.setattr(TrackedTurnSession, "execute", runner) - expected_dir = root / "native-sessions" / stable_thread_lookup(people[2].created_at) + expected_dir = root / "native-sessions" / ThreadIncarnation("fixture", people[2].created_at).lookup real_fsync = fresh_module._fsync_directory def unknown_parent(path: Path) -> None: @@ -745,7 +743,7 @@ async def test_repeated_awareness_timeouts_cannot_starve_unrelated_original( first_base.mkdir() first_root, _first_id, first_comms, first_initial, people = _root(first_base, direct=True) with Coordination(str(first_root / "coordination.sqlite3")) as store: - assignment = sealed_cohort_assignments(store, stable_thread_lookup(people[2].created_at))[0] + assignment = sealed_cohort_assignments(store, ThreadIncarnation("fixture", people[2].created_at).lookup)[0] owner = first_comms.registry.require("beta") monkeypatch.setattr(OptionalAwarenessProjection, "build_seconds", 0.02) entered, release = threading.Event(), threading.Event() @@ -873,7 +871,7 @@ async def test_historical_native_input_view_keeps_exact_triage_and_full_events( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[2].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) == () @@ -885,7 +883,7 @@ async def test_historical_native_input_view_keeps_exact_triage_and_full_events( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[2].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) result = await SelectedExecution( @@ -897,7 +895,7 @@ async def test_historical_native_input_view_keeps_exact_triage_and_full_events( rows = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[2].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) assert [type(row.execution) for row in rows] == [TriageNativeExecution, FullNativeExecution] @@ -915,7 +913,7 @@ async def test_historical_native_input_view_keeps_exact_triage_and_full_events( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) == () @@ -928,7 +926,7 @@ async def test_historical_native_input_view_keeps_exact_triage_and_full_events( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[2].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) @@ -937,7 +935,7 @@ async def test_private_raw_send_rejects_renamed_saved_file_before_selected_bind( tmp_path: Path, monkeypatch ) -> None: root, root_id, comms, initial, people = _root(tmp_path, direct=True) - lookup = stable_thread_lookup(people[2].created_at) + lookup = ThreadIncarnation("fixture", people[2].created_at).lookup session_dir = root / "native-sessions" / lookup session_dir.mkdir(parents=True, mode=0o700) saved = session_dir / "saved.jsonl" @@ -992,7 +990,7 @@ async def test_historical_native_input_view_omits_no_wake_and_reserved_unknown( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) == () @@ -1016,7 +1014,7 @@ async def test_historical_native_input_view_omits_no_wake_and_reserved_unknown( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[2].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) == () @@ -1166,7 +1164,7 @@ async def test_owner_generation_revoked_during_native_triage_fails_closed( root, root_id, comms, _initial, people = _root(tmp_path) monkeypatch.setattr("agent_comms.native_pi._trusted_package", lambda _: None) runner, _ = _fake_model(decision="IGNORE") - lookup = stable_thread_lookup(people[1].created_at) + lookup = ThreadIncarnation("fixture", people[1].created_at).lookup async def revoke(*args, **kwargs): result = await runner(*args, **kwargs) @@ -1737,7 +1735,7 @@ def test_native_runtime_schema_explicit_install_and_drift_fail_closed(tmp_path: async def test_settled_page_does_not_hide_later_selected_claim(tmp_path: Path, monkeypatch) -> None: """Page saturation is not an empty inbox; scaffolding is not model authority.""" root, root_id, comms, _initial, people = _root(tmp_path) - lookup = stable_thread_lookup(people[1].created_at) + lookup = ThreadIncarnation("fixture", people[1].created_at).lookup with Coordination(str(root / "coordination.sqlite3")) as store: for number in range(100): message = comms.messaging.send_initial_cohort( @@ -1823,7 +1821,7 @@ async def test_unconfigured_owner_does_not_reserve_or_launch(tmp_path, monkeypat assert calls == [] assert comms.registry.require("beta").active_turn is None with Coordination(str(root / "coordination.sqlite3")) as store: - pending = sealed_cohort_assignments(store, stable_thread_lookup(owner.created_at)) + pending = sealed_cohort_assignments(store, ThreadIncarnation("fixture", owner.created_at).lookup) assert type(pending[0].lifecycle) is TriagePendingAssignment @@ -1866,7 +1864,7 @@ async def failed(package, **kwargs): notice_initial = comms.bus.log.read_delivery_cohort(root_id, notice.seq) assert all(decision.wake_mode == PassiveWake() for decision in notice_initial.decisions) with Coordination(str(root / "coordination.sqlite3")) as store: - lookup = stable_thread_lookup(people[2].created_at) + lookup = ThreadIncarnation("fixture", people[2].created_at).lookup assert store.participants.get(lookup).pointer.execution_id is None assignment = sealed_cohort_assignments(store, lookup)[0] assert type(assignment.lifecycle) is FailedAssignment @@ -1941,7 +1939,7 @@ async def transient(package, **kwargs): assert len(calls) == 3 + (not direct) assert len(full_attempts) == 3 and len(set(full_attempts)) == 3 with Coordination(str(root / "coordination.sqlite3")) as store: - lookup = stable_thread_lookup(people[2].created_at) + lookup = ThreadIncarnation("fixture", people[2].created_at).lookup assignment = sealed_cohort_assignments(store, lookup)[0] assert assignment.source == initial.message.reference snapshot = store.snapshots.get(assignment.lifecycle.execution_id) diff --git a/tests/test_coordination_cohort.py b/tests/test_coordination_cohort.py index 925f91b47b..c333674a9a 100644 --- a/tests/test_coordination_cohort.py +++ b/tests/test_coordination_cohort.py @@ -13,12 +13,13 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.assignment_states import ( DeferredAssignment, DeliveredAssignment, FullPendingAssignment, ) -from agent_comms.bus_publication import PRIVATE_WIRE_FIELD, stable_thread_lookup +from agent_comms.bus_publication import PRIVATE_WIRE_FIELD from agent_comms.cohort_schema import CohortDeliveryReceipts, install_private_cohort_schema from agent_comms.comms import Comms from agent_comms.coordination_cohort import ( @@ -66,7 +67,7 @@ def _root(tmp_path: Path) -> tuple[Comms, Coordination, str, dict[str, str]]: comms.registry.register( Thread(name=name, worktree=str(root), created_at=created_at, tags=frozenset(tags)) ) - lookups[name] = stable_thread_lookup(created_at) + lookups[name] = ThreadIncarnation("fixture", created_at).lookup coordinator = Coordination(str(root / "coordinator.sqlite"), clock_ms=lambda: 4723) install_private_cohort_schema(coordinator) for name in ("Alice", "Bob", "Charlie"): diff --git a/tests/test_cursor_load_recovery.py b/tests/test_cursor_load_recovery.py index 355155c668..9ee6111c93 100644 --- a/tests/test_cursor_load_recovery.py +++ b/tests/test_cursor_load_recovery.py @@ -6,9 +6,9 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.acp import CommsAgent from agent_comms.acp_extension import CursorAdvancedUpdate, decode_updates -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.coordinator import Coordination @@ -50,7 +50,7 @@ async def test_trusted_load_recovers_after_real_flock_contention(tmp_path): root_id = comms.messaging.initialize_private_initial_protocol() with Coordination(str(comms.root / "coordination.sqlite3")) as store: store.participants.register( - stable_thread_lookup(owner.created_at), owner.name, owner.name, committed=True + ThreadIncarnation("fixture", owner.created_at).lookup, owner.name, owner.name, committed=True ) agent = CommsAgent( comms, diff --git a/tests/test_goal_wait_certified_reply.py b/tests/test_goal_wait_certified_reply.py index 8ee638c8fa..106a296370 100644 --- a/tests/test_goal_wait_certified_reply.py +++ b/tests/test_goal_wait_certified_reply.py @@ -8,7 +8,7 @@ from test_goal_standby_liveness import retained_native_acp_owner as retained_native_acp_owner from test_backend_native_lifecycle import native_backend as native_backend -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.goal_actions import GoalPrecondition, SetGoalAction, StandbyGoalAction from agent_comms.goal_presentation import GoalWaitTarget @@ -38,7 +38,7 @@ def test_original_reply_is_not_reassigned_to_same_name_replacement(tmp_path): with comms.bus.log.certified_read() as source: original = source.delivery(reply.seq) assert wait.matches(original) - assert original.audience.sender_lookup == stable_thread_lookup(peer.created_at) + assert original.audience.sender_lookup == ThreadIncarnation("fixture", peer.created_at).lookup comms.agents.finish_turn(lease) comms.registry.unregister("peer") @@ -174,7 +174,7 @@ async def test_native(retained_native_acp_owner): with Coordination(str(comms.root / "coordination.sqlite3")) as store: proofs = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(owner.created_at), + recipient_lookup=ThreadIncarnation("fixture", owner.created_at).lookup, source_seq=original.seq, ) assert len(proofs) == 1 and isinstance(proofs[0].execution, FullNativeExecution) diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index 2bad793473..65cedfb2f3 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -323,7 +323,9 @@ def test_historical_namespace_has_recorded_provenance_and_no_live_authority(migr recorded = source.provenance.require("alice") assert type(recorded) is ThreadProvenance assert recorded.incarnation == old.registry.require("alice").incarnation - assert set(FieldCodec.encode(recorded)) == {item.name for item in fields(ThreadProvenance)} + assert set(FieldCodec.encode(recorded)) == { + item.name for item in fields(ThreadProvenance) if item.init + } assert not hasattr(recorded, "process_identity") assert not hasattr(recorded, "active_turn") assert not hasattr(source.provenance, "admission_generations") diff --git a/tests/test_interrupted_delivery_recovery.py b/tests/test_interrupted_delivery_recovery.py index 419a8a7459..ef44662aa9 100644 --- a/tests/test_interrupted_delivery_recovery.py +++ b/tests/test_interrupted_delivery_recovery.py @@ -8,8 +8,8 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.assignment_states import DeferredAssignment, FullPendingAssignment -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.cohort_foreground import _accept_visible_deliveries from agent_comms.coordination_cohort import pending_sealed_assignments from agent_comms.coordinator import Coordination @@ -31,7 +31,7 @@ async def test_interrupted_following_delivery_recovers_only_before_native_send( owner = comms.registry.require("beta") message = comms.messaging.send_initial_cohort("sender", "beta", "must survive selection") store_path = comms.root / "coordination.sqlite3" - lookup = stable_thread_lookup(owner.created_at) + lookup = ThreadIncarnation("fixture", owner.created_at).lookup await _accept_visible_deliveries( comms.bus, root_id, store_path, lookup, 0, owner_name=owner.name, ) @@ -115,7 +115,7 @@ async def test_selected_phase_and_delivery_share_wire_without_blocking_loop(tmp_ original = comms.messaging.send_initial_cohort("sender", "beta", "selected batch") owner = comms.registry.require("beta") path = comms.root / "coordination.sqlite3" - lookup = stable_thread_lookup(owner.created_at) + lookup = ThreadIncarnation("fixture", owner.created_at).lookup await _accept_visible_deliveries(comms.bus, root_id, path, lookup, 0, owner_name="beta") entered, release = asyncio.Event(), threading.Event() loop = asyncio.get_running_loop() @@ -173,7 +173,7 @@ async def test_recovery_without_paused_assignments_is_read_only(tmp_path, monkey owner = comms.registry.require("beta") comms.messaging.send_initial_cohort("sender", "beta", "unbound recovery source") store_path = comms.root / "coordination.sqlite3" - lookup = stable_thread_lookup(owner.created_at) + lookup = ThreadIncarnation("fixture", owner.created_at).lookup await _accept_visible_deliveries(comms.bus, root_id, store_path, lookup, 0, owner_name="beta") with Coordination(str(store_path)) as store: assignments = pending_sealed_assignments(store, lookup, "beta") @@ -211,7 +211,7 @@ async def test_delivery_worker_retires_unplaced_inputs_after_cancellation_or_inb agent.inputs.original_sources["beta"] = SelectedOriginalInput.answering("live selected work") message = comms.messaging.send_initial_cohort("sender", "beta", "worker-held delivery") path = comms.root / "coordination.sqlite3" - lookup = stable_thread_lookup(leased.thread.created_at) + lookup = ThreadIncarnation("fixture", leased.thread.created_at).lookup await _accept_visible_deliveries(comms.bus, root_id, path, lookup, 0, owner_name="beta") loop = asyncio.get_running_loop() entered, release = asyncio.Event(), threading.Event() diff --git a/tests/test_keyed_response_delivery.py b/tests/test_keyed_response_delivery.py index b0defe84be..5fa485fc49 100644 --- a/tests/test_keyed_response_delivery.py +++ b/tests/test_keyed_response_delivery.py @@ -3,9 +3,9 @@ from pathlib import Path import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.bus_source_page import AddressedPage -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.cohort_foreground import _accept_visible_deliveries from agent_comms.coordination_cohort import sealed_cohort_assignments from agent_comms.coordination_response import prepare_fenced_response, publish_fenced_response @@ -24,7 +24,7 @@ async def test_reply_has_frozen_awareness_and_selected_native_barrier(tmp_path: # A subscriber arriving after the original is not a historical recipient. case.comms.registry.declare(Thread("late", frozenset({"team"}), worktree=str(tmp_path))) sender = case.comms.registry.require("sender") - lookup = stable_thread_lookup(sender.created_at) + lookup = ThreadIncarnation("fixture", sender.created_at).lookup intent = prepare_fenced_response( case.store, case.bus, @@ -120,7 +120,7 @@ def test_canonical_response_reader_rejects_invalid_receipt(tmp_path, field, valu with pytest.raises(RelationViolationError): with case.bus.log.certified_read() as source: source.addressed_page(case.bus.log, AddressedPage.capture( - lookup=stable_thread_lookup(sender.created_at))) + lookup=ThreadIncarnation("fixture", sender.created_at).lookup)) assert checkpoint.read_bytes() == saved_index return # Explicit cold certification must reject a corrupt candidate rather diff --git a/tests/test_maintenance_barrier.py b/tests/test_maintenance_barrier.py index 089e29fab6..21404bd8fe 100644 --- a/tests/test_maintenance_barrier.py +++ b/tests/test_maintenance_barrier.py @@ -10,6 +10,7 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.backend import _maintenance_send_boundary from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms @@ -30,7 +31,6 @@ async def test_channel_post_queues_without_start_during_cutover(tmp_path: Path, monkeypatch) -> None: - from agent_comms.bus_publication import stable_thread_lookup from agent_comms.coordination_cohort import next_sealed_assignment from agent_comms.coordinator import Coordination from agent_comms.routing import ScheduledTurn @@ -64,7 +64,7 @@ async def start(*args, **kwargs): assert starts == [] and not agent.inputs.wake_tasks beta = comms.registry.require('beta') with Coordination(comms.root / 'coordination.sqlite3') as store: - pending = next_sealed_assignment(store, stable_thread_lookup(beta.created_at), beta.name) + pending = next_sealed_assignment(store, ThreadIncarnation("fixture", beta.created_at).lookup, beta.name) assert pending.wire_seq == post.seq assert beta.active_turn is None @@ -81,13 +81,13 @@ async def start(*args, **kwargs): assert await agent.inputs.drain_inbox('beta') == 1 assert len(selected) == 1 with Coordination(comms.root / 'coordination.sqlite3') as store: - assert next_sealed_assignment(store, stable_thread_lookup(beta.created_at), beta.name).wire_seq == post.seq + assert next_sealed_assignment(store, ThreadIncarnation("fixture", beta.created_at).lookup, beta.name).wire_seq == post.seq # Selection scheduled just before another drain must still refuse at the # effect boundary without consuming that durable assignment. another_drain = control.begin(FieldCodec.decode(MaintenanceOperatorField, 'second-cutover')) assert await selected[0].run() == another_drain with Coordination(comms.root / 'coordination.sqlite3') as store: - assert next_sealed_assignment(store, stable_thread_lookup(beta.created_at), beta.name).wire_seq == post.seq + assert next_sealed_assignment(store, ThreadIncarnation("fixture", beta.created_at).lookup, beta.name).wire_seq == post.seq for phase in (PausedPhase, InstallingPhase, ReadyPhase): another_drain = control.advance(another_drain, phase) agent.inputs.pending_turns['beta'] = [queued] @@ -101,7 +101,6 @@ async def test_cutover_clear_delivers_pending_channel_post(tmp_path: Path, monke from agent_comms import cohort_foreground, native_pi from agent_comms.tracked_turn import TrackedTurnSession from test_coordinated_runtime import _fake_model - from agent_comms.bus_publication import stable_thread_lookup from agent_comms.coordination_cohort import next_sealed_assignment from agent_comms.coordinator import Coordination from test_acp_private_nk_delivery import _session @@ -116,7 +115,7 @@ async def test_cutover_clear_delivers_pending_channel_post(tmp_path: Path, monke assert await agent.inputs.drain_inbox('beta') == 0 beta = comms.registry.require('beta') with Coordination(comms.root / 'coordination.sqlite3') as store: - assert next_sealed_assignment(store, stable_thread_lookup(beta.created_at), beta.name).wire_seq == post.seq + assert next_sealed_assignment(store, ThreadIncarnation("fixture", beta.created_at).lookup, beta.name).wire_seq == post.seq assert beta.active_turn is None if abort: assert gate.clear(hold).phase is ReadyPhase diff --git a/tests/test_native_channel_reply_roundtrip.py b/tests/test_native_channel_reply_roundtrip.py index f7d41c1056..822c84a37b 100644 --- a/tests/test_native_channel_reply_roundtrip.py +++ b/tests/test_native_channel_reply_roundtrip.py @@ -14,6 +14,7 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.native_input_record import TriageNativeExecution, FullNativeExecution from agent_comms.selected_triage import IgnoreSelectedTriage from agent_comms.acp import CommsClient @@ -23,7 +24,6 @@ VerifiedCursorObservation, decode_updates, ) -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.comms import Comms from agent_comms.private_bus_checkpoint import addressed_source_pointers_unlocked from agent_comms.store_files import _store_lock @@ -371,7 +371,7 @@ def settled(): with comms.bus.log.locked(): marker = comms.bus.log._private_marker_unlocked() pointers = addressed_source_pointers_unlocked( - comms.bus.log, marker, stable_thread_lookup(owner.created_at) + comms.bus.log, marker, ThreadIncarnation("fixture", owner.created_at).lookup ) assert reply.message_id in [pointer.message_id for pointer in pointers] @@ -422,7 +422,7 @@ def selected_turn_finished(): with Coordination(str(comms.root / "coordination.sqlite3")) as store: rejected = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(owner.created_at), source_seq=reply.seq, + recipient_lookup=ThreadIncarnation("fixture", owner.created_at).lookup, source_seq=reply.seq, ) assert len(rejected) == 1 and rejected[0].proves_triage_source(rejected) claim = store.assignments.get(rejected[0].assignment_id) diff --git a/tests/test_native_prompt_binding.py b/tests/test_native_prompt_binding.py index 0cf094f85a..17c99ed6e1 100644 --- a/tests/test_native_prompt_binding.py +++ b/tests/test_native_prompt_binding.py @@ -17,9 +17,9 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.native_input_record import TriageNativeExecution, FullNativeExecution from agent_comms.selected_triage import IgnoreSelectedTriage -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -311,7 +311,7 @@ async def test_binding_matches_journal_and_exposes_equality(tmp_path: Path, monk comms.bus, store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, ).read() assert current.covered_seq == coverage.covered_seq assert coverage.covered_seq >= initial.message.seq @@ -319,7 +319,7 @@ async def test_binding_matches_journal_and_exposes_equality(tmp_path: Path, monk evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert len(evidence) == 1 and isinstance(evidence[0].execution, TriageNativeExecution) @@ -345,7 +345,7 @@ async def test_source_coverage_stops_at_missing_claim_and_unknown_input(tmp_path fake, calls = _fake_model(decision="IGNORE") monkeypatch.setattr(TrackedTurnSession, "execute", fake) second = comms.messaging.send_initial_cohort("sender", "#team", "Second source.") - lookup = stable_thread_lookup(people[1].created_at) + lookup = ThreadIncarnation("fixture", people[1].created_at).lookup bus = comms.bus with Coordination(str(root / "coordination.sqlite3")) as store: before = SourceCoverage(bus, store, wire_root_id=root_id, recipient_lookup=lookup).read() @@ -402,7 +402,7 @@ async def test_source_coverage_mismatch_cannot_skip_to_later_proof(tmp_path, mon comms.bus, store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, ).read() assert coverage.covered_seq == 0 assert coverage.injected_source_seqs == () @@ -416,7 +416,7 @@ async def test_source_coverage_mismatch_cannot_skip_to_later_proof(tmp_path, mon comms.bus, store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, ).read(limit=1) @@ -433,7 +433,7 @@ async def test_source_coverage_distinguishes_no_wake_from_native_injection(tmp_p comms.registry.declare(beta) with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - stable_thread_lookup(beta.created_at), "beta", "beta", committed=True + ThreadIncarnation("fixture", beta.created_at).lookup, "beta", "beta", committed=True ) second = comms.messaging.send_initial_cohort("sender", "#team", "@beta please review.") with Coordination(str(root / "coordination.sqlite3")) as store: @@ -449,7 +449,7 @@ async def test_source_coverage_distinguishes_no_wake_from_native_injection(tmp_p comms.bus, store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, ).read() assert coverage.covered_seq >= second.seq assert coverage.covered_seq == coverage.source_witness.latest_source_seq @@ -479,7 +479,7 @@ async def fail_full(package, **kwargs): comms.bus, store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, ).read() assert coverage.covered_seq == 0 assert coverage.injected_source_seqs == () @@ -524,7 +524,7 @@ async def test_old_input_id_cannot_directly_seed_new_admission_cursor(tmp_path, owner = comms.registry.require("alpha") admission_generation = comms.registry.snapshot().admission_generations["alpha"] with Coordination(str(root / "coordination.sqlite3")) as store: - lookup = stable_thread_lookup(people[1].created_at) + lookup = ThreadIncarnation("fixture", people[1].created_at).lookup generation = store.participants.get(lookup).participant_generation assert ( NativeSourceCursor(comms.bus, store, wire_root_id=root_id).read(owner_name="alpha") @@ -589,7 +589,7 @@ async def test_current_cursor_new_owner_generation_cannot_borrow_proof(tmp_path, root=root, wire_root_id=root_id, owner_name="alpha", native_package=tmp_path ).run() assert old_turn is not None and old_turn.cursor_status == "proven" - lookup = stable_thread_lookup(people[1].created_at) + lookup = ThreadIncarnation("fixture", people[1].created_at).lookup comms.registry.rename("alpha", "alpha-new") with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.advance_generation(lookup, "alpha-new", expected_generation=1) @@ -633,7 +633,7 @@ async def test_full_stage_binding_joins_after_triage_engagement(tmp_path: Path, evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert [type(row.execution) for row in evidence] == [TriageNativeExecution, FullNativeExecution] @@ -664,7 +664,7 @@ async def test_journal_digest_mismatch_is_not_equality(tmp_path: Path, monkeypat evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert evidence == () @@ -708,7 +708,7 @@ async def corrupt_full(package, **kwargs): evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert [type(row.execution) for row in evidence] == [TriageNativeExecution] @@ -776,7 +776,7 @@ async def mutate_after_admitted_send(package, **kwargs): evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert [type(row.execution) for row in evidence] == ([] if stage == "triage" else [TriageNativeExecution]) @@ -813,7 +813,7 @@ def generation_bumps_then_bind(*args, **kwargs): # committed but before the binding write. with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.advance_generation( - stable_thread_lookup(people[1].created_at), + ThreadIncarnation("fixture", people[1].created_at).lookup, "alpha", expected_generation=1, ) @@ -855,14 +855,14 @@ async def dying(package, **_): evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert evidence == () # The claim stays deferred (no auto-retry) and unproven. from agent_comms.coordination_cohort import sealed_cohort_assignments - claims = sealed_cohort_assignments(store, stable_thread_lookup(people[1].created_at)) + claims = sealed_cohort_assignments(store, ThreadIncarnation("fixture", people[1].created_at).lookup) assert [assignment.lifecycle.declared_name for assignment in claims] == ["deferred"] assert read_expected_prompt_binding(store, "0" * 32) is None # The sidecar binding row exists and stays immutable, but no equality is diff --git a/tests/test_native_send_admission.py b/tests/test_native_send_admission.py index b70c73f33a..285a0223c3 100644 --- a/tests/test_native_send_admission.py +++ b/tests/test_native_send_admission.py @@ -15,8 +15,8 @@ import pytest from agent_comms import native_pi, native_prompt_send +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.diagnostics import PublicationMeasurements -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.child_process import AttachedChild from agent_comms.coordinated_runtime import SelectedExecution from agent_comms.coordination_database import StoreBusy @@ -42,7 +42,7 @@ async def race(*args, **kwargs): if drift == "generation": with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.advance_generation( - stable_thread_lookup(owner.created_at), owner.name, expected_generation=1 + ThreadIncarnation("fixture", owner.created_at).lookup, owner.name, expected_generation=1 ) else: comms.registry.unregister(owner.name) @@ -117,7 +117,7 @@ async def test_actual_raw_writes_follow_committed_admission_without_global_exclu create = AttachedChild.start children = [] received = tmp_path / "received.json" - session = root / "native-sessions" / stable_thread_lookup(owner.created_at) / "s.jsonl" + session = root / "native-sessions" / ThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" async def launch(*args, **kwargs): child = await create( @@ -188,7 +188,7 @@ async def bounded_turn(*args, **kwargs): loop = asyncio.get_running_loop() create = AttachedChild.start children = [] - session = root / "native-sessions" / stable_thread_lookup(owner.created_at) / "s.jsonl" + session = root / "native-sessions" / ThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" async def launch(*args, **kwargs): child = await create( @@ -345,7 +345,7 @@ async def test_short_admission_contention_sends_once_after_release( monkeypatch.setattr(native_pi, "_trusted_package", lambda _: Path("/bin/true")) create = AttachedChild.start received = tmp_path / "received.json" - session = root / "native-sessions" / stable_thread_lookup(owner.created_at) / "s.jsonl" + session = root / "native-sessions" / ThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" async def launch(*args, **kwargs): return await create( @@ -535,7 +535,7 @@ async def test_actual_native_admission_commits_before_bytes_and_releases_feedbac monkeypatch.setattr(native_pi, "_trusted_package", lambda _: Path("/bin/true")) create = AttachedChild.start received = tmp_path / "received.json" - session = root / "native-sessions" / stable_thread_lookup(owner.created_at) / "s.jsonl" + session = root / "native-sessions" / ThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" children = [] async def launch(*_args, **kwargs): diff --git a/tests/test_native_source_cursor_certificate.py b/tests/test_native_source_cursor_certificate.py index 2411a91a84..b73a6e6605 100644 --- a/tests/test_native_source_cursor_certificate.py +++ b/tests/test_native_source_cursor_certificate.py @@ -11,7 +11,7 @@ import pytest from agent_comms import coordinated_runtime as runtime -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -69,7 +69,7 @@ def _fresh(tmp_path: Path, count: int = 2): name = "alpha" if n == 0 else f"other{n:03}" owner = comms.registry.require(name) store.participants.register( - stable_thread_lookup(owner.created_at), name, name, committed=True + ThreadIncarnation("fixture", owner.created_at).lookup, name, name, committed=True ) return root, root_id, comms diff --git a/tests/test_native_source_cursor_scale.py b/tests/test_native_source_cursor_scale.py index 2557424337..2afd0a355d 100644 --- a/tests/test_native_source_cursor_scale.py +++ b/tests/test_native_source_cursor_scale.py @@ -9,9 +9,9 @@ import pytest from agent_comms import coordinated_runtime as runtime +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.native_input_record import TriageNativeExecution, FullNativeExecution from agent_comms.selected_triage import IgnoreSelectedTriage -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.child_process import ProcessIdentity from agent_comms.coordination_cohort import accept_delivery_cohort from agent_comms.coordination_errors import IdentityConflict, StaleFence @@ -54,7 +54,7 @@ async def test_101_unrelated_initials_and_frozen_n_keeps_exact_native_cursor( comms.registry.declare(member) with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - stable_thread_lookup(member.created_at), + ThreadIncarnation("fixture", member.created_at).lookup, member.name, member.name, committed=True, @@ -73,7 +73,7 @@ async def test_101_unrelated_initials_and_frozen_n_keeps_exact_native_cursor( comms.bus, store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(people[1].created_at), + recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, ).read() assert coverage.blocked_seq == selected.seq second = await runtime.SelectedExecution( @@ -116,7 +116,7 @@ async def test_page_budget_refuses_progress_but_original_is_not_replayed(tmp_pat other = comms.registry.require("other") with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - stable_thread_lookup(other.created_at), "other", "other", committed=True + ThreadIncarnation("fixture", other.created_at).lookup, "other", "other", committed=True ) for number in range(101): message = comms.messaging.send_initial_cohort("sender", "#team", f"@other note-{number}") @@ -189,7 +189,7 @@ async def test_forged_cross_generation_cursor_reopen_denied_without_mutating_sql ) assert valid is not None and valid.input_id == first.input_id - lookup = stable_thread_lookup(people[1].created_at) + lookup = ThreadIncarnation("fixture", people[1].created_at).lookup comms.registry.rename("alpha", "alpha-new") with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.advance_generation(lookup, "alpha-new", expected_generation=1) @@ -248,7 +248,7 @@ async def test_reconnect_rechecks_sql_generation_after_proof_scan(tmp_path, monk root=root, wire_root_id=root_id, owner_name="alpha", native_package=tmp_path ).run() assert result is not None and result.cursor_status == "proven" - lookup = stable_thread_lookup(people[1].created_at) + lookup = ThreadIncarnation("fixture", people[1].created_at).lookup original = SourceCoverage.prefix def advance_generation(*args, **kwargs): diff --git a/tests/test_native_transcript.py b/tests/test_native_transcript.py index a8adb44a3e..04633b7a1b 100644 --- a/tests/test_native_transcript.py +++ b/tests/test_native_transcript.py @@ -5,6 +5,7 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.comms import wire from agent_comms.field_codec import FieldCodec from agent_comms.native_entries import MessageEntry, NativeEntry, TranscriptProjection, UnknownEntry @@ -155,7 +156,6 @@ def test_large_record_retained_in_tail_and_both_page_directions(tmp_path): def test_native_ancestry_is_acquired_before_coordinator_read(tmp_path, monkeypatch): from contextlib import contextmanager - from agent_comms.bus_publication import stable_thread_lookup from agent_comms.coordination_database import CoordinationStore, StoreBusy from agent_comms.coordination_response import install_private_response_schema from agent_comms.coordinated_runtime_schema import install_native_runtime_schema @@ -165,7 +165,7 @@ def test_native_ancestry_is_acquired_before_coordinator_read(tmp_path, monkeypat root = tmp_path / "wire" comms = wire(root) thread = comms.registry.declare(Thread("worker", frozenset(), str(tmp_path))) - lookup = stable_thread_lookup(thread.created_at) + lookup = ThreadIncarnation("fixture", thread.created_at).lookup database = root / "coordination.sqlite3" with Coordination(str(database)) as store: install_native_runtime_schema(store) diff --git a/tests/test_optional_awareness_projection.py b/tests/test_optional_awareness_projection.py index 5545015e50..07cb79cb49 100644 --- a/tests/test_optional_awareness_projection.py +++ b/tests/test_optional_awareness_projection.py @@ -11,7 +11,8 @@ from agent_comms import coordinated_runtime as runtime from agent_comms import coordination_cohort as cohort -from agent_comms.bus_publication import CommittedDelivery, stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.bus_publication import CommittedDelivery from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -107,7 +108,7 @@ def _projection( through_seq, registry=Registration(index.log.path.with_name("registry.json")), expected_participant_generation=store.participants.get( - stable_thread_lookup(owner.created_at) + ThreadIncarnation("fixture", owner.created_at).lookup ).participant_generation, expected_admission_generation=admission_generation, max_rows=max_rows, @@ -425,7 +426,7 @@ def test_fresh_gen2_selected_and_old_pending_obligation_are_scoped(tmp_path: Pat def test_fresh_gen2_only_selected_is_available(tmp_path: Path) -> None: comms, store, index, root_id = _root(tmp_path) try: - lookup = stable_thread_lookup(comms.registry.require("member000").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("member000").created_at).lookup store.participants.register(lookup, "member000", "member000", committed=True) store.participants.advance_generation(lookup, "member000", expected_generation=1) initial, assignment = _accepted(comms, store, root_id, "member000", "fresh") @@ -628,7 +629,7 @@ def test_saved_wire_awareness_preserves_passive_authority_and_rejects_incomplete passive = accept_delivery_cohort(comms.bus, root_id, notice.seq, store).value.assignments[0] assert isinstance(passive.lifecycle, PassiveAssignment) assert not passive.lifecycle.engageable and not passive.lifecycle.mode.active - observer = stable_thread_lookup(comms.registry.require("member001").created_at) + observer = ThreadIncarnation("fixture", comms.registry.require("member001").created_at).lookup assert not WakeAssignment.select( store.session._connection, where="recipient_lookup=? AND wire_seq=?", diff --git a/tests/test_ordinary_nk_delivery.py b/tests/test_ordinary_nk_delivery.py index 754dec709c..0a4bd77261 100644 --- a/tests/test_ordinary_nk_delivery.py +++ b/tests/test_ordinary_nk_delivery.py @@ -12,8 +12,9 @@ import pytest from agent_comms import cohort_foreground, coordinated_runtime +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.assignment_states import CompletedAssignment, IgnoredAssignment -from agent_comms.bus_publication import PRIVATE_WIRE_FIELD, stable_thread_lookup +from agent_comms.bus_publication import PRIVATE_WIRE_FIELD from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -66,7 +67,7 @@ async def test_normal_send_to_existing_foreground_executes_exact_nk( with Coordination(str(root / "coordination.sqlite3")) as store: install_private_cohort_schema(store) store.participants.register( - stable_thread_lookup(alpha.created_at), "alpha", "alpha", committed=True + ThreadIncarnation("fixture", alpha.created_at).lookup, "alpha", "alpha", committed=True ) monkeypatch.setattr(cohort_foreground, "_trusted_package", lambda _: None) monkeypatch.setattr("agent_comms.native_pi._trusted_package", lambda _: None) @@ -102,7 +103,7 @@ def ready(beta): assert initial.message == message expected_n = 1 if target == "beta" else 2 assert len(initial.audience.recipients) == expected_n - lookup = stable_thread_lookup(recipient[0].created_at) + lookup = ThreadIncarnation("fixture", recipient[0].created_at).lookup with Coordination(str(root / "coordination.sqlite3")) as store: db = store.session._connection assert ( diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index 873c34b871..c7905d3cf5 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -1,10 +1,10 @@ """Whole conversations derive their original frozen wire sources, in both directions.""" from agent_comms import invoke_tool +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.comms import wire from agent_comms.coordination_errors import StaleRevision from agent_comms.assignment_states import IgnoredAssignment -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.coordinator import Coordination from agent_comms.notification_assignment import NotificationAssignment from agent_comms.errors import RelationViolationError @@ -28,7 +28,7 @@ def test_original_read_contention_is_unavailable_then_same_receipts_return(tmp_p root, _root_id, comms, initial, _people = _root(tmp_path) database = root / "coordination.sqlite3" - lookup = stable_thread_lookup(comms.registry.require("beta").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup original = NotificationAssignment.read(root, (initial,)).resolve(root) wire_before = comms.bus.log.path.read_bytes() assert original @@ -67,7 +67,7 @@ def test_gateway_uses_original_snapshot_and_one_metadata_read(tmp_path, monkeypa root, _root_id, comms, _initial, _people = _root(tmp_path) database = root / "coordination.sqlite3" - lookup = stable_thread_lookup(comms.registry.require("beta").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup commit_requested = threading.Event() committed = threading.Event() errors, statements = [], [] @@ -186,8 +186,7 @@ def test_frozen_unhandled_delivery_projects_recovery_without_claiming(tmp_path): notices = comms.views.message_notifications((message,))[message.seq,message.message_id] beta = next(notice for notice in notices if notice.recipient=='beta') assert beta.state=='Sent' and beta.detail == '' - assert beta.recipient_identity.recipient_lookup==stable_thread_lookup( - comms.registry.require('beta').created_at) + assert beta.recipient_identity.recipient_lookup==ThreadIncarnation("fixture", comms.registry.require('beta').created_at).lookup with Coordination(str(root/'coordination.sqlite3')) as store: assert WakeAssignment.select(store.session._connection)==before assert not any(row.wire_seq==message.seq for row in before) @@ -238,7 +237,7 @@ def test_publication_intent_joins_original_sender_and_target_only(tmp_path): # comms.root, comms.registry.require("beta"), comms.bus.log ) original = tuple(source.rows("w.seq=?", (reply.seq,))) - lookup = stable_thread_lookup(source.recipient.created_at) + lookup = ThreadIncarnation("fixture", source.recipient.created_at).lookup intent = TurnRouting((initial.message.reference,), MessageRoute("beta", ("sender", "alpha"))) # A later send can fail; this first committed subset stays a valid relation. intent.require_publications(original, lookup) @@ -295,7 +294,7 @@ def test_original_target_handling_revokes_open_sender_read_without_new_message( before = comms.transcripts.capture_page_read("sender") sender_page = before.read() target = comms.registry.require("beta") - lookup = stable_thread_lookup(target.created_at) + lookup = ThreadIncarnation("fixture", target.created_at).lookup receipts = comms.views.recent_notifications("sender") beta = next(item for item in receipts if item.recipient_identity.recipient_lookup == lookup) assert beta.message.reference == message.reference and beta.state == "Sent" @@ -401,9 +400,7 @@ def observed(log, *, shared=False): assert len(barriers) == 1 assert view.read_identity.receipt_frontier.sequence == initial.message.seq assert view.notifications[0].message.reference == initial.message.reference - assert view.notifications[0].recipient_identity.recipient_lookup == stable_thread_lookup( - view.read_identity.thread.created_at - ) + assert view.notifications[0].recipient_identity.recipient_lookup == ThreadIncarnation("fixture", view.read_identity.thread.created_at).lookup def test_published_source_binding_keeps_exact_original_request(tmp_path): # noqa: F811 diff --git a/tests/test_private_bus_checkpoint.py b/tests/test_private_bus_checkpoint.py index c85923a898..0eb8a86826 100644 --- a/tests/test_private_bus_checkpoint.py +++ b/tests/test_private_bus_checkpoint.py @@ -10,7 +10,7 @@ import pytest -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.envelope_claim_transitions import ExistingFileClaim @@ -127,8 +127,8 @@ def test_marker_bound_complete_addressed_pages(tmp_path: Path) -> None: one = comms.messaging.send_initial_cohort("sender", "#team", "@Alice selected; Bob no-wake") two = comms.messaging.send_initial_cohort("sender", "outsider", "not their audience") three = comms.messaging.send_initial_cohort("sender", "#team", "@Bob selected; Alice no-wake") - alice = stable_thread_lookup(17002.0) - bob = stable_thread_lookup(17003.0) + alice = ThreadIncarnation("fixture", 17002.0).lookup + bob = ThreadIncarnation("fixture", 17003.0).lookup witness, rows, more = _page(comms, alice, limit=1) assert (witness.root_id, witness.through_seq, [r.message.seq for r in rows], more) == ( root_id, @@ -138,7 +138,7 @@ def test_marker_bound_complete_addressed_pages(tmp_path: Path) -> None: ) assert [r.message.seq for r in _page(comms, alice, after=one.seq)[1]] == [three.seq] assert [r.message.seq for r in _page(comms, bob)[1]] == [one.seq, three.seq] - assert _page(comms, stable_thread_lookup(17004.0))[1][0].message.seq == two.seq + assert _page(comms, ThreadIncarnation("fixture", 17004.0).lookup)[1][0].message.seq == two.seq assert witness.offset == comms.bus.log.path.stat().st_size assert witness.latest_source_seq == three.seq assert witness.offset > 0 @@ -186,7 +186,7 @@ def test_crash_after_bus_fsync_before_checkpoint_cold_recovers(tmp_path: Path, m comms, _ = _root(tmp_path) first = comms.messaging.send_initial_cohort("sender", "Alice", "first") - saved = _page(comms, stable_thread_lookup(17002.0))[0] + saved = _page(comms, ThreadIncarnation("fixture", 17002.0).lookup)[0] with monkeypatch.context() as patch: patch.setattr( checkpoint, @@ -197,7 +197,7 @@ def test_crash_after_bus_fsync_before_checkpoint_cold_recovers(tmp_path: Path, m comms.messaging.send_initial_cohort("sender", "Alice", "second") bus = comms.bus.log.path assert bus.stat().st_size > saved.offset - witness, rows, more = _page(comms, stable_thread_lookup(17002.0)) + witness, rows, more = _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) assert [row.message.seq for row in rows] == [first.seq, first.seq + 1] assert witness.offset == bus.stat().st_size and not more assert witness.digest != saved.digest @@ -270,7 +270,7 @@ def test_early_prefix_edit_plus_complete_lagged_suffix_denied(tmp_path: Path, mo stream.flush() os.fsync(stream.fileno()) with pytest.raises(RelationViolationError, match="certified prefix changed"): - _page(comms, stable_thread_lookup(17002.0)) + _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) assert bus.read_bytes().endswith(b"\n") @@ -328,7 +328,7 @@ def test_checkpoint_faults_deny_without_rebuild(tmp_path: Path, fault: str) -> N else: db.execute("UPDATE prefix_certificate SET tail=?", ("0" * 64,)) with pytest.raises(RelationViolationError): - _page(comms, stable_thread_lookup(17002.0)) + _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) if fault not in {"schema", "ahead", "root", "tail", "corrupt", "missing"}: assert db_path.read_bytes() == original @@ -342,13 +342,13 @@ def test_unknown_public_writer_cannot_make_initial_absent_audience(tmp_path: Pat stream.flush() os.fsync(stream.fileno()) with pytest.raises(RelationViolationError, match="Unattested public initial"): - _page(comms, stable_thread_lookup(17002.0)) + _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) def test_deleted_sql_index_cannot_certify_absent_selected_source(tmp_path: Path) -> None: comms, _ = _root(tmp_path) source = comms.messaging.send_initial_cohort("sender", "Alice", "mandatory selected") - lookup = stable_thread_lookup(17002.0) + lookup = ThreadIncarnation("fixture", 17002.0).lookup assert [item.message.seq for item in _page(comms, lookup)[1]] == [source.seq] with sqlite3.connect(comms.root / "private_bus_checkpoint.sqlite3") as db: db.execute("DELETE FROM addressed WHERE lookup=? AND seq=?", (lookup, source.seq)) @@ -374,7 +374,7 @@ def test_sql_index_change_plus_complete_bus_suffix_still_denies( with sqlite3.connect(comms.root / "private_bus_checkpoint.sqlite3") as db: db.execute("DELETE FROM addressed") with pytest.raises(RelationViolationError, match="index seal changed"): - _page(comms, stable_thread_lookup(17002.0)) + _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) def test_sql_index_change_during_page_is_not_exhaustiveness(tmp_path: Path, monkeypatch) -> None: @@ -398,7 +398,7 @@ def mutate_after_original_read(cursor): # Mutate after original row resolution, without assuming a second connection. monkeypatch.setattr(checkpoint.DeliverySources, "read", mutate_after_original_read) with pytest.raises(RelationViolationError, match="index seal changed"): - _page(comms, stable_thread_lookup(17002.0)) + _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) @pytest.mark.parametrize("phase", ["before_db_commit", "after_db_commit"]) @@ -424,7 +424,7 @@ def test_pending_marker_recovers_only_after_canonical_cold_parse( with pytest.raises(RelationViolationError, match="outcome UNKNOWN"): comms.messaging.send_initial_cohort("sender", "Alice", "second") monkeypatch.undo() - witness, rows, more = _page(comms, stable_thread_lookup(17002.0)) + witness, rows, more = _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) assert [item.message.seq for item in rows] == [1, 2] assert witness.through_seq == 2 and not more assert isinstance(comms.bus.log._private_marker_unlocked().seal, checkpoint.FinalSeal) @@ -445,7 +445,7 @@ def test_pending_checkpoint_does_not_repair_corrupt_sql_schema(tmp_path: Path, m with sqlite3.connect(comms.root / "private_bus_checkpoint.sqlite3") as db: db.execute("DROP TABLE addressed") with pytest.raises(RelationViolationError, match="schema is unavailable"): - _page(comms, stable_thread_lookup(17002.0)) + _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) def test_over_1000_initials_and_8mib_complete_lookup(tmp_path: Path, monkeypatch) -> None: @@ -457,13 +457,13 @@ def test_over_1000_initials_and_8mib_complete_lookup(tmp_path: Path, monkeypatch Message("sender", "#team", f"@Alice item-{index} " + "x" * 7900, InfoKind) ) assert comms.bus.log.path.stat().st_size > 8 * 1024 * 1024 - lookup = stable_thread_lookup(17002.0) + lookup = ThreadIncarnation("fixture", 17002.0).lookup warm_start = time.monotonic() witness, page, more = _page(comms, lookup, after=900, limit=100) warm_ms = (time.monotonic() - warm_start) * 1000 assert (witness.through_seq, len(page), page[0].message.seq, more) == (1002, 100, 901, True) assert [row.message.seq for row in _page(comms, lookup, after=1000)[1]] == [1001, 1002] - assert _page(comms, stable_thread_lookup(17004.0))[1] == () + assert _page(comms, ThreadIncarnation("fixture", 17004.0).lookup)[1] == () # Existing capped readers still fail closed; only the explicit certified # source API supplies a complete-prefix+bounded-page alternative. with ( @@ -501,7 +501,7 @@ def test_reopened_root_preserves_certified_source_and_supports_append( original = comms.bus.log.path.read_bytes() inode = comms.bus.log.path.stat().st_ino reopened = Comms(comms.root) - witness = _page(reopened, stable_thread_lookup(17002.0))[0] + witness = _page(reopened, ThreadIncarnation("fixture", 17002.0).lookup)[0] assert (witness.root_id, witness.through_seq, witness.offset) == ( root_id, two.seq, @@ -509,10 +509,10 @@ def test_reopened_root_preserves_certified_source_and_supports_append( ) assert comms.bus.log.path.read_bytes() == original assert comms.bus.log.path.stat().st_ino == inode - assert [r.message.seq for r in _page(reopened, stable_thread_lookup(17002.0))[1]] == [one.seq] + assert [r.message.seq for r in _page(reopened, ThreadIncarnation("fixture", 17002.0).lookup)[1]] == [one.seq] three = reopened.messaging.send_initial_cohort("sender", "Alice", "after installation") assert three.seq == two.seq + 1 - assert [r.message.seq for r in _page(reopened, stable_thread_lookup(17002.0))[1]] == [ + assert [r.message.seq for r in _page(reopened, ThreadIncarnation("fixture", 17002.0).lookup)[1]] == [ one.seq, three.seq, ] @@ -528,7 +528,7 @@ def test_warm_witness_rejects_changed_revision_even_with_complete_row(tmp_path: stream.flush() os.fsync(stream.fileno()) with pytest.raises(RelationViolationError, match="prefix tail changed"): - _page(comms, stable_thread_lookup(17002.0)) + _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) def test_shared_reader_hands_lagged_suffix_to_exclusive_repair(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_private_checkpoint_cursor_integration.py b/tests/test_private_checkpoint_cursor_integration.py index 0acdea5364..11089c1425 100644 --- a/tests/test_private_checkpoint_cursor_integration.py +++ b/tests/test_private_checkpoint_cursor_integration.py @@ -12,7 +12,7 @@ import pytest from agent_comms import coordinated_runtime as runtime -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -72,7 +72,7 @@ def _root(tmp_path: Path): install_private_response_schema(store) install_native_runtime_schema(store) install_prompt_binding_schema(store) - lookup = stable_thread_lookup(people[1].created_at) + lookup = ThreadIncarnation("fixture", people[1].created_at).lookup store.participants.register(lookup, "alpha", "alpha", committed=True) accept_delivery_cohort(comms.bus, root_id, first.seq, store) return root, root_id, comms, first, lookup diff --git a/tests/test_private_human_ingress.py b/tests/test_private_human_ingress.py index 247143b766..0d610e181f 100644 --- a/tests/test_private_human_ingress.py +++ b/tests/test_private_human_ingress.py @@ -11,7 +11,8 @@ import pytest -from agent_comms.bus_publication import HumanOrigin, stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.bus_publication import HumanOrigin from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms, wire from agent_comms.coordination_cohort import accept_delivery_cohort @@ -38,7 +39,7 @@ def _root(tmp_path: Path) -> tuple[Comms, Coordination, str, dict[str, str]]: lookups: dict[str, str] = {} for name, created_at in (("alice", 17001.0), ("bob", 17002.0)): comms.registry.register(Thread(name, frozenset({"team"}), str(root), created_at=created_at)) - lookups[name] = stable_thread_lookup(created_at) + lookups[name] = ThreadIncarnation("fixture", created_at).lookup store = Coordination(str(root / "coordination.sqlite"), clock_ms=lambda: 9999) install_private_cohort_schema(store) for name in lookups: diff --git a/tests/test_private_idle_drain.py b/tests/test_private_idle_drain.py index 6728f8d702..3d3044c3c4 100644 --- a/tests/test_private_idle_drain.py +++ b/tests/test_private_idle_drain.py @@ -10,7 +10,7 @@ from agent_comms import acp, cohort_foreground, coordinated_runtime, native_pi from agent_comms import agent_events as events -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.coordinator import Coordination from agent_comms.coordination_cohort import next_sealed_assignment from agent_comms.native_source_cursor import NativeSourceCursor @@ -200,7 +200,7 @@ def _covered_session(tmp_path): comms.registry.declare(other) with Coordination(comms.root / "coordination.sqlite3") as store: store.participants.register( - stable_thread_lookup(other.created_at), other.name, other.name, committed=True, + ThreadIncarnation("fixture", other.created_at).lookup, other.name, other.name, committed=True, ) comms.messaging.send_initial_cohort("sender", other.name, "another recipient's original source") return comms, agent, root_id @@ -506,7 +506,7 @@ async def observed(*args): # Actual recovery/reassignment of beta must still invalidate the # observation without a wire append or a provider request. beta = comms.registry.require("beta") - person = store.participants.get(stable_thread_lookup(beta.created_at)) + person = store.participants.get(ThreadIncarnation("fixture", beta.created_at).lookup) store.participants.advance_generation( person.lookup, beta.name, expected_generation=person.participant_generation, ) @@ -525,11 +525,11 @@ async def observed(*args): assert first.seq < second.seq with Coordination(comms.root / "coordination.sqlite3") as store: await cohort_foreground._accept_visible_deliveries( - comms.bus, _root_id, store.session.path, stable_thread_lookup(beta.created_at), 0, + comms.bus, _root_id, store.session.path, ThreadIncarnation("fixture", beta.created_at).lookup, 0, owner_name=beta.name, ) pending = next_sealed_assignment( - store, stable_thread_lookup(beta.created_at), beta.name, + store, ThreadIncarnation("fixture", beta.created_at).lookup, beta.name, ) assert pending.wire_seq == second.seq assert await agent.inputs._observe_private_revision("beta", _root_id) != before @@ -564,7 +564,7 @@ async def paused(*args): @pytest.mark.asyncio async def test_sealed_cohorts_are_not_reaccepted_or_rewritten(tmp_path, monkeypatch): root, root_id, comms, original, people = _root(tmp_path, direct=True) - lookup = stable_thread_lookup(people[2].created_at) + lookup = ThreadIncarnation("fixture", people[2].created_at).lookup accepted = [] accept = cohort_foreground.accept_delivery_cohort diff --git a/tests/test_private_nk_entrypoint.py b/tests/test_private_nk_entrypoint.py index 218d9627e2..a0ba230e34 100644 --- a/tests/test_private_nk_entrypoint.py +++ b/tests/test_private_nk_entrypoint.py @@ -20,7 +20,7 @@ private_nk_entrypoint, worker, ) -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ParentedProcess, ProcessIdentity from agent_comms.comms import Comms, wire from agent_comms.coordination_cohort import accept_delivery_cohort @@ -551,7 +551,7 @@ def test_late_private_owner_can_accept_first_message_before_worker_spawn(tmp_pat comms.owners.pin_private_nk_launch(root, root_id, tmp_path) comms.registry.declare(Thread("late-owner", frozenset(), str(tmp_path))) late = comms.registry.require("late-owner") - lookup = stable_thread_lookup(late.created_at) + lookup = ThreadIncarnation("fixture", late.created_at).lookup message = comms.messaging.send_initial_cohort("sender", "late-owner", "fresh private task") with Coordination(str(root / "coordination.sqlite3")) as store: participant = store.participants.get(lookup) diff --git a/tests/test_project_resume.py b/tests/test_project_resume.py index caa50a9743..4a36dbaf43 100644 --- a/tests/test_project_resume.py +++ b/tests/test_project_resume.py @@ -11,13 +11,13 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire from agent_comms.coordination_errors import StaleFence from agent_comms.coordinator import Coordination from agent_comms.field_codec import FieldCodec from agent_comms.messages import Message, InfoKind -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.threads import Thread from agent_comms.turn_phase import CancellingPhase from agent_comms.schedule_rules import ProjectResumeCheck, WakeScheduleCheck @@ -187,7 +187,7 @@ async def wait(self): # The turn that changed project recorded its result: this owner's # coordination pointer is free, so later messages can start turns. with Coordination(str(comms.root / "coordination.sqlite3")) as store: - participant = store.participants.get(stable_thread_lookup(thread.created_at)) + participant = store.participants.get(ThreadIncarnation("fixture", thread.created_at).lookup) assert participant.pointer.execution_id is None # The second message's turn started in the new project. assert str(second) in json.dumps(native.provider.requests[-1]) diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 0e30136581..7834987c1d 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -1,11 +1,16 @@ """Registry snapshots own recipient lookup; activity reads own their requested set.""" +import hashlib +from dataclasses import replace from unittest.mock import patch +import pytest + from agent_comms.activity import Activity, ActivityLog, ActivityState from agent_comms.agent_activity import UnavailableRecipientActivity from agent_comms.audience_manifest import FrozenRecipient from agent_comms.comms import Comms +from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec from agent_comms.thread_identity import ThreadIncarnation from agent_comms.threads import Thread @@ -18,10 +23,10 @@ def test_recipient_index_is_built_once_and_reused_for_small_window(tmp_path): snapshot = comms.registry.snapshot() assert FieldCodec.encode(snapshot)["threads"] wanted = snapshot.threads["peer-7"] - lookup = wanted.incarnation.stable_thread_lookup - assert snapshot.thread_for_lookup(lookup) is wanted - assert snapshot.thread_for_lookup("not-a-recipient") is None - with patch.object(ThreadIncarnation, "stable_thread_lookup", property( + lookup = wanted.incarnation.lookup + assert snapshot.by_lookup[lookup] is wanted + assert snapshot.by_lookup.get("not-a-recipient") is None + with patch.object(ThreadIncarnation, "lookup", property( lambda self: (_ for _ in ()).throw(AssertionError("rehashing unrelated registry")) )): observations = comms.agents.observe_recipients( @@ -33,6 +38,43 @@ def test_recipient_index_is_built_once_and_reused_for_small_window(tmp_path): assert isinstance(observations["not-a-recipient"], UnavailableRecipientActivity) +def test_creation_lookup_is_cached_on_the_renamable_incarnation(tmp_path): + thread = Thread("worker", frozenset(), str(tmp_path), created_at=1_700_000_000.0) + incarnation = thread.incarnation + assert thread.incarnation is incarnation + original_sha = hashlib.sha256 + with patch("agent_comms.thread_identity.hashlib.sha256", wraps=original_sha) as hashed: + first = incarnation.lookup + assert incarnation.lookup == first + assert hashed.call_count == 1 + renamed = replace(thread, name="renamed") + assert renamed.incarnation.lookup == first + assert FieldCodec.encode(incarnation) == {"name": "worker", "created_at": 1_700_000_000.0} + with pytest.raises(ValueError, match="positive recorded birth"): + assert ThreadIncarnation.deleted("worker").lookup + with pytest.raises(ValueError, match="positive recorded birth"): + assert ThreadIncarnation("historical", 0.0).lookup + + +def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_owner(tmp_path): + comms = Comms(tmp_path / "bus") + for name, birth in (("old", 1700.0), ("second", 1701.0)): + comms.registry.declare(Thread(name, frozenset(), str(tmp_path), created_at=birth)) + source = comms.registry.snapshot() + archived = replace(source, threads={ + **source.threads, + "second": replace(source.threads["second"], created_at=1700.0), + }) + lookup = source.threads["old"].incarnation.lookup + assert archived.by_lookup.get(lookup) is None + assert archived.thread_for_recipient(lookup, "old") is archived.threads["old"] + assert archived.thread_for_recipient(lookup, "second") is archived.threads["second"] + assert archived.thread_for_recipient(lookup, "missing") is None + assert archived.thread_for_recipient("wrong-lookup", "old") is None + with pytest.raises(RelationViolationError, match="creation identities collide"): + archived.require_unambiguous_ownership() + + def test_activity_log_projects_only_requested_threads(tmp_path): log = ActivityLog(tmp_path / "activity.jsonl") for index in range(10): diff --git a/tests/test_retained_native_tool_latency.py b/tests/test_retained_native_tool_latency.py index 031f48596a..9d7381db19 100644 --- a/tests/test_retained_native_tool_latency.py +++ b/tests/test_retained_native_tool_latency.py @@ -14,7 +14,7 @@ import pytest -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import AttachedChild from agent_comms.coordinated_runtime import SelectedExecution from agent_comms.coordination_errors import CoordinationError @@ -67,7 +67,7 @@ async def test_installed_retained_native_read_round_preserves_original_custody(t assert original.size >= 40 * 1024**2, "This acceptance requires representative retained history" root, root_id, comms, initial, _ = _root(tmp_path, direct=True, body="Perform three isolated fixture reads, then answer once.") owner = comms.registry.require("beta") - directory = root / "native-sessions" / stable_thread_lookup(owner.created_at) + directory = root / "native-sessions" / ThreadIncarnation("fixture", owner.created_at).lookup directory.mkdir(parents=True, mode=0o700) saved = directory / source.name shutil.copyfile(source, saved) diff --git a/tests/test_roster_restoration.py b/tests/test_roster_restoration.py index 9ab371d417..2700841c0b 100644 --- a/tests/test_roster_restoration.py +++ b/tests/test_roster_restoration.py @@ -5,6 +5,7 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.display_order import LastActivityThreadSort @@ -131,7 +132,6 @@ def test_catalog_restore_preserves_current_preferences_and_historical_channels(t def test_private_restoration_allows_new_cohort_without_starting_old_subscribers( tmp_path, repair_existing ): - from agent_comms.bus_publication import stable_thread_lookup from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.coordination_cohort import accept_delivery_cohort from agent_comms.coordinator import Coordination @@ -156,7 +156,7 @@ def test_private_restoration_allows_new_cohort_without_starting_old_subscribers( with Coordination(str(current.root / "coordination.sqlite3")) as store: install_private_cohort_schema(store) live_before = store.participants.register( - stable_thread_lookup(live.created_at), live.name, live.name, committed=True + ThreadIncarnation("fixture", live.created_at).lookup, live.name, live.name, committed=True ).value if repair_existing: current.registry.restore_stopped(source, (missing.name,)) @@ -173,9 +173,9 @@ def test_private_restoration_allows_new_cohort_without_starting_old_subscribers( assert snapshot.statuses[missing.name] == StoppedThreadStatus() assert snapshot.threads[missing.name].pid == 0 assert snapshot.threads[live.name] == live - assert store.participants.get(stable_thread_lookup(live.created_at)) == live_before + assert store.participants.get(ThreadIncarnation("fixture", live.created_at).lookup) == live_before assert ( - store.participants.get(stable_thread_lookup(missing.created_at)).pointer.execution_id is None + store.participants.get(ThreadIncarnation("fixture", missing.created_at).lookup).pointer.execution_id is None ) if bus_before is not None: assert current.bus.log.path.read_bytes() == bus_before diff --git a/tests/test_selected_answer_forms.py b/tests/test_selected_answer_forms.py index 2d7535d6c8..bc45392c00 100644 --- a/tests/test_selected_answer_forms.py +++ b/tests/test_selected_answer_forms.py @@ -18,7 +18,7 @@ import pytest -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -60,7 +60,7 @@ def alice_batch(*sent: tuple[str, str]): ) for message in messages: accept_delivery_cohort(comms.bus, root_id, message.seq, store) - lookup = stable_thread_lookup(comms.registry.require("Alice").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("Alice").created_at).lookup assignments = sealed_cohort_assignments(store, lookup) yield SelectedSourceBatch.first_route(tuple( SelectedSource(assignment.assignment_id, store.assignments, delivery) diff --git a/tests/test_selected_execution_native.py b/tests/test_selected_execution_native.py index abd02393cc..f501ccf3a0 100644 --- a/tests/test_selected_execution_native.py +++ b/tests/test_selected_execution_native.py @@ -11,7 +11,7 @@ import pytest -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.coordinated_runtime import SelectedExecution from agent_comms.coordination_cohort import accept_delivery_cohort from agent_comms.coordination_errors import IdentityConflict @@ -242,7 +242,7 @@ def log_message(self, *_args): proofs = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(owner.created_at), + recipient_lookup=ThreadIncarnation("fixture", owner.created_at).lookup, source_seq=initial.message.seq, ) assert len(proofs) == 1 + triage @@ -267,7 +267,7 @@ def log_message(self, *_args): read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=stable_thread_lookup(owner.created_at), + recipient_lookup=ThreadIncarnation("fixture", owner.created_at).lookup, source_seq=old_seq, ) == () diff --git a/tests/test_selected_source_batch.py b/tests/test_selected_source_batch.py index e3d3120326..f50826d4f8 100644 --- a/tests/test_selected_source_batch.py +++ b/tests/test_selected_source_batch.py @@ -8,7 +8,7 @@ import pytest -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.coordination_cohort import accept_delivery_cohort, pending_sealed_assignments @@ -37,7 +37,7 @@ async def test_original_pending_wave_has_one_fenced_input_and_late_arrivals_stay model="openai-codex/gpt-6-sol", )) root_id = comms.messaging.initialize_private_initial_protocol() - lookup = stable_thread_lookup(comms.registry.require("receiver").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("receiver").created_at).lookup with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register(lookup, "receiver", "receiver", committed=True) @@ -147,7 +147,7 @@ async def test_a_mixed_route_wave_turns_one_route_at_a_time(tmp_path): model="openai-codex/gpt-6-sol", )) root_id = comms.messaging.initialize_private_initial_protocol() - lookup = stable_thread_lookup(comms.registry.require("receiver").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("receiver").created_at).lookup with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register(lookup, "receiver", "receiver", committed=True) @@ -191,7 +191,7 @@ async def test_terminal_triage_failure_records_context_and_fails_its_claims(tmp_ model="openai-codex/gpt-6-sol", )) root_id = comms.messaging.initialize_private_initial_protocol() - lookup = stable_thread_lookup(comms.registry.require("receiver").created_at) + lookup = ThreadIncarnation("fixture", comms.registry.require("receiver").created_at).lookup session = NativeSessionIdentity("native-session", str(tmp_path / "session.jsonl")) with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register(lookup, "receiver", "receiver", committed=True) diff --git a/tests/test_selected_tool_runner_hook.py b/tests/test_selected_tool_runner_hook.py index 2444486cc0..ea39edeaed 100644 --- a/tests/test_selected_tool_runner_hook.py +++ b/tests/test_selected_tool_runner_hook.py @@ -10,8 +10,8 @@ import pytest from agent_comms import coordinated_runtime as runtime +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.acp import CommsAgent -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.comms import Comms from agent_comms.coordination_errors import IdentityConflict, StaleFence from agent_comms.coordinator import Coordination @@ -213,7 +213,7 @@ async def observed(*args, **kwargs): admission, owner, session_dir, input_id = bound[0] assert owner == "beta" and admission.source_seq == initial.message.seq assert admission.source_message_id == initial.message.message_id - assert admission.recipient_lookup == stable_thread_lookup(people[2].created_at) + assert admission.recipient_lookup == ThreadIncarnation("fixture", people[2].created_at).lookup assert admission.wire_root_id == root_id and admission.attempt_ordinal == 1 assert input_id == kwargs_seen[0]["input_id"] and session_dir == kwargs_seen[0]["session_dir"] assert "selected_claimed_write at most once" in calls[0][1] diff --git a/tests/test_session_components.py b/tests/test_session_components.py index 881339fb27..a933405300 100644 --- a/tests/test_session_components.py +++ b/tests/test_session_components.py @@ -7,8 +7,8 @@ from acp.agent.router import build_agent_router from acp.schema import SessionConfigSelectOption +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.acp import CommsClient -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.coordinator import Coordination @@ -64,7 +64,7 @@ async def test_state_is_owned_once_and_attachments_do_not_share_negotiation(owne thread = owner._comms.registry.require(session.session_id) assert thread.process_identity == ProcessIdentity.capture(os.getpid()) with Coordination(str(owner._comms.root / "coordination.sqlite3")) as store: - participant = store.participants.get(stable_thread_lookup(thread.created_at)) + participant = store.participants.get(ThreadIncarnation("fixture", thread.created_at).lookup) assert participant.committed and participant.owner_thread == thread.name assert participant.pointer.execution_id is None assert session.session_id not in owner.sessions.config.publications @@ -93,7 +93,7 @@ async def test_attached_declaration_publishes_membership_without_acquiring_execu assert owner._comms.registry.status(thread.name).active assert not attachment.sessions.bindings and not attachment.sessions.proxies with Coordination(str(owner._comms.root / "coordination.sqlite3")) as store: - participant = store.participants.get(stable_thread_lookup(thread.created_at)) + participant = store.participants.get(ThreadIncarnation("fixture", thread.created_at).lookup) assert participant.committed and participant.owner_thread == thread.name assert participant.pointer.execution_id is None finally: diff --git a/tests/test_turn_context_wire.py b/tests/test_turn_context_wire.py index e5de7618b9..0c19bb746a 100644 --- a/tests/test_turn_context_wire.py +++ b/tests/test_turn_context_wire.py @@ -5,6 +5,7 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.agent_events import AgentInfo from agent_comms.field_codec import FieldCodec from agent_comms.thread_identity import TurnId, TurnIdentity @@ -15,7 +16,6 @@ from agent_comms.cli_commands import ContextCliCommand from agent_comms.comms import Comms from test_private_bus_checkpoint import _root, _page -from agent_comms.bus_publication import stable_thread_lookup def manifest(owner, generation=1): @@ -206,7 +206,7 @@ def hold_original_writer(): try: with Coordination(str(comms.root / 'coordination.sqlite3')) as store: for registered in comms.registry.snapshot().threads.values(): - store.participants.register(stable_thread_lookup(registered.created_at), + store.participants.register(ThreadIncarnation("fixture", registered.created_at).lookup, registered.name, registered.name, committed=True) accept_delivery_cohort(comms.bus, root_id, original_message.seq, store) await publish_while_contended(progress, ContextObserved(data), cancel=True) @@ -245,7 +245,7 @@ def hold_original_writer(): def test_silent_manifest_continuous_original_message_and_cold_projection(tmp_path, monkeypatch): comms,root_id=_root(tmp_path) first=comms.messaging.send_initial_cohort('sender','#team','@Alice original question') - lookup=stable_thread_lookup(17002.0) + lookup=ThreadIncarnation("fixture", 17002.0).lookup prior=_page(comms,lookup) raw=comms.bus.log.path.read_bytes() activity=comms.bus.channel_activity() diff --git a/tests/test_wake_candidate_index.py b/tests/test_wake_candidate_index.py index a73abaa998..d5d5d8bc83 100644 --- a/tests/test_wake_candidate_index.py +++ b/tests/test_wake_candidate_index.py @@ -11,10 +11,10 @@ import pytest +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.bus_publication import ( PRIVATE_WIRE_FIELD, public_envelope_digest, - stable_thread_lookup, ) from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms @@ -75,7 +75,7 @@ def _private(tmp_path: Path) -> tuple[Comms, str, dict[str, str]]: ) ) root_id = comms.messaging.initialize_private_initial_protocol() - lookup = {name: stable_thread_lookup(identity) for name, identity in created.items()} + lookup = {name: ThreadIncarnation("fixture", identity).lookup for name, identity in created.items()} return comms, root_id, lookup diff --git a/tests/test_wake_candidate_index_incremental.py b/tests/test_wake_candidate_index_incremental.py index 654c1a7b59..4088ada6f9 100644 --- a/tests/test_wake_candidate_index_incremental.py +++ b/tests/test_wake_candidate_index_incremental.py @@ -10,7 +10,7 @@ import pytest -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.threads import Thread @@ -59,7 +59,7 @@ def _fresh(tmp_path: Path, *, recipients: int = 1) -> tuple[Comms, WakeCandidate ) ) root_id = comms.messaging.initialize_private_initial_protocol() - return comms, WakeCandidateIndex(comms.bus.log), root_id, stable_thread_lookup(1_700_010_100.0) + return comms, WakeCandidateIndex(comms.bus.log), root_id, ThreadIncarnation("fixture", 1_700_010_100.0).lookup def _page( diff --git a/tests/wire_row_application_journey.py b/tests/wire_row_application_journey.py index e214b3af53..6b373e96f8 100644 --- a/tests/wire_row_application_journey.py +++ b/tests/wire_row_application_journey.py @@ -13,7 +13,7 @@ from pathlib import Path import agent_comms -from agent_comms.bus_publication import stable_thread_lookup +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.threads import Thread @@ -54,7 +54,7 @@ def main(root: Path) -> None: # already-retired production maintenance worker. assert index.maintain(rebuild=True, max_bytes=1) page = index.page( - root_id=root_id, recipient_lookup=stable_thread_lookup(births["Alice"]), + root_id=root_id, recipient_lookup=ThreadIncarnation("fixture", births["Alice"]).lookup, after_seq=0, required_through_seq=message.seq, ) assert len(page.entries) == 1 diff --git a/tools/cutover/thread_retirement_installed_pilot.py b/tools/cutover/thread_retirement_installed_pilot.py index 69bd2191a9..6c41d2e2b1 100644 --- a/tools/cutover/thread_retirement_installed_pilot.py +++ b/tools/cutover/thread_retirement_installed_pilot.py @@ -12,7 +12,6 @@ import sys from agent_comms.active_route import read_active_route -from agent_comms.bus_publication import stable_thread_lookup from agent_comms.child_process import ObservedProcess, ProcessIdentity from agent_comms.comms import Comms from agent_comms.compaction_records import PrivateRawInput @@ -138,7 +137,7 @@ def main(): assert not Path(f'/proc/{owner.pid}/task/{owner.pid}/children').read_text().strip() with Coordination(str(root/'coordination.sqlite3')) as store: owner = service.registry.require('phase-alpha') - assert next_sealed_assignment(store, stable_thread_lookup(owner.created_at), owner.name, + assert next_sealed_assignment(store, owner.incarnation.lookup, owner.name, after_seq=0) is None, 'Historical failed reservation became a fresh native attempt' assert historical_reservation(root, source['historical_input_id']) == source['historical_reservation_sha256'] assert all(row.public_status == 'unknown' From 34b6ee7b8cb9d0d85f17b281650e8a6a5a754930 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 11:26:38 -0400 Subject: [PATCH 05/18] Separate recorded lookup capability from deleted and unrecorded births --- src/agent_comms/goal_presentation.py | 8 +- src/agent_comms/goal_waits.py | 6 +- src/agent_comms/registry_document.py | 9 ++- src/agent_comms/thread_identity.py | 107 +++++++++++++++++-------- tests/test_recipient_activity_index.py | 29 ++++++- 5 files changed, 114 insertions(+), 45 deletions(-) diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 9dac99200d..71d7496b6e 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -18,7 +18,7 @@ UnrecordedBlockGoal, ) from .goals import Goal -from .thread_identity import ThreadIncarnation +from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation from .thread_presentation import ThreadPresentation if TYPE_CHECKING: @@ -87,10 +87,12 @@ def for_domain_state(cls, state: GoalState) -> GoalExecutionState: class GoalWaitTarget: name: str created_at: float - _incarnation: ThreadIncarnation = field(init=False, repr=False, compare=False) + _incarnation: RecordedThreadIncarnation = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: - object.__setattr__(self, "_incarnation", ThreadIncarnation(self.name, self.created_at)) + object.__setattr__( + self, "_incarnation", ThreadIncarnation(self.name, self.created_at).require_recorded() + ) def sent(self, original: CommittedDelivery) -> bool: """Only this declared incarnation can supply a dependency reply.""" diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index 0646b6889c..86d8a63959 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -13,7 +13,7 @@ from .input_attempt import InputAttempt from .locked_store import LockedStore from .registry_document import RegistrySnapshot -from .thread_identity import ThreadIncarnation +from .thread_identity import RecordedThreadIncarnation from .thread_owned_state import ThreadOwnedState from .wire_log import WireLog @@ -49,7 +49,9 @@ def __post_init__(self) -> None: ): raise ValueError("A wait requires its owner incarnation and aligned target turns.") object.__setattr__( - self, "_owner_lookup", ThreadIncarnation._lookup_for_birth(self.owner_created_at) + self, + "_owner_lookup", + RecordedThreadIncarnation._lookup_for_birth(self.owner_created_at), ) if (self.report_turn_id is None) != (self.report_turn_generation is None) or ( self.report_turn_id is not None diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 324bd80973..b9e6927d0b 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -546,7 +546,10 @@ def __post_init__(self) -> None: indexed: dict[str, Thread] = {} ambiguous: set[str] = set() for thread in self.threads.values(): - lookup = thread.incarnation.lookup + incarnation = thread.incarnation + if not incarnation.has_recorded_birth: + continue + lookup = incarnation.require_recorded().lookup if lookup in indexed: del indexed[lookup] ambiguous.add(lookup) @@ -559,6 +562,8 @@ def require_unambiguous_ownership(self) -> None: """Archived collisions stay readable; publication requires unique owners.""" if self._ambiguous_lookups: raise RelationViolationError("Registry creation identities collide.") + if len(self.by_lookup) != len(self.threads): + raise RelationViolationError("Registry creation identities are unrecorded.") def thread_for_recipient(self, lookup: str, name: str) -> Thread | None: """Resolve a frozen recipient, including same-birth historical aliases.""" @@ -566,7 +571,7 @@ def thread_for_recipient(self, lookup: str, name: str) -> Thread | None: thread = self.by_lookup.get(lookup) if thread is None and lookup in self._ambiguous_lookups: thread = self.threads.get(canonical) - if thread is not None and thread.incarnation.lookup != lookup: + if thread is not None and thread.incarnation.require_recorded().lookup != lookup: return None return thread if thread is not None and thread.name == canonical else None diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 64c711a8c4..ee231b4ecb 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -10,6 +10,7 @@ import math from dataclasses import dataclass, field, replace from enum import StrEnum +from functools import cached_property from typing import TYPE_CHECKING, ClassVar from .errors import RelationViolationError @@ -22,19 +23,28 @@ class ThreadIncarnation: name: str created_at: float - _lookup: str | None = field(default=None, init=False, repr=False, compare=False) + + def __new__(cls, name: str, created_at: float) -> ThreadIncarnation: + if cls is ThreadIncarnation and type(created_at) in (int, float): + if created_at > 0: + return object.__new__(RecordedThreadIncarnation) + if created_at == cls.DELETED_BIRTH: + return object.__new__(DeletedThreadIncarnation) + if created_at == 0: + return object.__new__(UnrecordedThreadIncarnation) + return object.__new__(cls) # The persisted birth of a deleted participant: its declaration and real # birth are gone, its name stays on the bus. See DeletedThread. DELETED_BIRTH: ClassVar[float] = -1.0 @classmethod - def deleted(cls, name: str) -> ThreadIncarnation: - return cls(name, cls.DELETED_BIRTH) + def deleted(cls, name: str) -> DeletedThreadIncarnation: + return DeletedThreadIncarnation(name, cls.DELETED_BIRTH) @property def names_deleted_thread(self) -> bool: - return self.created_at == self.DELETED_BIRTH + return isinstance(self, DeletedThreadIncarnation) def __post_init__(self) -> None: from .field_codec import FieldCodec @@ -43,40 +53,17 @@ def __post_init__(self) -> None: raise ValueError("Thread incarnation requires a recorded name") if not math.isfinite(FieldCodec.decode(float, self.created_at)): raise ValueError("Thread incarnation requires a finite birth") - - @property - def lookup(self) -> str: - """The creation lookup of a recorded birth, computed once per incarnation.""" - if (cached := self._lookup) is not None: - return cached - lookup = self._lookup_for_birth(self.created_at) - object.__setattr__(self, "_lookup", lookup) - return lookup - - @staticmethod - def _require_recorded_birth(created_at: float) -> float: - if ( - isinstance(created_at, bool) - or not isinstance(created_at, (int, float)) - or not math.isfinite(created_at) - or created_at <= 0 - ): - raise ValueError("Thread incarnation requires a positive recorded birth") - return float(created_at) - - @staticmethod - def _lookup_for_birth(created_at: float) -> str: - """The retained birth-only GoalWait format uses this same identity rule.""" - birth = ThreadIncarnation._require_recorded_birth(created_at) - identity = f"agent-comms:thread-creation:v1\0{birth!r}".encode() - return hashlib.sha256(identity).hexdigest()[:32] + if type(self) is ThreadIncarnation: + raise ValueError("Thread incarnation requires a declared birth state") @property def has_recorded_birth(self) -> bool: - return self.created_at > 0 + return isinstance(self, RecordedThreadIncarnation) - def require_recorded(self) -> None: - self._require_recorded_birth(self.created_at) + def require_recorded(self) -> RecordedThreadIncarnation: + if not isinstance(self, RecordedThreadIncarnation): + raise ValueError("Thread incarnation requires a positive recorded birth") + return self def resolved(self, snapshot: RegistryProvenance) -> ThreadIncarnation: """Follow retained rename aliases only for this exact historical owner.""" @@ -109,6 +96,58 @@ def recorded_names(self, snapshot: RegistryProvenance) -> tuple[ThreadIncarnatio if name == canonical.name)) +@dataclass(frozen=True) +class RecordedThreadIncarnation(ThreadIncarnation): + """Only a positive recorded birth can select a durable lookup.""" + + def __post_init__(self) -> None: + super().__post_init__() + self._require_recorded_birth(self.created_at) + + @staticmethod + def _require_recorded_birth(created_at: float) -> float: + if ( + isinstance(created_at, bool) + or not isinstance(created_at, (int, float)) + or not math.isfinite(created_at) + or created_at <= 0 + ): + raise ValueError("Thread incarnation requires a positive recorded birth") + return float(created_at) + + @staticmethod + def _lookup_for_birth(created_at: float) -> str: + """The retained birth-only GoalWait format uses this same identity rule.""" + birth = RecordedThreadIncarnation._require_recorded_birth(created_at) + identity = f"agent-comms:thread-creation:v1\0{birth!r}".encode() + return hashlib.sha256(identity).hexdigest()[:32] + + @cached_property + def lookup(self) -> str: + """The creation lookup, computed once for this recorded incarnation.""" + return self._lookup_for_birth(self.created_at) + + +@dataclass(frozen=True) +class DeletedThreadIncarnation(ThreadIncarnation): + """A deleted participant retains only its name, never a lookup capability.""" + + def __post_init__(self) -> None: + super().__post_init__() + if self.created_at != self.DELETED_BIRTH: + raise ValueError("Deleted incarnation requires its deleted birth sentinel") + + +@dataclass(frozen=True) +class UnrecordedThreadIncarnation(ThreadIncarnation): + """Historical zero-birth evidence cannot select a live recipient.""" + + def __post_init__(self) -> None: + super().__post_init__() + if self.created_at != 0: + raise ValueError("Unrecorded incarnation requires its zero-birth sentinel") + + @dataclass(frozen=True, slots=True) class TurnId: value: str diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 7834987c1d..487e2c42d7 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -12,7 +12,13 @@ from agent_comms.comms import Comms from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.goal_presentation import GoalWaitTarget +from agent_comms.thread_identity import ( + DeletedThreadIncarnation, + RecordedThreadIncarnation, + ThreadIncarnation, + UnrecordedThreadIncarnation, +) from agent_comms.threads import Thread @@ -26,7 +32,7 @@ def test_recipient_index_is_built_once_and_reused_for_small_window(tmp_path): lookup = wanted.incarnation.lookup assert snapshot.by_lookup[lookup] is wanted assert snapshot.by_lookup.get("not-a-recipient") is None - with patch.object(ThreadIncarnation, "lookup", property( + with patch.object(RecordedThreadIncarnation, "lookup", property( lambda self: (_ for _ in ()).throw(AssertionError("rehashing unrelated registry")) )): observations = comms.agents.observe_recipients( @@ -50,10 +56,16 @@ def test_creation_lookup_is_cached_on_the_renamable_incarnation(tmp_path): renamed = replace(thread, name="renamed") assert renamed.incarnation.lookup == first assert FieldCodec.encode(incarnation) == {"name": "worker", "created_at": 1_700_000_000.0} + deleted = ThreadIncarnation.deleted("worker") + historical = ThreadIncarnation("historical", 0.0) + assert isinstance(incarnation, RecordedThreadIncarnation) + assert isinstance(deleted, DeletedThreadIncarnation) + assert isinstance(historical, UnrecordedThreadIncarnation) + assert not hasattr(deleted, "lookup") and not hasattr(historical, "lookup") with pytest.raises(ValueError, match="positive recorded birth"): - assert ThreadIncarnation.deleted("worker").lookup + deleted.require_recorded() with pytest.raises(ValueError, match="positive recorded birth"): - assert ThreadIncarnation("historical", 0.0).lookup + GoalWaitTarget("historical", 0.0) def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_owner(tmp_path): @@ -73,6 +85,15 @@ def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_own assert archived.thread_for_recipient("wrong-lookup", "old") is None with pytest.raises(RelationViolationError, match="creation identities collide"): archived.require_unambiguous_ownership() + unrecorded = replace(source, threads={ + **source.threads, + "second": replace(source.threads["second"], created_at=0.0), + }) + assert isinstance(unrecorded.threads["second"].incarnation, UnrecordedThreadIncarnation) + assert not hasattr(unrecorded.threads["second"].incarnation, "lookup") + assert lookup in unrecorded.by_lookup + with pytest.raises(RelationViolationError, match="creation identities are unrecorded"): + unrecorded.require_unambiguous_ownership() def test_activity_log_projects_only_requested_threads(tmp_path): From 39d6b6d447778e4901990bbb01b08b9061f517eb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 11:34:07 -0400 Subject: [PATCH 06/18] Split selected activity projection and require unambiguous lookup owners --- src/agent_comms/activity.py | 20 ++++++++++---- src/agent_comms/agent_activity.py | 4 +-- src/agent_comms/read_ledger.py | 38 ++++++++++++++------------ src/agent_comms/registry_document.py | 29 ++++++++------------ tests/test_recipient_activity_index.py | 30 ++++++++++++++------ 5 files changed, 70 insertions(+), 51 deletions(-) diff --git a/src/agent_comms/activity.py b/src/agent_comms/activity.py index 92e1e2fd47..ca9618ef02 100644 --- a/src/agent_comms/activity.py +++ b/src/agent_comms/activity.py @@ -399,14 +399,22 @@ def _observed(self, thread: str, events: Mapping[str, Activity], *, return Activity(thread=thread, state=ActivityState.IDLE, timestamp=latest.timestamp) return latest - def all_current(self, *, active: frozenset[str] = frozenset(), - threads: Iterable[str] | None = None) -> dict[str, Activity]: - """Latest activity for requested threads, or all events when unspecified.""" - result = self._read_snapshot().latest + def all_current(self, *, active: frozenset[str] = frozenset()) -> dict[str, Activity]: + """Latest activity for every observed thread.""" + latest = self._read_snapshot().latest + return self._current_for(latest, latest, active) + + def current_for(self, threads: Iterable[str], *, + active: frozenset[str] = frozenset()) -> dict[str, Activity]: + """Latest activity for the selected thread names, including absent ones.""" + return self._current_for(threads, self._read_snapshot().latest, active) + + def _current_for(self, threads: Iterable[str], events: Mapping[str, Activity], + active: frozenset[str]) -> dict[str, Activity]: now = time.time() return { - thread: self._observed(thread, result, active=thread in active, now=now) - for thread in dict.fromkeys(result if threads is None else threads) + thread: self._observed(thread, events, active=thread in active, now=now) + for thread in dict.fromkeys(threads) } def _read_snapshot(self) -> ActivitySnapshot: diff --git a/src/agent_comms/agent_activity.py b/src/agent_comms/agent_activity.py index e8f2290eb3..00b63950c9 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -131,9 +131,9 @@ def all_activity(self, threads: Iterable[Thread], *, snapshot: RegistrySnapshot) requested = {thread.name: thread for thread in threads} if not requested: return {} - events = self.activity.all_current( + events = self.activity.current_for( + requested, active=frozenset(name for name, thread in requested.items() if thread.executing), - threads=requested, ) return { name: self._observed(thread, snapshot.owner_identity(name), events[name]) diff --git a/src/agent_comms/read_ledger.py b/src/agent_comms/read_ledger.py index ac6d3b9b2f..452a85d8a5 100644 --- a/src/agent_comms/read_ledger.py +++ b/src/agent_comms/read_ledger.py @@ -166,14 +166,15 @@ def displayed_recipient(self, message, recipient, snapshot, *, document=None): Processing, assignment acceptance and a reply cannot grant this fact. A reused name with a different creation identity cannot inherit it. """ - thread = snapshot.thread_for_recipient( - recipient.recipient_lookup, recipient.canonical_thread - ) - return self._displayed_recipient( - message, recipient, snapshot, - thread if thread is not None and self.human(thread.role) else None, - document=document, - ) + try: + thread = snapshot.thread_for_recipient( + recipient.recipient_lookup, recipient.canonical_thread + ) + except KeyError: + return () # A retired lookup has no current human paint witness. + if not self.human(thread.role): + return () + return self._displayed_recipient(message, thread, snapshot, document=document) def historical_displayed_recipient(self, message, recipient, snapshot, *, document=None): """Read this archive's human paint ledger, never an original CLI ACK. @@ -183,17 +184,20 @@ def historical_displayed_recipient(self, message, recipient, snapshot, *, docume viewer's incarnation. A same-name destination reader cannot attest a different frozen recipient or replace the recorded namespace. """ - return self._displayed_recipient( - message, recipient, snapshot, - snapshot.thread_for_recipient(recipient.recipient_lookup, recipient.canonical_thread), - document=document, - ) - - def _displayed_recipient(self, message, recipient, snapshot, thread, *, document): + # The archived namespace can retain two original names with one birth. + # Its paint ledger binds the name and original hash; live selection + # remains lookup-only and never guesses an owner for that collision. + thread = snapshot.threads.get(snapshot.canonical_name(recipient.canonical_thread)) if ( - thread is not None - and message.seq in self.seen_sequences(thread.name, snapshot, document=document) + thread is None + or not thread.incarnation.has_recorded_birth + or thread.incarnation.require_recorded().lookup != recipient.recipient_lookup ): + return () + return self._displayed_recipient(message, thread, snapshot, document=document) + + def _displayed_recipient(self, message, thread, snapshot, *, document): + if message.seq in self.seen_sequences(thread.name, snapshot, document=document): return (thread.incarnation,) return () diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index b9e6927d0b..6c355afaa6 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -538,13 +538,12 @@ class RegistrySnapshot(RegistryPresence, RegistryProvenance): owner_generations: Mapping[str, int] admission_generations: Mapping[str, int] by_lookup: Mapping[str, Thread] = field(init=False, repr=False, compare=False) - _ambiguous_lookups: frozenset[str] = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: # An archive may retain two names with the same birth. Neither may be # selected by lookup alone; live publication refuses that snapshot. indexed: dict[str, Thread] = {} - ambiguous: set[str] = set() + collisions: set[str] = set() for thread in self.threads.values(): incarnation = thread.incarnation if not incarnation.has_recorded_birth: @@ -552,28 +551,22 @@ def __post_init__(self) -> None: lookup = incarnation.require_recorded().lookup if lookup in indexed: del indexed[lookup] - ambiguous.add(lookup) - elif lookup not in ambiguous: + collisions.add(lookup) + elif lookup not in collisions: indexed[lookup] = thread object.__setattr__(self, "by_lookup", MappingProxyType(indexed)) - object.__setattr__(self, "_ambiguous_lookups", frozenset(ambiguous)) def require_unambiguous_ownership(self) -> None: - """Archived collisions stay readable; publication requires unique owners.""" - if self._ambiguous_lookups: - raise RelationViolationError("Registry creation identities collide.") + """Archives can be ambiguous; live ownership cannot.""" if len(self.by_lookup) != len(self.threads): - raise RelationViolationError("Registry creation identities are unrecorded.") + raise RelationViolationError("Registry creation identities are ambiguous or unrecorded.") - def thread_for_recipient(self, lookup: str, name: str) -> Thread | None: - """Resolve a frozen recipient, including same-birth historical aliases.""" - canonical = self.canonical_name(name) - thread = self.by_lookup.get(lookup) - if thread is None and lookup in self._ambiguous_lookups: - thread = self.threads.get(canonical) - if thread is not None and thread.incarnation.require_recorded().lookup != lookup: - return None - return thread if thread is not None and thread.name == canonical else None + def thread_for_recipient(self, lookup: str, name: str) -> Thread: + """Resolve a recorded recipient by its unique creation identity.""" + thread = self.by_lookup[lookup] + if thread.name != self.canonical_name(name): + raise RelationViolationError("Frozen recipient name does not match its lookup.") + return thread def retired(self, name: str) -> bool: """Removal keeps the name's generations; they are the deletion record.""" diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 487e2c42d7..9e93069af9 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -2,6 +2,7 @@ import hashlib from dataclasses import replace +from types import SimpleNamespace from unittest.mock import patch import pytest @@ -13,6 +14,7 @@ from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec from agent_comms.goal_presentation import GoalWaitTarget +from agent_comms.read_ledger import ReadLedger from agent_comms.thread_identity import ( DeletedThreadIncarnation, RecordedThreadIncarnation, @@ -31,6 +33,11 @@ def test_recipient_index_is_built_once_and_reused_for_small_window(tmp_path): wanted = snapshot.threads["peer-7"] lookup = wanted.incarnation.lookup assert snapshot.by_lookup[lookup] is wanted + assert snapshot.thread_for_recipient(lookup, wanted.name) is wanted + with pytest.raises(KeyError): + snapshot.thread_for_recipient("not-a-recipient", wanted.name) + with pytest.raises(RelationViolationError, match="recipient name"): + snapshot.thread_for_recipient(lookup, "peer-8") assert snapshot.by_lookup.get("not-a-recipient") is None with patch.object(RecordedThreadIncarnation, "lookup", property( lambda self: (_ for _ in ()).throw(AssertionError("rehashing unrelated registry")) @@ -79,11 +86,18 @@ def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_own }) lookup = source.threads["old"].incarnation.lookup assert archived.by_lookup.get(lookup) is None - assert archived.thread_for_recipient(lookup, "old") is archived.threads["old"] - assert archived.thread_for_recipient(lookup, "second") is archived.threads["second"] - assert archived.thread_for_recipient(lookup, "missing") is None - assert archived.thread_for_recipient("wrong-lookup", "old") is None - with pytest.raises(RelationViolationError, match="creation identities collide"): + with pytest.raises(KeyError): + archived.thread_for_recipient(lookup, "old") + ledger = ReadLedger(tmp_path / "archive-reads.json") + with patch.object(ReadLedger, "seen_sequences", return_value=frozenset({7})): + for name in ("old", "second"): + assert ledger.historical_displayed_recipient( + SimpleNamespace(seq=7), FrozenRecipient(lookup, name), archived + ) == (archived.threads[name].incarnation,) + assert ledger.historical_displayed_recipient( + SimpleNamespace(seq=7), FrozenRecipient("wrong-lookup", "old"), archived + ) == () + with pytest.raises(RelationViolationError, match="creation identities are ambiguous"): archived.require_unambiguous_ownership() unrecorded = replace(source, threads={ **source.threads, @@ -92,7 +106,7 @@ def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_own assert isinstance(unrecorded.threads["second"].incarnation, UnrecordedThreadIncarnation) assert not hasattr(unrecorded.threads["second"].incarnation, "lookup") assert lookup in unrecorded.by_lookup - with pytest.raises(RelationViolationError, match="creation identities are unrecorded"): + with pytest.raises(RelationViolationError, match="creation identities are ambiguous or unrecorded"): unrecorded.require_unambiguous_ownership() @@ -100,6 +114,6 @@ def test_activity_log_projects_only_requested_threads(tmp_path): log = ActivityLog(tmp_path / "activity.jsonl") for index in range(10): log.emit(Activity(f"peer-{index}", ActivityState.THINKING, "working")) - assert set(log.all_current(threads=("peer-7",))) == {"peer-7"} - assert log.all_current(threads=()) == {} + assert set(log.current_for(("peer-7",))) == {"peer-7"} + assert log.current_for(()) == {} assert len(log.all_current()) == 10 From 46f6564bd6c62e3a5e57b10f0dbd0cda8e857fcf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 11:38:56 -0400 Subject: [PATCH 07/18] Derive dependency reply lookup from its actual recorded owner --- src/agent_comms/goal_management.py | 6 ++-- src/agent_comms/goal_waits.py | 27 +++++++---------- src/agent_comms/ordinary_admission_rules.py | 5 +++- src/agent_comms/owned_send_admission.py | 2 +- src/agent_comms/thread_identity.py | 10 ++----- src/agent_comms/turn_input_source.py | 27 ++++++++++------- tests/test_goal_wait_certified_reply.py | 32 ++++++++++++--------- tests/test_ordinary_admission_rules.py | 14 +++++---- 8 files changed, 65 insertions(+), 58 deletions(-) diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index 06bc85e260..6d31a0d4df 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -213,7 +213,7 @@ def recover_closed_goal_wait(self, name: str) -> tuple[str, ...]: return () # An unreadable bus raises to the drain loop, which records it as # the owner's visible drain diagnostic; it never proves silence. - if wait.has_reply(self.bus.log): + if wait.has_reply(self.bus.log, owner.incarnation.require_recorded()): return () names = ", ".join(f"@{member}" for member in closed) note = ( @@ -279,7 +279,7 @@ def release_waits_after_terminal_turn(self, fence: FinishedTurnFence | None) -> ): continue try: - reply = wait.has_reply(self.bus.log) + reply = wait.has_reply(self.bus.log, owner.incarnation.require_recorded()) except (OSError, ValueError, sqlite3.DatabaseError): # The terminal turn has already committed. An unavailable # optional reply read cannot prove silence or release this @@ -363,7 +363,7 @@ def consume_reply_wait(self, owner: RegistryOwner, reference: MessageReference) original = source.delivery(reference.seq) if original.message.reference != reference: raise ValueError("Dependency reply source reference changed.") - if not wait.matches(original): + if not wait.matches(original, current.incarnation.require_recorded()): return False return self.waits.clear(goal.id, wait_id=wait.wait_id) diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index 86d8a63959..d3be94a6bf 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -4,7 +4,7 @@ import math from collections.abc import Sequence -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, replace from typing import TYPE_CHECKING, ClassVar from .bus_publication import CommittedDelivery @@ -29,7 +29,6 @@ class GoalWait: after_seq: int targets: tuple[GoalWaitTarget, ...] owner_created_at: float - _owner_lookup: str = field(init=False, repr=False, compare=False) # An idle dependency has no turn generation at the moment of declaration. target_turn_generations: tuple[int | None, ...] # Runtime declarations outside a turn explicitly carry no reporting turn. @@ -48,11 +47,6 @@ def __post_init__(self) -> None: ) ): raise ValueError("A wait requires its owner incarnation and aligned target turns.") - object.__setattr__( - self, - "_owner_lookup", - RecordedThreadIncarnation._lookup_for_birth(self.owner_created_at), - ) if (self.report_turn_id is None) != (self.report_turn_generation is None) or ( self.report_turn_id is not None and ( @@ -74,20 +68,21 @@ def current_for(self, goal: Goal, owner_created_at: float) -> bool: self.goal_id, self.revision ) - def matches(self, original: CommittedDelivery) -> bool: - """Join original sender and addressed owner through the certified source.""" + def matches(self, original: CommittedDelivery, owner: RecordedThreadIncarnation) -> bool: + """Join original sender and the actual addressed owner through its source.""" return ( - original.message.seq > self.after_seq - and original.direct_for(self._owner_lookup) + owner.created_at == self.owner_created_at + and original.message.seq > self.after_seq + and original.direct_for(owner.lookup) and any(target.sent(original) for target in self.targets) ) - def has_reply(self, bus: WireLog) -> bool: + def has_reply(self, bus: WireLog, owner: RecordedThreadIncarnation) -> bool: + if owner.created_at != self.owner_created_at: + return False return any( - self.matches(original) - for original in bus.addressed_sources( - self._owner_lookup, self.after_seq - ) + self.matches(original, owner) + for original in bus.addressed_sources(owner.lookup, self.after_seq) ) diff --git a/src/agent_comms/ordinary_admission_rules.py b/src/agent_comms/ordinary_admission_rules.py index 0eaf3c58ee..9ebc83a56b 100644 --- a/src/agent_comms/ordinary_admission_rules.py +++ b/src/agent_comms/ordinary_admission_rules.py @@ -154,6 +154,7 @@ def violated(self, check: AcceptedInputCheck) -> bool: @dataclass(frozen=True, kw_only=True) class OrdinaryContextCheck(RuleCheck): source: TurnInputSource + owner: Thread goal: Goal | None wait: GoalWait | None comms: Comms @@ -180,7 +181,9 @@ class OrdinaryWaitRule(ReservationRule): explanation = "The current dependency wait does not authorize this input." def violated(self, check: OrdinaryContextCheck) -> bool: - return not check.source.allows_wait(check.wait, check.comms) + return not check.source.allows_wait( + check.wait, check.comms, check.owner.incarnation.require_recorded() + ) class OrdinaryGoalInputRule(ReservationRule): diff --git a/src/agent_comms/owned_send_admission.py b/src/agent_comms/owned_send_admission.py index 0a521bb693..d119452a9a 100644 --- a/src/agent_comms/owned_send_admission.py +++ b/src/agent_comms/owned_send_admission.py @@ -102,7 +102,7 @@ def _require_context( self, source: TurnInputSource, current: Thread, wait: GoalWait | None, ) -> None: OrdinaryContextCheck( - source=source, goal=current.goal, wait=wait, comms=self.comms + source=source, owner=current, goal=current.goal, wait=wait, comms=self.comms ).require_valid() if not source.bypasses_goal_permit and self.goal_permit is not None: OrdinaryGoalGrantCheck(permit=self.goal_permit, store=self.goal_store).require_valid() diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index ee231b4ecb..2c523abab7 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -115,17 +115,11 @@ def _require_recorded_birth(created_at: float) -> float: raise ValueError("Thread incarnation requires a positive recorded birth") return float(created_at) - @staticmethod - def _lookup_for_birth(created_at: float) -> str: - """The retained birth-only GoalWait format uses this same identity rule.""" - birth = RecordedThreadIncarnation._require_recorded_birth(created_at) - identity = f"agent-comms:thread-creation:v1\0{birth!r}".encode() - return hashlib.sha256(identity).hexdigest()[:32] - @cached_property def lookup(self) -> str: """The creation lookup, computed once for this recorded incarnation.""" - return self._lookup_for_birth(self.created_at) + identity = f"agent-comms:thread-creation:v1\0{float(self.created_at)!r}".encode() + return hashlib.sha256(identity).hexdigest()[:32] @dataclass(frozen=True) diff --git a/src/agent_comms/turn_input_source.py b/src/agent_comms/turn_input_source.py index 276fdd3dd5..f66271e365 100644 --- a/src/agent_comms/turn_input_source.py +++ b/src/agent_comms/turn_input_source.py @@ -17,7 +17,7 @@ from .goal_waits import GoalWait from .goals import Goal from .input_disposition import InputDispositions - from .thread_identity import TurnId + from .thread_identity import RecordedThreadIncarnation, TurnId from .threads import Thread @@ -38,7 +38,8 @@ def dependency_current(self, wait: GoalWait | None) -> bool: return True @abstractmethod - def allows_wait(self, wait: GoalWait | None, comms: Comms) -> bool: ... + def allows_wait(self, wait: GoalWait | None, comms: Comms, + owner: RecordedThreadIncarnation) -> bool: ... @abstractmethod def allows_goal_input(self, goal: Goal | None) -> bool: ... @@ -58,7 +59,8 @@ class InputDependency(ABC): def current(self, wait: GoalWait | None) -> bool: ... @abstractmethod - def allows(self, wait: GoalWait | None, comms: Comms, origins: tuple[Message, ...]) -> bool: ... + def allows(self, wait: GoalWait | None, comms: Comms, origins: tuple[Message, ...], + owner: RecordedThreadIncarnation) -> bool: ... @abstractmethod def consume(self, comms: Comms, canonical: str, wait: GoalWait | None) -> bool: ... @@ -68,7 +70,8 @@ class NoInputDependency(InputDependency): def current(self, wait: GoalWait | None) -> bool: return True - def allows(self, wait: GoalWait | None, comms: Comms, origins: tuple[Message, ...]) -> bool: + def allows(self, wait: GoalWait | None, comms: Comms, origins: tuple[Message, ...], + owner: RecordedThreadIncarnation) -> bool: return wait is None def consume(self, comms: Comms, canonical: str, wait: GoalWait | None) -> bool: @@ -82,7 +85,8 @@ class CapturedInputDependency(InputDependency): def current(self, wait: GoalWait | None) -> bool: return wait is not None and wait.wait_id == self.wait_id - def allows(self, wait: GoalWait | None, comms: Comms, origins: tuple[Message, ...]) -> bool: + def allows(self, wait: GoalWait | None, comms: Comms, origins: tuple[Message, ...], + owner: RecordedThreadIncarnation) -> bool: if wait is None: return True if not self.current(wait): @@ -90,7 +94,7 @@ def allows(self, wait: GoalWait | None, comms: Comms, origins: tuple[Message, .. with comms.bus.log.certified_read() as source: for origin in origins: original = source.delivery(origin.seq) - if original.message.reference == origin.reference and wait.matches(original): + if original.message.reference == origin.reference and wait.matches(original, owner): return True return False @@ -148,7 +152,8 @@ def dependency_current(self, wait: GoalWait | None) -> bool: class DirectInput(TurnInputSource): - def allows_wait(self, wait: GoalWait | None, comms: Comms) -> bool: + def allows_wait(self, wait: GoalWait | None, comms: Comms, + owner: RecordedThreadIncarnation) -> bool: return True def allows_goal_input(self, goal: Goal | None) -> bool: @@ -166,7 +171,8 @@ def notice_text(self) -> str: class RoutedInput(TurnInputSource): - def allows_wait(self, wait: GoalWait | None, comms: Comms) -> bool: + def allows_wait(self, wait: GoalWait | None, comms: Comms, + owner: RecordedThreadIncarnation) -> bool: return wait is None def allows_goal_input(self, goal: Goal | None) -> bool: @@ -194,8 +200,9 @@ def answering(cls, prompt: str) -> SelectedOriginalInput: class DependencyOriginalInput(OriginalTurnInput, DirectInput): - def allows_wait(self, wait: GoalWait | None, comms: Comms) -> bool: - return self.dependency.allows(wait, comms, self.origins) + def allows_wait(self, wait: GoalWait | None, comms: Comms, + owner: RecordedThreadIncarnation) -> bool: + return self.dependency.allows(wait, comms, self.origins, owner) class ScheduledOriginalInput(DependencyOriginalInput): diff --git a/tests/test_goal_wait_certified_reply.py b/tests/test_goal_wait_certified_reply.py index 106a296370..ab5a89d868 100644 --- a/tests/test_goal_wait_certified_reply.py +++ b/tests/test_goal_wait_certified_reply.py @@ -26,7 +26,7 @@ def register(comms, name): def test_original_reply_is_not_reassigned_to_same_name_replacement(tmp_path): comms = canonical_goal_wire(tmp_path / "wire") - register(comms, "owner") + owner = register(comms, "owner") peer = register(comms, "peer") lease = comms.agents.begin_turn("peer", "original-work").turn_lease goal = comms.goals.update_goal("owner", SetGoalAction(text="Wait for original peer")) @@ -37,7 +37,7 @@ def test_original_reply_is_not_reassigned_to_same_name_replacement(tmp_path): reply = comms.messaging.send_message("peer", "owner", "Original dependency reply") with comms.bus.log.certified_read() as source: original = source.delivery(reply.seq) - assert wait.matches(original) + assert wait.matches(original, owner.incarnation.require_recorded()) assert original.audience.sender_lookup == ThreadIncarnation("fixture", peer.created_at).lookup comms.agents.finish_turn(lease) @@ -47,13 +47,13 @@ def test_original_reply_is_not_reassigned_to_same_name_replacement(tmp_path): assert peer.created_at != replacement.created_at with comms.bus.log.certified_read() as source: original = source.delivery(reply.seq) - assert wait.matches(original) + assert wait.matches(original, owner.incarnation.require_recorded()) replacement_wait = replace( wait, targets=(GoalWaitTarget(replacement.name, replacement.created_at),) ) - assert not replacement_wait.matches(original) - assert wait.has_reply(comms.bus.log) - assert not replacement_wait.has_reply(comms.bus.log) + assert not replacement_wait.matches(original, owner.incarnation.require_recorded()) + assert wait.has_reply(comms.bus.log, owner.incarnation.require_recorded()) + assert not replacement_wait.has_reply(comms.bus.log, owner.incarnation.require_recorded()) review = comms.goals.goal_input_review("owner", goal.id, ["peer"]) assert review["reviewed_inputs"] == [] @@ -73,15 +73,15 @@ def test_original_recipient_and_direct_reply_semantics_cannot_be_replaced(tmp_pa notice = comms.messaging.send_message("peer", "owner", "Diagnostic only", notice=True) reply = comms.messaging.send_message("peer", "owner", "Original addressed reply") with comms.bus.log.certified_read() as source: - assert not wait.matches(source.delivery(wrong.seq)) - assert not wait.matches(source.delivery(notice.seq)) + assert not wait.matches(source.delivery(wrong.seq), owner.incarnation.require_recorded()) + assert not wait.matches(source.delivery(notice.seq), owner.incarnation.require_recorded()) original = source.delivery(reply.seq) - assert wait.matches(original) - assert not replace(wait, after_seq=reply.seq).matches(original) + assert wait.matches(original, owner.incarnation.require_recorded()) + assert not replace(wait, after_seq=reply.seq).matches(original, owner.incarnation.require_recorded()) comms.registry.rename("owner", "renamed-owner") comms.registry.rename("peer", "renamed-peer") - assert wait.has_reply(comms.bus.log) + assert wait.has_reply(comms.bus.log, owner.incarnation.require_recorded()) assert comms.goals.goal_input_review("renamed-owner", goal.id, ["renamed-peer"])[ "reviewed_inputs" ] == [f"bus:{reply.seq}"] @@ -90,8 +90,12 @@ def test_original_recipient_and_direct_reply_semantics_cannot_be_replaced(tmp_pa replacement = register(comms, "owner") assert owner.created_at != replacement.created_at with comms.bus.log.certified_read() as source: - assert not replace(wait, owner_created_at=replacement.created_at).matches(source.delivery(reply.seq)) - assert not replace(wait, owner_created_at=replacement.created_at).has_reply(comms.bus.log) + assert not replace(wait, owner_created_at=replacement.created_at).matches( + source.delivery(reply.seq), replacement.incarnation.require_recorded() + ) + assert not replace(wait, owner_created_at=replacement.created_at).has_reply( + comms.bus.log, replacement.incarnation.require_recorded() + ) async def test_native(retained_native_acp_owner): @@ -134,7 +138,7 @@ async def test_native(retained_native_acp_owner): }) original = comms.bus.log.message_by_id(sent["id"]) with comms.bus.log.certified_read() as source: - assert wait.matches(source.delivery(original.seq)) + assert wait.matches(source.delivery(original.seq), owner.incarnation.require_recorded()) root_id = source.marker.root_id turn = asyncio.create_task(agent.inputs.drain_inbox(sid)) try: diff --git a/tests/test_ordinary_admission_rules.py b/tests/test_ordinary_admission_rules.py index 1cd148e4ae..b938d42a2f 100644 --- a/tests/test_ordinary_admission_rules.py +++ b/tests/test_ordinary_admission_rules.py @@ -140,29 +140,33 @@ def test_routed_dependency_source_uses_existing_goal_and_wait_authorities(tmp_pa report_turn_generation=None, ) common = dict( - keys=("bus-input",), accepted_id=None, goal_permission=ContinuationGoalPermission(goal), prompt="Result", original_display="Result", origins=(message,), - batch=SingleInputBatch(), + batch=SingleInputBatch(()), ) dependency = DependencyOriginalInput( **common, dependency=CapturedInputDependency(wait.wait_id) ) routed = RoutedOriginalInput(**common, dependency=NoInputDependency()) rules.OrdinaryContextCheck( - source=dependency, goal=goal, wait=wait, comms=comms + source=dependency, owner=comms.registry.require("owner"), goal=goal, wait=wait, comms=comms + ).require_valid() + # A keyless routed fixture has no captured input key to refuse on goal authority. + rules.OrdinaryContextCheck( + source=routed, owner=comms.registry.require("owner"), goal=goal, + wait=None, comms=comms ).require_valid() for source, current_wait, expected in ( - (routed, None, rules.OrdinaryGoalInputRule), (dependency, None, rules.OrdinaryDependencyRule), (dependency, replace(wait, wait_id="replacement"), rules.OrdinaryDependencyRule), (dependency, replace(wait, after_seq=message.seq), rules.OrdinaryWaitRule), ): with pytest.raises(ReservationViolationError) as error: rules.OrdinaryContextCheck( - source=source, goal=goal, wait=current_wait, comms=comms + source=source, owner=comms.registry.require("owner"), goal=goal, + wait=current_wait, comms=comms ).require_valid() assert type(error.value.rule) is expected From 29df46cd24f6fd41a6b9a0f3492fc2431d86851f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 11:42:16 -0400 Subject: [PATCH 08/18] Separate strict live lookup from recorded historical registry provenance --- src/agent_comms/agent_activity.py | 1 - src/agent_comms/historical_views.py | 5 ++-- src/agent_comms/publisher.py | 2 -- src/agent_comms/read_ledger.py | 6 +++-- src/agent_comms/registry_document.py | 18 ++++---------- tests/test_recipient_activity_index.py | 33 +++++++++++++++----------- 6 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/agent_comms/agent_activity.py b/src/agent_comms/agent_activity.py index 00b63950c9..a22b847216 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -142,7 +142,6 @@ def all_activity(self, threads: Iterable[Thread], *, snapshot: RegistrySnapshot) def observe_recipients(self, recipients: Iterable[FrozenRecipient], *, snapshot: RegistrySnapshot) -> Mapping[str, RecipientActivity]: """Acquire one bounded window's recipients from the original source snapshot.""" - snapshot.require_unambiguous_ownership() observations: dict[str, RecipientActivity] = {} for recipient in recipients: lookup = recipient.recipient_lookup diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index 4a99c92a7e..f5ba910b45 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -387,9 +387,8 @@ def attach(self, source_root: Path, *, source_read: HistorySource | None = None) ] revisions = tuple(FileRevision.capture(path) for path in paths) if source_read is None: - provenance = RegistryProvenance.capture( - Registration(source_root / "registry.json").snapshot() - ) + with Registration(source_root / "registry.json").store.reading() as original: + provenance = RegistryProvenance.capture(original) else: source_read.require_original(source_root) provenance = source_read.provenance diff --git a/src/agent_comms/publisher.py b/src/agent_comms/publisher.py index b506067cdc..2123404f43 100644 --- a/src/agent_comms/publisher.py +++ b/src/agent_comms/publisher.py @@ -256,7 +256,6 @@ def publish_claim_envelope( if _locked_registry_snapshot is None else _locked_registry_snapshot ) - snapshot.require_unambiguous_ownership() sender, target = self._validate_publish_request(message, registry_snapshot=snapshot) with bus_lock.current_source(self.log, metadata) as source: # The checkpoint high-water also covers a prior visible append; @@ -350,7 +349,6 @@ def publish_initial_cohort( ) before_revisions = tuple(FileRevision.capture(path) for path in source_paths) snapshot = self._registry.snapshot() - snapshot.require_unambiguous_ownership() catalog = self._channels.read() sender, target = self._validate_publish_request( message, registry_snapshot=snapshot, catalog=catalog) diff --git a/src/agent_comms/read_ledger.py b/src/agent_comms/read_ledger.py index 452a85d8a5..03539cb98b 100644 --- a/src/agent_comms/read_ledger.py +++ b/src/agent_comms/read_ledger.py @@ -160,7 +160,7 @@ def seen_sequences( revision = FileRevision.capture(self.path.with_name("bus.jsonl")) return document.seen_sequences(viewer, snapshot, revision.identity if revision else None) - def displayed_recipient(self, message, recipient, snapshot, *, document=None): + def displayed_recipient(self, message, recipient, snapshot: RegistrySnapshot, *, document=None): """Return recorded human display evidence for this exact frozen recipient. Processing, assignment acceptance and a reply cannot grant this fact. @@ -176,7 +176,9 @@ def displayed_recipient(self, message, recipient, snapshot, *, document=None): return () return self._displayed_recipient(message, thread, snapshot, document=document) - def historical_displayed_recipient(self, message, recipient, snapshot, *, document=None): + def historical_displayed_recipient( + self, message, recipient, snapshot: RegistryProvenance, *, document=None + ): """Read this archive's human paint ledger, never an original CLI ACK. HistoryArchive does not copy the source's read ledger. Its new ledger diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 6c355afaa6..9bf294c846 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -540,27 +540,19 @@ class RegistrySnapshot(RegistryPresence, RegistryProvenance): by_lookup: Mapping[str, Thread] = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: - # An archive may retain two names with the same birth. Neither may be - # selected by lookup alone; live publication refuses that snapshot. + # This is a live ownership cut. Archived namespaces use the separate + # RegistryProvenance type and do not invent a lookup for duplicate births. indexed: dict[str, Thread] = {} - collisions: set[str] = set() for thread in self.threads.values(): incarnation = thread.incarnation if not incarnation.has_recorded_birth: - continue + raise RelationViolationError("Live registry creation identity is unrecorded.") lookup = incarnation.require_recorded().lookup if lookup in indexed: - del indexed[lookup] - collisions.add(lookup) - elif lookup not in collisions: - indexed[lookup] = thread + raise RelationViolationError("Registry creation identities collide.") + indexed[lookup] = thread object.__setattr__(self, "by_lookup", MappingProxyType(indexed)) - def require_unambiguous_ownership(self) -> None: - """Archives can be ambiguous; live ownership cannot.""" - if len(self.by_lookup) != len(self.threads): - raise RelationViolationError("Registry creation identities are ambiguous or unrecorded.") - def thread_for_recipient(self, lookup: str, name: str) -> Thread: """Resolve a recorded recipient by its unique creation identity.""" thread = self.by_lookup[lookup] diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 9e93069af9..d65841c94d 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -15,6 +15,7 @@ from agent_comms.field_codec import FieldCodec from agent_comms.goal_presentation import GoalWaitTarget from agent_comms.read_ledger import ReadLedger +from agent_comms.registry_provenance import RegistryProvenance from agent_comms.thread_identity import ( DeletedThreadIncarnation, RecordedThreadIncarnation, @@ -80,14 +81,18 @@ def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_own for name, birth in (("old", 1700.0), ("second", 1701.0)): comms.registry.declare(Thread(name, frozenset(), str(tmp_path), created_at=birth)) source = comms.registry.snapshot() - archived = replace(source, threads={ - **source.threads, - "second": replace(source.threads["second"], created_at=1700.0), + with pytest.raises(RelationViolationError, match="creation identities collide"): + replace(source, threads={ + **source.threads, + "second": replace(source.threads["second"], created_at=1700.0), + }) + recorded = RegistryProvenance.capture(source) + archived = replace(recorded, threads={ + **recorded.threads, + "second": replace(recorded.threads["second"], created_at=1700.0), }) lookup = source.threads["old"].incarnation.lookup - assert archived.by_lookup.get(lookup) is None - with pytest.raises(KeyError): - archived.thread_for_recipient(lookup, "old") + assert type(archived) is RegistryProvenance and not hasattr(archived, "by_lookup") ledger = ReadLedger(tmp_path / "archive-reads.json") with patch.object(ReadLedger, "seen_sequences", return_value=frozenset({7})): for name in ("old", "second"): @@ -97,17 +102,17 @@ def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_own assert ledger.historical_displayed_recipient( SimpleNamespace(seq=7), FrozenRecipient("wrong-lookup", "old"), archived ) == () - with pytest.raises(RelationViolationError, match="creation identities are ambiguous"): - archived.require_unambiguous_ownership() - unrecorded = replace(source, threads={ - **source.threads, - "second": replace(source.threads["second"], created_at=0.0), + with pytest.raises(RelationViolationError, match="creation identity is unrecorded"): + replace(source, threads={ + **source.threads, + "second": replace(source.threads["second"], created_at=0.0), + }) + unrecorded = replace(recorded, threads={ + **recorded.threads, + "second": replace(recorded.threads["second"], created_at=0.0), }) assert isinstance(unrecorded.threads["second"].incarnation, UnrecordedThreadIncarnation) assert not hasattr(unrecorded.threads["second"].incarnation, "lookup") - assert lookup in unrecorded.by_lookup - with pytest.raises(RelationViolationError, match="creation identities are ambiguous or unrecorded"): - unrecorded.require_unambiguous_ownership() def test_activity_log_projects_only_requested_threads(tmp_path): From 7ab871cad0ff9bcddbe99ca837cda30f481d2c19 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 11:50:39 -0400 Subject: [PATCH 09/18] Use original send to distinguish retired recipients from unknown lookups --- src/agent_comms/agent_activity.py | 20 ++++++----- src/agent_comms/presentation.py | 4 +-- src/agent_comms/read_ledger.py | 18 +++++----- src/agent_comms/registry_document.py | 31 +++++++++++++---- tests/test_recipient_activity_index.py | 46 +++++++++++++++++++------- 5 files changed, 83 insertions(+), 36 deletions(-) diff --git a/src/agent_comms/agent_activity.py b/src/agent_comms/agent_activity.py index a22b847216..430d2284fc 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from .agent_events import RuntimeInfoObservation + from .bus_publication import CommittedDelivery from .notification_assignment import AssignmentProgress from .presentation import MessageNotification from .activity import ( @@ -22,7 +23,7 @@ from .declared_family import DeclaredFamily from .audience_manifest import FrozenRecipient from .errors import RelationViolationError -from .registry_document import RegistrySnapshot +from .registry_document import RegisteredRecipient, RegistrySnapshot from .routing import TurnRouting from .runtime_info import AgentRuntimeInfo, RuntimeInfoStore from .store_files import _store_lock @@ -45,7 +46,7 @@ def after_inbox_read(self, notification, source, reads, document, snapshot): def displayed_recipient(self, notification, source, reads, document, snapshot): return reads.displayed_recipient( - source.message, notification.recipient_identity, snapshot, document=document + source, notification.recipient_identity, snapshot, document=document ) def selected_response( @@ -140,17 +141,20 @@ def all_activity(self, threads: Iterable[Thread], *, snapshot: RegistrySnapshot) for name, thread in requested.items() } - def observe_recipients(self, recipients: Iterable[FrozenRecipient], *, snapshot: RegistrySnapshot) -> Mapping[str, RecipientActivity]: - """Acquire one bounded window's recipients from the original source snapshot.""" + def observe_recipients( + self, recipients: Iterable[tuple[CommittedDelivery, FrozenRecipient]], *, + snapshot: RegistrySnapshot, + ) -> Mapping[str, RecipientActivity]: + """Acquire one bounded window's recipients from the original sends.""" observations: dict[str, RecipientActivity] = {} - for recipient in recipients: + for original, recipient in recipients: lookup = recipient.recipient_lookup if lookup in observations: continue - thread = snapshot.by_lookup.get(lookup) + selected = snapshot.recipient(recipient, original) observations[lookup] = ( - thread.execution.observe_recipient(self, snapshot, thread) - if thread is not None else UnavailableRecipientActivity() + selected.thread.execution.observe_recipient(self, snapshot, selected.thread) + if isinstance(selected, RegisteredRecipient) else UnavailableRecipientActivity() ) return observations diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 2fc956092c..256573b91e 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -177,7 +177,7 @@ def delivery_window( return {} snapshot = registry.snapshot() observations = AgentActivity(root, registry).observe_recipients( - (recipient for source in sources for recipient in source.audience.recipients + ((source, recipient) for source in sources for recipient in source.audience.recipients if cls.includes_recipient(source, recipient, owner_lookup)), snapshot=snapshot, ) @@ -273,7 +273,7 @@ def direct_read_notification( return None recipient = FrozenRecipient(target.incarnation.lookup, target.name) displayed_to = reads.displayed_recipient( - message, recipient, snapshot, document=document + source, recipient, snapshot, document=document ) if not displayed_to: return None diff --git a/src/agent_comms/read_ledger.py b/src/agent_comms/read_ledger.py index 03539cb98b..eb69cbce9e 100644 --- a/src/agent_comms/read_ledger.py +++ b/src/agent_comms/read_ledger.py @@ -16,11 +16,13 @@ from .locked_store import LockedStore from .private_path import FileIdentity, FileRevision from .read_basis import Conversation, DisplayBasis, DisplayedConversation +from .registry_document import RetiredRecipient from .sealed import Sealed from .thread_identity import ThreadIncarnation from .thread_owned_state import ThreadOwnedState if TYPE_CHECKING: + from .bus_publication import CommittedDelivery from .messages import Message from .registry_document import RegistrySnapshot from .registry_provenance import RegistryProvenance @@ -160,21 +162,21 @@ def seen_sequences( revision = FileRevision.capture(self.path.with_name("bus.jsonl")) return document.seen_sequences(viewer, snapshot, revision.identity if revision else None) - def displayed_recipient(self, message, recipient, snapshot: RegistrySnapshot, *, document=None): + def displayed_recipient( + self, original: CommittedDelivery, recipient, snapshot: RegistrySnapshot, *, document=None + ): """Return recorded human display evidence for this exact frozen recipient. Processing, assignment acceptance and a reply cannot grant this fact. A reused name with a different creation identity cannot inherit it. """ - try: - thread = snapshot.thread_for_recipient( - recipient.recipient_lookup, recipient.canonical_thread - ) - except KeyError: - return () # A retired lookup has no current human paint witness. + selected = snapshot.recipient(recipient, original) + if isinstance(selected, RetiredRecipient): + return () + thread = selected.thread if not self.human(thread.role): return () - return self._displayed_recipient(message, thread, snapshot, document=document) + return self._displayed_recipient(original.message, thread, snapshot, document=document) def historical_displayed_recipient( self, message, recipient, snapshot: RegistryProvenance, *, document=None diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 9bf294c846..8e5dd68701 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -37,6 +37,8 @@ from .turn_phase import TurnPhase if TYPE_CHECKING: + from .audience_manifest import FrozenRecipient + from .bus_publication import CommittedDelivery from .native_input_owner import RegistryOwner @@ -529,6 +531,16 @@ def owner_identity(self) -> OwnerIdentity: return OwnerIdentity(self.thread.incarnation, self.owner_generation) +@dataclass(frozen=True, slots=True) +class RegisteredRecipient: + thread: Thread + + +@dataclass(frozen=True, slots=True) +class RetiredRecipient: + lookup: str + + @dataclass(frozen=True, slots=True) class RegistrySnapshot(RegistryPresence, RegistryProvenance): threads: Mapping[str, Thread] @@ -553,12 +565,19 @@ def __post_init__(self) -> None: indexed[lookup] = thread object.__setattr__(self, "by_lookup", MappingProxyType(indexed)) - def thread_for_recipient(self, lookup: str, name: str) -> Thread: - """Resolve a recorded recipient by its unique creation identity.""" - thread = self.by_lookup[lookup] - if thread.name != self.canonical_name(name): - raise RelationViolationError("Frozen recipient name does not match its lookup.") - return thread + def recipient( + self, recipient: FrozenRecipient, original: CommittedDelivery + ) -> RegisteredRecipient | RetiredRecipient: + """A missing live thread was retired only if the original send names it.""" + lookup = recipient.recipient_lookup + thread = self.by_lookup.get(lookup) + if thread is not None: + if thread.name != self.canonical_name(recipient.canonical_thread): + raise RelationViolationError("Frozen recipient name does not match its lookup.") + return RegisteredRecipient(thread) + if recipient not in original.audience.recipients: + raise UnregisteredThreadError("Recipient lookup is not in the original message.") + return RetiredRecipient(lookup) def retired(self, name: str) -> bool: """Removal keeps the name's generations; they are the deletion record.""" diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index d65841c94d..65e0ee4072 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -11,10 +11,13 @@ from agent_comms.agent_activity import UnavailableRecipientActivity from agent_comms.audience_manifest import FrozenRecipient from agent_comms.comms import Comms -from agent_comms.errors import RelationViolationError +from agent_comms.errors import RelationViolationError, UnregisteredThreadError from agent_comms.field_codec import FieldCodec from agent_comms.goal_presentation import GoalWaitTarget +from agent_comms.notification_assignment import NotificationAssignment +from agent_comms.presentation import MessageNotification from agent_comms.read_ledger import ReadLedger +from agent_comms.registry_document import RegisteredRecipient, RetiredRecipient from agent_comms.registry_provenance import RegistryProvenance from agent_comms.thread_identity import ( DeletedThreadIncarnation, @@ -23,33 +26,52 @@ UnrecordedThreadIncarnation, ) from agent_comms.threads import Thread +from goal_owner_fixture import canonical_goal_wire def test_recipient_index_is_built_once_and_reused_for_small_window(tmp_path): - comms = Comms(tmp_path / "bus") + comms = canonical_goal_wire(tmp_path / "bus") for index in range(12): comms.registry.declare(Thread(f"peer-{index}", frozenset(), str(tmp_path), created_at=1_700_000_000.0 + index)) + sent = comms.messaging.send_user_message("peer-7", "original recipient", worktree=str(tmp_path)) + with comms.bus.log.certified_read() as source: + original = source.delivery(sent.seq) snapshot = comms.registry.snapshot() assert FieldCodec.encode(snapshot)["threads"] wanted = snapshot.threads["peer-7"] lookup = wanted.incarnation.lookup + recipient = FrozenRecipient(lookup, wanted.name) + assert recipient in original.audience.recipients assert snapshot.by_lookup[lookup] is wanted - assert snapshot.thread_for_recipient(lookup, wanted.name) is wanted - with pytest.raises(KeyError): - snapshot.thread_for_recipient("not-a-recipient", wanted.name) + assert snapshot.recipient(recipient, original) == RegisteredRecipient(wanted) + with pytest.raises(UnregisteredThreadError, match="not in the original message"): + snapshot.recipient(FrozenRecipient("not-a-recipient", "gone"), original) with pytest.raises(RelationViolationError, match="recipient name"): - snapshot.thread_for_recipient(lookup, "peer-8") - assert snapshot.by_lookup.get("not-a-recipient") is None + snapshot.recipient(FrozenRecipient(lookup, "peer-8"), original) with patch.object(RecordedThreadIncarnation, "lookup", property( lambda self: (_ for _ in ()).throw(AssertionError("rehashing unrelated registry")) )): observations = comms.agents.observe_recipients( - (FrozenRecipient("not-a-recipient", "gone"), - FrozenRecipient(lookup, wanted.name), FrozenRecipient(lookup, wanted.name)), - snapshot=snapshot, + ((original, recipient), (original, recipient)), snapshot=snapshot, ) - assert set(observations) == {"not-a-recipient", lookup} - assert isinstance(observations["not-a-recipient"], UnavailableRecipientActivity) + assert set(observations) == {lookup} + comms.registry.unregister(wanted.name) + comms.registry.remove(wanted.name) + retired_snapshot = comms.registry.snapshot() + assert retired_snapshot.recipient(recipient, original) == RetiredRecipient(lookup) + retired = comms.agents.observe_recipients( + ((original, recipient),), snapshot=retired_snapshot, + ) + assert isinstance(retired[lookup], UnavailableRecipientActivity) + assert ReadLedger(tmp_path / "reads.json").displayed_recipient( + original, recipient, retired_snapshot + ) == () + window = MessageNotification.delivery_window( + comms.root, comms.registry, (original,), + NotificationAssignment.read(comms.root, (original,)), + ) + assert (sent.seq, sent.message_id) in window + assert all(not item.displayed_to for item in window[(sent.seq, sent.message_id)]) def test_creation_lookup_is_cached_on_the_renamable_incarnation(tmp_path): From aca31774a5495bb0dc34834eefd8a171267e5cfd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 12:25:51 -0400 Subject: [PATCH 10/18] Put recipient, birth and dependency decisions on their owners --- src/agent_comms/agent_activity.py | 7 +- src/agent_comms/bus_route_counts.py | 4 +- src/agent_comms/goal_actions.py | 4 +- src/agent_comms/goal_failure_observation.py | 4 +- src/agent_comms/goal_management.py | 6 +- src/agent_comms/goal_presentation.py | 4 +- src/agent_comms/goal_scheduler.py | 2 +- src/agent_comms/goal_waits.py | 103 +++++++++++---- src/agent_comms/goals.py | 10 +- src/agent_comms/ordinary_admission_rules.py | 2 +- src/agent_comms/owned_send_admission.py | 4 +- src/agent_comms/owned_turn.py | 8 +- src/agent_comms/read_basis.py | 6 +- src/agent_comms/read_ledger.py | 31 ++--- src/agent_comms/registry_document.py | 94 +++++++++++--- src/agent_comms/registry_provenance.py | 24 +++- src/agent_comms/relationships.py | 12 +- src/agent_comms/routing.py | 8 +- src/agent_comms/selected_write_plan.py | 4 +- src/agent_comms/task_sources.py | 4 +- src/agent_comms/thread_configuration.py | 4 +- src/agent_comms/thread_identity.py | 121 ++++++++++++------ src/agent_comms/thread_provenance.py | 62 ++++++++- src/agent_comms/todos.py | 10 +- src/agent_comms/turn_input_source.py | 44 +++---- tests/native_context_reader_journey.py | 4 +- ...ative_evidence_reader_installed_journey.py | 4 +- tests/reply_relevance_installed_journey.py | 4 +- tests/shared_bus_restart_native.py | 8 +- tests/test_acp_channel_disposition.py | 8 +- tests/test_acp_extension.py | 5 +- tests/test_acp_input_disposition.py | 10 +- tests/test_acp_private_nk_delivery.py | 18 +-- tests/test_acp_queue_contract.py | 9 +- tests/test_acp_selected_write_plan.py | 16 +-- tests/test_busy_history_loading.py | 4 +- tests/test_candidate_maintenance.py | 8 +- .../test_channel_participant_registration.py | 4 +- tests/test_checkpoint_seals.py | 6 +- tests/test_claim_admission_baseline.py | 8 +- tests/test_claim_admission_verifier.py | 6 +- tests/test_claim_bus_read.py | 4 +- tests/test_cohort_foreground.py | 6 +- tests/test_concurrency.py | 6 +- tests/test_context_source_inspection.py | 7 +- tests/test_coordinated_runtime.py | 48 +++---- tests/test_coordination_cohort.py | 4 +- tests/test_cursor_load_recovery.py | 4 +- tests/test_goal_failure_observation.py | 11 +- tests/test_goal_selection_owner.py | 3 +- tests/test_goal_standby.py | 25 ++-- tests/test_goal_standby_liveness.py | 56 ++++---- tests/test_goal_wait_certified_reply.py | 11 +- tests/test_goal_wait_current_owner.py | 5 +- tests/test_historical_views.py | 10 +- tests/test_input_documents.py | 16 +-- tests/test_interrupted_delivery_recovery.py | 10 +- tests/test_keyed_response_delivery.py | 6 +- tests/test_locked_store.py | 5 +- tests/test_maintenance_barrier.py | 10 +- tests/test_native_channel_reply_roundtrip.py | 6 +- tests/test_native_drain_readiness.py | 7 +- tests/test_native_prompt_binding.py | 36 +++--- tests/test_native_send_admission.py | 12 +- .../test_native_source_cursor_certificate.py | 4 +- tests/test_native_source_cursor_scale.py | 12 +- tests/test_native_transcript.py | 4 +- tests/test_optional_awareness_projection.py | 8 +- tests/test_ordinary_admission_rules.py | 8 +- tests/test_ordinary_nk_delivery.py | 6 +- tests/test_outbound_transcript_source.py | 14 +- tests/test_owner_partial_retirement.py | 1 + tests/test_owner_restart_handoff.py | 1 + tests/test_private_bus_checkpoint.py | 40 +++--- ...t_private_checkpoint_cursor_integration.py | 4 +- tests/test_private_human_ingress.py | 4 +- tests/test_private_idle_drain.py | 12 +- tests/test_private_nk_entrypoint.py | 4 +- tests/test_project_resume.py | 4 +- tests/test_read_ledger.py | 4 +- tests/test_recipient_activity_index.py | 27 ++-- tests/test_retained_native_tool_latency.py | 4 +- tests/test_roster_restoration.py | 8 +- tests/test_route_current_sources.py | 15 ++- tests/test_runtime.py | 5 +- tests/test_s1_event_behavior.py | 18 +-- tests/test_selected_answer_forms.py | 4 +- tests/test_selected_execution_native.py | 6 +- tests/test_selected_owner_followup.py | 4 +- tests/test_selected_source_batch.py | 8 +- tests/test_selected_tool_runner_hook.py | 4 +- tests/test_session_components.py | 6 +- tests/test_thread_identity.py | 5 +- tests/test_tool_output.py | 5 +- tests/test_turn_context_wire.py | 6 +- tests/test_wake_candidate_index.py | 4 +- .../test_wake_candidate_index_incremental.py | 4 +- tests/turn_context_installed_journey.py | 3 +- tests/wire_row_application_journey.py | 4 +- 99 files changed, 761 insertions(+), 521 deletions(-) diff --git a/src/agent_comms/agent_activity.py b/src/agent_comms/agent_activity.py index 430d2284fc..edb189f329 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -23,7 +23,7 @@ from .declared_family import DeclaredFamily from .audience_manifest import FrozenRecipient from .errors import RelationViolationError -from .registry_document import RegisteredRecipient, RegistrySnapshot +from .registry_document import RegistrySnapshot from .routing import TurnRouting from .runtime_info import AgentRuntimeInfo, RuntimeInfoStore from .store_files import _store_lock @@ -152,10 +152,7 @@ def observe_recipients( if lookup in observations: continue selected = snapshot.recipient(recipient, original) - observations[lookup] = ( - selected.thread.execution.observe_recipient(self, snapshot, selected.thread) - if isinstance(selected, RegisteredRecipient) else UnavailableRecipientActivity() - ) + observations[lookup] = selected.activity(self, snapshot) return observations def _emit_activity(self, activity: Activity) -> None: diff --git a/src/agent_comms/bus_route_counts.py b/src/agent_comms/bus_route_counts.py index 8a3ec21c9a..a3cb3d55e0 100644 --- a/src/agent_comms/bus_route_counts.py +++ b/src/agent_comms/bus_route_counts.py @@ -29,7 +29,7 @@ from .typed_table import Column, ForeignKey, Index, SQLiteJournalMode, TypedRow, TypedTable if TYPE_CHECKING: - from .registry_document import RegistrySnapshot + from .registry_provenance import RegistryProvenance from .wire_log import OpenedWireSnapshot @@ -452,7 +452,7 @@ class OpenedRouteCounts(BusRouteCounts): def __init__( self, bus_path: Path, source: OpenedWireSnapshot, - registry: RegistrySnapshot, deliveries: Mapping[str, DeliveryScope], + registry: RegistryProvenance, deliveries: Mapping[str, DeliveryScope], ): super().__init__(bus_path) self.source = source diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 9409c59c59..18f6287864 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -22,7 +22,7 @@ PausedGoal, RuntimePause, ) -from .goal_waits import GoalWait, GoalWaits +from .goal_waits import GoalWaits, RecordedGoalWait from .goals import Goal from .threads import Thread @@ -292,7 +292,7 @@ def before_publish(self, goal: Goal | None, ctx: GoalActionContext) -> None: # progress update must leave this same goal waiting, not runnable. reporting_lease = thread.turn_lease ctx.goals.waits.record( - GoalWait( + RecordedGoalWait( goal.id, uuid4().hex, goal.revision, diff --git a/src/agent_comms/goal_failure_observation.py b/src/agent_comms/goal_failure_observation.py index 49061bbaf1..4cd9aefce3 100644 --- a/src/agent_comms/goal_failure_observation.py +++ b/src/agent_comms/goal_failure_observation.py @@ -22,7 +22,7 @@ from .goal_attempt_identity import FailureNotObserved, GoalAttemptIdentity from .goals import Goal from .recovery_projection import _preflight -from .thread_identity import AdmissionIdentity, ThreadIncarnation, TurnIdentity +from .thread_identity import AdmissionIdentity, RecordedThreadIncarnation, ThreadIncarnation, TurnIdentity from .thread_status import ThreadStatus from .threads import Thread from .turn_lease import TurnFence, TurnLeaseFence @@ -85,7 +85,7 @@ def identity(self) -> GoalAttemptIdentity: @property def incarnation(self) -> ThreadIncarnation: - return ThreadIncarnation(self.owner, self.owner_created_at) + return RecordedThreadIncarnation(self.owner, self.owner_created_at) @property def admission_identity(self) -> AdmissionIdentity: diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index 6d31a0d4df..80ab8a0c2e 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -180,7 +180,7 @@ def goal_history( with _store_lock(self._wire_lock_path, shared=True): return self.registry.goal_history(name, goal_id=goal_id) - def goal_wait(self, name: str) -> GoalWait | None: + def goal_wait(self, name: str) -> GoalWait: waits = self.waits return waits.for_goal(self.registry.require(name).goal, waits.read()) @@ -357,7 +357,7 @@ def consume_reply_wait(self, owner: RegistryOwner, reference: MessageReference) if goal is None: return False wait = self.waits.for_goal(goal, self.waits.read()) - if wait is None or wait.owner_created_at != current.created_at: + if not wait.owner_matches(current.created_at): return False with self.bus.log.certified_read() as source: original = source.delivery(reference.seq) @@ -365,7 +365,7 @@ def consume_reply_wait(self, owner: RegistryOwner, reference: MessageReference) raise ValueError("Dependency reply source reference changed.") if not wait.matches(original, current.incarnation.require_recorded()): return False - return self.waits.clear(goal.id, wait_id=wait.wait_id) + return wait.consume_reply(self.waits, goal.id) def update_goal( self, diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 71d7496b6e..a063913bf2 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -18,7 +18,7 @@ UnrecordedBlockGoal, ) from .goals import Goal -from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation +from .thread_identity import RecordedThreadIncarnation from .thread_presentation import ThreadPresentation if TYPE_CHECKING: @@ -91,7 +91,7 @@ class GoalWaitTarget: def __post_init__(self) -> None: object.__setattr__( - self, "_incarnation", ThreadIncarnation(self.name, self.created_at).require_recorded() + self, "_incarnation", RecordedThreadIncarnation(self.name, self.created_at) ) def sent(self, original: CommittedDelivery) -> bool: diff --git a/src/agent_comms/goal_scheduler.py b/src/agent_comms/goal_scheduler.py index 52f5e004d4..7931170b55 100644 --- a/src/agent_comms/goal_scheduler.py +++ b/src/agent_comms/goal_scheduler.py @@ -88,7 +88,7 @@ def prepare_goal(self, session_id: str) -> ScheduledTurn | None: return goal = thread.active_goal if goal is not None: - if self.comms.goals.goal_wait(thread.name) is not None: + if not self.comms.goals.goal_wait(thread.name).allows_routed_input(): return if self.pending_goal_origins.get(thread.name) == goal.id: return diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index d3be94a6bf..c2b8da99f8 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -3,6 +3,7 @@ from __future__ import annotations import math +from abc import ABC, abstractmethod from collections.abc import Sequence from dataclasses import dataclass, replace from typing import TYPE_CHECKING, ClassVar @@ -21,8 +22,43 @@ from .threads import Thread +class GoalWait(ABC): + """A resolved dependency state; only RecordedGoalWait is persisted.""" + + @abstractmethod + def allows_routed_input(self) -> bool: ... + + def matches_id(self, wait_id: str | None) -> bool: + return False + + def owner_matches(self, created_at: float) -> bool: + return False + + def matches(self, original: CommittedDelivery, owner: RecordedThreadIncarnation) -> bool: + return False + + def consume_dependency(self, comms, canonical: str) -> bool: + return True + + def consume_reply(self, waits: GoalWaits, goal_id: str) -> bool: + return False + + def project(self, goal: Goal, snapshot: RegistrySnapshot) -> GoalExecution: + return GoalExecution( + GoalExecutionState.for_domain_state(goal.state), + goal.id, + block_reason=goal.state.reason, + ) + + +@dataclass(frozen=True, slots=True) +class NoGoalWait(GoalWait): + def allows_routed_input(self) -> bool: + return True + + @dataclass(frozen=True, slots=True) -class GoalWait: +class RecordedGoalWait(GoalWait): goal_id: str wait_id: str revision: int @@ -57,6 +93,32 @@ def __post_init__(self) -> None: ): raise ValueError("A reporting turn requires both its ID and generation.") + def allows_routed_input(self) -> bool: + return False + + def matches_id(self, wait_id: str | None) -> bool: + return self.wait_id == wait_id + + def owner_matches(self, created_at: float) -> bool: + return self.owner_created_at == created_at + + def consume_dependency(self, comms, canonical: str) -> bool: + return comms.goals.consume_goal_wait(canonical, self.wait_id) + + def consume_reply(self, waits: GoalWaits, goal_id: str) -> bool: + return waits.clear(goal_id, wait_id=self.wait_id) + + def project(self, goal: Goal, snapshot: RegistrySnapshot) -> GoalExecution: + targets = tuple( + replace(target, name=snapshot.canonical_name(target.name)) + for target in self.targets + ) + inactive = tuple( + target for target in targets + if not GoalWaits.target_has_active_turn(target, snapshot) + ) + return GoalExecution(GoalExecutionState.STANDBY, goal.id, targets, inactive) + def current_for(self, goal: Goal, owner_created_at: float) -> bool: """A retained wait can survive edits, but not replacement of its owner. @@ -126,17 +188,17 @@ def public(self) -> dict: @dataclass(frozen=True, slots=True) -class GoalWaits(ThreadOwnedState, LockedStore[dict[str, GoalWait]]): +class GoalWaits(ThreadOwnedState, LockedStore[dict[str, RecordedGoalWait]]): filename: ClassVar[str] = "goal_waits.json" @property - def record_type(self) -> type[dict[str, GoalWait]]: - return dict[str, GoalWait] + def record_type(self) -> type[dict[str, RecordedGoalWait]]: + return dict[str, RecordedGoalWait] - def empty(self) -> dict[str, GoalWait]: + def empty(self) -> dict[str, RecordedGoalWait]: return {} - def record(self, wait: GoalWait) -> None: + def record(self, wait: RecordedGoalWait) -> None: self.update(lambda rows: {**rows, wait.goal_id: wait}) def remove_threads(self, threads: Sequence[Thread]) -> None: @@ -153,7 +215,7 @@ def remove_threads(self, threads: Sequence[Thread]) -> None: def clear(self, goal_id: str, *, wait_id: str | None = None) -> bool: removed = False - def change(rows: dict[str, GoalWait]) -> dict[str, GoalWait]: + def change(rows: dict[str, RecordedGoalWait]) -> dict[str, RecordedGoalWait]: nonlocal removed current = rows.get(goal_id) if current is None or (wait_id is not None and current.wait_id != wait_id): @@ -165,10 +227,10 @@ def change(rows: dict[str, GoalWait]) -> dict[str, GoalWait]: return removed @staticmethod - def for_goal(goal: Goal | None, rows: dict[str, GoalWait]) -> GoalWait | None: + def for_goal(goal: Goal | None, rows: dict[str, RecordedGoalWait]) -> GoalWait: if goal is None or not goal.state.active: - return None - return rows.get(goal.id) + return NoGoalWait() + return rows.get(goal.id, NoGoalWait()) @staticmethod def target_has_active_turn(target: GoalWaitTarget, snapshot: RegistrySnapshot) -> bool: @@ -187,7 +249,7 @@ def target_has_active_turn(target: GoalWaitTarget, snapshot: RegistrySnapshot) - def closed_wait_group( owner: str, targets: tuple[GoalWaitTarget, ...], - rows: dict[str, GoalWait], + rows: dict[str, RecordedGoalWait], snapshot: RegistrySnapshot, ) -> tuple[str, ...]: """Find waits with no path to a live active turn outside the wait graph. @@ -250,23 +312,8 @@ def closed_wait_group( @staticmethod def execution( - goal: Goal | None, rows: dict[str, GoalWait], snapshot: RegistrySnapshot + goal: Goal | None, rows: dict[str, RecordedGoalWait], snapshot: RegistrySnapshot ) -> GoalExecution | None: if goal is None: return None - if wait := GoalWaits.for_goal(goal, rows): - targets = tuple( - replace(target, name=snapshot.canonical_name(target.name)) - for target in wait.targets - ) - inactive = tuple( - target - for target in targets - if not GoalWaits.target_has_active_turn(target, snapshot) - ) - return GoalExecution(GoalExecutionState.STANDBY, goal.id, targets, inactive) - return GoalExecution( - GoalExecutionState.for_domain_state(goal.state), - goal.id, - block_reason=goal.state.reason, - ) + return GoalWaits.for_goal(goal, rows).project(goal, snapshot) diff --git a/src/agent_comms/goals.py b/src/agent_comms/goals.py index ac4a7a0711..0a095fec34 100644 --- a/src/agent_comms/goals.py +++ b/src/agent_comms/goals.py @@ -7,7 +7,7 @@ from abc import abstractmethod from .declared_family import DeclaredFamily -from .thread_identity import ThreadIncarnation +from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation from .field_codec import FieldCodec from .goal_states import ActiveGoal, GoalState @@ -82,11 +82,11 @@ class ResolvedMentionBinding(GoalMentionBinding): def __post_init__(self): super().__post_init__() - ThreadIncarnation(self.peer_name, self.peer_created_at) + RecordedThreadIncarnation(self.peer_name, self.peer_created_at) @property def peer(self) -> ThreadIncarnation: - return ThreadIncarnation(self.peer_name, self.peer_created_at) + return RecordedThreadIncarnation(self.peer_name, self.peer_created_at) def project(self, registry, owner, goal, source): from .relationships import GoalDerivedContact, GoalMentionDiagnostic @@ -133,7 +133,7 @@ def __post_init__(self) -> None: GoalRevision(self.goal_id, self.text_revision) TextDigest(self.text_digest) - ThreadIncarnation(self.owner_name, self.owner_created_at) + RecordedThreadIncarnation(self.owner_name, self.owner_created_at) @property def checkpoint(self) -> GoalRevision: @@ -147,7 +147,7 @@ def digest(self): @property def incarnation(self) -> ThreadIncarnation: - return ThreadIncarnation(self.owner_name, self.owner_created_at) + return RecordedThreadIncarnation(self.owner_name, self.owner_created_at) def matches(self, goal, owner, registry) -> bool: if self.goal_id != goal.id or self.text_revision > goal.revision: diff --git a/src/agent_comms/ordinary_admission_rules.py b/src/agent_comms/ordinary_admission_rules.py index 9ebc83a56b..bfe2d02007 100644 --- a/src/agent_comms/ordinary_admission_rules.py +++ b/src/agent_comms/ordinary_admission_rules.py @@ -156,7 +156,7 @@ class OrdinaryContextCheck(RuleCheck): source: TurnInputSource owner: Thread goal: Goal | None - wait: GoalWait | None + wait: GoalWait comms: Comms diff --git a/src/agent_comms/owned_send_admission.py b/src/agent_comms/owned_send_admission.py index d119452a9a..cc75c45301 100644 --- a/src/agent_comms/owned_send_admission.py +++ b/src/agent_comms/owned_send_admission.py @@ -99,7 +99,7 @@ def _check_owner( ).require_valid() def _require_context( - self, source: TurnInputSource, current: Thread, wait: GoalWait | None, + self, source: TurnInputSource, current: Thread, wait: GoalWait, ) -> None: OrdinaryContextCheck( source=source, owner=current, goal=current.goal, wait=wait, comms=self.comms @@ -123,13 +123,13 @@ def __call__( snapshot = self.comms.registry.snapshot() canonical = snapshot.canonical_name(self.thread.name) current = snapshot.threads.get(canonical) - wait = self.comms.goals.goal_wait(canonical) if current is not None else None source = self.source(public_id) try: self._check_owner(current, snapshot, canonical, source, sent_text) except ReservationViolationError as error: yield self._refusal(error.rule, False) return + wait = self.comms.goals.goal_wait(canonical) defer = source.defers_for_goal(self.thread.goal, current.goal) try: self._require_context(source, current, wait) diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index cf04162573..1ee91721a6 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -115,14 +115,12 @@ def admit(self, permits: ExitStack): wait = self.runner.comms.goals.goal_wait(self.thread_name) active_goal = self.thread.active_goal if ( - wait is not None + not wait.allows_routed_input() and not self.original_owner_input - and wait.wait_id != self.dependency_wait_id + and not wait.matches_id(self.dependency_wait_id) ): return - if self.dependency_wait_id is not None and ( - wait is None or wait.wait_id != self.dependency_wait_id - ): + if self.dependency_wait_id is not None and not wait.matches_id(self.dependency_wait_id): return if self.original_owner_input and self.original_goal_id != ( active_goal.id if active_goal is not None else None diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index e08eb2598f..a657ac95dc 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -17,7 +17,7 @@ from .errors import UnregisteredThreadError from .field_codec import FieldCodec from .store_files import _store_lock -from .thread_identity import ThreadIncarnation +from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation, ThreadIncarnationRecord if TYPE_CHECKING: from .channels import Channel @@ -52,7 +52,7 @@ def from_wire(cls, raw: dict) -> Conversation: pairs = FieldCodec.decode(tuple[tuple[str, float], ...], data.pop("participants")) return replace( FieldCodec.decode(cls, data), - participants=tuple(ThreadIncarnation(*pair) for pair in pairs), + participants=tuple(ThreadIncarnationRecord.decode(*pair) for pair in pairs), ) @@ -71,7 +71,7 @@ class DisplayBasis: @property def viewer_identity(self) -> ThreadIncarnation: - return ThreadIncarnation(self.viewer, self.viewer_created_at) + return RecordedThreadIncarnation(self.viewer, self.viewer_created_at) def select(self, sequences: Collection[int]) -> DisplayBasis: """Retain painted members of this proof; never introduce new sequences.""" diff --git a/src/agent_comms/read_ledger.py b/src/agent_comms/read_ledger.py index eb69cbce9e..cc50a9ee2e 100644 --- a/src/agent_comms/read_ledger.py +++ b/src/agent_comms/read_ledger.py @@ -16,9 +16,8 @@ from .locked_store import LockedStore from .private_path import FileIdentity, FileRevision from .read_basis import Conversation, DisplayBasis, DisplayedConversation -from .registry_document import RetiredRecipient from .sealed import Sealed -from .thread_identity import ThreadIncarnation +from .thread_identity import RecordedThreadIncarnation from .thread_owned_state import ThreadOwnedState if TYPE_CHECKING: @@ -170,13 +169,9 @@ def displayed_recipient( Processing, assignment acceptance and a reply cannot grant this fact. A reused name with a different creation identity cannot inherit it. """ - selected = snapshot.recipient(recipient, original) - if isinstance(selected, RetiredRecipient): - return () - thread = selected.thread - if not self.human(thread.role): - return () - return self._displayed_recipient(original.message, thread, snapshot, document=document) + return snapshot.recipient(recipient, original).read_effect( + self, original.message, snapshot, document=document + ) def historical_displayed_recipient( self, message, recipient, snapshot: RegistryProvenance, *, document=None @@ -188,17 +183,11 @@ def historical_displayed_recipient( viewer's incarnation. A same-name destination reader cannot attest a different frozen recipient or replace the recorded namespace. """ - # The archived namespace can retain two original names with one birth. - # Its paint ledger binds the name and original hash; live selection - # remains lookup-only and never guesses an owner for that collision. - thread = snapshot.threads.get(snapshot.canonical_name(recipient.canonical_thread)) - if ( - thread is None - or not thread.incarnation.has_recorded_birth - or thread.incarnation.require_recorded().lookup != recipient.recipient_lookup - ): - return () - return self._displayed_recipient(message, thread, snapshot, document=document) + # Archive selection binds both the original name and birth. A live + # lookup index would conflate different names with the same birth. + return snapshot.historical_recipient(recipient).read_effect( + self, message, snapshot, document=document + ) def _displayed_recipient(self, message, thread, snapshot, *, document): if message.seq in self.seen_sequences(thread.name, snapshot, document=document): @@ -219,7 +208,7 @@ def remove_threads(self, threads: Sequence[Thread]) -> None: def retained_message(key: str) -> bool: name, created, _ = json.loads(key) - return ThreadIncarnation(name, created) not in viewers + return RecordedThreadIncarnation(name, created) not in viewers def retained_transcript(key: str) -> bool: name, source, _ = json.loads(key) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 8e5dd68701..f73b54fa20 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -4,6 +4,7 @@ import math import time +from abc import ABC, abstractmethod from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass, field, replace from types import MappingProxyType @@ -27,6 +28,7 @@ from .routing import TurnRouting from .thread_identity import AdmissionIdentity, GenerationCounter, OwnerIdentity from .thread_presentation import ThreadOwnerBinding +from .thread_provenance import ThreadProvenance from .thread_status import ( ArchivedThreadStatus, StoppedThreadStatus, @@ -92,14 +94,7 @@ def copy(self) -> RegistryDocument: ) def snapshot(self) -> RegistrySnapshot: - return RegistrySnapshot( - threads=dict(self.threads), - statuses=dict(self.statuses), - last_seen=dict(self.last_seen), - aliases=dict(self.aliases), - owner_generations=dict(self.owners.generations), - admission_generations=dict(self.admissions.generations), - ) + return RegistrySnapshot.capture(self) def entry(self, name: str) -> RegistryEntry: thread = self.require(name) @@ -531,15 +526,52 @@ def owner_identity(self) -> OwnerIdentity: return OwnerIdentity(self.thread.incarnation, self.owner_generation) +class RecipientSelection(ABC): + """The registry owns the selected recipient's live or retired behavior.""" + + @abstractmethod + def activity(self, observations, snapshot): ... + + @abstractmethod + def read_effect(self, reads, message, snapshot, *, document=None): ... + + +@dataclass(frozen=True, slots=True) +class KnownRecipient(RecipientSelection): + thread: ThreadProvenance + + def read_effect(self, reads, message, snapshot, *, document=None): + return snapshot.displayed_recipient_effect( + reads, message, self.thread, document=document + ) + + @dataclass(frozen=True, slots=True) -class RegisteredRecipient: +class RegisteredRecipient(KnownRecipient): thread: Thread + def activity(self, observations, snapshot): + return self.thread.execution.observe_recipient(observations, snapshot, self.thread) + + +@dataclass(frozen=True, slots=True) +class ArchivedRecipient(KnownRecipient): + def activity(self, observations, snapshot): + raise RelationViolationError("Archived recipients have no live activity") + @dataclass(frozen=True, slots=True) -class RetiredRecipient: +class RetiredRecipient(RecipientSelection): lookup: str + def activity(self, observations, snapshot): + from .agent_activity import UnavailableRecipientActivity + + return UnavailableRecipientActivity() + + def read_effect(self, reads, message, snapshot, *, document=None): + return () + @dataclass(frozen=True, slots=True) class RegistrySnapshot(RegistryPresence, RegistryProvenance): @@ -549,21 +581,43 @@ class RegistrySnapshot(RegistryPresence, RegistryProvenance): aliases: Mapping[str, str] owner_generations: Mapping[str, int] admission_generations: Mapping[str, int] - by_lookup: Mapping[str, Thread] = field(init=False, repr=False, compare=False) + by_lookup: Mapping[str, Thread] = field(repr=False, compare=False) def __post_init__(self) -> None: - # This is a live ownership cut. Archived namespaces use the separate - # RegistryProvenance type and do not invent a lookup for duplicate births. - indexed: dict[str, Thread] = {} + super(RegistrySnapshot, self).__post_init__() + seen: set[str] = set() for thread in self.threads.values(): - incarnation = thread.incarnation - if not incarnation.has_recorded_birth: - raise RelationViolationError("Live registry creation identity is unrecorded.") - lookup = incarnation.require_recorded().lookup - if lookup in indexed: + lookup = thread.incarnation.require_recorded().lookup + if lookup in seen: raise RelationViolationError("Registry creation identities collide.") + seen.add(lookup) + if self.by_lookup.get(lookup) is not thread: + raise RelationViolationError("Snapshot lookup index differs from its threads.") + if len(seen) != len(self.by_lookup): + raise RelationViolationError("Snapshot lookup index includes an unregistered thread.") + + @classmethod + def capture(cls, document: RegistryDocument) -> RegistrySnapshot: + """One live registry cut owns the index and all its source maps.""" + indexed: dict[str, Thread] = {} + for thread in document.threads.values(): + incarnation = thread.incarnation.require_recorded() + lookup = incarnation.lookup indexed[lookup] = thread - object.__setattr__(self, "by_lookup", MappingProxyType(indexed)) + return cls( + threads=MappingProxyType(dict(document.threads)), + statuses=MappingProxyType(dict(document.statuses)), + last_seen=MappingProxyType(dict(document.last_seen)), + aliases=MappingProxyType(dict(document.aliases)), + owner_generations=MappingProxyType(dict(document.owners.generations)), + admission_generations=MappingProxyType(dict(document.admissions.generations)), + by_lookup=MappingProxyType(indexed), + ) + + def displayed_recipient_effect(self, reads, message, thread: Thread, *, document=None): + if not reads.human(thread.role): + return () + return super().displayed_recipient_effect(reads, message, thread, document=document) def recipient( self, recipient: FrozenRecipient, original: CommittedDelivery diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index fede631146..f68cac2339 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -4,11 +4,15 @@ from collections.abc import Iterator, Mapping from dataclasses import dataclass -from typing import Generic, TypeVar +from typing import TYPE_CHECKING, Annotated, Generic, TypeVar from .errors import RelationViolationError, UnregisteredThreadError from .thread_identity import ThreadIncarnation -from .thread_provenance import ThreadProvenance +from .thread_provenance import ThreadProvenance, ThreadProvenanceRecord + +if TYPE_CHECKING: + from .audience_manifest import FrozenRecipient + from .registry_document import ArchivedRecipient, RetiredRecipient Member = TypeVar("Member", bound=ThreadProvenance, covariant=True) @@ -76,7 +80,7 @@ def require(self, name: str) -> Member: @dataclass(frozen=True, slots=True, kw_only=True) class RegistryProvenance(RegistryNames[ThreadProvenance]): - threads: dict[str, ThreadProvenance] + threads: dict[str, Annotated[ThreadProvenance, ThreadProvenanceRecord]] aliases: dict[str, str] def __post_init__(self) -> None: @@ -91,6 +95,20 @@ def retired(self, name: str) -> bool: carries but its captured registry lacks was deleted before capture.""" return name not in self.threads + def displayed_recipient_effect(self, reads, message, thread: ThreadProvenance, *, document=None): + return reads._displayed_recipient(message, thread, self, document=document) + + def historical_recipient(self, recipient: FrozenRecipient) -> ArchivedRecipient | RetiredRecipient: + """Archives select by original name and birth; duplicate births stay distinct.""" + from .registry_document import ArchivedRecipient, RetiredRecipient + + thread = self.threads.get(self.canonical_name(recipient.canonical_thread)) + if thread is not None and thread.incarnation.has_recorded_birth and ( + thread.incarnation.require_recorded().lookup == recipient.recipient_lookup + ): + return ArchivedRecipient(thread) + return RetiredRecipient(recipient.recipient_lookup) + @staticmethod def capture(snapshot: RegistryProvenance) -> RegistryProvenance: return RegistryProvenance( diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index e853520115..b9d1ed5ea5 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -38,7 +38,7 @@ from .registry_document import RegistrySnapshot from .routing import DeliveryScope from .store_files import _store_lock -from .thread_identity import ThreadIncarnation +from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation from .thread_owned_state import ThreadOwnedState from .threads import Thread @@ -149,11 +149,11 @@ def record(self, peer: ThreadIncarnation) -> RelationRecord: @property def owner_incarnation(self) -> ThreadIncarnation: - return ThreadIncarnation(self.owner, self.owner_created) + return RecordedThreadIncarnation(self.owner, self.owner_created) @property def peer_incarnation(self) -> ThreadIncarnation: - return ThreadIncarnation(self.peer, self.peer_created) + return RecordedThreadIncarnation(self.peer, self.peer_created) def oriented(self, thread: Thread): if self.owner_incarnation == thread.incarnation: @@ -222,11 +222,11 @@ def record(self, peer: ThreadIncarnation) -> RelationRecord: @property def owner_incarnation(self) -> ThreadIncarnation: - return ThreadIncarnation(self.owner, self.owner_created_at) + return RecordedThreadIncarnation(self.owner, self.owner_created_at) @property def peer_incarnation(self) -> ThreadIncarnation: - return ThreadIncarnation(self.peer, self.peer_created_at) + return RecordedThreadIncarnation(self.peer, self.peer_created_at) @dataclass(frozen=True, slots=True) @@ -497,7 +497,7 @@ class RelationshipOrder: @property def incarnation(self) -> ThreadIncarnation: - return ThreadIncarnation(self.owner, self.owner_created) + return RecordedThreadIncarnation(self.owner, self.owner_created) @property def identity(self) -> tuple[ThreadIncarnation, type[SortableRelationshipGroup]]: diff --git a/src/agent_comms/routing.py b/src/agent_comms/routing.py index 64665adf3d..be5bc17b1d 100644 --- a/src/agent_comms/routing.py +++ b/src/agent_comms/routing.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from .catalog_document import CatalogDocument - from .registry_document import RegistrySnapshot + from .registry_provenance import RegistryProvenance from .thread_identity import ThreadIncarnation @@ -173,7 +173,7 @@ class DeliveryScope(MessageDisplayScope): @classmethod def capture( - cls, name: str, snapshot: RegistrySnapshot, catalog: CatalogDocument + cls, name: str, snapshot: RegistryProvenance, catalog: CatalogDocument ) -> DeliveryScope: thread = snapshot.require(name) return cls(thread.name, snapshot.aliases, catalog.targets_for(thread.tags)) @@ -223,7 +223,7 @@ def sender_lookups( on_unrecorded() def minimum_timestamp( - self, sender: str, target: str, sender_lookup: str, snapshot: RegistrySnapshot + self, sender: str, target: str, sender_lookup: str, snapshot: RegistryProvenance ) -> float | None: """Require current identities while preserving aliases of one incarnation.""" @@ -238,7 +238,7 @@ def minimum_timestamp( return max(source.created_at, snapshot.threads[self.actor].created_at) def unread( - self, delivery: DeliveryMessage, snapshot: RegistrySnapshot, seen: frozenset[int] + self, delivery: DeliveryMessage, snapshot: RegistryProvenance, seen: frozenset[int] ) -> bool: message = delivery.message if message.seq in seen: diff --git a/src/agent_comms/selected_write_plan.py b/src/agent_comms/selected_write_plan.py index 6fab5046e9..a9ac5918e8 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -35,7 +35,7 @@ from .private_path import PrivateDirectoryRole, PrivateFileRole from .pi_rpc import unique_fields from .message_reference import MessageReference -from .thread_identity import AdmissionIdentity, ThreadIncarnation +from .thread_identity import AdmissionIdentity, RecordedThreadIncarnation _MAX_BYTES = 1024 * 1024 @@ -78,7 +78,7 @@ class SelectedWriteIntent(DeclaredFamily, affix="WriteIntent"): def binding(self) -> SelectedWriteBinding: return SelectedWriteBinding( self.root_id, MessageReference(self.source_seq, self.source_message_id), - self.claim_id, AdmissionIdentity(ThreadIncarnation(self.owner, self.incarnation), self.admission_epoch), + self.claim_id, AdmissionIdentity(RecordedThreadIncarnation(self.owner, self.incarnation), self.admission_epoch), ) def require_accepted(self): diff --git a/src/agent_comms/task_sources.py b/src/agent_comms/task_sources.py index 07feb153ca..d8539132ad 100644 --- a/src/agent_comms/task_sources.py +++ b/src/agent_comms/task_sources.py @@ -11,7 +11,7 @@ from .field_codec import FieldCodec from .goals import GoalRevision from .message_reference import MessageReference -from .thread_identity import ThreadIncarnation, ThreadRole, TurnId, TurnIdentity +from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation, ThreadRole, TurnId, TurnIdentity from .turn_context import Provenance, WireProvenance if TYPE_CHECKING: @@ -552,7 +552,7 @@ def require_wording_publication(self, registry, original_source): origin = row.origin.require_human() if origin.root_id != original_source.witness.root_id: raise RelationViolationError("USER input pin belongs to another wire root") - original_author = ThreadIncarnation(origin.author.sender, origin.author.created_at) + 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.admission.incarnation.resolved(registry) != self.recipient.resolved(registry): diff --git a/src/agent_comms/thread_configuration.py b/src/agent_comms/thread_configuration.py index faedf22fa2..a44a49bb36 100644 --- a/src/agent_comms/thread_configuration.py +++ b/src/agent_comms/thread_configuration.py @@ -9,7 +9,7 @@ from .pi_vocabulary import ThinkingLevel from .registration import Registration from .runtime_info import AgentRuntimeInfo -from .thread_identity import ThreadIncarnation +from .thread_identity import RecordedThreadIncarnation from .thread_management import ThreadManagement from .agent_activity import AgentActivity @@ -26,7 +26,7 @@ def answer(self, name: str) -> CoordinationChangedUpdate: info = self.agents.agent_info_of(thread.name) usage = AgentRuntimeInfo.optional_context_usage(info) return CoordinationChangedUpdate( - ThreadIncarnation(thread.name, thread.created_at), + RecordedThreadIncarnation(thread.name, thread.created_at), str(self.root.resolve()), os.getpid(), thread.worktree, diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 2c523abab7..37fb18fe58 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -8,43 +8,45 @@ import hashlib import math +from abc import ABC, abstractmethod from dataclasses import dataclass, field, replace from enum import StrEnum from functools import cached_property from typing import TYPE_CHECKING, ClassVar from .errors import RelationViolationError +from .field_codec import WireValue if TYPE_CHECKING: from .registry_provenance import RegistryProvenance @dataclass(frozen=True, slots=True) -class ThreadIncarnation: +class ThreadIncarnation(WireValue, ABC): name: str created_at: float - def __new__(cls, name: str, created_at: float) -> ThreadIncarnation: - if cls is ThreadIncarnation and type(created_at) in (int, float): - if created_at > 0: - return object.__new__(RecordedThreadIncarnation) - if created_at == cls.DELETED_BIRTH: - return object.__new__(DeletedThreadIncarnation) - if created_at == 0: - return object.__new__(UnrecordedThreadIncarnation) - return object.__new__(cls) + def to_wire(self) -> dict[str, str | float]: + return {"name": self.name, "created_at": self.created_at} - # The persisted birth of a deleted participant: its declaration and real - # birth are gone, its name stays on the bus. See DeletedThread. - DELETED_BIRTH: ClassVar[float] = -1.0 + @classmethod + def schema(cls) -> dict: + from .field_codec import FieldCodec + + return FieldCodec.record_schema(ThreadIncarnation) @classmethod - def deleted(cls, name: str) -> DeletedThreadIncarnation: - return DeletedThreadIncarnation(name, cls.DELETED_BIRTH) + def from_wire(cls, data: dict) -> ThreadIncarnation: + decoded = ThreadIncarnationRecord.decode(data["name"], data["created_at"]) + if not isinstance(decoded, cls): + raise ValueError("Decoded thread birth does not match its declared member") + return decoded + names_deleted_thread: ClassVar[bool] = False + has_recorded_birth: ClassVar[bool] = False - @property - def names_deleted_thread(self) -> bool: - return isinstance(self, DeletedThreadIncarnation) + @classmethod + def deleted(cls, name: str) -> DeletedThreadIncarnation: + return DeletedThreadIncarnation(name) def __post_init__(self) -> None: from .field_codec import FieldCodec @@ -53,17 +55,18 @@ def __post_init__(self) -> None: raise ValueError("Thread incarnation requires a recorded name") if not math.isfinite(FieldCodec.decode(float, self.created_at)): raise ValueError("Thread incarnation requires a finite birth") - if type(self) is ThreadIncarnation: - raise ValueError("Thread incarnation requires a declared birth state") - - @property - def has_recorded_birth(self) -> bool: - return isinstance(self, RecordedThreadIncarnation) + @abstractmethod def require_recorded(self) -> RecordedThreadIncarnation: - if not isinstance(self, RecordedThreadIncarnation): - raise ValueError("Thread incarnation requires a positive recorded birth") - return self + """Only the recorded member can expose a lookup capability.""" + + @abstractmethod + def provenance_type(self): + """The archive declaration carrying this recorded birth state.""" + + @abstractmethod + def renamed(self, name: str) -> ThreadIncarnation: + """Retain this member's birth state under an original alias.""" def resolved(self, snapshot: RegistryProvenance) -> ThreadIncarnation: """Follow retained rename aliases only for this exact historical owner.""" @@ -91,7 +94,7 @@ def matches_recorded_name(self, name: str, snapshot: RegistryProvenance) -> bool def recorded_names(self, snapshot: RegistryProvenance) -> tuple[ThreadIncarnation, ...]: """Original names retained for this exact current incarnation.""" canonical = self.resolved(snapshot) - return (canonical, *(type(self)(alias, canonical.created_at) + return (canonical, *(canonical.renamed(alias) for alias, name in snapshot.aliases.items() if name == canonical.name)) @@ -100,6 +103,19 @@ def recorded_names(self, snapshot: RegistryProvenance) -> tuple[ThreadIncarnatio class RecordedThreadIncarnation(ThreadIncarnation): """Only a positive recorded birth can select a durable lookup.""" + has_recorded_birth: ClassVar[bool] = True + + def require_recorded(self) -> RecordedThreadIncarnation: + return self + + def provenance_type(self): + from .thread_provenance import ThreadProvenance + + return ThreadProvenance + + def renamed(self, name: str) -> RecordedThreadIncarnation: + return RecordedThreadIncarnation(name, self.created_at) + def __post_init__(self) -> None: super().__post_init__() self._require_recorded_birth(self.created_at) @@ -126,20 +142,53 @@ def lookup(self) -> str: class DeletedThreadIncarnation(ThreadIncarnation): """A deleted participant retains only its name, never a lookup capability.""" - def __post_init__(self) -> None: - super().__post_init__() - if self.created_at != self.DELETED_BIRTH: - raise ValueError("Deleted incarnation requires its deleted birth sentinel") + created_at: float = field(default=-1.0, init=False) + names_deleted_thread: ClassVar[bool] = True + + def require_recorded(self) -> RecordedThreadIncarnation: + raise ValueError("Thread incarnation requires a positive recorded birth") + + def provenance_type(self): + from .thread_provenance import DeletedThreadProvenance + + return DeletedThreadProvenance + + def renamed(self, name: str) -> DeletedThreadIncarnation: + return DeletedThreadIncarnation(name) @dataclass(frozen=True) class UnrecordedThreadIncarnation(ThreadIncarnation): """Historical zero-birth evidence cannot select a live recipient.""" - def __post_init__(self) -> None: - super().__post_init__() - if self.created_at != 0: - raise ValueError("Unrecorded incarnation requires its zero-birth sentinel") + created_at: float = field(default=0.0, init=False) + + def require_recorded(self) -> RecordedThreadIncarnation: + raise ValueError("Thread incarnation requires a positive recorded birth") + + def provenance_type(self): + from .thread_provenance import UnrecordedThreadProvenance + + return UnrecordedThreadProvenance + + def renamed(self, name: str) -> UnrecordedThreadIncarnation: + return UnrecordedThreadIncarnation(name) + + +class ThreadIncarnationRecord: + """One explicit boundary decoder for stored birth numbers, never a constructor switch.""" + + @staticmethod + def decode(name: str, birth: float) -> ThreadIncarnation: + from .field_codec import FieldCodec + + name = FieldCodec.decode(str, name) + birth = FieldCodec.decode(float, birth) + if birth == -1: + return DeletedThreadIncarnation(name) + if birth == 0: + return UnrecordedThreadIncarnation(name) + return RecordedThreadIncarnation(name, birth) @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/thread_provenance.py b/src/agent_comms/thread_provenance.py index 35b971fe4d..1699f92249 100644 --- a/src/agent_comms/thread_provenance.py +++ b/src/agent_comms/thread_provenance.py @@ -5,7 +5,11 @@ from dataclasses import dataclass, field, fields from .channel_targets import Tag -from .thread_identity import ThreadIncarnation +from .field_codec import FieldCodec, FieldRepresentation +from .thread_identity import ( + DeletedThreadIncarnation, RecordedThreadIncarnation, ThreadIncarnation, + ThreadIncarnationRecord, UnrecordedThreadIncarnation, +) @dataclass(frozen=True, slots=True, kw_only=True) @@ -18,12 +22,17 @@ class ThreadProvenance: session_file: str | None = None title: str | None = None + def make_incarnation(self) -> ThreadIncarnation: + return RecordedThreadIncarnation(self.name, self.created_at) + + @property + def provenance_type(self) -> type[ThreadProvenance]: + return ThreadProvenance + def __post_init__(self) -> None: - # Historical evidence retains finite recorded values, including zero. - # Positive-birth admission belongs to the existing live capability. - object.__setattr__( - self, "_incarnation", ThreadIncarnation(self.name, self.created_at) - ) + object.__setattr__(self, "_incarnation", self.make_incarnation()) + if self._incarnation.created_at != self.created_at: + raise ValueError("Thread provenance birth differs from its declared state") allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") if not self.name or not set(self.name) <= allowed: raise ValueError(f"Thread name {self.name!r} must be alphanumeric with hyphens/underscores.") @@ -35,7 +44,7 @@ def __post_init__(self) -> None: @staticmethod def capture(thread: ThreadProvenance) -> ThreadProvenance: """Freeze only determining provenance from the original typed declaration.""" - return ThreadProvenance( + return thread.provenance_type( **{ item.name: getattr(thread, item.name) for item in fields(ThreadProvenance) @@ -46,3 +55,42 @@ def capture(thread: ThreadProvenance) -> ThreadProvenance: @property def incarnation(self) -> ThreadIncarnation: return self._incarnation + + +@dataclass(frozen=True, slots=True, kw_only=True) +class UnrecordedThreadProvenance(ThreadProvenance): + def make_incarnation(self) -> ThreadIncarnation: + return UnrecordedThreadIncarnation(self.name) + + @property + def provenance_type(self) -> type[ThreadProvenance]: + return UnrecordedThreadProvenance + + +@dataclass(frozen=True, slots=True, kw_only=True) +class DeletedThreadProvenance(ThreadProvenance): + def make_incarnation(self) -> ThreadIncarnation: + return DeletedThreadIncarnation(self.name) + + @property + def provenance_type(self) -> type[ThreadProvenance]: + return DeletedThreadProvenance + + +class ThreadProvenanceRecord(FieldRepresentation): + """Decode an archived registry row's recorded birth state exactly once.""" + + @classmethod + def encode(cls, value: ThreadProvenance) -> object: + return FieldCodec.encode(value) + + @classmethod + def schema(cls) -> dict: + return FieldCodec.record_schema(ThreadProvenance) + + @classmethod + def decode(cls, value: object) -> ThreadProvenance: + if not isinstance(value, dict): + raise TypeError("Recorded thread provenance must be an object") + identity = ThreadIncarnationRecord.decode(value["name"], value["created_at"]) + return FieldCodec.decode(identity.provenance_type(), value) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index c43b983b31..796bd48eff 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -18,7 +18,7 @@ from .field_codec import FieldCodec from .command import Command from .declared_family import DeclaredFamily -from .thread_identity import ThreadIncarnation +from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation from .threads import Thread from .typed_table import Column, TypedRow, TypedTable, sql_literal @@ -52,7 +52,7 @@ def __post_init__(self) -> None: @property def incarnation(self) -> ThreadIncarnation: - return ThreadIncarnation(self.owner, self.owner_created) + return RecordedThreadIncarnation(self.owner, self.owner_created) @dataclass(frozen=True, slots=True) @@ -75,11 +75,11 @@ def __post_init__(self) -> None: @property def owner_identity(self) -> ThreadIncarnation: - return ThreadIncarnation(self.owner, self.owner_created) + return RecordedThreadIncarnation(self.owner, self.owner_created) @property def parent_identity(self) -> ThreadIncarnation: - return ThreadIncarnation(self.parent, self.parent_created) + return RecordedThreadIncarnation(self.parent, self.parent_created) def owned_by(self, actor: ThreadIncarnation, generation: str | None) -> bool: return (self.owner_identity, self.generation) == (actor, generation) @@ -307,7 +307,7 @@ class Todo(TypedTable): @property def creator_identity(self) -> ThreadIncarnation: - return ThreadIncarnation(self.creator, self.creator_created) + return RecordedThreadIncarnation(self.creator, self.creator_created) def require_result(self, result: Todo) -> None: if self != result: diff --git a/src/agent_comms/turn_input_source.py b/src/agent_comms/turn_input_source.py index f66271e365..0904d3053b 100644 --- a/src/agent_comms/turn_input_source.py +++ b/src/agent_comms/turn_input_source.py @@ -34,11 +34,11 @@ class TurnInputSource(ABC): def valid_keys(self, text: str) -> bool: return len(self.keys) <= 1 - def dependency_current(self, wait: GoalWait | None) -> bool: + def dependency_current(self, wait: GoalWait) -> bool: return True @abstractmethod - def allows_wait(self, wait: GoalWait | None, comms: Comms, + def allows_wait(self, wait: GoalWait, comms: Comms, owner: RecordedThreadIncarnation) -> bool: ... @abstractmethod @@ -47,7 +47,7 @@ def allows_goal_input(self, goal: Goal | None) -> bool: ... @abstractmethod def display(self, dispositions: InputDispositions, text: str) -> str | None: ... - def consume_wait(self, comms: Comms, canonical: str, wait: GoalWait | None) -> bool: + def consume_wait(self, comms: Comms, canonical: str, wait: GoalWait) -> bool: return True def defers_for_goal(self, original: Goal | None, current: Goal | None) -> bool: @@ -56,25 +56,25 @@ def defers_for_goal(self, original: Goal | None, current: Goal | None) -> bool: class InputDependency(ABC): @abstractmethod - def current(self, wait: GoalWait | None) -> bool: ... + def current(self, wait: GoalWait) -> bool: ... @abstractmethod - def allows(self, wait: GoalWait | None, comms: Comms, origins: tuple[Message, ...], + def allows(self, wait: GoalWait, comms: Comms, origins: tuple[Message, ...], owner: RecordedThreadIncarnation) -> bool: ... @abstractmethod - def consume(self, comms: Comms, canonical: str, wait: GoalWait | None) -> bool: ... + def consume(self, comms: Comms, canonical: str, wait: GoalWait) -> bool: ... class NoInputDependency(InputDependency): - def current(self, wait: GoalWait | None) -> bool: + def current(self, wait: GoalWait) -> bool: return True - def allows(self, wait: GoalWait | None, comms: Comms, origins: tuple[Message, ...], + def allows(self, wait: GoalWait, comms: Comms, origins: tuple[Message, ...], owner: RecordedThreadIncarnation) -> bool: - return wait is None + return wait.allows_routed_input() - def consume(self, comms: Comms, canonical: str, wait: GoalWait | None) -> bool: + def consume(self, comms: Comms, canonical: str, wait: GoalWait) -> bool: return True @@ -82,12 +82,12 @@ def consume(self, comms: Comms, canonical: str, wait: GoalWait | None) -> bool: class CapturedInputDependency(InputDependency): wait_id: str - def current(self, wait: GoalWait | None) -> bool: - return wait is not None and wait.wait_id == self.wait_id + def current(self, wait: GoalWait) -> bool: + return wait.matches_id(self.wait_id) - def allows(self, wait: GoalWait | None, comms: Comms, origins: tuple[Message, ...], + def allows(self, wait: GoalWait, comms: Comms, origins: tuple[Message, ...], owner: RecordedThreadIncarnation) -> bool: - if wait is None: + if wait.allows_routed_input(): return True if not self.current(wait): return False @@ -98,8 +98,8 @@ def allows(self, wait: GoalWait | None, comms: Comms, origins: tuple[Message, .. return True return False - def consume(self, comms: Comms, canonical: str, wait: GoalWait | None) -> bool: - return wait is None or comms.goals.consume_goal_wait(canonical, wait.wait_id) + def consume(self, comms: Comms, canonical: str, wait: GoalWait) -> bool: + return wait.consume_dependency(comms, canonical) @dataclass(frozen=True, kw_only=True) @@ -144,15 +144,15 @@ def reserve( def display(self, dispositions: InputDispositions, text: str) -> str | None: return self.original_display - def consume_wait(self, comms: Comms, canonical: str, wait: GoalWait | None) -> bool: + def consume_wait(self, comms: Comms, canonical: str, wait: GoalWait) -> bool: return self.dependency.consume(comms, canonical, wait) - def dependency_current(self, wait: GoalWait | None) -> bool: + def dependency_current(self, wait: GoalWait) -> bool: return self.dependency.current(wait) class DirectInput(TurnInputSource): - def allows_wait(self, wait: GoalWait | None, comms: Comms, + def allows_wait(self, wait: GoalWait, comms: Comms, owner: RecordedThreadIncarnation) -> bool: return True @@ -171,9 +171,9 @@ def notice_text(self) -> str: class RoutedInput(TurnInputSource): - def allows_wait(self, wait: GoalWait | None, comms: Comms, + def allows_wait(self, wait: GoalWait, comms: Comms, owner: RecordedThreadIncarnation) -> bool: - return wait is None + return wait.allows_routed_input() def allows_goal_input(self, goal: Goal | None) -> bool: return not self.keys or InactiveGoalPermission().allows(goal) @@ -200,7 +200,7 @@ def answering(cls, prompt: str) -> SelectedOriginalInput: class DependencyOriginalInput(OriginalTurnInput, DirectInput): - def allows_wait(self, wait: GoalWait | None, comms: Comms, + def allows_wait(self, wait: GoalWait, comms: Comms, owner: RecordedThreadIncarnation) -> bool: return self.dependency.allows(wait, comms, self.origins, owner) diff --git a/tests/native_context_reader_journey.py b/tests/native_context_reader_journey.py index 4876b17996..1597c9941e 100644 --- a/tests/native_context_reader_journey.py +++ b/tests/native_context_reader_journey.py @@ -8,7 +8,7 @@ import time from pathlib import Path -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.comms import Comms from agent_comms.child_process import ProcessIdentity from agent_comms.field_codec import FieldCodec @@ -62,7 +62,7 @@ async def read_original_context( # An in-process fixture registration has the same private participant # relation as the original owner launcher; a registry row alone does not. with Coordination(str(owner._comms.root / 'coordination.sqlite3')) as store: - store.participants.register(ThreadIncarnation("fixture", thread.created_at).lookup, + store.participants.register(RecordedThreadIncarnation("fixture", thread.created_at).lookup, thread.name, thread.name, committed=True) await owner.load_session(str(fixture.project), thread.name) connection = RuntimeConnection(owner._comms, thread.name, diff --git a/tests/native_evidence_reader_installed_journey.py b/tests/native_evidence_reader_installed_journey.py index a35ac9f38c..95686f302c 100644 --- a/tests/native_evidence_reader_installed_journey.py +++ b/tests/native_evidence_reader_installed_journey.py @@ -8,7 +8,7 @@ REPO = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO/'tests')) from compaction_loopback import LoopbackProvider -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.comms import Comms from agent_comms.threads import Thread from agent_comms.child_process import ProcessIdentity @@ -141,7 +141,7 @@ async def main(): for audience in initial.audience.recipients: store.participants.register(audience.recipient_lookup,audience.canonical_thread,audience.canonical_thread,committed=True) accept_delivery_cohort(comms.bus,rid,message.seq,store) - owner=comms.registry.require('beta');directory=root/'native-sessions'/ThreadIncarnation("fixture", owner.created_at).lookup + owner=comms.registry.require('beta');directory=root/'native-sessions'/RecordedThreadIncarnation("fixture", owner.created_at).lookup directory.mkdir(parents=True,mode=0o700);saved=directory/SOURCE.name;shutil.copyfile(SOURCE,saved);saved.chmod(0o600) with sqlite3.connect(Path(str(SOURCE)+'.input-proof').as_uri()+'?mode=ro',uri=True) as original: with sqlite3.connect(str(saved)+'.input-proof') as target:original.backup(target) diff --git a/tests/reply_relevance_installed_journey.py b/tests/reply_relevance_installed_journey.py index 668382b576..6b8f8af2d1 100644 --- a/tests/reply_relevance_installed_journey.py +++ b/tests/reply_relevance_installed_journey.py @@ -8,7 +8,7 @@ import time import acp -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.comms import Comms, wire from agent_comms.coordinator import Coordination from agent_comms.coordination_tables.assignments import WakeAssignment @@ -143,7 +143,7 @@ def persist(state): actual = service.bus.log.message_by_id(reply.message_id) assert actual.sender == child.name and actual.body.strip() history = read_historical_native_inputs(store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", service.registry.require(child.name).created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", service.registry.require(child.name).created_at).lookup, source_seq=original.seq) assert len(history) == 2 and all(row.expected_prompt_equality_established for row in history) handling = MessageNotification.window(service.root, service.registry, diff --git a/tests/shared_bus_restart_native.py b/tests/shared_bus_restart_native.py index a0a96c8577..8ebb22c380 100644 --- a/tests/shared_bus_restart_native.py +++ b/tests/shared_bus_restart_native.py @@ -18,7 +18,7 @@ import threading from compaction_loopback import LoopbackProvider -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.comms import Comms from agent_comms.acp import CommsClient from agent_comms.acp_extension import decode_updates @@ -541,7 +541,7 @@ async def session_update(self, **kwargs): parameters=(late_assignment.lifecycle.execution_id,)) assert len(late_receipts) == 1 and late_receipts[0].exact_target == 'human' assert '23' in service.bus.log.message_by_id(late_receipts[0].message_id).body - lookup = ThreadIncarnation("fixture", service.registry.require(name).created_at).lookup + lookup = RecordedThreadIncarnation("fixture", service.registry.require(name).created_at).lookup for original in (*originals,late): history = read_historical_native_inputs(store,wire_root_id=root_id, recipient_lookup=lookup,source_seq=original.seq) @@ -833,7 +833,7 @@ async def serve(reader, writer): if arguments.saved_source: for name in names: owner = service.registry.require(name) - directory = service.root/'native-sessions'/ThreadIncarnation("fixture", owner.created_at).lookup + directory = service.root/'native-sessions'/RecordedThreadIncarnation("fixture", owner.created_at).lookup directory.mkdir(mode=0o700, parents=True) saved = directory/'retained.jsonl' shutil.copyfile(arguments.saved_source, saved); saved.chmod(0o600) @@ -1107,7 +1107,7 @@ def hold_cursor_publication(): f'10+{index}={10+index}' for index in range(arguments.wave_size)) + '.' assert replies[1].body == 'Separate late-wave answer: 20+3=23.' assert provider.posts == 4 - lookup = ThreadIncarnation("fixture", service.registry.require(names[0]).created_at).lookup + lookup = RecordedThreadIncarnation("fixture", service.registry.require(names[0]).created_at).lookup with Coordination(str(service.root/'coordination.sqlite3')) as store: inputs = NativeRuntimeInput.select(store.session._connection) assert len(inputs) == 4 diff --git a/tests/test_acp_channel_disposition.py b/tests/test_acp_channel_disposition.py index f6b13b083f..2b1c4ec9fd 100644 --- a/tests/test_acp_channel_disposition.py +++ b/tests/test_acp_channel_disposition.py @@ -10,7 +10,7 @@ import pytest from agent_comms import cohort_foreground -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.activity import NativeInputDrainDiagnostic, UnavailableDrainDiagnostic from agent_comms.native_input_record import FullNativeExecution, TriageNativeExecution from agent_comms.selected_triage import FullSelectedTriage, IgnoreSelectedTriage @@ -66,8 +66,8 @@ def source_sequences(row): ) ) - alpha_lookup = ThreadIncarnation("fixture", comms.registry.require("alpha").created_at).lookup - beta_lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup + alpha_lookup = RecordedThreadIncarnation("fixture", comms.registry.require("alpha").created_at).lookup + beta_lookup = RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup by_source = {} for row in rows: by_source.setdefault((row.owner_lookup, source_sequences(row)), []).append(row) @@ -133,7 +133,7 @@ async def test_uncertain_channel_input_keeps_notification_and_never_replays(tmp_ read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", comms.registry.require("alpha").created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", comms.registry.require("alpha").created_at).lookup, source_seq=message.seq, ) == () diff --git a/tests/test_acp_extension.py b/tests/test_acp_extension.py index 8b53c56d9c..302b4aec89 100644 --- a/tests/test_acp_extension.py +++ b/tests/test_acp_extension.py @@ -35,7 +35,8 @@ from agent_comms.acp_failure import BackendDeliveryFailure from agent_comms.agent_events import CompactionStart from agent_comms.pi_payloads import McpLiveReceipt -from agent_comms.thread_identity import AdmissionIdentity, ThreadIncarnation +from agent_comms.thread_identity import AdmissionIdentity +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.transcripts import TranscriptCursor, TranscriptPage from agent_comms.turn_lease import ActiveTurn, TurnState from agent_comms.turn_context import ContextManifest, NextContextTurn @@ -47,7 +48,7 @@ def test_declared_family_roundtrip_and_strict_boundary(tmp_path): comms = wire(tmp_path) comms.registry.declare(Thread("pilot", frozenset(), str(tmp_path))) read = comms.transcripts.capture_page_read("pilot") - owner = AdmissionIdentity(ThreadIncarnation("pilot", 1.0), 1) + owner = AdmissionIdentity(RecordedThreadIncarnation("pilot", 1.0), 1) queue_scope = QueueScope("pilot", owner, 123) samples = ( ContextAnnotatedUpdate( diff --git a/tests/test_acp_input_disposition.py b/tests/test_acp_input_disposition.py index 78134f2ebe..1c635ab1bd 100644 --- a/tests/test_acp_input_disposition.py +++ b/tests/test_acp_input_disposition.py @@ -15,7 +15,7 @@ from agent_comms import backend from agent_comms import pi_commands as commands from agent_comms import pi_events as pi -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.acp_failure import RequestFailure from agent_comms.acp_extension import InputDeliveryChangedUpdate, QueuePromptRequest, SteerPromptRequest, decode_updates, encode_request from agent_comms.child_process import BoundedRun, ExitedOutcome @@ -227,7 +227,7 @@ async def test_bus_message_arriving_mid_turn_is_taken_by_the_running_turn( comms = owner._comms root_id = owner._private_nk_wire_root_id receiver = await Coordination.run_worker(partial(comms.registry.require, session)) - lookup = ThreadIncarnation("fixture", receiver.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", receiver.created_at).lookup def prepare(): comms.registry.register( @@ -326,7 +326,7 @@ async def delayed_started(following, input_id): ) as (owner, session): comms = owner._comms receiver = await Coordination.run_worker(partial(comms.registry.require, session)) - lookup = ThreadIncarnation("fixture", receiver.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", receiver.created_at).lookup def prepare(): comms.registry.register( @@ -409,7 +409,7 @@ async def test_waiting_messages_follow_the_original_on_the_changed_model( async with native.open_owner(runtime_enabled=True, auto_wake=True) as (owner, session): comms = owner._comms receiver = await Coordination.run_worker(partial(comms.registry.require, session)) - lookup = ThreadIncarnation("fixture", receiver.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", receiver.created_at).lookup def prepare(): comms.registry.register( @@ -545,7 +545,7 @@ async def test_bus_message_arriving_during_a_reply_turn_is_taken_by_that_turn(na async with native.open_owner(runtime_enabled=True, auto_wake=True) as (owner, session): comms = owner._comms receiver = await Coordination.run_worker(partial(comms.registry.require, session)) - lookup = ThreadIncarnation("fixture", receiver.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", receiver.created_at).lookup comms.threads.claim_thread("sender", tags=frozenset(), worktree=str(native.project)) try: first = comms.messaging.send_message("sender", session, "which parser is slow?") diff --git a/tests/test_acp_private_nk_delivery.py b/tests/test_acp_private_nk_delivery.py index 6c881c48de..5c65f9b72d 100644 --- a/tests/test_acp_private_nk_delivery.py +++ b/tests/test_acp_private_nk_delivery.py @@ -13,7 +13,7 @@ import pytest from agent_comms import cohort_foreground, coordinated_runtime, native_pi -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.acp import CommsAgent from agent_comms.acp_extension import ( CursorAdvancedUpdate, @@ -68,7 +68,7 @@ def _session(tmp_path, *, package=True): root_id = comms.messaging.initialize_private_initial_protocol() with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - ThreadIncarnation("fixture", owner.created_at).lookup, "beta", "beta", committed=True + RecordedThreadIncarnation("fixture", owner.created_at).lookup, "beta", "beta", committed=True ) agent = CommsAgent( comms, @@ -132,7 +132,7 @@ async def no_options(*args, **kwargs): session = await agent.new_session(cwd=str(project), mcp_servers=[]) owner = comms.registry.require(session.session_id) with Coordination(str(root / "coordination.sqlite3")) as store: - participant = store.participants.get(ThreadIncarnation("fixture", owner.created_at).lookup) + participant = store.participants.get(RecordedThreadIncarnation("fixture", owner.created_at).lookup) assert participant.committed and participant.owner_thread == owner.name invoke_tool( comms, @@ -149,7 +149,7 @@ async def test_private_owner_rename_migrates_generation_before_canonical_selecte tmp_path, monkeypatch, managed ): comms, agent, _ = _session(tmp_path) - lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup with Coordination(str(comms.root / "coordination.sqlite3")) as store: before = store.participants.get(lookup) assert before.owner_thread == "beta" and before.participant_generation == 1 @@ -182,7 +182,7 @@ async def test_private_rename_does_not_replay_unserved_old_name_selected_source( tmp_path, monkeypatch, seal_old ): comms, agent, root_id = _session(tmp_path) - lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup invoke_tool(comms, "comms_send", {"from": "sender", "to": "beta", "body": "old-before-rename"}) if seal_old: bus = MessageBus(comms.root / "bus.jsonl", comms.registry, private_response_writes=True) @@ -222,7 +222,7 @@ async def test_private_rename_old_name_backlog_does_not_exhaust_new_recipient_sc def test_private_rename_refuses_mismatched_sql_owner_before_registry_mutation(tmp_path): comms, _, _ = _session(tmp_path) - lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup with Coordination(str(comms.root / "coordination.sqlite3")) as store: store.participants.advance_generation(lookup, "unexpected", expected_generation=1) with pytest.raises(RelationViolationError, match="Private coordinator owner differs"): @@ -265,7 +265,7 @@ async def test_private_rename_compensates_registry_failure_with_new_old_owner_ge tmp_path, monkeypatch ): comms, _, root_id = _session(tmp_path) - lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup original = comms.registry.rename def fail_before_registry_write(name, new_name): @@ -695,7 +695,7 @@ async def test_acp_private_no_wake_has_delivery_receipt_but_no_model(tmp_path, m comms.registry.declare(alpha) with Coordination(str(comms.root / "coordination.sqlite3")) as store: store.participants.register( - ThreadIncarnation("fixture", alpha.created_at).lookup, "alpha", "alpha", committed=True + RecordedThreadIncarnation("fixture", alpha.created_at).lookup, "alpha", "alpha", committed=True ) monkeypatch.setattr(cohort_foreground, "_trusted_package", lambda _: None) monkeypatch.setattr(native_pi, "_trusted_package", lambda _: None) @@ -724,7 +724,7 @@ async def test_acp_private_no_wake_has_delivery_receipt_but_no_model(tmp_path, m ( root_id, original.seq, - ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, + RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, ), ).fetchone() assert tuple(receipt) == ("unmentioned_observer", None) diff --git a/tests/test_acp_queue_contract.py b/tests/test_acp_queue_contract.py index 765035c666..90c491c5e7 100644 --- a/tests/test_acp_queue_contract.py +++ b/tests/test_acp_queue_contract.py @@ -29,7 +29,8 @@ from agent_comms.comms import Comms from agent_comms.queued_input import QueuedInput, QueuedInputContext from agent_comms.runtime import present_session -from agent_comms.thread_identity import AdmissionIdentity, ThreadIncarnation +from agent_comms.thread_identity import AdmissionIdentity +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.threads import Thread from stored_inputs import stored_inputs @@ -53,7 +54,7 @@ async def test_queue_exact_ids_restore_snapshot_and_admission_change(tmp_path, m key: QueuedInput( "same text", True, - QueuedInputContext(AdmissionIdentity(ThreadIncarnation("beta", created), generation)), + QueuedInputContext(AdmissionIdentity(RecordedThreadIncarnation("beta", created), generation)), key, "same text", ) @@ -190,7 +191,7 @@ async def test_unavailable_projection_never_drops_or_replays_owned_rows(tmp_path key: QueuedInput( text, True, - QueuedInputContext(AdmissionIdentity(ThreadIncarnation("beta", created), generation)), + QueuedInputContext(AdmissionIdentity(RecordedThreadIncarnation("beta", created), generation)), key, text, ) @@ -214,7 +215,7 @@ async def test_alias_maps_only_attachment_session_id(tmp_path): def test_attachment_routing_rename_preserves_exact_executor_fences(): from dataclasses import replace - original = AdmissionIdentity(ThreadIncarnation("old-route", 1000.0), 7) + original = AdmissionIdentity(RecordedThreadIncarnation("old-route", 1000.0), 7) renamed = replace(original, incarnation=replace(original.incarnation, name="new-route")) scopes = (QueueScope("official-acp-id", original, 123), CursorScope("official-acp-id", "a" * 32, original, 123)) diff --git a/tests/test_acp_selected_write_plan.py b/tests/test_acp_selected_write_plan.py index 822e10c8fa..71129fe12a 100644 --- a/tests/test_acp_selected_write_plan.py +++ b/tests/test_acp_selected_write_plan.py @@ -16,7 +16,7 @@ import pytest from agent_comms import cohort_foreground, coordinated_runtime -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.activity import NativeInputDrainDiagnostic, UnavailableDrainDiagnostic from agent_comms.acp import CommsAgent from agent_comms.acp_extension import ( @@ -96,7 +96,7 @@ async def test_public_acp_preplan_one_selected_write_after_verified_fake_native( install_prompt_binding_schema(store) for name in ("alpha", "beta"): store.participants.register( - ThreadIncarnation("fixture", comms.registry.require(name).created_at).lookup, + RecordedThreadIncarnation("fixture", comms.registry.require(name).created_at).lookup, name, name, committed=True, @@ -129,12 +129,12 @@ async def session_update(self, **_kwargs): if prior_seq: with Coordination(str(root / "coordination.sqlite3")) as store: await _accept_visible_deliveries( - bus, root_id, store.session.path, ThreadIncarnation("fixture", 51003.0).lookup, 0, owner_name="beta" + bus, root_id, store.session.path, RecordedThreadIncarnation("fixture", 51003.0).lookup, 0, owner_name="beta" ) message = comms.messaging.send_message("sender", "#team", "@beta inspect module.py") with Coordination(str(root / "coordination.sqlite3")) as store: await _accept_visible_deliveries( - bus, root_id, store.session.path, ThreadIncarnation("fixture", 51003.0).lookup, prior_seq, owner_name="beta" + bus, root_id, store.session.path, RecordedThreadIncarnation("fixture", 51003.0).lookup, prior_seq, owner_name="beta" ) request = SelectedWriteRequest( message.seq, message.message_id, str(resource), "after selected\n" @@ -289,7 +289,7 @@ async def test_second_pid_public_acp_owner_ipc_preplan(monkeypatch): install_native_runtime_schema(store) install_prompt_binding_schema(store) store.participants.register( - ThreadIncarnation("fixture", 61002.0).lookup, "alpha", "alpha", committed=True + RecordedThreadIncarnation("fixture", 61002.0).lookup, "alpha", "alpha", committed=True ) env = dict(os.environ) source_root = Path(__file__).resolve().parents[1] @@ -327,7 +327,7 @@ async def test_second_pid_public_acp_owner_ipc_preplan(monkeypatch): ) with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - ThreadIncarnation("fixture", 61003.0).lookup, "beta", "beta", committed=True + RecordedThreadIncarnation("fixture", 61003.0).lookup, "beta", "beta", committed=True ) (base / "registered").touch() @@ -362,7 +362,7 @@ def event(expected: str) -> dict: bus = MessageBus(root / "bus.jsonl", comms.registry, private_response_writes=True) with Coordination(str(root / "coordination.sqlite3")) as store: await _accept_visible_deliveries( - bus, root_id, store.session.path, ThreadIncarnation("fixture", 61003.0).lookup, 0, owner_name="beta" + bus, root_id, store.session.path, RecordedThreadIncarnation("fixture", 61003.0).lookup, 0, owner_name="beta" ) options = encode_request( SelectedWriteRequest( @@ -390,7 +390,7 @@ def event(expected: str) -> dict: ).fetchone()[0] == 1 ) - assert sealed_cohort_assignments(store, ThreadIncarnation("fixture", 61002.0).lookup) == () + assert sealed_cohort_assignments(store, RecordedThreadIncarnation("fixture", 61002.0).lookup) == () finally: if attached is not None: await attached.shutdown() diff --git a/tests/test_busy_history_loading.py b/tests/test_busy_history_loading.py index 3264deba94..7d5d8b6c2b 100644 --- a/tests/test_busy_history_loading.py +++ b/tests/test_busy_history_loading.py @@ -16,6 +16,7 @@ from agent_comms.ui_model.observation import ( ObservationProcess, ReadBusy, ReadNotifications, ReadTranscript, ) +from agent_comms.thread_identity import UnrecordedThreadIncarnation def saved_history(tmp_path): @@ -268,14 +269,13 @@ async def test_peer_write_failure_retires_once_and_cancels_owner_reads(comms): async def test_owner_reply_uses_the_same_peer_retirement(comms): import multiprocessing - from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.observation import ObservationService, ReadOwnerGoal connection, viewer = multiprocessing.get_context("spawn").Pipe() with connection, viewer: service = ObservationService(comms, connection) # A real missing-owner refusal still has to be sent through the shared endpoint. - request = ReadOwnerGoal(str(comms.root), ThreadIncarnation("missing", 0), request_id=7) + request = ReadOwnerGoal(str(comms.root), UnrecordedThreadIncarnation("missing"), request_id=7) request.receive(service) asking = service.owner_reads[7] viewer.close() diff --git a/tests/test_candidate_maintenance.py b/tests/test_candidate_maintenance.py index f7edcaa9de..0688f103fa 100644 --- a/tests/test_candidate_maintenance.py +++ b/tests/test_candidate_maintenance.py @@ -10,7 +10,7 @@ import pytest from agent_comms import candidate_maintenance as maintenance -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.store_files import _store_lock @@ -69,7 +69,7 @@ def observe(root): assert indexed.wait(timeout=5), "deferred checkpoint did not catch up" page = WakeCandidateIndex(comms.bus.log).page( root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, after_seq=0, required_through_seq=message.seq, ) @@ -161,7 +161,7 @@ def test_one_background_drain_catches_up_a_backlog_of_oversized_records( assert index.catch_up_committed_append(hint).caught_up page = index.page( root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, after_seq=0, required_through_seq=comms.bus.log.full_history()[-1].seq, limit=100, @@ -200,7 +200,7 @@ def retired(): index = WakeCandidateIndex(comms.bus.log) assert index._verified_checkpoint(root_id).last_seq == messages[-1].seq page = index.page(root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, after_seq=0, required_through_seq=messages[-1].seq, limit=32) assert len(page.entries) == 32 and page.has_more assert comms.bus.pending_counts("beta") == {"sender": len(messages)} diff --git a/tests/test_channel_participant_registration.py b/tests/test_channel_participant_registration.py index 2f85fbe879..d695db216c 100644 --- a/tests/test_channel_participant_registration.py +++ b/tests/test_channel_participant_registration.py @@ -2,7 +2,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.cohort_foreground import _accept_visible_deliveries from agent_comms.comms import Comms from agent_comms.coordination_cohort import sealed_cohort_assignments @@ -21,7 +21,7 @@ async def test_channel_with_unstarted_and_stopped_subscribers_delivers_whole_coh stopped = comms.registry.require("stopped-reviewer") comms.registry.register(stopped, StoppedThreadStatus()) receiver = comms.registry.require("receiver") - lookup = ThreadIncarnation("fixture", receiver.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", receiver.created_at).lookup with Coordination(str(comms.root / "coordination.sqlite3")) as store: with store.session.read(): assert Participants.select(store.session._connection) == [] diff --git a/tests/test_checkpoint_seals.py b/tests/test_checkpoint_seals.py index 6ae0e18084..32a490c65c 100644 --- a/tests/test_checkpoint_seals.py +++ b/tests/test_checkpoint_seals.py @@ -5,7 +5,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.checkpoint_seals import FinalSeal, PendingSeal from agent_comms.errors import RelationViolationError from agent_comms.wire_log import WireLog @@ -117,13 +117,13 @@ def write(self, marker): with pytest.raises(RelationViolationError, match="UNKNOWN"): comms.messaging.send_initial_cohort("sender", "Alice", "durable bus, uncertain seal") bus = comms.bus.log.path.read_bytes() - witness, rows, more = _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) + witness, rows, more = _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup) assert [row.message.seq for row in rows] == [1, 2] and not more assert witness.through_seq == 2 assert comms.bus.log.path.read_bytes() == bus assert isinstance(comms.bus.log._private_marker_unlocked().seal, FinalSeal) # Reopened canonical readers agree; no replay or new publication. - assert [row.message.seq for row in _page(comms, ThreadIncarnation("fixture", 17002.0).lookup)[1]] == [1, 2] + assert [row.message.seq for row in _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup)[1]] == [1, 2] def test_absent_public_marker_never_substitutes_for_required_or_redirected_marker(tmp_path): diff --git a/tests/test_claim_admission_baseline.py b/tests/test_claim_admission_baseline.py index 0f261499f8..dcd46f93a4 100644 --- a/tests/test_claim_admission_baseline.py +++ b/tests/test_claim_admission_baseline.py @@ -14,7 +14,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms from agent_comms.coordination_cohort import accept_delivery_cohort, sealed_cohort_assignments @@ -52,7 +52,7 @@ def test_selected_wake_and_file_claims_have_no_common_admission_receipt() -> Non install_private_cohort_schema(coordinator) for name in ("Alice", "Bob"): coordinator.participants.register( - ThreadIncarnation("fixture", people[name]).lookup, name, name, committed=True + RecordedThreadIncarnation("fixture", people[name]).lookup, name, name, committed=True ) message = comms.messaging.send_initial_cohort( "sender", "#team", "Please investigate @Alice" @@ -60,8 +60,8 @@ def test_selected_wake_and_file_claims_have_no_common_admission_receipt() -> Non receipt = accept_delivery_cohort(comms.bus, root_id, message.seq, coordinator).value assert receipt.member_count == 2 assert receipt.assignment_count == 1 - assert sealed_cohort_assignments(coordinator, ThreadIncarnation("fixture", people["Alice"]).lookup) - assert sealed_cohort_assignments(coordinator, ThreadIncarnation("fixture", people["Bob"]).lookup) == () + assert sealed_cohort_assignments(coordinator, RecordedThreadIncarnation("fixture", people["Alice"]).lookup) + assert sealed_cohort_assignments(coordinator, RecordedThreadIncarnation("fixture", people["Bob"]).lookup) == () # Existing explicit resource claims are valid independent operations. # They neither consult nor reference the sealed N/K receipt. A diff --git a/tests/test_claim_admission_verifier.py b/tests/test_claim_admission_verifier.py index d31722f082..5e5aa8ca36 100644 --- a/tests/test_claim_admission_verifier.py +++ b/tests/test_claim_admission_verifier.py @@ -11,7 +11,7 @@ import pytest from agent_comms import claim_admission -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.assignment_states import FullPendingAssignment from agent_comms.attempt_start import AttemptStart from agent_comms.attempt_states import ModelRunningAttempt, PromptAcceptedAttempt, SettlingAttempt @@ -75,8 +75,8 @@ def test_selected_wake_verifier_refuses_no_wake_and_stale_authority( committed=True, ) accept_delivery_cohort(comms.bus, root_id, message.seq, store) - alice_lookup = ThreadIncarnation("fixture", comms.registry.require("Alice").created_at).lookup - bob_lookup = ThreadIncarnation("fixture", comms.registry.require("Bob").created_at).lookup + alice_lookup = RecordedThreadIncarnation("fixture", comms.registry.require("Alice").created_at).lookup + bob_lookup = RecordedThreadIncarnation("fixture", comms.registry.require("Bob").created_at).lookup assert sealed_cohort_assignments(store, bob_lookup) == () assignment = sealed_cohort_assignments(store, alice_lookup)[0] assert type(assignment.lifecycle) is FullPendingAssignment diff --git a/tests/test_claim_bus_read.py b/tests/test_claim_bus_read.py index 4074223c5e..d68e4958fc 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -18,7 +18,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.attempt_start import AttemptStart from agent_comms.child_process import ProcessIdentity from agent_comms.claim_admission import ( @@ -97,7 +97,7 @@ def selected_turn(): recipient.canonical_thread, committed=True, ) accept_delivery_cohort(comms.bus, root_id, message.seq, store) - lookup = ThreadIncarnation("fixture", comms.registry.require("Alice").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("Alice").created_at).lookup assignment = sealed_cohort_assignments(store, lookup)[0] owner, generation = comms.registry.live_owner_with_admission("Alice") owner, generation = comms.registry.lease_live_turn_with_admission( diff --git a/tests/test_cohort_foreground.py b/tests/test_cohort_foreground.py index 4bb80787f9..000724ac24 100644 --- a/tests/test_cohort_foreground.py +++ b/tests/test_cohort_foreground.py @@ -19,7 +19,7 @@ from agent_comms import cohort_foreground as foreground from agent_comms import cohort_send from agent_comms import coordinated_runtime as runtime -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -420,7 +420,7 @@ async def test_foreground_explicit_selected_write_never_mutates_no_wake( def ready(thread: Thread) -> None: with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, + RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, "beta", "beta", committed=True, @@ -526,7 +526,7 @@ async def test_foreground_refuses_takeover_and_cosmetic_subprocess_pid( install_private_response_schema(store) install_native_runtime_schema(store) store.participants.register( - ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, + RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, "beta", "beta", committed=True, diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index 50b6130f91..3b7ef0e7f6 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -12,7 +12,7 @@ import pytest from agent_comms import store_files -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.activity import ActivityState from agent_comms.agent_events import AgentInfo from agent_comms.comms import wire @@ -94,7 +94,7 @@ def test_default_same_tick_owners_get_distinct_durable_claim_identities( second_time = stored.require("second").created_at assert first_time == first.created_at assert first_time != second_time - assert ThreadIncarnation("fixture", first_time).lookup != ThreadIncarnation("fixture", second_time).lookup + assert RecordedThreadIncarnation("fixture", first_time).lookup != RecordedThreadIncarnation("fixture", second_time).lookup assert "_generated_created_at" not in stored.require("second").to_wire() with pytest.raises(RelationViolationError, match="creation identities collide"): wire(root).registry.declare( @@ -117,7 +117,7 @@ def test_six_spawned_same_tick_owners_register_distinct_incarnations( stored = wire(root).registry times = [stored.require(name).created_at for name in names] assert len(set(times)) == len(names) - assert len({ThreadIncarnation("fixture", created_at).lookup for created_at in times}) == len(names) + assert len({RecordedThreadIncarnation("fixture", created_at).lookup for created_at in times}) == len(names) def test_concurrent_senders_preserve_every_message_and_sequence(self, tmp_path: Path) -> None: root = tmp_path / "wire" diff --git a/tests/test_context_source_inspection.py b/tests/test_context_source_inspection.py index 68c266c05b..08fbf31b78 100644 --- a/tests/test_context_source_inspection.py +++ b/tests/test_context_source_inspection.py @@ -11,7 +11,8 @@ from agent_comms.native_turn_context import NativeContextData, NativeContextManifestData from agent_comms.runtime_requests import (RuntimeRequest, ContextRecordedSegmentRuntimeRequest, ContextCoreRuntimeRequest, ContextCoreSourceRuntimeRequest) -from agent_comms.thread_identity import ThreadIncarnation, TurnId, TurnIdentity +from agent_comms.thread_identity import TurnId, TurnIdentity +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.turn_context import ( ContextManifest, ContextSourceText, FileProvenance, JournalProvenance, NativeProvenance, PreviewProvenance, RecordedContextTurn, SegmentManifest, @@ -31,7 +32,7 @@ def identity(tmp_path): def recorded(segments): - owner = ThreadIncarnation("original-owner", 17002.0) + owner = RecordedThreadIncarnation("original-owner", 17002.0) turn = RecordedContextTurn(TurnId("original-turn"), TurnIdentity(owner, 2)) return ContextManifest(owner, turn, tuple(segments), "pi.estimateTokens", "request-one") @@ -294,7 +295,7 @@ def test_cold_client_acquires_current_and_recorded_contributor_declarations(): contributor = UnavailableAwarenessSegment( provenance=(source,), instruction=InstructionFile(original, source)) from agent_comms.turn_context import NextContextTurn - payload = FieldCodec.encode(TurnContext(ThreadIncarnation("original-owner", 17002.0), + payload = FieldCodec.encode(TurnContext(RecordedThreadIncarnation("original-owner", 17002.0), NextContextTurn(), (contributor,))) result = subprocess.run([sys.executable, "-c", ''' import hashlib, json, sys diff --git a/tests/test_coordinated_runtime.py b/tests/test_coordinated_runtime.py index bf0b6ddaf8..8b9df753a0 100644 --- a/tests/test_coordinated_runtime.py +++ b/tests/test_coordinated_runtime.py @@ -19,7 +19,7 @@ from agent_comms import coordinated_runtime as runtime from agent_comms import coordination_response -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.historical_native_inputs import FullHistoricalNativeInput, TriageHistoricalNativeInput from agent_comms.selected_triage import IgnoreSelectedTriage, FullSelectedTriage from agent_comms.native_input_record import TriageNativeExecution, FullNativeExecution @@ -339,7 +339,7 @@ async def test_unmentioned_agent_channel_real_sqlite_two_distinct_mocked_decisio ignored = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert len(ignored) == 1 and isinstance(ignored[0], TriageHistoricalNativeInput) @@ -371,7 +371,7 @@ async def test_unmentioned_agent_channel_real_sqlite_two_distinct_mocked_decisio is None ) with Coordination(str(root / "coordination.sqlite3")) as store: - lookups = {person.name: ThreadIncarnation("fixture", person.created_at).lookup for person in people} + lookups = {person.name: RecordedThreadIncarnation("fixture", person.created_at).lookup for person in people} assert store.participants.get(lookups["alpha"]).pointer.execution_id is None assert store.participants.get(lookups["beta"]).pointer.execution_id is None assert ( @@ -424,8 +424,8 @@ async def test_initial_no_wake_observer_never_enters_model_or_claim_page( def test_wake_frame_rejects_no_wake_forgery_and_unengaged_full(tmp_path: Path) -> None: root, _root_id, comms, initial, people = _root(tmp_path, mentioned=True) with Coordination(str(root / "coordination.sqlite3")) as store: - alpha_lookup = ThreadIncarnation("fixture", people[1].created_at).lookup - beta_lookup = ThreadIncarnation("fixture", people[2].created_at).lookup + alpha_lookup = RecordedThreadIncarnation("fixture", people[1].created_at).lookup + beta_lookup = RecordedThreadIncarnation("fixture", people[2].created_at).lookup assert sealed_cohort_assignments(store, alpha_lookup) == () selected = sealed_cohort_assignments(store, beta_lookup)[0] # A pending FULL claim has no response obligation, and no frame may grant one. @@ -440,7 +440,7 @@ def test_wake_frame_rejects_no_wake_forgery_and_unengaged_full(tmp_path: Path) - def test_triage_frame_is_read_only_and_does_not_promote_message_body(tmp_path: Path) -> None: root, _root_id, comms, initial, people = _root(tmp_path) with Coordination(str(root / "coordination.sqlite3")) as store: - lookup = ThreadIncarnation("fixture", people[1].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[1].created_at).lookup assignment = sealed_cohort_assignments(store, lookup)[0] frame = TurnContext.render_segments((SelectedWakeSegment.capture(((initial, assignment),), people[1]),)).text assert f'"source_seq":{initial.message.seq}' in frame @@ -455,7 +455,7 @@ def test_wake_frame_includes_bounded_changed_sent_outcomes(tmp_path: Path) -> No root, _root_id, _comms, initial, people = _root(tmp_path) with Coordination(str(root / "coordination.sqlite3")) as store: - lookup = ThreadIncarnation("fixture", people[1].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[1].created_at).lookup assignment = sealed_cohort_assignments(store, lookup)[0] notification = MessageNotification( initial.audience.recipients[0], "Read", "", @@ -516,7 +516,7 @@ async def test_direct_selected_reply_goes_to_original_sender(tmp_path: Path, mon one = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) assert len(one) == 1 and isinstance(one[0].execution, FullNativeExecution) @@ -543,7 +543,7 @@ async def test_explicit_fresh_enrollment_precedes_fake_private_raw_send( assert result is not None and result.disposition is CompletedAssignment fresh = result.fresh_session assert fresh is not None and len(calls) == 1 - assert fresh.path.parent == root / "native-sessions" / ThreadIncarnation("fixture", people[2].created_at).lookup + assert fresh.path.parent == root / "native-sessions" / RecordedThreadIncarnation("fixture", people[2].created_at).lookup fresh.verify_saved_identity() assert len(fresh.path.read_text().splitlines()) == 6 @@ -638,7 +638,7 @@ async def test_fresh_creation_fsync_unknown_never_enters_fake_model( monkeypatch.setattr("agent_comms.native_pi._trusted_package", lambda _: None) runner, calls = _fake_model() monkeypatch.setattr(TrackedTurnSession, "execute", runner) - expected_dir = root / "native-sessions" / ThreadIncarnation("fixture", people[2].created_at).lookup + expected_dir = root / "native-sessions" / RecordedThreadIncarnation("fixture", people[2].created_at).lookup real_fsync = fresh_module._fsync_directory def unknown_parent(path: Path) -> None: @@ -743,7 +743,7 @@ async def test_repeated_awareness_timeouts_cannot_starve_unrelated_original( first_base.mkdir() first_root, _first_id, first_comms, first_initial, people = _root(first_base, direct=True) with Coordination(str(first_root / "coordination.sqlite3")) as store: - assignment = sealed_cohort_assignments(store, ThreadIncarnation("fixture", people[2].created_at).lookup)[0] + assignment = sealed_cohort_assignments(store, RecordedThreadIncarnation("fixture", people[2].created_at).lookup)[0] owner = first_comms.registry.require("beta") monkeypatch.setattr(OptionalAwarenessProjection, "build_seconds", 0.02) entered, release = threading.Event(), threading.Event() @@ -871,7 +871,7 @@ async def test_historical_native_input_view_keeps_exact_triage_and_full_events( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) == () @@ -883,7 +883,7 @@ async def test_historical_native_input_view_keeps_exact_triage_and_full_events( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) result = await SelectedExecution( @@ -895,7 +895,7 @@ async def test_historical_native_input_view_keeps_exact_triage_and_full_events( rows = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) assert [type(row.execution) for row in rows] == [TriageNativeExecution, FullNativeExecution] @@ -913,7 +913,7 @@ async def test_historical_native_input_view_keeps_exact_triage_and_full_events( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) == () @@ -926,7 +926,7 @@ async def test_historical_native_input_view_keeps_exact_triage_and_full_events( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) @@ -935,7 +935,7 @@ async def test_private_raw_send_rejects_renamed_saved_file_before_selected_bind( tmp_path: Path, monkeypatch ) -> None: root, root_id, comms, initial, people = _root(tmp_path, direct=True) - lookup = ThreadIncarnation("fixture", people[2].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[2].created_at).lookup session_dir = root / "native-sessions" / lookup session_dir.mkdir(parents=True, mode=0o700) saved = session_dir / "saved.jsonl" @@ -990,7 +990,7 @@ async def test_historical_native_input_view_omits_no_wake_and_reserved_unknown( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) == () @@ -1014,7 +1014,7 @@ async def test_historical_native_input_view_omits_no_wake_and_reserved_unknown( read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[2].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[2].created_at).lookup, source_seq=initial.message.seq, ) == () @@ -1164,7 +1164,7 @@ async def test_owner_generation_revoked_during_native_triage_fails_closed( root, root_id, comms, _initial, people = _root(tmp_path) monkeypatch.setattr("agent_comms.native_pi._trusted_package", lambda _: None) runner, _ = _fake_model(decision="IGNORE") - lookup = ThreadIncarnation("fixture", people[1].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[1].created_at).lookup async def revoke(*args, **kwargs): result = await runner(*args, **kwargs) @@ -1735,7 +1735,7 @@ def test_native_runtime_schema_explicit_install_and_drift_fail_closed(tmp_path: async def test_settled_page_does_not_hide_later_selected_claim(tmp_path: Path, monkeypatch) -> None: """Page saturation is not an empty inbox; scaffolding is not model authority.""" root, root_id, comms, _initial, people = _root(tmp_path) - lookup = ThreadIncarnation("fixture", people[1].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[1].created_at).lookup with Coordination(str(root / "coordination.sqlite3")) as store: for number in range(100): message = comms.messaging.send_initial_cohort( @@ -1821,7 +1821,7 @@ async def test_unconfigured_owner_does_not_reserve_or_launch(tmp_path, monkeypat assert calls == [] assert comms.registry.require("beta").active_turn is None with Coordination(str(root / "coordination.sqlite3")) as store: - pending = sealed_cohort_assignments(store, ThreadIncarnation("fixture", owner.created_at).lookup) + pending = sealed_cohort_assignments(store, RecordedThreadIncarnation("fixture", owner.created_at).lookup) assert type(pending[0].lifecycle) is TriagePendingAssignment @@ -1864,7 +1864,7 @@ async def failed(package, **kwargs): notice_initial = comms.bus.log.read_delivery_cohort(root_id, notice.seq) assert all(decision.wake_mode == PassiveWake() for decision in notice_initial.decisions) with Coordination(str(root / "coordination.sqlite3")) as store: - lookup = ThreadIncarnation("fixture", people[2].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[2].created_at).lookup assert store.participants.get(lookup).pointer.execution_id is None assignment = sealed_cohort_assignments(store, lookup)[0] assert type(assignment.lifecycle) is FailedAssignment @@ -1939,7 +1939,7 @@ async def transient(package, **kwargs): assert len(calls) == 3 + (not direct) assert len(full_attempts) == 3 and len(set(full_attempts)) == 3 with Coordination(str(root / "coordination.sqlite3")) as store: - lookup = ThreadIncarnation("fixture", people[2].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[2].created_at).lookup assignment = sealed_cohort_assignments(store, lookup)[0] assert assignment.source == initial.message.reference snapshot = store.snapshots.get(assignment.lifecycle.execution_id) diff --git a/tests/test_coordination_cohort.py b/tests/test_coordination_cohort.py index c333674a9a..46c7b395f1 100644 --- a/tests/test_coordination_cohort.py +++ b/tests/test_coordination_cohort.py @@ -13,7 +13,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.assignment_states import ( DeferredAssignment, DeliveredAssignment, @@ -67,7 +67,7 @@ def _root(tmp_path: Path) -> tuple[Comms, Coordination, str, dict[str, str]]: comms.registry.register( Thread(name=name, worktree=str(root), created_at=created_at, tags=frozenset(tags)) ) - lookups[name] = ThreadIncarnation("fixture", created_at).lookup + lookups[name] = RecordedThreadIncarnation("fixture", created_at).lookup coordinator = Coordination(str(root / "coordinator.sqlite"), clock_ms=lambda: 4723) install_private_cohort_schema(coordinator) for name in ("Alice", "Bob", "Charlie"): diff --git a/tests/test_cursor_load_recovery.py b/tests/test_cursor_load_recovery.py index 9ee6111c93..b143b673a4 100644 --- a/tests/test_cursor_load_recovery.py +++ b/tests/test_cursor_load_recovery.py @@ -6,7 +6,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.acp import CommsAgent from agent_comms.acp_extension import CursorAdvancedUpdate, decode_updates from agent_comms.child_process import ProcessIdentity @@ -50,7 +50,7 @@ async def test_trusted_load_recovers_after_real_flock_contention(tmp_path): root_id = comms.messaging.initialize_private_initial_protocol() with Coordination(str(comms.root / "coordination.sqlite3")) as store: store.participants.register( - ThreadIncarnation("fixture", owner.created_at).lookup, owner.name, owner.name, committed=True + RecordedThreadIncarnation("fixture", owner.created_at).lookup, owner.name, owner.name, committed=True ) agent = CommsAgent( comms, diff --git a/tests/test_goal_failure_observation.py b/tests/test_goal_failure_observation.py index 174d3df6e6..5363fdb24c 100644 --- a/tests/test_goal_failure_observation.py +++ b/tests/test_goal_failure_observation.py @@ -21,7 +21,8 @@ from agent_comms.goal_generation import BlockedGeneration, ReservedGeneration from agent_comms.goal_states import BlockedGoal, ModelPause, OwnerPause, PausedGoal from agent_comms.goals import Goal -from agent_comms.thread_identity import ThreadIncarnation, TurnIdentity +from agent_comms.thread_identity import TurnIdentity +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.thread_status import ( ArchivedThreadStatus, DeletingThreadStatus, @@ -108,12 +109,12 @@ def test_duplicate_callback_and_restart_preserve_failure_bytes(bound): @pytest.mark.parametrize( "mutation", [ - {"identity": TurnIdentity(ThreadIncarnation("other", 10.0), 7)}, - {"identity": TurnIdentity(ThreadIncarnation("owner", 11.0), 7)}, + {"identity": TurnIdentity(RecordedThreadIncarnation("other", 10.0), 7)}, + {"identity": TurnIdentity(RecordedThreadIncarnation("owner", 11.0), 7)}, {"turn_id": "b" * 32}, {"admission_generation": 4}, - {"identity": TurnIdentity(ThreadIncarnation("owner", 10.0), 0)}, - {"identity": TurnIdentity(ThreadIncarnation("owner", 10.0), 8)}, + {"identity": TurnIdentity(RecordedThreadIncarnation("owner", 10.0), 0)}, + {"identity": TurnIdentity(RecordedThreadIncarnation("owner", 10.0), 8)}, ], ) def test_mismatched_turn_lease_is_not_bound(bound, mutation): diff --git a/tests/test_goal_selection_owner.py b/tests/test_goal_selection_owner.py index b47b160b70..159ecbb4ac 100644 --- a/tests/test_goal_selection_owner.py +++ b/tests/test_goal_selection_owner.py @@ -22,6 +22,7 @@ from agent_comms.threads import Thread from agent_comms.tools import CommsEditGoalTool, CommsResumeGoalTool from goal_owner_fixture import canonical_goal_wire +from agent_comms.goal_waits import NoGoalWait @pytest.fixture @@ -138,7 +139,7 @@ def test_certified_reply_consumes_only_original_wait(selected_goal): assert not comms.goals.consume_reply_wait(captured, wrong.reference) assert comms.goals.goal_wait("worker") == wait assert comms.goals.consume_reply_wait(captured, reply.reference) - assert comms.goals.goal_wait("worker") is None + assert isinstance(comms.goals.goal_wait("worker"), NoGoalWait) assert not comms.goals.consume_reply_wait(captured, reply.reference) finally: if owner is not None: diff --git a/tests/test_goal_standby.py b/tests/test_goal_standby.py index 71e663e339..f60827da1a 100644 --- a/tests/test_goal_standby.py +++ b/tests/test_goal_standby.py @@ -18,11 +18,12 @@ ) from agent_comms.goal_attempts import GoalAttemptStore, StaleAttemptError from agent_comms.goal_presentation import GoalExecutionState, GoalWaitTarget -from agent_comms.goal_waits import GoalWait, GoalWaits +from agent_comms.goal_waits import GoalWaits from agent_comms.store_files import _store_lock from agent_comms.threads import Thread from delivery_owner_fixture import canonical_agent from goal_owner_fixture import canonical_goal_wire +from agent_comms.goal_waits import RecordedGoalWait, NoGoalWait @pytest.mark.parametrize("changed", ["admission", "pid", "process_birth", "rename"]) @@ -109,8 +110,8 @@ def test_standby_rejects_closed_wait_cycle_while_both_turns_are_active(tmp_path) "bob", StandbyGoalAction(expect=GoalPrecondition(goal_id=bob.id), wait_for=("alice",)) ) - assert comms.goals.goal_wait("alice") is not None - assert comms.goals.goal_wait("bob") is None + assert isinstance(comms.goals.goal_wait("alice"), RecordedGoalWait) + assert isinstance(comms.goals.goal_wait("bob"), NoGoalWait) assert comms.registry.require("bob").goal == bob @@ -138,8 +139,8 @@ def test_standby_allows_independent_alternative_to_wait_cycle(tmp_path): "bob", StandbyGoalAction(expect=GoalPrecondition(goal_id=bob.id), wait_for=("alice",)) ) - assert comms.goals.goal_wait("alice") is not None - assert comms.goals.goal_wait("bob") is not None + assert isinstance(comms.goals.goal_wait("alice"), RecordedGoalWait) + assert isinstance(comms.goals.goal_wait("bob"), RecordedGoalWait) def test_idle_active_goal_does_not_make_wait_cycle_runnable(tmp_path): @@ -169,7 +170,7 @@ def test_idle_active_goal_does_not_make_wait_cycle_runnable(tmp_path): "bob", StandbyGoalAction(expect=GoalPrecondition(goal_id=bob.id), wait_for=("alice",)) ) - assert comms.goals.goal_wait("bob") is None + assert isinstance(comms.goals.goal_wait("bob"), NoGoalWait) assert comms.registry.require("bob").goal == bob @@ -243,7 +244,7 @@ def test_liveness_check_releases_preexisting_closed_wait_group(tmp_path, pending owner = comms.registry.require("bob") peer = comms.registry.require("alice") GoalWaits(tmp_path / "goal_waits.json").record( - GoalWait( + RecordedGoalWait( bob.id, "bob-wait", bob.revision, @@ -261,11 +262,11 @@ def test_liveness_check_releases_preexisting_closed_wait_group(tmp_path, pending if pending_reply: comms.messaging.send_message("bob", "alice", "The work is finished") assert comms.goals.recover_closed_goal_wait("alice") == () - assert comms.goals.goal_wait("alice") is not None + assert isinstance(comms.goals.goal_wait("alice"), RecordedGoalWait) return assert comms.goals.recover_closed_goal_wait("alice") == ("alice", "bob") - assert comms.goals.goal_wait("alice") is None + assert isinstance(comms.goals.goal_wait("alice"), NoGoalWait) assert comms.goals.goal_execution("alice").state is GoalExecutionState.RUNNABLE assert "Standby was released" in comms.registry.require("alice").goal.progress assert comms.goals.recover_closed_goal_wait("alice") == () @@ -293,7 +294,7 @@ def test_new_live_dependency_turn_keeps_old_wait_group_open(tmp_path, bound_old_ owner = comms.registry.require("bob") peer = comms.registry.require("alice") GoalWaits(tmp_path / "goal_waits.json").record( - GoalWait( + RecordedGoalWait( bob.id, "bob-wait", bob.revision, @@ -336,7 +337,7 @@ def test_recheck_crash_before_wait_clear_keeps_goal_in_standby(tmp_path, monkeyp "alice", StandbyGoalAction(expect=GoalPrecondition(goal_id=alice.id), wait_for=("bob",)) ) GoalWaits(tmp_path / "goal_waits.json").record( - GoalWait( + RecordedGoalWait( bob.id, "bob-wait", bob.revision, @@ -381,6 +382,6 @@ def test_standby_refuses_canonical_reply_that_arrived_before_wait(tmp_path, ackn "parent", StandbyGoalAction(expect=GoalPrecondition(goal_id=goal.id), wait_for=("child",)), ) - assert comms.goals.goal_wait("parent") is None + assert isinstance(comms.goals.goal_wait("parent"), NoGoalWait) assert comms.registry.require("parent").goal == goal assert not (tmp_path / "input_dispositions.sqlite3").exists() diff --git a/tests/test_goal_standby_liveness.py b/tests/test_goal_standby_liveness.py index 72db561d94..a4a6ceea5d 100644 --- a/tests/test_goal_standby_liveness.py +++ b/tests/test_goal_standby_liveness.py @@ -20,13 +20,15 @@ SetGoalAction, StandbyGoalAction, ) -from agent_comms.goal_waits import GoalWait, GoalWaits +from agent_comms.goal_waits import GoalWaits from agent_comms.thread_status import RunningThreadStatus, StoppedThreadStatus from agent_comms.threads import Thread from agent_comms.transcript_updates import TurnTranscriptUpdate from delivery_owner_fixture import canonical_agent from goal_owner_fixture import activate_empty_source, canonical_goal_wire from test_backend_native_lifecycle import native_backend as native_backend +from agent_comms.goal_waits import NoGoalWait +from agent_comms.goal_waits import RecordedGoalWait pytestmark = pytest.mark.usefixtures("fake_native_catalog") @@ -129,7 +131,7 @@ def test_idle_target_refusal_is_side_effect_free(tmp_path): for name in authoritative } == before assert comms.registry.require("owner").goal == goal - assert comms.goals.goal_wait("owner") is None + assert isinstance(comms.goals.goal_wait("owner"), NoGoalWait) def test_optional_reply_read_failure_after_terminal_commit_never_releases_or_fails( @@ -138,18 +140,18 @@ def test_optional_reply_read_failure_after_terminal_commit_never_releases_or_fai comms, goal = _waiting(tmp_path) fence = _finish(comms, "child", "child-turn") assert fence is not None and comms.registry.require("child").active_turn is None - original = GoalWait.has_reply + original = RecordedGoalWait.has_reply def unavailable(*_args, **_kwargs): raise OSError("injected optional direct-reply read failure") - monkeypatch.setattr(GoalWait, "has_reply", unavailable) + monkeypatch.setattr(RecordedGoalWait, "has_reply", unavailable) assert comms.goals.release_waits_after_terminal_turn(fence) == () assert comms.registry.require("child").active_turn is None assert comms.registry.require("owner").goal.state.active assert comms.registry.require("owner").goal.id == goal.id - assert comms.goals.goal_wait("owner") is not None - monkeypatch.setattr(GoalWait, "has_reply", original) + assert isinstance(comms.goals.goal_wait("owner"), RecordedGoalWait) + monkeypatch.setattr(RecordedGoalWait, "has_reply", original) assert comms.goals.release_waits_after_terminal_turn(fence) == ("owner",) @@ -167,7 +169,7 @@ def denied(*_args, **_kwargs): with pytest.raises(OSError, match="authoritative goal write failure"): comms.goals.release_waits_after_terminal_turn(fence) assert comms.registry.require("owner").goal.state.active - assert comms.goals.goal_wait("owner") is not None + assert isinstance(comms.goals.goal_wait("owner"), RecordedGoalWait) monkeypatch.setattr(comms.registry, "register", original_register) assert comms.goals.release_waits_after_terminal_turn(fence) == ("owner",) @@ -180,7 +182,7 @@ def test_mixed_targets_project_idle_without_suppressing_active_alternative(tmp_p assert "no active turn: @other" in execution.presentation("owner").summary assert comms.goals.release_waits_after_terminal_turn(other_fence) == () assert comms.registry.require("owner").goal.state.active - assert comms.goals.goal_wait("owner") is not None + assert isinstance(comms.goals.goal_wait("owner"), RecordedGoalWait) assert goal.id == comms.registry.require("owner").goal.id @@ -196,7 +198,7 @@ def test_last_target_finishes_silently_releases_once_and_reopens(tmp_path): assert continued.revision == goal.revision + 2 assert "without a qualifying direct reply" in continued.progress assert reopened.goals.goal_execution("owner").state.value == "runnable" - assert reopened.goals.goal_wait("owner") is None + assert isinstance(reopened.goals.goal_wait("owner"), NoGoalWait) assert reopened.registry.require("owner").goal.state.pause_source is None assert reopened.goals.release_waits_after_terminal_turn(child_fence) == () assert reopened.registry.require("owner").goal == continued @@ -212,7 +214,7 @@ def test_only_substantive_direct_reply_prevents_release(tmp_path, notice): changed = comms.goals.release_waits_after_terminal_turn(child_fence) assert changed == (("owner",) if notice else ()) assert comms.registry.require("owner").goal.state.declared_name == "active" - assert (comms.goals.goal_wait("owner") is None) is notice + assert (isinstance(comms.goals.goal_wait("owner"), NoGoalWait)) is notice def test_renamed_target_keeps_incarnation_but_replacement_fails_closed(tmp_path): @@ -256,7 +258,7 @@ def crash_before_wait_clear(*_args, **_kwargs): assert reopened.registry.require("owner").goal.state.declared_name == "active" assert reopened.registry.require("owner").goal.id == goal.id assert reopened.goals.goal_execution("owner").state.value == "standby" - assert reopened.goals.goal_wait("owner") is not None + assert isinstance(reopened.goals.goal_wait("owner"), RecordedGoalWait) assert reopened.registry.status("owner") == RunningThreadStatus() monkeypatch.setattr(GoalWaits, "clear", original_clear) assert reopened.goals.recover_closed_goal_wait("owner") == ("owner",) @@ -290,7 +292,7 @@ def test_owner_pause_still_prevents_quiet_dependency_release(tmp_path): assert comms.goals.release_waits_after_terminal_turn(fence) == () assert comms.registry.require("owner").goal == paused assert comms.registry.require("owner").goal.state.pause_source.declared_name == "owner" - assert comms.goals.goal_wait("owner") is None + assert isinstance(comms.goals.goal_wait("owner"), NoGoalWait) def test_reply_arriving_after_idle_check_stays_visible_without_model_start(tmp_path, monkeypatch): @@ -304,7 +306,7 @@ def late_send(): comms.messaging.send_message("child", "owner", "Late direct reply") sent.set() - real_history = GoalWait.has_reply + real_history = RecordedGoalWait.has_reply sender = threading.Thread(target=late_send) def interpose(*args, **kwargs): @@ -312,13 +314,13 @@ def interpose(*args, **kwargs): assert checked.wait(timeout=2) return real_history(*args, **kwargs) - monkeypatch.setattr(GoalWait, "has_reply", interpose) + monkeypatch.setattr(RecordedGoalWait, "has_reply", interpose) assert comms.goals.release_waits_after_terminal_turn(child_fence) == ("owner",) sender.join(timeout=2) assert sent.is_set() assert comms.registry.require("owner").goal.state.declared_name == "active" assert [message.body for message in comms.bus.inbox("owner")] == ["Late direct reply"] - assert comms.goals.goal_wait("owner") is None + assert isinstance(comms.goals.goal_wait("owner"), NoGoalWait) def test_terminal_hook_rechecks_goal_and_owner_identity(tmp_path): @@ -363,7 +365,7 @@ def test_delayed_old_finish_cannot_release_new_active_same_id(tmp_path): assert comms.registry.require("child").active_turn == active assert comms.goals.release_waits_after_terminal_turn(old_fence) == () assert comms.registry.require("owner").goal.state.active - assert comms.goals.goal_wait("owner") is not None + assert isinstance(comms.goals.goal_wait("owner"), RecordedGoalWait) new_fence = comms.agents.finish_turn(new_claim) assert new_fence is not None and new_fence.identity.generation == new_claim.identity.generation assert comms.goals.release_waits_after_terminal_turn(new_fence) == ("owner",) @@ -381,13 +383,13 @@ def test_delayed_old_terminal_cannot_release_newer_turn_before_reply(tmp_path, r assert new_fence.identity.generation == old_fence.identity.generation + 1 assert comms.goals.release_waits_after_terminal_turn(old_fence) == () assert comms.registry.require("owner").goal.state.active - assert comms.goals.goal_wait("owner") is not None + assert isinstance(comms.goals.goal_wait("owner"), RecordedGoalWait) # The newer turn publishes after the OLD callback, before its own callback. comms.messaging.send_message("child", "owner", "New turn's substantive answer") assert comms.goals.release_waits_after_terminal_turn(new_fence) == () assert comms.registry.require("owner").goal.state.active assert comms.registry.require("owner").goal.id == goal.id - assert comms.goals.goal_wait("owner") is not None + assert isinstance(comms.goals.goal_wait("owner"), RecordedGoalWait) def test_newer_silent_terminal_releases_waiter(tmp_path): @@ -397,7 +399,7 @@ def test_newer_silent_terminal_releases_waiter(tmp_path): new_fence = _finish(comms, "child", "new-child-turn") assert comms.goals.release_waits_after_terminal_turn(old_fence) == () assert comms.goals.release_waits_after_terminal_turn(new_fence) == ("owner",) - assert comms.goals.goal_wait("owner") is None + assert isinstance(comms.goals.goal_wait("owner"), NoGoalWait) def test_terminal_fence_survives_rename_not_stop_or_metadata_edit(tmp_path): @@ -453,7 +455,7 @@ async def capture_emit(session_id, event, client=None, **kwargs): terminal.append("settled") await original_emit(session_id, event, client, **kwargs) - original_history_page = GoalWait.has_reply + original_history_page = RecordedGoalWait.has_reply def unavailable(*args, **kwargs): if not terminal: @@ -463,12 +465,12 @@ def unavailable(*args, **kwargs): raise OSError("injected optional direct-reply read failure") monkeypatch.setattr(agent, "_emit_event", capture_emit) - monkeypatch.setattr(GoalWait, "has_reply", unavailable) + monkeypatch.setattr(RecordedGoalWait, "has_reply", unavailable) async with asyncio.timeout(20): await agent.turns.run_agent_turn(child, child, "Finish work") assert terminal == ["settled"] assert comms.registry.require("owner").goal.state.active - assert comms.goals.goal_wait("owner") is not None + assert isinstance(comms.goals.goal_wait("owner"), RecordedGoalWait) assert comms.registry.require(child).active_turn is None assert native.provider.posts == len(native.saved_inputs()) == 1 @@ -504,7 +506,7 @@ async def test_quiet_dependency_finish_schedules_the_still_active_goal(tmp_path, fence = comms.agents.finish_turn(lease) assert comms.goals.release_waits_after_terminal_turn(fence) == (owner,) assert comms.registry.require(owner).goal.state.active - assert comms.goals.goal_wait(owner) is None + assert isinstance(comms.goals.goal_wait(owner), NoGoalWait) await agent.turns.goals.schedule_goal(owner) assert wakes == [owner] assert [turn.goal_id for turn in agent.inputs.pending_turns[owner]] == [goal.id] @@ -547,7 +549,7 @@ async def delayed_emit(session_id, event, client=None, **kwargs): release_old.set() await asyncio.wait_for(old_task, 5) assert comms.registry.require("owner").goal.state.active - assert comms.goals.goal_wait("owner") is not None + assert isinstance(comms.goals.goal_wait("owner"), RecordedGoalWait) comms.messaging.send_message(child, "owner", "New turn's result") assert comms.goals.release_waits_after_terminal_turn(new_fence) == () assert comms.registry.require("owner").goal.state.active @@ -582,7 +584,7 @@ async def observe(session_id, event, client=None, **kwargs): observed.append("native-settled") assert comms.registry.require(child).active_turn is not None assert comms.registry.require("owner").goal.state.active - assert comms.goals.goal_wait("owner") is not None + assert isinstance(comms.goals.goal_wait("owner"), RecordedGoalWait) await real_emit(session_id, event, client, **kwargs) monkeypatch.setattr(agent, "_emit_event", observe) @@ -597,8 +599,8 @@ async def observe(session_id, event, client=None, **kwargs): assert comms.registry.require(child).active_turn is None if reply: assert comms.registry.require("owner").goal.state.active - assert comms.goals.goal_wait("owner") is not None + assert isinstance(comms.goals.goal_wait("owner"), RecordedGoalWait) assert any(m.body == "Reported" for m in comms.bus.inbox("owner")) else: assert comms.registry.require("owner").goal.state.active - assert comms.goals.goal_wait("owner") is None + assert isinstance(comms.goals.goal_wait("owner"), NoGoalWait) diff --git a/tests/test_goal_wait_certified_reply.py b/tests/test_goal_wait_certified_reply.py index ab5a89d868..00d86f9201 100644 --- a/tests/test_goal_wait_certified_reply.py +++ b/tests/test_goal_wait_certified_reply.py @@ -8,13 +8,14 @@ from test_goal_standby_liveness import retained_native_acp_owner as retained_native_acp_owner from test_backend_native_lifecycle import native_backend as native_backend -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.goal_actions import GoalPrecondition, SetGoalAction, StandbyGoalAction from agent_comms.goal_presentation import GoalWaitTarget from agent_comms.threads import Thread from goal_owner_fixture import canonical_goal_wire from stored_inputs import stored_inputs +from agent_comms.goal_waits import NoGoalWait def register(comms, name): @@ -38,7 +39,7 @@ def test_original_reply_is_not_reassigned_to_same_name_replacement(tmp_path): with comms.bus.log.certified_read() as source: original = source.delivery(reply.seq) assert wait.matches(original, owner.incarnation.require_recorded()) - assert original.audience.sender_lookup == ThreadIncarnation("fixture", peer.created_at).lookup + assert original.audience.sender_lookup == RecordedThreadIncarnation("fixture", peer.created_at).lookup comms.agents.finish_turn(lease) comms.registry.unregister("peer") @@ -162,7 +163,7 @@ async def test_native(retained_native_acp_owner): native.provider.response_gate = None async with asyncio.timeout(30): assert await turn == 1 - assert comms.goals.goal_wait(sid) is None + assert isinstance(comms.goals.goal_wait(sid), NoGoalWait) assert comms.registry.require(sid).goal == parked_goal assert comms.registry.require(sid).active_turn is None handled = stored_inputs(agent.inputs.dispositions.root).lookup(f"acp:{accepted.input_id}") @@ -178,7 +179,7 @@ async def test_native(retained_native_acp_owner): with Coordination(str(comms.root / "coordination.sqlite3")) as store: proofs = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", owner.created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", owner.created_at).lookup, source_seq=original.seq, ) assert len(proofs) == 1 and isinstance(proofs[0].execution, FullNativeExecution) @@ -189,7 +190,7 @@ async def test_native(retained_native_acp_owner): from agent_comms.comms import Comms reopened = Comms(comms.root, private_initial_writes=False, private_claim_writes=False) - assert reopened.goals.goal_wait(sid) is None + assert isinstance(reopened.goals.goal_wait(sid), NoGoalWait) assert reopened.registry.require(sid).goal == parked_goal assert reopened.bus.log.message_by_id(original.message_id).reference == original.reference assert stored_inputs(comms.root).lookup(old_key) == old diff --git a/tests/test_goal_wait_current_owner.py b/tests/test_goal_wait_current_owner.py index 9d2e2e0d19..3dd72fd618 100644 --- a/tests/test_goal_wait_current_owner.py +++ b/tests/test_goal_wait_current_owner.py @@ -6,9 +6,10 @@ from agent_comms.goal_actions import SetGoalAction from agent_comms.goal_presentation import GoalWaitTarget -from agent_comms.goal_waits import GoalWait, GoalWaits +from agent_comms.goal_waits import GoalWaits from agent_comms.threads import Thread from goal_owner_fixture import canonical_goal_wire +from agent_comms.goal_waits import RecordedGoalWait @pytest.mark.parametrize("change", ["same", "later_goal", "future_wait", "replacement", "foreign_goal"]) @@ -20,7 +21,7 @@ def test_graph_uses_only_wait_for_original_owner_goal(tmp_path, change): peer = comms.registry.require("peer") leaf = comms.registry.require("leaf") goal = peer.active_goal - wait = GoalWait( + wait = RecordedGoalWait( goal.id, "original-wait", goal.revision, 0, (GoalWaitTarget(leaf.name, leaf.created_at),), peer.created_at, (None,), None, None, diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index 65cedfb2f3..afddfe9e83 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -335,8 +335,14 @@ def test_historical_namespace_has_recorded_provenance_and_no_live_authority(migr with pytest.raises(ValueError, match="Missing required"): FieldCodec.decode(ThreadProvenance, raw) # Original finite historical timestamps are preserved, never synthesized. - zero = replace(recorded, created_at=0.0) - assert FieldCodec.decode(ThreadProvenance, FieldCodec.encode(zero)) == zero + from agent_comms.thread_provenance import UnrecordedThreadProvenance + + zero = UnrecordedThreadProvenance( + name=recorded.name, tags=recorded.tags, worktree=recorded.worktree, + created_at=0.0, session_file=recorded.session_file, title=recorded.title, + ) + archived_zero = RegistryProvenance(threads={zero.name: zero}, aliases={}) + assert FieldCodec.decode(RegistryProvenance, FieldCodec.encode(archived_zero)) == archived_zero aliased = RegistryProvenance(threads={"alice": recorded}, aliases={"original-alice": "alice"}) assert aliased.require("original-alice") is recorded with pytest.raises(ValueError): diff --git a/tests/test_input_documents.py b/tests/test_input_documents.py index a0fe7ceb88..73fd90ec1f 100644 --- a/tests/test_input_documents.py +++ b/tests/test_input_documents.py @@ -21,6 +21,7 @@ from agent_comms.input_disposition import InputDispositionRow, InputDispositions, InputDocument from agent_comms.errors import RelationViolationError from agent_comms.thread_identity import TurnId +from agent_comms.thread_identity import RecordedThreadIncarnation from stored_inputs import insert_inputs, stored_inputs @@ -132,7 +133,6 @@ def test_original_native_user_receipts_include_group_and_corrected_final_only(tm from agent_comms.child_process import ProcessIdentity from agent_comms.registration import Registration - from agent_comms.thread_identity import ThreadIncarnation from agent_comms.threads import Thread registry = Registration(tmp_path / "registry.json") @@ -187,12 +187,12 @@ def test_original_native_user_receipts_include_group_and_corrected_final_only(tm ) assert not document.started_for_native( replace(lease, identity=replace( - lease.identity, incarnation=ThreadIncarnation("foreign", 123.0) + lease.identity, incarnation=RecordedThreadIncarnation("foreign", 123.0) )), "a" * 32, "Actual grouped native user", snapshot=snapshot ) assert not document.started_for_native( replace(lease, identity=replace( - lease.identity, incarnation=ThreadIncarnation("worker", 124.0) + lease.identity, incarnation=RecordedThreadIncarnation("worker", 124.0) )), "a" * 32, "Actual grouped native user", snapshot=snapshot ) registry.release_turn(lease) @@ -210,7 +210,6 @@ def test_original_native_user_receipts_include_group_and_corrected_final_only(tm def test_distinct_lifecycle_and_missing_state_never_supply_sent_evidence(tmp_path): from dataclasses import fields - from agent_comms.thread_identity import ThreadIncarnation store = InputDispositions(tmp_path) missing = stored_inputs(store.root).lookup("acp:missing") @@ -223,10 +222,10 @@ def test_distinct_lifecycle_and_missing_state_never_supply_sent_evidence(tmp_pat ) reserved = stored_inputs(store.root).lookup("acp:input") assert isinstance(reserved, ReservedInput) and reserved.exists and reserved.accepts_reservation - assert reserved.matches_owner(ThreadIncarnation("owner", 10.0)) + assert reserved.matches_owner(RecordedThreadIncarnation("owner", 10.0)) # Existing input rows attest name/admission only, never a historical birth. - assert reserved.matches_owner(ThreadIncarnation("owner", 20.0)) - assert not reserved.matches_owner(ThreadIncarnation("other", 10.0)) + assert reserved.matches_owner(RecordedThreadIncarnation("owner", 20.0)) + assert not reserved.matches_owner(RecordedThreadIncarnation("other", 10.0)) assert reserved.matches_admission(4) and not reserved.matches_admission(5) for name in ("native_id", "turn_id", "sent_digest"): assert name not in {item.name for item in fields(reserved)} @@ -304,11 +303,10 @@ def test_invalid_bind_keeps_durable_reservation_intact(tmp_path, native_id): def test_reserved_input_is_queued_only_for_its_own_owner_admission_and_text(): from agent_comms.text_digest import TextDigest - from agent_comms.thread_identity import ThreadIncarnation reserved = ReservedInput("acp:x", None, "owner", 1, "owner", "original") bound = reserved.bind(admission=1, turn_id="turn", native_id="a" * 32, text="wrapped input") - owner = ThreadIncarnation("owner", 1.0) + owner = RecordedThreadIncarnation("owner", 1.0) assert reserved.digest == TextDigest.of("original") assert reserved.queued_for(owner, 1, "original") assert not reserved.queued_for(owner, 1, "different") diff --git a/tests/test_interrupted_delivery_recovery.py b/tests/test_interrupted_delivery_recovery.py index ef44662aa9..0913aca2f2 100644 --- a/tests/test_interrupted_delivery_recovery.py +++ b/tests/test_interrupted_delivery_recovery.py @@ -8,7 +8,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.assignment_states import DeferredAssignment, FullPendingAssignment from agent_comms.cohort_foreground import _accept_visible_deliveries from agent_comms.coordination_cohort import pending_sealed_assignments @@ -31,7 +31,7 @@ async def test_interrupted_following_delivery_recovers_only_before_native_send( owner = comms.registry.require("beta") message = comms.messaging.send_initial_cohort("sender", "beta", "must survive selection") store_path = comms.root / "coordination.sqlite3" - lookup = ThreadIncarnation("fixture", owner.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", owner.created_at).lookup await _accept_visible_deliveries( comms.bus, root_id, store_path, lookup, 0, owner_name=owner.name, ) @@ -115,7 +115,7 @@ async def test_selected_phase_and_delivery_share_wire_without_blocking_loop(tmp_ original = comms.messaging.send_initial_cohort("sender", "beta", "selected batch") owner = comms.registry.require("beta") path = comms.root / "coordination.sqlite3" - lookup = ThreadIncarnation("fixture", owner.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", owner.created_at).lookup await _accept_visible_deliveries(comms.bus, root_id, path, lookup, 0, owner_name="beta") entered, release = asyncio.Event(), threading.Event() loop = asyncio.get_running_loop() @@ -173,7 +173,7 @@ async def test_recovery_without_paused_assignments_is_read_only(tmp_path, monkey owner = comms.registry.require("beta") comms.messaging.send_initial_cohort("sender", "beta", "unbound recovery source") store_path = comms.root / "coordination.sqlite3" - lookup = ThreadIncarnation("fixture", owner.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", owner.created_at).lookup await _accept_visible_deliveries(comms.bus, root_id, store_path, lookup, 0, owner_name="beta") with Coordination(str(store_path)) as store: assignments = pending_sealed_assignments(store, lookup, "beta") @@ -211,7 +211,7 @@ async def test_delivery_worker_retires_unplaced_inputs_after_cancellation_or_inb agent.inputs.original_sources["beta"] = SelectedOriginalInput.answering("live selected work") message = comms.messaging.send_initial_cohort("sender", "beta", "worker-held delivery") path = comms.root / "coordination.sqlite3" - lookup = ThreadIncarnation("fixture", leased.thread.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", leased.thread.created_at).lookup await _accept_visible_deliveries(comms.bus, root_id, path, lookup, 0, owner_name="beta") loop = asyncio.get_running_loop() entered, release = asyncio.Event(), threading.Event() diff --git a/tests/test_keyed_response_delivery.py b/tests/test_keyed_response_delivery.py index 5fa485fc49..a12c5e4d49 100644 --- a/tests/test_keyed_response_delivery.py +++ b/tests/test_keyed_response_delivery.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.bus_source_page import AddressedPage from agent_comms.cohort_foreground import _accept_visible_deliveries @@ -24,7 +24,7 @@ async def test_reply_has_frozen_awareness_and_selected_native_barrier(tmp_path: # A subscriber arriving after the original is not a historical recipient. case.comms.registry.declare(Thread("late", frozenset({"team"}), worktree=str(tmp_path))) sender = case.comms.registry.require("sender") - lookup = ThreadIncarnation("fixture", sender.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", sender.created_at).lookup intent = prepare_fenced_response( case.store, case.bus, @@ -120,7 +120,7 @@ def test_canonical_response_reader_rejects_invalid_receipt(tmp_path, field, valu with pytest.raises(RelationViolationError): with case.bus.log.certified_read() as source: source.addressed_page(case.bus.log, AddressedPage.capture( - lookup=ThreadIncarnation("fixture", sender.created_at).lookup)) + lookup=RecordedThreadIncarnation("fixture", sender.created_at).lookup)) assert checkpoint.read_bytes() == saved_index return # Explicit cold certification must reject a corrupt candidate rather diff --git a/tests/test_locked_store.py b/tests/test_locked_store.py index db420ae98b..b4f3bff3b2 100644 --- a/tests/test_locked_store.py +++ b/tests/test_locked_store.py @@ -13,13 +13,14 @@ from agent_comms.field_codec import FieldCodec from agent_comms.goal_presentation import GoalWaitTarget -from agent_comms.goal_waits import GoalWait, GoalWaits +from agent_comms.goal_waits import GoalWaits from agent_comms.locked_store import LockedStore from agent_comms.store_files import _store_lock +from agent_comms.goal_waits import RecordedGoalWait def wait(goal_id="goal"): - return GoalWait( + return RecordedGoalWait( goal_id, "wait", 2, diff --git a/tests/test_maintenance_barrier.py b/tests/test_maintenance_barrier.py index 21404bd8fe..cbbb5ea8ef 100644 --- a/tests/test_maintenance_barrier.py +++ b/tests/test_maintenance_barrier.py @@ -10,7 +10,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.backend import _maintenance_send_boundary from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms @@ -64,7 +64,7 @@ async def start(*args, **kwargs): assert starts == [] and not agent.inputs.wake_tasks beta = comms.registry.require('beta') with Coordination(comms.root / 'coordination.sqlite3') as store: - pending = next_sealed_assignment(store, ThreadIncarnation("fixture", beta.created_at).lookup, beta.name) + pending = next_sealed_assignment(store, RecordedThreadIncarnation("fixture", beta.created_at).lookup, beta.name) assert pending.wire_seq == post.seq assert beta.active_turn is None @@ -81,13 +81,13 @@ async def start(*args, **kwargs): assert await agent.inputs.drain_inbox('beta') == 1 assert len(selected) == 1 with Coordination(comms.root / 'coordination.sqlite3') as store: - assert next_sealed_assignment(store, ThreadIncarnation("fixture", beta.created_at).lookup, beta.name).wire_seq == post.seq + assert next_sealed_assignment(store, RecordedThreadIncarnation("fixture", beta.created_at).lookup, beta.name).wire_seq == post.seq # Selection scheduled just before another drain must still refuse at the # effect boundary without consuming that durable assignment. another_drain = control.begin(FieldCodec.decode(MaintenanceOperatorField, 'second-cutover')) assert await selected[0].run() == another_drain with Coordination(comms.root / 'coordination.sqlite3') as store: - assert next_sealed_assignment(store, ThreadIncarnation("fixture", beta.created_at).lookup, beta.name).wire_seq == post.seq + assert next_sealed_assignment(store, RecordedThreadIncarnation("fixture", beta.created_at).lookup, beta.name).wire_seq == post.seq for phase in (PausedPhase, InstallingPhase, ReadyPhase): another_drain = control.advance(another_drain, phase) agent.inputs.pending_turns['beta'] = [queued] @@ -115,7 +115,7 @@ async def test_cutover_clear_delivers_pending_channel_post(tmp_path: Path, monke assert await agent.inputs.drain_inbox('beta') == 0 beta = comms.registry.require('beta') with Coordination(comms.root / 'coordination.sqlite3') as store: - assert next_sealed_assignment(store, ThreadIncarnation("fixture", beta.created_at).lookup, beta.name).wire_seq == post.seq + assert next_sealed_assignment(store, RecordedThreadIncarnation("fixture", beta.created_at).lookup, beta.name).wire_seq == post.seq assert beta.active_turn is None if abort: assert gate.clear(hold).phase is ReadyPhase diff --git a/tests/test_native_channel_reply_roundtrip.py b/tests/test_native_channel_reply_roundtrip.py index 822c84a37b..fe20054a88 100644 --- a/tests/test_native_channel_reply_roundtrip.py +++ b/tests/test_native_channel_reply_roundtrip.py @@ -14,7 +14,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.native_input_record import TriageNativeExecution, FullNativeExecution from agent_comms.selected_triage import IgnoreSelectedTriage from agent_comms.acp import CommsClient @@ -371,7 +371,7 @@ def settled(): with comms.bus.log.locked(): marker = comms.bus.log._private_marker_unlocked() pointers = addressed_source_pointers_unlocked( - comms.bus.log, marker, ThreadIncarnation("fixture", owner.created_at).lookup + comms.bus.log, marker, RecordedThreadIncarnation("fixture", owner.created_at).lookup ) assert reply.message_id in [pointer.message_id for pointer in pointers] @@ -422,7 +422,7 @@ def selected_turn_finished(): with Coordination(str(comms.root / "coordination.sqlite3")) as store: rejected = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", owner.created_at).lookup, source_seq=reply.seq, + recipient_lookup=RecordedThreadIncarnation("fixture", owner.created_at).lookup, source_seq=reply.seq, ) assert len(rejected) == 1 and rejected[0].proves_triage_source(rejected) claim = store.assignments.get(rejected[0].assignment_id) diff --git a/tests/test_native_drain_readiness.py b/tests/test_native_drain_readiness.py index 63de0cb4ef..9ad09db364 100644 --- a/tests/test_native_drain_readiness.py +++ b/tests/test_native_drain_readiness.py @@ -10,12 +10,13 @@ ) from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec -from agent_comms.thread_identity import OwnerIdentity, ThreadIncarnation +from agent_comms.thread_identity import OwnerIdentity +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.thread_presentation import AttentionThreadPresentationStatus def test_native_warning_keeps_fresh_notifications_and_active_paint_available(): - owner = OwnerIdentity(ThreadIncarnation('receiver', 1.0), 2) + owner = OwnerIdentity(RecordedThreadIncarnation('receiver', 1.0), 2) diagnostic = NativeInputDrainDiagnostic( owner, 'NativePiTerminalFailure', 'known provider failure', recorded_at=1.0 ) @@ -39,7 +40,7 @@ def test_native_warning_keeps_fresh_notifications_and_active_paint_available(): @pytest.mark.parametrize('kind', [UnavailableDrainDiagnostic, StoppedDrainDiagnostic]) def test_real_inbox_failures_refuse_without_owning_message_labels(kind): - owner = OwnerIdentity(ThreadIncarnation('receiver', 1.0), 2) + owner = OwnerIdentity(RecordedThreadIncarnation('receiver', 1.0), 2) ready = DrainReadiness.acquire( kind(owner, 'OSError', 'real read failure', recorded_at=1.0), owner ) diff --git a/tests/test_native_prompt_binding.py b/tests/test_native_prompt_binding.py index 17c99ed6e1..21d2d8e70d 100644 --- a/tests/test_native_prompt_binding.py +++ b/tests/test_native_prompt_binding.py @@ -17,7 +17,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.native_input_record import TriageNativeExecution, FullNativeExecution from agent_comms.selected_triage import IgnoreSelectedTriage from agent_comms.child_process import ProcessIdentity @@ -311,7 +311,7 @@ async def test_binding_matches_journal_and_exposes_equality(tmp_path: Path, monk comms.bus, store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, ).read() assert current.covered_seq == coverage.covered_seq assert coverage.covered_seq >= initial.message.seq @@ -319,7 +319,7 @@ async def test_binding_matches_journal_and_exposes_equality(tmp_path: Path, monk evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert len(evidence) == 1 and isinstance(evidence[0].execution, TriageNativeExecution) @@ -345,7 +345,7 @@ async def test_source_coverage_stops_at_missing_claim_and_unknown_input(tmp_path fake, calls = _fake_model(decision="IGNORE") monkeypatch.setattr(TrackedTurnSession, "execute", fake) second = comms.messaging.send_initial_cohort("sender", "#team", "Second source.") - lookup = ThreadIncarnation("fixture", people[1].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[1].created_at).lookup bus = comms.bus with Coordination(str(root / "coordination.sqlite3")) as store: before = SourceCoverage(bus, store, wire_root_id=root_id, recipient_lookup=lookup).read() @@ -402,7 +402,7 @@ async def test_source_coverage_mismatch_cannot_skip_to_later_proof(tmp_path, mon comms.bus, store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, ).read() assert coverage.covered_seq == 0 assert coverage.injected_source_seqs == () @@ -416,7 +416,7 @@ async def test_source_coverage_mismatch_cannot_skip_to_later_proof(tmp_path, mon comms.bus, store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, ).read(limit=1) @@ -433,7 +433,7 @@ async def test_source_coverage_distinguishes_no_wake_from_native_injection(tmp_p comms.registry.declare(beta) with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - ThreadIncarnation("fixture", beta.created_at).lookup, "beta", "beta", committed=True + RecordedThreadIncarnation("fixture", beta.created_at).lookup, "beta", "beta", committed=True ) second = comms.messaging.send_initial_cohort("sender", "#team", "@beta please review.") with Coordination(str(root / "coordination.sqlite3")) as store: @@ -449,7 +449,7 @@ async def test_source_coverage_distinguishes_no_wake_from_native_injection(tmp_p comms.bus, store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, ).read() assert coverage.covered_seq >= second.seq assert coverage.covered_seq == coverage.source_witness.latest_source_seq @@ -479,7 +479,7 @@ async def fail_full(package, **kwargs): comms.bus, store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, ).read() assert coverage.covered_seq == 0 assert coverage.injected_source_seqs == () @@ -524,7 +524,7 @@ async def test_old_input_id_cannot_directly_seed_new_admission_cursor(tmp_path, owner = comms.registry.require("alpha") admission_generation = comms.registry.snapshot().admission_generations["alpha"] with Coordination(str(root / "coordination.sqlite3")) as store: - lookup = ThreadIncarnation("fixture", people[1].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[1].created_at).lookup generation = store.participants.get(lookup).participant_generation assert ( NativeSourceCursor(comms.bus, store, wire_root_id=root_id).read(owner_name="alpha") @@ -589,7 +589,7 @@ async def test_current_cursor_new_owner_generation_cannot_borrow_proof(tmp_path, root=root, wire_root_id=root_id, owner_name="alpha", native_package=tmp_path ).run() assert old_turn is not None and old_turn.cursor_status == "proven" - lookup = ThreadIncarnation("fixture", people[1].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[1].created_at).lookup comms.registry.rename("alpha", "alpha-new") with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.advance_generation(lookup, "alpha-new", expected_generation=1) @@ -633,7 +633,7 @@ async def test_full_stage_binding_joins_after_triage_engagement(tmp_path: Path, evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert [type(row.execution) for row in evidence] == [TriageNativeExecution, FullNativeExecution] @@ -664,7 +664,7 @@ async def test_journal_digest_mismatch_is_not_equality(tmp_path: Path, monkeypat evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert evidence == () @@ -708,7 +708,7 @@ async def corrupt_full(package, **kwargs): evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert [type(row.execution) for row in evidence] == [TriageNativeExecution] @@ -776,7 +776,7 @@ async def mutate_after_admitted_send(package, **kwargs): evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert [type(row.execution) for row in evidence] == ([] if stage == "triage" else [TriageNativeExecution]) @@ -813,7 +813,7 @@ def generation_bumps_then_bind(*args, **kwargs): # committed but before the binding write. with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.advance_generation( - ThreadIncarnation("fixture", people[1].created_at).lookup, + RecordedThreadIncarnation("fixture", people[1].created_at).lookup, "alpha", expected_generation=1, ) @@ -855,14 +855,14 @@ async def dying(package, **_): evidence = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, source_seq=initial.message.seq, ) assert evidence == () # The claim stays deferred (no auto-retry) and unproven. from agent_comms.coordination_cohort import sealed_cohort_assignments - claims = sealed_cohort_assignments(store, ThreadIncarnation("fixture", people[1].created_at).lookup) + claims = sealed_cohort_assignments(store, RecordedThreadIncarnation("fixture", people[1].created_at).lookup) assert [assignment.lifecycle.declared_name for assignment in claims] == ["deferred"] assert read_expected_prompt_binding(store, "0" * 32) is None # The sidecar binding row exists and stays immutable, but no equality is diff --git a/tests/test_native_send_admission.py b/tests/test_native_send_admission.py index 285a0223c3..c4da9ea07d 100644 --- a/tests/test_native_send_admission.py +++ b/tests/test_native_send_admission.py @@ -15,7 +15,7 @@ import pytest from agent_comms import native_pi, native_prompt_send -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.diagnostics import PublicationMeasurements from agent_comms.child_process import AttachedChild from agent_comms.coordinated_runtime import SelectedExecution @@ -42,7 +42,7 @@ async def race(*args, **kwargs): if drift == "generation": with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.advance_generation( - ThreadIncarnation("fixture", owner.created_at).lookup, owner.name, expected_generation=1 + RecordedThreadIncarnation("fixture", owner.created_at).lookup, owner.name, expected_generation=1 ) else: comms.registry.unregister(owner.name) @@ -117,7 +117,7 @@ async def test_actual_raw_writes_follow_committed_admission_without_global_exclu create = AttachedChild.start children = [] received = tmp_path / "received.json" - session = root / "native-sessions" / ThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" + session = root / "native-sessions" / RecordedThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" async def launch(*args, **kwargs): child = await create( @@ -188,7 +188,7 @@ async def bounded_turn(*args, **kwargs): loop = asyncio.get_running_loop() create = AttachedChild.start children = [] - session = root / "native-sessions" / ThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" + session = root / "native-sessions" / RecordedThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" async def launch(*args, **kwargs): child = await create( @@ -345,7 +345,7 @@ async def test_short_admission_contention_sends_once_after_release( monkeypatch.setattr(native_pi, "_trusted_package", lambda _: Path("/bin/true")) create = AttachedChild.start received = tmp_path / "received.json" - session = root / "native-sessions" / ThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" + session = root / "native-sessions" / RecordedThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" async def launch(*args, **kwargs): return await create( @@ -535,7 +535,7 @@ async def test_actual_native_admission_commits_before_bytes_and_releases_feedbac monkeypatch.setattr(native_pi, "_trusted_package", lambda _: Path("/bin/true")) create = AttachedChild.start received = tmp_path / "received.json" - session = root / "native-sessions" / ThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" + session = root / "native-sessions" / RecordedThreadIncarnation("fixture", owner.created_at).lookup / "s.jsonl" children = [] async def launch(*_args, **kwargs): diff --git a/tests/test_native_source_cursor_certificate.py b/tests/test_native_source_cursor_certificate.py index b73a6e6605..0ad90850f2 100644 --- a/tests/test_native_source_cursor_certificate.py +++ b/tests/test_native_source_cursor_certificate.py @@ -11,7 +11,7 @@ import pytest from agent_comms import coordinated_runtime as runtime -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -69,7 +69,7 @@ def _fresh(tmp_path: Path, count: int = 2): name = "alpha" if n == 0 else f"other{n:03}" owner = comms.registry.require(name) store.participants.register( - ThreadIncarnation("fixture", owner.created_at).lookup, name, name, committed=True + RecordedThreadIncarnation("fixture", owner.created_at).lookup, name, name, committed=True ) return root, root_id, comms diff --git a/tests/test_native_source_cursor_scale.py b/tests/test_native_source_cursor_scale.py index 2afd0a355d..9f6f4aae24 100644 --- a/tests/test_native_source_cursor_scale.py +++ b/tests/test_native_source_cursor_scale.py @@ -9,7 +9,7 @@ import pytest from agent_comms import coordinated_runtime as runtime -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.native_input_record import TriageNativeExecution, FullNativeExecution from agent_comms.selected_triage import IgnoreSelectedTriage from agent_comms.child_process import ProcessIdentity @@ -54,7 +54,7 @@ async def test_101_unrelated_initials_and_frozen_n_keeps_exact_native_cursor( comms.registry.declare(member) with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - ThreadIncarnation("fixture", member.created_at).lookup, + RecordedThreadIncarnation("fixture", member.created_at).lookup, member.name, member.name, committed=True, @@ -73,7 +73,7 @@ async def test_101_unrelated_initials_and_frozen_n_keeps_exact_native_cursor( comms.bus, store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", people[1].created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", people[1].created_at).lookup, ).read() assert coverage.blocked_seq == selected.seq second = await runtime.SelectedExecution( @@ -116,7 +116,7 @@ async def test_page_budget_refuses_progress_but_original_is_not_replayed(tmp_pat other = comms.registry.require("other") with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - ThreadIncarnation("fixture", other.created_at).lookup, "other", "other", committed=True + RecordedThreadIncarnation("fixture", other.created_at).lookup, "other", "other", committed=True ) for number in range(101): message = comms.messaging.send_initial_cohort("sender", "#team", f"@other note-{number}") @@ -189,7 +189,7 @@ async def test_forged_cross_generation_cursor_reopen_denied_without_mutating_sql ) assert valid is not None and valid.input_id == first.input_id - lookup = ThreadIncarnation("fixture", people[1].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[1].created_at).lookup comms.registry.rename("alpha", "alpha-new") with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.advance_generation(lookup, "alpha-new", expected_generation=1) @@ -248,7 +248,7 @@ async def test_reconnect_rechecks_sql_generation_after_proof_scan(tmp_path, monk root=root, wire_root_id=root_id, owner_name="alpha", native_package=tmp_path ).run() assert result is not None and result.cursor_status == "proven" - lookup = ThreadIncarnation("fixture", people[1].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[1].created_at).lookup original = SourceCoverage.prefix def advance_generation(*args, **kwargs): diff --git a/tests/test_native_transcript.py b/tests/test_native_transcript.py index 04633b7a1b..7d3fda0f9d 100644 --- a/tests/test_native_transcript.py +++ b/tests/test_native_transcript.py @@ -5,7 +5,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.comms import wire from agent_comms.field_codec import FieldCodec from agent_comms.native_entries import MessageEntry, NativeEntry, TranscriptProjection, UnknownEntry @@ -165,7 +165,7 @@ def test_native_ancestry_is_acquired_before_coordinator_read(tmp_path, monkeypat root = tmp_path / "wire" comms = wire(root) thread = comms.registry.declare(Thread("worker", frozenset(), str(tmp_path))) - lookup = ThreadIncarnation("fixture", thread.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", thread.created_at).lookup database = root / "coordination.sqlite3" with Coordination(str(database)) as store: install_native_runtime_schema(store) diff --git a/tests/test_optional_awareness_projection.py b/tests/test_optional_awareness_projection.py index 07cb79cb49..367801c247 100644 --- a/tests/test_optional_awareness_projection.py +++ b/tests/test_optional_awareness_projection.py @@ -11,7 +11,7 @@ from agent_comms import coordinated_runtime as runtime from agent_comms import coordination_cohort as cohort -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.bus_publication import CommittedDelivery from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema @@ -108,7 +108,7 @@ def _projection( through_seq, registry=Registration(index.log.path.with_name("registry.json")), expected_participant_generation=store.participants.get( - ThreadIncarnation("fixture", owner.created_at).lookup + RecordedThreadIncarnation("fixture", owner.created_at).lookup ).participant_generation, expected_admission_generation=admission_generation, max_rows=max_rows, @@ -426,7 +426,7 @@ def test_fresh_gen2_selected_and_old_pending_obligation_are_scoped(tmp_path: Pat def test_fresh_gen2_only_selected_is_available(tmp_path: Path) -> None: comms, store, index, root_id = _root(tmp_path) try: - lookup = ThreadIncarnation("fixture", comms.registry.require("member000").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("member000").created_at).lookup store.participants.register(lookup, "member000", "member000", committed=True) store.participants.advance_generation(lookup, "member000", expected_generation=1) initial, assignment = _accepted(comms, store, root_id, "member000", "fresh") @@ -629,7 +629,7 @@ def test_saved_wire_awareness_preserves_passive_authority_and_rejects_incomplete passive = accept_delivery_cohort(comms.bus, root_id, notice.seq, store).value.assignments[0] assert isinstance(passive.lifecycle, PassiveAssignment) assert not passive.lifecycle.engageable and not passive.lifecycle.mode.active - observer = ThreadIncarnation("fixture", comms.registry.require("member001").created_at).lookup + observer = RecordedThreadIncarnation("fixture", comms.registry.require("member001").created_at).lookup assert not WakeAssignment.select( store.session._connection, where="recipient_lookup=? AND wire_seq=?", diff --git a/tests/test_ordinary_admission_rules.py b/tests/test_ordinary_admission_rules.py index b938d42a2f..e7c7b60c02 100644 --- a/tests/test_ordinary_admission_rules.py +++ b/tests/test_ordinary_admission_rules.py @@ -10,6 +10,7 @@ from agent_comms.store_files import _store_lock from agent_comms.thread_status import StoppedThreadStatus from test_acp_goal_input_authority import owner +from agent_comms.goal_waits import NoGoalWait, RecordedGoalWait @pytest.mark.asyncio @@ -108,7 +109,6 @@ def changed(thread): def test_routed_dependency_source_uses_existing_goal_and_wait_authorities(tmp_path): from agent_comms.channel_input_batch import SingleInputBatch from agent_comms.goal_presentation import GoalWaitTarget - from agent_comms.goal_waits import GoalWait from agent_comms.goals import Goal from agent_comms.threads import Thread from agent_comms.turn_goal_permission import ContinuationGoalPermission @@ -128,7 +128,7 @@ def test_routed_dependency_source_uses_existing_goal_and_wait_authorities(tmp_pa peer = comms.registry.require("peer") goal = Goal("Wait for peer", "goal") message = comms.messaging.send_message("peer", "owner", "Result") - wait = GoalWait( + wait = RecordedGoalWait( goal_id=goal.id, wait_id="wait", revision=0, @@ -157,10 +157,10 @@ def test_routed_dependency_source_uses_existing_goal_and_wait_authorities(tmp_pa # A keyless routed fixture has no captured input key to refuse on goal authority. rules.OrdinaryContextCheck( source=routed, owner=comms.registry.require("owner"), goal=goal, - wait=None, comms=comms + wait=NoGoalWait(), comms=comms ).require_valid() for source, current_wait, expected in ( - (dependency, None, rules.OrdinaryDependencyRule), + (dependency, NoGoalWait(), rules.OrdinaryDependencyRule), (dependency, replace(wait, wait_id="replacement"), rules.OrdinaryDependencyRule), (dependency, replace(wait, after_seq=message.seq), rules.OrdinaryWaitRule), ): diff --git a/tests/test_ordinary_nk_delivery.py b/tests/test_ordinary_nk_delivery.py index 0a4bd77261..88750a5307 100644 --- a/tests/test_ordinary_nk_delivery.py +++ b/tests/test_ordinary_nk_delivery.py @@ -12,7 +12,7 @@ import pytest from agent_comms import cohort_foreground, coordinated_runtime -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.assignment_states import CompletedAssignment, IgnoredAssignment from agent_comms.bus_publication import PRIVATE_WIRE_FIELD from agent_comms.child_process import ProcessIdentity @@ -67,7 +67,7 @@ async def test_normal_send_to_existing_foreground_executes_exact_nk( with Coordination(str(root / "coordination.sqlite3")) as store: install_private_cohort_schema(store) store.participants.register( - ThreadIncarnation("fixture", alpha.created_at).lookup, "alpha", "alpha", committed=True + RecordedThreadIncarnation("fixture", alpha.created_at).lookup, "alpha", "alpha", committed=True ) monkeypatch.setattr(cohort_foreground, "_trusted_package", lambda _: None) monkeypatch.setattr("agent_comms.native_pi._trusted_package", lambda _: None) @@ -103,7 +103,7 @@ def ready(beta): assert initial.message == message expected_n = 1 if target == "beta" else 2 assert len(initial.audience.recipients) == expected_n - lookup = ThreadIncarnation("fixture", recipient[0].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", recipient[0].created_at).lookup with Coordination(str(root / "coordination.sqlite3")) as store: db = store.session._connection assert ( diff --git a/tests/test_outbound_transcript_source.py b/tests/test_outbound_transcript_source.py index c7905d3cf5..3f93e8ecc1 100644 --- a/tests/test_outbound_transcript_source.py +++ b/tests/test_outbound_transcript_source.py @@ -1,7 +1,7 @@ """Whole conversations derive their original frozen wire sources, in both directions.""" from agent_comms import invoke_tool -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.comms import wire from agent_comms.coordination_errors import StaleRevision from agent_comms.assignment_states import IgnoredAssignment @@ -28,7 +28,7 @@ def test_original_read_contention_is_unavailable_then_same_receipts_return(tmp_p root, _root_id, comms, initial, _people = _root(tmp_path) database = root / "coordination.sqlite3" - lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup original = NotificationAssignment.read(root, (initial,)).resolve(root) wire_before = comms.bus.log.path.read_bytes() assert original @@ -67,7 +67,7 @@ def test_gateway_uses_original_snapshot_and_one_metadata_read(tmp_path, monkeypa root, _root_id, comms, _initial, _people = _root(tmp_path) database = root / "coordination.sqlite3" - lookup = ThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup commit_requested = threading.Event() committed = threading.Event() errors, statements = [], [] @@ -186,7 +186,7 @@ def test_frozen_unhandled_delivery_projects_recovery_without_claiming(tmp_path): notices = comms.views.message_notifications((message,))[message.seq,message.message_id] beta = next(notice for notice in notices if notice.recipient=='beta') assert beta.state=='Sent' and beta.detail == '' - assert beta.recipient_identity.recipient_lookup==ThreadIncarnation("fixture", comms.registry.require('beta').created_at).lookup + assert beta.recipient_identity.recipient_lookup==RecordedThreadIncarnation("fixture", comms.registry.require('beta').created_at).lookup with Coordination(str(root/'coordination.sqlite3')) as store: assert WakeAssignment.select(store.session._connection)==before assert not any(row.wire_seq==message.seq for row in before) @@ -237,7 +237,7 @@ def test_publication_intent_joins_original_sender_and_target_only(tmp_path): # comms.root, comms.registry.require("beta"), comms.bus.log ) original = tuple(source.rows("w.seq=?", (reply.seq,))) - lookup = ThreadIncarnation("fixture", source.recipient.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", source.recipient.created_at).lookup intent = TurnRouting((initial.message.reference,), MessageRoute("beta", ("sender", "alpha"))) # A later send can fail; this first committed subset stays a valid relation. intent.require_publications(original, lookup) @@ -294,7 +294,7 @@ def test_original_target_handling_revokes_open_sender_read_without_new_message( before = comms.transcripts.capture_page_read("sender") sender_page = before.read() target = comms.registry.require("beta") - lookup = ThreadIncarnation("fixture", target.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", target.created_at).lookup receipts = comms.views.recent_notifications("sender") beta = next(item for item in receipts if item.recipient_identity.recipient_lookup == lookup) assert beta.message.reference == message.reference and beta.state == "Sent" @@ -400,7 +400,7 @@ def observed(log, *, shared=False): assert len(barriers) == 1 assert view.read_identity.receipt_frontier.sequence == initial.message.seq assert view.notifications[0].message.reference == initial.message.reference - assert view.notifications[0].recipient_identity.recipient_lookup == ThreadIncarnation("fixture", view.read_identity.thread.created_at).lookup + assert view.notifications[0].recipient_identity.recipient_lookup == RecordedThreadIncarnation("fixture", view.read_identity.thread.created_at).lookup def test_published_source_binding_keeps_exact_original_request(tmp_path): # noqa: F811 diff --git a/tests/test_owner_partial_retirement.py b/tests/test_owner_partial_retirement.py index 8afe8047fb..f10529a76f 100644 --- a/tests/test_owner_partial_retirement.py +++ b/tests/test_owner_partial_retirement.py @@ -37,6 +37,7 @@ def __init__(self, mode, monkeypatch): last_seen={t.name: 1.0 for t in self.originals}, aliases={}, owner_generations={t.name: 4 for t in self.originals}, admission_generations={t.name: 5 for t in self.originals}, + by_lookup={t.incarnation.lookup: t for t in self.originals}, ) self.registry = SimpleNamespace(snapshot=lambda: self.snapshot) self.releases = SimpleNamespace(read=lambda: {}) diff --git a/tests/test_owner_restart_handoff.py b/tests/test_owner_restart_handoff.py index daad639044..230084db11 100644 --- a/tests/test_owner_restart_handoff.py +++ b/tests/test_owner_restart_handoff.py @@ -48,6 +48,7 @@ def stopped(): threads={thread.name: thread}, statuses={thread.name: StoppedThreadStatus()}, last_seen={thread.name: 1.0}, aliases={}, owner_generations={thread.name: 4}, admission_generations={thread.name: 5}, + by_lookup={thread.incarnation.lookup: thread}, ) return thread, snapshot diff --git a/tests/test_private_bus_checkpoint.py b/tests/test_private_bus_checkpoint.py index 0eb8a86826..3b4f710e35 100644 --- a/tests/test_private_bus_checkpoint.py +++ b/tests/test_private_bus_checkpoint.py @@ -10,7 +10,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.envelope_claim_transitions import ExistingFileClaim @@ -127,8 +127,8 @@ def test_marker_bound_complete_addressed_pages(tmp_path: Path) -> None: one = comms.messaging.send_initial_cohort("sender", "#team", "@Alice selected; Bob no-wake") two = comms.messaging.send_initial_cohort("sender", "outsider", "not their audience") three = comms.messaging.send_initial_cohort("sender", "#team", "@Bob selected; Alice no-wake") - alice = ThreadIncarnation("fixture", 17002.0).lookup - bob = ThreadIncarnation("fixture", 17003.0).lookup + alice = RecordedThreadIncarnation("fixture", 17002.0).lookup + bob = RecordedThreadIncarnation("fixture", 17003.0).lookup witness, rows, more = _page(comms, alice, limit=1) assert (witness.root_id, witness.through_seq, [r.message.seq for r in rows], more) == ( root_id, @@ -138,7 +138,7 @@ def test_marker_bound_complete_addressed_pages(tmp_path: Path) -> None: ) assert [r.message.seq for r in _page(comms, alice, after=one.seq)[1]] == [three.seq] assert [r.message.seq for r in _page(comms, bob)[1]] == [one.seq, three.seq] - assert _page(comms, ThreadIncarnation("fixture", 17004.0).lookup)[1][0].message.seq == two.seq + assert _page(comms, RecordedThreadIncarnation("fixture", 17004.0).lookup)[1][0].message.seq == two.seq assert witness.offset == comms.bus.log.path.stat().st_size assert witness.latest_source_seq == three.seq assert witness.offset > 0 @@ -186,7 +186,7 @@ def test_crash_after_bus_fsync_before_checkpoint_cold_recovers(tmp_path: Path, m comms, _ = _root(tmp_path) first = comms.messaging.send_initial_cohort("sender", "Alice", "first") - saved = _page(comms, ThreadIncarnation("fixture", 17002.0).lookup)[0] + saved = _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup)[0] with monkeypatch.context() as patch: patch.setattr( checkpoint, @@ -197,7 +197,7 @@ def test_crash_after_bus_fsync_before_checkpoint_cold_recovers(tmp_path: Path, m comms.messaging.send_initial_cohort("sender", "Alice", "second") bus = comms.bus.log.path assert bus.stat().st_size > saved.offset - witness, rows, more = _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) + witness, rows, more = _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup) assert [row.message.seq for row in rows] == [first.seq, first.seq + 1] assert witness.offset == bus.stat().st_size and not more assert witness.digest != saved.digest @@ -270,7 +270,7 @@ def test_early_prefix_edit_plus_complete_lagged_suffix_denied(tmp_path: Path, mo stream.flush() os.fsync(stream.fileno()) with pytest.raises(RelationViolationError, match="certified prefix changed"): - _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) + _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup) assert bus.read_bytes().endswith(b"\n") @@ -328,7 +328,7 @@ def test_checkpoint_faults_deny_without_rebuild(tmp_path: Path, fault: str) -> N else: db.execute("UPDATE prefix_certificate SET tail=?", ("0" * 64,)) with pytest.raises(RelationViolationError): - _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) + _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup) if fault not in {"schema", "ahead", "root", "tail", "corrupt", "missing"}: assert db_path.read_bytes() == original @@ -342,13 +342,13 @@ def test_unknown_public_writer_cannot_make_initial_absent_audience(tmp_path: Pat stream.flush() os.fsync(stream.fileno()) with pytest.raises(RelationViolationError, match="Unattested public initial"): - _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) + _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup) def test_deleted_sql_index_cannot_certify_absent_selected_source(tmp_path: Path) -> None: comms, _ = _root(tmp_path) source = comms.messaging.send_initial_cohort("sender", "Alice", "mandatory selected") - lookup = ThreadIncarnation("fixture", 17002.0).lookup + lookup = RecordedThreadIncarnation("fixture", 17002.0).lookup assert [item.message.seq for item in _page(comms, lookup)[1]] == [source.seq] with sqlite3.connect(comms.root / "private_bus_checkpoint.sqlite3") as db: db.execute("DELETE FROM addressed WHERE lookup=? AND seq=?", (lookup, source.seq)) @@ -374,7 +374,7 @@ def test_sql_index_change_plus_complete_bus_suffix_still_denies( with sqlite3.connect(comms.root / "private_bus_checkpoint.sqlite3") as db: db.execute("DELETE FROM addressed") with pytest.raises(RelationViolationError, match="index seal changed"): - _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) + _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup) def test_sql_index_change_during_page_is_not_exhaustiveness(tmp_path: Path, monkeypatch) -> None: @@ -398,7 +398,7 @@ def mutate_after_original_read(cursor): # Mutate after original row resolution, without assuming a second connection. monkeypatch.setattr(checkpoint.DeliverySources, "read", mutate_after_original_read) with pytest.raises(RelationViolationError, match="index seal changed"): - _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) + _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup) @pytest.mark.parametrize("phase", ["before_db_commit", "after_db_commit"]) @@ -424,7 +424,7 @@ def test_pending_marker_recovers_only_after_canonical_cold_parse( with pytest.raises(RelationViolationError, match="outcome UNKNOWN"): comms.messaging.send_initial_cohort("sender", "Alice", "second") monkeypatch.undo() - witness, rows, more = _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) + witness, rows, more = _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup) assert [item.message.seq for item in rows] == [1, 2] assert witness.through_seq == 2 and not more assert isinstance(comms.bus.log._private_marker_unlocked().seal, checkpoint.FinalSeal) @@ -445,7 +445,7 @@ def test_pending_checkpoint_does_not_repair_corrupt_sql_schema(tmp_path: Path, m with sqlite3.connect(comms.root / "private_bus_checkpoint.sqlite3") as db: db.execute("DROP TABLE addressed") with pytest.raises(RelationViolationError, match="schema is unavailable"): - _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) + _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup) def test_over_1000_initials_and_8mib_complete_lookup(tmp_path: Path, monkeypatch) -> None: @@ -457,13 +457,13 @@ def test_over_1000_initials_and_8mib_complete_lookup(tmp_path: Path, monkeypatch Message("sender", "#team", f"@Alice item-{index} " + "x" * 7900, InfoKind) ) assert comms.bus.log.path.stat().st_size > 8 * 1024 * 1024 - lookup = ThreadIncarnation("fixture", 17002.0).lookup + lookup = RecordedThreadIncarnation("fixture", 17002.0).lookup warm_start = time.monotonic() witness, page, more = _page(comms, lookup, after=900, limit=100) warm_ms = (time.monotonic() - warm_start) * 1000 assert (witness.through_seq, len(page), page[0].message.seq, more) == (1002, 100, 901, True) assert [row.message.seq for row in _page(comms, lookup, after=1000)[1]] == [1001, 1002] - assert _page(comms, ThreadIncarnation("fixture", 17004.0).lookup)[1] == () + assert _page(comms, RecordedThreadIncarnation("fixture", 17004.0).lookup)[1] == () # Existing capped readers still fail closed; only the explicit certified # source API supplies a complete-prefix+bounded-page alternative. with ( @@ -501,7 +501,7 @@ def test_reopened_root_preserves_certified_source_and_supports_append( original = comms.bus.log.path.read_bytes() inode = comms.bus.log.path.stat().st_ino reopened = Comms(comms.root) - witness = _page(reopened, ThreadIncarnation("fixture", 17002.0).lookup)[0] + witness = _page(reopened, RecordedThreadIncarnation("fixture", 17002.0).lookup)[0] assert (witness.root_id, witness.through_seq, witness.offset) == ( root_id, two.seq, @@ -509,10 +509,10 @@ def test_reopened_root_preserves_certified_source_and_supports_append( ) assert comms.bus.log.path.read_bytes() == original assert comms.bus.log.path.stat().st_ino == inode - assert [r.message.seq for r in _page(reopened, ThreadIncarnation("fixture", 17002.0).lookup)[1]] == [one.seq] + assert [r.message.seq for r in _page(reopened, RecordedThreadIncarnation("fixture", 17002.0).lookup)[1]] == [one.seq] three = reopened.messaging.send_initial_cohort("sender", "Alice", "after installation") assert three.seq == two.seq + 1 - assert [r.message.seq for r in _page(reopened, ThreadIncarnation("fixture", 17002.0).lookup)[1]] == [ + assert [r.message.seq for r in _page(reopened, RecordedThreadIncarnation("fixture", 17002.0).lookup)[1]] == [ one.seq, three.seq, ] @@ -528,7 +528,7 @@ def test_warm_witness_rejects_changed_revision_even_with_complete_row(tmp_path: stream.flush() os.fsync(stream.fileno()) with pytest.raises(RelationViolationError, match="prefix tail changed"): - _page(comms, ThreadIncarnation("fixture", 17002.0).lookup) + _page(comms, RecordedThreadIncarnation("fixture", 17002.0).lookup) def test_shared_reader_hands_lagged_suffix_to_exclusive_repair(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_private_checkpoint_cursor_integration.py b/tests/test_private_checkpoint_cursor_integration.py index 11089c1425..ad22e8421c 100644 --- a/tests/test_private_checkpoint_cursor_integration.py +++ b/tests/test_private_checkpoint_cursor_integration.py @@ -12,7 +12,7 @@ import pytest from agent_comms import coordinated_runtime as runtime -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -72,7 +72,7 @@ def _root(tmp_path: Path): install_private_response_schema(store) install_native_runtime_schema(store) install_prompt_binding_schema(store) - lookup = ThreadIncarnation("fixture", people[1].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[1].created_at).lookup store.participants.register(lookup, "alpha", "alpha", committed=True) accept_delivery_cohort(comms.bus, root_id, first.seq, store) return root, root_id, comms, first, lookup diff --git a/tests/test_private_human_ingress.py b/tests/test_private_human_ingress.py index 0d610e181f..5621f00288 100644 --- a/tests/test_private_human_ingress.py +++ b/tests/test_private_human_ingress.py @@ -11,7 +11,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.bus_publication import HumanOrigin from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms, wire @@ -39,7 +39,7 @@ def _root(tmp_path: Path) -> tuple[Comms, Coordination, str, dict[str, str]]: lookups: dict[str, str] = {} for name, created_at in (("alice", 17001.0), ("bob", 17002.0)): comms.registry.register(Thread(name, frozenset({"team"}), str(root), created_at=created_at)) - lookups[name] = ThreadIncarnation("fixture", created_at).lookup + lookups[name] = RecordedThreadIncarnation("fixture", created_at).lookup store = Coordination(str(root / "coordination.sqlite"), clock_ms=lambda: 9999) install_private_cohort_schema(store) for name in lookups: diff --git a/tests/test_private_idle_drain.py b/tests/test_private_idle_drain.py index 3d3044c3c4..478836e734 100644 --- a/tests/test_private_idle_drain.py +++ b/tests/test_private_idle_drain.py @@ -10,7 +10,7 @@ from agent_comms import acp, cohort_foreground, coordinated_runtime, native_pi from agent_comms import agent_events as events -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.coordinator import Coordination from agent_comms.coordination_cohort import next_sealed_assignment from agent_comms.native_source_cursor import NativeSourceCursor @@ -200,7 +200,7 @@ def _covered_session(tmp_path): comms.registry.declare(other) with Coordination(comms.root / "coordination.sqlite3") as store: store.participants.register( - ThreadIncarnation("fixture", other.created_at).lookup, other.name, other.name, committed=True, + RecordedThreadIncarnation("fixture", other.created_at).lookup, other.name, other.name, committed=True, ) comms.messaging.send_initial_cohort("sender", other.name, "another recipient's original source") return comms, agent, root_id @@ -506,7 +506,7 @@ async def observed(*args): # Actual recovery/reassignment of beta must still invalidate the # observation without a wire append or a provider request. beta = comms.registry.require("beta") - person = store.participants.get(ThreadIncarnation("fixture", beta.created_at).lookup) + person = store.participants.get(RecordedThreadIncarnation("fixture", beta.created_at).lookup) store.participants.advance_generation( person.lookup, beta.name, expected_generation=person.participant_generation, ) @@ -525,11 +525,11 @@ async def observed(*args): assert first.seq < second.seq with Coordination(comms.root / "coordination.sqlite3") as store: await cohort_foreground._accept_visible_deliveries( - comms.bus, _root_id, store.session.path, ThreadIncarnation("fixture", beta.created_at).lookup, 0, + comms.bus, _root_id, store.session.path, RecordedThreadIncarnation("fixture", beta.created_at).lookup, 0, owner_name=beta.name, ) pending = next_sealed_assignment( - store, ThreadIncarnation("fixture", beta.created_at).lookup, beta.name, + store, RecordedThreadIncarnation("fixture", beta.created_at).lookup, beta.name, ) assert pending.wire_seq == second.seq assert await agent.inputs._observe_private_revision("beta", _root_id) != before @@ -564,7 +564,7 @@ async def paused(*args): @pytest.mark.asyncio async def test_sealed_cohorts_are_not_reaccepted_or_rewritten(tmp_path, monkeypatch): root, root_id, comms, original, people = _root(tmp_path, direct=True) - lookup = ThreadIncarnation("fixture", people[2].created_at).lookup + lookup = RecordedThreadIncarnation("fixture", people[2].created_at).lookup accepted = [] accept = cohort_foreground.accept_delivery_cohort diff --git a/tests/test_private_nk_entrypoint.py b/tests/test_private_nk_entrypoint.py index a0ba230e34..37781f2875 100644 --- a/tests/test_private_nk_entrypoint.py +++ b/tests/test_private_nk_entrypoint.py @@ -20,7 +20,7 @@ private_nk_entrypoint, worker, ) -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ParentedProcess, ProcessIdentity from agent_comms.comms import Comms, wire from agent_comms.coordination_cohort import accept_delivery_cohort @@ -551,7 +551,7 @@ def test_late_private_owner_can_accept_first_message_before_worker_spawn(tmp_pat comms.owners.pin_private_nk_launch(root, root_id, tmp_path) comms.registry.declare(Thread("late-owner", frozenset(), str(tmp_path))) late = comms.registry.require("late-owner") - lookup = ThreadIncarnation("fixture", late.created_at).lookup + lookup = RecordedThreadIncarnation("fixture", late.created_at).lookup message = comms.messaging.send_initial_cohort("sender", "late-owner", "fresh private task") with Coordination(str(root / "coordination.sqlite3")) as store: participant = store.participants.get(lookup) diff --git a/tests/test_project_resume.py b/tests/test_project_resume.py index 4a36dbaf43..c4e8470a41 100644 --- a/tests/test_project_resume.py +++ b/tests/test_project_resume.py @@ -11,7 +11,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire from agent_comms.coordination_errors import StaleFence @@ -187,7 +187,7 @@ async def wait(self): # The turn that changed project recorded its result: this owner's # coordination pointer is free, so later messages can start turns. with Coordination(str(comms.root / "coordination.sqlite3")) as store: - participant = store.participants.get(ThreadIncarnation("fixture", thread.created_at).lookup) + participant = store.participants.get(RecordedThreadIncarnation("fixture", thread.created_at).lookup) assert participant.pointer.execution_id is None # The second message's turn started in the new project. assert str(second) in json.dumps(native.provider.requests[-1]) diff --git a/tests/test_read_ledger.py b/tests/test_read_ledger.py index 2177faa0ec..b71946cd77 100644 --- a/tests/test_read_ledger.py +++ b/tests/test_read_ledger.py @@ -8,6 +8,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms, wire from agent_comms.threads import Thread +from agent_comms.thread_identity import RecordedThreadIncarnation def prepared(root): @@ -22,9 +23,8 @@ def test_conversation_persisted_pairs_decode_once_without_an_encode_round_trip() from agent_comms.field_codec import FieldCodec from agent_comms.read_basis import Conversation - from agent_comms.thread_identity import ThreadIncarnation - conversation = Conversation(participants=(ThreadIncarnation("alice", 1.0),)) + conversation = Conversation(participants=(RecordedThreadIncarnation("alice", 1.0),)) encoded = conversation.to_wire() assert encoded == {"target": "", "participants": [["alice", 1.0]]} with patch.object(FieldCodec, "encode", side_effect=AssertionError("Already decoded")): diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 65e0ee4072..eb95df9a35 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -37,7 +37,7 @@ def test_recipient_index_is_built_once_and_reused_for_small_window(tmp_path): with comms.bus.log.certified_read() as source: original = source.delivery(sent.seq) snapshot = comms.registry.snapshot() - assert FieldCodec.encode(snapshot)["threads"] + assert snapshot.threads wanted = snapshot.threads["peer-7"] lookup = wanted.incarnation.lookup recipient = FrozenRecipient(lookup, wanted.name) @@ -87,10 +87,14 @@ def test_creation_lookup_is_cached_on_the_renamable_incarnation(tmp_path): assert renamed.incarnation.lookup == first assert FieldCodec.encode(incarnation) == {"name": "worker", "created_at": 1_700_000_000.0} deleted = ThreadIncarnation.deleted("worker") - historical = ThreadIncarnation("historical", 0.0) + historical = UnrecordedThreadIncarnation("historical") assert isinstance(incarnation, RecordedThreadIncarnation) assert isinstance(deleted, DeletedThreadIncarnation) assert isinstance(historical, UnrecordedThreadIncarnation) + assert FieldCodec.decode(ThreadIncarnation, FieldCodec.encode(deleted)) == deleted + assert FieldCodec.decode(ThreadIncarnation, FieldCodec.encode(historical)) == historical + with pytest.raises(ValueError, match="declared member"): + FieldCodec.decode(RecordedThreadIncarnation, FieldCodec.encode(historical)) assert not hasattr(deleted, "lookup") and not hasattr(historical, "lookup") with pytest.raises(ValueError, match="positive recorded birth"): deleted.require_recorded() @@ -124,15 +128,16 @@ def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_own assert ledger.historical_displayed_recipient( SimpleNamespace(seq=7), FrozenRecipient("wrong-lookup", "old"), archived ) == () - with pytest.raises(RelationViolationError, match="creation identity is unrecorded"): - replace(source, threads={ - **source.threads, - "second": replace(source.threads["second"], created_at=0.0), - }) - unrecorded = replace(recorded, threads={ - **recorded.threads, - "second": replace(recorded.threads["second"], created_at=0.0), - }) + with pytest.raises(ValueError, match="positive recorded birth"): + replace(source.threads["second"], created_at=0.0) + from agent_comms.thread_provenance import UnrecordedThreadProvenance + + original = recorded.threads["second"] + zero = UnrecordedThreadProvenance( + name=original.name, tags=original.tags, worktree=original.worktree, + created_at=0.0, session_file=original.session_file, title=original.title, + ) + unrecorded = replace(recorded, threads={**recorded.threads, "second": zero}) assert isinstance(unrecorded.threads["second"].incarnation, UnrecordedThreadIncarnation) assert not hasattr(unrecorded.threads["second"].incarnation, "lookup") diff --git a/tests/test_retained_native_tool_latency.py b/tests/test_retained_native_tool_latency.py index 9d7381db19..42e5cd4c6b 100644 --- a/tests/test_retained_native_tool_latency.py +++ b/tests/test_retained_native_tool_latency.py @@ -14,7 +14,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import AttachedChild from agent_comms.coordinated_runtime import SelectedExecution from agent_comms.coordination_errors import CoordinationError @@ -67,7 +67,7 @@ async def test_installed_retained_native_read_round_preserves_original_custody(t assert original.size >= 40 * 1024**2, "This acceptance requires representative retained history" root, root_id, comms, initial, _ = _root(tmp_path, direct=True, body="Perform three isolated fixture reads, then answer once.") owner = comms.registry.require("beta") - directory = root / "native-sessions" / ThreadIncarnation("fixture", owner.created_at).lookup + directory = root / "native-sessions" / RecordedThreadIncarnation("fixture", owner.created_at).lookup directory.mkdir(parents=True, mode=0o700) saved = directory / source.name shutil.copyfile(source, saved) diff --git a/tests/test_roster_restoration.py b/tests/test_roster_restoration.py index 2700841c0b..26a7dfaee2 100644 --- a/tests/test_roster_restoration.py +++ b/tests/test_roster_restoration.py @@ -5,7 +5,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.display_order import LastActivityThreadSort @@ -156,7 +156,7 @@ def test_private_restoration_allows_new_cohort_without_starting_old_subscribers( with Coordination(str(current.root / "coordination.sqlite3")) as store: install_private_cohort_schema(store) live_before = store.participants.register( - ThreadIncarnation("fixture", live.created_at).lookup, live.name, live.name, committed=True + RecordedThreadIncarnation("fixture", live.created_at).lookup, live.name, live.name, committed=True ).value if repair_existing: current.registry.restore_stopped(source, (missing.name,)) @@ -173,9 +173,9 @@ def test_private_restoration_allows_new_cohort_without_starting_old_subscribers( assert snapshot.statuses[missing.name] == StoppedThreadStatus() assert snapshot.threads[missing.name].pid == 0 assert snapshot.threads[live.name] == live - assert store.participants.get(ThreadIncarnation("fixture", live.created_at).lookup) == live_before + assert store.participants.get(RecordedThreadIncarnation("fixture", live.created_at).lookup) == live_before assert ( - store.participants.get(ThreadIncarnation("fixture", missing.created_at).lookup).pointer.execution_id is None + store.participants.get(RecordedThreadIncarnation("fixture", missing.created_at).lookup).pointer.execution_id is None ) if bus_before is not None: assert current.bus.log.path.read_bytes() == bus_before diff --git a/tests/test_route_current_sources.py b/tests/test_route_current_sources.py index 71b5b59bb6..d767229b3e 100644 --- a/tests/test_route_current_sources.py +++ b/tests/test_route_current_sources.py @@ -59,9 +59,18 @@ def test_zero_birth_accepts_public_format_but_refuses_assigned_source(tmp_path): viewer = c.messaging.user_identity(str(tmp_path)).name original = c.messaging.send_message('sender', viewer, 'assigned source') with c.bus.snapshot(viewer=viewer) as (basis, source): - threads = dict(basis.registry.threads) - threads['sender'] = replace(threads['sender'], created_at=0.0) - recorded = replace(basis.registry, threads=threads) + from agent_comms.registry_provenance import RegistryProvenance + from agent_comms.thread_provenance import UnrecordedThreadProvenance + + recorded = RegistryProvenance.capture(basis.registry) + threads = dict(recorded.threads) + original_sender = threads['sender'] + threads['sender'] = UnrecordedThreadProvenance( + name=original_sender.name, tags=original_sender.tags, + worktree=original_sender.worktree, created_at=0.0, + session_file=original_sender.session_file, title=original_sender.title, + ) + recorded = replace(recorded, threads=threads) scope = DeliveryScope.capture(viewer, recorded, basis.catalog) # The existing public-record owner supplies the unstamped saved format; # a current send always has an assigned stamp, even publish_ordinary. diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 5b853d8814..728dd190e6 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -22,6 +22,7 @@ from agent_comms.thread_management import ForkSpec from agent_comms.threads import Thread from delivery_owner_fixture import canonical_agent +from agent_comms.thread_identity import RecordedThreadIncarnation @pytest.fixture @@ -49,14 +50,14 @@ def test_owner_cursor_scope_rebases_only_attachment_session_alias(): decode_updates, encode_updates, ) - from agent_comms.thread_identity import AdmissionIdentity, ThreadIncarnation + from agent_comms.thread_identity import AdmissionIdentity fact = CursorAdvancedUpdate( CursorEnvelope( CursorScope( "canonical", "a" * 32, - AdmissionIdentity(ThreadIncarnation("canonical", 1000.0), 3), + AdmissionIdentity(RecordedThreadIncarnation("canonical", 1000.0), 3), 1234, ), 7, diff --git a/tests/test_s1_event_behavior.py b/tests/test_s1_event_behavior.py index 263bce0c7c..8c243c7329 100644 --- a/tests/test_s1_event_behavior.py +++ b/tests/test_s1_event_behavior.py @@ -25,6 +25,8 @@ from agent_comms.transcript_updates import TurnTranscriptUpdate from test_backend_native_lifecycle import native_backend as native_backend from stored_inputs import stored_inputs +from agent_comms.goal_waits import NoGoalWait +from agent_comms.goal_waits import RecordedGoalWait pytestmark = pytest.mark.usefixtures("fake_native_catalog") @@ -223,7 +225,7 @@ async def observe(session, event, **kwargs): await progress.consume(ae.StreamSettled()) assert execution.finish_event.is_set() assert comms.registry.require(execution.thread_name).turn_lease == execution.turn_lease - assert comms.goals.goal_wait("waiting") is not None + assert isinstance(comms.goals.goal_wait("waiting"), RecordedGoalWait) diagnostic = {"elapsed_ms": 3} if not value.successful else None await progress.consume( ae.Done( @@ -243,7 +245,7 @@ async def observe(session, event, **kwargs): ) is None ) - assert comms.goals.goal_wait("waiting") is not None + assert isinstance(comms.goals.goal_wait("waiting"), RecordedGoalWait) if not value.successful: with pytest.raises(RequestError, match="Internal error"): await progress.publish_result() @@ -255,7 +257,7 @@ async def observe(session, event, **kwargs): execution.session_id, execution.turn_lease, ) - assert comms.goals.goal_wait("waiting") is None + assert isinstance(comms.goals.goal_wait("waiting"), NoGoalWait) assert comms.registry.require("waiting").goal.state.active assert observed.index(ae.StreamSettled) < observed.index(ae.Done) if issubclass(case, SettingsCase): @@ -321,7 +323,7 @@ def capture_release(fence): execution.turn_lease, ) monkeypatch.setattr(runner.effects, "_emit_event", original) - assert comms.goals.goal_wait("waiting") is None + assert isinstance(comms.goals.goal_wait("waiting"), NoGoalWait) goal = comms.registry.require("waiting").goal assert len(released) == 1 and release_waits(released[0]) == () assert comms.registry.require("waiting").goal == goal @@ -455,7 +457,7 @@ async def observe(session_id, event, **kwargs): StandbyGoalAction(expect=GoalPrecondition(goal_id=goal.id), wait_for=(name,)), ) if isinstance(event, ae.CompactionEnd) and event.reason is ManualCompactionReason: - assert comms.goals.goal_wait("waiting") is not None + assert isinstance(comms.goals.goal_wait("waiting"), RecordedGoalWait) monkeypatch.setattr(owner, "_emit_event", observe) try: @@ -466,7 +468,7 @@ async def observe(session_id, event, **kwargs): rows = [json.loads(line) for line in native.session.read_text().splitlines()] assert len([row for row in rows if row.get("type") == "compaction"]) == 1 assert comms.registry.require(name).active_turn is None - assert comms.goals.goal_wait("waiting") is None + assert isinstance(comms.goals.goal_wait("waiting"), NoGoalWait) assert comms.registry.require("waiting").goal.state.active terminal = next(i for i, event in enumerate(observed) if isinstance(event, TurnTranscriptUpdate) and not event.state.busy) @@ -541,7 +543,7 @@ async def observed(session_id, event, **kwargs): StandbyGoalAction(expect=GoalPrecondition(goal_id=goal.id), wait_for=(name,)), ) if isinstance(event, TurnTranscriptUpdate) and not event.state.busy: - assert comms.goals.goal_wait("waiting") is not None + assert isinstance(comms.goals.goal_wait("waiting"), RecordedGoalWait) assert comms.registry.require(name).active_turn is None monkeypatch.setattr(owner, "_emit_event", observed) @@ -550,7 +552,7 @@ async def observed(session_id, event, **kwargs): await owner.turns.prompt_owned( session.session_id, [TextContentBlock(type="text", text="!relay #comms relay body")] ) - assert comms.goals.goal_wait("waiting") is None + assert isinstance(comms.goals.goal_wait("waiting"), NoGoalWait) assert comms.registry.require("waiting").goal.state.active assert sum(isinstance(event, TurnTranscriptUpdate) and not event.state.busy for event in events) == 1 diff --git a/tests/test_selected_answer_forms.py b/tests/test_selected_answer_forms.py index bc45392c00..8b31e94212 100644 --- a/tests/test_selected_answer_forms.py +++ b/tests/test_selected_answer_forms.py @@ -18,7 +18,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.cohort_schema import install_private_cohort_schema from agent_comms.comms import Comms @@ -60,7 +60,7 @@ def alice_batch(*sent: tuple[str, str]): ) for message in messages: accept_delivery_cohort(comms.bus, root_id, message.seq, store) - lookup = ThreadIncarnation("fixture", comms.registry.require("Alice").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("Alice").created_at).lookup assignments = sealed_cohort_assignments(store, lookup) yield SelectedSourceBatch.first_route(tuple( SelectedSource(assignment.assignment_id, store.assignments, delivery) diff --git a/tests/test_selected_execution_native.py b/tests/test_selected_execution_native.py index f501ccf3a0..d99aadf7e2 100644 --- a/tests/test_selected_execution_native.py +++ b/tests/test_selected_execution_native.py @@ -11,7 +11,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.coordinated_runtime import SelectedExecution from agent_comms.coordination_cohort import accept_delivery_cohort from agent_comms.coordination_errors import IdentityConflict @@ -242,7 +242,7 @@ def log_message(self, *_args): proofs = read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", owner.created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", owner.created_at).lookup, source_seq=initial.message.seq, ) assert len(proofs) == 1 + triage @@ -267,7 +267,7 @@ def log_message(self, *_args): read_historical_native_inputs( store, wire_root_id=root_id, - recipient_lookup=ThreadIncarnation("fixture", owner.created_at).lookup, + recipient_lookup=RecordedThreadIncarnation("fixture", owner.created_at).lookup, source_seq=old_seq, ) == () diff --git a/tests/test_selected_owner_followup.py b/tests/test_selected_owner_followup.py index 4c28ba7f5d..be9e5e6995 100644 --- a/tests/test_selected_owner_followup.py +++ b/tests/test_selected_owner_followup.py @@ -110,7 +110,9 @@ async def native(*args, **kwargs): assert comms.registry.require("beta").goal == before_goal # The completed selected reply consumes its certified dependency wait; # a queued owner input keeps its admission/goal authority across that change. - assert comms.goals.goal_wait("beta") == (None if goal_mode == "standby" else before_wait) + from agent_comms.goal_waits import NoGoalWait + + assert comms.goals.goal_wait("beta") == (NoGoalWait() if goal_mode == "standby" else before_wait) assert not (comms.root / "goal-private").exists() finally: release.set() diff --git a/tests/test_selected_source_batch.py b/tests/test_selected_source_batch.py index f50826d4f8..fdffa6436f 100644 --- a/tests/test_selected_source_batch.py +++ b/tests/test_selected_source_batch.py @@ -8,7 +8,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.coordination_cohort import accept_delivery_cohort, pending_sealed_assignments @@ -37,7 +37,7 @@ async def test_original_pending_wave_has_one_fenced_input_and_late_arrivals_stay model="openai-codex/gpt-6-sol", )) root_id = comms.messaging.initialize_private_initial_protocol() - lookup = ThreadIncarnation("fixture", comms.registry.require("receiver").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("receiver").created_at).lookup with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register(lookup, "receiver", "receiver", committed=True) @@ -147,7 +147,7 @@ async def test_a_mixed_route_wave_turns_one_route_at_a_time(tmp_path): model="openai-codex/gpt-6-sol", )) root_id = comms.messaging.initialize_private_initial_protocol() - lookup = ThreadIncarnation("fixture", comms.registry.require("receiver").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("receiver").created_at).lookup with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register(lookup, "receiver", "receiver", committed=True) @@ -191,7 +191,7 @@ async def test_terminal_triage_failure_records_context_and_fails_its_claims(tmp_ model="openai-codex/gpt-6-sol", )) root_id = comms.messaging.initialize_private_initial_protocol() - lookup = ThreadIncarnation("fixture", comms.registry.require("receiver").created_at).lookup + lookup = RecordedThreadIncarnation("fixture", comms.registry.require("receiver").created_at).lookup session = NativeSessionIdentity("native-session", str(tmp_path / "session.jsonl")) with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register(lookup, "receiver", "receiver", committed=True) diff --git a/tests/test_selected_tool_runner_hook.py b/tests/test_selected_tool_runner_hook.py index ea39edeaed..b65496b8f8 100644 --- a/tests/test_selected_tool_runner_hook.py +++ b/tests/test_selected_tool_runner_hook.py @@ -10,7 +10,7 @@ import pytest from agent_comms import coordinated_runtime as runtime -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.acp import CommsAgent from agent_comms.comms import Comms from agent_comms.coordination_errors import IdentityConflict, StaleFence @@ -213,7 +213,7 @@ async def observed(*args, **kwargs): admission, owner, session_dir, input_id = bound[0] assert owner == "beta" and admission.source_seq == initial.message.seq assert admission.source_message_id == initial.message.message_id - assert admission.recipient_lookup == ThreadIncarnation("fixture", people[2].created_at).lookup + assert admission.recipient_lookup == RecordedThreadIncarnation("fixture", people[2].created_at).lookup assert admission.wire_root_id == root_id and admission.attempt_ordinal == 1 assert input_id == kwargs_seen[0]["input_id"] and session_dir == kwargs_seen[0]["session_dir"] assert "selected_claimed_write at most once" in calls[0][1] diff --git a/tests/test_session_components.py b/tests/test_session_components.py index a933405300..4c842f7316 100644 --- a/tests/test_session_components.py +++ b/tests/test_session_components.py @@ -7,7 +7,7 @@ from acp.agent.router import build_agent_router from acp.schema import SessionConfigSelectOption -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.acp import CommsClient from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms @@ -64,7 +64,7 @@ async def test_state_is_owned_once_and_attachments_do_not_share_negotiation(owne thread = owner._comms.registry.require(session.session_id) assert thread.process_identity == ProcessIdentity.capture(os.getpid()) with Coordination(str(owner._comms.root / "coordination.sqlite3")) as store: - participant = store.participants.get(ThreadIncarnation("fixture", thread.created_at).lookup) + participant = store.participants.get(RecordedThreadIncarnation("fixture", thread.created_at).lookup) assert participant.committed and participant.owner_thread == thread.name assert participant.pointer.execution_id is None assert session.session_id not in owner.sessions.config.publications @@ -93,7 +93,7 @@ async def test_attached_declaration_publishes_membership_without_acquiring_execu assert owner._comms.registry.status(thread.name).active assert not attachment.sessions.bindings and not attachment.sessions.proxies with Coordination(str(owner._comms.root / "coordination.sqlite3")) as store: - participant = store.participants.get(ThreadIncarnation("fixture", thread.created_at).lookup) + participant = store.participants.get(RecordedThreadIncarnation("fixture", thread.created_at).lookup) assert participant.committed and participant.owner_thread == thread.name assert participant.pointer.execution_id is None finally: diff --git a/tests/test_thread_identity.py b/tests/test_thread_identity.py index 9ae17d5b63..319fe4c5f4 100644 --- a/tests/test_thread_identity.py +++ b/tests/test_thread_identity.py @@ -12,7 +12,8 @@ from agent_comms.field_codec import FieldCodec from agent_comms.read_basis import Conversation from agent_comms.registration import Registration -from agent_comms.thread_identity import AdmissionIdentity, GenerationCounter, ThreadIncarnation, TurnIdentity +from agent_comms.thread_identity import AdmissionIdentity, GenerationCounter, TurnIdentity +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.thread_status import IdleThreadStatus from agent_comms.threads import Thread @@ -134,7 +135,7 @@ def test_read_ledger_roundtrip_preserves_historical_identity(tmp_path): def test_identity_codec_and_counter_domain_are_not_parallel_registries(): - identity = TurnIdentity(ThreadIncarnation("owner", 1.0), 3) + identity = TurnIdentity(RecordedThreadIncarnation("owner", 1.0), 3) assert FieldCodec.decode(TurnIdentity, FieldCodec.encode(identity)) == identity counter = GenerationCounter() assert counter.advance("old") == 1 diff --git a/tests/test_tool_output.py b/tests/test_tool_output.py index 1c30a005f5..b60e00da40 100644 --- a/tests/test_tool_output.py +++ b/tests/test_tool_output.py @@ -19,6 +19,7 @@ from agent_comms.field_codec import FieldCodec from agent_comms.text_digest import TextDigest from stored_inputs import insert_inputs, stored_inputs +from agent_comms.goal_waits import RecordedGoalWait def seed_unknown(comms, count, text): @@ -209,7 +210,7 @@ def test_oversized_standby_exposes_counts_without_unseen_review_keys( ) comms.agents.begin_turn("a", "next-a-result-in-flight") invoke_tool(comms, "comms_goal", {**report, "reviewed_inputs": full_review["reviewed_inputs"]}, caller=ThreadCaller("b")) - assert comms.goals.goal_wait("b") is not None + assert isinstance(comms.goals.goal_wait("b"), RecordedGoalWait) assert all( stored_inputs(dispositions.root).rows[f"bus:{message.seq}"].declared_name == "reserved" for message in messages @@ -329,6 +330,6 @@ def test_small_dependency_review_stays_inline_despite_large_excluded_history( )["standby_review"] assert fresh["reviewed_inputs"] == [f"bus:{messages[1].seq}", f"bus:{late.seq}"] invoke_tool(comms, "comms_goal", {**report, "reviewed_inputs": fresh["reviewed_inputs"]}, caller=ThreadCaller("b")) - assert comms.goals.goal_wait("b") is not None + assert isinstance(comms.goals.goal_wait("b"), RecordedGoalWait) assert stored_inputs(dispositions.root).rows[f"bus:{messages[1].seq}"].declared_name == "reserved" assert not stored_inputs(dispositions.root).rows.get("acp:owner-0").goal_reviews diff --git a/tests/test_turn_context_wire.py b/tests/test_turn_context_wire.py index 0c19bb746a..c18e8051c7 100644 --- a/tests/test_turn_context_wire.py +++ b/tests/test_turn_context_wire.py @@ -5,7 +5,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.agent_events import AgentInfo from agent_comms.field_codec import FieldCodec from agent_comms.thread_identity import TurnId, TurnIdentity @@ -206,7 +206,7 @@ def hold_original_writer(): try: with Coordination(str(comms.root / 'coordination.sqlite3')) as store: for registered in comms.registry.snapshot().threads.values(): - store.participants.register(ThreadIncarnation("fixture", registered.created_at).lookup, + store.participants.register(RecordedThreadIncarnation("fixture", registered.created_at).lookup, registered.name, registered.name, committed=True) accept_delivery_cohort(comms.bus, root_id, original_message.seq, store) await publish_while_contended(progress, ContextObserved(data), cancel=True) @@ -245,7 +245,7 @@ def hold_original_writer(): def test_silent_manifest_continuous_original_message_and_cold_projection(tmp_path, monkeypatch): comms,root_id=_root(tmp_path) first=comms.messaging.send_initial_cohort('sender','#team','@Alice original question') - lookup=ThreadIncarnation("fixture", 17002.0).lookup + lookup=RecordedThreadIncarnation("fixture", 17002.0).lookup prior=_page(comms,lookup) raw=comms.bus.log.path.read_bytes() activity=comms.bus.channel_activity() diff --git a/tests/test_wake_candidate_index.py b/tests/test_wake_candidate_index.py index d5d5d8bc83..890224227b 100644 --- a/tests/test_wake_candidate_index.py +++ b/tests/test_wake_candidate_index.py @@ -11,7 +11,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.bus_publication import ( PRIVATE_WIRE_FIELD, public_envelope_digest, @@ -75,7 +75,7 @@ def _private(tmp_path: Path) -> tuple[Comms, str, dict[str, str]]: ) ) root_id = comms.messaging.initialize_private_initial_protocol() - lookup = {name: ThreadIncarnation("fixture", identity).lookup for name, identity in created.items()} + lookup = {name: RecordedThreadIncarnation("fixture", identity).lookup for name, identity in created.items()} return comms, root_id, lookup diff --git a/tests/test_wake_candidate_index_incremental.py b/tests/test_wake_candidate_index_incremental.py index 4088ada6f9..cbcb8a2f2c 100644 --- a/tests/test_wake_candidate_index_incremental.py +++ b/tests/test_wake_candidate_index_incremental.py @@ -10,7 +10,7 @@ import pytest -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.threads import Thread @@ -59,7 +59,7 @@ def _fresh(tmp_path: Path, *, recipients: int = 1) -> tuple[Comms, WakeCandidate ) ) root_id = comms.messaging.initialize_private_initial_protocol() - return comms, WakeCandidateIndex(comms.bus.log), root_id, ThreadIncarnation("fixture", 1_700_010_100.0).lookup + return comms, WakeCandidateIndex(comms.bus.log), root_id, RecordedThreadIncarnation("fixture", 1_700_010_100.0).lookup def _page( diff --git a/tests/turn_context_installed_journey.py b/tests/turn_context_installed_journey.py index 75f697207e..eb6b5224d8 100644 --- a/tests/turn_context_installed_journey.py +++ b/tests/turn_context_installed_journey.py @@ -10,6 +10,7 @@ import agent_comms from stored_inputs import stored_inputs +from agent_comms.goal_waits import NoGoalWait async def configured_terminal(owner, thread, output, receipt, marker): @@ -55,7 +56,7 @@ def wait_on_original(): response = await pending state = service.registry.require(thread.name).turn_state assert not state.busy and state.finished_turn_id == lease.turn_id - assert service.goals.goal_wait('terminal-waiter') is None + assert isinstance(service.goals.goal_wait('terminal-waiter'), NoGoalWait) assert service.registry.require('terminal-waiter').goal.state.active assert response.stop_reason == 'end_turn' assert any(marker in str(update) for update in subscriber.updates) diff --git a/tests/wire_row_application_journey.py b/tests/wire_row_application_journey.py index 6b373e96f8..81583752d4 100644 --- a/tests/wire_row_application_journey.py +++ b/tests/wire_row_application_journey.py @@ -13,7 +13,7 @@ from pathlib import Path import agent_comms -from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.threads import Thread @@ -54,7 +54,7 @@ def main(root: Path) -> None: # already-retired production maintenance worker. assert index.maintain(rebuild=True, max_bytes=1) page = index.page( - root_id=root_id, recipient_lookup=ThreadIncarnation("fixture", births["Alice"]).lookup, + root_id=root_id, recipient_lookup=RecordedThreadIncarnation("fixture", births["Alice"]).lookup, after_seq=0, required_through_seq=message.seq, ) assert len(page.entries) == 1 From 0b94c62836fbba90b8844b93a3d7fe79f7801aea Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 13:03:35 -0400 Subject: [PATCH 11/18] Own recipient provenance and non-null goal wait selection --- src/agent_comms/agent_activity.py | 4 +- src/agent_comms/catalog_document.py | 2 +- src/agent_comms/goal_management.py | 14 +-- src/agent_comms/goal_waits.py | 16 +-- src/agent_comms/historical_views.py | 2 +- src/agent_comms/history_views.py | 5 +- src/agent_comms/native_input_owner.py | 2 +- src/agent_comms/owned_turn.py | 11 +- src/agent_comms/presentation.py | 3 +- src/agent_comms/registry_document.py | 51 +--------- src/agent_comms/registry_provenance.py | 54 +++++++++- src/agent_comms/thread_identity.py | 107 ++++++++++++++++---- src/agent_comms/thread_provenance.py | 96 ------------------ src/agent_comms/threads.py | 2 +- src/agent_comms/transcripts.py | 2 +- tests/guards/test_source_proof_ownership.py | 2 +- tests/test_goal_wait_certified_reply.py | 6 ++ tests/test_historical_views.py | 4 +- tests/test_recipient_activity_index.py | 4 +- tests/test_route_current_sources.py | 2 +- tools/cutover/carry_history_provenance.py | 2 +- 21 files changed, 178 insertions(+), 213 deletions(-) delete mode 100644 src/agent_comms/thread_provenance.py diff --git a/src/agent_comms/agent_activity.py b/src/agent_comms/agent_activity.py index edb189f329..6c251770d7 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -9,11 +9,12 @@ from pathlib import Path from typing import TYPE_CHECKING -from .registration import Registration from .native_input_owner import RegistryOwner if TYPE_CHECKING: from .agent_events import RuntimeInfoObservation + from .registration import Registration + from .registry_document import RegistrySnapshot from .bus_publication import CommittedDelivery from .notification_assignment import AssignmentProgress from .presentation import MessageNotification @@ -23,7 +24,6 @@ from .declared_family import DeclaredFamily from .audience_manifest import FrozenRecipient from .errors import RelationViolationError -from .registry_document import RegistrySnapshot from .routing import TurnRouting from .runtime_info import AgentRuntimeInfo, RuntimeInfoStore from .store_files import _store_lock diff --git a/src/agent_comms/catalog_document.py b/src/agent_comms/catalog_document.py index 96a66b6716..3b12e986e6 100644 --- a/src/agent_comms/catalog_document.py +++ b/src/agent_comms/catalog_document.py @@ -9,7 +9,7 @@ from .channel_targets import BuiltinChannel, Tag from .channels import Channel, SavedView, ViewPredicate from .display_order import ChannelSort, CreatedThreadSort, NameChannelSort, ThreadSort -from .thread_provenance import ThreadProvenance +from .thread_identity import ThreadProvenance @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index 80ab8a0c2e..3686d33035 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -15,7 +15,7 @@ from .errors import RelationViolationError from .native_input_owner import GoalLaunchOwner from .goal_history import GoalHistoryEntry -from .goal_waits import GoalInputReview, GoalWait, GoalWaits +from .goal_waits import GoalInputReview, GoalWait, GoalWaits, NoGoalWait from .registration import Registration if TYPE_CHECKING: @@ -181,8 +181,8 @@ def goal_history( return self.registry.goal_history(name, goal_id=goal_id) def goal_wait(self, name: str) -> GoalWait: - waits = self.waits - return waits.for_goal(self.registry.require(name).goal, waits.read()) + goal = self.registry.require(name).goal + return self.waits.for_goal(goal, self.waits.read()) if goal is not None else NoGoalWait() def recover_closed_goal_wait(self, name: str) -> tuple[str, ...]: """Release one stranded standby without replaying a dependency input. @@ -331,11 +331,11 @@ def _goal_snapshot(self, name: str) -> tuple[Goal | None, GoalExecution | None]: snapshot = self.registry.snapshot() canonical = snapshot.canonical_name(name) goal = snapshot.threads[canonical].goal - return goal, GoalWaits.execution( - goal, - self.waits.read(), - snapshot, + execution = ( + GoalWaits.for_goal(goal, self.waits.read()).project(goal, snapshot) + if goal is not None else None ) + return goal, execution def consume_goal_wait(self, name: str, wait_id: str) -> bool: """Called under the send-boundary wire lock after reserving an attempt.""" diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index c2b8da99f8..c248d60846 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -28,7 +28,7 @@ class GoalWait(ABC): @abstractmethod def allows_routed_input(self) -> bool: ... - def matches_id(self, wait_id: str | None) -> bool: + def matches_id(self, wait_id: str) -> bool: return False def owner_matches(self, created_at: float) -> bool: @@ -96,7 +96,7 @@ def __post_init__(self) -> None: def allows_routed_input(self) -> bool: return False - def matches_id(self, wait_id: str | None) -> bool: + def matches_id(self, wait_id: str) -> bool: return self.wait_id == wait_id def owner_matches(self, created_at: float) -> bool: @@ -227,8 +227,8 @@ def change(rows: dict[str, RecordedGoalWait]) -> dict[str, RecordedGoalWait]: return removed @staticmethod - def for_goal(goal: Goal | None, rows: dict[str, RecordedGoalWait]) -> GoalWait: - if goal is None or not goal.state.active: + def for_goal(goal: Goal, rows: dict[str, RecordedGoalWait]) -> GoalWait: + if not goal.state.active: return NoGoalWait() return rows.get(goal.id, NoGoalWait()) @@ -309,11 +309,3 @@ def closed_wait_group( if peer_wait is not None: pending.append(canonical) return tuple(sorted(seen)) - - @staticmethod - def execution( - goal: Goal | None, rows: dict[str, RecordedGoalWait], snapshot: RegistrySnapshot - ) -> GoalExecution | None: - if goal is None: - return None - return GoalWaits.for_goal(goal, rows).project(goal, snapshot) diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index f5ba910b45..eec5c117a7 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -27,7 +27,7 @@ from .registry_provenance import RegistryProvenance from .response_policy import InformationalPolicy, ResponsePolicy from .store_files import _atomic_write_text, _iter_jsonl_records, _store_lock -from .thread_provenance import ThreadProvenance +from .thread_identity import ThreadProvenance from .wire_log import WireLog from .wire_metadata import ArchivedAccess diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index 9bffd76fed..c1eb637db1 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -558,9 +558,8 @@ def list_threads(self, active_only: bool = False) -> Sequence[Mapping]: "is_fork": t.is_fork, "pending": pending[name], "goal_execution": ( - asdict(execution) - if (execution := GoalWaits.execution(t.goal, waits, snapshot)) - else None + asdict(GoalWaits.for_goal(t.goal, waits).project(t.goal, snapshot)) + if t.goal is not None else None ), "activity": activities[name].state.value if name in activities else "idle", "activity_detail": activities[name].detail if name in activities else "", diff --git a/src/agent_comms/native_input_owner.py b/src/agent_comms/native_input_owner.py index 1ae2c55b3e..e9f82d56cf 100644 --- a/src/agent_comms/native_input_owner.py +++ b/src/agent_comms/native_input_owner.py @@ -21,13 +21,13 @@ RegistryIdentityCheck, RunningTurnRegistryCheck, ) -from .registry_document import RegistrySnapshot from .reservation_rules import ReservationViolationError from .threads import Thread from .thread_identity import OwnerIdentity if TYPE_CHECKING: from .registration import Registration + from .registry_document import RegistrySnapshot from .turn_lease import TurnLeaseFence diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index 1ee91721a6..91a6e5f6d0 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -114,13 +114,10 @@ def admit(self, permits: ExitStack): self.thread_name = self.thread.name wait = self.runner.comms.goals.goal_wait(self.thread_name) active_goal = self.thread.active_goal - if ( - not wait.allows_routed_input() - and not self.original_owner_input - and not wait.matches_id(self.dependency_wait_id) - ): - return - if self.dependency_wait_id is not None and not wait.matches_id(self.dependency_wait_id): + if self.dependency_wait_id is not None: + if not wait.matches_id(self.dependency_wait_id): + return + elif not wait.allows_routed_input() and not self.original_owner_input: return if self.original_owner_input and self.original_goal_id != ( active_goal.id if active_goal is not None else None diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 256573b91e..243f22de88 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -679,7 +679,8 @@ def capture( activity, runtime, snapshot.seen_at(thread.name), - GoalWaits.execution(thread.goal, waits, snapshot), + (GoalWaits.for_goal(thread.goal, waits).project(thread.goal, snapshot) + if thread.goal is not None else None), snapshot.owner_binding(thread.name), ) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index f73b54fa20..632949bc0e 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -4,7 +4,6 @@ import math import time -from abc import ABC, abstractmethod from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass, field, replace from types import MappingProxyType @@ -19,7 +18,7 @@ RegistrationChange, UpdatedRegistration, ) -from .registry_provenance import RegistryNames, RegistryProvenance +from .registry_provenance import RegisteredRecipient, RegistryNames, RegistryProvenance, RetiredRecipient from .restart_refusals import ( OwnerBusyRefusal, OwnerChangedBeforeFenceRefusal, @@ -28,7 +27,6 @@ from .routing import TurnRouting from .thread_identity import AdmissionIdentity, GenerationCounter, OwnerIdentity from .thread_presentation import ThreadOwnerBinding -from .thread_provenance import ThreadProvenance from .thread_status import ( ArchivedThreadStatus, StoppedThreadStatus, @@ -526,53 +524,6 @@ def owner_identity(self) -> OwnerIdentity: return OwnerIdentity(self.thread.incarnation, self.owner_generation) -class RecipientSelection(ABC): - """The registry owns the selected recipient's live or retired behavior.""" - - @abstractmethod - def activity(self, observations, snapshot): ... - - @abstractmethod - def read_effect(self, reads, message, snapshot, *, document=None): ... - - -@dataclass(frozen=True, slots=True) -class KnownRecipient(RecipientSelection): - thread: ThreadProvenance - - def read_effect(self, reads, message, snapshot, *, document=None): - return snapshot.displayed_recipient_effect( - reads, message, self.thread, document=document - ) - - -@dataclass(frozen=True, slots=True) -class RegisteredRecipient(KnownRecipient): - thread: Thread - - def activity(self, observations, snapshot): - return self.thread.execution.observe_recipient(observations, snapshot, self.thread) - - -@dataclass(frozen=True, slots=True) -class ArchivedRecipient(KnownRecipient): - def activity(self, observations, snapshot): - raise RelationViolationError("Archived recipients have no live activity") - - -@dataclass(frozen=True, slots=True) -class RetiredRecipient(RecipientSelection): - lookup: str - - def activity(self, observations, snapshot): - from .agent_activity import UnavailableRecipientActivity - - return UnavailableRecipientActivity() - - def read_effect(self, reads, message, snapshot, *, document=None): - return () - - @dataclass(frozen=True, slots=True) class RegistrySnapshot(RegistryPresence, RegistryProvenance): threads: Mapping[str, Thread] diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index f68cac2339..5e13938229 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -2,17 +2,18 @@ from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Iterator, Mapping from dataclasses import dataclass from typing import TYPE_CHECKING, Annotated, Generic, TypeVar +from .agent_activity import UnavailableRecipientActivity from .errors import RelationViolationError, UnregisteredThreadError -from .thread_identity import ThreadIncarnation -from .thread_provenance import ThreadProvenance, ThreadProvenanceRecord +from .thread_identity import ThreadIncarnation, ThreadProvenance, ThreadProvenanceRecord if TYPE_CHECKING: from .audience_manifest import FrozenRecipient - from .registry_document import ArchivedRecipient, RetiredRecipient + from .threads import Thread Member = TypeVar("Member", bound=ThreadProvenance, covariant=True) @@ -32,6 +33,51 @@ def incarnation(self) -> ThreadIncarnation: return ThreadIncarnation.deleted(self.name) +class RecipientSelection(ABC): + """One registry selection owns live, archived and retired recipient behavior.""" + + @abstractmethod + def activity(self, observations, snapshot): ... + + @abstractmethod + def read_effect(self, reads, message, snapshot, *, document=None): ... + + +@dataclass(frozen=True, slots=True) +class KnownRecipient(RecipientSelection): + thread: ThreadProvenance + + def read_effect(self, reads, message, snapshot, *, document=None): + return snapshot.displayed_recipient_effect( + reads, message, self.thread, document=document + ) + + +@dataclass(frozen=True, slots=True) +class RegisteredRecipient(KnownRecipient): + thread: Thread + + def activity(self, observations, snapshot): + return self.thread.execution.observe_recipient(observations, snapshot, self.thread) + + +@dataclass(frozen=True, slots=True) +class ArchivedRecipient(KnownRecipient): + def activity(self, observations, snapshot): + raise RelationViolationError("Archived recipients have no live activity") + + +@dataclass(frozen=True, slots=True) +class RetiredRecipient(RecipientSelection): + lookup: str + + def activity(self, observations, snapshot): + return UnavailableRecipientActivity() + + def read_effect(self, reads, message, snapshot, *, document=None): + return () + + class RegistryNames(Generic[Member]): """Shared resolution behavior for live and recorded registry namespaces.""" @@ -100,8 +146,6 @@ def displayed_recipient_effect(self, reads, message, thread: ThreadProvenance, * def historical_recipient(self, recipient: FrozenRecipient) -> ArchivedRecipient | RetiredRecipient: """Archives select by original name and birth; duplicate births stay distinct.""" - from .registry_document import ArchivedRecipient, RetiredRecipient - thread = self.threads.get(self.canonical_name(recipient.canonical_thread)) if thread is not None and thread.incarnation.has_recorded_birth and ( thread.incarnation.require_recorded().lookup == recipient.recipient_lookup diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 37fb18fe58..844a555f85 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -9,13 +9,14 @@ import hashlib import math from abc import ABC, abstractmethod -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field, fields, replace from enum import StrEnum from functools import cached_property from typing import TYPE_CHECKING, ClassVar +from .channel_targets import Tag from .errors import RelationViolationError -from .field_codec import WireValue +from .field_codec import FieldCodec, FieldRepresentation, WireValue if TYPE_CHECKING: from .registry_provenance import RegistryProvenance @@ -31,8 +32,6 @@ def to_wire(self) -> dict[str, str | float]: @classmethod def schema(cls) -> dict: - from .field_codec import FieldCodec - return FieldCodec.record_schema(ThreadIncarnation) @classmethod @@ -49,8 +48,6 @@ def deleted(cls, name: str) -> DeletedThreadIncarnation: return DeletedThreadIncarnation(name) def __post_init__(self) -> None: - from .field_codec import FieldCodec - if not FieldCodec.decode(str, self.name): raise ValueError("Thread incarnation requires a recorded name") if not math.isfinite(FieldCodec.decode(float, self.created_at)): @@ -109,8 +106,6 @@ def require_recorded(self) -> RecordedThreadIncarnation: return self def provenance_type(self): - from .thread_provenance import ThreadProvenance - return ThreadProvenance def renamed(self, name: str) -> RecordedThreadIncarnation: @@ -149,8 +144,6 @@ def require_recorded(self) -> RecordedThreadIncarnation: raise ValueError("Thread incarnation requires a positive recorded birth") def provenance_type(self): - from .thread_provenance import DeletedThreadProvenance - return DeletedThreadProvenance def renamed(self, name: str) -> DeletedThreadIncarnation: @@ -167,21 +160,84 @@ def require_recorded(self) -> RecordedThreadIncarnation: raise ValueError("Thread incarnation requires a positive recorded birth") def provenance_type(self): - from .thread_provenance import UnrecordedThreadProvenance - return UnrecordedThreadProvenance def renamed(self, name: str) -> UnrecordedThreadIncarnation: return UnrecordedThreadIncarnation(name) +@dataclass(frozen=True, slots=True, kw_only=True) +class ThreadProvenance: + """Recorded thread provenance, without process, turn or admission authority.""" + + name: str + tags: frozenset[str] + worktree: str + created_at: float = field(metadata={"wire_required": True}) + _incarnation: ThreadIncarnation = field(init=False, repr=False, compare=False) + session_file: str | None = None + title: str | None = None + + def make_incarnation(self) -> ThreadIncarnation: + return RecordedThreadIncarnation(self.name, self.created_at) + + @property + def provenance_type(self) -> type[ThreadProvenance]: + return ThreadProvenance + + def __post_init__(self) -> None: + object.__setattr__(self, "_incarnation", self.make_incarnation()) + if self._incarnation.created_at != self.created_at: + raise ValueError("Thread provenance birth differs from its declared state") + allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + if not self.name or not set(self.name) <= allowed: + raise ValueError(f"Thread name {self.name!r} must be alphanumeric with hyphens/underscores.") + if not self.worktree: + raise ValueError("Thread worktree cannot be empty.") + for tag in self.tags: + Tag(tag) + + @staticmethod + def capture(thread: ThreadProvenance) -> ThreadProvenance: + """Freeze only determining provenance from the original typed declaration.""" + return thread.provenance_type( + **{ + item.name: getattr(thread, item.name) + for item in fields(ThreadProvenance) + if item.init + } + ) + + @property + def incarnation(self) -> ThreadIncarnation: + return self._incarnation + + +@dataclass(frozen=True, slots=True, kw_only=True) +class UnrecordedThreadProvenance(ThreadProvenance): + def make_incarnation(self) -> ThreadIncarnation: + return UnrecordedThreadIncarnation(self.name) + + @property + def provenance_type(self) -> type[ThreadProvenance]: + return UnrecordedThreadProvenance + + +@dataclass(frozen=True, slots=True, kw_only=True) +class DeletedThreadProvenance(ThreadProvenance): + def make_incarnation(self) -> ThreadIncarnation: + return DeletedThreadIncarnation(self.name) + + @property + def provenance_type(self) -> type[ThreadProvenance]: + return DeletedThreadProvenance + + class ThreadIncarnationRecord: """One explicit boundary decoder for stored birth numbers, never a constructor switch.""" @staticmethod def decode(name: str, birth: float) -> ThreadIncarnation: - from .field_codec import FieldCodec - name = FieldCodec.decode(str, name) birth = FieldCodec.decode(float, birth) if birth == -1: @@ -191,14 +247,31 @@ def decode(name: str, birth: float) -> ThreadIncarnation: return RecordedThreadIncarnation(name, birth) +class ThreadProvenanceRecord(FieldRepresentation): + """Decode an archived registry row's recorded birth state exactly once.""" + + @classmethod + def encode(cls, value: ThreadProvenance) -> object: + return FieldCodec.encode(value) + + @classmethod + def schema(cls) -> dict: + return FieldCodec.record_schema(ThreadProvenance) + + @classmethod + def decode(cls, value: object) -> ThreadProvenance: + if not isinstance(value, dict): + raise TypeError("Recorded thread provenance must be an object") + identity = ThreadIncarnationRecord.decode(value["name"], value["created_at"]) + return FieldCodec.decode(identity.provenance_type(), value) + + @dataclass(frozen=True, slots=True) class TurnId: value: str @classmethod def for_registration(cls, value: str) -> TurnId: - from .field_codec import FieldCodec - value = FieldCodec.decode(str, value) if not 0 < len(value) <= 128: raise ValueError("live owner turn requires a bounded ID") @@ -259,8 +332,6 @@ def __post_init__(self) -> None: @staticmethod def require_positive(value: int) -> int: - from .field_codec import FieldCodec - value = FieldCodec.decode(int, value) if value < 1: raise ValueError("live owner requires a positive generation") diff --git a/src/agent_comms/thread_provenance.py b/src/agent_comms/thread_provenance.py deleted file mode 100644 index 1699f92249..0000000000 --- a/src/agent_comms/thread_provenance.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Recorded thread provenance, without process, turn or admission authority.""" - -from __future__ import annotations - -from dataclasses import dataclass, field, fields - -from .channel_targets import Tag -from .field_codec import FieldCodec, FieldRepresentation -from .thread_identity import ( - DeletedThreadIncarnation, RecordedThreadIncarnation, ThreadIncarnation, - ThreadIncarnationRecord, UnrecordedThreadIncarnation, -) - - -@dataclass(frozen=True, slots=True, kw_only=True) -class ThreadProvenance: - name: str - tags: frozenset[str] - worktree: str - created_at: float = field(metadata={"wire_required": True}) - _incarnation: ThreadIncarnation = field(init=False, repr=False, compare=False) - session_file: str | None = None - title: str | None = None - - def make_incarnation(self) -> ThreadIncarnation: - return RecordedThreadIncarnation(self.name, self.created_at) - - @property - def provenance_type(self) -> type[ThreadProvenance]: - return ThreadProvenance - - def __post_init__(self) -> None: - object.__setattr__(self, "_incarnation", self.make_incarnation()) - if self._incarnation.created_at != self.created_at: - raise ValueError("Thread provenance birth differs from its declared state") - allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") - if not self.name or not set(self.name) <= allowed: - raise ValueError(f"Thread name {self.name!r} must be alphanumeric with hyphens/underscores.") - if not self.worktree: - raise ValueError("Thread worktree cannot be empty.") - for tag in self.tags: - Tag(tag) - - @staticmethod - def capture(thread: ThreadProvenance) -> ThreadProvenance: - """Freeze only determining provenance from the original typed declaration.""" - return thread.provenance_type( - **{ - item.name: getattr(thread, item.name) - for item in fields(ThreadProvenance) - if item.init - } - ) - - @property - def incarnation(self) -> ThreadIncarnation: - return self._incarnation - - -@dataclass(frozen=True, slots=True, kw_only=True) -class UnrecordedThreadProvenance(ThreadProvenance): - def make_incarnation(self) -> ThreadIncarnation: - return UnrecordedThreadIncarnation(self.name) - - @property - def provenance_type(self) -> type[ThreadProvenance]: - return UnrecordedThreadProvenance - - -@dataclass(frozen=True, slots=True, kw_only=True) -class DeletedThreadProvenance(ThreadProvenance): - def make_incarnation(self) -> ThreadIncarnation: - return DeletedThreadIncarnation(self.name) - - @property - def provenance_type(self) -> type[ThreadProvenance]: - return DeletedThreadProvenance - - -class ThreadProvenanceRecord(FieldRepresentation): - """Decode an archived registry row's recorded birth state exactly once.""" - - @classmethod - def encode(cls, value: ThreadProvenance) -> object: - return FieldCodec.encode(value) - - @classmethod - def schema(cls) -> dict: - return FieldCodec.record_schema(ThreadProvenance) - - @classmethod - def decode(cls, value: object) -> ThreadProvenance: - if not isinstance(value, dict): - raise TypeError("Recorded thread provenance must be an object") - identity = ThreadIncarnationRecord.decode(value["name"], value["created_at"]) - return FieldCodec.decode(identity.provenance_type(), value) diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index ed694ea1cb..8d61079ba9 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -11,7 +11,6 @@ from .child_process import ProcessIdentity from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import FieldCodec -from .thread_provenance import ThreadProvenance from .thread_execution import ThreadExecution, NativeThreadExecution from .pi_vocabulary import SERVICE_TIER_ENVIRONMENT, ServiceTier, ThinkingLevel from .goals import ( @@ -19,6 +18,7 @@ ) from .registration_inheritance import InheritEmpty, InheritMissing, InheritPrevious from .thread_identity import ( + ThreadProvenance, ThreadRole, TurnId, TurnIdentity, diff --git a/src/agent_comms/transcripts.py b/src/agent_comms/transcripts.py index 6115181b80..6c3aa9112b 100644 --- a/src/agent_comms/transcripts.py +++ b/src/agent_comms/transcripts.py @@ -17,7 +17,7 @@ from .field_codec import FieldCodec, projected from .declared_family import DeclaredFamily from .historical_views import HistorySource -from .thread_provenance import ThreadProvenance +from .thread_identity import ThreadProvenance from .message_bus import MessageBus from .messages import Message from .message_reference import MessageReference diff --git a/tests/guards/test_source_proof_ownership.py b/tests/guards/test_source_proof_ownership.py index 2c10183a5c..9f46f4d319 100644 --- a/tests/guards/test_source_proof_ownership.py +++ b/tests/guards/test_source_proof_ownership.py @@ -92,7 +92,7 @@ def test_archived_identity_cannot_reacquire_a_live_registry_or_thread(): from dataclasses import MISSING, fields from typing import get_type_hints from agent_comms.historical_views import HistorySource, HistoricalThread - from agent_comms.thread_provenance import ThreadProvenance + from agent_comms.thread_identity import ThreadProvenance provenance = next(item for item in fields(HistorySource) if item.name == "provenance") assert provenance.default is MISSING and provenance.default_factory is MISSING diff --git a/tests/test_goal_wait_certified_reply.py b/tests/test_goal_wait_certified_reply.py index 00d86f9201..ba8127a37a 100644 --- a/tests/test_goal_wait_certified_reply.py +++ b/tests/test_goal_wait_certified_reply.py @@ -25,6 +25,12 @@ def register(comms, name): return comms.registry.require(name) +def test_thread_without_goal_has_no_wait(tmp_path): + comms = canonical_goal_wire(tmp_path / "wire") + register(comms, "owner") + assert isinstance(comms.goals.goal_wait("owner"), NoGoalWait) + + def test_original_reply_is_not_reassigned_to_same_name_replacement(tmp_path): comms = canonical_goal_wire(tmp_path / "wire") owner = register(comms, "owner") diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index afddfe9e83..2b990d3197 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -317,7 +317,7 @@ def test_historical_namespace_has_recorded_provenance_and_no_live_authority(migr from dataclasses import fields from agent_comms.field_codec import FieldCodec from agent_comms.registry_provenance import RegistryProvenance - from agent_comms.thread_provenance import ThreadProvenance + from agent_comms.thread_identity import ThreadProvenance old, _, live, source = migrated recorded = source.provenance.require("alice") @@ -335,7 +335,7 @@ def test_historical_namespace_has_recorded_provenance_and_no_live_authority(migr with pytest.raises(ValueError, match="Missing required"): FieldCodec.decode(ThreadProvenance, raw) # Original finite historical timestamps are preserved, never synthesized. - from agent_comms.thread_provenance import UnrecordedThreadProvenance + from agent_comms.thread_identity import UnrecordedThreadProvenance zero = UnrecordedThreadProvenance( name=recorded.name, tags=recorded.tags, worktree=recorded.worktree, diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index eb95df9a35..890cc090ee 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -17,7 +17,7 @@ from agent_comms.notification_assignment import NotificationAssignment from agent_comms.presentation import MessageNotification from agent_comms.read_ledger import ReadLedger -from agent_comms.registry_document import RegisteredRecipient, RetiredRecipient +from agent_comms.registry_provenance import RegisteredRecipient, RetiredRecipient from agent_comms.registry_provenance import RegistryProvenance from agent_comms.thread_identity import ( DeletedThreadIncarnation, @@ -130,7 +130,7 @@ def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_own ) == () with pytest.raises(ValueError, match="positive recorded birth"): replace(source.threads["second"], created_at=0.0) - from agent_comms.thread_provenance import UnrecordedThreadProvenance + from agent_comms.thread_identity import UnrecordedThreadProvenance original = recorded.threads["second"] zero = UnrecordedThreadProvenance( diff --git a/tests/test_route_current_sources.py b/tests/test_route_current_sources.py index d767229b3e..98c8b4300f 100644 --- a/tests/test_route_current_sources.py +++ b/tests/test_route_current_sources.py @@ -60,7 +60,7 @@ def test_zero_birth_accepts_public_format_but_refuses_assigned_source(tmp_path): original = c.messaging.send_message('sender', viewer, 'assigned source') with c.bus.snapshot(viewer=viewer) as (basis, source): from agent_comms.registry_provenance import RegistryProvenance - from agent_comms.thread_provenance import UnrecordedThreadProvenance + from agent_comms.thread_identity import UnrecordedThreadProvenance recorded = RegistryProvenance.capture(basis.registry) threads = dict(recorded.threads) diff --git a/tools/cutover/carry_history_provenance.py b/tools/cutover/carry_history_provenance.py index d20d79eafd..6056ebae92 100644 --- a/tools/cutover/carry_history_provenance.py +++ b/tools/cutover/carry_history_provenance.py @@ -17,7 +17,7 @@ from agent_comms.historical_views import HistorySource from agent_comms.registry_provenance import RegistryProvenance from agent_comms.store_files import _atomic_write_text -from agent_comms.thread_provenance import ThreadProvenance +from agent_comms.thread_identity import ThreadProvenance def carry(manifest: Path): From 2979497b130b726a7535e9a4c8592ac04b7077cc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 13:23:02 -0400 Subject: [PATCH 12/18] Derive goal target and provenance identities from their owners --- src/agent_comms/goal_management.py | 2 +- src/agent_comms/goal_presentation.py | 46 +++++++++++++++---------- src/agent_comms/goal_waits.py | 4 +-- src/agent_comms/history_views.py | 6 ++-- src/agent_comms/presentation.py | 4 +-- src/agent_comms/runtime_requests.py | 4 +-- src/agent_comms/thread_identity.py | 31 +++++++++++++---- src/agent_comms/tools.py | 2 +- tests/test_goal_standby.py | 6 ++-- tests/test_goal_wait_certified_reply.py | 2 +- tests/test_goal_wait_current_owner.py | 4 +-- tests/test_locked_store.py | 3 +- tests/test_ordinary_admission_rules.py | 2 +- tests/test_owner_process.py | 2 +- tests/test_private_idle_drain.py | 2 +- tests/test_recipient_activity_index.py | 11 +++++- tests/test_runtime_goal_snapshot.py | 4 +-- 17 files changed, 87 insertions(+), 48 deletions(-) diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index 3686d33035..f2b7c99806 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -131,7 +131,7 @@ def _goal_input_review( if any(target.created_at == thread.created_at for target in resolved): raise ValueError("A goal cannot wait for its own thread.") targets = tuple( - dict.fromkeys(GoalWaitTarget(target.name, target.created_at) for target in resolved) + dict.fromkeys(GoalWaitTarget(target.incarnation.require_recorded()) for target in resolved) ) owners = self.registry.aliases_for(thread.name) dispositions = InputDispositions(self.root) diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index a063913bf2..581b9d021b 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -4,11 +4,11 @@ from abc import ABC, abstractmethod from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import StrEnum from typing import TYPE_CHECKING -from .field_codec import FieldCodec +from .field_codec import FieldCodec, WireValue from .goal_states import ( ActiveGoal, BlockedGoal, @@ -84,19 +84,31 @@ def for_domain_state(cls, state: GoalState) -> GoalExecutionState: @dataclass(frozen=True, slots=True) -class GoalWaitTarget: - name: str - created_at: float - _incarnation: RecordedThreadIncarnation = field(init=False, repr=False, compare=False) +class GoalWaitTarget(WireValue): + incarnation: RecordedThreadIncarnation - def __post_init__(self) -> None: - object.__setattr__( - self, "_incarnation", RecordedThreadIncarnation(self.name, self.created_at) - ) + @property + def name(self) -> str: + return self.incarnation.name + + @property + def created_at(self) -> float: + return self.incarnation.created_at def sent(self, original: CommittedDelivery) -> bool: """Only this declared incarnation can supply a dependency reply.""" - return original.audience.sender_lookup == self._incarnation.lookup + return original.audience.sender_lookup == self.incarnation.lookup + + def to_wire(self) -> dict[str, str | float]: + return self.incarnation.to_wire() + + @classmethod + def from_wire(cls, data: Mapping) -> GoalWaitTarget: + return cls(RecordedThreadIncarnation.from_wire(data)) + + @classmethod + def schema(cls) -> dict: + return RecordedThreadIncarnation.schema() @dataclass(frozen=True, slots=True) @@ -111,15 +123,13 @@ def presentation(self, title: str) -> ThreadPresentation: glyph, summary = self.state.view.render(self) return ThreadPresentation(title, glyph, summary) + def to_wire(self) -> dict: + """Use the one field codec; target births keep their original wire keys.""" + return FieldCodec.encode(self) + @classmethod def from_wire(cls, data: Mapping) -> GoalExecution: - return cls( - GoalExecutionState(data["state"]), - str(data["goal_id"]), - tuple(GoalWaitTarget(**target) for target in data.get("wait_for", ())), - tuple(GoalWaitTarget(**target) for target in data.get("inactive_wait_for", ())), - data.get("block_reason"), - ) + return FieldCodec.decode(cls, data) @dataclass(frozen=True) diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index c248d60846..9e52f7a6ec 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -5,7 +5,7 @@ import math from abc import ABC, abstractmethod from collections.abc import Sequence -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar from .bus_publication import CommittedDelivery @@ -110,7 +110,7 @@ def consume_reply(self, waits: GoalWaits, goal_id: str) -> bool: def project(self, goal: Goal, snapshot: RegistrySnapshot) -> GoalExecution: targets = tuple( - replace(target, name=snapshot.canonical_name(target.name)) + GoalWaitTarget(target.incarnation.renamed(snapshot.canonical_name(target.name))) for target in self.targets ) inactive = tuple( diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index c1eb637db1..544f5fdece 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -6,7 +6,7 @@ import time from collections.abc import Iterable, Mapping, Sequence from contextlib import ExitStack -from dataclasses import asdict, dataclass, replace +from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING @@ -558,7 +558,7 @@ def list_threads(self, active_only: bool = False) -> Sequence[Mapping]: "is_fork": t.is_fork, "pending": pending[name], "goal_execution": ( - asdict(GoalWaits.for_goal(t.goal, waits).project(t.goal, snapshot)) + GoalWaits.for_goal(t.goal, waits).project(t.goal, snapshot).to_wire() if t.goal is not None else None ), "activity": activities[name].state.value if name in activities else "idle", @@ -575,7 +575,7 @@ def thread_detail(self, name: str, *, include_pending: bool = True) -> Mapping[s "is_fork": t.is_fork, } execution = self.goals.goal_execution(name) - detail["goal_execution"] = asdict(execution) if execution else None + detail["goal_execution"] = execution.to_wire() if execution else None if include_pending: detail["pending"] = self.bus.pending_count(name) return detail diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 243f22de88..271d845c5d 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from contextlib import ExitStack, contextmanager -from dataclasses import asdict, dataclass, field, replace +from dataclasses import dataclass, field, replace from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, ClassVar @@ -745,7 +745,7 @@ def to_wire(self) -> dict[str, object]: "last_activity": self.activity.timestamp, "activity": self.activity.state.value, "activity_detail": self.activity.detail, - "goal_execution": asdict(self.goal_execution) if self.goal_execution else None, + "goal_execution": self.goal_execution.to_wire() if self.goal_execution else None, "session_name": self.runtime.session_name if self.runtime else None, "context_used": self.runtime.context_used if self.runtime else None, "context_size": self.runtime.context_size if self.runtime else None, diff --git a/src/agent_comms/runtime_requests.py b/src/agent_comms/runtime_requests.py index e02e507607..91f0b355c3 100644 --- a/src/agent_comms/runtime_requests.py +++ b/src/agent_comms/runtime_requests.py @@ -13,7 +13,7 @@ from abc import abstractmethod from functools import partial from .coordinator import Coordination -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Self from .command import Command @@ -279,7 +279,7 @@ async def result(self, ctx: RuntimeRequestContext) -> dict[str, Any]: )) return { "goal": goal.to_wire() if goal is not None else None, - "goalExecution": asdict(execution) if execution is not None else None, + "goalExecution": execution.to_wire() if execution is not None else None, "_meta": encode_updates(TurnChangedUpdate(await Coordination.run_worker(partial( ctx.server.agent.turns.turn_state, ctx.session_id, )))), diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 844a555f85..babca1df03 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -36,6 +36,9 @@ def schema(cls) -> dict: @classmethod def from_wire(cls, data: dict) -> ThreadIncarnation: + declared = {key for _, key in FieldCodec.record_fields(ThreadIncarnation)} + if type(data) is not dict or data.keys() != declared: + raise TypeError("Thread incarnation requires exactly its declared wire fields") decoded = ThreadIncarnationRecord.decode(data["name"], data["created_at"]) if not isinstance(decoded, cls): raise ValueError("Decoded thread birth does not match its declared member") @@ -57,6 +60,11 @@ def __post_init__(self) -> None: def require_recorded(self) -> RecordedThreadIncarnation: """Only the recorded member can expose a lookup capability.""" + @property + @abstractmethod + def birth_description(self) -> str: + """Describe recorded or missing birth evidence without inventing a date.""" + @abstractmethod def provenance_type(self): """The archive declaration carrying this recorded birth state.""" @@ -105,6 +113,10 @@ class RecordedThreadIncarnation(ThreadIncarnation): def require_recorded(self) -> RecordedThreadIncarnation: return self + @property + def birth_description(self) -> str: + return f"created {self.created_at}" + def provenance_type(self): return ThreadProvenance @@ -143,6 +155,10 @@ class DeletedThreadIncarnation(ThreadIncarnation): def require_recorded(self) -> RecordedThreadIncarnation: raise ValueError("Thread incarnation requires a positive recorded birth") + @property + def birth_description(self) -> str: + return "deleted declaration" + def provenance_type(self): return DeletedThreadProvenance @@ -159,6 +175,10 @@ class UnrecordedThreadIncarnation(ThreadIncarnation): def require_recorded(self) -> RecordedThreadIncarnation: raise ValueError("Thread incarnation requires a positive recorded birth") + @property + def birth_description(self) -> str: + return "birth unrecorded" + def provenance_type(self): return UnrecordedThreadProvenance @@ -166,7 +186,7 @@ def renamed(self, name: str) -> UnrecordedThreadIncarnation: return UnrecordedThreadIncarnation(name) -@dataclass(frozen=True, slots=True, kw_only=True) +@dataclass(frozen=True, kw_only=True) class ThreadProvenance: """Recorded thread provenance, without process, turn or admission authority.""" @@ -174,7 +194,6 @@ class ThreadProvenance: tags: frozenset[str] worktree: str created_at: float = field(metadata={"wire_required": True}) - _incarnation: ThreadIncarnation = field(init=False, repr=False, compare=False) session_file: str | None = None title: str | None = None @@ -186,8 +205,7 @@ def provenance_type(self) -> type[ThreadProvenance]: return ThreadProvenance def __post_init__(self) -> None: - object.__setattr__(self, "_incarnation", self.make_incarnation()) - if self._incarnation.created_at != self.created_at: + if self.incarnation.created_at != self.created_at: raise ValueError("Thread provenance birth differs from its declared state") allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") if not self.name or not set(self.name) <= allowed: @@ -208,9 +226,10 @@ def capture(thread: ThreadProvenance) -> ThreadProvenance: } ) - @property + @cached_property def incarnation(self) -> ThreadIncarnation: - return self._incarnation + """The declaration's birth member, created once and shared by its readers.""" + return self.make_incarnation() @dataclass(frozen=True, slots=True, kw_only=True) diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 35193a32f8..9dd0f08f82 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -510,7 +510,7 @@ def apply(self, comms: Comms, caller: ThreadCaller) -> JsonObject: goal, execution = comms.goals.goal_snapshot(name) return { "goal": goal.to_wire() if goal else None, - "goal_execution": asdict(execution) if execution else None, + "goal_execution": execution.to_wire() if execution else None, } diff --git a/tests/test_goal_standby.py b/tests/test_goal_standby.py index f60827da1a..8d74a5dbb0 100644 --- a/tests/test_goal_standby.py +++ b/tests/test_goal_standby.py @@ -249,7 +249,7 @@ def test_liveness_check_releases_preexisting_closed_wait_group(tmp_path, pending "bob-wait", bob.revision, 0, - (GoalWaitTarget("alice", peer.created_at),), + (GoalWaitTarget(peer.incarnation.require_recorded()),), owner_created_at=owner.created_at, target_turn_generations=(peer.turn_generation,), report_turn_id=owner.active_turn.id, @@ -299,7 +299,7 @@ def test_new_live_dependency_turn_keeps_old_wait_group_open(tmp_path, bound_old_ "bob-wait", bob.revision, 0, - (GoalWaitTarget("alice", peer.created_at),), + (GoalWaitTarget(peer.incarnation.require_recorded()),), owner_created_at=owner.created_at, target_turn_generations=(peer.turn_generation,), report_turn_id="bob-first" if bound_old_turn else None, @@ -342,7 +342,7 @@ def test_recheck_crash_before_wait_clear_keeps_goal_in_standby(tmp_path, monkeyp "bob-wait", bob.revision, 0, - (GoalWaitTarget("alice", comms.registry.require("alice").created_at),), + (GoalWaitTarget(comms.registry.require("alice").incarnation.require_recorded()),), owner_created_at=comms.registry.require("bob").created_at, target_turn_generations=(comms.registry.require("alice").turn_generation,), report_turn_id="bob-turn", diff --git a/tests/test_goal_wait_certified_reply.py b/tests/test_goal_wait_certified_reply.py index ba8127a37a..116fbe3e83 100644 --- a/tests/test_goal_wait_certified_reply.py +++ b/tests/test_goal_wait_certified_reply.py @@ -56,7 +56,7 @@ def test_original_reply_is_not_reassigned_to_same_name_replacement(tmp_path): original = source.delivery(reply.seq) assert wait.matches(original, owner.incarnation.require_recorded()) replacement_wait = replace( - wait, targets=(GoalWaitTarget(replacement.name, replacement.created_at),) + wait, targets=(GoalWaitTarget(replacement.incarnation.require_recorded()),) ) assert not replacement_wait.matches(original, owner.incarnation.require_recorded()) assert wait.has_reply(comms.bus.log, owner.incarnation.require_recorded()) diff --git a/tests/test_goal_wait_current_owner.py b/tests/test_goal_wait_current_owner.py index 3dd72fd618..b80354c88c 100644 --- a/tests/test_goal_wait_current_owner.py +++ b/tests/test_goal_wait_current_owner.py @@ -23,7 +23,7 @@ def test_graph_uses_only_wait_for_original_owner_goal(tmp_path, change): goal = peer.active_goal wait = RecordedGoalWait( goal.id, "original-wait", goal.revision, 0, - (GoalWaitTarget(leaf.name, leaf.created_at),), peer.created_at, + (GoalWaitTarget(leaf.incarnation.require_recorded()),), peer.created_at, (None,), None, None, ) if change == "later_goal": @@ -37,7 +37,7 @@ def test_graph_uses_only_wait_for_original_owner_goal(tmp_path, change): # The foreign-goal case models a mismatched persisted lookup key. Its row # must not become the current goal's dependency graph merely through lookup. group = GoalWaits.closed_wait_group( - "owner", (GoalWaitTarget(peer.name, peer.created_at),), + "owner", (GoalWaitTarget(peer.incarnation.require_recorded()),), {goal.id: wait}, comms.registry.snapshot(), ) # The graph reports waiting owners, not terminal leaves without a wait. diff --git a/tests/test_locked_store.py b/tests/test_locked_store.py index b4f3bff3b2..8da13270fc 100644 --- a/tests/test_locked_store.py +++ b/tests/test_locked_store.py @@ -13,6 +13,7 @@ from agent_comms.field_codec import FieldCodec from agent_comms.goal_presentation import GoalWaitTarget +from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.goal_waits import GoalWaits from agent_comms.locked_store import LockedStore from agent_comms.store_files import _store_lock @@ -25,7 +26,7 @@ def wait(goal_id="goal"): "wait", 2, 7, - (GoalWaitTarget("worker", 123.0),), + (GoalWaitTarget(RecordedThreadIncarnation("worker", 123.0)),), 122.0, (3,), "turn", diff --git a/tests/test_ordinary_admission_rules.py b/tests/test_ordinary_admission_rules.py index e7c7b60c02..960d3d86ee 100644 --- a/tests/test_ordinary_admission_rules.py +++ b/tests/test_ordinary_admission_rules.py @@ -133,7 +133,7 @@ def test_routed_dependency_source_uses_existing_goal_and_wait_authorities(tmp_pa wait_id="wait", revision=0, after_seq=0, - targets=(GoalWaitTarget(peer.name, peer.created_at),), + targets=(GoalWaitTarget(peer.incarnation.require_recorded()),), owner_created_at=comms.registry.require("owner").created_at, target_turn_generations=(None,), report_turn_id=None, diff --git a/tests/test_owner_process.py b/tests/test_owner_process.py index e10a89b00d..4a996ed72e 100644 --- a/tests/test_owner_process.py +++ b/tests/test_owner_process.py @@ -154,7 +154,7 @@ def test_wait_graph_uses_exact_birth_for_real_active_peer(tmp_path: Path) -> Non ) comms.registry.declare(owner) comms.registry.declare(peer) - targets = (GoalWaitTarget(peer.name, peer.created_at),) + targets = (GoalWaitTarget(peer.incarnation.require_recorded()),) assert GoalWaits.closed_wait_group(owner.name, targets, {}, comms.registry.snapshot()) == () stale = replace( peer, process_identity=replace(child.identity, start_time=child.identity.start_time + 1) diff --git a/tests/test_private_idle_drain.py b/tests/test_private_idle_drain.py index 478836e734..8b0fed3e0c 100644 --- a/tests/test_private_idle_drain.py +++ b/tests/test_private_idle_drain.py @@ -416,7 +416,7 @@ async def counted_certified(*args, **kwargs): monkeypatch.setattr(comms.goals, "recover_closed_goal_wait", recovered.append) sender = comms.registry.require("sender") agent.inputs.effects.turns.goals.goal_execution_signatures["beta"] = (None, GoalExecution( - GoalExecutionState.STANDBY, "goal", (GoalWaitTarget("sender", sender.created_at),), + GoalExecutionState.STANDBY, "goal", (GoalWaitTarget(sender.incarnation.require_recorded()),), )) comms.registry.declare(Thread("unrelated", frozenset(), str(tmp_path))) await tick() diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 890cc090ee..0172c080a9 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -91,6 +91,15 @@ def test_creation_lookup_is_cached_on_the_renamable_incarnation(tmp_path): assert isinstance(incarnation, RecordedThreadIncarnation) assert isinstance(deleted, DeletedThreadIncarnation) assert isinstance(historical, UnrecordedThreadIncarnation) + assert incarnation.birth_description == "created 1700000000.0" + assert deleted.birth_description == "deleted declaration" + assert historical.birth_description == "birth unrecorded" + target = GoalWaitTarget(incarnation) + assert (target.name, target.created_at) == ("worker", 1_700_000_000.0) + assert FieldCodec.encode(target) == {"name": "worker", "created_at": 1_700_000_000.0} + assert FieldCodec.decode(GoalWaitTarget, FieldCodec.encode(target)) == target + with pytest.raises(TypeError, match="exactly its declared wire fields"): + FieldCodec.decode(GoalWaitTarget, {**target.to_wire(), "lookup": incarnation.lookup}) assert FieldCodec.decode(ThreadIncarnation, FieldCodec.encode(deleted)) == deleted assert FieldCodec.decode(ThreadIncarnation, FieldCodec.encode(historical)) == historical with pytest.raises(ValueError, match="declared member"): @@ -99,7 +108,7 @@ def test_creation_lookup_is_cached_on_the_renamable_incarnation(tmp_path): with pytest.raises(ValueError, match="positive recorded birth"): deleted.require_recorded() with pytest.raises(ValueError, match="positive recorded birth"): - GoalWaitTarget("historical", 0.0) + GoalWaitTarget(RecordedThreadIncarnation("historical", 0.0)) def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_owner(tmp_path): diff --git a/tests/test_runtime_goal_snapshot.py b/tests/test_runtime_goal_snapshot.py index f73befb3a0..b3dd7fe8a7 100644 --- a/tests/test_runtime_goal_snapshot.py +++ b/tests/test_runtime_goal_snapshot.py @@ -4,7 +4,7 @@ import json import os -from dataclasses import asdict, replace +from dataclasses import replace import pytest @@ -65,7 +65,7 @@ async def test_goal_snapshot_reads_current_pair_without_mutation_or_scheduling(g result = await proxy.request("goal_snapshot") assert result == { "goal": json.loads(json.dumps(expected_goal.to_wire())), - "goalExecution": json.loads(json.dumps(asdict(expected_execution))), + "goalExecution": json.loads(json.dumps(expected_execution.to_wire())), "_meta": encode_updates(TurnChangedUpdate(comms.registry.require(session).turn_state)), } assert result["goalExecution"]["state"] == "standby" From 0702f09e70bd803d9662940e08a25ff6687bd0c1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 13:47:51 -0400 Subject: [PATCH 13/18] Own birth and deletion behavior in incarnation members --- src/agent_comms/acp_extension.py | 8 +- src/agent_comms/goal_actions.py | 6 +- src/agent_comms/goal_management.py | 6 +- src/agent_comms/goal_presentation.py | 12 +-- src/agent_comms/goal_waits.py | 12 +-- src/agent_comms/input_origin.py | 8 +- src/agent_comms/registry_provenance.py | 4 +- src/agent_comms/routing.py | 5 +- src/agent_comms/thread_identity.py | 143 +++++++++++++++++-------- src/agent_comms/transcript_receipts.py | 16 +-- src/agent_comms/turn_context.py | 4 +- tests/test_goal_standby_liveness.py | 4 +- tests/test_recipient_activity_index.py | 16 ++- 13 files changed, 145 insertions(+), 99 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index a356982dd9..0a14e832f6 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -136,7 +136,7 @@ def logical_key(self) -> tuple[str, ...]: ... @property def owner_created_at(self): - return self.admission.incarnation.created_at + return self.admission.incarnation.require_recorded().created_at @property def admission_generation(self): @@ -190,11 +190,11 @@ class CursorScope(AttachmentScope): def __post_init__(self): WireRootIdText.decode(self.wire_root_id) + self.admission.incarnation.require_recorded() if ( not self.session_id or self.owner_pid <= 0 or self.admission.admission_generation <= 0 - or self.admission.incarnation.created_at <= 0 ): raise ValueError("Invalid native cursor scope") @@ -471,9 +471,7 @@ class CoordinationChangedUpdate(AgentCommsUpdate): def source_identity(self): from .transcript_receipts import AssignedSourceIdentity - if self.thread.has_recorded_birth: - return AssignedSourceIdentity(self.wire_root, self.thread) - return None + return self.thread.assigned_source_identity(self.wire_root, AssignedSourceIdentity) @property def persistence(self) -> str: diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 18f6287864..8d14de5b70 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -257,10 +257,10 @@ def before_publish(self, goal: Goal | None, ctx: GoalActionContext) -> None: snapshot = ctx.goals.registry.snapshot() if not any( GoalWaits.target_has_active_turn(target, snapshot) - and snapshot.threads[snapshot.canonical_name(target.name)].process_alive + and snapshot.threads[snapshot.canonical_name(target.incarnation.name)].process_alive for target in wait_targets ): - names = ", ".join(f"@{target.name}" for target in wait_targets) + names = ", ".join(f"@{target.incarnation.name}" for target in wait_targets) raise ValueError( f"No declared dependency has an active turn ({names}). " "A running/ready process or queued input does not prove active work. " @@ -306,7 +306,7 @@ def before_publish(self, goal: Goal | None, ctx: GoalActionContext) -> None: target_turn_generations=tuple( ( snapshot.threads[ - snapshot.canonical_name(target.name) + snapshot.canonical_name(target.incarnation.name) ].turn_generation if GoalWaits.target_has_active_turn(target, snapshot) else None diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index f2b7c99806..2676c246aa 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -128,7 +128,7 @@ def _goal_input_review( if not wait_for: raise ValueError("Standby requires explicit wait_for thread names.") resolved = tuple(self.registry.require(target.removeprefix("@")) for target in wait_for) - if any(target.created_at == thread.created_at for target in resolved): + if any(target.incarnation == thread.incarnation for target in resolved): raise ValueError("A goal cannot wait for its own thread.") targets = tuple( dict.fromkeys(GoalWaitTarget(target.incarnation.require_recorded()) for target in resolved) @@ -266,8 +266,8 @@ def release_waits_after_terminal_turn(self, fence: FinishedTurnFence | None) -> wait is None or not wait.current_for(goal, owner.created_at) or not any( - snapshot.canonical_name(target.name) == canonical - and target.created_at == fence.identity.incarnation.created_at + target.incarnation.resolved(snapshot) + == fence.identity.incarnation.resolved(snapshot) and (generation := wait.target_turn_generations[index]) is not None and 0 < generation <= fence.identity.generation for index, target in enumerate(wait.targets) diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 581b9d021b..98f25c9c0e 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -52,8 +52,8 @@ class StandbyExecutionPresentation(ExecutionPresentation): waiting = True def render(self, execution: GoalExecution) -> tuple[str, str]: - names = ", ".join(f"@{target.name}" for target in execution.wait_for) - idle = ", ".join(f"@{target.name}" for target in execution.inactive_wait_for) + names = ", ".join(f"@{target.incarnation.name}" for target in execution.wait_for) + idle = ", ".join(f"@{target.incarnation.name}" for target in execution.inactive_wait_for) suffix = f"; no active turn: {idle}" if idle else "" return "◌", f"Standby · waiting for {names}{suffix}" @@ -87,14 +87,6 @@ def for_domain_state(cls, state: GoalState) -> GoalExecutionState: class GoalWaitTarget(WireValue): incarnation: RecordedThreadIncarnation - @property - def name(self) -> str: - return self.incarnation.name - - @property - def created_at(self) -> float: - return self.incarnation.created_at - def sent(self, original: CommittedDelivery) -> bool: """Only this declared incarnation can supply a dependency reply.""" return original.audience.sender_lookup == self.incarnation.lookup diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index 9e52f7a6ec..ec6ef53e4d 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -110,7 +110,7 @@ def consume_reply(self, waits: GoalWaits, goal_id: str) -> bool: def project(self, goal: Goal, snapshot: RegistrySnapshot) -> GoalExecution: targets = tuple( - GoalWaitTarget(target.incarnation.renamed(snapshot.canonical_name(target.name))) + GoalWaitTarget(target.incarnation.renamed(snapshot.canonical_name(target.incarnation.name))) for target in self.targets ) inactive = tuple( @@ -179,7 +179,7 @@ def public(self) -> dict: eligible.append(item) return { "goal_id": self.goal_id, - "wait_for": [target.name for target in self.targets], + "wait_for": [target.incarnation.name for target in self.targets], "reviewed_inputs": [item["inputId"] for item in eligible], "messages": eligible, "already_reviewed_inputs": reviewed, @@ -234,12 +234,12 @@ def for_goal(goal: Goal, rows: dict[str, RecordedGoalWait]) -> GoalWait: @staticmethod def target_has_active_turn(target: GoalWaitTarget, snapshot: RegistrySnapshot) -> bool: - canonical = snapshot.canonical_name(target.name) + canonical = snapshot.canonical_name(target.incarnation.name) thread = snapshot.threads.get(canonical) status = snapshot.statuses.get(canonical) return bool( thread is not None - and thread.created_at == target.created_at + and target.incarnation.resolved(snapshot) == thread.incarnation and status is not None and status.running and thread.executing @@ -276,9 +276,9 @@ def closed_wait_group( wait = None dependencies = targets if name == owner else (wait.targets if wait is not None else ()) for target in dependencies: - canonical = snapshot.canonical_name(target.name) + canonical = snapshot.canonical_name(target.incarnation.name) peer = snapshot.threads.get(canonical) - if peer is None or peer.created_at != target.created_at: + if peer is None or target.incarnation.resolved(snapshot) != peer.incarnation: continue if canonical == owner: pending.append(owner) diff --git a/src/agent_comms/input_origin.py b/src/agent_comms/input_origin.py index 83fc667cdf..8f9b4ba024 100644 --- a/src/agent_comms/input_origin.py +++ b/src/agent_comms/input_origin.py @@ -150,7 +150,7 @@ def applies(self, owner: Thread, snapshot: RegistrySnapshot) -> bool: def matches_owner(self, recorded_name, incarnation): """The captured original birth survives a rename, unlike a neutral name.""" - self.admission.incarnation.require_recorded() - incarnation.require_recorded() - return (recorded_name == self.admission.incarnation.name - and self.admission.incarnation.created_at == incarnation.created_at) + admitted = self.admission.incarnation.require_recorded() + original = incarnation.require_recorded() + return (recorded_name == admitted.name + and admitted.created_at == original.created_at) diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index 5e13938229..ff80824ef2 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -147,9 +147,7 @@ def displayed_recipient_effect(self, reads, message, thread: ThreadProvenance, * def historical_recipient(self, recipient: FrozenRecipient) -> ArchivedRecipient | RetiredRecipient: """Archives select by original name and birth; duplicate births stay distinct.""" thread = self.threads.get(self.canonical_name(recipient.canonical_thread)) - if thread is not None and thread.incarnation.has_recorded_birth and ( - thread.incarnation.require_recorded().lookup == recipient.recipient_lookup - ): + if thread is not None and thread.incarnation.selects_lookup(recipient.recipient_lookup): return ArchivedRecipient(thread) return RetiredRecipient(recipient.recipient_lookup) diff --git a/src/agent_comms/routing.py b/src/agent_comms/routing.py index be5bc17b1d..cd7375ba94 100644 --- a/src/agent_comms/routing.py +++ b/src/agent_comms/routing.py @@ -217,10 +217,7 @@ def sender_lookups( probe for an assigned stamp; admission must refuse that unproven stamp. """ yield "" - if source.has_recorded_birth: - yield source.lookup - else: - on_unrecorded() + yield from source.route_lookup(on_unrecorded) def minimum_timestamp( self, sender: str, target: str, sender_lookup: str, snapshot: RegistryProvenance diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index babca1df03..9c7f439481 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -12,7 +12,7 @@ from dataclasses import dataclass, field, fields, replace from enum import StrEnum from functools import cached_property -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Callable, ClassVar from .channel_targets import Tag from .errors import RelationViolationError @@ -20,31 +20,28 @@ if TYPE_CHECKING: from .registry_provenance import RegistryProvenance + from .transcript_receipts import AssignedSourceIdentity, TranscriptSource @dataclass(frozen=True, slots=True) class ThreadIncarnation(WireValue, ABC): name: str - created_at: float + + @property + @abstractmethod + def wire_birth(self) -> float: + """Only the external spelling of this member's birth evidence.""" def to_wire(self) -> dict[str, str | float]: - return {"name": self.name, "created_at": self.created_at} + return {"name": self.name, "created_at": self.wire_birth} @classmethod def schema(cls) -> dict: - return FieldCodec.record_schema(ThreadIncarnation) + return FieldCodec.record_schema(ThreadIncarnationRecord) @classmethod def from_wire(cls, data: dict) -> ThreadIncarnation: - declared = {key for _, key in FieldCodec.record_fields(ThreadIncarnation)} - if type(data) is not dict or data.keys() != declared: - raise TypeError("Thread incarnation requires exactly its declared wire fields") - decoded = ThreadIncarnationRecord.decode(data["name"], data["created_at"]) - if not isinstance(decoded, cls): - raise ValueError("Decoded thread birth does not match its declared member") - return decoded - names_deleted_thread: ClassVar[bool] = False - has_recorded_birth: ClassVar[bool] = False + return FieldCodec.decode(ThreadIncarnationRecord, data).incarnation() @classmethod def deleted(cls, name: str) -> DeletedThreadIncarnation: @@ -53,8 +50,6 @@ def deleted(cls, name: str) -> DeletedThreadIncarnation: def __post_init__(self) -> None: if not FieldCodec.decode(str, self.name): raise ValueError("Thread incarnation requires a recorded name") - if not math.isfinite(FieldCodec.decode(float, self.created_at)): - raise ValueError("Thread incarnation requires a finite birth") @abstractmethod def require_recorded(self) -> RecordedThreadIncarnation: @@ -75,17 +70,34 @@ def renamed(self, name: str) -> ThreadIncarnation: def resolved(self, snapshot: RegistryProvenance) -> ThreadIncarnation: """Follow retained rename aliases only for this exact historical owner.""" - if self.names_deleted_thread or not self.current(snapshot): + if not self.current(snapshot): return self return snapshot.threads[snapshot.canonical_name(self.name)].incarnation def current(self, snapshot: RegistryProvenance) -> bool: thread = snapshot.threads.get(snapshot.canonical_name(self.name)) - return ( - thread is None - if self.names_deleted_thread - else thread is not None and thread.created_at == self.created_at - ) + return thread is not None and thread.incarnation == self.renamed(thread.name) + + def route_lookup(self, on_missing: Callable[[], None]) -> tuple[str, ...]: + on_missing() + return () + + def selects_lookup(self, lookup: str) -> bool: + return False + + def assigned_source_identity( + self, root: str, source_type: type[AssignedSourceIdentity] + ) -> AssignedSourceIdentity | None: + return None + + def transcript_source( + self, make: Callable[[RecordedThreadIncarnation], TranscriptSource], + missing: TranscriptSource, + ) -> TranscriptSource: + return missing + + def same_publication_birth(self, other: ThreadIncarnation) -> bool: + return False def matches_recorded_name(self, name: str, snapshot: RegistryProvenance) -> bool: """Relate a partial stored name to this original current incarnation. @@ -108,11 +120,39 @@ def recorded_names(self, snapshot: RegistryProvenance) -> tuple[ThreadIncarnatio class RecordedThreadIncarnation(ThreadIncarnation): """Only a positive recorded birth can select a durable lookup.""" - has_recorded_birth: ClassVar[bool] = True + created_at: float + + @property + def wire_birth(self) -> float: + return self.created_at + + @classmethod + def from_wire(cls, data: dict) -> RecordedThreadIncarnation: + return ThreadIncarnation.from_wire(data).require_recorded() def require_recorded(self) -> RecordedThreadIncarnation: return self + def route_lookup(self, on_missing: Callable[[], None]) -> tuple[str, ...]: + return (self.lookup,) + + def selects_lookup(self, lookup: str) -> bool: + return self.lookup == lookup + + def assigned_source_identity( + self, root: str, source_type: type[AssignedSourceIdentity] + ) -> AssignedSourceIdentity: + return source_type(root, self) + + def transcript_source( + self, make: Callable[[RecordedThreadIncarnation], TranscriptSource], + missing: TranscriptSource, + ) -> TranscriptSource: + return make(self) + + def same_publication_birth(self, other: ThreadIncarnation) -> bool: + return self.renamed(other.name) == other + @property def birth_description(self) -> str: return f"created {self.created_at}" @@ -125,18 +165,9 @@ def renamed(self, name: str) -> RecordedThreadIncarnation: def __post_init__(self) -> None: super().__post_init__() - self._require_recorded_birth(self.created_at) - - @staticmethod - def _require_recorded_birth(created_at: float) -> float: - if ( - isinstance(created_at, bool) - or not isinstance(created_at, (int, float)) - or not math.isfinite(created_at) - or created_at <= 0 - ): + birth = FieldCodec.decode(float, self.created_at) + if not math.isfinite(birth) or birth <= 0: raise ValueError("Thread incarnation requires a positive recorded birth") - return float(created_at) @cached_property def lookup(self) -> str: @@ -149,8 +180,17 @@ def lookup(self) -> str: class DeletedThreadIncarnation(ThreadIncarnation): """A deleted participant retains only its name, never a lookup capability.""" - created_at: float = field(default=-1.0, init=False) - names_deleted_thread: ClassVar[bool] = True + WIRE_BIRTH: ClassVar[float] = -1.0 + + @property + def wire_birth(self) -> float: + return self.WIRE_BIRTH + + def current(self, snapshot: RegistryProvenance) -> bool: + return snapshot.canonical_name(self.name) not in snapshot.threads + + def resolved(self, snapshot: RegistryProvenance) -> DeletedThreadIncarnation: + return self def require_recorded(self) -> RecordedThreadIncarnation: raise ValueError("Thread incarnation requires a positive recorded birth") @@ -170,7 +210,11 @@ def renamed(self, name: str) -> DeletedThreadIncarnation: class UnrecordedThreadIncarnation(ThreadIncarnation): """Historical zero-birth evidence cannot select a live recipient.""" - created_at: float = field(default=0.0, init=False) + WIRE_BIRTH: ClassVar[float] = 0.0 + + @property + def wire_birth(self) -> float: + return self.WIRE_BIRTH def require_recorded(self) -> RecordedThreadIncarnation: raise ValueError("Thread incarnation requires a positive recorded birth") @@ -205,7 +249,7 @@ def provenance_type(self) -> type[ThreadProvenance]: return ThreadProvenance def __post_init__(self) -> None: - if self.incarnation.created_at != self.created_at: + if self.incarnation.wire_birth != self.created_at: raise ValueError("Thread provenance birth differs from its declared state") allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") if not self.name or not set(self.name) <= allowed: @@ -252,18 +296,23 @@ def provenance_type(self) -> type[ThreadProvenance]: return DeletedThreadProvenance +@dataclass(frozen=True, slots=True) class ThreadIncarnationRecord: - """One explicit boundary decoder for stored birth numbers, never a constructor switch.""" + """The single exact external name/birth record; members own its meaning.""" - @staticmethod - def decode(name: str, birth: float) -> ThreadIncarnation: - name = FieldCodec.decode(str, name) - birth = FieldCodec.decode(float, birth) - if birth == -1: - return DeletedThreadIncarnation(name) - if birth == 0: - return UnrecordedThreadIncarnation(name) - return RecordedThreadIncarnation(name, birth) + name: str + created_at: float + + def incarnation(self) -> ThreadIncarnation: + if self.created_at == DeletedThreadIncarnation.WIRE_BIRTH: + return DeletedThreadIncarnation(self.name) + if self.created_at == UnrecordedThreadIncarnation.WIRE_BIRTH: + return UnrecordedThreadIncarnation(self.name) + return RecordedThreadIncarnation(self.name, self.created_at) + + @classmethod + def decode(cls, name: str, birth: float) -> ThreadIncarnation: + return FieldCodec.decode(cls, {"name": name, "created_at": birth}).incarnation() class ThreadProvenanceRecord(FieldRepresentation): diff --git a/src/agent_comms/transcript_receipts.py b/src/agent_comms/transcript_receipts.py index 6fa31a6f89..4804040766 100644 --- a/src/agent_comms/transcript_receipts.py +++ b/src/agent_comms/transcript_receipts.py @@ -35,10 +35,10 @@ class AssignedSourceIdentity: @property def publication_key(self) -> tuple[str, float]: """Published authorship survives a rename, not a new birth or wire.""" - return self.root, self.recipient.created_at + return self.root, self.recipient.require_recorded().created_at def same_publication_source(self, other: AssignedSourceIdentity) -> bool: - return self.recipient.has_recorded_birth and self.publication_key == other.publication_key + return self.root == other.root and self.recipient.same_publication_birth(other.recipient) @dataclass(frozen=True) @@ -285,8 +285,10 @@ def open(cls, identity: AssignedSourceIdentity): from .wire_log import WireLog root = Path(identity.root) - return (cls(root, identity.recipient, WireLog(root / "bus.jsonl")) - if identity.recipient.has_recorded_birth else TranscriptSource()) + return identity.recipient.transcript_source( + lambda recorded: cls(root, recorded, WireLog(root / "bus.jsonl")), + TranscriptSource(), + ) def resolve_sent(self, messages): """Qualify reports against exact full rows in one bounded wire read. @@ -324,9 +326,9 @@ def require_cursor(self, cursor): @classmethod def for_recorded(cls, root, thread, log, records): """Only the original recorded birth can select frozen assignments.""" - if thread.incarnation.has_recorded_birth: - return cls.for_thread(root, thread, log) - return records + return thread.incarnation.transcript_source( + lambda recorded: cls(root, recorded, log), records + ) def rows( self, predicate="1", parameters=(), *, limit=1, ascending=False, diff --git a/src/agent_comms/turn_context.py b/src/agent_comms/turn_context.py index 195b060c63..5cc1cce6d2 100644 --- a/src/agent_comms/turn_context.py +++ b/src/agent_comms/turn_context.py @@ -937,8 +937,8 @@ def same_recording(self, other: ContextTurn) -> bool: previous = other.require_recorded() return (selected.identity == previous.identity and selected.occurrence.generation == previous.occurrence.generation - and selected.occurrence.incarnation.created_at - == previous.occurrence.incarnation.created_at) + and selected.occurrence.incarnation.require_recorded().created_at + == previous.occurrence.incarnation.require_recorded().created_at) def matches_generation(self, generation: int) -> bool: return False diff --git a/tests/test_goal_standby_liveness.py b/tests/test_goal_standby_liveness.py index a4a6ceea5d..92ecb6c38f 100644 --- a/tests/test_goal_standby_liveness.py +++ b/tests/test_goal_standby_liveness.py @@ -178,7 +178,7 @@ def test_mixed_targets_project_idle_without_suppressing_active_alternative(tmp_p comms, goal = _waiting(tmp_path, second=True) other_fence = _finish(comms, "other", "other-turn") execution = comms.goals.goal_execution("owner") - assert [target.name for target in execution.inactive_wait_for] == ["other"] + assert [target.incarnation.name for target in execution.inactive_wait_for] == ["other"] assert "no active turn: @other" in execution.presentation("owner").summary assert comms.goals.release_waits_after_terminal_turn(other_fence) == () assert comms.registry.require("owner").goal.state.active @@ -276,7 +276,7 @@ def test_replaced_target_cannot_report_for_old_incarnation(tmp_path): assert replacement.created_at != old.created_at assert comms.goals.release_waits_after_terminal_turn(old_fence) == () execution = comms.goals.goal_execution("owner") - assert [target.name for target in execution.inactive_wait_for] == ["child"] + assert [target.incarnation.name for target in execution.inactive_wait_for] == ["child"] assert comms.registry.require("owner").goal.id == goal.id # Removal/replacement has no subscribed terminal hook in this prototype. assert comms.registry.require("owner").goal.state.active diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index 0172c080a9..df0ef71fae 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -94,15 +94,25 @@ def test_creation_lookup_is_cached_on_the_renamable_incarnation(tmp_path): assert incarnation.birth_description == "created 1700000000.0" assert deleted.birth_description == "deleted declaration" assert historical.birth_description == "birth unrecorded" + assert not hasattr(deleted, "created_at") and not hasattr(historical, "created_at") + assert (deleted.wire_birth, historical.wire_birth) == (-1.0, 0.0) + assert incarnation.same_publication_birth(incarnation.renamed("alias")) + assert not incarnation.same_publication_birth(historical) + missing = [] + assert deleted.route_lookup(lambda: missing.append("deleted")) == () + assert missing == ["deleted"] + assert incarnation.route_lookup(lambda: missing.append("recorded")) == (incarnation.lookup,) + assert missing == ["deleted"] target = GoalWaitTarget(incarnation) - assert (target.name, target.created_at) == ("worker", 1_700_000_000.0) + assert not hasattr(target, "name") and not hasattr(target, "created_at") + assert (target.incarnation.name, target.incarnation.created_at) == ("worker", 1_700_000_000.0) assert FieldCodec.encode(target) == {"name": "worker", "created_at": 1_700_000_000.0} assert FieldCodec.decode(GoalWaitTarget, FieldCodec.encode(target)) == target - with pytest.raises(TypeError, match="exactly its declared wire fields"): + with pytest.raises(ValueError, match="Unknown fields for ThreadIncarnationRecord"): FieldCodec.decode(GoalWaitTarget, {**target.to_wire(), "lookup": incarnation.lookup}) assert FieldCodec.decode(ThreadIncarnation, FieldCodec.encode(deleted)) == deleted assert FieldCodec.decode(ThreadIncarnation, FieldCodec.encode(historical)) == historical - with pytest.raises(ValueError, match="declared member"): + with pytest.raises(ValueError, match="positive recorded birth"): FieldCodec.decode(RecordedThreadIncarnation, FieldCodec.encode(historical)) assert not hasattr(deleted, "lookup") and not hasattr(historical, "lookup") with pytest.raises(ValueError, match="positive recorded birth"): From 276c9fe9ca52df75cafed23cd4d91760cab8f052 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 14:09:41 -0400 Subject: [PATCH 14/18] Keep incarnation member choice independent of consumers --- src/agent_comms/acp_extension.py | 4 ++- src/agent_comms/routing.py | 6 +++- src/agent_comms/thread_identity.py | 40 +++++++------------------- src/agent_comms/transcript_receipts.py | 8 +++--- tests/test_recipient_activity_index.py | 8 ++++-- 5 files changed, 29 insertions(+), 37 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 0a14e832f6..3f3c5af314 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -471,7 +471,9 @@ class CoordinationChangedUpdate(AgentCommsUpdate): def source_identity(self): from .transcript_receipts import AssignedSourceIdentity - return self.thread.assigned_source_identity(self.wire_root, AssignedSourceIdentity) + return self.thread.recorded_or( + lambda recorded: AssignedSourceIdentity(self.wire_root, recorded), lambda: None + ) @property def persistence(self) -> str: diff --git a/src/agent_comms/routing.py b/src/agent_comms/routing.py index cd7375ba94..9598773760 100644 --- a/src/agent_comms/routing.py +++ b/src/agent_comms/routing.py @@ -216,8 +216,12 @@ def sender_lookups( The unstamped format matches before a birth is required. An index must probe for an assigned stamp; admission must refuse that unproven stamp. """ + def missing() -> tuple[str, ...]: + on_unrecorded() + return () + yield "" - yield from source.route_lookup(on_unrecorded) + yield from source.recorded_or(lambda recorded: (recorded.lookup,), missing) def minimum_timestamp( self, sender: str, target: str, sender_lookup: str, snapshot: RegistryProvenance diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 9c7f439481..ad6c38a7b4 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -12,7 +12,7 @@ from dataclasses import dataclass, field, fields, replace from enum import StrEnum from functools import cached_property -from typing import TYPE_CHECKING, Callable, ClassVar +from typing import TYPE_CHECKING, Callable, ClassVar, TypeVar from .channel_targets import Tag from .errors import RelationViolationError @@ -20,7 +20,8 @@ if TYPE_CHECKING: from .registry_provenance import RegistryProvenance - from .transcript_receipts import AssignedSourceIdentity, TranscriptSource + +T = TypeVar("T") @dataclass(frozen=True, slots=True) @@ -78,24 +79,14 @@ def current(self, snapshot: RegistryProvenance) -> bool: thread = snapshot.threads.get(snapshot.canonical_name(self.name)) return thread is not None and thread.incarnation == self.renamed(thread.name) - def route_lookup(self, on_missing: Callable[[], None]) -> tuple[str, ...]: - on_missing() - return () + def recorded_or( + self, make: Callable[[RecordedThreadIncarnation], T], otherwise: Callable[[], T] + ) -> T: + return otherwise() def selects_lookup(self, lookup: str) -> bool: return False - def assigned_source_identity( - self, root: str, source_type: type[AssignedSourceIdentity] - ) -> AssignedSourceIdentity | None: - return None - - def transcript_source( - self, make: Callable[[RecordedThreadIncarnation], TranscriptSource], - missing: TranscriptSource, - ) -> TranscriptSource: - return missing - def same_publication_birth(self, other: ThreadIncarnation) -> bool: return False @@ -133,23 +124,14 @@ def from_wire(cls, data: dict) -> RecordedThreadIncarnation: def require_recorded(self) -> RecordedThreadIncarnation: return self - def route_lookup(self, on_missing: Callable[[], None]) -> tuple[str, ...]: - return (self.lookup,) + def recorded_or( + self, make: Callable[[RecordedThreadIncarnation], T], otherwise: Callable[[], T] + ) -> T: + return make(self) def selects_lookup(self, lookup: str) -> bool: return self.lookup == lookup - def assigned_source_identity( - self, root: str, source_type: type[AssignedSourceIdentity] - ) -> AssignedSourceIdentity: - return source_type(root, self) - - def transcript_source( - self, make: Callable[[RecordedThreadIncarnation], TranscriptSource], - missing: TranscriptSource, - ) -> TranscriptSource: - return make(self) - def same_publication_birth(self, other: ThreadIncarnation) -> bool: return self.renamed(other.name) == other diff --git a/src/agent_comms/transcript_receipts.py b/src/agent_comms/transcript_receipts.py index 4804040766..e38576aee1 100644 --- a/src/agent_comms/transcript_receipts.py +++ b/src/agent_comms/transcript_receipts.py @@ -285,9 +285,9 @@ def open(cls, identity: AssignedSourceIdentity): from .wire_log import WireLog root = Path(identity.root) - return identity.recipient.transcript_source( + return identity.recipient.recorded_or( lambda recorded: cls(root, recorded, WireLog(root / "bus.jsonl")), - TranscriptSource(), + TranscriptSource, ) def resolve_sent(self, messages): @@ -326,8 +326,8 @@ def require_cursor(self, cursor): @classmethod def for_recorded(cls, root, thread, log, records): """Only the original recorded birth can select frozen assignments.""" - return thread.incarnation.transcript_source( - lambda recorded: cls(root, recorded, log), records + return thread.incarnation.recorded_or( + lambda recorded: cls(root, recorded, log), lambda: records ) def rows( diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index df0ef71fae..27ee499d5c 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -99,9 +99,13 @@ def test_creation_lookup_is_cached_on_the_renamable_incarnation(tmp_path): assert incarnation.same_publication_birth(incarnation.renamed("alias")) assert not incarnation.same_publication_birth(historical) missing = [] - assert deleted.route_lookup(lambda: missing.append("deleted")) == () + assert deleted.recorded_or( + lambda recorded: recorded.lookup, lambda: missing.append("deleted") + ) is None assert missing == ["deleted"] - assert incarnation.route_lookup(lambda: missing.append("recorded")) == (incarnation.lookup,) + assert incarnation.recorded_or( + lambda recorded: recorded.lookup, lambda: missing.append("recorded") + ) == incarnation.lookup assert missing == ["deleted"] target = GoalWaitTarget(incarnation) assert not hasattr(target, "name") and not hasattr(target, "created_at") From 0668349c8f8d6aa00b6c3bb9e970ef3ab8c3d2aa Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 14:13:12 -0400 Subject: [PATCH 15/18] Leave transcript source choice with the Toad caller --- src/agent_comms/acp_extension.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 3f3c5af314..332370bd4d 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -467,14 +467,6 @@ class CoordinationChangedUpdate(AgentCommsUpdate): title: str context_usage: ContextUsage | None - @property - def source_identity(self): - from .transcript_receipts import AssignedSourceIdentity - - return self.thread.recorded_or( - lambda recorded: AssignedSourceIdentity(self.wire_root, recorded), lambda: None - ) - @property def persistence(self) -> str: return "shared on-disk wire" From c6a606906d55e657a6e27a1fba820393bdc33464 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 14:50:19 -0400 Subject: [PATCH 16/18] Keep wait and roster projections with their owners; retire stale CLI fixture --- src/agent_comms/goal_waits.py | 16 +++++++++++++--- src/agent_comms/history_views.py | 13 +++++-------- src/agent_comms/presentation.py | 25 +++++++++++++++---------- tests/test_private_nk_entrypoint.py | 1 - 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index ec6ef53e4d..5595f8bd9f 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -132,15 +132,16 @@ def current_for(self, goal: Goal, owner_created_at: float) -> bool: def matches(self, original: CommittedDelivery, owner: RecordedThreadIncarnation) -> bool: """Join original sender and the actual addressed owner through its source.""" + if not self.owner_matches(owner.created_at): + return False return ( - owner.created_at == self.owner_created_at - and original.message.seq > self.after_seq + original.message.seq > self.after_seq and original.direct_for(owner.lookup) and any(target.sent(original) for target in self.targets) ) def has_reply(self, bus: WireLog, owner: RecordedThreadIncarnation) -> bool: - if owner.created_at != self.owner_created_at: + if not self.owner_matches(owner.created_at): return False return any( self.matches(original, owner) @@ -232,6 +233,15 @@ def for_goal(goal: Goal, rows: dict[str, RecordedGoalWait]) -> GoalWait: return NoGoalWait() return rows.get(goal.id, NoGoalWait()) + @classmethod + def execution( + cls, goal: Goal | None, rows: dict[str, RecordedGoalWait], snapshot: RegistrySnapshot + ) -> GoalExecution | None: + """Project an optional thread goal from its resolved wait state.""" + if goal is None: + return None + return cls.for_goal(goal, rows).project(goal, snapshot) + @staticmethod def target_has_active_turn(target: GoalWaitTarget, snapshot: RegistrySnapshot) -> bool: canonical = snapshot.canonical_name(target.incarnation.name) diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index 544f5fdece..73ccf81ab7 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -314,12 +314,9 @@ def channel_views( catalog.pinned_members(), catalog.list_order, self.agents.all_activity( - (thread for thread in snapshot.threads.values() - if ThreadView.visible( - thread, snapshot, show_stopped=show_stopped, - show_archived=show_archived, - )), - snapshot=snapshot, + ThreadView.visible_threads( + snapshot, show_stopped=show_stopped, show_archived=show_archived + ), snapshot=snapshot, ), self.last_sent_timestamps(), ChannelActivity.for_views(channels, self.bus.channel_activity()), @@ -558,8 +555,8 @@ def list_threads(self, active_only: bool = False) -> Sequence[Mapping]: "is_fork": t.is_fork, "pending": pending[name], "goal_execution": ( - GoalWaits.for_goal(t.goal, waits).project(t.goal, snapshot).to_wire() - if t.goal is not None else None + execution.to_wire() + if (execution := GoalWaits.execution(t.goal, waits, snapshot)) else None ), "activity": activities[name].state.value if name in activities else "idle", "activity_detail": activities[name].detail if name in activities else "", diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 271d845c5d..e59d39ce15 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -663,6 +663,15 @@ def visible( show_stopped=show_stopped, show_archived=show_archived ) + @classmethod + def visible_threads( + cls, snapshot: RegistrySnapshot, *, show_stopped: bool, show_archived: bool + ) -> Iterator[Thread]: + return ( + thread for thread in snapshot.threads.values() + if cls.visible(thread, snapshot, show_stopped=show_stopped, show_archived=show_archived) + ) + @classmethod def capture( cls, @@ -679,8 +688,7 @@ def capture( activity, runtime, snapshot.seen_at(thread.name), - (GoalWaits.for_goal(thread.goal, waits).project(thread.goal, snapshot) - if thread.goal is not None else None), + GoalWaits.execution(thread.goal, waits, snapshot), snapshot.owner_binding(thread.name), ) @@ -698,9 +706,9 @@ def roster( ) -> tuple[ThreadView, ...]: selected = None if names is None else {snapshot.canonical_name(name) for name in names} threads = tuple( - thread for name, thread in snapshot.threads.items() - if (selected is None or name in selected) - and cls.visible(thread, snapshot, show_stopped=show_stopped, show_archived=show_archived) + thread for thread in cls.visible_threads( + snapshot, show_stopped=show_stopped, show_archived=show_archived + ) if selected is None or thread.name in selected ) if not threads: return () @@ -777,11 +785,8 @@ def capture( show_stopped: bool, show_archived: bool, read_marker_notice: str | None = None, ) -> CoordinationSnapshot: """Assemble both rosters from the caller's acquired identity and read cut.""" - visible = ( - thread for thread in registry.threads.values() - if ThreadView.visible( - thread, registry, show_stopped=show_stopped, show_archived=show_archived - ) + visible = ThreadView.visible_threads( + registry, show_stopped=show_stopped, show_archived=show_archived ) activities = agents.all_activity(visible, snapshot=registry) return cls( diff --git a/tests/test_private_nk_entrypoint.py b/tests/test_private_nk_entrypoint.py index 37781f2875..0656b4f888 100644 --- a/tests/test_private_nk_entrypoint.py +++ b/tests/test_private_nk_entrypoint.py @@ -351,7 +351,6 @@ def test_default_cli_send_holds_route_guard_until_bus_append(tmp_path, monkeypat monkeypatch.setattr(active_route, "active_route_path", lambda: route_file) monkeypatch.setattr(cohort_foreground, "_trusted_package", lambda _: None) monkeypatch.setattr(cli, "wire", lambda _root=None: legacy) - monkeypatch.setattr(cli, "_emit", lambda _payload: None) entered_send = threading.Event() release_send = threading.Event() exclusive_requested = threading.Event() From 5dc15763744ce9014aa9cf66749b2227cb3c64c3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 14:53:44 -0400 Subject: [PATCH 17/18] Keep route-guard fixture aligned with message send signature --- tests/test_private_nk_entrypoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_private_nk_entrypoint.py b/tests/test_private_nk_entrypoint.py index 616fb00969..81164435fe 100644 --- a/tests/test_private_nk_entrypoint.py +++ b/tests/test_private_nk_entrypoint.py @@ -358,10 +358,10 @@ def test_default_cli_send_holds_route_guard_until_bus_append(tmp_path, monkeypat original_send = legacy.messaging.send original_flock = active_route.fcntl.flock - def delayed_send(*args): + def delayed_send(*args, **kwargs): entered_send.set() assert release_send.wait(timeout=5) - return original_send(*args) + return original_send(*args, **kwargs) def observed_flock(fd, operation): if operation == active_route.fcntl.LOCK_EX: From 940999d84cbecffe5ce07d8cd82271ab91a261d4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 14:58:21 -0400 Subject: [PATCH 18/18] Pass recorded recipient directly to native publication revision --- src/agent_comms/native_transcript.py | 9 ++++++--- src/agent_comms/transcript_receipts.py | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/agent_comms/native_transcript.py b/src/agent_comms/native_transcript.py index be6f55101e..09551dcc3c 100644 --- a/src/agent_comms/native_transcript.py +++ b/src/agent_comms/native_transcript.py @@ -8,6 +8,10 @@ from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .thread_identity import RecordedThreadIncarnation from .declared_family import DeclaredFamily from .native_entries import NativeEntry, SessionEntry @@ -166,13 +170,12 @@ def imported_sources(self): def cut(self) -> SessionCut: return SessionCut(FileRevision.capture(self.path)) - def publication_revision(self, root: Path, thread): + def publication_revision(self, root: Path, recipient: RecordedThreadIncarnation): from .native_runtime_input import NativeRuntimeInput, PublishedReplyRevision if not self.path.is_file(): return PublishedReplyRevision(0, 0) - return NativeRuntimeInput.publication_revision( - root, self, thread.incarnation.lookup) + return NativeRuntimeInput.publication_revision(root, self, recipient.lookup) @contextmanager def projection(self, receipts, routes): diff --git a/src/agent_comms/transcript_receipts.py b/src/agent_comms/transcript_receipts.py index e38576aee1..ff0ac74ca6 100644 --- a/src/agent_comms/transcript_receipts.py +++ b/src/agent_comms/transcript_receipts.py @@ -18,7 +18,7 @@ from .bus_publication import CommittedDelivery from .routing import MessageRoute, TurnRouting -from .thread_identity import ThreadIncarnation +from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation from .transcript_events import AssistantTranscript, IncomingTranscript, SentTranscript, UserTranscript from .wire_log import ConversationSourceWindowRequest @@ -272,12 +272,12 @@ def native_events(self, record, routes, project_events, references): @dataclass(frozen=True) class AssignedTranscriptSource(TranscriptSource): root: Path - recipient: ThreadIncarnation + recipient: RecordedThreadIncarnation log: WireLog @classmethod def for_thread(cls, root, thread, log): - return cls(root, thread.incarnation, log) + return cls(root, thread.incarnation.require_recorded(), log) @classmethod def open(cls, identity: AssignedSourceIdentity):