diff --git a/src/agent_comms/acp.py b/src/agent_comms/acp.py index e27985c51b..628b45d57a 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.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.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 c2403f8a74..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] = ()) -> dict[str, Activity]: - """Latest activity per thread (idle included for known threads).""" - 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, *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 b5fe4d1b63..6c251770d7 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -9,11 +9,13 @@ 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 from .activity import ( @@ -22,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 @@ -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( @@ -127,30 +128,31 @@ 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() - events = self.activity.all_current( - active=frozenset(name for name, thread in snapshot.threads.items() if thread.executing), - threads=snapshot.threads, + 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.current_for( + requested, + active=frozenset(name for name, thread in requested.items() if thread.executing), ) 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: + 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 original, recipient in recipients: + lookup = recipient.recipient_lookup + if lookup in observations: continue - observations[lookup] = thread.execution.observe_recipient(self, snapshot, thread) + selected = snapshot.recipient(recipient, original) + observations[lookup] = selected.activity(self, snapshot) 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..02e97a99e1 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.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_activity_index.py b/src/agent_comms/bus_activity_index.py index 39073cfbdd..dc54d18ad2 100644 --- a/src/agent_comms/bus_activity_index.py +++ b/src/agent_comms/bus_activity_index.py @@ -109,9 +109,7 @@ def for_views(cls, channels: Mapping[str, Channel], """Combine target-owned clocks using each declaration's history membership.""" result = {} for name, channel in channels.items(): - targets = channel.history_targets - clocks = (tuple(by_target.values()) if targets is None - else tuple(by_target.get(target, cls()) for target in targets)) + clocks = tuple(channel.history_targets.matching_values(by_target)) result[name] = cls(max((item.last_message for item in clocks), default=0), max((item.last_user_input for item in clocks), default=0)) return result diff --git a/src/agent_comms/bus_display_index.py b/src/agent_comms/bus_display_index.py index f0ceb232ef..a24fbd7963 100644 --- a/src/agent_comms/bus_display_index.py +++ b/src/agent_comms/bus_display_index.py @@ -52,7 +52,7 @@ class DisplayCheckpoint(AppendCheckpoint): semantics: DisplayMetricScope activity: dict[str, tuple[float, float]] counts: dict[str, int] - schema: Literal[3] = field(default=3, kw_only=True) + schema: Literal[4] = field(default=4, kw_only=True) def __post_init__(self) -> None: super().__post_init__() diff --git a/src/agent_comms/bus_page_index.py b/src/agent_comms/bus_page_index.py index 38eba8591e..d5ea6652e5 100644 --- a/src/agent_comms/bus_page_index.py +++ b/src/agent_comms/bus_page_index.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import TYPE_CHECKING, BinaryIO, Literal +from .channel_targets import HistoryTargets from .coordination_database import CoordinationStore from .bus_projection import AppendCheckpoint from .private_path import FileRevision @@ -224,7 +225,7 @@ def offsets( lower: int | None, upper: int | None, descending: bool, - targets: frozenset[str] | None, + targets: HistoryTargets, before_offset: int | None = None, ) -> Generator[BusPageRow, None, None]: clauses: list[str] = [] @@ -238,11 +239,7 @@ def offsets( if before_offset is not None: clauses.append("offset < ?") params.append(before_offset) - if targets is not None: - if not targets: - return - clauses.append("target IN (" + ",".join("?" for _ in targets) + ")") - params.extend(sorted(targets)) + targets.constrain_offsets(clauses, params) where = " WHERE " + " AND ".join(clauses) if clauses else "" direction = "DESC" if descending else "ASC" query = ( 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/bus_route_counts.py b/src/agent_comms/bus_route_counts.py index 8a3ec21c9a..bbc2d88264 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 @@ -582,7 +582,7 @@ def _request_bindings(self, actors): )) return { "targets": json.dumps({ - actor: sorted(self.deliveries[actor].index_targets) for actor in actors + actor: sorted(self.deliveries[actor].index_targets.names) for actor in actors }), "senders": json.dumps(senders), "unrecorded": json.dumps(unrecorded), "through": self.source.through, diff --git a/src/agent_comms/catalog_document.py b/src/agent_comms/catalog_document.py index 96a66b6716..595d440b71 100644 --- a/src/agent_comms/catalog_document.py +++ b/src/agent_comms/catalog_document.py @@ -6,10 +6,10 @@ from collections.abc import Mapping from dataclasses import dataclass, field, replace -from .channel_targets import BuiltinChannel, Tag +from .channel_targets import BuiltinChannel, HistoryTargets, 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) @@ -101,7 +101,7 @@ def targets_for(self, tags: frozenset[str]) -> frozenset[str]: f"#{tag}" for tag in tags ) - def history_targets(self, target: str) -> frozenset[str] | None: + def history_targets(self, target: str) -> HistoryTargets: return self.resolve(target).history_targets def is_view_target(self, target: str) -> bool: diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index 6c33f3e3d5..abcf10ae89 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -4,10 +4,63 @@ from dataclasses import dataclass from abc import ABC, abstractmethod +from collections.abc import Iterator, Mapping + +from .declared_family import DeclaredFamily from typing import ClassVar from enum import StrEnum +class HistoryTargets(DeclaredFamily, affix="HistoryTargets"): + """Captured target membership, including an explicit unrestricted member.""" + + @abstractmethod + def includes(self, target: str) -> bool: ... + + @abstractmethod + def matching_values[T](self, values: Mapping[str, T]) -> Iterator[T]: ... + + @abstractmethod + def constrain_offsets(self, clauses: list[str], params: list[object]) -> None: ... + + @abstractmethod + def require_names(self) -> frozenset[str]: ... + + +@dataclass(frozen=True, slots=True) +class AllHistoryTargets(HistoryTargets): + def includes(self, target): + return True + + def matching_values(self, values): + return iter(values.values()) + + def constrain_offsets(self, clauses, params): + pass + + def require_names(self): + from .errors import RelationViolationError + raise RelationViolationError("An aggregate is not an exportable conversation.") + + +@dataclass(frozen=True, slots=True) +class NamedHistoryTargets(HistoryTargets): + names: frozenset[str] + + def includes(self, target): + return target in self.names + + def matching_values(self, values): + return (values[target] for target in self.names if target in values) + + def constrain_offsets(self, clauses, params): + clauses.append("target IN (" + ",".join("?" for _ in self.names) + ")") + params.extend(sorted(self.names)) + + def require_names(self): + return self.names + + class BuiltinChannel(StrEnum): ANY = "#any" NONE = "#none" @@ -38,8 +91,8 @@ def matches(self, tags: frozenset[str]) -> bool: return self is not self.NONE or not tags @property - def history_targets(self) -> frozenset[str] | None: - return None if self.aggregate else frozenset((self.value,)) + def history_targets(self) -> HistoryTargets: + return AllHistoryTargets() if self.aggregate else NamedHistoryTargets(frozenset((self.value,))) class CommandTarget(ABC): diff --git a/src/agent_comms/channels.py b/src/agent_comms/channels.py index d883390ce1..c36d18c93e 100644 --- a/src/agent_comms/channels.py +++ b/src/agent_comms/channels.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from enum import StrEnum -from .channel_targets import _TAG_CHARS, BuiltinChannel, Tag +from .channel_targets import _TAG_CHARS, BuiltinChannel, HistoryTargets, NamedHistoryTargets, Tag from .declared_family import DeclaredFamily from .display_order import CreatedThreadSort, ThreadSort from .errors import RelationViolationError @@ -75,6 +75,13 @@ def matches(self, tags: frozenset[str]) -> bool: builtin = self.builtin return builtin.matches(tags) if builtin is not None else bool(self.tags & tags) + @property + def tag_description(self) -> str: + builtin = self.builtin + if builtin is not None: + return builtin.value + return ', '.join(sorted(self.tags)) + def can_set_archived(self, archived: bool) -> bool: """Only nonbuiltin channels admit a changed archive preference.""" return self.builtin is None and self.archived != archived @@ -93,11 +100,11 @@ def exact(self) -> bool: return self.view is None and self.builtin is None @property - def history_targets(self) -> frozenset[str] | None: + def history_targets(self) -> HistoryTargets: if self.view is not None: return self.view.history_targets builtin = self.builtin - return builtin.history_targets if builtin is not None else frozenset({self.name}) + return builtin.history_targets if builtin is not None else NamedHistoryTargets(frozenset({self.name})) def to_wire(self) -> dict[str, object]: return FieldCodec.encode(self) @@ -157,10 +164,10 @@ class SavedView: original_targets: frozenset[str] = frozenset() @property - def history_targets(self) -> frozenset[str]: + def history_targets(self) -> NamedHistoryTargets: # Original addressed messages remain attached to their immutable target; # this is a read projection, never a rewritten message or delivery scope. - return self.original_targets | frozenset(f"#{tag}" for tag in self.predicate.tags) + return NamedHistoryTargets(self.original_targets | frozenset(f"#{tag}" for tag in self.predicate.tags)) def __post_init__(self) -> None: if ( diff --git a/src/agent_comms/claim_admission.py b/src/agent_comms/claim_admission.py index 065f34f215..4afb1e91d2 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 @@ -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 stable_thread_lookup(owner.created_at) != 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/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..22cef66a95 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.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 ecea487a30..14ba2bf630 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.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 3481eca812..fa4eeba804 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.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..8288ea4398 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.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..6116ef7388 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.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/exporting.py b/src/agent_comms/exporting.py index 202b2880d2..a2d38a12bb 100644 --- a/src/agent_comms/exporting.py +++ b/src/agent_comms/exporting.py @@ -148,11 +148,7 @@ def resolve(self, catalog: ChannelCatalog, registry: Registration) -> ResolvedEx raise RelationViolationError( f"Saved view {self.channel!r} has no authoritative wire history." ) - targets = catalog.read().history_targets(self.channel) - if targets is None: - raise RelationViolationError( - f"View {self.channel!r} is an aggregate, not an exportable conversation." - ) + targets = catalog.read().history_targets(self.channel).require_names() return ResolvedExportScope(self, lambda message: message.target in targets) diff --git a/src/agent_comms/external_transcript.py b/src/agent_comms/external_transcript.py index 34554d9c38..4de5258940 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.lookup def finish(self, *, turn_id: str, item_id: str, events: tuple[TranscriptEvent, ...]) -> None: 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 ebf8762f5e..3686d33035 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -15,8 +15,7 @@ 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 .goal_waits import GoalInputReview, GoalWait, GoalWaits, NoGoalWait from .registration import Registration if TYPE_CHECKING: @@ -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.lookup # Original certification supplies both sides of the reply. Display ACKs # and today's sender aliases cannot admit a different incarnation. replies = { @@ -181,9 +180,9 @@ 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: - waits = self.waits - return waits.for_goal(self.registry.require(name).goal, waits.read()) + def goal_wait(self, name: str) -> GoalWait: + 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. @@ -214,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 = ( @@ -280,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 @@ -332,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.""" @@ -358,15 +357,15 @@ 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) 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) + 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 e98007503f..a063913bf2 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 RecordedThreadIncarnation from .thread_presentation import ThreadPresentation if TYPE_CHECKING: @@ -86,12 +87,16 @@ def for_domain_state(cls, state: GoalState) -> GoalExecutionState: class GoalWaitTarget: name: str created_at: float + _incarnation: RecordedThreadIncarnation = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + object.__setattr__( + self, "_incarnation", RecordedThreadIncarnation(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_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 690c2aa96e..c248d60846 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -3,27 +3,62 @@ 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 -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 RecordedThreadIncarnation from .thread_owned_state import ThreadOwnedState - from .wire_log import WireLog if TYPE_CHECKING: 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) -> 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 @@ -58,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) -> 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. @@ -69,20 +130,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(stable_thread_lookup(self.owner_created_at)) + 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( - stable_thread_lookup(self.owner_created_at), self.after_seq - ) + self.matches(original, owner) + for original in bus.addressed_sources(owner.lookup, self.after_seq) ) @@ -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: - if goal is None or not goal.state.active: - return None - return rows.get(goal.id) + def for_goal(goal: Goal, rows: dict[str, RecordedGoalWait]) -> GoalWait: + if not goal.state.active: + 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. @@ -247,26 +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, GoalWait], 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, - ) 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/historical_views.py b/src/agent_comms/historical_views.py index 7b3a6b290c..7e29dc1af1 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -4,13 +4,15 @@ import json from abc import ABC, abstractmethod -from collections.abc import Iterator -from contextlib import contextmanager +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager, contextmanager from dataclasses import dataclass, fields, replace from pathlib import Path from typing import TYPE_CHECKING +from .channel_targets import HistoryTargets from .channels import Channel +from .declared_family import DeclaredFamily from .field_codec import FieldCodec from .message_page import ( ConversationMessagePageRequest, @@ -27,7 +29,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 ThreadIncarnation, ThreadProvenance, UnrecordedThreadIncarnation from .wire_log import WireLog from .wire_metadata import ArchivedAccess @@ -35,28 +37,36 @@ from .presentation import BusPresentation -class HistoryView(ABC): - """A requested view binds its predicate once to each original registry.""" +class HistoryViewer(DeclaredFamily, affix="HistoryViewer"): + """A viewer owns whether a display can establish a human read position.""" - viewer: str | None = None + def display_evidence(self, page: MessagePage, presentation: BusPresentation): + return None @abstractmethod - def live_page(self, presentation: BusPresentation, **paging) -> MessagePage: - """Each view owns its current-source interpretation as well as retained capture.""" + def channel_page(self, presentation: BusPresentation, target: str, **paging) -> MessagePage: ... - def page(self, presentation: BusPresentation, **paging) -> MessagePage: - return presentation.bus.history.integrated_page(self, presentation, **paging) - def display_evidence(self, page: MessagePage, presentation: BusPresentation): - """Paint evidence belongs to the selected view, never executor delivery.""" - if self.viewer is None: - return None +@dataclass(frozen=True, slots=True) +class ArchiveHistoryViewer(HistoryViewer): + def channel_page(self, presentation, target, **paging): + return presentation.channel_page(target, **paging) + + +@dataclass(frozen=True, slots=True) +class SpecificHistoryViewer(HistoryViewer): + name: str + + def channel_page(self, presentation, target, **paging): + return presentation.channel_page(target, viewer=self.name, **paging) + + def display_evidence(self, page, presentation): source = page.messages[0].source source.validate() return HistoricalDisplay( source, presentation.bus.reads.capture( - self.viewer, + self.name, page.messages, presentation.registry.snapshot(), Path(source.root) / "bus.jsonl", @@ -64,6 +74,19 @@ def display_evidence(self, page: MessagePage, presentation: BusPresentation): ), ) + +class HistoryView(ABC): + """A requested view binds its predicate once to each original registry.""" + + viewer: HistoryViewer = ArchiveHistoryViewer() + + @abstractmethod + def live_page(self, presentation: BusPresentation, **paging) -> MessagePage: + """Each view owns its current-source interpretation as well as retained capture.""" + + def page(self, presentation: BusPresentation, **paging) -> MessagePage: + return presentation.bus.history.integrated_page(self, presentation, **paging) + def full_history(self, presentation: BusPresentation) -> list[Message]: page = self.page(presentation, limit=1000) pages = [page.messages] @@ -90,7 +113,7 @@ def page_request(self, scope, traversal, limit, max_bytes) -> MessagePageRequest @dataclass(frozen=True, slots=True) class ChannelHistory(HistoryView): target: str - targets: frozenset[str] | None + targets: HistoryTargets def live_page(self, presentation: BusPresentation, **paging) -> MessagePage: return presentation.bus.channel_history_page(self.target, **paging) @@ -102,10 +125,10 @@ def capture(self, snapshot: RegistryProvenance) -> ChannelDisplayScope: @dataclass(frozen=True, slots=True) class ChannelDisplayHistory(ConversationHistoryView, HistoryView): channel: Channel - viewer: str | None = None + viewer: HistoryViewer = ArchiveHistoryViewer() def live_page(self, presentation: BusPresentation, **paging) -> MessagePage: - return presentation.channel_page(self.channel.name, viewer=self.viewer, **paging) + return self.viewer.channel_page(presentation, self.channel.name, **paging) def capture(self, snapshot: RegistryProvenance) -> ChannelDisplayScope: return ChannelDisplayScope.capture(self.channel, snapshot) @@ -128,14 +151,14 @@ class DMDisplayHistory(ConversationHistoryView, DMHistory): worktree: str @property - def viewer(self) -> str: - return self.first + def viewer(self) -> HistoryViewer: + return SpecificHistoryViewer(self.first) def live_page(self, presentation: BusPresentation, **paging) -> MessagePage: from .read_basis import DMDisplayBasis bus = presentation.bus - if self.second not in presentation.registry and bus.history.threads(self.second): + if self.second not in presentation.registry and bus.history.thread(self.second): return MessagePage((), False, False) return DMDisplayBasis.fetch_page( bus, @@ -199,6 +222,13 @@ def require(cls, sources: tuple[HistorySource, ...], key: str) -> HistorySource: source.validate() return source + @contextmanager + def acquire_original(self, root: Path) -> Iterator[RegistryProvenance]: + """Lend this original projection only while its source proof holds.""" + self.require_original(root) + yield self.provenance + self.require_original(root) + def notification_references(self, sources: tuple[HistorySource, ...], references): """Read this namespace within its archive's current catalogue read.""" from functools import partial @@ -220,29 +250,27 @@ def notification_references(self, sources: tuple[HistorySource, ...], references class HistoricalMessage(Message): source: HistorySource source_order: int - sender_created_at: float | None - target_created_at: float | None + sender_incarnation: ThreadIncarnation + target_incarnation: ThreadIncarnation @classmethod def project( cls, message: Message, source: HistorySource, order: int, snapshot: RegistryProvenance ) -> HistoricalMessage: - def creation(name: str) -> float | None: + def incarnation(name: str) -> ThreadIncarnation: declaration = snapshot.threads.get(snapshot.canonical_name(name)) - # A later source declaration cannot certify a row from an earlier - # incarnation. The original registry remains available for inspection. - return ( - declaration.created_at - if declaration is not None and declaration.created_at <= message.timestamp - else None - ) + # A later source declaration cannot certify an earlier message. + # Preserve the named unknown state; never manufacture a birth. + if declaration is None or declaration.created_at > message.timestamp: + return UnrecordedThreadIncarnation(name) + return declaration.incarnation.renamed(name) return cls( **{f.name: getattr(message, f.name) for f in fields(Message)}, source=source, source_order=order, - sender_created_at=creation(message.sender), - target_created_at=creation(message.target), + sender_incarnation=incarnation(message.sender), + target_incarnation=incarnation(message.target), ) def notification_references(self) -> tuple[MessageReference, ...]: @@ -272,8 +300,8 @@ def display_metadata(self) -> dict: "source": self.source.original_root, "wire_root_id": self.source.wire_root_id, "bus_identity": self.source.bus_identity, - "sender_created_at": self.sender_created_at, - "target_created_at": self.target_created_at, + "sender_incarnation": FieldCodec.encode(self.sender_incarnation), + "target_incarnation": FieldCodec.encode(self.target_incarnation), }, } @@ -354,7 +382,23 @@ def _require_revision(self, revision: FileRevision) -> None: if FileRevision.capture(self.path) != revision: raise ValueError("Historical source catalogue changed; refresh history") - def attach(self, source_root: Path, *, source_read: HistorySource | None = None) -> HistorySource: + def attach(self, source_root: Path) -> HistorySource: + return self._attach(source_root, self.acquire_current) + + def attach_original(self, source: HistorySource) -> HistorySource: + return self._attach(Path(source.root), source.acquire_original) + + @staticmethod + @contextmanager + def acquire_current(root: Path) -> Iterator[RegistryProvenance]: + with Registration(root / "registry.json").store.reading() as original: + provenance = RegistryProvenance.capture(original) + yield provenance + + def _attach( + self, source_root: Path, + acquire: Callable[[Path], AbstractContextManager[RegistryProvenance]], + ) -> HistorySource: """Snapshot a preserved source, then publish it for ordinary display. Only destination files are written. No Comms constructor, source locks, @@ -366,7 +410,6 @@ def attach(self, source_root: Path, *, source_read: HistorySource | None = None) from .catalog_store import ChannelCatalog from .private_bus_checkpoint import install_private_bus_checkpoint - from .historical_views import HistorySource from .transcript_routes import TranscriptRoutes source_root = source_root.resolve() @@ -391,25 +434,16 @@ 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() - ) - else: - source_read.require_original(source_root) - provenance = source_read.provenance archived = WireLog(stage / "bus.jsonl") # Lock the empty stage before copying its marker. Once marked, # ordinary reads correctly require the not-yet-built index. - with archived.locked(): + with acquire(source_root) as provenance, archived.locked(): for path in paths: if path.exists(): shutil.copy2(path, stage / path.name) TranscriptRoutes(source_root).snapshot(stage) if revisions != tuple(FileRevision.capture(path) for path in paths): raise ValueError("Historical source changed during snapshot; retry") - if source_read is not None: - source_read.require_original(source_root) bus_info = paths[0].stat() if paths[0].exists() else None if bus_info is None: archived.path.touch() @@ -537,7 +571,7 @@ def integrated_page( max_bytes=max_bytes, ) if history.messages: - display = view.display_evidence(history, presentation) + display = view.viewer.display_evidence(history, presentation) latest = view.live_page(presentation, limit=1) return replace( history, @@ -552,11 +586,20 @@ def integrated_page( ) return replace(history if historical else page, history_revision=history_revision) - def threads(self, name: str | None = None) -> tuple[HistoricalThread, ...]: + def threads(self) -> tuple[HistoricalThread, ...]: result = [] for source in self.sources(): source.validate() result.extend(HistoricalThread(source, thread) - for thread in source.provenance.threads.values() - if name is None or thread.incarnation.matches_recorded_name(name, source.provenance)) + for thread in source.provenance.threads.values()) + return tuple(result) + + def thread(self, name: str) -> tuple[HistoricalThread, ...]: + result = [] + for source in self.sources(): + source.validate() + declarations = source.provenance.threads + canonical = source.provenance.canonical_name(name) + if canonical in declarations: + result.append(HistoricalThread(source, declarations[canonical])) return tuple(result) diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index dae8ed08ed..005cc2a322 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -34,7 +34,10 @@ WireTranscriptExporter, ) from .goal_management import Goals -from .historical_views import ChannelDisplayHistory, ChannelHistory, DMDisplayHistory, DMHistory +from .historical_views import ( + ArchiveHistoryViewer, ChannelDisplayHistory, ChannelHistory, + DMDisplayHistory, DMHistory, SpecificHistoryViewer, +) from .message_bus import MessageBus from .message_page import MessagePage from .message_reference import MessageReference @@ -156,9 +159,15 @@ def dm_history_page( max_bytes=max_bytes, ) - def attach_history(self, source_root: Path, *, source_read: HistorySource | None = None) -> HistorySource: - """Attach preserved history without admitting any historical execution.""" - source = self.bus.history.attach(Path(source_root), source_read=source_read) + def attach_history(self, source_root: Path) -> HistorySource: + """Attach a current-format source without historical execution.""" + return self._restore_history_channels(self.bus.history.attach(Path(source_root))) + + def attach_original_history(self, source: HistorySource) -> HistorySource: + """Attach an acquired original, preserving its validated projection.""" + return self._restore_history_channels(self.bus.history.attach_original(source)) + + def _restore_history_channels(self, source: HistorySource) -> HistorySource: catalog = ChannelCatalog(Path(source.root) / ChannelCatalog.filename) incoming = catalog.read() source_threads = source.provenance.threads @@ -168,8 +177,11 @@ def attach_history(self, source_root: Path, *, source_read: HistorySource | None document.restore_missing(incoming, source_threads, existing=existing) return source - def historical_threads(self, name: str | None = None) -> tuple[HistoricalThread, ...]: - return self.bus.history.threads(name) + def historical_threads(self) -> tuple[HistoricalThread, ...]: + return self.bus.history.threads() + + def historical_thread(self, name: str) -> tuple[HistoricalThread, ...]: + return self.bus.history.thread(name) def dm_display_page( self, @@ -200,11 +212,11 @@ def channel_display_page( limit: int = 100, max_bytes: int = 256 * 1024, ) -> MessagePage: - viewer = self.messaging.user_identity(worktree).name if worktree is not None else None + viewer = (SpecificHistoryViewer(self.messaging.user_identity(worktree).name) + if worktree is not None else ArchiveHistoryViewer()) if not self.bus.history.sources(): - return self.presentation.channel_page( - target, - viewer=viewer, + return viewer.channel_page( + self.presentation, target, before=before, after=after, limit=limit, @@ -313,7 +325,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, @@ -394,7 +413,7 @@ def coordination_snapshot( for name in declarations: targets = catalog.history_targets(name) channel_unread[name] = sum( - count for target, count in unread.items() if targets is None or target in targets + targets.matching_values(unread) ) return CoordinationSnapshot.capture( self.root, registry, catalog, self.agents, declarations, sent, @@ -538,7 +557,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 [ @@ -548,9 +570,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/input_drain.py b/src/agent_comms/input_drain.py index 5f79ebdb71..181d9e637d 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.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.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..2719ff2483 100644 --- a/src/agent_comms/message_bus.py +++ b/src/agent_comms/message_bus.py @@ -177,7 +177,7 @@ def pending_count(self, name: str, target: str | None = None) -> int: if is_channel_target(target): targets = basis.catalog.history_targets(target) return sum( - count for scope, count in counts.items() if targets is None or scope in targets + targets.matching_values(counts) ) return counts.get(basis.registry.require(target).name, 0) @@ -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.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.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..e9f82d56cf 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 @@ -22,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 @@ -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.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..00e90f0356 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.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..2cd3c92ed8 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.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.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..be6f55101e 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.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..e6a8659a32 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.lookup, owner.name, ): raise ProjectionUnavailableError("selected recipient changed") diff --git a/src/agent_comms/ordinary_admission_rules.py b/src/agent_comms/ordinary_admission_rules.py index 0eaf3c58ee..bfe2d02007 100644 --- a/src/agent_comms/ordinary_admission_rules.py +++ b/src/agent_comms/ordinary_admission_rules.py @@ -154,8 +154,9 @@ 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 + wait: GoalWait 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..cc75c45301 100644 --- a/src/agent_comms/owned_send_admission.py +++ b/src/agent_comms/owned_send_admission.py @@ -99,10 +99,10 @@ 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, 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() @@ -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..91a6e5f6d0 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -114,15 +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 ( - wait is not None - and not self.original_owner_input - and wait.wait_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: + 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/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index 02f10eb0cf..37f7b572c9 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.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.lookup, thread.name, thread.name, committed=True, diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index c38607448b..3c4af53c3f 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -24,7 +24,7 @@ from .messages import Message from .private_path import FileRevision from .read_basis import ChannelDisplayScope, DMDisplayScope -from .read_ledger import ReadLedger +from .read_ledger import NoReadMarkerNotice, ReadLedger, ReadMarkerNotice from .runtime_info import AgentRuntimeInfo from .store_files import _store_lock from .thread_identity import ThreadIncarnation @@ -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, ) @@ -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,9 +271,9 @@ 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.lookup, target.name) displayed_to = reads.displayed_recipient( - message, recipient, snapshot, document=document + source, recipient, snapshot, document=document ) if not displayed_to: return None @@ -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.lookup, assignment_read=assignment_read, ) return tuple( @@ -682,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), ) @@ -709,7 +707,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 @@ -755,6 +753,14 @@ def to_wire(self) -> dict[str, object]: } +@dataclass(frozen=True, slots=True) +class ListingFilter: + """The stopped/archived selection that produced a roster.""" + + show_stopped: bool = True + show_archived: bool = False + + @dataclass(frozen=True, slots=True) class CoordinationSnapshot: threads: tuple[ThreadView, ...] @@ -766,9 +772,8 @@ class CoordinationSnapshot: channel_order: type[ChannelSort] = NameChannelSort thread_unread: Mapping[str, int] = field(default_factory=dict) unread_pending: frozenset[str] = frozenset() - show_stopped: bool = True - show_archived: bool = False - read_marker_notice: str | None = None + filters: ListingFilter = field(default_factory=ListingFilter) + read_marker_notice: ReadMarkerNotice = NoReadMarkerNotice() @classmethod def capture( @@ -776,10 +781,17 @@ def capture( agents: AgentActivity, declarations: Mapping[str, Channel], sent: Mapping[str, float], messages: Mapping[str, ChannelActivity], *, unread: Mapping[str, int], channel_unread: Mapping[str, int], - show_stopped: bool, show_archived: bool, read_marker_notice: str | None = None, + show_stopped: bool, show_archived: bool, + read_marker_notice: ReadMarkerNotice = NoReadMarkerNotice(), ) -> 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, @@ -792,8 +804,9 @@ def capture( ), unread=unread, last_sent=sent, channel_unread=channel_unread, references=DisplayReferences.capture(root, registry, declarations), - channel_order=catalog.list_order, show_stopped=show_stopped, - show_archived=show_archived, read_marker_notice=read_marker_notice, + channel_order=catalog.list_order, + filters=ListingFilter(show_stopped, show_archived), + read_marker_notice=read_marker_notice, ) def participants(self, channel: str) -> tuple[ThreadView, ...]: diff --git a/src/agent_comms/private_bus_checkpoint.py b/src/agent_comms/private_bus_checkpoint.py index f7abd912b1..72c12061b6 100644 --- a/src/agent_comms/private_bus_checkpoint.py +++ b/src/agent_comms/private_bus_checkpoint.py @@ -496,9 +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. """ - from .bus_publication import stable_thread_lookup - - lookup = stable_thread_lookup(incarnation.created_at) + 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 2591467fe3..2123404f43 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 ( @@ -257,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; @@ -351,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) @@ -394,12 +391,8 @@ 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] - if len(set(lookups)) != len(lookups): - raise RelationViolationError("Recipient creation identities collide.") - sender_lookup = stable_thread_lookup(snapshot.threads[sender].created_at) - 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 773a4b1a4f..e1ce21d9b3 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.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.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..1755e047fd 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -9,15 +9,15 @@ 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 -from .channel_targets import is_channel_target +from .channel_targets import AllHistoryTargets, HistoryTargets, NamedHistoryTargets, is_channel_target 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 @@ -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 @@ -50,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), ) @@ -69,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.""" @@ -308,8 +310,8 @@ class MessageDisplayScope(ABC): @property @abstractmethod - def index_targets(self) -> frozenset[str] | None: - """None means that target alone cannot exclude a candidate.""" + def index_targets(self) -> HistoryTargets: + """The sound target reduction, or the explicit unrestricted member.""" @abstractmethod def includes(self, message: Message) -> bool: @@ -350,8 +352,8 @@ def unread_before(self, message: Message, before: int, seen: frozenset[int]) -> return message.seq < before and message.seq not in seen and self.inbound(message) @property - def index_targets(self) -> frozenset[str]: - return self.first_names | self.second_names + def index_targets(self) -> NamedHistoryTargets: + return NamedHistoryTargets(self.first_names | self.second_names) def includes(self, message: Message) -> bool: return (message.sender in self.first_names and message.target in self.second_names) or ( @@ -368,8 +370,8 @@ class ListedDeliveries(MessageDisplayScope): targets: frozenset[str] @property - def index_targets(self) -> frozenset[str]: - return self.targets + def index_targets(self) -> NamedHistoryTargets: + return NamedHistoryTargets(self.targets) def includes(self, message: Message) -> bool: return message.seq in self.sequences and self.scope.includes(message) @@ -380,7 +382,7 @@ class ChannelDisplayScope(MessageDisplayScope): """One captured local display predicate; it never changes delivery or history ownership.""" channel: str - targets: frozenset[str] | None + targets: HistoryTargets any_mode: bool = False participant_names: frozenset[str] = frozenset() displayed: DisplayBasis | None = field(default=None, compare=False) @@ -401,10 +403,10 @@ def capture( return cls(channel.name, targets, channel.any_mode, participant_names) @property - def index_targets(self) -> frozenset[str] | None: + def index_targets(self) -> HistoryTargets: # The existing offset index has no mention column. Any-mode can match # sender, DM peer or a mention regardless of target; never drop those rows. - return None if self.any_mode else self.targets + return AllHistoryTargets() if self.any_mode else self.targets def require_displayed(self) -> DisplayBasis: if self.displayed is None: @@ -412,7 +414,7 @@ def require_displayed(self) -> DisplayBasis: return self.displayed def includes(self, message: Message) -> bool: - if self.targets is None or message.target in self.targets: + if self.targets.includes(message.target): return True if not self.any_mode: return False diff --git a/src/agent_comms/read_ledger.py b/src/agent_comms/read_ledger.py index 877088344f..d06090a3ca 100644 --- a/src/agent_comms/read_ledger.py +++ b/src/agent_comms/read_ledger.py @@ -8,19 +8,23 @@ from __future__ import annotations import json +from abc import abstractmethod from collections.abc import Iterable, Sequence from dataclasses import dataclass, field, replace from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar +from .declared_family import DeclaredFamily +from .field_codec import FieldCodec, WireValue from .locked_store import LockedStore from .private_path import FileIdentity, FileRevision from .read_basis import Conversation, DisplayBasis, DisplayedConversation from .sealed import Sealed -from .thread_identity import ThreadIncarnation +from .thread_identity import RecordedThreadIncarnation 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 @@ -28,12 +32,89 @@ from .threads import Thread +class ReadMarkerNotice(DeclaredFamily, WireValue, affix="ReadMarkerNotice"): + """The ledger's native text/null value, decoded once as a notice state.""" + + accepts_null = True + external_json_shape = True + + @classmethod + def from_wire(cls, raw): + match raw: + case None: + notice = NoReadMarkerNotice() + case str(): + notice = RecordedReadMarkerNotice(raw) + case _: + raise ValueError("A read-marker notice requires text or null") + if not isinstance(notice, cls): + raise ValueError("Read-marker notice differs from its declared member") + return notice + + @classmethod + def schema(cls): + return {'anyOf': [member.schema() for member in cls.members_with(cls)]} + + @property + @abstractmethod + def messages(self) -> tuple[str, ...]: ... + + def messages_since(self, previous: ReadMarkerNotice) -> tuple[str, ...]: + return () if self == previous else self.messages + + @abstractmethod + def take_from(self, document: ReadDocument) -> tuple[ReadDocument, ReadMarkerNotice]: ... + + +@dataclass(frozen=True, slots=True) +class NoReadMarkerNotice(ReadMarkerNotice): + @classmethod + def schema(cls): + return FieldCodec.value_schema(type(None)) + + def to_wire(self): + return None + + @property + def messages(self) -> tuple[str, ...]: + return () + + def take_from(self, document): + return document, self + + +@dataclass(frozen=True, slots=True) +class RecordedReadMarkerNotice(ReadMarkerNotice): + value: str + minimum_length: ClassVar[int] = 1 + + @classmethod + def schema(cls): + return {**FieldCodec.value_schema(str), 'minLength': cls.minimum_length} + + def __post_init__(self) -> None: + if len(self.value) < self.minimum_length: + raise ValueError("A recorded read-marker notice requires text") + + def to_wire(self): + return self.value + + @property + def messages(self) -> tuple[str, ...]: + return (self.value,) + + def take_from(self, document): + if document.notice != self: + return document, NoReadMarkerNotice() + return replace(document, notice=NoReadMarkerNotice()), self + + @dataclass(frozen=True, slots=True) class ReadDocument: messages: dict[str, tuple[int, ...]] = field(default_factory=dict) bus_identity: tuple[int, int] | None = None transcripts: dict[str, int] = field(default_factory=dict) - notice: str | None = None + notice: ReadMarkerNotice = NoReadMarkerNotice() def seen_sequences( self, viewer: str, snapshot: RegistryProvenance, source: FileIdentity | None @@ -139,16 +220,14 @@ def advance(document: ReadDocument) -> ReadDocument: self.update(advance) - def take_notice(self, shown: str | None) -> str | None: - """Clear the stored notice once it has been shown; return it when this call cleared it.""" - taken = None + def take_notice(self, shown: ReadMarkerNotice) -> ReadMarkerNotice: + """Clear exactly the shown notice, returning the state this call cleared.""" + taken: ReadMarkerNotice = NoReadMarkerNotice() def take(document: ReadDocument) -> ReadDocument: nonlocal taken - if shown is None or document.notice != shown: - return document - taken = shown - return replace(document, notice=None) + updated, taken = shown.take_from(document) + return updated self.update(take) return taken @@ -160,19 +239,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, *, 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. """ - return self._displayed_recipient( - message, recipient, snapshot, - (thread for thread in snapshot.threads.values() if self.human(thread.role)), - document=document, + return snapshot.recipient(recipient, original).read_effect( + self, original.message, 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 @@ -180,20 +261,15 @@ 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.threads.values(), 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, 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 - 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, thread, snapshot, *, document): + if message.seq in self.seen_sequences(thread.name, snapshot, document=document): + return (thread.incarnation,) return () def remove_threads(self, threads: Sequence[Thread]) -> None: @@ -210,7 +286,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 e11daf462f..632949bc0e 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -4,20 +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 RegisteredRecipient, RegistryNames, RegistryProvenance, RetiredRecipient 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 ( @@ -26,11 +33,12 @@ ThreadStatus, ) from .threads import Thread -from .registry_provenance import RegistryNames, RegistryProvenance from .turn_lease import ActiveTurn, FinishedTurnFence, TurnLeaseFence, TurnState from .turn_phase import TurnPhase if TYPE_CHECKING: + from .audience_manifest import FrozenRecipient + from .bus_publication import CommittedDelivery from .native_input_owner import RegistryOwner @@ -84,14 +92,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,6 +532,57 @@ class RegistrySnapshot(RegistryPresence, RegistryProvenance): aliases: Mapping[str, str] owner_generations: Mapping[str, int] admission_generations: Mapping[str, int] + by_lookup: Mapping[str, Thread] = field(repr=False, compare=False) + + def __post_init__(self) -> None: + super(RegistrySnapshot, self).__post_init__() + seen: set[str] = set() + for thread in self.threads.values(): + 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 + 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 + ) -> 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.""" @@ -549,11 +601,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/registry_provenance.py b/src/agent_comms/registry_provenance.py index fede631146..5e13938229 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -2,13 +2,18 @@ from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Iterator, Mapping from dataclasses import dataclass -from typing import Generic, TypeVar +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 +from .thread_identity import ThreadIncarnation, ThreadProvenance, ThreadProvenanceRecord + +if TYPE_CHECKING: + from .audience_manifest import FrozenRecipient + from .threads import Thread Member = TypeVar("Member", bound=ThreadProvenance, covariant=True) @@ -28,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.""" @@ -76,7 +126,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 +141,18 @@ 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.""" + 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 10dcc079d9..b4866b7b9b 100644 --- a/src/agent_comms/routing.py +++ b/src/agent_comms/routing.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING -from .channel_targets import BuiltinChannel, is_channel_target +from .channel_targets import AllHistoryTargets, BuiltinChannel, NamedHistoryTargets, is_channel_target from .field_codec import FieldCodec from .message_reference import MessageReference from .messages import Message @@ -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,23 +173,23 @@ 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)) @property - def index_targets(self) -> frozenset[str]: - return self.channels | frozenset( + def index_targets(self) -> NamedHistoryTargets: + return NamedHistoryTargets(self.channels | frozenset( name for name in (self.actor, *self.aliases) if self.canonical(name) == self.actor - ) + )) def includes(self, message: Message) -> bool: return self.delivers(message.sender, message.target) def selection(self, target, snapshot, catalog) -> MessageDisplayScope: if target is None: - return ChannelDisplayScope(BuiltinChannel.ANY.value, None) + return ChannelDisplayScope(BuiltinChannel.ANY.value, AllHistoryTargets()) if is_channel_target(target): return ChannelDisplayScope(target, catalog.history_targets(target)) peer = snapshot.canonical_name(target) @@ -216,16 +216,14 @@ 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.lookup else: 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.""" @@ -240,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_participant.py b/src/agent_comms/selected_participant.py index 5b1ea011b2..56c3e05093 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.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..a9ac5918e8 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 @@ -36,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 @@ -79,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): @@ -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.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.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.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..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: @@ -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.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.lookup): raise RelationViolationError("USER pin recipient did not receive its original message") def user_task_facts(self, message): @@ -554,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 f7e191757d..844a555f85 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -6,49 +6,64 @@ from __future__ import annotations +import hashlib import math -from dataclasses import dataclass, field, replace +from abc import ABC, abstractmethod +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 FieldCodec, FieldRepresentation, 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 - # 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 + def to_wire(self) -> dict[str, str | float]: + return {"name": self.name, "created_at": self.created_at} @classmethod - def deleted(cls, name: str) -> ThreadIncarnation: - return cls(name, cls.DELETED_BIRTH) + def schema(cls) -> dict: + return FieldCodec.record_schema(ThreadIncarnation) - @property - def names_deleted_thread(self) -> bool: - return self.created_at == self.DELETED_BIRTH + @classmethod + 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 - def __post_init__(self) -> None: - from .field_codec import FieldCodec + @classmethod + def deleted(cls, name: str) -> DeletedThreadIncarnation: + return DeletedThreadIncarnation(name) + 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") - @property - def has_recorded_birth(self) -> bool: - return self.created_at > 0 + @abstractmethod + def require_recorded(self) -> RecordedThreadIncarnation: + """Only the recorded member can expose a lookup capability.""" - def require_recorded(self) -> None: - if not self.has_recorded_birth: - raise ValueError("Thread incarnation requires a positive recorded birth") + @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.""" @@ -76,19 +91,187 @@ 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)) +@dataclass(frozen=True) +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): + 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) + + @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) + + @cached_property + def lookup(self) -> str: + """The creation lookup, computed once for this recorded incarnation.""" + identity = f"agent-comms:thread-creation:v1\0{float(self.created_at)!r}".encode() + return hashlib.sha256(identity).hexdigest()[:32] + + +@dataclass(frozen=True) +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 + + def require_recorded(self) -> RecordedThreadIncarnation: + raise ValueError("Thread incarnation requires a positive recorded birth") + + def provenance_type(self): + 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.""" + + 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): + 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: + 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) + + +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") @@ -124,7 +307,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)) @@ -149,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_management.py b/src/agent_comms/thread_management.py index eadde21bc2..6b3653bfc2 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.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.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 cb101b7bd1..dac11134c6 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -189,7 +189,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 @@ -199,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=stable_thread_lookup(window.read.identity.thread.created_at), + 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 deleted file mode 100644 index 114734ea59..0000000000 --- a/src/agent_comms/thread_provenance.py +++ /dev/null @@ -1,41 +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 .thread_identity import ThreadIncarnation - - -@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}) - 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 - 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 ThreadProvenance( - **{item.name: getattr(thread, item.name) for item in fields(ThreadProvenance)} - ) - - @property - def incarnation(self) -> ThreadIncarnation: - return ThreadIncarnation(self.name, self.created_at) 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/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/transcript_receipts.py b/src/agent_comms/transcript_receipts.py index 1acaf98de0..6fa31a6f89 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.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.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.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.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.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.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.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..b9b735676f 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.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/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/src/agent_comms/turn_input_source.py b/src/agent_comms/turn_input_source.py index c4fb80ad95..6a8b99638c 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 @@ -34,11 +34,12 @@ 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) -> bool: ... + def allows_wait(self, wait: GoalWait, comms: Comms, + owner: RecordedThreadIncarnation) -> bool: ... @abstractmethod def allows_goal_input(self, goal: Goal | None) -> bool: ... @@ -46,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: @@ -55,23 +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, ...]) -> bool: ... + 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, ...]) -> bool: - return wait is None + def allows(self, wait: GoalWait, comms: Comms, origins: tuple[Message, ...], + owner: RecordedThreadIncarnation) -> bool: + 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 @@ -79,23 +82,24 @@ 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, ...]) -> bool: - if wait is None: + def allows(self, wait: GoalWait, comms: Comms, origins: tuple[Message, ...], + owner: RecordedThreadIncarnation) -> bool: + if wait.allows_routed_input(): return True if not self.current(wait): return False 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 - 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) @@ -140,15 +144,16 @@ 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) -> bool: + def allows_wait(self, wait: GoalWait, comms: Comms, + owner: RecordedThreadIncarnation) -> bool: return True def allows_goal_input(self, goal: Goal | None) -> bool: @@ -166,8 +171,9 @@ def notice_text(self) -> str: class RoutedInput(TurnInputSource): - def allows_wait(self, wait: GoalWait | None, comms: Comms) -> bool: - return wait is None + def allows_wait(self, wait: GoalWait, comms: Comms, + owner: RecordedThreadIncarnation) -> bool: + return wait.allows_routed_input() def allows_goal_input(self, goal: Goal | None) -> bool: return not self.keys or InactiveGoalPermission().allows(goal) @@ -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, comms: Comms, + owner: RecordedThreadIncarnation) -> bool: + return self.dependency.allows(wait, comms, self.origins, owner) class ScheduledOriginalInput(DependencyOriginalInput): 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/src/agent_comms/ui_model/sidebar.py b/src/agent_comms/ui_model/sidebar.py index cbf49cf19b..f7b8e7367e 100644 --- a/src/agent_comms/ui_model/sidebar.py +++ b/src/agent_comms/ui_model/sidebar.py @@ -7,13 +7,14 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Container from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import NoReturn from agent_comms.display_order import ChannelSort, NameChannelSort, ThreadSort -from agent_comms.presentation import CoordinationSnapshot, ThreadView +from agent_comms.presentation import CoordinationSnapshot, ListingFilter, ThreadView +from agent_comms.read_ledger import NoReadMarkerNotice, ReadMarkerNotice from agent_comms.thread_execution import ConversationPreparation, ThreadExecution from agent_comms.thread_identity import ThreadIncarnation from agent_comms.thread_presentation import ThreadPresentationStatus @@ -55,13 +56,12 @@ def name(self) -> str: return self.incarnation.name @classmethod - def of(cls, person: ThreadView, snapshot: CoordinationSnapshot | None) -> ThreadRowModel: + def of(cls, person: ThreadView, unread: UnreadPresentation) -> ThreadRowModel: presentation = person.presentation return cls( person.thread.incarnation, presentation.title, presentation.label, presentation.summary, presentation.status, person.thread.model, - (UnobservedUnread() if snapshot is None else - person.thread.execution.prepare_conversation(PersonUnread(person, snapshot))), + unread, person.status.active, person.thread.execution, ) @@ -78,29 +78,44 @@ class ChannelRowModel: order: type[ThreadSort] @classmethod - def of(cls, view, snapshot: CoordinationSnapshot, working: frozenset[str]) -> ChannelRowModel: + def of(cls, view, snapshot: CoordinationSnapshot, working: frozenset[str], + names: Container[str]) -> ChannelRowModel: channel = view.channel return cls( channel.name, f"{'* ' if channel.pinned else ''}{channel.name} {view.active_agents}/{view.registered_agents}", f"{view.active_agents} running or idle / {view.registered_agents} registered agents" - f" · tags: {', '.join(sorted(channel.tags)) or 'all'}", + f" · tags: {channel.tag_description}", UnreadPresentation.for_channel(snapshot, channel.name), any(name in working for name in view.members), - tuple(name for name in view.members if name in {person.thread.name for person in snapshot.threads}), + tuple(name for name in view.members if name in names), frozenset(view.pinned_members), channel.order, ) class SidebarObservation(ABC): - snapshot: CoordinationSnapshot | None = None + channel_order: type[ChannelSort] = NameChannelSort + + @property + def read_marker_notice(self) -> ReadMarkerNotice: + return NoReadMarkerNotice() + + @property + def filters(self) -> ListingFilter: + self.unavailable() def require_snapshot(self) -> CoordinationSnapshot: - snapshot = self.snapshot - if snapshot is None: - self.unavailable() - return snapshot + self.unavailable() + + def channel_unread(self, target: str) -> UnreadPresentation: + return UnobservedUnread() + + def bus_unread(self, target: str) -> UnreadPresentation: + return UnobservedUnread() + + def thread_row(self, person: ThreadView) -> ThreadRowModel: + return ThreadRowModel.of(person, UnobservedUnread()) def unavailable(self) -> NoReturn: raise RuntimeError("Sidebar source has not been observed") @@ -132,6 +147,31 @@ def references_for(self, root): class ObservedSidebar(SidebarObservation): snapshot: CoordinationSnapshot = field() + def require_snapshot(self) -> CoordinationSnapshot: + return self.snapshot + + @property + def channel_order(self): + return self.snapshot.channel_order + + @property + def read_marker_notice(self): + return self.snapshot.read_marker_notice + + @property + def filters(self): + return self.snapshot.filters + + def channel_unread(self, target): + return UnreadPresentation.for_channel(self.snapshot, target) + + def bus_unread(self, target): + return UnreadPresentation.for_bus(self.snapshot, target) + + def thread_row(self, person): + unread = person.thread.execution.prepare_conversation(PersonUnread(person, self.snapshot)) + return ThreadRowModel.of(person, unread) + def references_for(self, root): from ..display_references import ForeignReferences references = self.snapshot.references @@ -153,34 +193,30 @@ def __init__(self, schedule: Callable[[Callable[[], None]], None]): self.threads: KeyedModel[str, ThreadRowModel] = KeyedModel(schedule) self.observation: SidebarObservation = WaitingSidebar() - @property - def snapshot(self) -> CoordinationSnapshot | None: - return self.observation.snapshot - def references_for(self, root): return self.observation.references_for(root) @property def channel_order(self) -> type[ChannelSort]: - return self.snapshot.channel_order if self.snapshot is not None else NameChannelSort + return self.observation.channel_order @property - def read_marker_notice(self) -> str | None: - return self.snapshot.read_marker_notice if self.snapshot is not None else None + def read_marker_notice(self): + return self.observation.read_marker_notice @property - def filters(self) -> tuple[bool, bool] | None: - """The stopped/archived filters the published rows were read with.""" - if self.snapshot is None: - return None - return self.snapshot.show_stopped, self.snapshot.show_archived + def filters(self) -> ListingFilter: + """The stopped/archived selection that produced the published rows.""" + return self.observation.filters @staticmethod def derive(snapshot: CoordinationSnapshot) -> tuple[dict[str, ChannelRowModel], dict[str, ThreadRowModel]]: - threads = {person.thread.name: ThreadRowModel.of(person, snapshot) for person in snapshot.threads} + observation = ObservedSidebar(snapshot) + threads = {person.thread.name: observation.thread_row(person) for person in snapshot.threads} + names = threads.keys() working = frozenset(member for name, row in threads.items() for member in row.status.working_members(name)) - channels = {view.channel.name: ChannelRowModel.of(view, snapshot, working) + channels = {view.channel.name: ChannelRowModel.of(view, snapshot, working, names) for view in snapshot.channels} return channels, threads diff --git a/src/agent_comms/wire_log.py b/src/agent_comms/wire_log.py index 39006390a2..ea731fd2a8 100644 --- a/src/agent_comms/wire_log.py +++ b/src/agent_comms/wire_log.py @@ -32,6 +32,7 @@ from .errors import ( RelationViolationError, ) +from .channel_targets import AllHistoryTargets from .field_codec import FieldCodec from .messages import Message from .private_path import FileRevision @@ -206,7 +207,7 @@ def public_records_for(self, sequences: frozenset[int], *, before_offset: int rows = tuple( row for seq in sorted(sequences) for row in index.offsets(lower=seq - 1, upper=seq + 1, - descending=False, targets=None, + descending=False, targets=AllHistoryTargets(), before_offset=before_offset) ) except (OSError, sqlite3.DatabaseError, StaleBusPageIndexError, ValueError, TypeError): diff --git a/tests/attached_history_installed_pilot.py b/tests/attached_history_installed_pilot.py index 28d4d5316e..2c216ecffc 100644 --- a/tests/attached_history_installed_pilot.py +++ b/tests/attached_history_installed_pilot.py @@ -79,7 +79,7 @@ async def main(): for alias,name in source.provenance.aliases.items(): assert source.provenance.require(alias).incarnation==source.provenance.require(name).incarnation assert any(item.source==source and item.thread.incarnation==source.provenance.require(name).incarnation - for item in comms.views.historical_threads(alias)) + for item in comms.views.historical_thread(alias)) print('111_ORIGINAL_DECLARATIONS_9_SOURCE_SCOPED_ALIASES_RETAINED',flush=True) for source in sources: from agent_comms.catalog_store import ChannelCatalog 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/native_context_reader_journey.py b/tests/native_context_reader_journey.py index d2715ca26d..1597c9941e 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 RecordedThreadIncarnation 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(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 2ba89b504b..95686f302c 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 RecordedThreadIncarnation 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'/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 b16674f421..6b8f8af2d1 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 RecordedThreadIncarnation 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=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/retained_history_cutover_pilot.py b/tests/retained_history_cutover_pilot.py index 9dbcf23b30..e67805a472 100644 --- a/tests/retained_history_cutover_pilot.py +++ b/tests/retained_history_cutover_pilot.py @@ -54,7 +54,8 @@ def test_current_saved_history_has_no_live_admission_or_delivery(tmp_path): reference_actual_history(comms.root, Path(os.environ['ACTUAL_COMMS_SOURCE_ROOT'])) source=max(comms.bus.history.sources(),key=lambda source:source.size) live_before=(comms.root/'bus.jsonl').read_bytes() - historical=comms.bus.history.page(ChannelHistory('#comms',frozenset({'#comms'}))) + from agent_comms.channel_targets import NamedHistoryTargets + historical=comms.bus.history.page(ChannelHistory('#comms',NamedHistoryTargets(frozenset({'#comms'})))) assert historical.messages,'Real supplied history must contain #comms messages' assert all(not message.starts_turn for message in historical.messages) assert all(message.source==source for message in historical.messages) diff --git a/tests/shared_bus_restart_native.py b/tests/shared_bus_restart_native.py index f21d675dc1..8ebb22c380 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 RecordedThreadIncarnation 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 = 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'/stable_thread_lookup(owner.created_at) + 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) @@ -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()) @@ -1104,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 = 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 d6ca49229b..2b1c4ec9fd 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 RecordedThreadIncarnation 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 = 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,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=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 3ff9a54a75..1c635ab1bd 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 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 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 = 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 = stable_thread_lookup(receiver.created_at) + 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 = stable_thread_lookup(receiver.created_at) + 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 = stable_thread_lookup(receiver.created_at) + 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 d6c586d5c8..5c65f9b72d 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 RecordedThreadIncarnation 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 + 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(stable_thread_lookup(owner.created_at)) + 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 = stable_thread_lookup(comms.registry.require("beta").created_at) + 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 = stable_thread_lookup(comms.registry.require("beta").created_at) + 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 = stable_thread_lookup(comms.registry.require("beta").created_at) + 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 = stable_thread_lookup(comms.registry.require("beta").created_at) + 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( - stable_thread_lookup(alpha.created_at), "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, - stable_thread_lookup(comms.registry.require("beta").created_at), + 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 93d03703c5..71129fe12a 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 RecordedThreadIncarnation 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), + 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, stable_thread_lookup(51003.0), 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, stable_thread_lookup(51003.0), 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( - stable_thread_lookup(61002.0), "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( - stable_thread_lookup(61003.0), "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, stable_thread_lookup(61003.0), 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, stable_thread_lookup(61002.0)) == () + assert sealed_cohort_assignments(store, RecordedThreadIncarnation("fixture", 61002.0).lookup) == () finally: if attached is not None: await attached.shutdown() diff --git a/tests/test_bus_page_index.py b/tests/test_bus_page_index.py index ae20ab194b..75655dc7ca 100644 --- a/tests/test_bus_page_index.py +++ b/tests/test_bus_page_index.py @@ -99,7 +99,8 @@ def index(): results = [workers.submit(index) for _ in range(2)] assert all(result.result(timeout=10) for result in results) with BusPageIndex(comms.bus.log.path) as source: - rows = tuple(source.offsets(lower=None, upper=None, descending=False, targets=None)) + from agent_comms.channel_targets import AllHistoryTargets + rows = tuple(source.offsets(lower=None, upper=None, descending=False, targets=AllHistoryTargets())) assert [row.seq for row in rows] == [message.seq] with pytest.raises(sqlite3.IntegrityError), source.connection: rows[0].insert(source.connection) 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 b6b09577ce..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.bus_publication import stable_thread_lookup +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=stable_thread_lookup(comms.registry.require("beta").created_at), + recipient_lookup=RecordedThreadIncarnation("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=RecordedThreadIncarnation("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=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 c6ce58fbbe..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.bus_publication import stable_thread_lookup +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 = stable_thread_lookup(receiver.created_at) + 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 a55ee10aab..32a490c65c 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 RecordedThreadIncarnation 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, 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, stable_thread_lookup(17002.0))[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 55788a0671..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.bus_publication import stable_thread_lookup +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( - stable_thread_lookup(people[name]), 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, stable_thread_lookup(people["Alice"])) - assert sealed_cohort_assignments(coordinator, stable_thread_lookup(people["Bob"])) == () + 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 5f58210d9a..5e5aa8ca36 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 RecordedThreadIncarnation 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 = 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 152dec6e2f..d68e4958fc 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 RecordedThreadIncarnation 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 = 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 b9da76285e..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.bus_publication import stable_thread_lookup +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( - stable_thread_lookup(comms.registry.require("beta").created_at), + 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( - stable_thread_lookup(comms.registry.require("beta").created_at), + RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup, "beta", "beta", committed=True, diff --git a/tests/test_command_families.py b/tests/test_command_families.py index 852c914d5d..cfdf3f511a 100644 --- a/tests/test_command_families.py +++ b/tests/test_command_families.py @@ -232,7 +232,7 @@ def test_normal_history_cli_keeps_migrated_content_and_source_identity( assert all(row["history"]["source"] == str(old.root) for row in historical) assert all(row["history"]["wire_root_id"] == source.wire_root_id for row in historical) assert all( - row["history"]["sender_created_at"] == old.registry.require("alice").created_at + row["history"]["sender_incarnation"] == old.registry.require("alice").incarnation.to_wire() for row in historical ) assert (live.root / "bus.jsonl").read_bytes() == live_bus diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index 0954e7d3d8..3b7ef0e7f6 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 RecordedThreadIncarnation 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 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({stable_thread_lookup(created_at) 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" @@ -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_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 95e21edc16..8b9df753a0 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 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 @@ -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=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: stable_thread_lookup(person.created_at) 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 = stable_thread_lookup(people[1].created_at) - beta_lookup = stable_thread_lookup(people[2].created_at) + 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 = stable_thread_lookup(people[1].created_at) + 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 = stable_thread_lookup(people[1].created_at) + 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=stable_thread_lookup(people[2].created_at), + 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,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" / RecordedThreadIncarnation("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" / RecordedThreadIncarnation("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, 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() @@ -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=RecordedThreadIncarnation("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=RecordedThreadIncarnation("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=RecordedThreadIncarnation("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=RecordedThreadIncarnation("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=RecordedThreadIncarnation("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 = 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" @@ -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=RecordedThreadIncarnation("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=RecordedThreadIncarnation("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 = RecordedThreadIncarnation("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 = 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( @@ -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, RecordedThreadIncarnation("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 = 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 @@ -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 = 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 925f91b47b..46c7b395f1 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 RecordedThreadIncarnation 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] = 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 355155c668..b143b673a4 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 RecordedThreadIncarnation 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 + 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 8ee638c8fa..ba8127a37a 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.bus_publication import stable_thread_lookup +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): @@ -24,9 +25,15 @@ def register(comms, name): return comms.registry.require(name) -def test_original_reply_is_not_reassigned_to_same_name_replacement(tmp_path): +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") 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,8 +44,8 @@ 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 original.audience.sender_lookup == stable_thread_lookup(peer.created_at) + assert wait.matches(original, owner.incarnation.require_recorded()) + assert original.audience.sender_lookup == RecordedThreadIncarnation("fixture", peer.created_at).lookup comms.agents.finish_turn(lease) comms.registry.unregister("peer") @@ -47,13 +54,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 +80,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 +97,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 +145,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: @@ -158,7 +169,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}") @@ -174,7 +185,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=RecordedThreadIncarnation("fixture", owner.created_at).lookup, source_seq=original.seq, ) assert len(proofs) == 1 and isinstance(proofs[0].execution, FullNativeExecution) @@ -185,7 +196,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_page_scope.py b/tests/test_historical_page_scope.py index 0c5d167f61..389146b082 100644 --- a/tests/test_historical_page_scope.py +++ b/tests/test_historical_page_scope.py @@ -5,6 +5,7 @@ import pytest +from agent_comms.channel_targets import AllHistoryTargets from agent_comms.comms import Comms from agent_comms.historical_views import ChannelDisplayHistory, HistoryCursor from agent_comms.mentions import ThreadMention @@ -111,7 +112,7 @@ def test_cold_and_warm_historical_pages_equal_full_scan( live, source, rows = history read = reader(live, kind) class SelectedScope(MessageDisplayScope): - index_targets = None + index_targets = AllHistoryTargets() def includes(self, message): return selected(kind, message) @@ -179,7 +180,7 @@ def capture(cls, channel, snapshot, **kwargs): narrowed_decodes = decoded decoded = 0 with monkeypatch.context() as patch: - patch.setattr(ChannelDisplayScope, "index_targets", property(lambda _scope: None)) + patch.setattr(ChannelDisplayScope, "index_targets", property(lambda _scope: AllHistoryTargets())) unfiltered = live.bus.history.page(view, limit=2) assert unfiltered == warm assert decoded == 141 # same predicate/page without the target prefilter diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index c8edd48eb9..3acc84ca1d 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -12,6 +12,7 @@ from agent_comms.read_ledger import ReadLedger from agent_comms.routing import MessageRoute, TurnRouting from agent_comms.threads import Thread +from agent_comms.thread_identity import RecordedThreadIncarnation, UnrecordedThreadIncarnation @pytest.mark.parametrize('exists', [False, True]) @@ -99,7 +100,8 @@ def test_original_identity_and_no_delivery_authority(migrated): historical = [m for m in rows if isinstance(m, HistoricalMessage) and m.source == source] original = old.views.channel_history("#team") assert [m.to_wire() for m in historical] == [m.to_wire() for m in original] - assert all(m.sender_created_at == 10.0 for m in historical) + assert all(m.sender_incarnation == RecordedThreadIncarnation(m.sender, 10.0) + for m in historical) assert len(live.bus.incoming_page("bob", after=0).messages) == 2 assert live.bus.log.latest_sequence() == 4 assert live.views.attach_history(old.root) == source @@ -255,7 +257,7 @@ def test_original_recorded_projection_is_strict_and_attaches_before_destination_ observed = FieldCodec.decode(HistorySource, packet) assert observed.provenance == RegistryProvenance.capture(old.registry.snapshot()) observed.require_original(old.root) - attached = live.views.attach_history(old.root, source_read=observed) + attached = live.views.attach_original_history(observed) assert attached.provenance == observed.provenance assert (Path(attached.root) / "registry.json").read_bytes() == original assert (old.root / "registry.json").read_bytes() == original @@ -320,7 +322,7 @@ def test_older_than_registry_incarnation_stays_unattributed(migrated): projected = HistoricalMessage.project( replace(original, timestamp=1.0), source, 0, source.provenance ) - assert projected.sender_created_at is None + assert projected.sender_incarnation == UnrecordedThreadIncarnation(projected.sender) assert projected.timestamp == 1.0 @@ -338,13 +340,15 @@ 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") 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") @@ -354,8 +358,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_identity 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): @@ -364,7 +374,7 @@ def test_historical_namespace_has_recorded_provenance_and_no_live_authority(migr before = FieldCodec.encode(source.provenance) live.registry.rename("alice", "new-alice") assert FieldCodec.encode(source.provenance) == before - assert live.views.historical_threads("alice") + assert live.views.historical_thread("alice") @pytest.mark.parametrize('changed_resource', ['bus', 'certificate', 'pending']) diff --git a/tests/test_history_view_declaration.py b/tests/test_history_view_declaration.py index 85bb0ef84c..ac56ba2ffb 100644 --- a/tests/test_history_view_declaration.py +++ b/tests/test_history_view_declaration.py @@ -1,5 +1,6 @@ """A new history declaration crosses real current and retained-source boundaries.""" +from agent_comms.channel_targets import NamedHistoryTargets from agent_comms.comms import Comms from agent_comms.historical_views import HistoryView from agent_comms.message_page import MessagePageRequest @@ -9,7 +10,7 @@ class ReviewHistory(HistoryView): def capture(self, snapshot): - return ChannelDisplayScope("#review", frozenset({"#review"})) + return ChannelDisplayScope("#review", NamedHistoryTargets(frozenset({"#review"}))) def live_page(self, presentation, **paging): return MessagePageRequest.capture( 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 419a8a7459..0913aca2f2 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 RecordedThreadIncarnation 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 = 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 = stable_thread_lookup(owner.created_at) + 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 = stable_thread_lookup(owner.created_at) + 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 = stable_thread_lookup(leased.thread.created_at) + 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 b0defe84be..a12c5e4d49 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 RecordedThreadIncarnation 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 = 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=stable_thread_lookup(sender.created_at))) + 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 089e29fab6..cbbb5ea8ef 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 RecordedThreadIncarnation 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, 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, stable_thread_lookup(beta.created_at), 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, stable_thread_lookup(beta.created_at), 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] @@ -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, 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_message_page_request.py b/tests/test_message_page_request.py index 10cb502802..eb5181b36e 100644 --- a/tests/test_message_page_request.py +++ b/tests/test_message_page_request.py @@ -5,6 +5,7 @@ import pytest +from agent_comms.channel_targets import AllHistoryTargets from agent_comms.comms import wire from agent_comms.message_page import MessagePageRequest from agent_comms.read_basis import MessageDisplayScope @@ -15,7 +16,7 @@ @dataclass(frozen=True) class ReviewScope(MessageDisplayScope): prefix: str - index_targets = None + index_targets = AllHistoryTargets() def includes(self, message): return message.body.startswith(self.prefix) diff --git a/tests/test_native_channel_reply_roundtrip.py b/tests/test_native_channel_reply_roundtrip.py index f7d41c1056..fe20054a88 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 RecordedThreadIncarnation 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, 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=stable_thread_lookup(owner.created_at), 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 0cf094f85a..21d2d8e70d 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 RecordedThreadIncarnation 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=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=stable_thread_lookup(people[1].created_at), + 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 = stable_thread_lookup(people[1].created_at) + 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=stable_thread_lookup(people[1].created_at), + 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=stable_thread_lookup(people[1].created_at), + 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( - stable_thread_lookup(beta.created_at), "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=stable_thread_lookup(people[1].created_at), + 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=stable_thread_lookup(people[1].created_at), + 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 = stable_thread_lookup(people[1].created_at) + 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 = stable_thread_lookup(people[1].created_at) + 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=stable_thread_lookup(people[1].created_at), + 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=stable_thread_lookup(people[1].created_at), + 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=stable_thread_lookup(people[1].created_at), + 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=stable_thread_lookup(people[1].created_at), + 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( - stable_thread_lookup(people[1].created_at), + 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=stable_thread_lookup(people[1].created_at), + 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, stable_thread_lookup(people[1].created_at)) + 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 b70c73f33a..c4da9ea07d 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 RecordedThreadIncarnation 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 + 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" / stable_thread_lookup(owner.created_at) / "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" / stable_thread_lookup(owner.created_at) / "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" / stable_thread_lookup(owner.created_at) / "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" / stable_thread_lookup(owner.created_at) / "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 2411a91a84..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.bus_publication import stable_thread_lookup +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( - stable_thread_lookup(owner.created_at), 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 2557424337..9f6f4aae24 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 RecordedThreadIncarnation 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), + 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=stable_thread_lookup(people[1].created_at), + 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( - stable_thread_lookup(other.created_at), "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 = stable_thread_lookup(people[1].created_at) + 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 = stable_thread_lookup(people[1].created_at) + 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 a8adb44a3e..7d3fda0f9d 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 RecordedThreadIncarnation 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 = 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 5545015e50..367801c247 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 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 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) + RecordedThreadIncarnation("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 = 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") @@ -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 = 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 1cd148e4ae..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, @@ -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=NoGoalWait(), comms=comms ).require_valid() for source, current_wait, expected in ( - (routed, None, rules.OrdinaryGoalInputRule), - (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), ): 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 diff --git a/tests/test_ordinary_nk_delivery.py b/tests/test_ordinary_nk_delivery.py index 754dec709c..88750a5307 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 RecordedThreadIncarnation 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 + 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) @@ -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 = 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 873c34b871..3f93e8ecc1 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 RecordedThreadIncarnation 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 = 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 = stable_thread_lookup(comms.registry.require("beta").created_at) + lookup = RecordedThreadIncarnation("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==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) @@ -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 = 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) @@ -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 = 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" @@ -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 == 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 c85923a898..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.bus_publication import stable_thread_lookup +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 = stable_thread_lookup(17002.0) - bob = stable_thread_lookup(17003.0) + 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, stable_thread_lookup(17004.0))[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, stable_thread_lookup(17002.0))[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, stable_thread_lookup(17002.0)) + 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, stable_thread_lookup(17002.0)) + _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, stable_thread_lookup(17002.0)) + _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, stable_thread_lookup(17002.0)) + _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 = stable_thread_lookup(17002.0) + 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, stable_thread_lookup(17002.0)) + _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, stable_thread_lookup(17002.0)) + _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, stable_thread_lookup(17002.0)) + 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, stable_thread_lookup(17002.0)) + _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 = stable_thread_lookup(17002.0) + 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, stable_thread_lookup(17004.0))[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, stable_thread_lookup(17002.0))[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, stable_thread_lookup(17002.0))[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, stable_thread_lookup(17002.0))[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, stable_thread_lookup(17002.0)) + _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 0acdea5364..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.bus_publication import stable_thread_lookup +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 = stable_thread_lookup(people[1].created_at) + 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 247143b766..5621f00288 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 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 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] = 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 6728f8d702..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.bus_publication import stable_thread_lookup +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( - stable_thread_lookup(other.created_at), 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(stable_thread_lookup(beta.created_at)) + 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, stable_thread_lookup(beta.created_at), 0, + comms.bus, _root_id, store.session.path, RecordedThreadIncarnation("fixture", beta.created_at).lookup, 0, owner_name=beta.name, ) pending = next_sealed_assignment( - store, stable_thread_lookup(beta.created_at), 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 = stable_thread_lookup(people[2].created_at) + 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 218d9627e2..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.bus_publication import stable_thread_lookup +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 = stable_thread_lookup(late.created_at) + 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 caa50a9743..c4e8470a41 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 RecordedThreadIncarnation 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(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..fd1eb02358 100644 --- a/tests/test_read_ledger.py +++ b/tests/test_read_ledger.py @@ -7,7 +7,10 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms, wire +from agent_comms.field_codec import FieldCodec from agent_comms.threads import Thread +from agent_comms.read_ledger import NoReadMarkerNotice, ReadDocument, RecordedReadMarkerNotice +from agent_comms.thread_identity import RecordedThreadIncarnation def prepared(root): @@ -20,11 +23,9 @@ def prepared(root): def test_conversation_persisted_pairs_decode_once_without_an_encode_round_trip(): from unittest.mock import patch - 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")): @@ -50,17 +51,36 @@ def test_sidebar_presents_pending_read_reset_notice_once(tmp_path): comms = Comms(tmp_path) comms.messaging.initialize_private_initial_protocol() viewer = comms.messaging.user_identity(str(tmp_path)).name - notice = "Read positions were reset; reopen conversations to review them." + notice = RecordedReadMarkerNotice("Read positions were reset; reopen conversations to review them.") comms.bus.reads.replace(replace(comms.bus.reads.read(), notice=notice)) - assert comms.bus.reads.take_notice(None) is None + assert comms.bus.reads.take_notice(NoReadMarkerNotice()) == NoReadMarkerNotice() assert comms.bus.reads.read().notice == notice first = comms.views.presentation.roster(viewer) assert first.read_marker_notice == notice - assert comms.bus.reads.read().notice is None + assert comms.bus.reads.read().notice == NoReadMarkerNotice() second = Comms(tmp_path).views.presentation.roster(viewer) - assert second.read_marker_notice is None + assert second.read_marker_notice == NoReadMarkerNotice() + + +@pytest.mark.parametrize('raw,expected', [ + (None, NoReadMarkerNotice()), + ('Read positions changed', RecordedReadMarkerNotice('Read positions changed')), +]) +def test_read_notice_is_chosen_once_at_the_native_boundary(raw, expected): + document = FieldCodec.decode(ReadDocument, {'notice': raw}) + assert document.notice == expected + assert FieldCodec.decode(ReadDocument, FieldCodec.encode(document)) == document + assert document.notice.messages_since(document.notice) == () + with pytest.raises(ValueError, match='declared member'): + FieldCodec.decode(NoReadMarkerNotice, 'This is a notice') + with pytest.raises(ValueError, match='declared member'): + FieldCodec.decode(RecordedReadMarkerNotice, None) + with pytest.raises(ValueError, match='requires text'): + FieldCodec.decode(ReadDocument, {'notice': ''}) + with pytest.raises(ValueError, match='requires text'): + RecordedReadMarkerNotice('') def test_bounded_page_marks_only_shown_messages(tmp_path): diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py new file mode 100644 index 0000000000..890cc090ee --- /dev/null +++ b/tests/test_recipient_activity_index.py @@ -0,0 +1,151 @@ +"""Registry snapshots own recipient lookup; activity reads own their requested set.""" + +import hashlib +from dataclasses import replace +from types import SimpleNamespace +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, 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_provenance import RegisteredRecipient, RetiredRecipient +from agent_comms.registry_provenance import RegistryProvenance +from agent_comms.thread_identity import ( + DeletedThreadIncarnation, + RecordedThreadIncarnation, + ThreadIncarnation, + 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 = 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 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.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.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( + ((original, recipient), (original, recipient)), snapshot=snapshot, + ) + 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): + 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} + deleted = ThreadIncarnation.deleted("worker") + 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() + with pytest.raises(ValueError, match="positive recorded birth"): + GoalWaitTarget("historical", 0.0) + + +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() + 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 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"): + 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(ValueError, match="positive recorded birth"): + replace(source.threads["second"], created_at=0.0) + from agent_comms.thread_identity 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") + + +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.current_for(("peer-7",))) == {"peer-7"} + assert log.current_for(()) == {} + assert len(log.all_current()) == 10 diff --git a/tests/test_retained_native_tool_latency.py b/tests/test_retained_native_tool_latency.py index 031f48596a..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.bus_publication import stable_thread_lookup +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" / stable_thread_lookup(owner.created_at) + 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 9ab371d417..26a7dfaee2 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 RecordedThreadIncarnation 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 + 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(stable_thread_lookup(live.created_at)) == live_before + assert store.participants.get(RecordedThreadIncarnation("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(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..98c8b4300f 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_identity 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 2d7535d6c8..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.bus_publication import stable_thread_lookup +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 = stable_thread_lookup(comms.registry.require("Alice").created_at) + 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 abd02393cc..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.bus_publication import stable_thread_lookup +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=stable_thread_lookup(owner.created_at), + 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=stable_thread_lookup(owner.created_at), + 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 e3d3120326..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.bus_publication import stable_thread_lookup +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 = stable_thread_lookup(comms.registry.require("receiver").created_at) + 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 = stable_thread_lookup(comms.registry.require("receiver").created_at) + 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 = stable_thread_lookup(comms.registry.require("receiver").created_at) + 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 2444486cc0..b65496b8f8 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 RecordedThreadIncarnation 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 == 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 881339fb27..4c842f7316 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 RecordedThreadIncarnation 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(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(stable_thread_lookup(thread.created_at)) + 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 e5de7618b9..c18e8051c7 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 RecordedThreadIncarnation 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(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=stable_thread_lookup(17002.0) + 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_viewer_snapshot_index.py b/tests/test_viewer_snapshot_index.py index 3785e9ca19..5fc4808eb5 100644 --- a/tests/test_viewer_snapshot_index.py +++ b/tests/test_viewer_snapshot_index.py @@ -32,7 +32,8 @@ def test_display_metrics_encode_one_viewer_read_set_for_all_channels(tmp_path): from agent_comms.field_codec import FieldCodec from agent_comms.read_basis import ChannelDisplayScope - scopes = tuple(ChannelDisplayScope(f"#{n}", frozenset({f"#{n}"})) for n in range(48)) + from agent_comms.channel_targets import NamedHistoryTargets + scopes = tuple(ChannelDisplayScope(f"#{n}", NamedHistoryTargets(frozenset({f"#{n}"}))) for n in range(48)) seen = frozenset(range(10_000)) encoded = FieldCodec.encode(DisplayMetricScope(scopes, frozenset({"viewer"}), seen)) assert set(encoded) == {"scopes", "viewer_names", "seen_sequences"} diff --git a/tests/test_visibility_views.py b/tests/test_visibility_views.py index a1cd9f3c59..3c06f6b32a 100644 --- a/tests/test_visibility_views.py +++ b/tests/test_visibility_views.py @@ -1,6 +1,13 @@ """Viewer-only presence flags reveal archived threads without reviving owners.""" +import pytest + from agent_comms.comms import wire +from agent_comms.display_order import NameChannelSort +from agent_comms.presentation import ListingFilter +from agent_comms.read_ledger import NoReadMarkerNotice +from agent_comms.ui_model.sidebar import SidebarModel +from agent_comms.ui_model.unread import UnobservedUnread from agent_comms.thread_identity import ThreadRole from agent_comms.thread_status import ArchivedThreadStatus, StoppedThreadStatus from agent_comms.threads import Thread @@ -25,7 +32,7 @@ def members(snapshot): default = comms.views.viewer_snapshot(str(tmp_path)) assert names(default) == {"active", "stopped"} assert set(members(default)) == {"active", "stopped"} - assert default.show_stopped and not default.show_archived + assert default.filters == ListingFilter(show_stopped=True, show_archived=False) running = comms.views.viewer_snapshot(str(tmp_path), show_stopped=False) assert names(running) == set(members(running)) == {"active"} both = comms.views.viewer_snapshot(str(tmp_path), show_archived=True) @@ -39,3 +46,33 @@ def members(snapshot): assert {view.thread.name for view in comms.views.thread_views()} == {"active", "stopped"} channel = next(view for view in comms.views.channel_views() if view.channel.name == "#team") assert set(channel.members) == {"active", "stopped"} + + +def test_sidebar_observation_owns_unavailable_and_observed_answers(tmp_path): + model = SidebarModel(lambda flush: flush()) + assert model.channel_order is NameChannelSort + assert model.read_marker_notice == NoReadMarkerNotice() + assert isinstance(model.observation.channel_unread('#team'), UnobservedUnread) + assert isinstance(model.observation.bus_unread('member'), UnobservedUnread) + with pytest.raises(RuntimeError, match='has not been observed'): + _ = model.filters + + comms = wire(tmp_path) + comms.messaging.initialize_private_initial_protocol() + comms.registry.declare(Thread('member', frozenset({'team'}), str(tmp_path))) + snapshot = comms.views.viewer_snapshot(str(tmp_path)) + model.apply(snapshot, model.derive(snapshot)) + assert model.filters is snapshot.filters + assert model.observation.require_snapshot() is snapshot + assert model.channels.rows['#team'].members == ('member',) + for target in ('#all', '#any', '#none'): + assert model.channels.rows[target].tooltip.endswith(f'tags: {target}') + + failure = ValueError('Sidebar read failed') + model.clear(failure) + assert model.threads.rows == model.channels.rows == {} + assert isinstance(model.observation.channel_unread('#team'), UnobservedUnread) + assert isinstance(model.observation.bus_unread('member'), UnobservedUnread) + with pytest.raises(ValueError) as refused: + _ = model.filters + assert refused.value is failure diff --git a/tests/test_wake_candidate_index.py b/tests/test_wake_candidate_index.py index a73abaa998..890224227b 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 RecordedThreadIncarnation 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: 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 654c1a7b59..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.bus_publication import stable_thread_lookup +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, stable_thread_lookup(1_700_010_100.0) + return comms, WakeCandidateIndex(comms.bus.log), root_id, RecordedThreadIncarnation("fixture", 1_700_010_100.0).lookup def _page( diff --git a/tests/test_wire_read_custody.py b/tests/test_wire_read_custody.py index 25ac914643..3b930d5918 100644 --- a/tests/test_wire_read_custody.py +++ b/tests/test_wire_read_custody.py @@ -5,6 +5,7 @@ from concurrent.futures import ThreadPoolExecutor from agent_comms.bus_page_index import BusPageIndex, BusPageSource +from agent_comms.channel_targets import AllHistoryTargets from agent_comms.comms import wire from agent_comms.threads import Thread from agent_comms.wire_record import WireRecord @@ -91,7 +92,7 @@ def sync(): rows = tuple( reader.offsets( lower=None, upper=None, descending=False, - targets=None, before_offset=saved.revision.size, + targets=AllHistoryTargets(), before_offset=saved.revision.size, ) ) assert update.result(timeout=5) @@ -100,7 +101,7 @@ def sync(): assert [row.seq for row in rows] == [first.seq] with BusPageIndex(comms.bus.log.path, readonly=True) as current: assert [row.seq for row in current.offsets( - lower=None, upper=None, descending=False, targets=None + lower=None, upper=None, descending=False, targets=AllHistoryTargets() )] == [first.seq, second.seq] 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 e214b3af53..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.bus_publication import stable_thread_lookup +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=stable_thread_lookup(births["Alice"]), + root_id=root_id, recipient_lookup=RecordedThreadIncarnation("fixture", births["Alice"]).lookup, after_seq=0, required_through_seq=message.seq, ) assert len(page.entries) == 1 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): diff --git a/tools/cutover/retained_routing_installed_pilot.py b/tools/cutover/retained_routing_installed_pilot.py index 162a7ec550..6bbd1e1944 100644 --- a/tools/cutover/retained_routing_installed_pilot.py +++ b/tools/cutover/retained_routing_installed_pilot.py @@ -250,7 +250,7 @@ def main(): service = Comms(stage / 'reader') receipt = json.loads((stage / 'carry-receipt.json').read_text()) acquired = FieldCodec.decode(HistorySource, receipt['recorded_source']) - source = service.views.attach_history(root, source_read=acquired) + source = service.views.attach_original_history(acquired) read = service.transcripts.capture_page_read('alpha', historical_source=source.key) page = service.transcripts.bind_page_read('alpha', read.identity).read() reference = MessageReference(seed['original']['seq'], seed['original']['id'])