From 67638a7dafa36cb4d94f3a167161dccb9deea4ff Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 01:54:25 -0400 Subject: [PATCH 01/17] Plan fleet awareness as commander-native phase 10 On 2026-10-23 the bus was down and the commander kept the fleet map by hand-run scripts. Each finding is a fact Core can derive from git, order records or turn records. Phase 10 names the owner for each: one holder per file at order time, drift and overlap standing rules, a spinning UnitState member, unpushed and empty-PR standing rules, review triggered by delivery, and lost tools as a bus fact. The PlanPhase declaration is left to the phase's first order. Five open PRs already edit plan_phases.py. Co-Authored-By: Claude Opus 5.5 --- plans/commander-native-plan.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/plans/commander-native-plan.md b/plans/commander-native-plan.md index f9fcec6bd9..08fef59ced 100644 --- a/plans/commander-native-plan.md +++ b/plans/commander-native-plan.md @@ -229,6 +229,23 @@ The fix is one derived model with four renderers. **Core is the engine, Toad is **Done when** the skill names no script. +### Phase 10: fleet awareness (after the four plans) + +**Why.** On 2026-10-23 the bus was down for the installer route fix (#2633). Eight Codex threads were coordinated through `codex queue` and one handoff file, and the commander kept the map by hand-run scripts: a git overlap scan and a thread rotation reader. Every finding below came from those scripts or from Tristan noticing. Each finding is a fact Core can derive from git, its order records or turn records, so a commander that has to remember it is a defect. Tristan, 2026-10-23: "i can't wait to have all the infra together so i don't need to manually remember this shit". This phase starts after the virtual bus, multiple inheritance, harness and this plan's phase 9 are done. Its `PlanPhase` declaration in `plan_phases.py` is the first edit of its first order. It is not in this change, because five open PRs (#2622, #2624, #2627, #2630, #2635) already edit that file or this plan. + +**Changes.** Each line names the fact, its owner, and the 2026-10-23 incident it would have caught. +- **One holder per file, refused at order time.** `CommsOrderTool` refuses an order whose scope's files overlap a file already held by another open order or by another open branch's diff. The refusal names both holders. Incident: two threads wrote the same route fix in `installer/backup.py` until Tristan reassigned it by hand. +- **Drift and overlap as standing rules.** Two `StandingRule` members are derived from git on read. A held branch more than 30 commits behind `main`. Two open branches whose `src` diffs share a file. Each line names the holders and the files. Incident: `bus_tables.py` was edited on four branches at once, and five branches sat 66 to 90 commits behind `main`. +- **Spinning as a `UnitState` member.** Derived from turn records: patches without a commit, test reruns with no source change between them, and minutes since the last push while holding an order. It extends phase 7's `UnitTime` instead of adding a second clock. Incident: the commander sampled tool calls per 15 minutes by hand to tell work from wheel-spinning. +- **Unpushed work as a standing rule.** `comms_work_branches` gains local branches with no remote alongside unlinked remote branches, and a `StandingRule` member reports both. Incident: the lock fix and the projection producer fix lived only in local worktrees. +- **Empty PRs as a standing rule.** An open PR with no commit ahead of `main` is a line whose disposition is closing it. Incident: #2618, #2619, #2621 and #2625. +- **Review is triggered by delivery.** A delivery recorded at a commit (`comms_order_deliver`) enqueues phase 4's review of that commit. A reviewer never reviews a head nobody delivered. Incident: #2633 was reviewed and failed at 8af8f95, a head that did not yet contain the fix, and a rehearsal was ordered against the same stale head. +- **Lost tools are a bus fact.** Each thread's turn records the tool set it was offered. A turn offered fewer tools than the previous one raises a fact for the commander, naming the missing tools and the runtime version. Incident: the Codex 0.164.0 upgrade removed `send_message_to_thread` and `read_thread` from every thread after restart, and only Tristan noticed. + +**Deletes:** the hand-run overlap and rotation scripts the commander keeps until this phase lands (`fleet_check.py` in the commander's scratch, outside `src`). + +**Done when** replaying the 2026-10-23 fleet state on a copy raises each incident above from Core alone, once each, with no script running. + ## Parity gate (before phase 8) 1. **Every row of the matrix** names a native owner that is on `main`. From 26cb9b7f72797fe730eb56c4bf64705aa792b5ba Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 01:58:00 -0400 Subject: [PATCH 02/17] Name the three PRs that hold plan_phases.py Co-Authored-By: Claude Opus 5.5 --- plans/commander-native-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/commander-native-plan.md b/plans/commander-native-plan.md index 08fef59ced..16ea7c233a 100644 --- a/plans/commander-native-plan.md +++ b/plans/commander-native-plan.md @@ -231,7 +231,7 @@ The fix is one derived model with four renderers. **Core is the engine, Toad is ### Phase 10: fleet awareness (after the four plans) -**Why.** On 2026-10-23 the bus was down for the installer route fix (#2633). Eight Codex threads were coordinated through `codex queue` and one handoff file, and the commander kept the map by hand-run scripts: a git overlap scan and a thread rotation reader. Every finding below came from those scripts or from Tristan noticing. Each finding is a fact Core can derive from git, its order records or turn records, so a commander that has to remember it is a defect. Tristan, 2026-10-23: "i can't wait to have all the infra together so i don't need to manually remember this shit". This phase starts after the virtual bus, multiple inheritance, harness and this plan's phase 9 are done. Its `PlanPhase` declaration in `plan_phases.py` is the first edit of its first order. It is not in this change, because five open PRs (#2622, #2624, #2627, #2630, #2635) already edit that file or this plan. +**Why.** On 2026-10-23 the bus was down for the installer route fix (#2633). Eight Codex threads were coordinated through `codex queue` and one handoff file, and the commander kept the map by hand-run scripts: a git overlap scan and a thread rotation reader. Every finding below came from those scripts or from Tristan noticing. Each finding is a fact Core can derive from git, its order records or turn records, so a commander that has to remember it is a defect. Tristan, 2026-10-23: "i can't wait to have all the infra together so i don't need to manually remember this shit". This phase starts after the virtual bus, multiple inheritance, harness and this plan's phase 9 are done. Its `PlanPhase` declaration in `plan_phases.py` is the first edit of its first order. It is not in this change, because three open PRs (#2622, #2627, #2635) already edit that file. **Changes.** Each line names the fact, its owner, and the 2026-10-23 incident it would have caught. - **One holder per file, refused at order time.** `CommsOrderTool` refuses an order whose scope's files overlap a file already held by another open order or by another open branch's diff. The refusal names both holders. Incident: two threads wrote the same route fix in `installer/backup.py` until Tristan reassigned it by hand. From 2da58560437dacaa71bd5700d9b540a0faa37f12 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 03:59:15 -0400 Subject: [PATCH 03/17] Checkpoint kind families and migrate their current consumers --- src/agent_comms/agent_backend.py | 35 ++++++++++--- src/agent_comms/channel_management.py | 65 +++++++++++++++++------- src/agent_comms/channels.py | 30 +++++------ src/agent_comms/delivery_policy.py | 4 +- src/agent_comms/messages.py | 39 +++++++++----- src/agent_comms/pi_commands.py | 3 +- src/agent_comms/plan_phases.py | 2 +- src/agent_comms/private_sidecar.py | 13 +++-- src/agent_comms/publisher.py | 6 +-- src/agent_comms/response_conversation.py | 6 +-- src/agent_comms/tools.py | 21 +++----- src/agent_comms/turn_input_source.py | 4 +- src/agent_comms/turn_inputs.py | 7 +-- src/agent_comms/wake.py | 37 +++++++------- tests/bus_record_fixture.py | 6 +-- tests/native_backend_fixture.py | 4 +- tests/test_agent_backend.py | 2 +- tests/test_backend.py | 4 +- tests/test_bus.py | 4 +- tests/test_channel_display.py | 8 ++- tests/test_channel_retirement.py | 19 ++----- tests/test_channels.py | 51 ++++++------------- tests/test_exporting.py | 8 +-- tests/test_field_codec.py | 15 ++---- tests/test_message_categories.py | 10 ++-- tests/test_s4_ownership.py | 9 +--- tests/test_tracked_native_retry.py | 6 +-- tests/test_turn_inbox.py | 4 +- tests/test_wake.py | 61 ++++++++++------------ tests/verify_connector_tools.py | 6 +-- 30 files changed, 239 insertions(+), 250 deletions(-) diff --git a/src/agent_comms/agent_backend.py b/src/agent_comms/agent_backend.py index 743f8a25de..23e5bf9407 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -21,7 +21,6 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass -from enum import Enum from types import MappingProxyType from typing import TYPE_CHECKING, Any, ClassVar, Protocol, Self @@ -119,12 +118,34 @@ def __post_init__(self) -> None: raise ValueError("An input ID must be a nonempty string") -class WhenBusy(Enum): +class BusyInputPolicy(DeclaredFamily, affix="InputPolicy"): """What the backend does with an input that arrives while a run is going.""" - FOLLOW_UP = "follow_up" # run it after the current run ends - STEER = "steer" # place it at the next boundary of the current run - REJECT = "reject" # refuse it + @classmethod + @abstractmethod + def streaming(cls) -> type[StreamingInputPolicy]: ... + + +class StreamingInputPolicy(BusyInputPolicy, declared_name=None): + """The busy policies Pi's streamingBehavior protocol accepts.""" + + @classmethod + def streaming(cls) -> type[StreamingInputPolicy]: + return cls + + +class FollowUpInputPolicy(StreamingInputPolicy, declared_name="followUp"): + """Run after the current run ends; preserve Pi's exact followUp spelling.""" + + +class SteerInputPolicy(StreamingInputPolicy): + """Place the input at the next boundary of the current run.""" + + +class RejectInputPolicy(BusyInputPolicy): + @classmethod + def streaming(cls) -> type[StreamingInputPolicy]: + raise ValueError("Pi cannot reject a prompt through streamingBehavior.") @dataclass(frozen=True, kw_only=True) @@ -198,7 +219,7 @@ class InputRequest(TurnInboxItem): input_id: InputId content: InputContent - when_busy: WhenBusy + when_busy: type[BusyInputPolicy] @property def carried_inputs(self) -> tuple[InputId, ...]: @@ -328,7 +349,7 @@ async def close_idle(self) -> None: class Steers(RunsTurns): - """Accepts :class:`InputRequest` with ``WhenBusy.STEER`` while a run is going.""" + """Accepts :class:`InputRequest` with ``SteerInputPolicy`` while a run is going.""" class Interrupts(Steers): diff --git a/src/agent_comms/channel_management.py b/src/agent_comms/channel_management.py index 3d61be57de..5c30819624 100644 --- a/src/agent_comms/channel_management.py +++ b/src/agent_comms/channel_management.py @@ -5,7 +5,6 @@ from abc import abstractmethod from collections.abc import Sequence from dataclasses import dataclass, replace -from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, ClassVar @@ -108,25 +107,53 @@ def deletion_cohort(self, channels, tag, cohort): for member in channels.registry.status(thread.name).deletable_threads(thread)) -class TagAction(Enum): - LIST = "list" - CREATE = "create" - RENAME = "rename" - DELETE = "delete" - - def apply( - self, channels: ChannelManagement, name: str = "", new_name: str = "", *, - disposition: TagDisposition = KeepThreadsTagDisposition(), confirmed: bool = False - ) -> frozenset[str]: - match self: - case self.CREATE: - channels.create_tag(name) - case self.RENAME: - channels.rename_tag(name, new_name) - case self.DELETE: - channels.delete_tag(name, disposition=disposition, confirmed=confirmed) +@dataclass(frozen=True, kw_only=True) +class TagEdit(DeclaredFamily, affix="TagEdit"): + """A tag operation carries only the arguments that operation uses.""" + + def apply(self, channels: ChannelManagement) -> frozenset[str]: + self.change(channels) return channels.catalog.read().tags + @abstractmethod + def change(self, channels: ChannelManagement) -> None: ... + + +class ListTagEdit(TagEdit): + label = "List tags" + def change(self, channels: ChannelManagement) -> None: + pass + + +@dataclass(frozen=True, kw_only=True) +class NamedTagEdit(TagEdit, declared_name=None): + name: str + + +class CreateTagEdit(NamedTagEdit): + label = "Create tag" + def change(self, channels: ChannelManagement) -> None: + channels.create_tag(self.name) + + +@dataclass(frozen=True, kw_only=True) +class RenameTagEdit(NamedTagEdit): + label: ClassVar[str] = "Rename tag" + new_name: str + + def change(self, channels: ChannelManagement) -> None: + channels.rename_tag(self.name, self.new_name) + + +@dataclass(frozen=True, kw_only=True) +class DeleteTagEdit(NamedTagEdit): + label: ClassVar[str] = "Delete tag" + disposition: TagDisposition = KeepThreadsTagDisposition() + confirmed: bool = False + + def change(self, channels: ChannelManagement) -> None: + channels.delete_tag(self.name, disposition=self.disposition, confirmed=self.confirmed) + class ChannelManagement: def __init__(self, root: Path, registry: Registration, bus: MessageBus, threads: ThreadManagement): @@ -181,7 +208,7 @@ def _update_tags(self, name: str, choice: TagChoice) -> Thread: Message( announcer.name, channel.name, - f"{announcer.name} {change.value} {channel.name}", + f"{announcer.name} {change.declared_name} {channel.name}", InfoKind, role=MembershipConversationRole(change), ), diff --git a/src/agent_comms/channels.py b/src/agent_comms/channels.py index b3a6f82cdd..935a9776e2 100644 --- a/src/agent_comms/channels.py +++ b/src/agent_comms/channels.py @@ -7,7 +7,6 @@ from collections import defaultdict from collections.abc import Iterable, Iterator from dataclasses import dataclass, field -from enum import StrEnum from typing import TYPE_CHECKING, ClassVar, Annotated @@ -18,7 +17,7 @@ from .errors import RelationViolationError from .field_codec import FieldCodec, CatalogExclude from .history_result import ChannelHistoryPageResult, EverythingHistoryPageResult, HistoryPageResult -from .messages import MembershipChange +from .messages import MembershipTransition from .read_basis import AllChannelDisplayScope, ChannelDisplayScope, MemberActivityDisplayScope from .tool_result import ToolResult @@ -128,7 +127,7 @@ def delete_view_bindings(self, command) -> tuple: return () @abstractmethod - def membership_changes(self, before: frozenset[str], after: frozenset[str]) -> tuple[MembershipChange, ...]: ... + def membership_changes(self, before: frozenset[str], after: frozenset[str]) -> tuple[type[MembershipTransition], ...]: ... def pending_count(self, counts: dict[str, int]) -> int: return sum(self.history_scope().target_values(counts)) @@ -190,11 +189,8 @@ class MembershipChannel: def read_only(self) -> bool: return False - def membership_changes(self, before: frozenset[str], after: frozenset[str]) -> tuple[MembershipChange, ...]: - previous, current = self.matches(before), self.matches(after) - if previous == current: - return () - return (MembershipChange.JOINED if current else MembershipChange.LEFT,) + def membership_changes(self, before: frozenset[str], after: frozenset[str]) -> tuple[type[MembershipTransition], ...]: + return MembershipTransition.between(self.matches(before), self.matches(after)) class BuiltinChannelView(PredicateChannel, MembershipChannel, Channel, declared_name=None): @@ -286,7 +282,7 @@ class ReadOnlyChannel(NonRoutableChannel): def read_only(self) -> bool: return True - def membership_changes(self, before: frozenset[str], after: frozenset[str]) -> tuple[MembershipChange, ...]: + def membership_changes(self, before: frozenset[str], after: frozenset[str]) -> tuple[type[MembershipTransition], ...]: return () @@ -360,11 +356,6 @@ def matches(self, tags: frozenset[str]) -> bool: return False -class ViewKind(StrEnum): - PARTICIPANTS = "participants" - ACTIVITY = "activity" - - class ViewMatch(DeclaredFamily, affix="Match"): """A tag relation, selected once at the external boundary.""" @@ -404,11 +395,10 @@ def matches(self, tags: frozenset[str]) -> bool: @dataclass(frozen=True, slots=True) -class SavedView(ToolResult): +class SavedView(DeclaredFamily, ToolResult, affix="View"): """A named non-routable projection over authoritative channels or activity.""" name: Annotated[str, CatalogExclude()] - kind: ViewKind predicate: ViewPredicate created_at: float = field(default_factory=time.time) original_targets: frozenset[str] = frozenset() @@ -422,3 +412,11 @@ def history_targets(self) -> frozenset[str]: def __post_init__(self) -> None: Tag.validate(self.name) + + +class ParticipantsView(SavedView): + """A saved participant projection over its declared tag predicate.""" + + +class ActivityView(SavedView): + """A saved activity projection over its declared tag predicate.""" diff --git a/src/agent_comms/delivery_policy.py b/src/agent_comms/delivery_policy.py index dc6ea373d8..f06afcda67 100644 --- a/src/agent_comms/delivery_policy.py +++ b/src/agent_comms/delivery_policy.py @@ -12,7 +12,7 @@ from agent_comms.response_acknowledgement import ( KeyedResponseAcknowledgement, NoResponseAcknowledgement, ResponseAcknowledgement, ) -from agent_comms.wake import ControlClassification, resolve_wake_cohort +from agent_comms.wake import MessageControl, resolve_wake_cohort from agent_comms.wake_policy import MessageAudience, WakeDecision @@ -22,7 +22,7 @@ class DeliveryManifest: manifest_codec: str resolver_version: str policy_version: str - control: ControlClassification + control: type[MessageControl] audience: FrozenAudience # Exact existing canonical decision projection. Its digest is already bound # by immutable cohort evidences; changing this spelling would change proofs. diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index b7722c205d..bcf45c3c1f 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -10,7 +10,6 @@ from abc import abstractmethod from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass, field, fields, replace -from enum import StrEnum from pathlib import Path from typing import TYPE_CHECKING, Annotated, ClassVar, Self, Type, cast, dataclass_transform @@ -162,12 +161,30 @@ def require_publishable(cls, message: Message, worktree: Path) -> None: message.report.require_references_exist(worktree) -class MembershipChange(StrEnum): - JOINED = "joined" - LEFT = "left" +class MembershipTransition(DeclaredFamily, affix="Membership"): + @classmethod + def conversation_role(cls): + return MembershipConversationRole(cls) - def conversation_role(self): - return MembershipConversationRole(self) + @classmethod + def between(cls, previous: bool, current: bool) -> tuple[type[MembershipTransition], ...]: + return tuple(member for member in cls.members_with(cls) if member.applies(previous, current)) + + @staticmethod + @abstractmethod + def applies(previous: bool, current: bool) -> bool: ... + + +class JoinedMembership(MembershipTransition): + @staticmethod + def applies(previous: bool, current: bool) -> bool: + return not previous and current + + +class LeftMembership(MembershipTransition): + @staticmethod + def applies(previous: bool, current: bool) -> bool: + return previous and not current @dataclass(frozen=True, slots=True) @@ -181,7 +198,7 @@ class MessageRoleWire: """The unchanged external fields, decoded once into a message role.""" change: Annotated[ - MembershipChange | NoMembershipChange, + type[MembershipTransition] | NoMembershipChange, WireName("membership"), WireOmitDefault(), ] = field(default_factory=NoMembershipChange) @@ -275,9 +292,7 @@ def notification_references(self, message): return (message.reference,) if message.type.conversation_message else () def wake_mode(self, target, recipient, mentions, control): - if control.passive: - return PassiveWake() - return target.wake_policy(recipient, mentions) + return control.wake_mode(target, recipient, mentions) @dataclass(frozen=True, slots=True) @@ -303,13 +318,13 @@ def require_recipients(self, target, audience): @dataclass(frozen=True) class Membership(InformationalMessage): - change: MembershipChange + change: type[MembershipTransition] def wire_fields(self): return replace(super().wire_fields(), change=self.change) def export_attributes(self): - return (*super().export_attributes(), f"membership={self.change.value}") + return (*super().export_attributes(), f"membership={self.change.declared_name}") def as_notice(self): return MembershipNoticeRole(self.change) diff --git a/src/agent_comms/pi_commands.py b/src/agent_comms/pi_commands.py index 867b3f2d0b..edce90680a 100644 --- a/src/agent_comms/pi_commands.py +++ b/src/agent_comms/pi_commands.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal from uuid import uuid4 +from .agent_backend import StreamingInputPolicy from . import agent_events as events from . import turn_phase as phases from .declared_family import DeclaredFamily, UnknownFamilyMember @@ -183,7 +184,7 @@ class Prompt(PiCommand): ] = () owner_tools: Annotated[OwnerToolConnection, WireName("ownerTools")] streaming_behavior: Annotated[ - Literal[None, "steer", "followUp"], + type[StreamingInputPolicy] | None, WireOmitDefault(), WireName("streamingBehavior"), ] = None diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index 24fa581c95..594d66bdbd 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -651,7 +651,7 @@ class KindFamiliesPhase(RemainingSlopPhase): """6.3: kinds are families whose members carry their behaviour and spelling.""" evidence = PatternAbsent( - r"class (TagAction|ExecutionOrigin|WhenBusy|ControlClassification|MembershipChange" + r"class (TagAction|ExecutionOrigin|WhenBusy|MessageControl|MembershipTransition" r"|ViewKind)\b" ) diff --git a/src/agent_comms/private_sidecar.py b/src/agent_comms/private_sidecar.py index 5c7c195f60..0d24f362aa 100644 --- a/src/agent_comms/private_sidecar.py +++ b/src/agent_comms/private_sidecar.py @@ -25,11 +25,16 @@ from dataclasses import dataclass from functools import partial from pathlib import Path -from typing import Annotated, Literal +from typing import Annotated, Literal, TYPE_CHECKING from agent_comms.locked_store import SQLiteFile from agent_comms.coordination_errors import IdentityConflict +from .field_codec import FieldCodec + +if TYPE_CHECKING: + from .agent_backend import StreamingInputPolicy + from .private_path import ObservedPath from .store_files import StoreOwner from .typed_table import ( @@ -365,14 +370,14 @@ def sidecar_connection( connection.close() -def encode_request(text: str, *, streaming_behavior: str | None = None) -> str: +def encode_request(text: str, *, streaming_behavior: type[StreamingInputPolicy] | None = None) -> str: """JSON-encode like the pinned native ``_claimNativeInput`` request.""" return json.dumps( { "kind": "prompt", "text": text, "images": None, - "streamingBehavior": streaming_behavior, + "streamingBehavior": FieldCodec.encode(streaming_behavior), "expandPromptTemplates": True, "source": "rpc", }, @@ -381,7 +386,7 @@ def encode_request(text: str, *, streaming_behavior: str | None = None) -> str: ) -def native_request_digest(text: str, *, streaming_behavior: str | None = None) -> str: +def native_request_digest(text: str, *, streaming_behavior: type[StreamingInputPolicy] | None = None) -> str: """sha256 over the pinned native request envelope, not the bare text.""" return hashlib.sha256( ("pi-input-request-v1\n" + encode_request(text, streaming_behavior=streaming_behavior)).encode("utf-8") diff --git a/src/agent_comms/publisher.py b/src/agent_comms/publisher.py index 2f767e8c47..3fa5b22cc6 100644 --- a/src/agent_comms/publisher.py +++ b/src/agent_comms/publisher.py @@ -43,7 +43,7 @@ from .mentions import ThreadMention from .private_path import FileRevision from .registry_document import DEFAULT_ROSTER, RegistrySnapshot -from .wake import ControlClassification, resolve_wake_cohort +from .wake import MessageControl, resolve_wake_cohort, OrdinaryControl if TYPE_CHECKING: from .coordination_session import NativeBoundary @@ -241,11 +241,11 @@ def build(seq: int, db: sqlite3.Connection) -> PrivateRecord: revision, sender_lookup=sender_lookup, sender_name=sender, ) decisions = resolve_wake_cohort( - stored, frozen_audience=audience, control=ControlClassification.ORDINARY, + stored, frozen_audience=audience, control=OrdinaryControl, ) return PrivateRecord.from_message( stored, audience=audience, decisions=decisions, - control=ControlClassification.ORDINARY.value, + control=OrdinaryControl.declared_name, ) return origin.append(self.bus, build) diff --git a/src/agent_comms/response_conversation.py b/src/agent_comms/response_conversation.py index d68bff572d..9211725174 100644 --- a/src/agent_comms/response_conversation.py +++ b/src/agent_comms/response_conversation.py @@ -103,16 +103,16 @@ def audience(self, message: Message): def record(self, seq: int, message: Message, intent) -> KeyedPrivateRecord: from agent_comms.bus import KeyedPrivateRecord from agent_comms.delivery_policy import ResponseDeliveryPolicy - from agent_comms.wake import ControlClassification + from agent_comms.wake import MessageControl, OrdinaryControl stored = replace(message, seq=seq) audience = self.audience(stored) decisions = ResponseDeliveryPolicy.resolve( - stored, audience, ControlClassification.ORDINARY, + stored, audience, OrdinaryControl, ) return KeyedPrivateRecord.from_message( stored, audience=audience, decisions=decisions, - control=ControlClassification.ORDINARY.value, + control=OrdinaryControl.declared_name, acknowledgement=KeyedResponseAcknowledgement( self.privacy.namespace, intent.execution_id, intent.publication_key, public_envelope_digest(stored.to_wire()), diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 7f055133eb..48ad07477f 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -37,9 +37,9 @@ from . import cli as comms_cli from .change_report import NoChangeReport, ReportEvidence -from .channel_management import KeepThreadsTagDisposition, TagAction, TagDisposition +from .channel_management import KeepThreadsTagDisposition, ListTagEdit, TagDisposition, TagEdit from agent_comms.channel_targets import ChannelTarget, CommandTarget, NamedTarget -from .channels import AnyChannel, Channel, SavedView, ViewKind, ViewMatch, ViewPredicate +from .channels import AnyChannel, Channel, SavedView, ViewMatch, ViewPredicate from .cli_commands import ( ArchiveCliCommand, CliCommand, @@ -629,19 +629,12 @@ class CommsTagsTool(ToolRequest): "List, create, rename, or delete tags. Renaming/deleting updates thread " "assignments and channel filters. Uncovered tags automatically have a channel." ) - action: Annotated[TagAction, *tool_field("Operation")] = TagAction.LIST - name: Annotated[str, *tool_field("Tag name")] = "" - new_name: Annotated[str, *tool_field("Replacement tag for rename")] = "" - disposition: Annotated[TagDisposition, *tool_field("Tagged-thread operation")] = ( - dataclasses.field(default_factory=KeepThreadsTagDisposition) - ) - confirmed: Annotated[bool, *tool_field("Confirm thread archiving or deletion")] = ( - False + edit: Annotated[TagEdit, *tool_field("Tag operation and its arguments")] = ( + dataclasses.field(default_factory=ListTagEdit) ) def apply(self, comms: Comms, caller: ToolCaller) -> TagList: - tags = self.action.apply(comms.channels, self.name, self.new_name, - disposition=self.disposition, confirmed=self.confirmed) + tags = self.edit.apply(comms.channels) return TagList(tuple(sorted(tags))) @@ -703,13 +696,13 @@ class CommsSetViewTool(ToolRequest): " messages and never own channel history." ) name: Annotated[str, *tool_field("View name without #")] - kind: Annotated[ViewKind, *tool_field("Projection kind")] + kind: Annotated[type[SavedView], *tool_field("Projection kind")] match: Annotated[type[ViewMatch], *tool_field("Tag predicate")] tags: Annotated[str, *tool_field("One or more comma-separated tags")] def apply(self, comms: Comms, caller: ToolCaller) -> SavedView: return comms.channels.set_saved_view( - SavedView(self.name, self.kind, ViewPredicate(self.match, _tag_set(self.tags)))) + self.kind(self.name, ViewPredicate(self.match, _tag_set(self.tags)))) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/turn_input_source.py b/src/agent_comms/turn_input_source.py index 6daeb31c86..f92e6b0574 100644 --- a/src/agent_comms/turn_input_source.py +++ b/src/agent_comms/turn_input_source.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, replace from typing import TYPE_CHECKING, ClassVar -from .agent_backend import WhenBusy +from .agent_backend import BusyInputPolicy, SteerInputPolicy from .channel_input_batch import InputBatch, SingleInputBatch from .messages import Message from .started_input import StartedInput @@ -209,7 +209,7 @@ class ScheduledOriginalInput(DependencyOriginalInput): @dataclass(frozen=True, kw_only=True) class FollowingTurnInput(TurnInputSource): keys: tuple[str, ...] - when_busy: ClassVar[WhenBusy] = WhenBusy.STEER + when_busy: ClassVar[type[BusyInputPolicy]] = SteerInputPolicy def continue_response(self, response, parts): """An actual following request may revise the turn's automatic answer.""" diff --git a/src/agent_comms/turn_inputs.py b/src/agent_comms/turn_inputs.py index 31c32485c7..8b4f7076de 100644 --- a/src/agent_comms/turn_inputs.py +++ b/src/agent_comms/turn_inputs.py @@ -22,7 +22,6 @@ TurnInbox, TurnInboxItem, UseSettings, - WhenBusy, ) from .field_codec import Id128Text from .locked_store import RegistryFile @@ -33,10 +32,6 @@ from .pi_events import MessageStart, Response -# Pi's spelling of each busy policy it supports; Pi has no way to refuse an input. -_PI_STREAMING_BEHAVIOR = {WhenBusy.STEER: "steer", WhenBusy.FOLLOW_UP: "followUp"} - - @dataclass class InputForwarding: queue: TurnInbox = field(default_factory=TurnInbox) @@ -167,7 +162,7 @@ async def place(self, session: TurnSession, item: InputRequest) -> bool: images=item.content.images, context_contributions=item.content.contributions, owner_tools=session.tool_socket.listener.connection, - streaming_behavior=_PI_STREAMING_BEHAVIOR[item.when_busy], + streaming_behavior=item.when_busy.streaming(), ) def write(): if command.images: diff --git a/src/agent_comms/wake.py b/src/agent_comms/wake.py index 4c639ec01d..e72397589d 100644 --- a/src/agent_comms/wake.py +++ b/src/agent_comms/wake.py @@ -7,28 +7,29 @@ from __future__ import annotations -from enum import StrEnum - - from .audience_manifest import FrozenAudience, FrozenRecipient, freeze_audience from .channel_targets import CommandTarget, NamedTarget +from .declared_family import DeclaredFamily from .messages import Message -from .wake_policy import NoWakeDecision, RecipientDecision +from .wake_policy import NoWakeDecision, PassiveWake, RecipientDecision -class ControlClassification(StrEnum): +class MessageControl(DeclaredFamily, affix="Control"): """Explicit system-control classification; not inferred from text or ACK type.""" - ORDINARY = "ordinary" - SYSTEM_CONTROL = "system_control" + @classmethod + def wake_mode(cls, target, recipient, mentions): + return target.wake_policy(recipient, mentions) + + +class OrdinaryControl(MessageControl): + pass - @property - def passive(self) -> bool: - return self is self.SYSTEM_CONTROL - @property - def supports_initial(self) -> bool: - return self is self.ORDINARY +class SystemControl(MessageControl, declared_name="system_control"): + @classmethod + def wake_mode(cls, target, recipient, mentions): + return PassiveWake() def _require_stored(message: Message) -> None: @@ -37,12 +38,10 @@ def _require_stored(message: Message) -> None: def _validated_context( - message: Message, frozen_audience: FrozenAudience, control: ControlClassification + message: Message, frozen_audience: FrozenAudience, control: type[MessageControl] ) -> tuple[NamedTarget, frozenset[str]]: """Check the full envelope exactly once before interpreting recipient rows.""" _require_stored(message) - if type(control) is not ControlClassification: - raise TypeError("control must be an explicit ControlClassification.") if type(frozen_audience) is not FrozenAudience: raise TypeError("wake requires a FrozenAudience and FrozenRecipient.") if ( @@ -70,7 +69,7 @@ def _validated_context( def _member_decision( message: Message, recipient: FrozenRecipient, - control: ControlClassification, + control: type[MessageControl], target: NamedTarget, effective_mentions: frozenset[str], ) -> RecipientDecision: @@ -85,7 +84,7 @@ def resolve_wake( *, recipient: FrozenRecipient, frozen_audience: FrozenAudience, - control: ControlClassification, + control: type[MessageControl], ) -> RecipientDecision | None: """Resolve one member; prefer resolve_wake_cohort for the full frozen N. @@ -101,7 +100,7 @@ def resolve_wake( def resolve_wake_cohort( - message: Message, *, frozen_audience: FrozenAudience, control: ControlClassification + message: Message, *, frozen_audience: FrozenAudience, control: type[MessageControl] ) -> tuple[RecipientDecision, ...]: """Resolve ordered full N with one pure full-envelope check and no live lookup. diff --git a/tests/bus_record_fixture.py b/tests/bus_record_fixture.py index adf03269ff..9c9c7f006b 100644 --- a/tests/bus_record_fixture.py +++ b/tests/bus_record_fixture.py @@ -14,7 +14,7 @@ from agent_comms.response_acknowledgement import ( KeyedResponseAcknowledgement, canonical_publication_key, public_envelope_digest, ) -from agent_comms.wake import ControlClassification, resolve_wake_cohort +from agent_comms.wake import MessageControl, resolve_wake_cohort, OrdinaryControl publication_processes = ForkServerContext() @@ -58,9 +58,9 @@ def record_from(declaration, message, *, sender_lookup="a" * 32, recipient_looku capabilities.update( audience=audience, decisions=resolve_wake_cohort( - message, frozen_audience=audience, control=ControlClassification.ORDINARY + message, frozen_audience=audience, control=OrdinaryControl ), - control=ControlClassification.ORDINARY.value, + control=OrdinaryControl.declared_name, ) if issubclass(declaration, KeyedPublication): capabilities["acknowledgement"] = KeyedResponseAcknowledgement( diff --git a/tests/native_backend_fixture.py b/tests/native_backend_fixture.py index d796e10452..3737fc4496 100644 --- a/tests/native_backend_fixture.py +++ b/tests/native_backend_fixture.py @@ -14,7 +14,7 @@ import agent_comms.agent_events as events import agent_comms.backend as backend -from agent_comms.agent_backend import InputContent, InputId, InputRequest, TurnInbox, WhenBusy +from agent_comms.agent_backend import InputContent, InputId, InputRequest, TurnInbox, BusyInputPolicy, SteerInputPolicy from agent_comms.comms import Comms from agent_comms.fresh_private_session import create_fresh_private_session from agent_comms.child_process import BoundedRun, join_retirement @@ -186,7 +186,7 @@ async def run(self, text, *, followup=None, queue=None, **options): queue.put_nowait(InputRequest( input_id=InputId("queued"), content=InputContent(text=followup), - when_busy=WhenBusy.STEER, + when_busy=SteerInputPolicy, )) followup = None return result diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index 2f32efdff2..bdc0671b49 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -312,7 +312,7 @@ def _session_lines(path: Path) -> None: def test_inbox_records_say_which_inputs_they_carry(): request = ab.InputRequest( - input_id=ab.InputId("q1"), content=ab.InputContent(text="t"), when_busy=ab.WhenBusy.STEER, + input_id=ab.InputId("q1"), content=ab.InputContent(text="t"), when_busy=ab.SteerInputPolicy, ) assert request.carried_inputs == (ab.InputId("q1"),) assert ab.SendNow((ab.InputId("q1"),)).carried_inputs == () diff --git a/tests/test_backend.py b/tests/test_backend.py index c295094e8f..515905c4e8 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -10,7 +10,7 @@ import agent_comms.agent_events as ae import agent_comms.backend as backend -from agent_comms.agent_backend import InputContent, InputId, InputRequest, WhenBusy +from agent_comms.agent_backend import InputContent, InputId, InputRequest, BusyInputPolicy, SteerInputPolicy from agent_comms.native_pi import CAPABILITY from agent_comms.pi_rpc import PiRpcChannel from agent_comms.turn_phase import ( @@ -27,7 +27,7 @@ def steer(text: str, input_id: str | None = None) -> InputRequest: return InputRequest( input_id=InputId(input_id or f"agent-comms-steer-{uuid4().hex}"), content=InputContent(text=text), - when_busy=WhenBusy.STEER, + when_busy=SteerInputPolicy, ) diff --git a/tests/test_bus.py b/tests/test_bus.py index db63dc0118..6f4333ba10 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -33,7 +33,7 @@ Claimed, ClaimHistoryKind, InfoKind, - MembershipChange, + MembershipTransition, Message, MessageRoleWire, NoticeRole, @@ -132,7 +132,7 @@ def test_projection_freshness_follows_the_installed_core_commit(bus): "role", tuple( MessageRoleWire(change=change, notice=notice).role() - for change in (MessageRoleWire().change, *MembershipChange) + for change in (MessageRoleWire().change, *MembershipTransition.members_with(MembershipTransition)) for notice in (False, True) ), ) diff --git a/tests/test_channel_display.py b/tests/test_channel_display.py index b3ba2ed55b..7a0987364a 100644 --- a/tests/test_channel_display.py +++ b/tests/test_channel_display.py @@ -8,7 +8,7 @@ import pytest from agent_comms.channel_targets import CommandTarget -from agent_comms.channels import AnyOfMatch, SavedView, ViewKind, ViewPredicate +from agent_comms.channels import AnyOfMatch, ViewPredicate, ActivityView from agent_comms.comms import wire from agent_comms.coordination_errors import DMPaintedBoundChanged, DMScopeChanged from agent_comms.message_page import PageBounds @@ -86,12 +86,10 @@ def test_default_off_and_opt_in_only_changes_display(tmp_path): def test_mode_rejects_builtins_union_view_and_unknown_without_sidecar_mutation(tmp_path): comms = populated(tmp_path) comms.channels.set_saved_view( - SavedView( - "engineering", ViewKind.ACTIVITY, ViewPredicate(AnyOfMatch, frozenset({"api", "ui"})) - ) + ActivityView('engineering', ViewPredicate(AnyOfMatch, frozenset({'api', 'ui'}))) ) comms.channels.set_saved_view( - SavedView("saved", ViewKind.ACTIVITY, ViewPredicate(AnyOfMatch, frozenset({"api"}))) + ActivityView('saved', ViewPredicate(AnyOfMatch, frozenset({'api'}))) ) metadata = comms.channels.catalog.path before = metadata.read_bytes() if metadata.exists() else None diff --git a/tests/test_channel_retirement.py b/tests/test_channel_retirement.py index a285dffcef..39b4c2fb59 100644 --- a/tests/test_channel_retirement.py +++ b/tests/test_channel_retirement.py @@ -7,7 +7,7 @@ from agent_comms.bus import Bus, PublicRecord from agent_comms.catalog_store import ChannelCatalog -from agent_comms.channels import AnyOfMatch, SavedView, ViewKind, ViewPredicate +from agent_comms.channels import AnyOfMatch, ViewPredicate, ActivityView, ParticipantsView from agent_comms.comms import wire from agent_comms.field_codec import FieldCodec from agent_comms.message_page import PageBounds @@ -35,13 +35,7 @@ def saved_catalog(root): "#ui": ChannelPreferences(created_at=17), }, saved_views={ - "engineering": SavedView( - "engineering", - ViewKind.ACTIVITY, - ViewPredicate(AnyOfMatch, frozenset({"api", "ui"})), - 17, - frozenset({"#engineering"}), - ) + "engineering": ActivityView('engineering', ViewPredicate(AnyOfMatch, frozenset({'api', 'ui'})), 17, frozenset({'#engineering'})) }, list_order=LastUserInputChannelSort, ) @@ -111,7 +105,7 @@ def test_saved_projection_preserves_preferences_history_and_has_no_writable_alia def test_private_initial_rejects_view_but_routes_exact_tags_and_membership(tmp_path): comms, _store, _identity, _lookups = _root(tmp_path) comms.channels.set_saved_view( - SavedView("team-view", ViewKind.ACTIVITY, ViewPredicate(AnyOfMatch, frozenset({"team"}))) + ActivityView('team-view', ViewPredicate(AnyOfMatch, frozenset({'team'}))) ) before = comms.bus.bus.revision() with pytest.raises(ValueError): @@ -133,12 +127,7 @@ def test_restoring_same_named_view_keeps_current_predicate_and_original_target_h Bus.install(Bus.path_in(tmp_path / "destination")) comms = wire(tmp_path / "destination") comms.channels.create_tag("api") - current = SavedView( - "engineering", - ViewKind.PARTICIPANTS, - ViewPredicate(AnyOfMatch, frozenset({"api"})), - created_at=19, - ) + current = ParticipantsView('engineering', ViewPredicate(AnyOfMatch, frozenset({'api'})), created_at=19) comms.channels.set_saved_view(current) with comms.bus.opened_activity() as activity, comms.channels.catalog.editing() as document: document.restore_missing(source, activity, existing=True) diff --git a/tests/test_channels.py b/tests/test_channels.py index 5a441b5b99..e312a0f276 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -7,7 +7,7 @@ from agent_comms.tools import invoke_tool from agent_comms.tool_caller import ThreadCaller, NoThreadCaller -from agent_comms.channels import AllOfMatch, AnyOfMatch, BuiltinChannelView, ChannelMemberIndex, RetiredTagChannel, TagChannel, SavedView, ViewKind, ViewPredicate +from agent_comms.channels import AllOfMatch, AnyOfMatch, BuiltinChannelView, ChannelMemberIndex, RetiredTagChannel, TagChannel, ViewPredicate, ParticipantsView from agent_comms.channel_targets import CommandTarget, CommsTag, DeclaredTag from agent_comms.child_process import NoProcess, ProcessIdentity from agent_comms.channel_management import ( @@ -236,7 +236,9 @@ async def test_new_tag_disposition_is_declared_in_original_editor_and_tool_catal assert edited.bound[0].disposition == disposition assert 'only inactive' in edited.confirmation tag_tool = next(tool for tool in NativeRequestTools.catalog() if tool['name'] == CommsTagsTool.declared_name) - schema = tag_tool['parameters']['properties']['disposition'] + delete_edit = next(case for case in tag_tool['parameters']['properties']['edit']['oneOf'] + if case['properties']['kind']['const'] == 'delete') + schema = delete_edit['properties']['disposition'] assert schema['oneOf'] == FieldCodec.value_schema(TagDisposition)['oneOf'] assert any(member['properties'][TagDisposition.family_discriminator]['const'] == disposition.declared_name for member in schema['oneOf']) @@ -244,12 +246,7 @@ async def test_new_tag_disposition_is_declared_in_original_editor_and_tool_catal def test_saved_views_are_typed_persistent_and_non_routable(tmp_path): comms = setup_wire(tmp_path) - view = SavedView( - "api-and-ui", - ViewKind.PARTICIPANTS, - ViewPredicate(AllOfMatch, frozenset({"api", "ui"})), - created_at=123, - ) + view = ParticipantsView('api-and-ui', ViewPredicate(AllOfMatch, frozenset({'api', 'ui'})), created_at=123) assert comms.channels.set_saved_view(view) == view assert wire(tmp_path).channels.catalog.read().saved_views == {"api-and-ui": view} assert [ @@ -281,11 +278,7 @@ def test_tag_lifecycle_is_transactional_for_metadata_views_and_history(tmp_path) comms.channels.set_channel_metadata("api", parent="#ui", archived=True) comms.channels.set_channel_metadata("child", parent="#api", archived=False) comms.channels.set_saved_view( - SavedView( - "cross-team", - ViewKind.PARTICIPANTS, - ViewPredicate(AllOfMatch, frozenset({"api", "ui"})), - ) + ParticipantsView('cross-team', ViewPredicate(AllOfMatch, frozenset({'api', 'ui'}))) ) comms.messaging.send("other", "#api", "target-owned historical row").message.message_id @@ -320,11 +313,7 @@ def test_tag_lifecycle_is_transactional_for_metadata_views_and_history(tmp_path) assert observer.bus.require_channel("#child").parent is None observer.channels.set_saved_view( - SavedView( - "cross-team", - ViewKind.PARTICIPANTS, - ViewPredicate(AllOfMatch, frozenset({"docs"})), - ) + ParticipantsView('cross-team', ViewPredicate(AllOfMatch, frozenset({'docs'}))) ) restarted = wire(tmp_path) assert restarted.bus.require_channel("#child").parent is None @@ -345,14 +334,10 @@ def test_saved_view_names_are_reserved_for_tags_and_legacy_channels(tmp_path): comms = setup_wire(tmp_path) predicate = ViewPredicate(AnyOfMatch, frozenset({"api"})) with pytest.raises(ValueError): - comms.channels.set_saved_view(SavedView("api", ViewKind.PARTICIPANTS, predicate)) + comms.channels.set_saved_view(ParticipantsView('api', predicate)) comms.channels.set_saved_view( - SavedView( - "reserved", - ViewKind.PARTICIPANTS, - predicate, - ) + ParticipantsView('reserved', predicate) ) before = comms.registry.snapshot() with pytest.raises(ValueError): @@ -373,7 +358,7 @@ def test_all_implicit_tag_introduction_paths_honor_name_reservations(tmp_path): comms = setup_wire(tmp_path) predicate = ViewPredicate(AnyOfMatch, frozenset({"api"})) - comms.channels.set_saved_view(SavedView("reserved", ViewKind.PARTICIPANTS, predicate)) + comms.channels.set_saved_view(ParticipantsView('reserved', predicate)) before = comms.registry.snapshot() with pytest.raises(ValueError): @@ -441,7 +426,7 @@ def test_tag_operations_preserve_owner_and_update_all_views(tmp_path, monkeypatc comms = setup_wire(tmp_path) original = comms.registry.require("other") monkeypatch.setenv("AGENT_COMMS_THREAD", "other") - invoke_tool(comms, "comms_tags", {"action": "create", "name": "new"}, caller=NoThreadCaller()) + invoke_tool(comms, "comms_tags", {"edit": {"kind": "create", "name": "new"}}, caller=NoThreadCaller()) assert "#new" in comms.channels.channels() invoke_tool(comms, "comms_thread_tags", {"add": "new,api", "remove": "ui"}, caller=ThreadCaller("other")) assert comms.registry.require("other") == replace( @@ -452,13 +437,13 @@ def test_tag_operations_preserve_owner_and_update_all_views(tmp_path, monkeypatc "comms_set_view", {"name": "team", "kind": "participants", "match": "any_of", "tags": "api,ui"}, caller=NoThreadCaller(), ) - invoke_tool(comms, "comms_tags", {"action": "rename", "name": "api", "new_name": "backend"}, caller=NoThreadCaller()) + invoke_tool(comms, "comms_tags", {"edit": {"kind": "rename", "name": "api", "new_name": "backend"}}, caller=NoThreadCaller()) assert "backend" in comms.registry.require("a").tags assert wire(tmp_path).bus.require_channel("#team").tags == frozenset( {"backend", "ui"} ) invoke_tool(comms, "comms_delete_view", {"name": "team"}, caller=NoThreadCaller()) - invoke_tool(comms, "comms_tags", {"action": "delete", "name": "backend"}, caller=NoThreadCaller()) + invoke_tool(comms, "comms_tags", {"edit": {"kind": "delete", "name": "backend"}}, caller=NoThreadCaller()) assert not comms.registry.require("a").tags assert "#ui" in comms.channels.channels() assert "#team" not in comms.channels.channels() @@ -553,9 +538,7 @@ def test_channel_pins_persist_and_partition_existing_order(tmp_path, order): def test_member_pins_are_persistent_channel_scoped_and_preserve_sort(tmp_path, order): comms = setup_wire(tmp_path) comms.channels.set_saved_view( - SavedView( - "team", ViewKind.PARTICIPANTS, ViewPredicate(AnyOfMatch, frozenset({"api", "ui"})) - ) + ParticipantsView('team', ViewPredicate(AnyOfMatch, frozenset({'api', 'ui'}))) ) comms.channels.set_channel_sort("#team", order) original = {view.channel.name: view.members for view in comms.views.channel_views()} @@ -576,9 +559,7 @@ def test_member_pins_are_persistent_channel_scoped_and_preserve_sort(tmp_path, o def test_pin_lifecycle_tracks_identity_and_membership(tmp_path): comms = setup_wire(tmp_path) comms.channels.set_saved_view( - SavedView( - "team", ViewKind.PARTICIPANTS, ViewPredicate(AnyOfMatch, frozenset({"api", "ui"})) - ) + ParticipantsView('team', ViewPredicate(AnyOfMatch, frozenset({'api', 'ui'}))) ) comms.channels.set_channel_pinned("team", True) comms.channels.set_thread_pinned("team", "a", True) @@ -598,7 +579,7 @@ def test_pin_lifecycle_tracks_identity_and_membership(tmp_path): assert team.pinned_members == {"renamed"} and team.members[0] == "renamed" comms.channels.delete_saved_view("team") comms.channels.set_saved_view( - SavedView("team", ViewKind.PARTICIPANTS, ViewPredicate(AnyOfMatch, frozenset({"api"}))) + ParticipantsView('team', ViewPredicate(AnyOfMatch, frozenset({'api'}))) ) assert not observer.bus.require_channel("#team").pinned diff --git a/tests/test_exporting.py b/tests/test_exporting.py index 15eaa936e8..8153214b0e 100644 --- a/tests/test_exporting.py +++ b/tests/test_exporting.py @@ -7,7 +7,7 @@ import pytest -from agent_comms.channels import AnyOfMatch, SavedView, ViewKind, ViewPredicate +from agent_comms.channels import AnyOfMatch, ViewPredicate, ParticipantsView from agent_comms.cli import main from agent_comms.comms import Comms from agent_comms.errors import RelationViolationError @@ -325,11 +325,7 @@ def test_comms_export_uses_target_owned_channel_and_alias_aware_dm_scopes(tmp_pa assert dm_receipt.scope.participants == ("renamed", "bob") comms.channels.set_saved_view( - SavedView( - "projection", - ViewKind.PARTICIPANTS, - ViewPredicate(AnyOfMatch, frozenset({"team"})), - ) + ParticipantsView('projection', ViewPredicate(AnyOfMatch, frozenset({'team'}))) ) with pytest.raises(RelationViolationError): comms.views.export_wire( diff --git a/tests/test_field_codec.py b/tests/test_field_codec.py index afa0337fa6..9b3e98af9d 100644 --- a/tests/test_field_codec.py +++ b/tests/test_field_codec.py @@ -29,16 +29,7 @@ WireNonNull, WireTransient, ) -from agent_comms.messages import ( - ConversationRole, - InfoKind, - MembershipChange, - MembershipConversationRole, - MembershipNoticeRole, - Message, - MessageRole, - NoticeRole, -) +from agent_comms.messages import ConversationRole, InfoKind, MembershipTransition, MembershipConversationRole, MembershipNoticeRole, Message, MessageRole, NoticeRole, JoinedMembership, LeftMembership def test_transient_plan_excludes_fields_before_decoder_or_value_access(): @@ -840,8 +831,8 @@ def test_missing_and_null_membership_decode_to_the_same_role(wire, expected): [ (ConversationRole(), {}), (NoticeRole(), {"notice": True}), - (MembershipConversationRole(MembershipChange.JOINED), {"membership": "joined"}), - (MembershipNoticeRole(MembershipChange.LEFT), {"membership": "left", "notice": True}), + (MembershipConversationRole(JoinedMembership), {"membership": "joined"}), + (MembershipNoticeRole(LeftMembership), {"membership": "left", "notice": True}), ], ) def test_message_role_keeps_the_exact_external_envelope(role, external): diff --git a/tests/test_message_categories.py b/tests/test_message_categories.py index 502330c898..e5cf51f8d4 100644 --- a/tests/test_message_categories.py +++ b/tests/test_message_categories.py @@ -6,11 +6,7 @@ from agent_comms.bus_publication import Delivered from agent_comms.field_codec import FieldCodec -from agent_comms.messages import ( - Message, MessageRole, InfoKind, ConversationRole, NoticeRole, - HistoricalConversationRole, HistoricalNoticeRole, - MembershipConversationRole, MembershipNoticeRole, MembershipChange, -) +from agent_comms.messages import Message, MessageRole, InfoKind, ConversationRole, NoticeRole, HistoricalConversationRole, HistoricalNoticeRole, MembershipConversationRole, MembershipNoticeRole, MembershipTransition, JoinedMembership from agent_comms.thread_identity import AgentThreadRole, UserThreadRole from agent_comms.transcript_events import IncomingTranscript from agent_comms.transcript_sources import AssignedTranscriptSource @@ -26,8 +22,8 @@ (NoticeRole(), OtherCategory), (HistoricalConversationRole(), InboundCategory), (HistoricalNoticeRole(), OtherCategory), - (MembershipConversationRole(MembershipChange.JOINED), InboundCategory), - (MembershipNoticeRole(MembershipChange.JOINED), OtherCategory), + (MembershipConversationRole(JoinedMembership), InboundCategory), + (MembershipNoticeRole(JoinedMembership), OtherCategory), ]) @pytest.mark.parametrize("sender_role", [AgentThreadRole, UserThreadRole]) def test_role_owns_wire_filter_category(role, category, sender_role): diff --git a/tests/test_s4_ownership.py b/tests/test_s4_ownership.py index bb5c831a1b..7bf0084691 100644 --- a/tests/test_s4_ownership.py +++ b/tests/test_s4_ownership.py @@ -2,18 +2,13 @@ -from agent_comms.channels import AnyOfMatch, SavedView, ViewKind, ViewPredicate +from agent_comms.channels import AnyOfMatch, ViewPredicate, ActivityView from agent_comms.field_codec import FieldCodec from agent_comms.messages import Message, InfoKind def test_wire_formats_derive_field_names_optional_values_and_sorted_tags(): - view = SavedView( - "work", - ViewKind.ACTIVITY, - ViewPredicate(AnyOfMatch, frozenset({"z", "a"})), - created_at=7.0, - ) + view = ActivityView('work', ViewPredicate(AnyOfMatch, frozenset({'z', 'a'})), created_at=7.0) expected = { "name": "work", "kind": "activity", diff --git a/tests/test_tracked_native_retry.py b/tests/test_tracked_native_retry.py index 2891a5a390..0b0cae2f36 100644 --- a/tests/test_tracked_native_retry.py +++ b/tests/test_tracked_native_retry.py @@ -3,7 +3,7 @@ from agent_comms.native_arguments import SelectedModel from agent_comms.native_startup import NativeStartupPolicy -from agent_comms.agent_backend import WhenBusy +from agent_comms.agent_backend import BusyInputPolicy, SteerInputPolicy from agent_comms.pi_events import NativeRetryEnd from agent_comms.pi_payloads import FailedProviderRetry, PiMessage, SucceededProviderRetry from agent_comms.tracked_turn import CompletedTrackedTerminal, PendingTrackedTerminal, TrackedTurnSession @@ -96,8 +96,8 @@ def following(kind): def test_delivered_notice_preserves_original_but_real_steering_can_revise(text): notice = following(DeliveredFollowingInput) steering = following(AcceptedFollowingInput) - assert notice.when_busy is WhenBusy.STEER - assert steering.when_busy is WhenBusy.STEER + assert notice.when_busy is SteerInputPolicy + assert steering.when_busy is SteerInputPolicy response = notice.continue_response(CompletedTrackedTerminal("Requested answer"), ["Requested answer"]) response = response.tool_round().append(text) assert response.require_response([text]) == "Requested answer" diff --git a/tests/test_turn_inbox.py b/tests/test_turn_inbox.py index 7fb0842675..57f71e9241 100644 --- a/tests/test_turn_inbox.py +++ b/tests/test_turn_inbox.py @@ -2,7 +2,7 @@ -from agent_comms.agent_backend import InputContent, InputId, InputRequest, TurnInbox, WhenBusy +from agent_comms.agent_backend import InputContent, InputId, InputRequest, TurnInbox, BusyInputPolicy, FollowUpInputPolicy from agent_comms.turn_inputs import InputForwarding @@ -10,7 +10,7 @@ def test_inbox_owns_ready_work_and_retains_parked_input(): inbox = TurnInbox() inputs = InputForwarding(inbox) item = InputRequest(input_id=InputId("parked"), content=InputContent(text="later"), - when_busy=WhenBusy.FOLLOW_UP) + when_busy=FollowUpInputPolicy) assert not inbox.has_ready_work and inputs.settled inbox.put_nowait(item) assert inbox.has_ready_work and inputs.unresolved diff --git a/tests/test_wake.py b/tests/test_wake.py index 757a005dd4..1cdbc85462 100644 --- a/tests/test_wake.py +++ b/tests/test_wake.py @@ -10,18 +10,11 @@ from conftest import installed_comms from agent_comms.wake_policy import MessageAudience from agent_comms.mentions import ThreadMention -from agent_comms.messages import ( - AckKind, HandoffKind, InfoKind, MembershipChange, MembershipConversationRole, Message, MessageKind, - NoticeRole, -) +from agent_comms.messages import AckKind, HandoffKind, InfoKind, MembershipTransition, MembershipConversationRole, Message, MessageKind, NoticeRole, JoinedMembership from agent_comms.obligation_states import SilentResponse from agent_comms.response_policy import CollectivePolicy, InformationalPolicy, MentionedOnlyPolicy from agent_comms.thread_identity import AgentThreadRole, ThreadRole, UserThreadRole -from agent_comms.wake import ( - ControlClassification, - resolve_wake, - resolve_wake_cohort, -) +from agent_comms.wake import MessageControl, resolve_wake, resolve_wake_cohort, OrdinaryControl, SystemControl from agent_comms.wake_policy import BoundedTriageWake, FullWake, PassiveWake, NoWakeDecision, NoWakeReason, RecipientDecision, WakeDecision MEMBERS = (FrozenRecipient("lookup-alpha", "alpha"), FrozenRecipient("lookup-beta", "beta")) @@ -56,7 +49,7 @@ def decide( message: Message, lookup: str = "lookup-alpha", members: tuple[FrozenRecipient, ...] = MEMBERS, - control: ControlClassification = ControlClassification.ORDINARY, + control: MessageControl = OrdinaryControl, ) -> RecipientDecision | None: recipient = next( (member for member in members if member.recipient_lookup == lookup), @@ -159,10 +152,10 @@ def test_mixed_in_and_out_of_scope_mentions_only_name_valid_frozen_member() -> N assert MEMBERS[0] in audience(message).recipients -@pytest.mark.parametrize("control", list(ControlClassification)) -@pytest.mark.parametrize("role", [NoticeRole(), MembershipConversationRole(MembershipChange.JOINED)]) +@pytest.mark.parametrize("control", MessageControl.members_with(MessageControl)) +@pytest.mark.parametrize("role", [NoticeRole(), MembershipConversationRole(JoinedMembership)]) def test_notice_and_membership_are_passive_regardless_of_control_or_sender( - role, control: ControlClassification + role, control: MessageControl ) -> None: message = replace(stored(sender_role=AgentThreadRole), role=role) assert decide(message, control=control) == WakeDecision( @@ -174,7 +167,7 @@ def test_explicit_control_on_mentioned_row_is_passive_not_unmentioned_observer() message = stored("#team", "@beta investigate", mentions=(ThreadMention("beta", 0, 5),)) for member in MEMBERS: assert decide( - message, member.recipient_lookup, control=ControlClassification.SYSTEM_CONTROL + message, member.recipient_lookup, control=SystemControl ) == WakeDecision(member.recipient_lookup, MessageAudience.MENTIONED, PassiveWake()) @@ -183,20 +176,18 @@ def test_system_control_must_be_explicit_not_guessed_from_body_or_ack_type() -> assert decide(ordinary) == WakeDecision( "lookup-alpha", MessageAudience.COLLECTIVE, BoundedTriageWake() ) - assert decide(ordinary, control=ControlClassification.SYSTEM_CONTROL) == WakeDecision( + assert decide(ordinary, control=SystemControl) == WakeDecision( "lookup-alpha", MessageAudience.COLLECTIVE, PassiveWake() ) direct = stored("alpha", sender_role=UserThreadRole) - assert decide(direct, members=MEMBERS[:1], control=ControlClassification.SYSTEM_CONTROL) == ( + assert decide(direct, members=MEMBERS[:1], control=SystemControl) == ( WakeDecision("lookup-alpha", MessageAudience.DIRECT, PassiveWake()) ) - with pytest.raises(TypeError): - resolve_wake( - ordinary, - recipient=MEMBERS[0], - frozen_audience=audience(ordinary), - control="system_control", # type: ignore[arg-type] - ) + from agent_comms.field_codec import FieldCodec, MalformedRecord + + assert FieldCodec.decode(type[MessageControl], "system_control") is SystemControl + with pytest.raises(MalformedRecord): + FieldCodec.decode(type[MessageControl], "invented_control") def test_no_wake_is_not_a_passive_claim() -> None: @@ -223,7 +214,7 @@ def test_excluded_recipient_and_bad_identity_fail_closed() -> None: message, recipient=FrozenRecipient("lookup-alpha", "someone_else"), frozen_audience=manifest, - control=ControlClassification.ORDINARY, + control=OrdinaryControl, ) is None ) # Lookup alone cannot steal another frozen name's decision. @@ -232,14 +223,14 @@ def test_excluded_recipient_and_bad_identity_fail_closed() -> None: message, recipient="alpha", # type: ignore[arg-type] frozen_audience=manifest, - control=ControlClassification.ORDINARY, + control=OrdinaryControl, ) with pytest.raises(TypeError): resolve_wake( message, recipient=MEMBERS[0], frozen_audience=object(), # type: ignore[arg-type] - control=ControlClassification.ORDINARY, + control=OrdinaryControl, ) with pytest.raises(ValueError): decide(stored("alpha"), members=MEMBERS) @@ -250,7 +241,7 @@ def test_excluded_recipient_and_bad_identity_fail_closed() -> None: replace(message, seq=0), recipient=MEMBERS[0], frozen_audience=manifest, - control=ControlClassification.ORDINARY, + control=OrdinaryControl, ) @@ -271,14 +262,14 @@ def test_prepared_message_must_match_frozen_manifest_identity(field: str) -> Non mismatched, recipient=MEMBERS[0], frozen_audience=manifest, - control=ControlClassification.ORDINARY, + control=OrdinaryControl, ) @pytest.mark.parametrize("changes", [ {"mentions": ()}, pytest.param({"role": NoticeRole()}, id="notice"), - pytest.param({"role": MembershipConversationRole(MembershipChange.JOINED)}, id="membership"), + pytest.param({"role": MembershipConversationRole(JoinedMembership)}, id="membership"), {"type": HandoffKind}, {"sender_role": UserThreadRole}, ]) @@ -293,7 +284,7 @@ def test_same_message_id_does_not_override_frozen_full_envelope(changes) -> None changed, recipient=MEMBERS[0], frozen_audience=manifest, - control=ControlClassification.ORDINARY, + control=OrdinaryControl, ) @@ -302,13 +293,13 @@ def test_full_cohort_preserves_direct_control_and_fail_closed_envelope() -> None assert resolve_wake_cohort( direct, frozen_audience=audience(direct, MEMBERS[:1]), - control=ControlClassification.ORDINARY, + control=OrdinaryControl, ) == (WakeDecision("lookup-alpha", MessageAudience.DIRECT, FullWake()),) collective = stored() assert resolve_wake_cohort( collective, frozen_audience=audience(collective), - control=ControlClassification.SYSTEM_CONTROL, + control=SystemControl, ) == tuple( WakeDecision(member.recipient_lookup, MessageAudience.COLLECTIVE, PassiveWake()) for member in MEMBERS @@ -317,13 +308,13 @@ def test_full_cohort_preserves_direct_control_and_fail_closed_envelope() -> None resolve_wake_cohort( direct, frozen_audience=audience(direct, (MEMBERS[1],)), - control=ControlClassification.ORDINARY, + control=OrdinaryControl, ) with pytest.raises(ValueError): resolve_wake_cohort( replace(collective, role=NoticeRole()), frozen_audience=audience(collective), - control=ControlClassification.ORDINARY, + control=OrdinaryControl, ) @@ -365,7 +356,7 @@ def test_pure_shadow_does_not_mutate_envelope_state_or_live_cursor(tmp_path: Pat message, recipient=MEMBERS[0], frozen_audience=manifest, - control=ControlClassification.ORDINARY, + control=OrdinaryControl, ) == WakeDecision("lookup-alpha", MessageAudience.COLLECTIVE, BoundedTriageWake()) assert message.exact_reply_target == "#team" assert message.to_wire() == before diff --git a/tests/verify_connector_tools.py b/tests/verify_connector_tools.py index 749282c364..dd70826e2d 100644 --- a/tests/verify_connector_tools.py +++ b/tests/verify_connector_tools.py @@ -24,7 +24,7 @@ from pathlib import Path from agent_comms.change_report import FileLine -from agent_comms.channels import AnyOfMatch, SavedView, ViewKind, ViewPredicate +from agent_comms.channels import AnyOfMatch, ViewPredicate, ParticipantsView from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms from agent_comms.connector import Connector @@ -54,9 +54,7 @@ def seed(root: Path, project: Path) -> None: ) Bus.install(Bus.path_in(comms.root)) comms.channels.set_saved_view( - SavedView( - "probe-view", ViewKind.PARTICIPANTS, ViewPredicate(AnyOfMatch, frozenset({"probe"})) - ) + ParticipantsView('probe-view', ViewPredicate(AnyOfMatch, frozenset({'probe'}))) ) comms.goals.update_goal( "claude-ai", SetGoalAction(text="Measure the connector calls.", goal_id="probe-goal") From e65d869166bb612bd24ab1883cddc15e09c02106 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:04:56 -0400 Subject: [PATCH 04/17] Move execution source and retry policy onto provenance cases --- src/agent_comms/cohort_schema.py | 4 +- src/agent_comms/coordination_snapshot.py | 7 +- .../coordination_tables/executions.py | 119 ++++++++++++++---- .../coordination_tables/responses.py | 12 +- src/agent_comms/execution_store.py | 12 +- src/agent_comms/plan_phases.py | 12 +- src/agent_comms/selected_participant.py | 4 +- src/agent_comms/selected_triage.py | 4 +- src/agent_comms/selected_turn.py | 4 +- tests/test_acp_private_nk_delivery.py | 4 +- tests/test_claim_admission_verifier.py | 4 +- tests/test_claim_bus_read.py | 4 +- tests/test_coordination.py | 20 +-- tests/test_coordination_nominal.py | 6 +- tests/test_coordination_response.py | 4 +- tests/test_coordination_store.py | 18 +-- tests/test_lifecycle_subclasses.py | 6 +- tests/test_native_failure_recovery.py | 4 +- tests/test_native_unknown_recovery.py | 4 +- tests/test_optional_awareness_projection.py | 10 +- tests/test_s3_legality_behavior.py | 6 +- 21 files changed, 171 insertions(+), 97 deletions(-) diff --git a/src/agent_comms/cohort_schema.py b/src/agent_comms/cohort_schema.py index e4954330f9..5988a0f6c2 100644 --- a/src/agent_comms/cohort_schema.py +++ b/src/agent_comms/cohort_schema.py @@ -336,11 +336,9 @@ def require_selection(self, assignment: WakeAssignment, acknowledgement: ClaimBa def require_obligation(self, execution, link, assignment, obligation) -> None: """Captured ownership and the reply's original relational key agree.""" - from .coordination_tables.executions import ExecutionOrigin from .wake_candidate_index import ProjectionUnavailableError - if execution.origin is not ExecutionOrigin.WIRE: - raise ProjectionUnavailableError("open obligation has no wire origin") + execution.origin.require_obligation() original_owner = OwnerGenerations( owner_lookup=execution.owner_lookup, owner_thread=execution.owner_thread, generation=self.owner_generation, diff --git a/src/agent_comms/coordination_snapshot.py b/src/agent_comms/coordination_snapshot.py index a9a8ca174e..2131aa551f 100644 --- a/src/agent_comms/coordination_snapshot.py +++ b/src/agent_comms/coordination_snapshot.py @@ -23,7 +23,6 @@ ) from agent_comms.coordination_tables.executions import ( CurrentExecutions, - ExecutionOrigin, ExecutionRecord, NoPointedAttempt, NoPointedExecution, @@ -208,11 +207,7 @@ def validate_current_pointer(self) -> None: raise IntegrityViolationError("snapshot current pointer is inconsistent") def validate_response_route(self) -> None: - if self.execution.origin is ExecutionOrigin.WIRE: - if not self.assignments or not self.obligations: - raise IntegrityViolationError("wire snapshots require original obligations") - elif self.obligations or self.assignments: - raise IntegrityViolationError("claimless snapshots cannot have claims or obligations") + self.execution.origin.validate_routes(self.assignments, self.obligations) expected = {row.lifecycle.exact_target for row in self.assignments} targets = tuple(row.exact_target for row in self.obligations) if len(set(targets)) != len(targets) or set(targets) != expected: diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index 6b51f79a58..8bdc95267f 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -5,7 +5,6 @@ import sqlite3 from abc import ABC, abstractmethod from dataclasses import dataclass, replace -from enum import StrEnum from typing import Annotated, ClassVar from agent_comms.coordination_contracts import ( @@ -16,6 +15,7 @@ ) from agent_comms.coordination_errors import IdentityConflict, IntegrityViolationError, StaleRevision from agent_comms.coordination_schema import CoordinatorRow +from agent_comms.declared_family import DeclaredFamily from agent_comms.coordination_tables.attempts import ( APPROVED_REPLAY_FACT_MASK, AttemptRecord, @@ -52,14 +52,95 @@ NonIncreasing, OrdinalTransitions, TextStorage, + sql_literal, ) -class ExecutionOrigin(StrEnum): - WIRE = "wire" - ACP = "acp" - GOAL = "goal" - SYSTEM = "system" +class ExecutionProvenance(DeclaredFamily, affix="Origin"): + """The origin owns source membership, route eligibility and retry policy.""" + + @classmethod + @abstractmethod + def require_sources(cls, sources) -> None: ... + + @classmethod + @abstractmethod + def validate_routes(cls, assignments, obligations) -> None: ... + + @classmethod + @abstractmethod + def retry_authorized(cls, obligations) -> bool: ... + + @classmethod + @abstractmethod + def require_obligation(cls) -> None: ... + + +class ClaimlessOrigin(ExecutionProvenance, declared_name=None): + @classmethod + def require_sources(cls, sources) -> None: + if sources: + raise IdentityConflict("claimless execution cannot bind claims") + + @classmethod + def validate_routes(cls, assignments, obligations) -> None: + if assignments or obligations: + raise IntegrityViolationError("claimless snapshots cannot have claims or obligations") + + @classmethod + def retry_authorized(cls, obligations) -> bool: + return True + + @classmethod + def require_obligation(cls) -> None: + from agent_comms.wake_candidate_index import ProjectionUnavailableError + + raise ProjectionUnavailableError("open obligation has no wire origin") + + +class WireOrigin(ExecutionProvenance): + @classmethod + def require_sources(cls, sources) -> None: + if not sources: + raise IdentityConflict("wire execution requires ordered original sources") + + @classmethod + def validate_routes(cls, assignments, obligations) -> None: + if not assignments or not obligations: + raise IntegrityViolationError("wire snapshots require original obligations") + + @classmethod + def retry_authorized(cls, obligations) -> bool: + return bool(obligations) and all(row.lifecycle.retryable for row in obligations) + + @classmethod + def require_obligation(cls) -> None: + pass + + @classmethod + def matches_sql(cls, column: str) -> str: + return f"{column} = {sql_literal(cls)}" + + @classmethod + def retry_sql(cls, execution: str) -> str: + return ( + f"({execution}.origin != {sql_literal(cls)} OR (EXISTS (SELECT 1 FROM obligations o\n" + f" WHERE o.execution_id={execution}.execution_id) AND NOT EXISTS " + "(SELECT 1 FROM obligations o\n" + f" WHERE o.execution_id={execution}.execution_id AND o.retryable != 1)))" + ) + + +class AcpOrigin(ClaimlessOrigin): + pass + + +class GoalOrigin(ClaimlessOrigin): + pass + + +class SystemOrigin(ClaimlessOrigin): + pass class PointerExecution(SubclassUnion, TextChoice, affix="Execution"): @@ -174,14 +255,12 @@ def references(cls): ), ) -_RETRY_BASIS = """SELECT e.execution_id, +_RETRY_BASIS = f"""SELECT e.execution_id, CASE WHEN e.current_attempt_ordinal IS NOT NULL AND e.current_attempt_ordinal < e.max_attempts AND EXISTS (SELECT 1 FROM replay_assessments r WHERE r.execution_id = e.execution_id AND r.retry_authorized = 1) - AND (e.origin != 'wire' OR (EXISTS (SELECT 1 FROM obligations o - WHERE o.execution_id=e.execution_id) AND NOT EXISTS (SELECT 1 FROM obligations o - WHERE o.execution_id=e.execution_id AND o.retryable != 1))) + AND {WireOrigin.retry_sql("e")} THEN 1 ELSE 0 END AS authorized FROM executions e""" @@ -199,12 +278,12 @@ class ExecutionRecord( ): column_orders = (NonDecreasing(("updated_at_ms",)),) column_guards = ( - ColumnGuard(("lifecycle",), """(json_extract(NEW.lifecycle, '$.kind') - IN ({optional_attempt_execution_names}) AND json_extract(NEW.lifecycle, '$.ordinal') + ColumnGuard(("lifecycle",), f"""(json_extract(NEW.lifecycle, '$.kind') + IN ({{optional_attempt_execution_names}}) AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.execution_id = NEW.execution_id AND a.attempt_ordinal = json_extract(NEW.lifecycle, '$.ordinal') - AND a.owner_lookup = NEW.owner_lookup AND a.phase IN ({failed_attempt_phases}))) - OR ((NEW.origin = 'wire') AND ((NOT EXISTS (SELECT 1 FROM obligations + AND a.owner_lookup = NEW.owner_lookup AND a.phase IN ({{failed_attempt_phases}}))) + OR (({WireOrigin.matches_sql("NEW.origin")}) AND ((NOT EXISTS (SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id)) OR (json_extract(NEW.lifecycle, '$.kind') = 'completed' AND EXISTS (SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id @@ -232,10 +311,7 @@ def retry_authorized(self, replay: ReplayState, obligations) -> bool: return ( self.lifecycle.has_retry_budget(self.max_attempts) and replay.allows_retry - and ( - self.origin is not ExecutionOrigin.WIRE - or (bool(obligations) and all(row.lifecycle.retryable for row in obligations)) - ) + and self.origin.retry_authorized(obligations) ) execution_id: Annotated[ @@ -250,7 +326,7 @@ def retry_authorized(self, replay: ReplayState, obligations) -> bool: ), ), ] - origin: Annotated[ExecutionOrigin, FrozenColumn()] + origin: Annotated[type[ExecutionProvenance], FrozenColumn()] lifecycle: Annotated[ExecutionState, SnapshotExclude()] owner_thread: Annotated[str, FrozenColumn(check="length(owner_thread) BETWEEN 1 AND 256")] owner_lookup: Annotated[str, FrozenColumn()] @@ -274,7 +350,6 @@ def snapshot_current_attempt_ordinal(self): def __post_init__(self) -> None: validate_execution_id(self.execution_id) - object.__setattr__(self, "origin", ExecutionOrigin(self.origin)) for field, value in ( ("owner_thread", self.owner_thread), ("owner_lookup", self.owner_lookup), @@ -332,11 +407,11 @@ def __post_init__(self) -> None: ) wire_execution_id: ClassVar[Generated] = Generated( str, - Column(generated="CASE WHEN origin = 'wire' THEN execution_id END", nullable=True), + Column(generated=f"CASE WHEN {WireOrigin.matches_sql('origin')} THEN execution_id END", nullable=True), ) wire_claim_ordinal: ClassVar[Generated] = Generated( int, - Column(generated="CASE WHEN origin = 'wire' THEN 0 END", nullable=True), + Column(generated=f"CASE WHEN {WireOrigin.matches_sql('origin')} THEN 0 END", nullable=True), ) claim_status_kind: ClassVar[Generated] = Generated( str, diff --git a/src/agent_comms/coordination_tables/responses.py b/src/agent_comms/coordination_tables/responses.py index cfb9e0d96a..6ee63d8c12 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -12,7 +12,7 @@ validate_execution_id, ) from agent_comms.coordination_schema import CoordinatorRow -from agent_comms.coordination_tables.executions import ExecutionReference, RetryPartition +from agent_comms.coordination_tables.executions import ExecutionReference, RetryPartition, WireOrigin from agent_comms.field_codec import SnapshotExclude, WireName, projected from agent_comms.obligation_states import ResponseState from agent_comms.reason_codes import ReasonCode @@ -60,10 +60,10 @@ class ResponseObligation( AND exact_target = NEW.exact_target AND message_id = json_extract(NEW.lifecycle, '$.message_id') AND seq = json_extract(NEW.lifecycle, '$.seq'))))""" - insert_condition = """(NOT EXISTS (SELECT 1 FROM executions e + insert_condition = f"""(NOT EXISTS (SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id WHERE e.execution_id=NEW.execution_id - AND e.origin='wire' AND c.exact_target=NEW.exact_target)) - OR (json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names}))""" + AND {WireOrigin.matches_sql("e.origin")} AND c.exact_target=NEW.exact_target)) + OR (json_extract(NEW.lifecycle, '$.kind') IN ({{required_intent_response_names}}))""" column_guards = ( ColumnGuard(("lifecycle",), """(EXISTS (SELECT 1 FROM publication_receipts WHERE execution_id = OLD.execution_id AND exact_target = OLD.exact_target) @@ -71,9 +71,9 @@ class ResponseObligation( SELECT 1 FROM publication_intents WHERE execution_id = OLD.execution_id AND exact_target = OLD.exact_target) AND json_extract(NEW.lifecycle, '$.kind') NOT IN ({intent_response_names}))"""), - ColumnGuard(("exact_target",), """(NOT EXISTS (SELECT 1 FROM executions e + ColumnGuard(("exact_target",), f"""(NOT EXISTS (SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id WHERE e.execution_id=NEW.execution_id - AND e.origin='wire' AND c.exact_target=NEW.exact_target)) OR (EXISTS ( + AND {WireOrigin.matches_sql("e.origin")} AND c.exact_target=NEW.exact_target)) OR (EXISTS ( SELECT 1 FROM publication_intents WHERE execution_id = OLD.execution_id AND exact_target = OLD.exact_target))"""), ) diff --git a/src/agent_comms/execution_store.py b/src/agent_comms/execution_store.py index 5039a23f9b..c8462c5a03 100644 --- a/src/agent_comms/execution_store.py +++ b/src/agent_comms/execution_store.py @@ -14,7 +14,7 @@ from agent_comms.coordination_snapshot import RecoverySnapshot from agent_comms.coordination_tables.assignments import ExecutionAssignmentLink, WakeAssignment from agent_comms.coordination_tables.executions import ( - ExecutionOrigin, + ExecutionProvenance, ExecutionRecord, ) from agent_comms.coordination_tables.responses import ResponseObligation @@ -37,7 +37,7 @@ class ExecutionStore(RecoveryReader): def create( self, execution_id: str, - origin: ExecutionOrigin, + origin: type[ExecutionProvenance], owner_lookup: str, owner_thread: str, max_attempts: int, @@ -51,7 +51,7 @@ def create( def create_after_triage( self, execution_id: str, - origin: ExecutionOrigin, + origin: type[ExecutionProvenance], owner_lookup: str, owner_thread: str, max_attempts: int, @@ -69,15 +69,11 @@ def create_after_triage( def _create(self, db, execution_id, origin, owner_lookup, owner_thread, max_attempts, sources, assignments): """The single creation algorithm, under its caller's owning transaction.""" - origin = ExecutionOrigin(origin) for source in sources: if source.store is not self.assignments: raise IdentityConflict("execution source belongs to another assignment store") assignment_ids = tuple(source.assignment_id for source in sources) - if origin is ExecutionOrigin.WIRE and not sources: - raise IdentityConflict("wire execution requires ordered original sources") - if origin is not ExecutionOrigin.WIRE and sources: - raise IdentityConflict("claimless execution cannot bind claims") + origin.require_sources(sources) row = ExecutionRecord.one(self.session._connection, execution_id=execution_id) if row is not None: snapshot = self.get(execution_id) diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index 594d66bdbd..3994a1e2f2 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -650,9 +650,15 @@ class OwnedDispatchPhase(RemainingSlopPhase): class KindFamiliesPhase(RemainingSlopPhase): """6.3: kinds are families whose members carry their behaviour and spelling.""" - evidence = PatternAbsent( - r"class (TagAction|ExecutionOrigin|WhenBusy|MessageControl|MembershipTransition" - r"|ViewKind)\b" + evidence = AllEvidence( + ( + PatternAbsent( + r"class (TagAction|ExecutionOrigin|WhenBusy|ControlClassification" + r"|MembershipChange)\b" + ), + # SQLite's ViewKind(CatalogKind) is already a behavior-owning member. + PatternAbsent(r"class ViewKind\((StrEnum|Enum)\)"), + ) ) diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index a689fe7257..5990e03411 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -20,7 +20,7 @@ from .coordination_response import LiveResponseOwner, ResponseTable from .coordination_snapshot import RecoverySnapshot from .coordination_tables.assignments import WakeAssignment -from .coordination_tables.executions import ExecutionOrigin +from .coordination_tables.executions import ExecutionProvenance, WireOrigin from .coordinator import Coordination from .cursor_publication import CursorDelivery from .errors import RelationViolationError @@ -89,7 +89,7 @@ async def fail_before_send(self, max_attempts: int) -> None: """Settle captured originals when acquisition fails before any native send.""" await self.require_current() snapshot = self.store.create( - self.batch.execution_id, ExecutionOrigin.WIRE, + self.batch.execution_id, WireOrigin, self.lookup, self.owner.thread.name, max_attempts, sources=self.batch.sources, ).value diff --git a/src/agent_comms/selected_triage.py b/src/agent_comms/selected_triage.py index cc216f73fc..3f81eb9fb7 100644 --- a/src/agent_comms/selected_triage.py +++ b/src/agent_comms/selected_triage.py @@ -108,11 +108,11 @@ def settle(self, store, db, current): pass async def continue_turn(self, participant, session, input_id, execution, settled): - from .coordination_tables.executions import ExecutionOrigin + from .coordination_tables.executions import ExecutionProvenance, WireOrigin await participant.require_current() created = participant.store.create_after_triage( - participant.batch.execution_id, ExecutionOrigin.WIRE, + participant.batch.execution_id, WireOrigin, participant.lookup, participant.owner.thread.name, execution.max_attempts, sources=participant.batch.sources, settled=settled, diff --git a/src/agent_comms/selected_turn.py b/src/agent_comms/selected_turn.py index e2b7cf0949..14fe68b524 100644 --- a/src/agent_comms/selected_turn.py +++ b/src/agent_comms/selected_turn.py @@ -11,7 +11,7 @@ from .channel_coding_tools import SelectedCodingToolOwner from .coordination_errors import CoordinationError, IdentityConflict, StaleFence from .coordinator import Coordination -from .coordination_tables.executions import ExecutionOrigin +from .coordination_tables.executions import ExecutionProvenance, WireOrigin from .coordination_snapshot import RecoverySnapshot from .coordination_tables.responses import ResponseObligation from .durable_turn import DurableTurn @@ -256,7 +256,7 @@ async def run(self, execution, session): if not participant.batch.requires_triage: await participant.require_current() created = participant.store.create( - participant.batch.execution_id, ExecutionOrigin.WIRE, + participant.batch.execution_id, WireOrigin, participant.lookup, participant.owner.thread.name, execution.max_attempts, sources=participant.batch.sources, diff --git a/tests/test_acp_private_nk_delivery.py b/tests/test_acp_private_nk_delivery.py index 0fd3e2b835..a3458ce83f 100644 --- a/tests/test_acp_private_nk_delivery.py +++ b/tests/test_acp_private_nk_delivery.py @@ -74,7 +74,7 @@ def cursor_envelope(metadata): def test_private_owner_rename_during_active_attempt_can_complete(tmp_path, managed): from agent_comms.attempt_start import AttemptStart from agent_comms.attempt_states import SucceededAttempt - from agent_comms.coordination_tables.executions import ExecutionOrigin + from agent_comms.coordination_tables.executions import ExecutionProvenance, AcpOrigin from agent_comms.owner_fence import prepare_fence_token from test_coordination_store import final_evidence @@ -82,7 +82,7 @@ def test_private_owner_rename_during_active_attempt_can_complete(tmp_path, manag lookup = RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup with Coordination(str(comms.root / "coordination.sqlite3")) as store: before = store.participants.get(lookup) - store.create("rename-active", ExecutionOrigin.ACP, lookup, "beta", 1) + store.create("rename-active", AcpOrigin, lookup, "beta", 1) store.mark_pending("rename-active", expected_revision=1) fence = store.start(AttemptStart( "rename-active", 1, "beta", 1, prepare_fence_token(), diff --git a/tests/test_claim_admission_verifier.py b/tests/test_claim_admission_verifier.py index 7510471afd..1998334398 100644 --- a/tests/test_claim_admission_verifier.py +++ b/tests/test_claim_admission_verifier.py @@ -27,7 +27,7 @@ from agent_comms.comms import Comms from agent_comms.coordination_cohort import accept_delivery_cohort, sealed_cohort_assignments from agent_comms.coordination_errors import IdentityConflict -from agent_comms.coordination_tables.executions import ExecutionOrigin +from agent_comms.coordination_tables.executions import ExecutionProvenance, WireOrigin from agent_comms.coordinator import Coordination from agent_comms.envelope_claim_transitions import ExistingFileClaim, WakeClaim from agent_comms.errors import RelationViolationError @@ -83,7 +83,7 @@ def test_selected_wake_verifier_refuses_no_wake_and_stale_authority( execution_id = "verifier-execution" store.create( execution_id, - ExecutionOrigin.WIRE, + WireOrigin, alice_lookup, owner.name, 1, diff --git a/tests/test_claim_bus_read.py b/tests/test_claim_bus_read.py index 0a44fbc6e5..071aa50ce4 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -31,7 +31,7 @@ from agent_comms.comms import Comms from agent_comms.coordination_cohort import accept_delivery_cohort, sealed_cohort_assignments from agent_comms.coordination_errors import IdentityConflict -from agent_comms.coordination_tables.executions import ExecutionOrigin +from agent_comms.coordination_tables.executions import ExecutionProvenance, WireOrigin from agent_comms.coordinator import Coordination from agent_comms.envelope_claim_transitions import ExistingFileClaim, NativeClaim, WakeClaim from agent_comms.coordination_session import NativeBoundary @@ -104,7 +104,7 @@ def selected_turn(private_comms, private_directory): ) person = store.participants.get(lookup) store.create( - "execution", ExecutionOrigin.WIRE, lookup, owner.name, 1, + "execution", WireOrigin, lookup, owner.name, 1, sources=(SelectedSource(assignment.assignment_id, store.assignments, initial),), ) snapshot = store.get("execution") diff --git a/tests/test_coordination.py b/tests/test_coordination.py index 9aa5cece55..baed606b71 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -43,7 +43,11 @@ from agent_comms.coordination_tables.executions import NoReplayAssessment, ReplayAssessments from agent_comms.coordination_tables.executions import ( CurrentExecutions, - ExecutionOrigin, + ExecutionProvenance, + WireOrigin, + AcpOrigin, + GoalOrigin, + SystemOrigin, ExecutionRecord, ) from agent_comms.coordination_tables.participants import OwnerGenerations, Participants @@ -71,7 +75,7 @@ from agent_comms.wake_policy import FullWake -def execution(*, status=PendingExecution, ordinal=None, origin=ExecutionOrigin.ACP): +def execution(*, status=PendingExecution, ordinal=None, origin=AcpOrigin): return ExecutionRecord( execution_id="execution-1", origin=origin, @@ -134,7 +138,7 @@ def snapshot( *, execution_record=None, attempt_record=None, - origin=ExecutionOrigin.ACP, + origin=AcpOrigin, current=False, response=None, replay=None, @@ -145,7 +149,7 @@ def snapshot( attempt=attempt_record, links=( (ExecutionAssignmentLink(record.execution_id, "claim-1", 0),) - if record.origin is ExecutionOrigin.WIRE + if record.origin is WireOrigin else () ), assignments=( @@ -161,11 +165,11 @@ def snapshot( ) ), ) - if record.origin is ExecutionOrigin.WIRE + if record.origin is WireOrigin else () ), replay=replay or ReplayAssessments(record.execution_id, ReplayFact.NONE, True, False, 1), - obligations=(response,) if record.origin is ExecutionOrigin.WIRE and response else (), + obligations=(response,) if record.origin is WireOrigin and response else (), publication_intents=(), publication_evidence=(), connectivity=None, @@ -189,7 +193,7 @@ def sql_execution( thread="worker", ): ExecutionRecord( - execution_id=name, origin=ExecutionOrigin(origin), + execution_id=name, origin=ExecutionProvenance.member_named(origin), lifecycle=ExecutionState.member_named(status).load(ordinal), owner_thread=thread, owner_lookup=owner, revision=1, max_attempts=max_attempts, reason_code=NoReasonCode(), created_at_ms=0, updated_at_ms=0, @@ -360,7 +364,7 @@ def test_snapshot_projection_retry_and_inert_secrets(): def test_wire_claim_obligation_and_publication_contract(): - record = execution(origin=ExecutionOrigin.WIRE) + record = execution(origin=WireOrigin) valid = snapshot(execution_record=record, response=obligation()) assert FieldCodec.project(valid, "snapshot")["claims"][0]["claim_id"] == "claim-1" primitive = FieldCodec.project(valid, "snapshot") diff --git a/tests/test_coordination_nominal.py b/tests/test_coordination_nominal.py index 7fc8d29cd7..6aaecad815 100644 --- a/tests/test_coordination_nominal.py +++ b/tests/test_coordination_nominal.py @@ -10,7 +10,7 @@ from agent_comms.attempt_states import SucceededAttempt from agent_comms.coordination_errors import IntegrityViolationError from agent_comms.coordination_tables.assignments import WakeAssignment -from agent_comms.coordination_tables.executions import ExecutionOrigin, ExecutionRecord +from agent_comms.coordination_tables.executions import ExecutionProvenance, ExecutionRecord, WireOrigin, AcpOrigin from agent_comms.execution_states import ( ActiveExecution, ExecutionState, @@ -43,7 +43,7 @@ def test_state_data_cannot_be_attached_to_wrong_variant(): def test_record_replacement_uses_only_nominal_state(): record = ExecutionRecord( execution_id="e", - origin=ExecutionOrigin.ACP, + origin=AcpOrigin, lifecycle=PendingExecution(), owner_thread="t", owner_lookup="o", @@ -120,7 +120,7 @@ def successors(cls): store.participants.register("p", "owner", "owner", committed=True) with store.session.writing() as db: ExecutionRecord( - execution_id="e", origin=ExecutionOrigin.WIRE, + execution_id="e", origin=WireOrigin, lifecycle=QueuedExecution(), owner_thread="owner", owner_lookup="p", revision=1, max_attempts=2, reason_code=NoReasonCode(), created_at_ms=0, updated_at_ms=0, diff --git a/tests/test_coordination_response.py b/tests/test_coordination_response.py index e9fd6520c0..cc2e3636b8 100644 --- a/tests/test_coordination_response.py +++ b/tests/test_coordination_response.py @@ -26,7 +26,7 @@ ) from agent_comms.coordination_response import LiveResponseOwner, prepare_fenced_response, publish_fenced_response, resolve_existing_response from agent_comms.coordination_results import AlreadyApplied -from agent_comms.coordination_tables.executions import ExecutionOrigin +from agent_comms.coordination_tables.executions import ExecutionProvenance, WireOrigin from agent_comms.coordinator import Coordination from agent_comms.coordination_tables.responses import ResponseObligation from agent_comms.execution_states import CompletedExecution @@ -97,7 +97,7 @@ def _ready(tmp_path: Path, *, direct: bool = False, extra_targets: tuple[str, .. assert reply_target is not None store.create( "exec", - ExecutionOrigin.WIRE, + WireOrigin, recipient.recipient_lookup, "owner", 1, diff --git a/tests/test_coordination_store.py b/tests/test_coordination_store.py index a94891ccbe..d0538b2c18 100644 --- a/tests/test_coordination_store.py +++ b/tests/test_coordination_store.py @@ -51,7 +51,7 @@ ReplayAssessment, ReplayAssessments, ) -from agent_comms.coordination_tables.executions import ExecutionOrigin +from agent_comms.coordination_tables.executions import ExecutionProvenance, WireOrigin, AcpOrigin, GoalOrigin from agent_comms.coordination_tables.publications import ( PublicationIntents, ) @@ -99,12 +99,12 @@ def wire_source(db, assignment_id: str, original: Message, mode: WakePolicy): def ready( - path: Path, *, origin: ExecutionOrigin = ExecutionOrigin.WIRE, max_attempts: int = 2 + path: Path, *, origin: type[ExecutionProvenance] = WireOrigin, max_attempts: int = 2 ) -> StandaloneCoordination: db = store(path) sources = () assert isinstance(db.participants.register("owner", "Owner", "thread", committed=True), Applied) - if origin is ExecutionOrigin.WIRE: + if origin is WireOrigin: original = Message("owner", "Owner", "Original request", InfoKind, timestamp=1.0, seq=1) db.assignments.accept( WakeAssignment( @@ -280,7 +280,7 @@ def test_claim_execution_idempotency_and_rollback(db_path: Path) -> None: original = Message("owner", "Owner", "Original request", InfoKind, timestamp=1.0, seq=1) replay = db.create( "exec", - ExecutionOrigin.WIRE, + WireOrigin, "owner", "thread", 2, @@ -292,7 +292,7 @@ def test_claim_execution_idempotency_and_rollback(db_path: Path) -> None: with pytest.raises(IdentityConflict): db.create( "exec", - ExecutionOrigin.WIRE, + WireOrigin, "owner", "thread", 2, @@ -400,7 +400,7 @@ def test_claim_preengagement_and_mixed_engagement_rollback(db_path: Path) -> Non with pytest.raises((IdentityConflict, sqlite3.IntegrityError)): db.create( "exec", - ExecutionOrigin.WIRE, + WireOrigin, "owner", "thread", 2, @@ -503,7 +503,7 @@ def test_token_digest_is_global_and_only_digest_persists(db_path: Path) -> None: with ready(db_path) as db: token, _ = started(db) db.participants.register("other", "Other", "other-thread", committed=True) - db.create("other-exec", ExecutionOrigin.GOAL, "other", "other-thread", 1) + db.create("other-exec", GoalOrigin, "other", "other-thread", 1) db.mark_pending("other-exec", expected_revision=1) with pytest.raises(IdentityConflict): db.start( @@ -529,7 +529,7 @@ def test_settled_snapshot_encodes_recovery_kind_separately_from_table(db_path: P with store(db_path) as db: db.participants.register("owner", "Owner", "thread", committed=True) - db.create("exec", ExecutionOrigin.ACP, "owner", "thread", 1) + db.create("exec", AcpOrigin, "owner", "thread", 1) db.mark_pending("exec", expected_revision=1) _, fence = started(db) fence = final_evidence(db, fence) @@ -551,7 +551,7 @@ def test_settled_snapshot_encodes_recovery_kind_separately_from_table(db_path: P def test_nonpublication_claimless_atomic_settlement(db_path: Path) -> None: - with ready(db_path, origin=ExecutionOrigin.ACP) as db: + with ready(db_path, origin=AcpOrigin) as db: _, fence = started(db) fence = final_evidence(db, fence) settled = db.settle_nonpublication( diff --git a/tests/test_lifecycle_subclasses.py b/tests/test_lifecycle_subclasses.py index 11f552ab9e..1372919cd3 100644 --- a/tests/test_lifecycle_subclasses.py +++ b/tests/test_lifecycle_subclasses.py @@ -27,7 +27,7 @@ ) from agent_comms.attempt_recovery import MonitorEvidence from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayFact -from agent_comms.coordination_tables.executions import CurrentExecutions, ExecutionOrigin, ExecutionRecord +from agent_comms.coordination_tables.executions import CurrentExecutions, ExecutionProvenance, ExecutionRecord, AcpOrigin from agent_comms.execution_states import DeferredExecution, FailedExecution from agent_comms.owner_fence import prepare_fence_token from test_coordination_store import final_evidence, ready, started @@ -64,7 +64,7 @@ def test_attempt_transition_table_expands_declared_failure_subclasses(): @pytest.mark.parametrize("authorized", [False, True]) def test_unknown_settles_failed_in_current_store(tmp_path, authorized): - with ready(tmp_path / "coordination.sqlite3", origin=ExecutionOrigin.ACP) as store: + with ready(tmp_path / "coordination.sqlite3", origin=AcpOrigin) as store: _, fence = started(store) if authorized: store.observe_replay( @@ -101,7 +101,7 @@ def test_monitor_evidence_preserves_its_outcome_and_owned_replay_contribution(ou @pytest.mark.parametrize("predecessor", [AttemptFailedAttempt, UnknownOutcomeAttempt]) def test_sql_retry_predecessor_is_not_every_unsuccessful_outcome(tmp_path, predecessor): - with ready(tmp_path / "coordination.sqlite3", origin=ExecutionOrigin.ACP) as store: + with ready(tmp_path / "coordination.sqlite3", origin=AcpOrigin) as store: _, fence = started(store) store.observe_replay( fence, expected_pointer_revision=1, expected_replay_revision=None, diff --git a/tests/test_native_failure_recovery.py b/tests/test_native_failure_recovery.py index f24abd6dcf..e50236a9ad 100644 --- a/tests/test_native_failure_recovery.py +++ b/tests/test_native_failure_recovery.py @@ -19,7 +19,7 @@ from agent_comms.attempt_recovery import RecoveryMonitorCapability from agent_comms.comms import Comms from agent_comms.coordination_errors import IdentityConflict, RecoveryBlocked -from agent_comms.coordination_tables.executions import ExecutionOrigin +from agent_comms.coordination_tables.executions import ExecutionProvenance, WireOrigin from agent_comms.coordinator import Coordination from agent_comms.cursor_publication import CursorDelivery from agent_comms.declared_family import DeclaredFamily @@ -130,7 +130,7 @@ async def release_native_input(directory: Path, history: type[NativeSendHistory] ) as participant: assert participant is not None created = store.create( - participant.batch.execution_id, ExecutionOrigin.WIRE, + participant.batch.execution_id, WireOrigin, participant.lookup, participant.owner.thread.name, 1, sources=participant.batch.sources, ).value diff --git a/tests/test_native_unknown_recovery.py b/tests/test_native_unknown_recovery.py index 694c45c7c7..230aed43dc 100644 --- a/tests/test_native_unknown_recovery.py +++ b/tests/test_native_unknown_recovery.py @@ -30,7 +30,7 @@ from agent_comms.comms import Comms from agent_comms.coordination_errors import RecoveryBlocked from agent_comms.coordination_tables.attempts import ReplayFact -from agent_comms.coordination_tables.executions import CurrentExecutions, ExecutionOrigin +from agent_comms.coordination_tables.executions import CurrentExecutions, ExecutionProvenance, WireOrigin from agent_comms.cursor_publication import CursorDelivery from agent_comms.native_input_record import FullNativeExecution, NumberNativeColumn, TextNativeColumn from agent_comms.selected_participant import SelectedParticipant @@ -55,7 +55,7 @@ async def release_starting_attempt(directory): ) as participant: assert participant is not None created = store.create( - participant.batch.execution_id, ExecutionOrigin.WIRE, + participant.batch.execution_id, WireOrigin, participant.lookup, participant.owner.thread.name, 1, sources=participant.batch.sources, ).value diff --git a/tests/test_optional_awareness_projection.py b/tests/test_optional_awareness_projection.py index d159ca7171..d2a822cbd9 100644 --- a/tests/test_optional_awareness_projection.py +++ b/tests/test_optional_awareness_projection.py @@ -24,7 +24,7 @@ from agent_comms.coordination_tables.assignments import WakeAssignment from agent_comms.assignment_states import EngagedAssignment from agent_comms.wake_policy import FullWake -from agent_comms.coordination_tables.executions import ExecutionOrigin +from agent_comms.coordination_tables.executions import ExecutionProvenance, WireOrigin from agent_comms.coordinator import Coordination from agent_comms.optional_awareness_projection import OptionalAwarenessProjection from agent_comms.registration import Registration @@ -115,7 +115,7 @@ def test_selected_decision_and_open_obligation_are_both_source_cited(tmp_path: P owner = _owner(comms, "member000") store.create( "reply000", - ExecutionOrigin.WIRE, + WireOrigin, assignment.recipient_lookup, owner.name, 1, @@ -169,7 +169,7 @@ def test_open_obligation_budget_cannot_be_hidden_by_selected_cursor(tmp_path: Pa for number, (assignment, delivery) in enumerate(((old_claim, older), (new_claim, newer))): store.create( f"reply{number}", - ExecutionOrigin.WIRE, + WireOrigin, assignment.recipient_lookup, owner.name, 1, @@ -229,7 +229,7 @@ def test_fresh_gen2_selected_and_old_pending_obligation_are_scoped(tmp_path: Pat old, old_claim = _accepted(comms, store, root_id, "member000", "old pending") store.create( "old-reply", - ExecutionOrigin.WIRE, + WireOrigin, old_claim.recipient_lookup, "member000", 1, @@ -415,7 +415,7 @@ def test_saved_wire_awareness_preserves_passive_authority_and_rejects_incomplete owner = _owner(comms, "member000") store.create( "wire-awareness-reply", - ExecutionOrigin.WIRE, + WireOrigin, assignment.recipient_lookup, owner.name, 1, diff --git a/tests/test_s3_legality_behavior.py b/tests/test_s3_legality_behavior.py index f526d001ba..313f00a4fe 100644 --- a/tests/test_s3_legality_behavior.py +++ b/tests/test_s3_legality_behavior.py @@ -13,7 +13,7 @@ from agent_comms.coordination_snapshot import RecoverySnapshot from agent_comms.coordination_tables.attempts import ReplayFact from agent_comms.coordination_tables.executions import ReplayAssessments -from agent_comms.coordination_tables.executions import ExecutionOrigin +from agent_comms.coordination_tables.executions import ExecutionProvenance, AcpOrigin from agent_comms.coordination_tables.publications import ( PublicationIntents, PublicationEvidence, @@ -119,7 +119,7 @@ def test_execution_numeric_time_and_route_boundaries(pending): rejected(row, "must be positive", **{field: 0}) rejected(row, "timestamps", created_at_ms=-1) rejected(row, "timestamps", updated_at_ms=row.created_at_ms - 1) - for origin in ExecutionOrigin: + for origin in ExecutionProvenance.members_with(ExecutionProvenance): assert replace(row, origin=origin).origin is origin route = pending.obligations[0] rejected(route, "exact_target", exact_target=None) @@ -239,7 +239,7 @@ def test_snapshot_wire_claimless_and_exact_routes(pending): rejected( pending, "claimless snapshots", - execution=replace(pending.execution, origin=ExecutionOrigin.ACP), + execution=replace(pending.execution, origin=AcpOrigin), ) rejected( pending, From 9927295a85386f1a3c205b298a354c9338f611c1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:14:07 -0400 Subject: [PATCH 05/17] Finish typed policy contracts and derive sourced-origin SQL membership --- .../coordination_tables/executions.py | 44 +++++++++++++------ src/agent_comms/messages.py | 2 +- src/agent_comms/publisher.py | 2 +- src/agent_comms/response_conversation.py | 2 +- src/agent_comms/tools.py | 2 +- src/agent_comms/wake.py | 10 +++-- tests/bus_record_fixture.py | 2 +- tests/native_backend_fixture.py | 2 +- tests/test_backend.py | 2 +- tests/test_field_codec.py | 2 +- tests/test_message_categories.py | 2 +- tests/test_tracked_native_retry.py | 2 +- tests/test_turn_inbox.py | 2 +- tests/test_wake.py | 4 +- 14 files changed, 51 insertions(+), 29 deletions(-) diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index 8bdc95267f..9bccbfa1ba 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -5,7 +5,7 @@ import sqlite3 from abc import ABC, abstractmethod from dataclasses import dataclass, replace -from typing import Annotated, ClassVar +from typing import TYPE_CHECKING, Annotated, ClassVar from agent_comms.coordination_contracts import ( MAX_IDENTIFIER_CHARS, @@ -56,20 +56,28 @@ ) +if TYPE_CHECKING: + from agent_comms.coordination_tables.assignments import WakeAssignment + from agent_comms.coordination_tables.responses import ResponseObligation + from agent_comms.selected_source_batch import SelectedSource + + class ExecutionProvenance(DeclaredFamily, affix="Origin"): """The origin owns source membership, route eligibility and retry policy.""" @classmethod @abstractmethod - def require_sources(cls, sources) -> None: ... + def require_sources(cls, sources: tuple[SelectedSource, ...]) -> None: ... @classmethod @abstractmethod - def validate_routes(cls, assignments, obligations) -> None: ... + def validate_routes( + cls, assignments: tuple[WakeAssignment, ...], obligations: tuple[ResponseObligation, ...], + ) -> None: ... @classmethod @abstractmethod - def retry_authorized(cls, obligations) -> bool: ... + def retry_authorized(cls, obligations: tuple[ResponseObligation, ...]) -> bool: ... @classmethod @abstractmethod @@ -78,17 +86,19 @@ def require_obligation(cls) -> None: ... class ClaimlessOrigin(ExecutionProvenance, declared_name=None): @classmethod - def require_sources(cls, sources) -> None: + def require_sources(cls, sources: tuple[SelectedSource, ...]) -> None: if sources: raise IdentityConflict("claimless execution cannot bind claims") @classmethod - def validate_routes(cls, assignments, obligations) -> None: + def validate_routes( + cls, assignments: tuple[WakeAssignment, ...], obligations: tuple[ResponseObligation, ...], + ) -> None: if assignments or obligations: raise IntegrityViolationError("claimless snapshots cannot have claims or obligations") @classmethod - def retry_authorized(cls, obligations) -> bool: + def retry_authorized(cls, obligations: tuple[ResponseObligation, ...]) -> bool: return True @classmethod @@ -100,17 +110,19 @@ def require_obligation(cls) -> None: class WireOrigin(ExecutionProvenance): @classmethod - def require_sources(cls, sources) -> None: + def require_sources(cls, sources: tuple[SelectedSource, ...]) -> None: if not sources: raise IdentityConflict("wire execution requires ordered original sources") @classmethod - def validate_routes(cls, assignments, obligations) -> None: + def validate_routes( + cls, assignments: tuple[WakeAssignment, ...], obligations: tuple[ResponseObligation, ...], + ) -> None: if not assignments or not obligations: raise IntegrityViolationError("wire snapshots require original obligations") @classmethod - def retry_authorized(cls, obligations) -> bool: + def retry_authorized(cls, obligations: tuple[ResponseObligation, ...]) -> bool: return bool(obligations) and all(row.lifecycle.retryable for row in obligations) @classmethod @@ -119,12 +131,16 @@ def require_obligation(cls) -> None: @classmethod def matches_sql(cls, column: str) -> str: - return f"{column} = {sql_literal(cls)}" + members = cls.members_with(cls) + # Preserve the existing scalar DDL; additional sourced cases derive a set. + if len(members) == 1: + return f"{column} = {sql_literal(members[0])}" + return f"{column} IN {cls.sql_choices()}" @classmethod def retry_sql(cls, execution: str) -> str: return ( - f"({execution}.origin != {sql_literal(cls)} OR (EXISTS (SELECT 1 FROM obligations o\n" + f"(NOT ({cls.matches_sql(execution + '.origin')}) OR (EXISTS (SELECT 1 FROM obligations o\n" f" WHERE o.execution_id={execution}.execution_id) AND NOT EXISTS " "(SELECT 1 FROM obligations o\n" f" WHERE o.execution_id={execution}.execution_id AND o.retryable != 1)))" @@ -307,7 +323,9 @@ def schema_objects(cls, *, if_not_exists: bool = False): return super().schema_objects(if_not_exists=if_not_exists) | { cls.retry_view: f"CREATE VIEW {existence}{cls.retry_view} AS\n" + _RETRY_BASIS} - def retry_authorized(self, replay: ReplayState, obligations) -> bool: + def retry_authorized( + self, replay: ReplayState, obligations: tuple[ResponseObligation, ...], + ) -> bool: return ( self.lifecycle.has_retry_budget(self.max_attempts) and replay.allows_retry diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index bcf45c3c1f..8d813a000e 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -163,7 +163,7 @@ def require_publishable(cls, message: Message, worktree: Path) -> None: class MembershipTransition(DeclaredFamily, affix="Membership"): @classmethod - def conversation_role(cls): + def conversation_role(cls) -> MembershipConversationRole: return MembershipConversationRole(cls) @classmethod diff --git a/src/agent_comms/publisher.py b/src/agent_comms/publisher.py index 3fa5b22cc6..5893ec6130 100644 --- a/src/agent_comms/publisher.py +++ b/src/agent_comms/publisher.py @@ -43,7 +43,7 @@ from .mentions import ThreadMention from .private_path import FileRevision from .registry_document import DEFAULT_ROSTER, RegistrySnapshot -from .wake import MessageControl, resolve_wake_cohort, OrdinaryControl +from .wake import OrdinaryControl, resolve_wake_cohort if TYPE_CHECKING: from .coordination_session import NativeBoundary diff --git a/src/agent_comms/response_conversation.py b/src/agent_comms/response_conversation.py index 9211725174..9d71c1183e 100644 --- a/src/agent_comms/response_conversation.py +++ b/src/agent_comms/response_conversation.py @@ -103,7 +103,7 @@ def audience(self, message: Message): def record(self, seq: int, message: Message, intent) -> KeyedPrivateRecord: from agent_comms.bus import KeyedPrivateRecord from agent_comms.delivery_policy import ResponseDeliveryPolicy - from agent_comms.wake import MessageControl, OrdinaryControl + from agent_comms.wake import OrdinaryControl stored = replace(message, seq=seq) audience = self.audience(stored) diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 48ad07477f..2e4e30d133 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -37,7 +37,7 @@ from . import cli as comms_cli from .change_report import NoChangeReport, ReportEvidence -from .channel_management import KeepThreadsTagDisposition, ListTagEdit, TagDisposition, TagEdit +from .channel_management import ListTagEdit, TagEdit from agent_comms.channel_targets import ChannelTarget, CommandTarget, NamedTarget from .channels import AnyChannel, Channel, SavedView, ViewMatch, ViewPredicate from .cli_commands import ( diff --git a/src/agent_comms/wake.py b/src/agent_comms/wake.py index e72397589d..1a71979ca1 100644 --- a/src/agent_comms/wake.py +++ b/src/agent_comms/wake.py @@ -11,14 +11,16 @@ from .channel_targets import CommandTarget, NamedTarget from .declared_family import DeclaredFamily from .messages import Message -from .wake_policy import NoWakeDecision, PassiveWake, RecipientDecision +from .wake_policy import NoWakeDecision, PassiveWake, RecipientDecision, WakePolicy class MessageControl(DeclaredFamily, affix="Control"): """Explicit system-control classification; not inferred from text or ACK type.""" @classmethod - def wake_mode(cls, target, recipient, mentions): + def wake_mode( + cls, target: NamedTarget, recipient: FrozenRecipient, mentions: frozenset[str], + ) -> WakePolicy: return target.wake_policy(recipient, mentions) @@ -28,7 +30,9 @@ class OrdinaryControl(MessageControl): class SystemControl(MessageControl, declared_name="system_control"): @classmethod - def wake_mode(cls, target, recipient, mentions): + def wake_mode( + cls, target: NamedTarget, recipient: FrozenRecipient, mentions: frozenset[str], + ) -> WakePolicy: return PassiveWake() diff --git a/tests/bus_record_fixture.py b/tests/bus_record_fixture.py index 9c9c7f006b..0215a59c7f 100644 --- a/tests/bus_record_fixture.py +++ b/tests/bus_record_fixture.py @@ -14,7 +14,7 @@ from agent_comms.response_acknowledgement import ( KeyedResponseAcknowledgement, canonical_publication_key, public_envelope_digest, ) -from agent_comms.wake import MessageControl, resolve_wake_cohort, OrdinaryControl +from agent_comms.wake import OrdinaryControl, resolve_wake_cohort publication_processes = ForkServerContext() diff --git a/tests/native_backend_fixture.py b/tests/native_backend_fixture.py index 3737fc4496..e30f6c8ea1 100644 --- a/tests/native_backend_fixture.py +++ b/tests/native_backend_fixture.py @@ -14,7 +14,7 @@ import agent_comms.agent_events as events import agent_comms.backend as backend -from agent_comms.agent_backend import InputContent, InputId, InputRequest, TurnInbox, BusyInputPolicy, SteerInputPolicy +from agent_comms.agent_backend import InputContent, InputId, InputRequest, TurnInbox, SteerInputPolicy from agent_comms.comms import Comms from agent_comms.fresh_private_session import create_fresh_private_session from agent_comms.child_process import BoundedRun, join_retirement diff --git a/tests/test_backend.py b/tests/test_backend.py index 515905c4e8..25961266a3 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -10,7 +10,7 @@ import agent_comms.agent_events as ae import agent_comms.backend as backend -from agent_comms.agent_backend import InputContent, InputId, InputRequest, BusyInputPolicy, SteerInputPolicy +from agent_comms.agent_backend import InputContent, InputId, InputRequest, SteerInputPolicy from agent_comms.native_pi import CAPABILITY from agent_comms.pi_rpc import PiRpcChannel from agent_comms.turn_phase import ( diff --git a/tests/test_field_codec.py b/tests/test_field_codec.py index 9b3e98af9d..410e262d66 100644 --- a/tests/test_field_codec.py +++ b/tests/test_field_codec.py @@ -29,7 +29,7 @@ WireNonNull, WireTransient, ) -from agent_comms.messages import ConversationRole, InfoKind, MembershipTransition, MembershipConversationRole, MembershipNoticeRole, Message, MessageRole, NoticeRole, JoinedMembership, LeftMembership +from agent_comms.messages import ConversationRole, InfoKind, MembershipConversationRole, MembershipNoticeRole, Message, MessageRole, NoticeRole, JoinedMembership, LeftMembership def test_transient_plan_excludes_fields_before_decoder_or_value_access(): diff --git a/tests/test_message_categories.py b/tests/test_message_categories.py index e5cf51f8d4..9db8e60af8 100644 --- a/tests/test_message_categories.py +++ b/tests/test_message_categories.py @@ -6,7 +6,7 @@ from agent_comms.bus_publication import Delivered from agent_comms.field_codec import FieldCodec -from agent_comms.messages import Message, MessageRole, InfoKind, ConversationRole, NoticeRole, HistoricalConversationRole, HistoricalNoticeRole, MembershipConversationRole, MembershipNoticeRole, MembershipTransition, JoinedMembership +from agent_comms.messages import Message, MessageRole, InfoKind, ConversationRole, NoticeRole, HistoricalConversationRole, HistoricalNoticeRole, MembershipConversationRole, MembershipNoticeRole, JoinedMembership from agent_comms.thread_identity import AgentThreadRole, UserThreadRole from agent_comms.transcript_events import IncomingTranscript from agent_comms.transcript_sources import AssignedTranscriptSource diff --git a/tests/test_tracked_native_retry.py b/tests/test_tracked_native_retry.py index 0b0cae2f36..8bc7629bd7 100644 --- a/tests/test_tracked_native_retry.py +++ b/tests/test_tracked_native_retry.py @@ -3,7 +3,7 @@ from agent_comms.native_arguments import SelectedModel from agent_comms.native_startup import NativeStartupPolicy -from agent_comms.agent_backend import BusyInputPolicy, SteerInputPolicy +from agent_comms.agent_backend import SteerInputPolicy from agent_comms.pi_events import NativeRetryEnd from agent_comms.pi_payloads import FailedProviderRetry, PiMessage, SucceededProviderRetry from agent_comms.tracked_turn import CompletedTrackedTerminal, PendingTrackedTerminal, TrackedTurnSession diff --git a/tests/test_turn_inbox.py b/tests/test_turn_inbox.py index 57f71e9241..006a22bbe5 100644 --- a/tests/test_turn_inbox.py +++ b/tests/test_turn_inbox.py @@ -2,7 +2,7 @@ -from agent_comms.agent_backend import InputContent, InputId, InputRequest, TurnInbox, BusyInputPolicy, FollowUpInputPolicy +from agent_comms.agent_backend import InputContent, InputId, InputRequest, TurnInbox, FollowUpInputPolicy from agent_comms.turn_inputs import InputForwarding diff --git a/tests/test_wake.py b/tests/test_wake.py index 1cdbc85462..fffd3183e2 100644 --- a/tests/test_wake.py +++ b/tests/test_wake.py @@ -49,7 +49,7 @@ def decide( message: Message, lookup: str = "lookup-alpha", members: tuple[FrozenRecipient, ...] = MEMBERS, - control: MessageControl = OrdinaryControl, + control: type[MessageControl] = OrdinaryControl, ) -> RecipientDecision | None: recipient = next( (member for member in members if member.recipient_lookup == lookup), @@ -155,7 +155,7 @@ def test_mixed_in_and_out_of_scope_mentions_only_name_valid_frozen_member() -> N @pytest.mark.parametrize("control", MessageControl.members_with(MessageControl)) @pytest.mark.parametrize("role", [NoticeRole(), MembershipConversationRole(JoinedMembership)]) def test_notice_and_membership_are_passive_regardless_of_control_or_sender( - role, control: MessageControl + role, control: type[MessageControl] ) -> None: message = replace(stored(sender_role=AgentThreadRole), role=role) assert decide(message, control=control) == WakeDecision( From fa87d28cd99337e5411d4796edba61adebdfaa1e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:17:21 -0400 Subject: [PATCH 06/17] Migrate membership assertions to their declaration-owned spelling --- tests/test_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_channels.py b/tests/test_channels.py index e312a0f276..1f3204e02b 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -492,7 +492,7 @@ def test_membership_notices_follow_exact_membership_and_do_not_wake(tmp_path): ) comms.channels.update_tags("moving", add=frozenset({"api"})) joined = list(comms.views.history_page(CommandTarget.named("#api")).messages) - assert len(joined) == 1 and joined[0].role.change.value == "joined" + assert len(joined) == 1 and joined[0].role.change.declared_name == "joined" assert joined[0].body == "moving joined #api" and not joined[0].role.response_policy(joined[0]).starts_turn with comms.bus.opened_activity() as clocks: assert "moving" not in comms.views.last_sent_timestamps(clocks.sent.items(), registry=comms.registry.snapshot()) @@ -501,7 +501,7 @@ def test_membership_notices_follow_exact_membership_and_do_not_wake(tmp_path): assert len(list(comms.views.history_page(CommandTarget.named("#api")).messages)) == 2 comms.channels.update_tags("moving", remove=frozenset({"ui"})) history = list(wire(tmp_path).views.history_page(CommandTarget.named("#api")).messages) - assert [message.role.change.value for message in history] == ["joined", "left"] + assert [message.role.change.declared_name for message in history] == ["joined", "left"] assert all(not message.role.response_policy(message).starts_turn for message in history) assert not comms.views.viewer_snapshot(str(tmp_path)).participants("#api") comms.agents.begin_turn("peer", "active-turn") From 2a1dd10f828358954b9e646d51fb4db93d5ed91c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:19:50 -0400 Subject: [PATCH 07/17] Share disclosure behavior between messages and context segments Public instruction disclosure retains credential filtering rather than withholding all text. NativeMessages keeps its span-to-original-message lookup override. Wire decoders retain distinct tag, retry, diagnostic and response-shape rules. The eight existing NoExactCopies groups already passed before this change. --- src/agent_comms/annotation_disclosure.py | 26 ++++++++++++++++++++++++ src/agent_comms/pi_payloads.py | 15 +++----------- src/agent_comms/turn_context.py | 25 ++++++----------------- 3 files changed, 35 insertions(+), 31 deletions(-) create mode 100644 src/agent_comms/annotation_disclosure.py diff --git a/src/agent_comms/annotation_disclosure.py b/src/agent_comms/annotation_disclosure.py new file mode 100644 index 0000000000..2122111ebe --- /dev/null +++ b/src/agent_comms/annotation_disclosure.py @@ -0,0 +1,26 @@ +"""Disclosure capabilities shared by native messages and context segments.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .turn_context import ContextSpan + + +class WithheldAnnotations: + """Source text cannot be disclosed to an annotation provider.""" + + def disclosure_for(self, span: ContextSpan): + from .working_memory_disclosure import WithheldDisclosure + + return WithheldDisclosure() + + +class PublicInstructionAnnotations: + """Instruction text uses the existing credential-filtering disclosure policy.""" + + def disclosure_for(self, span: ContextSpan): + from .working_memory_disclosure import PublicInstructionDisclosure + + return PublicInstructionDisclosure() diff --git a/src/agent_comms/pi_payloads.py b/src/agent_comms/pi_payloads.py index 06b014c41d..1d926a3081 100644 --- a/src/agent_comms/pi_payloads.py +++ b/src/agent_comms/pi_payloads.py @@ -23,6 +23,7 @@ from pydantic import JsonValue +from .annotation_disclosure import PublicInstructionAnnotations, WithheldAnnotations from .declared_family import DeclaredFamily, UnknownFamilyMember from .field_codec import ( ExternalField, @@ -747,12 +748,7 @@ class UnknownDiagnostic(UnknownPayload, PiDiagnostic): @dataclass(frozen=True) -class PiMessage(ExternalNull, OpenPayload, affix="Message"): - def annotation_disclosure(self): - from .working_memory_disclosure import WithheldDisclosure - - return WithheldDisclosure() - +class PiMessage(WithheldAnnotations, ExternalNull, OpenPayload, affix="Message"): @property def parts(self) -> tuple[PiContent, ...]: return (TextContent(self.content),) if isinstance(self.content, str) else self.content or () @@ -839,12 +835,7 @@ class AbsentMessage(ExternalNullMember, PiMessage): @dataclass(frozen=True) -class AssistantMessage(PiMessage): - def annotation_disclosure(self): - from .working_memory_disclosure import PublicInstructionDisclosure - - return PublicInstructionDisclosure() - +class AssistantMessage(PublicInstructionAnnotations, PiMessage): # Pi's assistant record always carries an array, including failed terminals. content: Annotated[tuple[PiContent, ...], WireRequired()] = () # Original Pi completion metadata. These are observations, not registry diff --git a/src/agent_comms/turn_context.py b/src/agent_comms/turn_context.py index 9097705679..d8afc93af9 100644 --- a/src/agent_comms/turn_context.py +++ b/src/agent_comms/turn_context.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Self, Annotated +from .annotation_disclosure import PublicInstructionAnnotations, WithheldAnnotations from .cli_result import CliResult from .declared_family import DeclaredFamily from .field_codec import ( @@ -638,7 +639,7 @@ def inherited(self, count: int) -> tuple[SegmentManifest, ...]: @dataclass(frozen=True, kw_only=True) class ContextSegment( - DeclaredFamily, affix="Segment", discovery_package="agent_comms.context_segments" + WithheldAnnotations, DeclaredFamily, affix="Segment", discovery_package="agent_comms.context_segments" ): provenance: tuple[Provenance, ...] @@ -697,11 +698,6 @@ def public_spans(self) -> tuple[ContextSpan, ...]: for source in self.source_ranges() for sentence in source.sentences(text)) - def disclosure_for(self, span: ContextSpan): - from .working_memory_disclosure import WithheldDisclosure - - return WithheldDisclosure() - def render_into(self, prompt_parts, provider): prompt_parts.append(self.text()) @@ -848,17 +844,8 @@ def contributor_manifests(self) -> tuple[SegmentManifest, ...]: return self.contributors -class PublicInstructionSegment: - """Instruction-bearing context discloses its wording as public instruction.""" - - def disclosure_for(self, span: ContextSpan): - from .working_memory_disclosure import PublicInstructionDisclosure - - return PublicInstructionDisclosure() - - @dataclass(frozen=True, kw_only=True) -class SystemLayerSegment(PublicInstructionSegment, MeasuredNativeSegment): +class SystemLayerSegment(PublicInstructionAnnotations, MeasuredNativeSegment): content: str tokens: int sha256: str @@ -951,7 +938,7 @@ def disclosure_for(self, span: ContextSpan): for source, message in zip(self.source_ranges(), self.messages, strict=True): if source.contains(span.coordinates): - return PiMessage.from_wire(message).annotation_disclosure() + return PiMessage.from_wire(message).disclosure_for(span) raise ValueError("Annotation names no original native message") def render_into(self, prompt_parts, provider): @@ -983,7 +970,7 @@ def record_manifest( @dataclass(frozen=True, kw_only=True) -class CompactionSummarySegment(PublicInstructionSegment, NativeMessages, MeasuredNativeSegment): +class CompactionSummarySegment(PublicInstructionAnnotations, NativeMessages, MeasuredNativeSegment): pass @@ -1017,7 +1004,7 @@ def render_into(self, prompt_parts, provider): @dataclass(frozen=True, kw_only=True) -class InstructionSegment(PublicInstructionSegment, ContextSegment): +class InstructionSegment(PublicInstructionAnnotations, ContextSegment): instruction: InstructionFile @classmethod From eb279e75415907a7e3fef1088b53546f8c5b27f2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:09:59 -0400 Subject: [PATCH 08/17] Keep coordinator owners on lookup and generation, delete rename transaction --- src/agent_comms/acp_extension.py | 2 +- src/agent_comms/attempt_recovery.py | 4 +- src/agent_comms/attempt_start.py | 15 +-- src/agent_comms/cohort_foreground.py | 2 - src/agent_comms/cohort_schema.py | 15 +-- src/agent_comms/coordination_cohort.py | 5 +- src/agent_comms/coordination_response.py | 22 ++-- src/agent_comms/coordination_snapshot.py | 4 +- .../coordination_tables/attempts.py | 6 +- .../coordination_tables/executions.py | 2 - .../coordination_tables/participants.py | 20 +--- src/agent_comms/cursor_owner.py | 2 +- src/agent_comms/execution_store.py | 13 +-- src/agent_comms/external_transcript.py | 2 +- src/agent_comms/historical_native_inputs.py | 2 - src/agent_comms/native_input_owner.py | 2 +- src/agent_comms/native_input_record.py | 1 - src/agent_comms/native_prompt_binding.py | 4 +- src/agent_comms/native_runtime_input.py | 4 +- .../optional_awareness_projection.py | 20 +--- src/agent_comms/owner_fence.py | 8 +- src/agent_comms/participant_store.py | 106 +----------------- src/agent_comms/private_nk_entrypoint.py | 2 +- src/agent_comms/private_registry_guard.py | 105 +---------------- src/agent_comms/private_send_claim.py | 2 - src/agent_comms/private_send_stage.py | 1 - src/agent_comms/proven_source_coverage.py | 2 +- src/agent_comms/publisher.py | 2 +- src/agent_comms/response_conversation.py | 6 +- src/agent_comms/selected_claim.py | 8 +- src/agent_comms/selected_participant.py | 5 +- src/agent_comms/selected_tool_broker.py | 1 - src/agent_comms/selected_triage.py | 2 +- src/agent_comms/selected_turn.py | 4 +- src/agent_comms/thread_identity.py | 2 +- src/agent_comms/thread_management.py | 4 +- 36 files changed, 74 insertions(+), 333 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index 34fba48c9a..f1c4a57617 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -370,7 +370,7 @@ def validate_scope(self, scope: CursorScope) -> None: cursor = self.cursor if ( cursor.wire_root_id != scope.wire_root_id - or cursor.owner_thread != scope.claim_scope.incarnation.name + or cursor.recipient_lookup != scope.claim_scope.incarnation.lookup or cursor.owner_claimed_generation != scope.claimed_generation or not 0 <= cursor.injected_seq <= cursor.covered_seq ): diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index f1a6a21e16..df46c3cd1a 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -93,7 +93,7 @@ def observe_native_release( if attempt is None or not snapshot.is_current: raise RecoveryBlocked("native recovery requires the current attempted execution") try: - release = comms.owners.releases.get(attempt.owner_thread) + release = comms.owners.releases.get(registry.by_lookup[attempt.owner_lookup].name) except (OSError, RelationViolationError, MalformedRecord) as error: raise RecoveryBlocked("native owner release acknowledgement is invalid") from error try: @@ -203,7 +203,7 @@ def settle(self, recovery_cls, store: Coordination, execution_id: str, owner) -> self.require_native_exit() PublicationAppendDispatches.resolve_retained( store, self.boundary.bus, self.boundary, - owner_lookup=owner.incarnation.lookup, owner_thread=owner.name, + owner_lookup=owner.incarnation.lookup, execution_id=execution_id, ) pointer = CurrentExecutions.require(store.session._connection, owner_lookup=owner.incarnation.lookup) diff --git a/src/agent_comms/attempt_start.py b/src/agent_comms/attempt_start.py index bd134bbc85..964f2fe8c2 100644 --- a/src/agent_comms/attempt_start.py +++ b/src/agent_comms/attempt_start.py @@ -27,7 +27,7 @@ class AttemptStart: execution_id: str attempt_ordinal: int - owner_thread: str + owner_lookup: str owner_generation: int token: str expected_execution_revision: int @@ -41,7 +41,7 @@ def fence(self, revision: int) -> OwnerFence: return OwnerFence( self.execution_id, self.attempt_ordinal, - self.owner_thread, + self.owner_lookup, self.owner_generation, revision, self.token, @@ -52,13 +52,11 @@ def replayed_fence(self, snapshot: RecoverySnapshot, old: AttemptRecord) -> Owne if ( old.attempt_ordinal, old.owner_lookup, - old.owner_thread, old.owner_generation, old.created_at_ms >= snapshot.execution.created_at_ms, ) != ( self.attempt_ordinal, - snapshot.execution.owner_lookup, - self.owner_thread, + self.owner_lookup, self.owner_generation, True, ): @@ -69,10 +67,10 @@ def validate(self, snapshot: RecoverySnapshot, participant: ParticipantSnapshot) execution = snapshot.execution ordinal = execution.lifecycle.next_attempt_ordinal if not participant.committed or ( - participant.owner_identity.owner_thread, + participant.owner_identity.owner_lookup, participant.owner_identity.generation, - ) != (self.owner_thread, self.owner_generation): - raise StaleFence("owner generation or thread changed") + ) != (self.owner_lookup, self.owner_generation): + raise StaleFence("owner lookup or generation changed") if (execution.revision, participant.pointer.pointer_revision) != ( self.expected_execution_revision, self.expected_pointer_revision, @@ -99,7 +97,6 @@ def activate(self, session, snapshot: RecoverySnapshot) -> None: execution_id=self.execution_id, attempt_ordinal=self.attempt_ordinal, owner_lookup=execution.owner_lookup, - owner_thread=self.owner_thread, owner_generation=self.owner_generation, owner_token_digest=self.digest, lifecycle=PromptStartingAttempt(lease), diff --git a/src/agent_comms/cohort_foreground.py b/src/agent_comms/cohort_foreground.py index 691d1c4969..395d6d6654 100644 --- a/src/agent_comms/cohort_foreground.py +++ b/src/agent_comms/cohort_foreground.py @@ -13,7 +13,6 @@ from .coordination_cohort import accept_delivery_cohort from .errors import RelationViolationError from .message_bus import MessageBus -from .private_registry_guard import _require_no_private_owner_rename async def _accept_visible_deliveries( @@ -34,7 +33,6 @@ async def _accept_visible_deliveries( """ def accept(resource: Coordination) -> int: with NativeBoundary(bus) as boundary, bus.bus.reading() as source: - _require_no_private_owner_rename(bus.bus.path.parent) boundary.privacy.require_root(root_id, IdentityConflict("native namespace changed")) with resource.session.read(): CohortTable.require_installed(resource.session._connection) diff --git a/src/agent_comms/cohort_schema.py b/src/agent_comms/cohort_schema.py index e4954330f9..a9cb4154be 100644 --- a/src/agent_comms/cohort_schema.py +++ b/src/agent_comms/cohort_schema.py @@ -299,23 +299,18 @@ class AwarenessClaimGenerations( ON r.wire_root_id=m.wire_root_id AND r.wire_seq=m.wire_seq JOIN owner_generations g ON g.owner_lookup=m.recipient_lookup WHERE m.claim_id=NEW.claim_id AND m.wire_root_id=NEW.wire_root_id AND m.wire_seq=NEW.wire_seq - AND m.recipient_lookup=NEW.recipient_lookup AND c.recipient=NEW.canonical_thread - AND c.message_id=r.message_id AND r.sealed=0 AND g.owner_thread=NEW.canonical_thread + AND m.recipient_lookup=NEW.recipient_lookup AND c.recipient_lookup=NEW.recipient_lookup + AND c.message_id=r.message_id AND r.sealed=0 AND g.generation=NEW.owner_generation))""" claim_id: Annotated[str, Column(primary_key=True)] wire_root_id: str wire_seq: Annotated[int, Column(check="wire_seq > 0")] owner_lookup: Annotated[str, WireName("recipient_lookup"), Column(record_key=True)] - owner_thread: Annotated[ - str, - WireName("canonical_thread"), - Column(record_key=True, check="length(canonical_thread) BETWEEN 1 AND 256"), - ] owner_generation: Annotated[int, Column(check="owner_generation > 0")] def current(self, expected: OwnerGenerations) -> bool: - """Old generations and pre-rename names are historical, never current context.""" + """Old owner generations are historical; renaming preserves their identity.""" from .wake_candidate_index import ProjectionUnavailableError if self.owner_lookup != expected.owner_lookup or self.owner_generation > expected.generation: @@ -329,7 +324,7 @@ def require_selection(self, assignment: WakeAssignment, acknowledgement: ClaimBa expected = type(self)( claim_id=assignment.assignment_id, wire_root_id=acknowledgement.wire_root_id, wire_seq=assignment.wire_seq, owner_lookup=assignment.recipient_lookup, - owner_thread=assignment.recipient, owner_generation=self.owner_generation, + owner_generation=self.owner_generation, ) if self != expected: raise ProjectionUnavailableError("selected source generation provenance changed") @@ -342,7 +337,7 @@ def require_obligation(self, execution, link, assignment, obligation) -> None: if execution.origin is not ExecutionOrigin.WIRE: raise ProjectionUnavailableError("open obligation has no wire origin") original_owner = OwnerGenerations( - owner_lookup=execution.owner_lookup, owner_thread=execution.owner_thread, + owner_lookup=execution.owner_lookup, generation=self.owner_generation, ) original_link = type(link)( diff --git a/src/agent_comms/coordination_cohort.py b/src/agent_comms/coordination_cohort.py index bb3ea50932..c5d05dfb01 100644 --- a/src/agent_comms/coordination_cohort.py +++ b/src/agent_comms/coordination_cohort.py @@ -164,14 +164,13 @@ def _record_optional_owner_generations( assert_optional_awareness_schema(db) for assignment in assignments: current = OwnerGenerations.one(db, owner_lookup=assignment.recipient_lookup) - if current is None or current.owner_thread != assignment.recipient: - raise IdentityConflict("optional claim generation has no current canonical owner") + if current is None: + raise IdentityConflict("optional claim generation has no current owner") AwarenessClaimGenerations( claim_id=assignment.assignment_id, wire_root_id=wire_root_id, wire_seq=wire_seq, owner_lookup=assignment.recipient_lookup, - owner_thread=assignment.recipient, owner_generation=current.generation, ).insert(db) except (sqlite3.Error, SchemaVersionError, IdentityConflict, MalformedRecord) as error: diff --git a/src/agent_comms/coordination_response.py b/src/agent_comms/coordination_response.py index 2da5b96167..b1123b11e0 100644 --- a/src/agent_comms/coordination_response.py +++ b/src/agent_comms/coordination_response.py @@ -105,7 +105,7 @@ def matches_attempt( @classmethod def resolve_retained( cls, store: Coordination, bus: MessageBus, boundary, *, - owner_lookup: str, owner_thread: str, execution_id: str | None = None, + owner_lookup: str, execution_id: str | None = None, ) -> tuple[RecoverySnapshot, ...]: """Finish only frozen responses whose exact keyed bus rows already exist. @@ -131,7 +131,7 @@ def resolve_retained( if execution_id is not None and current_id != execution_id: continue snapshot = store.get(current_id) - if snapshot.execution.owner_thread != owner_thread: + if snapshot.execution.owner_lookup != owner_lookup: raise StaleFence("retained response belongs to another owner") for frozen in snapshot.publication_intents: if ResponseObligation.response_record( @@ -142,7 +142,7 @@ def resolve_retained( snapshot = store.get(current_id) _require_cohort_assignments(store, bus, snapshot, boundary.privacy) intent, obligation = snapshot.require_publishing_intent( - frozen.exact_target + frozen.exact_target, boundary.registry ) attempt = snapshot.require_current_attempt() dispatch = cls.one( @@ -231,12 +231,11 @@ def recipient_lookup(self) -> str: return self.thread.incarnation.lookup def require_live(self, snapshot: RegistrySnapshot, fence: OwnerFence) -> None: - # The registry resolves a renamed owner through its alias; the reply's - # sender is the current canonical name, which must still be the fenced one. + # The registry resolves a renamed owner; its lookup keeps the fence valid. actual = self._snapshot_owner( snapshot, "response owner turn stopped or changed before publication", self.check_type, ) - if actual.name != fence.owner_thread: + if actual.incarnation.lookup != fence.owner_lookup: raise StaleFence("response turn belongs to a different fenced owner") self.require_active_turn() @@ -264,7 +263,6 @@ def resolve_retained( ) return PublicationAppendDispatches.resolve_retained( store, bus, boundary, owner_lookup=self.recipient_lookup, - owner_thread=self.thread.name, ) def _publish_owned( @@ -430,7 +428,7 @@ def prepare_fenced_response( if existing is not None: snapshot.require_existing_preparation( Message( - execution.owner_thread, + registry_snapshot.by_lookup[execution.owner_lookup].name, target, payload, message_type, @@ -442,7 +440,7 @@ def prepare_fenced_response( return AlreadyApplied(existing) obligation = snapshot.require_preparation(target) candidate = Message( - execution.owner_thread, + registry_snapshot.by_lookup[execution.owner_lookup].name, target, payload, message_type, @@ -523,7 +521,7 @@ def _settle_fenced_response( if terminal is not None: return terminal snapshot = _require_final_owner(store, bus, fence, boundary.privacy, owner_witness, exact_target) - snapshot.require_publishing_intent(exact_target) + snapshot.require_publishing_intent(exact_target, registry_snapshot) prior = PublicationAppendDispatches.one(db, execution_id=fence.execution_id, exact_target=exact_target) if prior is None: PublicationAppendDispatches( @@ -543,7 +541,7 @@ def _settle_fenced_response( if terminal is not None: return terminal snapshot = _require_final_owner(store, bus, fence, boundary.privacy, owner_witness, exact_target) - intent, obligation = snapshot.require_publishing_intent(exact_target) + intent, obligation = snapshot.require_publishing_intent(exact_target, registry_snapshot) dispatch = PublicationAppendDispatches.one(db, execution_id=fence.execution_id, exact_target=exact_target) if dispatch is None or not dispatch.matches(wire_root_id, fence, exact_target): raise PublicationUncertain("no matching durable append dispatch") @@ -555,7 +553,7 @@ def _settle_fenced_response( # only authorized first append while its final fence is locked. from .response_conversation import ResponseConversation - conversation = ResponseConversation.capture(bus, snapshot, obligation, boundary.privacy) + conversation = ResponseConversation.capture(bus, snapshot, obligation, boundary.privacy, registry_snapshot) matched = bus.publisher._publish_keyed_response_unlocked( intent, conversation=conversation, registry_snapshot=registry_snapshot, activity=boundary.activity ) diff --git a/src/agent_comms/coordination_snapshot.py b/src/agent_comms/coordination_snapshot.py index a9a8ca174e..3184d6b267 100644 --- a/src/agent_comms/coordination_snapshot.py +++ b/src/agent_comms/coordination_snapshot.py @@ -97,13 +97,13 @@ def require_existing_preparation(self, candidate) -> None: if intent is None or not intent.matches_request(self.execution.execution_id, candidate): raise IdentityConflict("prepared response envelope conflicts") - def require_publishing_intent(self, exact_target: str) -> tuple[PublicationIntents, ResponseObligation]: + def require_publishing_intent(self, exact_target: str, registry) -> tuple[PublicationIntents, ResponseObligation]: if len(self.publication_intents) != len(self.require_wire_responses()): raise IdentityConflict("all original response bodies must be frozen before first append") obligation = self.require_wire_response(exact_target) obligation.lifecycle.require_publishing() intent = ResponseObligation.response_record(self.publication_intents, exact_target) - if intent is None or intent.message.sender != self.execution.owner_thread: + if intent is None or registry.require(intent.message.sender).incarnation.lookup != self.execution.owner_lookup: raise IdentityConflict("no frozen publishing intent for current owner") return intent, obligation diff --git a/src/agent_comms/coordination_tables/attempts.py b/src/agent_comms/coordination_tables/attempts.py index b2830e39a5..4facdc4b30 100644 --- a/src/agent_comms/coordination_tables/attempts.py +++ b/src/agent_comms/coordination_tables/attempts.py @@ -112,7 +112,7 @@ class AttemptRecord( AND NEW.attempt_ordinal = 1 + coalesce((SELECT max(a.attempt_ordinal) FROM attempts a WHERE a.execution_id = e.execution_id), 0))) OR (NOT EXISTS (SELECT 1 FROM owner_generations g WHERE g.owner_lookup = NEW.owner_lookup - AND g.owner_thread = NEW.owner_thread AND g.generation = NEW.owner_generation)) + AND g.generation = NEW.owner_generation)) OR ((json_extract(NEW.lifecycle, '$.kind')) != 'prompt_starting' OR NEW.revision != 1 OR (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.backend_done') END) != 0 @@ -169,7 +169,7 @@ def authority(self) -> AttemptAuthority: return AttemptAuthority( self.execution_id, self.attempt_ordinal, - self.owner_thread, + self.owner_lookup, self.owner_generation, self.owner_token_digest, ) @@ -177,7 +177,6 @@ def authority(self) -> AttemptAuthority: execution_id: Annotated[str, SnapshotExclude(), FrozenColumn(primary_key=True)] attempt_ordinal: Annotated[int, FrozenColumn(primary_key=True, check="attempt_ordinal > 0")] owner_lookup: Annotated[str, FrozenColumn()] - owner_thread: Annotated[str, FrozenColumn(check="length(owner_thread) BETWEEN 1 AND 256")] owner_generation: Annotated[int, FrozenColumn(check="owner_generation > 0")] owner_token_digest: Annotated[ str, @@ -215,7 +214,6 @@ def __post_init__(self) -> None: validate_execution_id(self.execution_id) for field, value in ( ("owner_lookup", self.owner_lookup), - ("owner_thread", self.owner_thread), ("owner_token_digest", self.owner_token_digest), ): require_nonempty(value, field) diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index 6b51f79a58..1c68c569db 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -252,7 +252,6 @@ def retry_authorized(self, replay: ReplayState, obligations) -> bool: ] origin: Annotated[ExecutionOrigin, FrozenColumn()] lifecycle: Annotated[ExecutionState, SnapshotExclude()] - owner_thread: Annotated[str, FrozenColumn(check="length(owner_thread) BETWEEN 1 AND 256")] owner_lookup: Annotated[str, FrozenColumn()] revision: Annotated[int, Column(check="revision > 0")] max_attempts: Annotated[int, FrozenColumn(check="max_attempts > 0")] @@ -276,7 +275,6 @@ def __post_init__(self) -> None: validate_execution_id(self.execution_id) object.__setattr__(self, "origin", ExecutionOrigin(self.origin)) for field, value in ( - ("owner_thread", self.owner_thread), ("owner_lookup", self.owner_lookup), ): require_nonempty(value, field) diff --git a/src/agent_comms/coordination_tables/participants.py b/src/agent_comms/coordination_tables/participants.py index cf8ebe55a6..8c4df9ac07 100644 --- a/src/agent_comms/coordination_tables/participants.py +++ b/src/agent_comms/coordination_tables/participants.py @@ -15,7 +15,6 @@ ForeignKey, FrozenColumn, GuardedColumns, - ImmutableRows, MonotoneColumns, NoDelete, NonDecreasing, @@ -26,13 +25,12 @@ class OwnerRecord: """Stored owner components produce one coordinator identity, never live permission.""" owner_lookup: str - owner_thread: str owner_generation: int @property def owner_identity(self) -> OwnerGenerations: return OwnerGenerations( - owner_lookup=self.owner_lookup, owner_thread=self.owner_thread, + owner_lookup=self.owner_lookup, generation=self.owner_generation, ) @@ -53,19 +51,8 @@ class Participants(MonotoneColumns, GuardedColumns, NoDelete, CoordinatorRow): check="\n length(participant_lookup) BETWEEN 1 AND 256\n ", ), ] - display_name: Annotated[str, Column(check="length(display_name) BETWEEN 1 AND 256")] committed: bool -@dataclass(frozen=True, kw_only=True) -class ParticipantAliases(ImmutableRows, CoordinatorRow): - alias: Annotated[str, Column(primary_key=True, check="length(alias) BETWEEN 1 AND 256")] - participant_lookup: str - renamed_at_ms: Annotated[int, Column(check="renamed_at_ms >= 0")] - - @classmethod - def references(cls): - return (ForeignKey(("participant_lookup",), Participants, ("participant_lookup",)),) - @dataclass(frozen=True, kw_only=True) class OwnerGenerations( CounterAdvance, NoDelete, ParticipantIdentity, CoordinatorRow @@ -74,13 +61,12 @@ class OwnerGenerations( counter_column = 'generation' owner_lookup: Annotated[str, FrozenColumn(primary_key=True)] - owner_thread: Annotated[str, Column(check="length(owner_thread) BETWEEN 1 AND 256")] generation: Annotated[int, Column(check="generation > 0")] def require_recorded_owner(self, thread): - """Join original coordinator naming/birth; grant no live ownership.""" + """Join the recorded incarnation; a rename does not change ownership.""" expected = type(self)(owner_lookup=thread.incarnation.lookup, - owner_thread=thread.name, generation=self.generation) + generation=self.generation) if self != expected: raise IdentityConflict("Native input belongs to another recorded owner") diff --git a/src/agent_comms/cursor_owner.py b/src/agent_comms/cursor_owner.py index 0773e97eaf..8123aa581d 100644 --- a/src/agent_comms/cursor_owner.py +++ b/src/agent_comms/cursor_owner.py @@ -59,7 +59,7 @@ def _read_owned(cls, bus, root_id, owner_name, source_reads, store) -> CursorSta person = store.participants.get( captured.thread.incarnation.require_recorded().lookup ) - if not person.committed or person.owner_identity.owner_thread != owner_name: + if not person.committed: raise StaleFence("Current cursor recipient is not committed") owner = cls(thread=captured.thread, claimed_generation=captured.claimed_generation, wire_root_id=root_id, generation=person.owner_identity.generation) diff --git a/src/agent_comms/execution_store.py b/src/agent_comms/execution_store.py index 5039a23f9b..eefe61b42f 100644 --- a/src/agent_comms/execution_store.py +++ b/src/agent_comms/execution_store.py @@ -39,21 +39,19 @@ def create( execution_id: str, origin: ExecutionOrigin, owner_lookup: str, - owner_thread: str, max_attempts: int, *, sources: tuple[SelectedSource, ...] = (), ) -> Applied[RecoverySnapshot] | AlreadyApplied[RecoverySnapshot]: with self.session.writing() as db: assignments = tuple(source.assignment for source in sources) - return self._create(db, execution_id, origin, owner_lookup, owner_thread, max_attempts, sources, assignments) + return self._create(db, execution_id, origin, owner_lookup, max_attempts, sources, assignments) def create_after_triage( self, execution_id: str, origin: ExecutionOrigin, owner_lookup: str, - owner_thread: str, max_attempts: int, *, sources: tuple[SelectedSource, ...], @@ -64,9 +62,9 @@ def create_after_triage( assignments = tuple(source.assignment for source in sources) if assignments != settled: raise StaleRevision("original claims changed after proved native settlement") - return self._create(db, execution_id, origin, owner_lookup, owner_thread, max_attempts, sources, assignments) + return self._create(db, execution_id, origin, owner_lookup, max_attempts, sources, assignments) - def _create(self, db, execution_id, origin, owner_lookup, owner_thread, + def _create(self, db, execution_id, origin, owner_lookup, max_attempts, sources, assignments): """The single creation algorithm, under its caller's owning transaction.""" origin = ExecutionOrigin(origin) @@ -85,14 +83,12 @@ def _create(self, db, execution_id, origin, owner_lookup, owner_thread, if ( e.origin, e.owner_lookup, - e.owner_thread, e.max_attempts, tuple(sorted(row.exact_target for row in snapshot.obligations)), tuple(link.assignment_id for link in snapshot.links), ) != ( origin, owner_lookup, - owner_thread, max_attempts, tuple(sorted({source.delivery.message.exact_reply_target for source in sources})), assignment_ids, @@ -100,7 +96,7 @@ def _create(self, db, execution_id, origin, owner_lookup, owner_thread, raise IdentityConflict("execution identity conflicts") return AlreadyApplied(snapshot) participant = self.participants.get(owner_lookup) - if not participant.committed or participant.owner_identity.owner_thread != owner_thread: + if not participant.committed: raise IdentityConflict("execution requires committed current owner") if not isinstance(max_attempts, int) or max_attempts <= 0: raise ValueError("max_attempts must be positive") @@ -111,7 +107,6 @@ def _create(self, db, execution_id, origin, owner_lookup, owner_thread, execution_id=execution_id, origin=origin, lifecycle=QueuedExecution(), - owner_thread=owner_thread, owner_lookup=owner_lookup, revision=1, max_attempts=max_attempts, diff --git a/src/agent_comms/external_transcript.py b/src/agent_comms/external_transcript.py index fba350ed2d..30938b553e 100644 --- a/src/agent_comms/external_transcript.py +++ b/src/agent_comms/external_transcript.py @@ -278,7 +278,7 @@ def publication(cls, root: Path, input_id: str): yield DraftPublication() return from .registration import Registration - thread = Registration(RegistryFile.path_in(root)).require(original.owner_thread) + thread = Registration(RegistryFile.path_in(root)).snapshot().by_lookup[original.owner_lookup] original.owner_identity.require_recorded_owner(thread) transcript = ExternalTranscript(root, thread.incarnation) with transcript.writer_connection(check_same_thread=False) as connection: diff --git a/src/agent_comms/historical_native_inputs.py b/src/agent_comms/historical_native_inputs.py index 4db953591d..65039a7124 100644 --- a/src/agent_comms/historical_native_inputs.py +++ b/src/agent_comms/historical_native_inputs.py @@ -46,7 +46,6 @@ class HistoricalNativeInput(NativeInputRecord, DeclaredFamily, affix="Historical execution: NativeInputExecution input_id: str owner_lookup: str - owner_thread: str owner_generation: int context: NativeContextProof native_reference: NativeInputReference @@ -201,7 +200,6 @@ def read_historical_native_inputs( assignment_id=source.assignment_id, input_id=row.input_id, owner_lookup=row.owner_lookup, - owner_thread=row.owner_thread, owner_generation=row.owner_generation, context=recorded, native_reference=row.reference, diff --git a/src/agent_comms/native_input_owner.py b/src/agent_comms/native_input_owner.py index c9d66cf089..4e1d192a83 100644 --- a/src/agent_comms/native_input_owner.py +++ b/src/agent_comms/native_input_owner.py @@ -190,7 +190,7 @@ class ParticipantOwner: def coordinator_identity(self, lookup: str) -> OwnerGenerations: return OwnerGenerations( - owner_lookup=lookup, owner_thread=self.thread.name, generation=self.generation + owner_lookup=lookup, generation=self.generation ) def require(self, store: Coordination, lookup: str) -> None: diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index d730f6aa39..85993a98d1 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -408,7 +408,6 @@ class NativeInputRecord(OwnerRecord, ABC): input_id: str execution: NativeInputExecution owner_lookup: str - owner_thread: str owner_generation: int @property diff --git a/src/agent_comms/native_prompt_binding.py b/src/agent_comms/native_prompt_binding.py index 92ccba6312..2a94ad3f6a 100644 --- a/src/agent_comms/native_prompt_binding.py +++ b/src/agent_comms/native_prompt_binding.py @@ -64,7 +64,6 @@ class PromptBinding( execution_id: Annotated[NativeColumn, Column(storage=TextStorage, nullable=True)] attempt_ordinal: Annotated[NativeColumn, Column(storage=IntegerStorage, nullable=True)] owner_lookup: str - owner_thread: str owner_generation: Annotated[int, Column(check="owner_generation>0")] wire_root_id: str expected_prompt_digest: str @@ -85,7 +84,7 @@ def execution(self) -> NativeInputExecution: checks = ( "(stage='triage' AND execution_id IS NULL AND attempt_ordinal IS NULL) OR " "(stage='full' AND execution_id IS NOT NULL AND attempt_ordinal>0)", - "length(owner_lookup)=32 AND owner_thread<>'' AND length(wire_root_id)=32 " + "length(owner_lookup)=32 AND length(wire_root_id)=32 " "AND wire_root_id NOT GLOB '*[^0-9a-f]*' " "AND length(expected_prompt_digest)=64 AND expected_prompt_digest NOT GLOB '*[^0-9a-f]*'", ) @@ -164,7 +163,6 @@ def bind_expected_prompt( stage=type(stage.execution), **stage.execution.binding_fields(), owner_lookup=stage.recipient_lookup, - owner_thread=owner.name, owner_generation=generation, wire_root_id=wire_root_id, expected_prompt_digest=digest, diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index 33d696970e..43f679b4fe 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -259,7 +259,6 @@ def published_replies(self, db, user, owner_lookup): ] attempt_ordinal: Annotated[NativeColumn, FrozenColumn(storage=IntegerStorage, nullable=True)] owner_lookup: Annotated[str, FrozenColumn(references=(Participants, "participant_lookup"))] - owner_thread: Annotated[str, FrozenColumn()] owner_generation: Annotated[int, FrozenColumn(check="owner_generation>0")] owner_token_digest: Annotated[str, FrozenColumn(check="length(owner_token_digest)=64")] sent_owner_claimed_generation: Annotated[ @@ -362,7 +361,6 @@ def context_anchor(self) -> NativeColumn: recipient_lookup: Annotated[ str, FrozenColumn(primary_key=True, references=(Participants, "participant_lookup")) ] - owner_thread: Annotated[str, FrozenColumn(check="owner_thread<>''")] owner_generation: Annotated[int, FrozenColumn(primary_key=True, check="owner_generation>0")] owner_claimed_generation: Annotated[ int, @@ -374,7 +372,7 @@ def context_anchor(self) -> NativeColumn: def owner_identity(self): from .coordination_tables.participants import OwnerGenerations - return OwnerGenerations(owner_lookup=self.recipient_lookup, owner_thread=self.owner_thread, generation=self.owner_generation) + return OwnerGenerations(owner_lookup=self.recipient_lookup, generation=self.owner_generation) covered_seq: Annotated[int, Column(check="covered_seq>=0")] injected_seq: Annotated[ diff --git a/src/agent_comms/optional_awareness_projection.py b/src/agent_comms/optional_awareness_projection.py index 8889f2438e..b511e52d08 100644 --- a/src/agent_comms/optional_awareness_projection.py +++ b/src/agent_comms/optional_awareness_projection.py @@ -52,7 +52,7 @@ from .field_codec import FieldCodec from .message_reference import MessageReference from .native_input_owner import RegistryOwner -from .private_registry_guard import RegistryPrivacy, _require_no_private_owner_rename +from .private_registry_guard import RegistryPrivacy from .registration import Registration from .store_files import _store_lock from .threads import Thread @@ -65,7 +65,6 @@ class _ParticipantOwner(TypedRow): generation: int committed: bool - owner_thread: str @dataclass(frozen=True, slots=True) @@ -180,10 +179,7 @@ def _build( turn = owner.turn_record.read() if not turn.has_claim(self.expected_claimed_generation): raise ProjectionUnavailableError("selected owner turn changed") - if FrozenRecipient(assignment.recipient_lookup, assignment.recipient) != FrozenRecipient( - owner.incarnation.lookup, - owner.name, - ): + if assignment.recipient_lookup != owner.incarnation.lookup: raise ProjectionUnavailableError("selected recipient changed") if (assignment.wire_seq, assignment.message_id) != ( initial.message.seq, @@ -192,7 +188,6 @@ def _build( raise ProjectionUnavailableError("selected source changed") if not self.after_seq < initial.message.seq <= self.through_seq: raise ProjectionUnavailableError("selected source is outside the captured window") - _require_no_private_owner_rename(self.index.root) # Each selected row must now carry immutable same-transaction owner # generation provenance. An incomplete join omits the whole read. with _store_lock(self.registry.store.path, blocking=False): @@ -221,7 +216,6 @@ def _build( obligations = self._open_obligations(db, lookup) expected = OwnerGenerations( owner_lookup=lookup, - owner_thread=owner.name, generation=self.expected_participant_generation, ) selected = [row for row in decisions if row.generation.current(expected)] @@ -272,7 +266,6 @@ def _build( return result def _verify_live_inclusion(self, path: os.PathLike[str], lookup: str, owner: Thread) -> None: - _require_no_private_owner_rename(self.index.root) registry = self.registry with _store_lock(registry.store.path, blocking=False): snapshot = registry.store.read_snapshot_unlocked() @@ -285,15 +278,14 @@ def _verify_live_inclusion(self, path: os.PathLike[str], lookup: str, owner: Thr with CoordinationStore.observing(Path(path), StoreBusy()) as db: rows = _ParticipantOwner.read( db.execute( - "SELECT g.generation,p.committed,g.owner_thread FROM owner_generations g " + "SELECT g.generation,p.committed FROM owner_generations g " "JOIN participants p ON p.participant_lookup=g.owner_lookup " "WHERE g.owner_lookup=?", (lookup,), ) ) - if rows != [_ParticipantOwner(self.expected_participant_generation, True, owner.name)]: + if rows != [_ParticipantOwner(self.expected_participant_generation, True)]: raise ProjectionUnavailableError("current participant generation changed") - _require_no_private_owner_rename(self.index.root) def _verify_schema_and_owner( self, @@ -308,13 +300,13 @@ def _verify_schema_and_owner( assert_optional_awareness_schema(db) people = _ParticipantOwner.read( db.execute( - "SELECT p.committed,g.owner_thread,g.generation FROM participants p " + "SELECT p.committed,g.generation FROM participants p " "JOIN owner_generations g ON g.owner_lookup=p.participant_lookup " "WHERE p.participant_lookup=?", (assignment.recipient_lookup,), ) ) - if people != [_ParticipantOwner(self.expected_participant_generation, True, owner.name)]: + if people != [_ParticipantOwner(self.expected_participant_generation, True)]: raise ProjectionUnavailableError("selected participant owner changed") acknowledgement = _claim_record(db, initial, privacy) if not any(current == assignment for current in acknowledgement.assignments): diff --git a/src/agent_comms/owner_fence.py b/src/agent_comms/owner_fence.py index a02110621d..7a6fe40892 100644 --- a/src/agent_comms/owner_fence.py +++ b/src/agent_comms/owner_fence.py @@ -20,7 +20,7 @@ class AttemptAuthority: execution_id: str ordinal: int - owner_thread: str + owner_lookup: str owner_generation: int token_digest: str @@ -29,7 +29,7 @@ class AttemptAuthority: class OwnerFence: execution_id: str attempt_ordinal: int - owner_thread: str + owner_lookup: str owner_generation: int revision: int token: str @@ -37,13 +37,13 @@ class OwnerFence: @property def authority(self) -> AttemptAuthority: return AttemptAuthority( - self.execution_id, self.attempt_ordinal, self.owner_thread, + self.execution_id, self.attempt_ordinal, self.owner_lookup, self.owner_generation, _digest(self.token), ) def __post_init__(self) -> None: validate_execution_id(self.execution_id) - for field, value in (("owner_thread", self.owner_thread), ("token", self.token)): + for field, value in (("owner_lookup", self.owner_lookup), ("token", self.token)): require_nonempty(value, field) require_bounded(value, field, MAX_IDENTIFIER_CHARS) if min(self.attempt_ordinal, self.owner_generation, self.revision) <= 0: diff --git a/src/agent_comms/participant_store.py b/src/agent_comms/participant_store.py index 7439430969..341831f8d8 100644 --- a/src/agent_comms/participant_store.py +++ b/src/agent_comms/participant_store.py @@ -21,7 +21,6 @@ ) from agent_comms.coordination_tables.participants import ( OwnerGenerations, - ParticipantAliases, Participants, ) from agent_comms.field_codec import MalformedRecord @@ -37,9 +36,7 @@ class ParticipantSnapshot: """ lookup: str - display_name: str committed: bool - aliases: tuple[str, ...] owner_identity: OwnerGenerations pointer: CurrentExecutions @@ -50,27 +47,14 @@ def __init__(self, session: CoordinationSession) -> None: def get(self, lookup: str) -> ParticipantSnapshot: with self.session.read(): - db = self.session._connection person = Participants.one(self.session._connection, participant_lookup=lookup) generation = OwnerGenerations.one(self.session._connection, owner_lookup=lookup) pointer = CurrentExecutions.one(self.session._connection, owner_lookup=lookup) if person is None or generation is None or pointer is None: raise ParticipantNotRegistered("participant aggregate is not registered") - aliases = tuple( - row.alias - for row in ParticipantAliases.read( - db.execute( - f"SELECT * FROM {ParticipantAliases.declared_name} WHERE participant_lookup=? " - "ORDER BY renamed_at_ms, rowid", - (lookup,), - ) - ) - ) return ParticipantSnapshot( lookup, - person.display_name, person.committed, - aliases, generation, pointer, ) @@ -78,37 +62,22 @@ def get(self, lookup: str) -> ParticipantSnapshot: def register( self, lookup: str, - display_name: str, - owner_thread: str, *, - alias: str | None = None, committed: bool = False, ) -> Applied[ParticipantSnapshot] | AlreadyApplied[ParticipantSnapshot]: if type(committed) is not bool: raise ValueError("committed must be a boolean") - for value in (lookup, display_name, owner_thread, alias or lookup): - if not isinstance(value, str) or not 1 <= len(value) <= MAX_IDENTIFIER_CHARS: - raise MalformedRecord("participant identity must be bounded and nonempty") - primary = alias or lookup + if not isinstance(lookup, str) or not 1 <= len(lookup) <= MAX_IDENTIFIER_CHARS: + raise MalformedRecord("participant identity must be bounded and nonempty") with self.session.writing() as db: existing = Participants.one(self.session._connection, participant_lookup=lookup) if existing is not None: snapshot = self.get(lookup) - # Commitment, display name and generation owner may advance. - # The lookup and first accepted alias are the durable identity. - # No mutable registration-row bundle becomes a fingerprint. - if not snapshot.aliases or snapshot.aliases[0] != primary: - raise IdentityConflict("participant registration alias conflicts") return AlreadyApplied(snapshot) - if ParticipantAliases.one(self.session._connection, alias=primary) is not None: - raise IdentityConflict("alias already belongs to another participant") Participants( - participant_lookup=lookup, display_name=display_name, committed=committed - ).insert(db) - ParticipantAliases( - alias=primary, participant_lookup=lookup, renamed_at_ms=self.session.now() + participant_lookup=lookup, committed=committed ).insert(db) - OwnerGenerations(owner_lookup=lookup, owner_thread=owner_thread, generation=1).insert( + OwnerGenerations(owner_lookup=lookup, generation=1).insert( db ) CurrentExecutions( @@ -132,76 +101,12 @@ def commit( ) return Applied(self.get(lookup)) - def rename( - self, - lookup: str, - alias: str, - display_name: str, - *, - expected_generation: int, - ) -> Applied[ParticipantSnapshot] | AlreadyApplied[ParticipantSnapshot]: - for value in (alias, display_name): - if not isinstance(value, str) or not 1 <= len(value) <= MAX_IDENTIFIER_CHARS: - raise ValueError("alias and display name must be bounded") - with self.session.writing() as db: - person = self.get(lookup) - if person.owner_identity.generation != expected_generation: - raise StaleRevision("owner generation changed") - existing = ParticipantAliases.one(self.session._connection, alias=alias) - if existing is not None: - if existing.participant_lookup != lookup: - raise IdentityConflict("alias is already assigned") - return AlreadyApplied(person) - last_alias_ms = max( - row.renamed_at_ms - for row in ParticipantAliases.select( - db, where="participant_lookup=?", parameters=(lookup,) - ) - ) - ParticipantAliases( - alias=alias, - participant_lookup=lookup, - renamed_at_ms=self.session.now(last_alias_ms), - ).insert(db) - Participants.update( - db, where="participant_lookup=?", parameters=(lookup,), display_name=display_name - ) - return Applied(self.get(lookup)) - - def rename_owner( - self, - lookup: str, - owner_thread: str, - *, - expected_generation: int, - ) -> Applied[ParticipantSnapshot]: - return self._update_owner( - lookup, owner_thread, - expected_generation=expected_generation, new_generation=expected_generation, - ) - def advance_generation( self, lookup: str, - owner_thread: str, - *, - expected_generation: int, - ) -> Applied[ParticipantSnapshot]: - return self._update_owner( - lookup, owner_thread, - expected_generation=expected_generation, new_generation=expected_generation + 1, - ) - - def _update_owner( - self, - lookup: str, - owner_thread: str, *, expected_generation: int, - new_generation: int, ) -> Applied[ParticipantSnapshot]: - if not isinstance(owner_thread, str) or not 1 <= len(owner_thread) <= MAX_IDENTIFIER_CHARS: - raise ValueError("owner thread must be bounded and nonempty") with self.session.writing() as db: person = self.get(lookup) if not person.committed: @@ -212,7 +117,6 @@ def _update_owner( db, where="owner_lookup=? AND generation=?", parameters=(lookup, expected_generation), - owner_thread=owner_thread, - generation=new_generation, + generation=expected_generation + 1, ) return Applied(self.get(lookup)) diff --git a/src/agent_comms/private_nk_entrypoint.py b/src/agent_comms/private_nk_entrypoint.py index 6d4ca303fc..7cdcb410c8 100644 --- a/src/agent_comms/private_nk_entrypoint.py +++ b/src/agent_comms/private_nk_entrypoint.py @@ -82,7 +82,7 @@ def prepare_owner(self, thread: Thread, agent_bin: str, runtime: LaunchRuntime) Registration(RegistryFile.path_in(root)).prepare_native_root() with Coordination(str(CoordinationStore.path_in(root))) as store: store.participants.register( - thread.incarnation.require_recorded().lookup, thread.name, thread.name, + thread.incarnation.require_recorded().lookup, committed=True, ) return agent_bin diff --git a/src/agent_comms/private_registry_guard.py b/src/agent_comms/private_registry_guard.py index 151e20e6b2..b366ebbdf7 100644 --- a/src/agent_comms/private_registry_guard.py +++ b/src/agent_comms/private_registry_guard.py @@ -30,9 +30,7 @@ from .child_process import Platform from . import store_files -from .coordination_contracts import MAX_IDENTIFIER_CHARS from .coordination_database import CoordinationStore -from .coordination_errors import ParticipantNotRegistered from .coordinator import Coordination from .errors import RelationViolationError from .field_codec import FieldCodec, Id128Text, MalformedRecord, WireName @@ -121,14 +119,7 @@ def _decode(data: bytes, root_id: bytes) -> tuple[int, int, bytes]: class RegistryPrivacy(ABC): """What a registry root's protocol marker decides, selected by the registry store.""" - @abstractmethod - def rename( - self, threads: ThreadManagement, before: Thread, new_name: str - ) -> tuple[str, str]: - """Rename under this root's original registry and coordinator authority.""" - def rename_applied(self, root: Path) -> None: - """Public roots have no private owner intent to complete.""" @staticmethod def capture(root: Path) -> RegistryPrivacy: @@ -186,10 +177,6 @@ def coordinator_schema(self) -> type[TableSchema]: class PublicRegistry(RegistryPrivacy): - def rename( - self, threads: ThreadManagement, before: Thread, new_name: str - ) -> tuple[str, str]: - return threads.registry.rename(before.name, new_name) def coordinator_schema(self) -> type[TableSchema]: from .coordination_schema import CoordinatorTable @@ -248,61 +235,7 @@ def require_root(self, root_id: str, error: Exception) -> None: class PrivateRegistryGuard(DurableFile, WireWakeFile, RegistryPrivacy): - def rename( - self, threads: ThreadManagement, before: Thread, new_name: str - ) -> tuple[str, str]: - root, registry = threads.root, threads.registry - _require_no_private_owner_rename(root) - coordinator = CoordinationStore.path_in(root) - if not coordinator.exists() or before.name == new_name: - return registry.rename(before.name, new_name) - # Validate registry/SQL name refusals before renaming the private owner. - # Registry readers cannot select a different owner while both stores change. - registry.status(before.name).require_self_rename() - if registry.snapshot().taken(new_name, owner=before.name): - raise RelationViolationError(f"Thread name {new_name!r} is already in use.") - before.require_name(new_name) - if len(new_name) > MAX_IDENTIFIER_CHARS: - raise ValueError("Private coordinator owner name exceeds its bound.") - with Coordination(str(coordinator)) as store: - try: - person = store.participants.get(before.incarnation.require_recorded().lookup) - except ParticipantNotRegistered: - return registry.rename(before.name, new_name) - if not person.committed or person.owner_identity.owner_thread != before.name: - raise RelationViolationError( - "Private coordinator owner differs from the registered owner." - ) - generation = person.owner_identity.generation - PrivateOwnerRenameIntent( - lookup=person.lookup, - old=before.name, - new=new_name, - generation=generation, - wire_root_id=self.root_id.hex(), - ).write(root) - store.participants.rename_owner(person.lookup, new_name, expected_generation=generation) - try: - return registry.rename(before.name, new_name) - except BaseException as error: - # Registry persistence can fail after the SQL CAS. If its atomic snapshot still - # names the old owner, restore that name without revoking its active attempt. - uncertain = RelationViolationError( - "Private owner rename is uncertain; inspect both authorities." - ) - try: - if registry.require(before.name).name == before.name: - store.participants.rename_owner( - person.lookup, before.name, expected_generation=generation - ) - except BaseException as compensation: - # Ambiguous dual-store failure: the inspection needs both failures. - raise uncertain from compensation - raise uncertain from error - - def rename_applied(self, root: Path) -> None: - if PrivateOwnerRenameIntent.path_in(root).exists(): - PrivateOwnerRenameIntent.complete(root) + filename = ".registry-owner-guard" @@ -528,7 +461,7 @@ def register_restored(self, threads: Iterable[Thread]) -> None: with Coordination(str(CoordinationStore.path_in(self.registry_path.parent))) as store: for thread in threads: store.participants.register( - thread.incarnation.require_recorded().lookup, thread.name, thread.name, + thread.incarnation.require_recorded().lookup, committed=True) def create_pending(self) -> None: @@ -572,37 +505,3 @@ def commit_initial(self) -> None: self._write(fd, 1, _record(self.root_id, 1, _COMMITTED, first[2])) finally: os.close(fd) - - -@dataclass(frozen=True, kw_only=True) -class PrivateOwnerRenameIntent(DurableFile): - """Written before a private owner rename changes either store; while it exists, private - publication and selected execution stay blocked.""" - - filename = ".private-owner-rename.pending" - - lookup: str - old: str - new: str - generation: int - wire_root_id: Annotated[str, WireName("wireRootId")] - version: int = 1 - completed: bool = False - - def write(self, root: Path) -> None: - store_files.AtomicFile(self.path_in(root)).write_text(json.dumps(FieldCodec.encode(self), sort_keys=True)) - - @classmethod - def complete(cls, root: Path) -> None: - """A completed record proves a finished transition if the unlink is uncertain.""" - intent = cls.path_in(root) - replace(FieldCodec.decode(cls, json.loads(intent.read_text())), completed=True).write(root) - intent.unlink() - Platform.current().sync_directory(root) - - -def _require_no_private_owner_rename(root: Path) -> None: - """A crashed cross-store rename cannot publish or run a selected input.""" - intent = PrivateOwnerRenameIntent.path_in(root) - if intent.exists() or intent.is_symlink(): - raise RelationViolationError("Private owner rename is pending; inspect both authorities.") diff --git a/src/agent_comms/private_send_claim.py b/src/agent_comms/private_send_claim.py index c137121385..c83cd9507c 100644 --- a/src/agent_comms/private_send_claim.py +++ b/src/agent_comms/private_send_claim.py @@ -48,7 +48,6 @@ from .coordinated_runtime import SelectedExecution from .selected_participant import SelectedParticipant from .selected_session import SelectedSession -from .private_registry_guard import _require_no_private_owner_rename from .private_send_stage import NativeSendStage @@ -250,7 +249,6 @@ def _start_once(self, identity: NativeSessionIdentity) -> None: if not self._once.acquire(blocking=False): raise IdentityConflict("native send claim_scope cannot be reused") with self.selected.cursor_delivery.measurements.operation("claim_checks"): - _require_no_private_owner_rename(self.bus.bus.path.parent) self.owner.require_snapshot( registry, "recipient registry owner changed before native send" ) diff --git a/src/agent_comms/private_send_stage.py b/src/agent_comms/private_send_stage.py index 493b40672c..9af09dab86 100644 --- a/src/agent_comms/private_send_stage.py +++ b/src/agent_comms/private_send_stage.py @@ -102,7 +102,6 @@ def reserve(self, store: Coordination, owner: ParticipantOwner, token_digest: st stage=type(self.execution), **self.execution.binding_fields(), owner_lookup=self.recipient_lookup, - owner_thread=owner.thread.name, owner_generation=owner.generation, owner_token_digest=token_digest, ).insert(db) diff --git a/src/agent_comms/proven_source_coverage.py b/src/agent_comms/proven_source_coverage.py index f6c30d2763..2301929c5a 100644 --- a/src/agent_comms/proven_source_coverage.py +++ b/src/agent_comms/proven_source_coverage.py @@ -86,7 +86,7 @@ def require_reference(self, owner: CursorOwner, cursor: CurrentNativeCursor) -> def build(self, owner, covered_seq): return CurrentNativeCursor( wire_root_id=owner.wire_root_id, recipient_lookup=owner.lookup, - owner_thread=owner.thread.name, owner_generation=owner.generation, + owner_generation=owner.generation, owner_claimed_generation=owner.claimed_generation, covered_seq=covered_seq, **self.input_fields(), ) diff --git a/src/agent_comms/publisher.py b/src/agent_comms/publisher.py index 2f767e8c47..b8b49c2c80 100644 --- a/src/agent_comms/publisher.py +++ b/src/agent_comms/publisher.py @@ -217,7 +217,7 @@ def publish_ordinary(self, message: Message, *, origin: InitialOrigin, with Coordination(str(CoordinationStore.path_in(self.bus.path.parent))) as store: sender_thread.role.register_sender(store.participants, sender_lookup, sender) for thread, lookup in zip(selected, lookups, strict=True): - store.participants.register(lookup, thread.name, thread.name, committed=True) + store.participants.register(lookup, committed=True) def build(seq: int, db: sqlite3.Connection) -> PrivateRecord: source = BusSnapshot.from_connection(db) diff --git a/src/agent_comms/response_conversation.py b/src/agent_comms/response_conversation.py index d68bff572d..4aae6dfc46 100644 --- a/src/agent_comms/response_conversation.py +++ b/src/agent_comms/response_conversation.py @@ -51,7 +51,7 @@ def answer_references( return tuple(dict.fromkeys((*cls.source_references(snapshot, obligation), *following))) @classmethod - def capture(cls, bus, snapshot, obligation: ResponseObligation, privacy: RegistryPrivacy): + def capture(cls, bus, snapshot, obligation: ResponseObligation, privacy: RegistryPrivacy, registry): originals = cls.source_references(snapshot, obligation) sources = bus.bus.deliveries_for_references(originals) if len(sources) != len(originals): @@ -61,7 +61,7 @@ def capture(cls, bus, snapshot, obligation: ResponseObligation, privacy: Registr if any(source.message.exact_reply_target != obligation.exact_target for source in sources): raise IdentityConflict("Response obligation differs from its original routes") return cls( - FrozenRecipient(snapshot.execution.owner_lookup, snapshot.execution.owner_thread), + FrozenRecipient(snapshot.execution.owner_lookup, registry.by_lookup[snapshot.execution.owner_lookup].name), obligation, sources, privacy, @@ -81,7 +81,7 @@ def audience(self, message: Message): participants.extend(source.message.sender_role.executor( FrozenRecipient(audience.sender_lookup, audience.sender_name) )) - if self.responder not in audience.recipients: + if not any(member.recipient_lookup == self.responder.recipient_lookup for member in audience.recipients): raise IdentityConflict("Responder was not in original frozen audience") for member in participants: previous = members.setdefault(member.recipient_lookup, member) diff --git a/src/agent_comms/selected_claim.py b/src/agent_comms/selected_claim.py index 9c330c27e6..74aac46a09 100644 --- a/src/agent_comms/selected_claim.py +++ b/src/agent_comms/selected_claim.py @@ -31,7 +31,7 @@ ) from .errors import RelationViolationError from .native_input_owner import RegistryOwner -from .private_registry_guard import RegistryPrivacy, _require_no_private_owner_rename +from .private_registry_guard import RegistryPrivacy from .registry_document import RegistrySnapshot from .store_files import SharedLock from .threads import Thread @@ -82,7 +82,6 @@ def _verify_selected_wake_state( raise IdentityConflict("Wake assignment binding changed") expected_owner = OwnerGenerations( owner_lookup=claim_scope.recipient_lookup, - owner_thread=owner.name, generation=claim_scope.participant_generation, ) if not participant.committed or participant.owner_identity != expected_owner: @@ -96,11 +95,11 @@ def _verify_selected_wake_state( or attempt.attempt_ordinal != claim_scope.attempt_ordinal ): raise IdentityConflict("Wake selected attempt owner changed") - if snapshot.execution.owner_thread != owner.name: + if snapshot.execution.owner_lookup != expected_owner.owner_lookup: raise IdentityConflict("Wake execution owner changed") if not attempt.lifecycle.tool_claim_open: raise IdentityConflict("Wake execution is not the current selected attempt") - if assignment not in snapshot.assignments or assignment.recipient != owner.name: + if assignment not in snapshot.assignments or assignment.recipient_lookup != expected_owner.owner_lookup: raise IdentityConflict("Wake assignment is not a member of this owner's execution") @@ -139,7 +138,6 @@ def _selected_claim_boundary( except StaleFence as error: raise IdentityConflict("Selected wake owner stopped or changed") from error owner, generation = captured.thread, captured.claimed_generation - _require_no_private_owner_rename(comms.root) privacy = comms.registry.store.private_guard_unlocked() privacy.require_root( claim_scope.wire_root_id, IdentityConflict("Selected wake belongs to another namespace")) diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index a689fe7257..d197d1e361 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -34,7 +34,6 @@ from .participant_store import ParticipantSnapshot from .pi_events import TurnContextObserved from .presentation import MessageNotification, OwnerOutcomes, SentMessageOutcome -from .private_registry_guard import _require_no_private_owner_rename from .store_files import SharedLock from .publisher import ParticipantPublisher from .selected_source_batch import SelectedSource, SelectedSourceBatch @@ -90,7 +89,7 @@ async def fail_before_send(self, max_attempts: int) -> None: await self.require_current() snapshot = self.store.create( self.batch.execution_id, ExecutionOrigin.WIRE, - self.lookup, self.owner.thread.name, max_attempts, + self.lookup, max_attempts, sources=self.batch.sources, ).value self.store.fail_unstarted( @@ -103,7 +102,6 @@ async def prepare_attempt(self, snapshot: RecoverySnapshot) -> None: if snapshot.execution.lifecycle.retry: participant = self.store.participants.advance_generation( self.lookup, - self.owner.thread.name, expected_generation=self.identity.generation, ).value self.identity = ParticipantOwner( @@ -221,7 +219,6 @@ def prepare( ]: """Capture detached registry, participant and optional context in one worker.""" with comms.registry.store.locked(mode=SharedLock()): - _require_no_private_owner_rename(comms.root) privacy = comms.registry.store.private_guard_unlocked() privacy.require_root( root_id, IdentityConflict("Selected claim namespace changed")) diff --git a/src/agent_comms/selected_tool_broker.py b/src/agent_comms/selected_tool_broker.py index e8fc10b09b..d45c95b063 100644 --- a/src/agent_comms/selected_tool_broker.py +++ b/src/agent_comms/selected_tool_broker.py @@ -296,7 +296,6 @@ def _require_sent_full_input( input_id, FullNativeExecution(claim_scope.execution_id, claim_scope.attempt_ordinal), OwnerGenerations( owner_lookup=claim_scope.recipient_lookup, - owner_thread=owner_name, generation=claim_scope.participant_generation, ), ) diff --git a/src/agent_comms/selected_triage.py b/src/agent_comms/selected_triage.py index cc216f73fc..577c1965ea 100644 --- a/src/agent_comms/selected_triage.py +++ b/src/agent_comms/selected_triage.py @@ -113,7 +113,7 @@ async def continue_turn(self, participant, session, input_id, execution, settled await participant.require_current() created = participant.store.create_after_triage( participant.batch.execution_id, ExecutionOrigin.WIRE, - participant.lookup, participant.owner.thread.name, + participant.lookup, execution.max_attempts, sources=participant.batch.sources, settled=settled, ).value diff --git a/src/agent_comms/selected_turn.py b/src/agent_comms/selected_turn.py index e2b7cf0949..eead8c83bd 100644 --- a/src/agent_comms/selected_turn.py +++ b/src/agent_comms/selected_turn.py @@ -125,7 +125,7 @@ async def engage(cls, participant: SelectedParticipant, AttemptStart( execution_id, attempt_ordinal, - participant.owner.thread.name, + participant.lookup, participant.identity.generation, token, expected_execution_revision=snapshot.execution.revision, @@ -257,7 +257,7 @@ async def run(self, execution, session): await participant.require_current() created = participant.store.create( participant.batch.execution_id, ExecutionOrigin.WIRE, - participant.lookup, participant.owner.thread.name, + participant.lookup, execution.max_attempts, sources=participant.batch.sources, ).value diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index d6b96dfe14..69e94c875a 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -525,7 +525,7 @@ def initial_delivery(cls, message: Message, target: str) -> tuple[Message, list[ @classmethod def register_sender(cls, participants: ParticipantStore, lookup: str, name: str) -> None: - participants.register(lookup, name, name, committed=True) + participants.register(lookup, committed=True) @classmethod def require_executable(cls, refusal: Exception) -> None: diff --git a/src/agent_comms/thread_management.py b/src/agent_comms/thread_management.py index f455b4017f..bb8d7d082f 100644 --- a/src/agent_comms/thread_management.py +++ b/src/agent_comms/thread_management.py @@ -387,8 +387,7 @@ def _rename_thread( def _rename_thread_unlocked( self, name: str, new_name: str, *, title: ThreadText = NoThreadText() ) -> RenameThreadResult: - privacy = self._privacy() - previous, current = privacy.rename(self, self.registry.require(name), new_name) + previous, current = self.registry.rename(name, new_name) thread = self.registry.require(current) titled = replace(thread, auto_title_pending=False, title=title.applied(thread.title)) if titled != thread: @@ -400,7 +399,6 @@ def _rename_thread_unlocked( self.ledger.rename_thread(previous, current) with self.catalog.editing() as document: document.rename_thread(previous, current) - privacy.rename_applied(self.root) return RenameThreadResult(previous, current, changed) def heartbeat(self, name: str) -> None: From acda163fa5c6560fb9597daef63bb3629d4e6504 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:19:12 -0400 Subject: [PATCH 09/17] Carry retained owner tables and migrate identity consumers and fixtures --- src/agent_comms/acp.py | 2 +- src/agent_comms/coordination_cohort.py | 8 +- src/agent_comms/input_drain.py | 2 +- src/agent_comms/installer/carry.py | 5 +- src/agent_comms/queued_input.py | 2 +- src/agent_comms/response_conversation.py | 6 +- src/agent_comms/selected_participant.py | 2 +- src/agent_comms/selected_write_plan.py | 2 +- tests/shared_bus_restart_native.py | 15 +- tests/test_acp_private_nk_delivery.py | 24 ++-- tests/test_acp_selected_write_plan.py | 4 +- tests/test_bus_files.py | 2 - tests/test_claim_admission_verifier.py | 5 +- tests/test_claim_bus_read.py | 9 +- tests/test_coordinated_runtime.py | 4 - tests/test_coordination.py | 25 ++-- tests/test_coordination_cohort.py | 6 +- tests/test_coordination_nominal.py | 13 +- tests/test_coordination_response.py | 7 +- tests/test_coordination_store.py | 136 +++++++------------ tests/test_cursor_load_recovery.py | 2 +- tests/test_interrupted_delivery_recovery.py | 8 +- tests/test_lifecycle_subclasses.py | 2 +- tests/test_native_channel_reply_roundtrip.py | 8 +- tests/test_native_context_reference.py | 6 +- tests/test_native_evidence_custody.py | 8 +- tests/test_native_failure_recovery.py | 2 +- tests/test_native_send_admission.py | 2 +- tests/test_native_unknown_recovery.py | 4 +- tests/test_optional_awareness_projection.py | 26 ++-- tests/test_private_human_ingress.py | 4 +- tests/test_private_idle_drain.py | 4 +- tests/test_private_sidecar.py | 5 +- tests/test_reconcile_stores.py | 2 +- tests/test_roster_restoration.py | 2 +- tests/test_runtime.py | 8 +- tests/test_selected_answer_forms.py | 5 +- tests/test_selected_source_batch.py | 7 +- tests/test_sqlite_connection_ownership.py | 10 +- tests/test_table_mutation_capabilities.py | 6 +- tests/test_thread_identity.py | 6 +- tests/test_thread_name_owners_carry.py | 89 ++++++++++++ tools/cutover/thread_name_owners.py | 93 +++++++++++++ 43 files changed, 341 insertions(+), 247 deletions(-) create mode 100644 tests/test_thread_name_owners_carry.py create mode 100644 tools/cutover/thread_name_owners.py diff --git a/src/agent_comms/acp.py b/src/agent_comms/acp.py index bd3929a99a..9fe38d4f57 100644 --- a/src/agent_comms/acp.py +++ b/src/agent_comms/acp.py @@ -384,7 +384,7 @@ def select(store): with store.session.read(): participant = store.participants.get(lookup) candidate = next_sealed_assignment( - store, participant.lookup, owner.name, after_seq=after_seq, + store, participant.lookup, after_seq=after_seq, ) if candidate is None: return False diff --git a/src/agent_comms/coordination_cohort.py b/src/agent_comms/coordination_cohort.py index c5d05dfb01..2effff8a36 100644 --- a/src/agent_comms/coordination_cohort.py +++ b/src/agent_comms/coordination_cohort.py @@ -245,7 +245,6 @@ def accept_delivery_cohort( def next_sealed_assignment( store: Coordination, recipient_lookup: str, - owner_name: str, *, after_seq: int = 0, ) -> WakeAssignment | None: @@ -254,12 +253,12 @@ def next_sealed_assignment( Skip completed pages to find later work. Displaying work does not start a turn; executing it still requires checking the process and stored message. """ - pending = pending_sealed_assignments(store, recipient_lookup, owner_name, after_seq=after_seq) + pending = pending_sealed_assignments(store, recipient_lookup, after_seq=after_seq) return next(iter(pending), None) def pending_sealed_assignments( - store: Coordination, recipient_lookup: str, owner_name: str, *, after_seq: int = 0, + store: Coordination, recipient_lookup: str, *, after_seq: int = 0, ) -> tuple[WakeAssignment, ...]: """Read acknowledged pending work once, excluding messages arriving afterward. @@ -275,8 +274,7 @@ def pending_sealed_assignments( store, recipient_lookup, after_seq=cursor, limit=100, state_capability=PendingNotification, ) - pending.extend(assignment for assignment in selected - if assignment.recipient == owner_name) + pending.extend(selected) if len(selected) < 100: return tuple(pending) cursor = selected[-1].wire_seq diff --git a/src/agent_comms/input_drain.py b/src/agent_comms/input_drain.py index 48b268cd1e..8c4c0c05b1 100644 --- a/src/agent_comms/input_drain.py +++ b/src/agent_comms/input_drain.py @@ -262,7 +262,7 @@ def _private_observation_revision( wire_root_id=root_id, generation=participant.owner_identity.generation, ).state(store.session._connection) pending = next_sealed_assignment( - store, participant.lookup, owner.thread.name, after_seq=cursor.covered_seq, + store, participant.lookup, after_seq=cursor.covered_seq, ) original = store.session.path.stat() observation = PrivateObservation diff --git a/src/agent_comms/installer/carry.py b/src/agent_comms/installer/carry.py index a406fa1550..30139413d5 100644 --- a/src/agent_comms/installer/carry.py +++ b/src/agent_comms/installer/carry.py @@ -1,6 +1,6 @@ """The steps a retained bus is carried through when its durable format changes. -A step is one module in ``tools/cutover``, ``carry_.py``, holding one ``Carry`` +A step is one module in ``tools/cutover`` holding one ``Carry`` subclass. Only ``agent-comms install`` runs it, with the target revision's ``src`` and ``tools``, so every step runs against the source it is installed with. A step is idempotent: data it already carried reports no change, so a step's own dry run is the @@ -123,7 +123,7 @@ def at(cls, tree: Path) -> CarryChain: """Import the tree's steps (its ``tools`` is on the path) and order them by the first-parent commit that added each file.""" directory = (tree / STEPS).resolve() - for path in sorted(directory.glob("carry_*.py")): + for path in sorted(directory.glob("*.py")): import_module(f"{directory.name}.{path.stem}") steps = [step for step in Carry.declared.values() if step.module().parent == directory] log = subprocess.check_output( @@ -150,4 +150,3 @@ def pending(self, dry_run: Callable[[type[Carry]], Carried]) -> tuple[type[Carry if dry_run(step).changed: return self.steps[position:] return () - diff --git a/src/agent_comms/queued_input.py b/src/agent_comms/queued_input.py index d0a0ca67a8..ead40319d8 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -483,7 +483,7 @@ def read(store: Coordination): with store.session.read(): assignments = tuple( assignment for assignment in pending_sealed_assignments( - store, lookup, owner.name, after_seq=after_seq + store, lookup, after_seq=after_seq ) if assignment.audience.joins_active_turn() ) with NativeBoundary(bus) as boundary, bus.bus.reading() as source: diff --git a/src/agent_comms/response_conversation.py b/src/agent_comms/response_conversation.py index 4aae6dfc46..8de407f665 100644 --- a/src/agent_comms/response_conversation.py +++ b/src/agent_comms/response_conversation.py @@ -84,9 +84,9 @@ def audience(self, message: Message): if not any(member.recipient_lookup == self.responder.recipient_lookup for member in audience.recipients): raise IdentityConflict("Responder was not in original frozen audience") for member in participants: - previous = members.setdefault(member.recipient_lookup, member) - if previous != member: - raise IdentityConflict("Conversation participant identity changed") + # Names belong to these original envelopes. A participant can + # have been renamed between sources without changing identity. + members.setdefault(member.recipient_lookup, member) members.pop(self.responder.recipient_lookup, None) recipients = CommandTarget.named(self.obligation.exact_target).response_recipients(tuple(members.values())) revision = hashlib.sha256( diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index d197d1e361..a316f6f214 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -244,7 +244,7 @@ def prepare( identity = ParticipantOwner(thread, participant.owner_identity.generation) identity.require(store, lookup) pending = pending_sealed_assignments( - store, lookup, thread.name, after_seq=after_seq + store, lookup, after_seq=after_seq ) sent_read = NotificationAssignment.capture( store.session._connection, sent_sources, privacy, diff --git a/src/agent_comms/selected_write_plan.py b/src/agent_comms/selected_write_plan.py index 229c35f005..137de0076e 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -205,7 +205,7 @@ def submit( for assignment in assignments for pending in assignment.lifecycle.writable_pending_assignments(assignment) if pending.source == MessageReference(source_seq, source_message_id) - and pending.recipient == owner.name + and pending.recipient_lookup == lookup ] if len(selected) != 1: raise IdentityConflict("Selected write has no one pending FULL selected claim") diff --git a/tests/shared_bus_restart_native.py b/tests/shared_bus_restart_native.py index 8ae9a1f1d7..baafd2c434 100644 --- a/tests/shared_bus_restart_native.py +++ b/tests/shared_bus_restart_native.py @@ -239,14 +239,14 @@ async def observe_original_bus(): if service.registry.require(name).executing) observed = {'elapsed_seconds':time.perf_counter()-begun, 'active_owners':active, - 'dispatched_unproven_inputs':[{'owner':row.owner_thread, + 'dispatched_unproven_inputs':[{'owner':service.registry.snapshot().by_lookup[row.owner_lookup].name, 'stage':row.reference_stage.declared_name,'input_id':row.input_id} for row in dispatched], 'claims':[{'owner':row.recipient,'revision':row.revision, 'disposition':row.lifecycle.declared_name} for row in claims]} if not timeline or observed['dispatched_unproven_inputs'] != timeline[-1]['dispatched_unproven_inputs'] or observed['claims'] != timeline[-1]['claims']: timeline.append(observed) - overlap |= len({row.owner_thread for row in dispatched}) == len(names) + overlap |= len({service.registry.snapshot().by_lookup[row.owner_lookup].name for row in dispatched}) == len(names) triage = [row for row in inputs if row.reference_stage is TriageNativeExecution] full = [row for row in inputs if row.reference_stage is FullNativeExecution] if len(claims) == len(names)*len(originals) and all(bool(row.lifecycle.items_with(Complete, True)) for row in claims) and not active: @@ -256,7 +256,7 @@ async def observe_original_bus(): assert all(len({row.accepted_at_ms for row in claims if row.wire_seq == original.seq}) == 1 for original in originals) for native in inputs: - expected = tuple(row.assignment_id for row in claims if row.recipient == native.owner_thread) + expected = tuple(row.assignment_id for row in claims if row.recipient_lookup == native.owner_lookup) assert membership[native.input_id] == expected, "Native input did not capture the complete ordered wave" with NativeEntry.open_evidence(Path(native.session_file)) as evidence: evidence.observe() @@ -296,14 +296,14 @@ async def observe_original_bus(): if (isinstance(fact, CursorAdvancedUpdate) and isinstance(fact.envelope.observation, VerifiedCursorObservation)): cursor = fact.envelope.observation.cursor - if cursor.owner_thread in names and cursor.covered_seq >= originals[-1].seq: + if cursor.recipient_lookup in {service.registry.require(name).incarnation.lookup for name in names} and cursor.covered_seq >= originals[-1].seq: native = recorded_inputs[cursor.input_id] assert cursor.owner_identity == native.owner_identity assert cursor.reference == native.reference assert native.sent_owner_claimed_generation.matches(cursor.owner_claimed_generation) assert cursor.injected_seq >= originals[-1].seq - assert fact.envelope.scope.owner == service.registry.require(native.owner_thread).require_process() - published_coverage[native.owner_thread] = fact.envelope + assert fact.envelope.scope.owner == service.registry.snapshot().by_lookup[native.owner_lookup].require_process() + published_coverage[service.registry.snapshot().by_lookup[native.owner_lookup].name] = fact.envelope # Original completion and informational ACP publication # are separate asynchronous boundaries. Await the actual # installed publisher within the existing journey budget; @@ -969,8 +969,7 @@ def hold_cursor_publication(): initial = service.bus.bus.delivery(cutover.originals[0].seq) with Coordination(str(service.root/'coordination.sqlite3')) as store: for audience in initial.audience.recipients: - store.participants.register(audience.recipient_lookup, - audience.canonical_thread, audience.canonical_thread, committed=True) + store.participants.register(audience.recipient_lookup, committed=True) accept_delivery_cohort(service.bus, root_id, cutover.originals[0].seq, store) else: result = await asyncio.to_thread(service.owners.restart_owners,names,cutover=cutover) diff --git a/tests/test_acp_private_nk_delivery.py b/tests/test_acp_private_nk_delivery.py index d9acc41649..02ceb156cc 100644 --- a/tests/test_acp_private_nk_delivery.py +++ b/tests/test_acp_private_nk_delivery.py @@ -42,7 +42,7 @@ def _private_comms(tmp_path): root_id = comms.registry.prepare_native_root().namespace with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - RecordedThreadIncarnation("fixture", owner.created_at).lookup, "beta", "beta", committed=True + RecordedThreadIncarnation("fixture", owner.created_at).lookup, committed=True ) PrivateCoordinatorTable.install(store) return comms, root_id @@ -82,10 +82,10 @@ def test_private_owner_rename_during_active_attempt_can_complete(tmp_path, manag lookup = RecordedThreadIncarnation("fixture", comms.registry.require("beta").created_at).lookup with Coordination(str(comms.root / "coordination.sqlite3")) as store: before = store.participants.get(lookup) - store.create("rename-active", ExecutionOrigin.ACP, lookup, "beta", 1) + store.create("rename-active", ExecutionOrigin.ACP, lookup, 1) store.mark_pending("rename-active", expected_revision=1) fence = store.start(AttemptStart( - "rename-active", 1, "beta", 1, prepare_fence_token(), + "rename-active", 1, lookup, 1, prepare_fence_token(), expected_execution_revision=2, expected_pointer_revision=0, )).value.fence active = store.participants.get(lookup) @@ -96,11 +96,9 @@ def test_private_owner_rename_during_active_attempt_can_complete(tmp_path, manag assert comms.registry.require("beta").name == "gamma" with Coordination(str(comms.root / "coordination.sqlite3")) as store: after = store.participants.get(lookup) - assert after.owner_identity.owner_thread == "gamma" + assert after.owner_identity == before.owner_identity assert after.owner_identity.generation == before.owner_identity.generation assert after.pointer == active.pointer - with pytest.raises(StaleRevision): - store.participants.rename_owner(lookup, "wrong", expected_generation=2) fence = final_evidence(store, fence) settled = store.settle_nonpublication( fence, expected_pointer_revision=1, outcome=SucceededAttempt(), @@ -109,15 +107,15 @@ def test_private_owner_rename_during_active_attempt_can_complete(tmp_path, manag assert not settled.is_current -def test_private_rename_refuses_mismatched_sql_owner_before_registry_mutation(tmp_path): +def test_private_rename_preserves_independent_sql_generation(tmp_path): comms, _ = _private_comms(tmp_path) 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): - comms.threads._rename_thread("beta", "gamma") - assert comms.registry.require("beta").name == "beta" - assert "gamma" not in comms.registry + store.participants.advance_generation(lookup, expected_generation=1) + comms.threads._rename_thread("beta", "gamma") + assert comms.registry.require("beta").name == "gamma" + with Coordination(str(comms.root / "coordination.sqlite3")) as store: + assert store.participants.get(lookup).owner_identity.generation == 2 assert comms.bus.bus.revision() == 0 @@ -140,5 +138,3 @@ async def test_missing_native_schema_refuses_cursor_metadata(tmp_path): store.session._connection.execute("DROP TABLE native_runtime_triage_sources") with pytest.raises(PublicationActivationBlocked): await agent.sessions.metadata("beta") - - diff --git a/tests/test_acp_selected_write_plan.py b/tests/test_acp_selected_write_plan.py index 9086fbd32c..7f8655f722 100644 --- a/tests/test_acp_selected_write_plan.py +++ b/tests/test_acp_selected_write_plan.py @@ -59,7 +59,7 @@ async def test_second_pid_public_acp_owner_ipc_preplan(tmp_path, native_runtime_ with Coordination(str(root / "coordination.sqlite3")) as store: PrivateCoordinatorTable.install(store) store.participants.register( - alpha.incarnation.lookup, "alpha", "alpha", committed=True + alpha.incarnation.lookup, committed=True ) env = dict(os.environ) source_root = Path(__file__).resolve().parents[1] @@ -89,7 +89,7 @@ async def test_second_pid_public_acp_owner_ipc_preplan(tmp_path, native_runtime_ ) with Coordination(str(root / "coordination.sqlite3")) as store: store.participants.register( - beta.incarnation.lookup, "beta", "beta", committed=True + beta.incarnation.lookup, committed=True ) (base / "registered").touch() diff --git a/tests/test_bus_files.py b/tests/test_bus_files.py index 090addb306..2c1d68a0e4 100644 --- a/tests/test_bus_files.py +++ b/tests/test_bus_files.py @@ -14,7 +14,6 @@ from agent_comms.coordination_session import NativeBoundary from agent_comms.envelope_claim_transitions import ExistingFileClaim, NativeClaim from agent_comms.locked_store import DurableFile, RootFile, RuntimeFile, SQLiteFile -from agent_comms.private_registry_guard import PrivateOwnerRenameIntent from agent_comms.recorded_time import RecordedEventTime from agent_comms.repository_name import RepositoryName from agent_comms.todos import TodoStore @@ -55,7 +54,6 @@ def test_every_bus_file_is_a_declared_root_file(tmp_path: Path, source_repositor ) assert todo.id == "P1" declared = RootFile.paths_in(comms.root) - assert PrivateOwnerRenameIntent.path_in(comms.root) in declared # Directory traversal observes the outcome; it is not the production inventory. actual = {path for path in comms.root.rglob("*") if path.is_file()} assert actual <= declared, sorted(str(path.relative_to(comms.root)) for path in actual - declared) diff --git a/tests/test_claim_admission_verifier.py b/tests/test_claim_admission_verifier.py index 7510471afd..3582601dd2 100644 --- a/tests/test_claim_admission_verifier.py +++ b/tests/test_claim_admission_verifier.py @@ -65,8 +65,6 @@ def test_selected_wake_verifier_refuses_no_wake_and_stale_authority( for recipient in initial.audience.recipients: store.participants.register( recipient.recipient_lookup, - recipient.canonical_thread, - recipient.canonical_thread, committed=True, ) accept_delivery_cohort(comms.bus, root_id, message.seq, store) @@ -85,7 +83,6 @@ def test_selected_wake_verifier_refuses_no_wake_and_stale_authority( execution_id, ExecutionOrigin.WIRE, alice_lookup, - owner.name, 1, sources=(SelectedSource(assignment.assignment_id, store.assignments, initial),), ) @@ -96,7 +93,7 @@ def test_selected_wake_verifier_refuses_no_wake_and_stale_authority( started = store.start(AttemptStart( execution_id, 1, - owner.name, + alice_lookup, person.owner_identity.generation, prepare_fence_token(), expected_execution_revision=snapshot.execution.revision, diff --git a/tests/test_claim_bus_read.py b/tests/test_claim_bus_read.py index 0a44fbc6e5..45d836c16b 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -92,8 +92,7 @@ def selected_turn(private_comms, private_directory): PrivateCoordinatorTable.install(store) for recipient in initial.audience.recipients: store.participants.register( - recipient.recipient_lookup, recipient.canonical_thread, - recipient.canonical_thread, committed=True, + recipient.recipient_lookup, committed=True, ) accept_delivery_cohort(comms.bus, root_id, message.seq, store) lookup = RecordedThreadIncarnation("fixture", comms.registry.require("Alice").created_at).lookup @@ -104,7 +103,7 @@ def selected_turn(private_comms, private_directory): ) person = store.participants.get(lookup) store.create( - "execution", ExecutionOrigin.WIRE, lookup, owner.name, 1, + "execution", ExecutionOrigin.WIRE, lookup, 1, sources=(SelectedSource(assignment.assignment_id, store.assignments, initial),), ) snapshot = store.get("execution") @@ -112,7 +111,7 @@ def selected_turn(private_comms, private_directory): "execution", expected_revision=snapshot.execution.revision ).value store.start(AttemptStart( - "execution", 1, owner.name, person.owner_identity.generation, prepare_fence_token(), + "execution", 1, lookup, person.owner_identity.generation, prepare_fence_token(), expected_execution_revision=snapshot.execution.revision, expected_pointer_revision=snapshot.pointer_revision, )) @@ -232,5 +231,3 @@ def test_claim_operation_is_refused_after_its_claim_was_released(selected_turn) ) assert again.generation != claimed.generation assert dict(comms.bus.bus.claim_projection()) == {str(resource): again} - - diff --git a/tests/test_coordinated_runtime.py b/tests/test_coordinated_runtime.py index d62c5bd78a..dd88a47ca2 100644 --- a/tests/test_coordinated_runtime.py +++ b/tests/test_coordinated_runtime.py @@ -67,8 +67,6 @@ def _root( for recipient in initial.audience.recipients: store.participants.register( recipient.recipient_lookup, - recipient.canonical_thread, - recipient.canonical_thread, committed=True, ) accepted = accept_delivery_cohort(comms.bus, root_id, message.seq, store) @@ -184,5 +182,3 @@ async def test_untrusted_pi_fails_before_any_bus_or_sql_mutation(tmp_path: Path, ).run() assert isinstance(failure.value.__cause__, NativePiUnavailable) assert not list(root.iterdir()) - - diff --git a/tests/test_coordination.py b/tests/test_coordination.py index 9aa5cece55..373e7f3da4 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -75,7 +75,6 @@ def execution(*, status=PendingExecution, ordinal=None, origin=ExecutionOrigin.A return ExecutionRecord( execution_id="execution-1", origin=origin, - owner_thread="worker", owner_lookup="owner-1", revision=1, max_attempts=3, @@ -91,7 +90,6 @@ def attempt(*, ordinal=1, phase=PromptStartingAttempt, generation=1, done=False, execution_id="execution-1", attempt_ordinal=ordinal, owner_lookup="owner-1", - owner_thread="worker", owner_generation=generation, owner_token_digest=f"digest-{generation}", revision=1, @@ -186,12 +184,11 @@ def sql_execution( ordinal=None, max_attempts=3, owner="p", - thread="worker", ): ExecutionRecord( execution_id=name, origin=ExecutionOrigin(origin), lifecycle=ExecutionState.member_named(status).load(ordinal), - owner_thread=thread, owner_lookup=owner, revision=1, + owner_lookup=owner, revision=1, max_attempts=max_attempts, reason_code=NoReasonCode(), created_at_ms=0, updated_at_ms=0, ).insert(db) @@ -218,10 +215,10 @@ def sql_wire_execution(db, *, pending=True, name="e", target="requester"): db.execute("COMMIT") -def sql_attempt(db, name="e", ordinal=1, generation=1, owner="p", thread="worker", digest=None): +def sql_attempt(db, name="e", ordinal=1, generation=1, owner="p", digest=None): AttemptRecord( execution_id=name, attempt_ordinal=ordinal, owner_lookup=owner, - owner_thread=thread, owner_generation=generation, + owner_generation=generation, owner_token_digest=digest if digest is not None else f"digest-{generation}", lifecycle=PromptStartingAttempt.load(500, False, False), revision=1, last_progress_at_ms=None, reason_code=NoReasonCode(), created_at_ms=0, updated_at_ms=0, @@ -229,8 +226,8 @@ def sql_attempt(db, name="e", ordinal=1, generation=1, owner="p", thread="worker def owner(db): - Participants(participant_lookup="p", display_name="worker", committed=True).insert(db) - OwnerGenerations(owner_lookup="p", owner_thread="worker", generation=1).insert(db) + Participants(participant_lookup="p", committed=True).insert(db) + OwnerGenerations(owner_lookup="p", generation=1).insert(db) CurrentExecutions(owner_lookup="p", execution_id=NoPointedExecution(), attempt_ordinal=NoPointedAttempt(), pointer_revision=0).insert(db) @@ -1003,7 +1000,7 @@ def test_direct_sql_immutable_attempt_identity_and_monotonic_evidence(db): "attempt_ordinal=2", "owner_generation=2", "owner_token_digest='changed'", - "owner_thread='renamed'", + "owner_lookup='renamed'", "lifecycle=json_set(lifecycle,'$.backend_done',json('true'),'$.process_dead',json('true')," "'$.lease_expires_at_ms',499),revision=2,last_progress_at_ms=5,updated_at_ms=5", ): @@ -1212,14 +1209,14 @@ def test_fence_digest_is_globally_unique_across_executions(db): c, path = db sql_execution(c) activate(c) - Participants(participant_lookup="q", display_name="other-worker", committed=True).insert(c) - OwnerGenerations(owner_lookup="q", owner_thread="other-worker", generation=1).insert(c) + Participants(participant_lookup="q", committed=True).insert(c) + OwnerGenerations(owner_lookup="q", generation=1).insert(c) CurrentExecutions(owner_lookup="q", execution_id=NoPointedExecution(), attempt_ordinal=NoPointedAttempt(), pointer_revision=0).insert(c) - sql_execution(c, name="other-e", owner="q", thread="other-worker") + sql_execution(c, name="other-e", owner="q") c.execute("BEGIN IMMEDIATE") with pytest.raises(sqlite3.IntegrityError): - sql_attempt(c, name="other-e", owner="q", thread="other-worker", digest="digest-1") + sql_attempt(c, name="other-e", owner="q", digest="digest-1") c.execute("ROLLBACK") with CoordinationStore(path) as reopened: assert reopened._connection.execute("SELECT count(*) FROM attempts").fetchone()[0] == 1 @@ -1230,7 +1227,7 @@ def test_fence_digest_is_globally_unique_across_executions(db): def test_claim_target_and_recipient_lineage_is_immutable(db): c, path = db sql_wire_execution(c, pending=False) - Participants(participant_lookup="other", display_name="other-worker", committed=True).insert(c) + Participants(participant_lookup="other", committed=True).insert(c) original = WakeAssignment.one(c, assignment_id="a") assert original is not None c.execute("BEGIN IMMEDIATE") diff --git a/tests/test_coordination_cohort.py b/tests/test_coordination_cohort.py index cdab6d9f93..dbb698f207 100644 --- a/tests/test_coordination_cohort.py +++ b/tests/test_coordination_cohort.py @@ -75,7 +75,7 @@ def _root(tmp_path: Path) -> tuple[Comms, Coordination, str, dict[str, str]]: coordinator = Coordination(str(root / "coordinator.sqlite"), clock_ms=lambda: 4723) PrivateCoordinatorTable.install(coordinator) for name in ("Alice", "Bob", "Charlie"): - coordinator.participants.register(lookups[name], name, name, committed=True) + coordinator.participants.register(lookups[name], committed=True) root_id = comms.registry.prepare_native_root().namespace return comms, coordinator, root_id, lookups @@ -417,7 +417,7 @@ def test_message_taken_by_a_running_turn_is_delivered_once_and_stays_delivered( coordinator.assignments.transition_preengagement( alice.assignment_id, after, expected_revision=delivered.revision ) - pending = pending_sealed_assignments(coordinator, lookups["Alice"], "Alice") + pending = pending_sealed_assignments(coordinator, lookups["Alice"]) assert alice.assignment_id not in {item.assignment_id for item in pending} # The stored rules, not only the Python states, refuse to reopen it. @@ -437,5 +437,5 @@ def test_message_taken_by_a_running_turn_is_delivered_once_and_stays_delivered( bob.assignment_id, type(bob.lifecycle), expected_revision=bob_paused.revision ).value assert type(back.lifecycle) is type(bob.lifecycle) - pending = pending_sealed_assignments(coordinator, lookups["Bob"], "Bob") + pending = pending_sealed_assignments(coordinator, lookups["Bob"]) assert bob.assignment_id in {item.assignment_id for item in pending} diff --git a/tests/test_coordination_nominal.py b/tests/test_coordination_nominal.py index 7fc8d29cd7..a714609f69 100644 --- a/tests/test_coordination_nominal.py +++ b/tests/test_coordination_nominal.py @@ -45,7 +45,6 @@ def test_record_replacement_uses_only_nominal_state(): execution_id="e", origin=ExecutionOrigin.ACP, lifecycle=PendingExecution(), - owner_thread="t", owner_lookup="o", revision=1, max_attempts=3, @@ -86,11 +85,11 @@ def successors(cls): try: with Coordination(tmp_path / "coordination.sqlite3") as store: - store.participants.register("owner", "owner", "owner", committed=True) + store.participants.register("owner", committed=True) store.session._connection.execute( - "INSERT INTO executions (execution_id,origin,lifecycle,owner_thread,owner_loo" + "INSERT INTO executions (execution_id,origin,lifecycle,owner_loo" "kup,revision,max_attempts,reason_code,created_at_ms,updated_at_ms) VALUES ('" - "e','acp',json_object('kind','paused'),'owner','owner',1,2,NULL,1,1)" + "e','acp',json_object('kind','paused'),'owner',1,2,NULL,1,1)" ) record = store.get("e").execution assert isinstance(record.lifecycle, PausedExecution) @@ -117,11 +116,11 @@ def successors(cls): try: with Coordination(tmp_path / "coordination.sqlite3") as store: - store.participants.register("p", "owner", "owner", committed=True) + store.participants.register("p", committed=True) with store.session.writing() as db: ExecutionRecord( execution_id="e", origin=ExecutionOrigin.WIRE, - lifecycle=QueuedExecution(), owner_thread="owner", owner_lookup="p", + lifecycle=QueuedExecution(), owner_lookup="p", revision=1, max_attempts=2, reason_code=NoReasonCode(), created_at_ms=0, updated_at_ms=0, ).insert(db) @@ -158,7 +157,7 @@ class AwaitingAssignment(FullPendingAssignment): try: with Coordination(tmp_path / "coordination.sqlite3") as store: - store.participants.register("owner", "owner", "owner", committed=True) + store.participants.register("owner", committed=True) assignment = WakeAssignment( assignment_id="assignment", recipient="owner", diff --git a/tests/test_coordination_response.py b/tests/test_coordination_response.py index e9fd6520c0..93cc07ddd5 100644 --- a/tests/test_coordination_response.py +++ b/tests/test_coordination_response.py @@ -68,7 +68,7 @@ def _ready(tmp_path: Path, *, direct: bool = False, extra_targets: tuple[str, .. ) root_id = comms.registry.prepare_native_root().namespace target = "owner" if direct else "#team" - original = publish_initial(comms, + original = publish_initial(comms, "sender", target, "Need owner to consider and reply." ) original_record = comms.bus.bus.delivery(original.seq) @@ -79,8 +79,6 @@ def _ready(tmp_path: Path, *, direct: bool = False, extra_targets: tuple[str, .. PrivateCoordinatorTable.install(store) store.participants.register( recipient.recipient_lookup, - recipient.canonical_thread, - recipient.canonical_thread, committed=True, ) accepted = accept_delivery_cohort(comms.bus, root_id, original.seq, store) @@ -99,7 +97,6 @@ def _ready(tmp_path: Path, *, direct: bool = False, extra_targets: tuple[str, .. "exec", ExecutionOrigin.WIRE, recipient.recipient_lookup, - "owner", 1, sources=tuple(sources), ) @@ -109,7 +106,7 @@ def _ready(tmp_path: Path, *, direct: bool = False, extra_targets: tuple[str, .. AttemptStart( "exec", 1, - "owner", + recipient.recipient_lookup, 1, token, expected_execution_revision=2, diff --git a/tests/test_coordination_store.py b/tests/test_coordination_store.py index a94891ccbe..541859deae 100644 --- a/tests/test_coordination_store.py +++ b/tests/test_coordination_store.py @@ -103,7 +103,7 @@ def ready( ) -> StandaloneCoordination: db = store(path) sources = () - assert isinstance(db.participants.register("owner", "Owner", "thread", committed=True), Applied) + assert isinstance(db.participants.register("owner", committed=True), Applied) if origin is ExecutionOrigin.WIRE: original = Message("owner", "Owner", "Original request", InfoKind, timestamp=1.0, seq=1) db.assignments.accept( @@ -124,7 +124,6 @@ def ready( "exec", origin, "owner", - "thread", max_attempts, sources=sources, ) @@ -138,7 +137,7 @@ def started(db: StandaloneCoordination) -> tuple[str, object]: AttemptStart( "exec", 1, - "thread", + "owner", 1, token, expected_execution_revision=2, @@ -175,7 +174,7 @@ def _concurrent_registration( try: with StandaloneCoordination(path, clock_ms=lambda: 1_000) as db: gate.wait(timeout=5) - result = db.participants.register("owner", "Owner", "thread", committed=True) + result = db.participants.register("owner", committed=True) output.put(type(result).__name__) except Exception as exc: output.put(type(exc).__name__) @@ -191,7 +190,7 @@ def _concurrent_start( AttemptStart( "exec", 1, - "thread", + "owner", 1, token, expected_execution_revision=2, @@ -219,55 +218,29 @@ def observe(reader, execution_id): assert readers and all(reader is db for reader in readers) -def test_registration_complete_atomic_reopen_and_rename(db_path: Path) -> None: +def test_registration_complete_atomic_reopen_and_generation(db_path: Path) -> None: db = store(db_path) - original = db.participants.register("owner", "Owner", "thread", committed=False) + original = db.participants.register("owner", committed=False) assert isinstance(original, Applied) assert original.value.pointer.pointer_revision == 0 - assert original.value.aliases == ("owner",) - assert isinstance(db.participants.register("owner", "Owner", "thread"), AlreadyApplied) + assert isinstance(db.participants.register("owner"), AlreadyApplied) assert isinstance(db.participants.commit("owner"), Applied) assert isinstance(db.participants.commit("owner"), AlreadyApplied) - rename = db.participants.rename("owner", "Owner New", "Owner New", expected_generation=1) - assert rename.value.aliases == ("owner", "Owner New") - assert isinstance( - db.participants.rename("owner", "Owner New", "Owner New", expected_generation=1), - AlreadyApplied, - ) assert isinstance( - db.participants.advance_generation("owner", "thread", expected_generation=1), Applied + db.participants.advance_generation("owner", expected_generation=1), Applied ) with pytest.raises(StaleRevision): - db.participants.advance_generation("owner", "thread", expected_generation=1) + db.participants.advance_generation("owner", expected_generation=1) db.close() with store(db_path) as reopened: assert reopened.participants.get("owner").owner_identity.generation == 2 assert reopened.participants.get("owner").committed - assert reopened.participants.get("owner").aliases == ("owner", "Owner New") - replay = reopened.participants.register("owner", "Owner", "thread", committed=False) + replay = reopened.participants.register("owner", committed=False) assert isinstance(replay, AlreadyApplied) assert replay.value.owner_identity.generation == 2 - assert replay.value.display_name == "Owner New" + assert replay.value.owner_identity.owner_lookup == "owner" -def test_registration_transaction_rollback_on_alias_collision(db_path: Path) -> None: - with store(db_path) as db: - db.participants.register("first", "First", "thread", alias="alias") - with pytest.raises(IdentityConflict): - db.participants.register("second", "Second", "thread", alias="alias") - assert ( - db.session._connection.execute("SELECT count(*) FROM participants").fetchone()[0] == 1 - ) - assert not db.session._connection.in_transaction - with store(db_path) as db: - assert ( - db.session._connection.execute("SELECT count(*) FROM owner_generations").fetchone()[0] - == 1 - ) - assert ( - db.session._connection.execute("SELECT count(*) FROM current_executions").fetchone()[0] - == 1 - ) def test_claim_execution_idempotency_and_rollback(db_path: Path) -> None: @@ -282,7 +255,6 @@ def test_claim_execution_idempotency_and_rollback(db_path: Path) -> None: "exec", ExecutionOrigin.WIRE, "owner", - "thread", 2, sources=(wire_source(db, "claim", original, FullWake()),), ) @@ -294,7 +266,6 @@ def test_claim_execution_idempotency_and_rollback(db_path: Path) -> None: "exec", ExecutionOrigin.WIRE, "owner", - "thread", 2, sources=(wire_source(db, "claim", different, FullWake()),), ) @@ -305,7 +276,7 @@ def test_claim_execution_idempotency_and_rollback(db_path: Path) -> None: def test_claim_acceptance_requires_initial_mode_decision(db_path: Path) -> None: with store(db_path) as db: - db.participants.register("owner", "Owner", "thread", committed=True) + db.participants.register("owner", committed=True) def assignment( index: int, @@ -368,7 +339,7 @@ def assignment( def test_claim_preengagement_and_mixed_engagement_rollback(db_path: Path) -> None: with store(db_path) as db: - db.participants.register("owner", "Owner", "thread", committed=True) + db.participants.register("owner", committed=True) sources = [] for index, disposition in ( (1, TriagePendingAssignment), @@ -402,7 +373,6 @@ def test_claim_preengagement_and_mixed_engagement_rollback(db_path: Path) -> Non "exec", ExecutionOrigin.WIRE, "owner", - "thread", 2, sources=tuple(sources), ) @@ -420,7 +390,7 @@ def test_token_prepared_before_transaction_and_loss_replay_ignores_mutable(db_pa AttemptStart( "exec", 1, - "thread", + "owner", 1, token, expected_execution_revision=2, @@ -441,7 +411,7 @@ def test_token_prepared_before_transaction_and_loss_replay_ignores_mutable(db_pa AttemptStart( "exec", 1, - "thread", + "owner", 1, token, expected_execution_revision=2, @@ -461,7 +431,7 @@ def test_token_prepared_before_transaction_and_loss_replay_ignores_mutable(db_pa AttemptStart( "exec", 1, - "thread", + "owner", 1, token, expected_execution_revision=2, @@ -475,7 +445,7 @@ def test_token_prepared_before_transaction_and_loss_replay_ignores_mutable(db_pa AttemptStart( "exec", 2, - "thread", + "owner", 1, token, expected_execution_revision=2, @@ -489,7 +459,7 @@ def test_token_prepared_before_transaction_and_loss_replay_ignores_mutable(db_pa AttemptStart( "exec", 1, - "thread", + "owner", 1, token, expected_execution_revision=2, @@ -502,15 +472,15 @@ def test_token_prepared_before_transaction_and_loss_replay_ignores_mutable(db_pa def test_token_digest_is_global_and_only_digest_persists(db_path: Path) -> None: with ready(db_path) as db: token, _ = started(db) - db.participants.register("other", "Other", "other-thread", committed=True) - db.create("other-exec", ExecutionOrigin.GOAL, "other", "other-thread", 1) + db.participants.register("other", committed=True) + db.create("other-exec", ExecutionOrigin.GOAL, "other", 1) db.mark_pending("other-exec", expected_revision=1) with pytest.raises(IdentityConflict): db.start( AttemptStart( "other-exec", 1, - "other-thread", + "other", 1, token, expected_execution_revision=2, @@ -528,8 +498,8 @@ def test_settled_snapshot_encodes_recovery_kind_separately_from_table(db_path: P from agent_comms.coordination_tables.recovery import RecoveryAudit with store(db_path) as db: - db.participants.register("owner", "Owner", "thread", committed=True) - db.create("exec", ExecutionOrigin.ACP, "owner", "thread", 1) + db.participants.register("owner", committed=True) + db.create("exec", ExecutionOrigin.ACP, "owner", 1) db.mark_pending("exec", expected_revision=1) _, fence = started(db) fence = final_evidence(db, fence) @@ -593,19 +563,19 @@ def test_retry_partition_and_generation_fence(db_path: Path) -> None: AttemptStart( "exec", 2, - "thread", + "owner", 1, prepare_fence_token(), expected_execution_revision=4, expected_pointer_revision=2, ) ) - db.participants.advance_generation("owner", "thread", expected_generation=1) + db.participants.advance_generation("owner", expected_generation=1) second = db.start( AttemptStart( "exec", 2, - "thread", + "owner", 2, prepare_fence_token(), expected_execution_revision=4, @@ -621,7 +591,7 @@ def test_retry_partition_and_generation_fence(db_path: Path) -> None: AttemptStart( "exec", 1, - "thread", + "owner", 1, token, expected_execution_revision=2, @@ -722,12 +692,12 @@ def test_provider_proof_carries_until_later_attempt_records_output( ) proof = deferred.value.replay.require_assessment() assert deferred.value.can_retry and proof.allows_retry - db.participants.advance_generation("owner", "thread", expected_generation=1) + db.participants.advance_generation("owner", expected_generation=1) second = db.start( AttemptStart( "exec", 2, - "thread", + "owner", 2, prepare_fence_token(), expected_execution_revision=4, @@ -825,7 +795,7 @@ def test_retry_start_rollback_restores_prior_replay_authorization(db_path: Path) failure=ProviderRetryExhausted("controlled provider failure"), ) assert deferred.value.can_retry - db.participants.advance_generation("owner", "thread", expected_generation=1) + db.participants.advance_generation("owner", expected_generation=1) before = db.get("exec") prepared = prepare_fence_token() db.session._connection.execute( @@ -840,7 +810,7 @@ def test_retry_start_rollback_restores_prior_replay_authorization(db_path: Path) AttemptStart( "exec", 2, - "thread", + "owner", 2, prepared, expected_execution_revision=4, @@ -855,7 +825,7 @@ def test_retry_start_rollback_restores_prior_replay_authorization(db_path: Path) AttemptStart( "exec", 2, - "thread", + "owner", 2, prepared, expected_execution_revision=4, @@ -1060,12 +1030,12 @@ def test_recovered_audit_rejects_prior_attempt_incident_and_terminal(db_path: Pa kind=RecoveredRecovery, reason_code=GivenReasonCode("terminal"), ) - db.participants.advance_generation("owner", "thread", expected_generation=1) + db.participants.advance_generation("owner", expected_generation=1) second = db.start( AttemptStart( "exec", 2, - "thread", + "owner", 2, prepare_fence_token(), expected_execution_revision=4, @@ -1102,7 +1072,7 @@ def test_unstarted_failure_and_claim_requeue(db_path: Path) -> None: AttemptStart( "exec", 1, - "thread", + "owner", 1, prepare_fence_token(), expected_execution_revision=3, @@ -1113,7 +1083,7 @@ def test_unstarted_failure_and_claim_requeue(db_path: Path) -> None: assert type(reopened.get("exec").execution.lifecycle) is FailedExecution with store(db_path.with_name("claim-only.sqlite3")) as db: - db.participants.register("owner", "Owner", "thread", committed=True) + db.participants.register("owner", committed=True) db.assignments.accept( WakeAssignment( assignment_id="claim", @@ -1168,7 +1138,7 @@ def test_frozen_v2_pre_attempt_deferred_execution_cannot_resume(db_path: Path) - AttemptStart( "exec", 1, - "thread", + "owner", 1, prepare_fence_token(), expected_execution_revision=3, @@ -1191,13 +1161,13 @@ def test_crash_reopen_pending_starts_once_after_owner_returns(db_path: Path) -> db.close() # Owner disappeared before the attempt began; PENDING remains durable. with store(db_path) as returned: assert type(returned.get("exec").execution.lifecycle) is PendingExecution - returned.participants.advance_generation("owner", "thread", expected_generation=1) + returned.participants.advance_generation("owner", expected_generation=1) token = prepare_fence_token() applied = returned.start( AttemptStart( "exec", 1, - "thread", + "owner", 2, token, expected_execution_revision=2, @@ -1212,7 +1182,7 @@ def test_crash_reopen_pending_starts_once_after_owner_returns(db_path: Path) -> AttemptStart( "exec", 1, - "thread", + "owner", 2, token, expected_execution_revision=2, @@ -1226,7 +1196,7 @@ def test_crash_reopen_pending_starts_once_after_owner_returns(db_path: Path) -> AttemptStart( "exec", 1, - "thread", + "owner", 2, prepare_fence_token(), expected_execution_revision=2, @@ -1459,21 +1429,19 @@ def test_public_snapshot_is_one_committed_revision_during_settlement(db_path: Pa assert type(db.get("exec").execution.lifecycle) is FailedExecution -def test_public_participant_is_one_committed_revision_during_rename(db_path: Path) -> None: +def test_public_participant_is_one_committed_revision_during_commit(db_path: Path) -> None: with store(db_path) as db: - db.participants.register("owner", "Old", "thread", committed=True) + db.participants.register("owner", committed=False) observed = _read_while_other_store_waits( db_path, "participants", lambda reader: reader.participants.get("owner"), - lambda writer: writer.participants.rename("owner", "New", "New", expected_generation=1), + lambda writer: writer.participants.commit("owner"), ) - assert observed.display_name == "Old" - assert observed.aliases == ("owner",) + assert not observed.committed with store(db_path) as db: - assert db.participants.get("owner").display_name == "New" - assert db.participants.get("owner").aliases == ("owner", "New") + assert db.participants.get("owner").committed def test_public_read_exception_releases_transaction_and_mutators_reuse_it(db_path: Path) -> None: @@ -1490,17 +1458,17 @@ def test_public_read_exception_releases_transaction_and_mutators_reuse_it(db_pat assert db.participants.get("owner").committed assert db.session._connection.in_transaction assert not db.session._connection.in_transaction - db.participants.register("other", "Other", "other-thread") - assert db.participants.get("other").display_name == "Other" + db.participants.register("other") + assert db.participants.get("other").lookup == "other" def test_observation_closes_failed_reads_and_preserves_schema_disposition(db_path: Path) -> None: from agent_comms.coordination_database import CoordinationStore with store(db_path) as writer: - writer.participants.register("owner", "Owner", "thread", committed=True) + writer.participants.register("owner", committed=True) for statement in ( - "UPDATE participants SET display_name='unauthorized'", + "UPDATE participants SET committed=0", "SELECT * FROM nonexistent_original_table", ): with pytest.raises(sqlite3.OperationalError): @@ -1514,7 +1482,7 @@ def test_observation_closes_failed_reads_and_preserves_schema_disposition(db_pat with pytest.raises(sqlite3.ProgrammingError): reader.execute("SELECT 1") with store(db_path) as writer: - assert writer.participants.get("owner").display_name == "Owner" + assert writer.participants.get("owner").committed @pytest.mark.parametrize( @@ -1537,7 +1505,7 @@ def test_nonterminal_settlement_cannot_silence_wire(db_path: Path, invalid: obje def test_other_public_evidence_flags_require_exact_bool(db_path: Path, invalid: object) -> None: with store(db_path) as db: with pytest.raises(ValueError): - db.participants.register("owner", "Owner", "thread", committed=invalid) + db.participants.register("owner", committed=invalid) assert ( db.session._connection.execute("SELECT count(*) FROM participants").fetchone()[0] == 0 ) diff --git a/tests/test_cursor_load_recovery.py b/tests/test_cursor_load_recovery.py index d98a23ab48..63f2f6b388 100644 --- a/tests/test_cursor_load_recovery.py +++ b/tests/test_cursor_load_recovery.py @@ -26,7 +26,7 @@ async def test_trusted_load_recovers_after_real_flock_contention(tmp_path): Bus.install(Bus.path_in(comms.root)) with Coordination(str(comms.root / "coordination.sqlite3")) as store: store.participants.register( - RecordedThreadIncarnation("fixture", owner.created_at).lookup, owner.name, owner.name, committed=True + RecordedThreadIncarnation("fixture", owner.created_at).lookup, committed=True ) agent = CommsAgent( comms, diff --git a/tests/test_interrupted_delivery_recovery.py b/tests/test_interrupted_delivery_recovery.py index 555ae70c1e..0f9b488cad 100644 --- a/tests/test_interrupted_delivery_recovery.py +++ b/tests/test_interrupted_delivery_recovery.py @@ -36,7 +36,7 @@ async def test_interrupted_following_delivery_recovers_only_before_native_send( ) with Coordination(str(store_path)) as store: - assignments = pending_sealed_assignments(store, lookup, owner.name) + assignments = pending_sealed_assignments(store, lookup) deliveries = comms.bus.bus.deliveries_for_references( tuple(row.source for row in assignments)) # Freeze the original storage operation without placing its live result, @@ -78,7 +78,7 @@ async def test_interrupted_following_delivery_recovers_only_before_native_send( ) with Coordination(str(store_path)) as store: current = store.assignments.get(paused.assignment_id) - pending = pending_sealed_assignments(store, lookup, owner.name) + pending = pending_sealed_assignments(store, lookup) if native_bound: assert recovered == 0 assert isinstance(current.lifecycle, DeferredAssignment) @@ -115,7 +115,7 @@ async def test_recovery_without_paused_assignments_is_read_only(tmp_path, monkey 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") + assignments = pending_sealed_assignments(store, lookup) deliveries = comms.bus.bus.deliveries_for_references( tuple(row.source for row in assignments)) reserved = [] @@ -134,5 +134,3 @@ def forbidden(*args, **kwargs): assert await DeliveredInput.recover_interrupted(agent.inputs, "beta", root_id) == 0 finally: await agent.shutdown() - - diff --git a/tests/test_lifecycle_subclasses.py b/tests/test_lifecycle_subclasses.py index 11f552ab9e..d8705c4d3e 100644 --- a/tests/test_lifecycle_subclasses.py +++ b/tests/test_lifecycle_subclasses.py @@ -125,7 +125,7 @@ def test_sql_retry_predecessor_is_not_every_unsuccessful_outcome(tmp_path, prede db, where="owner_lookup=?", parameters=("owner",), execution_id=NoPointedExecution(), attempt_ordinal=NoPointedAttempt(), pointer_revision=snapshot.pointer_revision + 1, ) - store.participants.advance_generation("owner", "thread", expected_generation=1) + store.participants.advance_generation("owner", expected_generation=1) next_attempt = replace( attempt, attempt_ordinal=2, owner_generation=2, owner_token_digest=prepare_fence_token(), lifecycle=PromptStartingAttempt(1000), diff --git a/tests/test_native_channel_reply_roundtrip.py b/tests/test_native_channel_reply_roundtrip.py index 54035b7d08..c1c49f307d 100644 --- a/tests/test_native_channel_reply_roundtrip.py +++ b/tests/test_native_channel_reply_roundtrip.py @@ -90,10 +90,10 @@ def do_POST(self): pending = NativeRuntimeInput.select( store.session._connection, where=( - "owner_thread=? AND session_entry_id IS NULL" + "owner_lookup=? AND session_entry_id IS NULL" if broadcast else "session_entry_id IS NULL" ), - parameters=((request["model"],) if broadcast else ()), + parameters=((comms.registry.require(request["model"]).incarnation.lookup,) if broadcast else ()), ) if not pending: assert not broadcast @@ -124,7 +124,7 @@ def do_POST(self): assert original is not None request_inputs.append({"input_id": original.input_id, "stage": original.stage.declared_name, - "owner": original.owner_thread}) + "owner": comms.registry.snapshot().by_lookup[original.owner_lookup].name}) if broadcast and len(requests) <= 2: # Both actual owners must reach the endpoint while the # first request is still open. No model turn may retain @@ -526,5 +526,3 @@ def selected_turn_finished(): "server_thread_alive": serving.is_alive(), "bus_seq": comms.bus.bus.revision(), }, indent=2) + "\n") - - diff --git a/tests/test_native_context_reference.py b/tests/test_native_context_reference.py index 89f0e4532d..3c305b4942 100644 --- a/tests/test_native_context_reference.py +++ b/tests/test_native_context_reference.py @@ -46,7 +46,7 @@ def cursor(): return CurrentNativeCursor( - wire_root_id="a" * 32, recipient_lookup="b" * 32, owner_thread="owner", + wire_root_id="a" * 32, recipient_lookup="b" * 32, owner_generation=1, owner_claimed_generation=2, covered_seq=1, injected_seq=1, input_id=TextNativeColumn("c" * 32), stage=TextNativeColumn("full"), session_id=TextNativeColumn("original-session"), request_generation=NumberNativeColumn(1), @@ -69,7 +69,7 @@ def test_native_cursor_state_owns_start_observation_and_input_proof( def reservation(): return NativeRuntimeInput( input_id="c" * 32, stage=TriageNativeExecution, execution_id=UnrecordedNativeColumn(), attempt_ordinal=UnrecordedNativeColumn(), - owner_lookup="b" * 32, owner_thread="owner", owner_generation=1, + owner_lookup="b" * 32, owner_generation=1, owner_token_digest="e" * 64, ) @@ -120,7 +120,7 @@ def historical_source(recorded): return dict( wire_root_id="a" * 32, source_seq=1, source_message_id="original-message", assignment_id="d" * 32, input_id=recorded.input_id, - owner_lookup=recorded.owner_lookup, owner_thread=recorded.owner_thread, + owner_lookup=recorded.owner_lookup, owner_generation=recorded.owner_generation, context=NativeContextProof(recorded.input_id, recorded.session_id, recorded.context_value("session_entry_id"), 1, "f" * 64, 1_791_964_800_001, diff --git a/tests/test_native_evidence_custody.py b/tests/test_native_evidence_custody.py index 283b1fccbd..395434db7d 100644 --- a/tests/test_native_evidence_custody.py +++ b/tests/test_native_evidence_custody.py @@ -169,7 +169,7 @@ async def test_joined_worker_retains_original_evidence_and_owns_sqlite_until_can path = tmp_path / 'coordination.sqlite3' with Coordination(str(path), clock_ms=lambda: 1234) as original: PrivateCoordinatorTable.install(original) - original.participants.register('original', 'owner', 'owner', committed=True) + original.participants.register('original', committed=True) original_connection = original.session._connection entered, release = threading.Event(), threading.Event() worker_connections = [] @@ -179,7 +179,7 @@ def observe(store): assert store.session.now() == original.session.now() worker_connections.append(store.session._connection) with store.session.read(): - assert store.participants.get('original').owner_identity.owner_thread == 'owner' + assert store.participants.get('original').owner_identity.owner_lookup == 'original' NativeContextProof.read_evidence(files, INPUT_ID, evidence=evidence, session_entry_start=input_record.start) entered.set() @@ -204,7 +204,7 @@ def observe(store): with pytest.raises(sqlite3.ProgrammingError): _ = worker_connections[0].in_transaction with original.session.read(): - assert original.participants.get('original').owner_identity.owner_thread == 'owner' + assert original.participants.get('original').owner_identity.owner_lookup == 'original' finally: release.set() if not pending.done(): @@ -212,5 +212,3 @@ def observe(store): await asyncio.gather(pending, return_exceptions=True) assert evidence.source.stream.closed assert (session.read_bytes(), files.input_proof.read_bytes()) == before - - diff --git a/tests/test_native_failure_recovery.py b/tests/test_native_failure_recovery.py index f24abd6dcf..a9edcbe014 100644 --- a/tests/test_native_failure_recovery.py +++ b/tests/test_native_failure_recovery.py @@ -131,7 +131,7 @@ async def release_native_input(directory: Path, history: type[NativeSendHistory] assert participant is not None created = store.create( participant.batch.execution_id, ExecutionOrigin.WIRE, - participant.lookup, participant.owner.thread.name, 1, + participant.lookup, 1, sources=participant.batch.sources, ).value attempt = await SelectedAttempt.engage(participant, created) diff --git a/tests/test_native_send_admission.py b/tests/test_native_send_admission.py index 7878913650..d4272ffcdf 100644 --- a/tests/test_native_send_admission.py +++ b/tests/test_native_send_admission.py @@ -225,7 +225,7 @@ def test_transaction_excludes_late_reader_before_irreversible_write(tmp_path): """A transaction which admits the pipe write owns its complete commit lifetime.""" path = tmp_path / "coordinator.sqlite3" with Coordination(path, contention=StoreBusy()) as store: - store.participants.register("owner", "owner", "owner", committed=True) + store.participants.register("owner", committed=True) reader = sqlite3.connect(path, isolation_level=None, timeout=0) read_fd, write_fd = os.pipe2(os.O_NONBLOCK) entered = [] diff --git a/tests/test_native_unknown_recovery.py b/tests/test_native_unknown_recovery.py index 694c45c7c7..08759de349 100644 --- a/tests/test_native_unknown_recovery.py +++ b/tests/test_native_unknown_recovery.py @@ -56,7 +56,7 @@ async def release_starting_attempt(directory): assert participant is not None created = store.create( participant.batch.execution_id, ExecutionOrigin.WIRE, - participant.lookup, participant.owner.thread.name, 1, + participant.lookup, 1, sources=participant.batch.sources, ).value await SelectedAttempt.engage(participant, created) @@ -232,7 +232,7 @@ def test_recover_owner_refuses_a_conflicting_input_identity(released_starting, c input_id="c" * 32, stage=FullNativeExecution, execution_id=TextNativeColumn(execution_id), attempt_ordinal=NumberNativeColumn(before.attempt.attempt_ordinal), - owner_lookup=owner.incarnation.lookup, owner_thread=owner.name, + owner_lookup=owner.incarnation.lookup, owner_generation=before.attempt.owner_generation + 1, owner_token_digest="e" * 64, ).insert(db) diff --git a/tests/test_optional_awareness_projection.py b/tests/test_optional_awareness_projection.py index d159ca7171..f374c9751a 100644 --- a/tests/test_optional_awareness_projection.py +++ b/tests/test_optional_awareness_projection.py @@ -66,8 +66,6 @@ def _accepted( for recipient in initial.audience.recipients: store.participants.register( recipient.recipient_lookup, - recipient.canonical_thread, - recipient.canonical_thread, committed=True, ) acknowledgement = accept_delivery_cohort(comms.bus, root_id, message.seq, store).value @@ -117,7 +115,6 @@ def test_selected_decision_and_open_obligation_are_both_source_cited(tmp_path: P "reply000", ExecutionOrigin.WIRE, assignment.recipient_lookup, - owner.name, 1, sources=(SelectedSource(assignment.assignment_id, store.assignments, initial),), ) @@ -171,7 +168,6 @@ def test_open_obligation_budget_cannot_be_hidden_by_selected_cursor(tmp_path: Pa f"reply{number}", ExecutionOrigin.WIRE, assignment.recipient_lookup, - owner.name, 1, sources=(SelectedSource(assignment.assignment_id, store.assignments, delivery),), ) @@ -195,7 +191,7 @@ def test_captured_owner_sql_generation_advance_omits(tmp_path: Path) -> None: builder = _projection(index, store, owner, 0, initial.message.seq) assert builder(initial, assignment, owner).context()["selected"] store.participants.advance_generation( - assignment.recipient_lookup, owner.name, expected_generation=1 + assignment.recipient_lookup, expected_generation=1 ) stale = builder(initial, assignment, owner) assert 'Optional awareness omitted' in stale.render(32768) @@ -210,7 +206,7 @@ def test_new_snapshot_after_same_name_generation_bump_omits_old_claim( try: initial, assignment = _accepted(comms, store, root_id, "member000", "old pending") store.participants.advance_generation( - assignment.recipient_lookup, "member000", expected_generation=1 + assignment.recipient_lookup, expected_generation=1 ) owner = _owner(comms, "member000") # Even a newly captured SQL gen2+registry turn cannot assign a gen1 @@ -231,12 +227,11 @@ def test_fresh_gen2_selected_and_old_pending_obligation_are_scoped(tmp_path: Pat "old-reply", ExecutionOrigin.WIRE, old_claim.recipient_lookup, - "member000", 1, sources=(SelectedSource(old_claim.assignment_id, store.assignments, old),), ) store.participants.advance_generation( - old_claim.recipient_lookup, "member000", expected_generation=1 + old_claim.recipient_lookup, expected_generation=1 ) current, assignment = _accepted(comms, store, root_id, "member000", "new pending") owner = _owner(comms, "member000") @@ -259,8 +254,8 @@ def test_fresh_gen2_only_selected_is_available(tmp_path: Path) -> None: comms, store, index, root_id = _root(tmp_path) try: 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) + store.participants.register(lookup, committed=True) + store.participants.advance_generation(lookup, expected_generation=1) initial, assignment = _accepted(comms, store, root_id, "member000", "fresh") owner = _owner(comms, "member000") result = _projection(index, store, owner, 0, initial.message.seq)( @@ -296,7 +291,7 @@ def test_older_receipt_message_id_mismatch_omits_entire_context(tmp_path: Path) store.close() -def test_normal_rename_does_not_inject_old_selected_claim_into_new_owner( +def test_normal_rename_preserves_selected_claims_for_the_same_owner( tmp_path: Path, ) -> None: comms, store, index, root_id = _root(tmp_path) @@ -314,10 +309,10 @@ def test_normal_rename_does_not_inject_old_selected_claim_into_new_owner( ) assert old_claim.recipient == "member000" and current_claim.recipient == "gamma" assert old.message.seq < current.message.seq - assert result.omitted_count == 1 + assert result.omitted_count == 0 selected = result.context()["selected"] - assert [row["claim_id"] for row in selected] == [current_claim.assignment_id] - assert old_claim.assignment_id not in result.render(32768) + assert [row["claim_id"] for row in selected] == [old_claim.assignment_id, current_claim.assignment_id] + assert old_claim.assignment_id in result.render(32768) captured = AwarenessClaimGenerations.one( store.session._connection, claim_id=old_claim.assignment_id, ) @@ -399,7 +394,7 @@ def test_saved_wire_awareness_preserves_passive_authority_and_rejects_incomplete comms, store, index, root_id = _root(tmp_path) try: initial, assignment = _accepted(comms, store, root_id, "#team", "@member000 act") - notice = publish_initial(comms, + notice = publish_initial(comms, "sender", "member000", "Passive retained context", role=NoticeRole() ) passive = accept_delivery_cohort(comms.bus, root_id, notice.seq, store).value.assignments[0] @@ -417,7 +412,6 @@ def test_saved_wire_awareness_preserves_passive_authority_and_rejects_incomplete "wire-awareness-reply", ExecutionOrigin.WIRE, assignment.recipient_lookup, - owner.name, 1, sources=(SelectedSource(assignment.assignment_id, store.assignments, initial),), ) diff --git a/tests/test_private_human_ingress.py b/tests/test_private_human_ingress.py index ceb1b9d5d5..cb1dd6adf0 100644 --- a/tests/test_private_human_ingress.py +++ b/tests/test_private_human_ingress.py @@ -84,7 +84,7 @@ def _root(tmp_path: Path) -> tuple[Comms, Coordination, str, dict[str, str]]: store = Coordination(str(CoordinationStore.path_in(root)), clock_ms=lambda: 9999) PrivateCoordinatorTable.install(store) for name in lookups: - store.participants.register(lookups[name], name, name, committed=True) + store.participants.register(lookups[name], committed=True) return comms, store, comms.registry.prepare_native_root().namespace, lookups @@ -228,5 +228,3 @@ def test_separate_processes_serialize_one_private_user_identity(tmp_path: Path) humans = [human for thread in members for human in thread.role.human(thread)] assert len(humans) == 1 assert [comms.bus.bus.delivery(seq).message.seq for seq in seqs] == seqs - - diff --git a/tests/test_private_idle_drain.py b/tests/test_private_idle_drain.py index 3e129d472e..026713612f 100644 --- a/tests/test_private_idle_drain.py +++ b/tests/test_private_idle_drain.py @@ -123,7 +123,7 @@ def _covered_session(tmp_path): comms.registry.declare(other) with Coordination(comms.root / "coordination.sqlite3") as store: store.participants.register( - RecordedThreadIncarnation("fixture", other.created_at).lookup, other.name, other.name, committed=True, + RecordedThreadIncarnation("fixture", other.created_at).lookup, committed=True, ) publish_initial(comms, "sender", other.name, "another recipient's original source") return comms, agent, root_id @@ -144,5 +144,3 @@ def test_coordination_read_preserves_revision_and_repairs_exposed_mode(tmp_path) # This control observes actual delivery/recovery facts. It neither mocks # native execution nor grants a provider call to the pending input. - - diff --git a/tests/test_private_sidecar.py b/tests/test_private_sidecar.py index da00559603..2461ff5c30 100644 --- a/tests/test_private_sidecar.py +++ b/tests/test_private_sidecar.py @@ -41,7 +41,6 @@ def _insert(db, key="1"): execution_id=UnrecordedNativeColumn(), attempt_ordinal=UnrecordedNativeColumn(), owner_lookup="2" * 32, - owner_thread="owner", owner_generation=1, wire_root_id="3" * 32, expected_prompt_digest="a" * 64, @@ -112,7 +111,7 @@ def test_symlink_ancestor_refused(path): [ "CREATE TRIGGER unrelated_insert_suppressor BEFORE INSERT ON prompt_binding " "BEGIN SELECT RAISE(IGNORE); END", - "CREATE INDEX unrelated_index ON prompt_binding(owner_thread)", + "CREATE INDEX unrelated_index ON prompt_binding(owner_lookup)", "CREATE VIEW unrelated_view AS SELECT * FROM prompt_binding", "CREATE TABLE unrelated_table (x)", *(f'DROP TRIGGER "{name}"' for name in PromptBinding.triggers()), @@ -220,7 +219,7 @@ def crash(fd): with s.sidecar_connection(p, PromptBinding) as db: PromptBinding(input_id='1'*32,stage=TriageNativeExecution, execution_id=UnrecordedNativeColumn(),attempt_ordinal=UnrecordedNativeColumn(),owner_lookup='2'*32, - owner_thread='owner',owner_generation=1,wire_root_id='3'*32, + owner_generation=1,wire_root_id='3'*32, expected_prompt_digest='a'*64, bound_at_ms=1).insert(db) print('OK', flush=True) diff --git a/tests/test_reconcile_stores.py b/tests/test_reconcile_stores.py index 026ee08244..90e43be834 100644 --- a/tests/test_reconcile_stores.py +++ b/tests/test_reconcile_stores.py @@ -120,7 +120,7 @@ def test_private_carry_plans_and_restores_missing_native_tables(private_comms): root = private_comms.root database = CoordinationStore.path_in(root) with Coordination(database) as store: - store.participants.register("retained", "Retained owner", "retained-thread", committed=True) + store.participants.register("retained", committed=True) retained = store.participants.get("retained") store.session._connection.execute(f"DROP TABLE {TriageNativeSources.declared_name}") diff --git a/tests/test_roster_restoration.py b/tests/test_roster_restoration.py index 7ff2e69c59..cc78480540 100644 --- a/tests/test_roster_restoration.py +++ b/tests/test_roster_restoration.py @@ -137,7 +137,7 @@ def test_private_restoration_allows_new_cohort_without_starting_old_subscribers( with Coordination(str(current.root / "coordination.sqlite3")) as store: PrivateCoordinatorTable.install(store) live_before = store.participants.register( - RecordedThreadIncarnation("fixture", live.created_at).lookup, live.name, live.name, committed=True + RecordedThreadIncarnation("fixture", live.created_at).lookup, committed=True ).value if repair_existing: current.registry.restore_stopped(source, (missing.name,)) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 05f242e39a..2e13fbc2b7 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -135,7 +135,7 @@ async def session_update(self, session_id, update): thread = comms.registry.require(original) with Coordination(str(comms.root / "coordination.sqlite3")) as store: PrivateCoordinatorTable.install(store) - store.participants.register(thread.incarnation.lookup, original, original, committed=True) + store.participants.register(thread.incarnation.lookup, committed=True) comms.threads.attach_session(thread, str(journal), process=thread.process_identity) comms.threads.rename_managed_thread(original, "renamed", owner=ProcessIdentity.capture(os.getpid())) requested = original if request_original else "renamed" @@ -317,9 +317,3 @@ async def test_new_session_metadata_has_a_display_title_before_first_switch(tmp_ ) finally: await agent.shutdown() - - - - - - diff --git a/tests/test_selected_answer_forms.py b/tests/test_selected_answer_forms.py index b276ac0400..c8dba71ceb 100644 --- a/tests/test_selected_answer_forms.py +++ b/tests/test_selected_answer_forms.py @@ -52,8 +52,7 @@ def alice_batch(*sent: tuple[str, str]): deliveries = [comms.bus.bus.delivery(m.seq) for m in messages] for recipient in deliveries[0].audience.recipients: store.participants.register( - recipient.recipient_lookup, recipient.canonical_thread, - recipient.canonical_thread, committed=True, + recipient.recipient_lookup, committed=True, ) for message in messages: accept_delivery_cohort(comms.bus, root_id, message.seq, store) @@ -107,5 +106,3 @@ def test_one_selected_turn_answers_one_reply_route() -> None: assert all(source.delivery.message.target == "Alice" for source in batch.sources) reply = batch.response_message("One answer for everyone.", "Alice") assert (reply.target, reply.body) == ("sender", "One answer for everyone.") - - diff --git a/tests/test_selected_source_batch.py b/tests/test_selected_source_batch.py index f3975c62ad..18e2a186b7 100644 --- a/tests/test_selected_source_batch.py +++ b/tests/test_selected_source_batch.py @@ -32,7 +32,7 @@ async def test_a_mixed_route_wave_turns_one_route_at_a_time(tmp_path, *, cursor_ root_id = comms.registry.prepare_native_root().namespace 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) + store.participants.register(lookup, committed=True) def send(target, body): message = publish_initial(comms, "sender", target, body) @@ -42,7 +42,7 @@ def send(target, body): direct = send("receiver", "A private question") # reply route: sender channel = send("#team", "A team question") # reply route: #team pending_wires = (direct.seq, channel.seq) - pending = pending_sealed_assignments(store, lookup, "receiver") + pending = pending_sealed_assignments(store, lookup) assert tuple(row.wire_seq for row in pending) == pending_wires async with SelectedParticipant.select( comms, store, root_id, "receiver", 0, cursor_delivery=cursor_delivery @@ -52,5 +52,4 @@ def send(target, body): # The channel original was never claimed by this turn: selection alone # settles nothing, so it stays pending for its own turn. assert tuple(row.wire_seq for row in - pending_sealed_assignments(store, lookup, "receiver")) == pending_wires - + pending_sealed_assignments(store, lookup)) == pending_wires diff --git a/tests/test_sqlite_connection_ownership.py b/tests/test_sqlite_connection_ownership.py index 4169473556..6539f1cc6c 100644 --- a/tests/test_sqlite_connection_ownership.py +++ b/tests/test_sqlite_connection_ownership.py @@ -62,7 +62,7 @@ def read(store): def commit(store): store.participants.register( - "reader-release-owner", "Reader Release Owner", "owner-thread" + "reader-release-owner" ) read_task = asyncio.create_task(Coordination.run_read_async(path, read)) @@ -116,7 +116,7 @@ async def test_resident_cancellation_joins_operation_before_retirement(tmp_path) def operation(store): begun.set() assert release.wait(2) - store.participants.register("joined", "Joined", "joined") + store.participants.register("joined") finished.set() async with Coordination.worker(path): pending = asyncio.create_task(Coordination.run_async(path, operation)) @@ -128,7 +128,7 @@ def operation(store): with pytest.raises(asyncio.CancelledError): await pending assert finished.is_set() - assert await Coordination.run_read_async(path, lambda store: store.participants.get("joined").owner_identity.owner_thread) == "joined" + assert await Coordination.run_read_async(path, lambda store: store.participants.get("joined").owner_identity.owner_lookup) == "joined" @pytest.mark.asyncio @@ -150,10 +150,10 @@ async def test_nested_resident_scopes_restore_outer_and_standalone_routes(tmp_pa outer, inner = tmp_path / "outer.sqlite3", tmp_path / "inner.sqlite3" async def register(path, name): - await Coordination.run_async(path, lambda store: store.participants.register(name, name, name)) + await Coordination.run_async(path, lambda store: store.participants.register(name)) async def owner(path, name): - return await Coordination.run_read_async(path, lambda store: store.participants.get(name).owner_identity.owner_thread) + return await Coordination.run_read_async(path, lambda store: store.participants.get(name).owner_identity.owner_lookup) async with Coordination.worker(outer): await register(outer, "outer") diff --git a/tests/test_table_mutation_capabilities.py b/tests/test_table_mutation_capabilities.py index 4a990af1f8..a72e287a96 100644 --- a/tests/test_table_mutation_capabilities.py +++ b/tests/test_table_mutation_capabilities.py @@ -343,8 +343,8 @@ def test_declared_insert_validator_receives_the_field_values(db): def test_owner_counter_refuses_skipping_a_generation(): with closing(SQLiteFile.writer(":memory:", isolation_level=None)) as connection: CoordinatorTable.create_schema(connection) - Participants(participant_lookup="owner", display_name="owner", committed=True).insert(connection) - OwnerGenerations(owner_lookup="owner", owner_thread="owner", generation=1).insert(connection) + Participants(participant_lookup="owner", committed=True).insert(connection) + OwnerGenerations(owner_lookup="owner", generation=1).insert(connection) with pytest.raises(sqlite3.IntegrityError): OwnerGenerations.update(connection, where="owner_lookup='owner'", generation=3) OwnerGenerations.update(connection, where="owner_lookup='owner'", generation=2) @@ -354,7 +354,7 @@ def test_normal_writer_replaces_old_guards_without_rewriting_rows(tmp_path): path = tmp_path / CoordinationStore.filename with closing(CoordinationStore(path)) as store: connection = store._connection - Participants(participant_lookup="owner", display_name="owner", committed=True).insert( + Participants(participant_lookup="owner", committed=True).insert( connection ) before = Participants.select(connection) diff --git a/tests/test_thread_identity.py b/tests/test_thread_identity.py index 1e96db0b6b..72890f618f 100644 --- a/tests/test_thread_identity.py +++ b/tests/test_thread_identity.py @@ -223,9 +223,9 @@ def test_coordination_assignment_generation_is_independent_of_registry_process(t registry = registry_with_owner(tmp_path) initial = registry.snapshot().owner_identity("owner") with Coordination(str(tmp_path / "coordination.sqlite3")) as store: - participant = store.participants.register("lookup", "Owner", "owner", committed=True).value + participant = store.participants.register("lookup", committed=True).value advanced = store.participants.advance_generation( - "lookup", "owner", expected_generation=participant.owner_identity.generation + "lookup", expected_generation=participant.owner_identity.generation ).value assert advanced.owner_identity.generation == participant.owner_identity.generation + 1 assert registry.snapshot().owner_identity("owner") == initial @@ -316,5 +316,3 @@ def test_same_launch_ignores_metadata_and_follows_process_session_and_model(tmp_ replace(thread, created_at=2.0), ): assert not thread.same_launch(changed) - - diff --git a/tests/test_thread_name_owners_carry.py b/tests/test_thread_name_owners_carry.py new file mode 100644 index 0000000000..4c2f98324c --- /dev/null +++ b/tests/test_thread_name_owners_carry.py @@ -0,0 +1,89 @@ +"""Retire with the one-shot carry after the retained buses have been installed.""" + +import os +import subprocess +import sys +from contextlib import closing +from pathlib import Path + +import pytest + +from agent_comms.coordination_database import CoordinationStore +from agent_comms.coordination_tables.attempts import AttemptRecord +from agent_comms.coordination_tables.participants import OwnerGenerations +from agent_comms.locked_store import RegistryFile, SQLiteFile +from agent_comms.maintenance_barrier import MaintenanceBarrier, MaintenanceNotHeldError, MaintenanceOperator +from agent_comms.native_prompt_binding import PromptBinding +from agent_comms.native_runtime_input import NativeRuntimeInput +from test_carry_bus_history import outgoing_source + +PREDECESSOR = "f2f80357091809ec925b306b46a4b9ed6db812a5" + + +def generate(root): + """Run under the predecessor's declarations, including its transition guards.""" + from agent_comms.native_input_record import FullNativeExecution + from test_coordination_response import _ready + + root.mkdir() + case = _ready(root, direct=True) + try: + row = NativeRuntimeInput( + input_id="1" * 32, stage=FullNativeExecution, + execution_id="exec", attempt_ordinal=1, + owner_lookup=case.owner_lookup, owner_thread="owner", owner_generation=1, + owner_token_digest=case.fence.authority.token_digest, + ) + row.insert(case.store.session._connection) + with closing(SQLiteFile.writer(PromptBinding.path_in(case.comms.root))) as db: + PromptBinding.create(db) + PromptBinding( + input_id=row.input_id, stage=FullNativeExecution, + execution_id="exec", attempt_ordinal=1, + owner_lookup=case.owner_lookup, owner_thread="owner", owner_generation=1, + wire_root_id=case.root_id, expected_prompt_digest="a" * 64, bound_at_ms=1, + ).insert(db) + finally: + case.close() + + +def test_carry_keeps_existing_attempts_inputs_and_bindings(tmp_path): + from cutover.thread_name_owners import ThreadNameOwners + + source = outgoing_source(tmp_path / "predecessor", PREDECESSOR) + generated = tmp_path / "generated" + result = subprocess.run( + [sys.executable, str(Path(__file__).resolve()), str(generated)], cwd=source, + env={**os.environ, "PYTHONPATH": os.pathsep.join((str(source / "src"), str(source / "tests")))}, + capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + root = generated / "wire" + step = ThreadNameOwners(root, root, tmp_path / "spill") + before = {path: path.read_bytes() for path in step.backup_files()} + pending = step.carry(apply=False) + assert pending.changed > 0 + assert pending.detail["attempts_before"] == pending.detail["attempts_after"] == 1 + assert pending.detail["native_runtime_inputs_before"] == pending.detail["native_runtime_inputs_after"] == 1 + assert pending.detail["prompt_binding_before"] == pending.detail["prompt_binding_after"] == 1 + assert all(path.read_bytes() == original for path, original in before.items()) + with pytest.raises(MaintenanceNotHeldError): + step.carry(apply=True) + assert all(path.read_bytes() == original for path, original in before.items()) + hold = MaintenanceBarrier(RegistryFile.path_in(root)).begin(MaintenanceOperator.current()) + with hold.holding(): + carried = step.carry(apply=True) + assert carried.detail == pending.detail + assert step.carry(apply=False).changed == 0 + with closing(SQLiteFile.read_snapshot(CoordinationStore.path_in(root))) as db: + attempt = AttemptRecord.one(db, execution_id="exec", attempt_ordinal=1) + native = NativeRuntimeInput.one(db, input_id="1" * 32) + assert native.owner_identity == attempt.owner_identity + assert OwnerGenerations.one(db, owner_lookup=attempt.owner_lookup) == attempt.owner_identity + assert not db.execute("SELECT 1 FROM sqlite_master WHERE name='participant_aliases'").fetchall() + with closing(SQLiteFile.read_snapshot(PromptBinding.path_in(root))) as db: + assert PromptBinding.one(db, input_id="1" * 32).owner_identity == native.owner_identity + + +if __name__ == "__main__": + generate(Path(sys.argv[1])) diff --git a/tools/cutover/thread_name_owners.py b/tools/cutover/thread_name_owners.py new file mode 100644 index 0000000000..463048eb2d --- /dev/null +++ b/tools/cutover/thread_name_owners.py @@ -0,0 +1,93 @@ +"""Drop copied thread names after the installer stops the old owners. + +The declared tables own their replacement schema and retained columns. Retire +this step after the retained buses have been installed with it. +""" + +from __future__ import annotations + +import sqlite3 +from contextlib import closing +from pathlib import Path + +from agent_comms.cohort_schema import AwarenessClaimGenerations +from agent_comms.coordination_database import CoordinationStore +from agent_comms.coordination_tables.attempts import AttemptRecord +from agent_comms.coordination_tables.executions import ExecutionRecord +from agent_comms.coordination_tables.participants import OwnerGenerations, Participants +from agent_comms.errors import RelationViolationError +from agent_comms.installer.carry import Carried, Carry +from agent_comms.locked_store import SQLiteFile +from agent_comms.native_prompt_binding import PromptBinding +from agent_comms.native_runtime_input import CurrentNativeCursor, NativeRuntimeInput +from agent_comms.store_files import _store_lock + + +class ThreadNameOwners(Carry): + def backup_files(self) -> tuple[Path, ...]: + paths = (CoordinationStore.path_in(self.root), PromptBinding.path_in(self.root)) + for path in paths: + self.require_confined(self.root, path) + return tuple(path for path in paths if path.exists()) + + def carry(self, *, apply: bool) -> Carried: + paths = self.backup_files() + intent = self.root / ".private-owner-rename.pending" + if intent.exists() or intent.is_symlink(): + raise RelationViolationError("Inspect the unfinished old rename before carrying its bus.") + changed = 0 + detail: dict[str, int] = {} + rewritten: list[str] = [] + for path in paths: + with _store_lock(path), closing(SQLiteFile.read_snapshot(path)) as source, closing( + SQLiteFile.writer(":memory:", isolation_level=None) + ) as db: + source.backup(db) + tables = ( + (Participants, OwnerGenerations, ExecutionRecord, AttemptRecord, + NativeRuntimeInput, CurrentNativeCursor, AwarenessClaimGenerations) + if path.name == CoordinationStore.filename else (PromptBinding,) + ) + present = tuple(table for table in tables if table.stored_columns(db)) + pending = tuple(table for table in present if table.schema_differs(db)) + aliases = db.execute( + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='participant_aliases'" + ).fetchone()[0] + if not pending and not aliases: + continue + before = { + table.declared_name: db.execute( + f'SELECT count(*) FROM "{table.declared_name}"' + ).fetchone()[0] + for table in present + } + # Rebuild preserves existing lifecycle rows. Insert guards are + # for new transitions, not for replaying sealed historical rows. + db.execute("PRAGMA foreign_keys=OFF") + db.setconfig(sqlite3.SQLITE_DBCONFIG_ENABLE_TRIGGER, False) + with SQLiteFile.write_transaction(db): + for table in pending: + table.rebuild(db) + if aliases: + db.execute("DROP TABLE participant_aliases") + db.setconfig(sqlite3.SQLITE_DBCONFIG_ENABLE_TRIGGER, True) + db.execute("PRAGMA foreign_keys=ON") + for table in present: + table.require_schema(db) + after = db.execute(f'SELECT count(*) FROM "{table.declared_name}"').fetchone()[0] + if before[table.declared_name] != after: + raise RelationViolationError(f"Carry changed {table.declared_name} row count.") + detail[f"{table.declared_name}_before"] = before[table.declared_name] + detail[f"{table.declared_name}_after"] = after + for _ in table.stream(db): + pass + if list(db.execute("PRAGMA foreign_key_check")): + raise RelationViolationError("Carried owner tables have broken foreign keys.") + changed += len(pending) + aliases + if apply: + owner = CoordinationStore if path.name == CoordinationStore.filename else PromptBinding + owner.schema_change(path).require() + with closing(SQLiteFile.writer(path)) as destination: + db.backup(destination) + rewritten.append(str(path.relative_to(self.root))) + return Carried(changed, detail, tuple(rewritten)) From 622849db52af75a6367277c613be85feca71fd36 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:22:06 -0400 Subject: [PATCH 10/17] Use the maintenance owner for carries and the predecessor fixture source --- src/agent_comms/optional_awareness_projection.py | 1 - src/agent_comms/private_registry_guard.py | 4 ++-- tests/test_thread_name_owners_carry.py | 5 ++++- tools/cutover/thread_name_owners.py | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/agent_comms/optional_awareness_projection.py b/src/agent_comms/optional_awareness_projection.py index b511e52d08..8a1afdecdd 100644 --- a/src/agent_comms/optional_awareness_projection.py +++ b/src/agent_comms/optional_awareness_projection.py @@ -29,7 +29,6 @@ from agent_comms.coordination_tables.participants import OwnerGenerations from agent_comms.coordination_tables.responses import ResponseObligation -from .audience_manifest import FrozenRecipient from .bus_publication import Delivered from .child_process import ProcessIdentity from .cohort_schema import ( diff --git a/src/agent_comms/private_registry_guard.py b/src/agent_comms/private_registry_guard.py index b366ebbdf7..8b4388fe91 100644 --- a/src/agent_comms/private_registry_guard.py +++ b/src/agent_comms/private_registry_guard.py @@ -24,7 +24,7 @@ import struct from abc import ABC, abstractmethod from collections.abc import Iterable, Sequence -from dataclasses import dataclass, replace +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Annotated, NoReturn @@ -33,7 +33,7 @@ from .coordination_database import CoordinationStore from .coordinator import Coordination from .errors import RelationViolationError -from .field_codec import FieldCodec, Id128Text, MalformedRecord, WireName +from .field_codec import FieldCodec, Id128Text, MalformedRecord from .locked_store import DurableFile, WireWakeFile from .native_runtime_input import NativeRuntimeInput, NativeRuntimeTable diff --git a/tests/test_thread_name_owners_carry.py b/tests/test_thread_name_owners_carry.py index 4c2f98324c..274d7f47a7 100644 --- a/tests/test_thread_name_owners_carry.py +++ b/tests/test_thread_name_owners_carry.py @@ -1,6 +1,7 @@ """Retire with the one-shot carry after the retained buses have been installed.""" import os +import shutil import subprocess import sys from contextlib import closing @@ -52,8 +53,10 @@ def test_carry_keeps_existing_attempts_inputs_and_bindings(tmp_path): source = outgoing_source(tmp_path / "predecessor", PREDECESSOR) generated = tmp_path / "generated" + runner = source / Path(__file__).name + shutil.copyfile(__file__, runner) result = subprocess.run( - [sys.executable, str(Path(__file__).resolve()), str(generated)], cwd=source, + [sys.executable, str(runner), str(generated)], cwd=source, env={**os.environ, "PYTHONPATH": os.pathsep.join((str(source / "src"), str(source / "tests")))}, capture_output=True, text=True, timeout=60, ) diff --git a/tools/cutover/thread_name_owners.py b/tools/cutover/thread_name_owners.py index 463048eb2d..f515b9fb2d 100644 --- a/tools/cutover/thread_name_owners.py +++ b/tools/cutover/thread_name_owners.py @@ -21,6 +21,7 @@ from agent_comms.native_prompt_binding import PromptBinding from agent_comms.native_runtime_input import CurrentNativeCursor, NativeRuntimeInput from agent_comms.store_files import _store_lock +from agent_comms.typed_table import HeldBusFile class ThreadNameOwners(Carry): @@ -86,7 +87,7 @@ def carry(self, *, apply: bool) -> Carried: changed += len(pending) + aliases if apply: owner = CoordinationStore if path.name == CoordinationStore.filename else PromptBinding - owner.schema_change(path).require() + HeldBusFile(owner, path).require() with closing(SQLiteFile.writer(path)) as destination: db.backup(destination) rewritten.append(str(path.relative_to(self.root))) From 93cdb708c2871a0cd1803b564dbc68b08c84bb0d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:25:13 -0400 Subject: [PATCH 11/17] Retire declared dependent views before rebuilding retained owner tables --- src/agent_comms/private_registry_guard.py | 2 -- tools/cutover/thread_name_owners.py | 5 ++++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/private_registry_guard.py b/src/agent_comms/private_registry_guard.py index 8b4388fe91..b2615c2cc4 100644 --- a/src/agent_comms/private_registry_guard.py +++ b/src/agent_comms/private_registry_guard.py @@ -24,7 +24,6 @@ import struct from abc import ABC, abstractmethod from collections.abc import Iterable, Sequence -from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Annotated, NoReturn @@ -44,7 +43,6 @@ from .notification_assignment import NotificationAssignmentRead from .private_nk_entrypoint import RootLaunch from .registry_document import RegistrySnapshot - from .thread_management import ThreadManagement from .threads import Thread from .typed_table import TableSchema diff --git a/tools/cutover/thread_name_owners.py b/tools/cutover/thread_name_owners.py index f515b9fb2d..53507f825d 100644 --- a/tools/cutover/thread_name_owners.py +++ b/tools/cutover/thread_name_owners.py @@ -21,7 +21,7 @@ from agent_comms.native_prompt_binding import PromptBinding from agent_comms.native_runtime_input import CurrentNativeCursor, NativeRuntimeInput from agent_comms.store_files import _store_lock -from agent_comms.typed_table import HeldBusFile +from agent_comms.typed_table import HeldBusFile, SQLiteCatalogObject class ThreadNameOwners(Carry): @@ -67,6 +67,9 @@ def carry(self, *, apply: bool) -> Carried: db.execute("PRAGMA foreign_keys=OFF") db.setconfig(sqlite3.SQLITE_DBCONFIG_ENABLE_TRIGGER, False) with SQLiteFile.write_transaction(db): + SQLiteCatalogObject.carry( + db, {}, frozenset(name for table in pending for name in table.schema_objects()) + ) for table in pending: table.rebuild(db) if aliases: From 88360bf2698df01bc13aa3a564fc6abd5d1b0d4b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:29:41 -0400 Subject: [PATCH 12/17] Carry prompt bindings through their snapshot owner and retire deleted-behavior tests --- tests/test_claim_admission_verifier.py | 19 ------------ tests/test_coordination.py | 3 -- tests/test_thread_name_owners_carry.py | 15 ++++----- tools/cutover/thread_name_owners.py | 43 ++++++++++++++++++++------ 4 files changed, 41 insertions(+), 39 deletions(-) diff --git a/tests/test_claim_admission_verifier.py b/tests/test_claim_admission_verifier.py index 3582601dd2..98573240a7 100644 --- a/tests/test_claim_admission_verifier.py +++ b/tests/test_claim_admission_verifier.py @@ -137,25 +137,6 @@ def test_selected_wake_verifier_refuses_no_wake_and_stale_authority( == selected_owner ) assert len(comms.bus.bus.scoped_page("1", (), limit=100)) == 2 - # Simulate a crash after the durable rename intent but before SQL - # owner CAS: an already selected attempt and exact resource claim - # must not admit a new claim or touch the existing file. - intent = root / ".private-owner-rename.pending" - intent.write_text("pending owner transition") - with intent.open("rb") as stream: - os.fsync(stream.fileno()) - with pytest.raises(IdentityConflict): - verify_selected_wake(comms, store, claim_scope, "Alice") - with pytest.raises(RelationViolationError): - publish_selected_resource_claim( - comms, store, claim_scope, "Alice", ExistingFileClaim(Path(resource)) - ) - with pytest.raises(RelationViolationError): - write_selected_claimed_file( - comms, store, claim_scope, "Alice", selected_owner, b"forbidden\n" - ) - assert resource.read_bytes() == b"value = 1\n" - intent.unlink() # Fixture-only return to the positive pre-rename case. outside = Path(dirname) / "outside" outside.mkdir(mode=0o700) external = outside / "module.py" diff --git a/tests/test_coordination.py b/tests/test_coordination.py index 373e7f3da4..10df748548 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -1253,7 +1253,6 @@ def test_populated_durable_authorities_reject_delete_and_survive_reopen(db): c, path = db sql_wire_execution(c) activate(c) - c.execute("INSERT INTO participant_aliases VALUES ('old-worker','p',100)") c.execute("INSERT INTO replay_assessments VALUES ('e',0,1,0,1)") c.execute("INSERT INTO connectivity VALUES ('e','connected','connected',1,100)") c.execute( @@ -1263,7 +1262,6 @@ def test_populated_durable_authorities_reject_delete_and_survive_reopen(db): ) tables = ( "participants", - "participant_aliases", "owner_generations", "executions", "attempts", @@ -1414,7 +1412,6 @@ def test_recovery_and_connectivity_monotonic_reopen(db): "publication_receipts", "connectivity", "recovery_audit", - "participant_aliases", ], ) def test_all_durable_tables_reject_delete_even_when_empty(db, table): diff --git a/tests/test_thread_name_owners_carry.py b/tests/test_thread_name_owners_carry.py index 274d7f47a7..ec6b8a657d 100644 --- a/tests/test_thread_name_owners_carry.py +++ b/tests/test_thread_name_owners_carry.py @@ -24,6 +24,7 @@ def generate(root): """Run under the predecessor's declarations, including its transition guards.""" from agent_comms.native_input_record import FullNativeExecution + from agent_comms.private_sidecar import create_sidecar_file, sidecar_connection from test_coordination_response import _ready root.mkdir() @@ -31,16 +32,17 @@ def generate(root): try: row = NativeRuntimeInput( input_id="1" * 32, stage=FullNativeExecution, - execution_id="exec", attempt_ordinal=1, + **FullNativeExecution("exec", 1).binding_fields(), owner_lookup=case.owner_lookup, owner_thread="owner", owner_generation=1, owner_token_digest=case.fence.authority.token_digest, ) row.insert(case.store.session._connection) - with closing(SQLiteFile.writer(PromptBinding.path_in(case.comms.root))) as db: - PromptBinding.create(db) + binding_path = PromptBinding.path_in(case.comms.root) + create_sidecar_file(binding_path, PromptBinding) + with sidecar_connection(binding_path, PromptBinding) as db: PromptBinding( input_id=row.input_id, stage=FullNativeExecution, - execution_id="exec", attempt_ordinal=1, + **FullNativeExecution("exec", 1).binding_fields(), owner_lookup=case.owner_lookup, owner_thread="owner", owner_generation=1, wire_root_id=case.root_id, expected_prompt_digest="a" * 64, bound_at_ms=1, ).insert(db) @@ -66,9 +68,8 @@ def test_carry_keeps_existing_attempts_inputs_and_bindings(tmp_path): before = {path: path.read_bytes() for path in step.backup_files()} pending = step.carry(apply=False) assert pending.changed > 0 - assert pending.detail["attempts_before"] == pending.detail["attempts_after"] == 1 - assert pending.detail["native_runtime_inputs_before"] == pending.detail["native_runtime_inputs_after"] == 1 - assert pending.detail["prompt_binding_before"] == pending.detail["prompt_binding_after"] == 1 + for table in (AttemptRecord, NativeRuntimeInput, PromptBinding): + assert pending.detail[f"{table.declared_name}_before"] == pending.detail[f"{table.declared_name}_after"] == 1 assert all(path.read_bytes() == original for path, original in before.items()) with pytest.raises(MaintenanceNotHeldError): step.carry(apply=True) diff --git a/tools/cutover/thread_name_owners.py b/tools/cutover/thread_name_owners.py index 53507f825d..a63f61bd5d 100644 --- a/tools/cutover/thread_name_owners.py +++ b/tools/cutover/thread_name_owners.py @@ -7,7 +7,7 @@ from __future__ import annotations import sqlite3 -from contextlib import closing +from contextlib import ExitStack, closing from pathlib import Path from agent_comms.cohort_schema import AwarenessClaimGenerations @@ -20,8 +20,11 @@ from agent_comms.locked_store import SQLiteFile from agent_comms.native_prompt_binding import PromptBinding from agent_comms.native_runtime_input import CurrentNativeCursor, NativeRuntimeInput +from agent_comms.private_sidecar import ( + SnapshotMeta, _connect, _digest, _locked_directory, _publish, _read_snapshot, _verify_schema, +) from agent_comms.store_files import _store_lock -from agent_comms.typed_table import HeldBusFile, SQLiteCatalogObject +from agent_comms.typed_table import HeldBusFile, SQLiteCatalogObject, SQLiteSchemaObject class ThreadNameOwners(Carry): @@ -40,17 +43,29 @@ def carry(self, *, apply: bool) -> Carried: detail: dict[str, int] = {} rewritten: list[str] = [] for path in paths: - with _store_lock(path), closing(SQLiteFile.read_snapshot(path)) as source, closing( - SQLiteFile.writer(":memory:", isolation_level=None) - ) as db: - source.backup(db) + with ExitStack() as lifetime: + sidecar = path.name == PromptBinding.filename + if sidecar: + directory = lifetime.enter_context(_locked_directory(path)) + snapshot = _read_snapshot(directory, path.name) + if snapshot is None: + raise RelationViolationError("Prompt binding disappeared before its carry.") + raw, identity = snapshot + db = lifetime.enter_context(closing(_connect(raw))) + else: + lifetime.enter_context(_store_lock(path)) + source = lifetime.enter_context(closing(SQLiteFile.read_snapshot(path))) + db = lifetime.enter_context(closing(SQLiteFile.writer(":memory:", isolation_level=None))) + source.backup(db) tables = ( (Participants, OwnerGenerations, ExecutionRecord, AttemptRecord, NativeRuntimeInput, CurrentNativeCursor, AwarenessClaimGenerations) if path.name == CoordinationStore.filename else (PromptBinding,) ) present = tuple(table for table in tables if table.stored_columns(db)) - pending = tuple(table for table in present if table.schema_differs(db)) + pending = tuple(table for table in present if SQLiteSchemaObject.differences( + table.stored_objects(db), table.schema_objects() + )) aliases = db.execute( "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='participant_aliases'" ).fetchone()[0] @@ -74,10 +89,13 @@ def carry(self, *, apply: bool) -> Carried: table.rebuild(db) if aliases: db.execute("DROP TABLE participant_aliases") + if sidecar: + SnapshotMeta.update(db, where="singleton=1", ddl_digest=_digest(PromptBinding)) db.setconfig(sqlite3.SQLITE_DBCONFIG_ENABLE_TRIGGER, True) db.execute("PRAGMA foreign_keys=ON") for table in present: - table.require_schema(db) + if SQLiteSchemaObject.differences(table.stored_objects(db), table.schema_objects()): + raise RelationViolationError(f"Carried {table.declared_name} schema differs.") after = db.execute(f'SELECT count(*) FROM "{table.declared_name}"').fetchone()[0] if before[table.declared_name] != after: raise RelationViolationError(f"Carry changed {table.declared_name} row count.") @@ -87,11 +105,16 @@ def carry(self, *, apply: bool) -> Carried: pass if list(db.execute("PRAGMA foreign_key_check")): raise RelationViolationError("Carried owner tables have broken foreign keys.") + if sidecar: + _verify_schema(db, PromptBinding) changed += len(pending) + aliases if apply: owner = CoordinationStore if path.name == CoordinationStore.filename else PromptBinding HeldBusFile(owner, path).require() - with closing(SQLiteFile.writer(path)) as destination: - db.backup(destination) + if sidecar: + _publish(directory, path.name, identity, db.serialize()) + else: + with closing(SQLiteFile.writer(path)) as destination: + db.backup(destination) rewritten.append(str(path.relative_to(self.root))) return Carried(changed, detail, tuple(rewritten)) From 55550684ef4f384ffdf37394e261e52daabff47c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:33:56 -0400 Subject: [PATCH 13/17] Drop the stale PR list from the fleet awareness phase Co-Authored-By: Claude Opus 5.5 --- plans/commander-native-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/commander-native-plan.md b/plans/commander-native-plan.md index 260e222355..d21dcf34e7 100644 --- a/plans/commander-native-plan.md +++ b/plans/commander-native-plan.md @@ -267,7 +267,7 @@ rebuildable projections follow their producing Core build. ### Phase 10: fleet awareness (after the four plans) -**Why.** On 2026-10-23 the bus was down for the installer route fix (#2633). Eight Codex threads were coordinated through `codex queue` and one handoff file, and the commander kept the map by hand-run scripts: a git overlap scan and a thread rotation reader. Every finding below came from those scripts or from Tristan noticing. Each finding is a fact Core can derive from git, its order records or turn records, so a commander that has to remember it is a defect. Tristan, 2026-10-23: "i can't wait to have all the infra together so i don't need to manually remember this shit". This phase starts after the virtual bus, multiple inheritance, harness and this plan's phase 9 are done. Its `PlanPhase` declaration in `plan_phases.py` is the first edit of its first order. It is not in this change, because three open PRs (#2622, #2627, #2635) already edit that file. +**Why.** On 2026-10-23 the bus was down for the installer route fix (#2633). Eight Codex threads were coordinated through `codex queue` and one handoff file, and the commander kept the map by hand-run scripts: a git overlap scan and a thread rotation reader. Every finding below came from those scripts or from Tristan noticing. Each finding is a fact Core can derive from git, its order records or turn records, so a commander that has to remember it is a defect. Tristan, 2026-10-23: "i can't wait to have all the infra together so i don't need to manually remember this shit". This phase starts after the virtual bus, multiple inheritance, harness and this plan's phase 9 are done. Its `PlanPhase` declaration in `plan_phases.py` is the first edit of its first order. **Changes.** Each line names the fact, its owner, and the 2026-10-23 incident it would have caught. - **One holder per file, refused at order time.** `CommsOrderTool` refuses an order whose scope's files overlap a file already held by another open order or by another open branch's diff. The refusal names both holders. Incident: two threads wrote the same route fix in `installer/backup.py` until Tristan reassigned it by hand. From b057f5f20cb181f8ce62969e05ef8b9609bfa5b2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:34:02 -0400 Subject: [PATCH 14/17] Exclude phase declarations from pattern evidence searches --- src/agent_comms/phase_evidence.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/agent_comms/phase_evidence.py b/src/agent_comms/phase_evidence.py index 51d3e8d67f..5ec3664824 100644 --- a/src/agent_comms/phase_evidence.py +++ b/src/agent_comms/phase_evidence.py @@ -252,7 +252,10 @@ class PatternEvidence(MainEvidence): paths: tuple[str, ...] = ("src",) def read(self, main: MainTree) -> bool: - return self.satisfied(main.count(self.pattern, self.paths)) + from .plan_phases import PlanPhase + + declaration = main.repo.module_file(PlanPhase.__module__) + return self.satisfied(main.count(self.pattern, (*self.paths, f":!{declaration.as_posix()}"))) @abstractmethod def satisfied(self, count: int) -> bool: ... From 363c489b5bb4e4ad2b56f350be3d8c949f88f193 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:34:30 -0400 Subject: [PATCH 15/17] Give commander package resources their own owner --- src/agent_comms/commander/config.py | 26 ------------------ src/agent_comms/commander/package.py | 32 ++++++++++++++++++++++ src/agent_comms/commander/rule_file.py | 4 +-- src/agent_comms/commander/vibe_check.py | 6 ++-- src/agent_comms/unit_instructions.py | 4 +-- src/agent_comms_hooks/skill.py | 11 ++++---- tests/test_commander_skill_hook.py | 21 +++++++------- tests/test_selected_tool_native_package.py | 2 +- tests/test_unit_instructions.py | 6 ++-- 9 files changed, 60 insertions(+), 52 deletions(-) create mode 100644 src/agent_comms/commander/package.py diff --git a/src/agent_comms/commander/config.py b/src/agent_comms/commander/config.py index 1f237dd4ef..134128315b 100644 --- a/src/agent_comms/commander/config.py +++ b/src/agent_comms/commander/config.py @@ -7,7 +7,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from importlib.resources import files from pathlib import Path from typing import TYPE_CHECKING, Annotated, ClassVar, Self @@ -66,23 +65,6 @@ class CommanderConfig(CommanderRole, BaseModel, DurableFile): def path(home: Path | None = None) -> Path: return (home or Path.home()) / '.config/agent-comms' / CommanderConfig.filename - @staticmethod - def packaged_rules() -> Path: - return Path(str(files(__package__) / 'rules')) - - @staticmethod - def packaged_skill() -> Path: - return Path(str(files('agent_comms.skills') / 'comms-commander/SKILL.md')) - - @staticmethod - def packaged_indictment() -> Path: - return Path(str(files(__package__) / 'indictment.md')) - - - @staticmethod - def packaged_voice() -> Path: - return Path(str(files(__package__) / 'voice.md')) - @field_validator('root') @classmethod def selected_root(cls, root: Path, info: ValidationInfo) -> Path: @@ -106,14 +88,6 @@ def on_machine(cls, commander: str, repos: tuple[Repo, ...], root: Path, home: P state=home / '.local/state/agent-comms/commander', worktrees=home / 'wt', scratch=home / '.cache/agent-scratch', disk=home) - @classmethod - def packaged_rules_files(cls) -> tuple[Path, ...]: - """All packaged rule files, shared by review and session context.""" - rules = tuple(sorted(path for path in cls.packaged_rules().iterdir() if path.is_file())) - if not rules: - raise ValueError('The packaged commander rules directory is empty.') - return rules - def repo_directory(self, name: str) -> Path: """Only the configured repositories can supply an order's check checkout.""" for repo in self.repos: diff --git a/src/agent_comms/commander/package.py b/src/agent_comms/commander/package.py new file mode 100644 index 0000000000..0d0bdb7b88 --- /dev/null +++ b/src/agent_comms/commander/package.py @@ -0,0 +1,32 @@ +"""Files shipped with the commander, independent of any machine's configuration.""" + +from importlib.resources import files +from pathlib import Path + + +class CommanderPackage: + """The installed package owns its instructions and their declared locations.""" + + @classmethod + def rules(cls) -> Path: + return Path(str(files(__package__) / "rules")) + + @classmethod + def rules_files(cls) -> tuple[Path, ...]: + """All package rule files, shared by review and session context.""" + rules = tuple(sorted(path for path in cls.rules().iterdir() if path.is_file())) + if not rules: + raise ValueError("The packaged commander rules directory is empty.") + return rules + + @classmethod + def skill(cls) -> Path: + return Path(str(files("agent_comms.skills") / "comms-commander/SKILL.md")) + + @classmethod + def indictment(cls) -> Path: + return Path(str(files(__package__) / "indictment.md")) + + @classmethod + def voice(cls) -> Path: + return Path(str(files(__package__) / "voice.md")) diff --git a/src/agent_comms/commander/rule_file.py b/src/agent_comms/commander/rule_file.py index 32967a635c..414894072b 100644 --- a/src/agent_comms/commander/rule_file.py +++ b/src/agent_comms/commander/rule_file.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import ClassVar, Self -from agent_comms.commander.config import CommanderConfig +from agent_comms.commander.package import CommanderPackage from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec, boundary_decoder @@ -89,7 +89,7 @@ def read(cls, path: Path) -> Self: def packaged(cls) -> tuple[Self, ...]: return tuple( sorted( - (cls.read(path) for path in CommanderConfig.packaged_rules_files()), + (cls.read(path) for path in CommanderPackage.rules_files()), key=lambda rule: (cls.kinds().index(type(rule)), rule.name), ) ) diff --git a/src/agent_comms/commander/vibe_check.py b/src/agent_comms/commander/vibe_check.py index 9096489391..717fbb694d 100644 --- a/src/agent_comms/commander/vibe_check.py +++ b/src/agent_comms/commander/vibe_check.py @@ -15,7 +15,7 @@ from typing import ClassVar, Self from agent_comms.bus import BusReader -from agent_comms.commander.config import CommanderConfig +from agent_comms.commander.package import CommanderPackage from agent_comms.commander.questions import SenderMessages, UnansweredBurst from agent_comms.commander.standing import AnswersTo, PlanStalled from agent_comms.comms import Comms @@ -254,7 +254,7 @@ def indictment(self) -> tuple[str, ...]: return ( f"VIBE CHECK ({self.sign.mark}). This is about you, right now, not the units.", self.sign.line, - f"Stop. Reread {CommanderConfig.packaged_indictment()} in full. You are doing what it" + f"Stop. Reread {CommanderPackage.indictment()} in full. You are doing what it" " describes, this hour.", "Answer this burst once, naming any one of its messages with --answers: which failure" " in the indictment is you right now, why, and the one structural change you make" @@ -294,7 +294,7 @@ class Indictment: @staticmethod def text() -> str: - return " ".join(CommanderConfig.packaged_indictment().read_text(encoding="utf-8").split()) + return " ".join(CommanderPackage.indictment().read_text(encoding="utf-8").split()) @classmethod def quoted(cls, body: str) -> bool: diff --git a/src/agent_comms/unit_instructions.py b/src/agent_comms/unit_instructions.py index 0176607e62..2b15a16aae 100644 --- a/src/agent_comms/unit_instructions.py +++ b/src/agent_comms/unit_instructions.py @@ -11,7 +11,7 @@ import tempfile from pathlib import Path -from agent_comms.commander.config import CommanderConfig +from agent_comms.commander.package import CommanderPackage from agent_comms.commander.rule_file import RuleFile from agent_comms.native_package import OWNER_INSTRUCTIONS, UNIT_INSTRUCTIONS from agent_comms.todos import Slop @@ -24,7 +24,7 @@ class UnitInstructions: def render() -> str: sections = ( OWNER_INSTRUCTIONS.read_text(encoding="utf-8"), - CommanderConfig.packaged_indictment().read_text(encoding="utf-8"), + CommanderPackage.indictment().read_text(encoding="utf-8"), "# Rules every unit follows\n\n" + "\n\n".join(text for rule in RuleFile.packaged() for text in rule.unit_rules()), (Slop.catalog() / "README.md").read_text(encoding="utf-8"), diff --git a/src/agent_comms_hooks/skill.py b/src/agent_comms_hooks/skill.py index b9c3edb3a4..6d4cf76cec 100644 --- a/src/agent_comms_hooks/skill.py +++ b/src/agent_comms_hooks/skill.py @@ -19,6 +19,7 @@ from agent_comms.behaviour_patterns import PostmortemFailure from agent_comms.commander.config import CommanderConfig +from agent_comms.commander.package import CommanderPackage from agent_comms.commander.rule_file import RuleFile from agent_comms.todos import Slop from agent_comms_hooks import ClaudeHookProgram @@ -82,9 +83,9 @@ def slices(cls) -> list[str]: """The voice, the indictment, the drift tells and the recorded failures, one slice of each per round, so each comes back every fourth slice and the longest source runs through once. The failures are the habits caught so far; they are called out by name, repeatedly.""" - voice = cls.paragraphs(CommanderConfig.packaged_voice().read_text(encoding="utf-8")) + voice = cls.paragraphs(CommanderPackage.voice().read_text(encoding="utf-8")) indictment = cls.paragraphs( - CommanderConfig.packaged_indictment().read_text(encoding="utf-8")) + CommanderPackage.indictment().read_text(encoding="utf-8")) tells = RuleFile.drift_tells() failures = tuple(f"Recorded failure {line}" for line in PostmortemFailure.catalog().splitlines()) streams = (voice, indictment, tells, failures) @@ -109,10 +110,10 @@ def sources(cls) -> str: return "\n\n".join( ( cls.notice(), - CommanderConfig.packaged_voice().read_text(encoding="utf-8", newline=""), - CommanderConfig.packaged_indictment().read_text(encoding="utf-8", newline=""), + CommanderPackage.voice().read_text(encoding="utf-8", newline=""), + CommanderPackage.indictment().read_text(encoding="utf-8", newline=""), "# Recorded failures\n\n" + PostmortemFailure.catalog(), - RuleFile.document(CommanderConfig.packaged_skill())[1], + RuleFile.document(CommanderPackage.skill())[1], *(rule.render() for rule in RuleFile.packaged()), (Slop.catalog() / "README.md").read_text(encoding="utf-8", newline=""), ) diff --git a/tests/test_commander_skill_hook.py b/tests/test_commander_skill_hook.py index 961bbe39ff..4eafee507a 100644 --- a/tests/test_commander_skill_hook.py +++ b/tests/test_commander_skill_hook.py @@ -10,6 +10,7 @@ from agent_comms.behaviour_patterns import PostmortemFailure from agent_comms.commander.config import CommanderConfig +from agent_comms.commander.package import CommanderPackage from agent_comms.commander.rule_file import RuleFile from agent_comms.todos import Slop from agent_comms_hooks.reread import SliceReminder @@ -18,7 +19,7 @@ @pytest.mark.parametrize("source", ["startup", "resume", "compact"]) @pytest.mark.parametrize("configuration", ["configured", "missing", "invalid"]) -def test_session_start_reads_all_packaged_files_without_a_bus(tmp_path, source, configuration): +def test_session_start_reads_all_package_files_without_a_bus(tmp_path, source, configuration): home = tmp_path / "home" home.mkdir() bus = tmp_path / "bus" @@ -65,10 +66,10 @@ def test_session_start_reads_all_packaged_files_without_a_bus(tmp_path, source, ) assert text.startswith(expected_notice) assert text.split("\n\n", 1)[1] == "\n\n".join(( - CommanderConfig.packaged_voice().read_text(), - CommanderConfig.packaged_indictment().read_text(), + CommanderPackage.voice().read_text(), + CommanderPackage.indictment().read_text(), "# Recorded failures\n\n" + PostmortemFailure.catalog(), - RuleFile.document(CommanderConfig.packaged_skill())[1], + RuleFile.document(CommanderPackage.skill())[1], *(rule.render() for rule in RuleFile.packaged()), (Slop.catalog() / "README.md").read_text(), )) @@ -103,8 +104,8 @@ def test_a_slice_every_few_calls_rotating_and_dealt_again_at_compaction(tmp_path def test_the_deck_carries_the_voice_every_fourth_slice_every_drift_tell_and_every_failure(): slices = CommanderStart.slices() - voice = CommanderStart.paragraphs(CommanderConfig.packaged_voice().read_text()) - indictment = CommanderStart.paragraphs(CommanderConfig.packaged_indictment().read_text()) + voice = CommanderStart.paragraphs(CommanderPackage.voice().read_text()) + indictment = CommanderStart.paragraphs(CommanderPackage.indictment().read_text()) assert set(slices[0::4]) == set(voice) assert set(slices[1::4]) == set(indictment) assert set(slices[2::4]) == set(RuleFile.drift_tells()) @@ -137,13 +138,13 @@ def run(program, event): assert "Read each of these" not in context -@pytest.mark.parametrize("path", CommanderConfig.packaged_rules_files(), ids=lambda path: path.name) -def test_packaged_rule_has_a_reason(path): +@pytest.mark.parametrize("path", CommanderPackage.rules_files(), ids=lambda path: path.name) +def test_package_rule_has_a_reason(path): assert RuleFile.read(path).why -def test_packaged_descriptions_do_not_repeat_rule_kind(): - for path in CommanderConfig.packaged_rules_files(): +def test_package_descriptions_do_not_repeat_rule_kind(): + for path in CommanderPackage.rules_files(): metadata, _ = RuleFile.document(path) assert not metadata["description"].startswith("HARD RULE:"), path.name diff --git a/tests/test_selected_tool_native_package.py b/tests/test_selected_tool_native_package.py index 5c37da3f1a..d6dacfdcb1 100644 --- a/tests/test_selected_tool_native_package.py +++ b/tests/test_selected_tool_native_package.py @@ -10,7 +10,7 @@ @pytest.mark.parametrize("packaged", [True, False]) -def test_actual_pi_loader_admits_packaged_channel_tool_only(tmp_path, packaged, pinned_native): +def test_actual_pi_loader_admits_channel_tool_from_package_only(tmp_path, packaged, pinned_native): package = pinned_native extension = selected_extension(package) if not packaged: diff --git a/tests/test_unit_instructions.py b/tests/test_unit_instructions.py index 50e427cf1d..0514791dba 100644 --- a/tests/test_unit_instructions.py +++ b/tests/test_unit_instructions.py @@ -2,7 +2,7 @@ import pytest -from agent_comms.commander.config import CommanderConfig +from agent_comms.commander.package import CommanderPackage from agent_comms.commander.rule_file import CommanderAudience, FleetAudience, RuleFile from agent_comms.field_codec import MalformedRecord from agent_comms.native_package import OWNER_INSTRUCTIONS @@ -12,7 +12,7 @@ def test_unit_prompt_carries_fleet_rules_and_never_commander_process(tmp_path): text = UnitInstructions.prepare(tmp_path / "UNIT_SYSTEM.md").read_text(encoding="utf-8") - for source in (OWNER_INSTRUCTIONS, CommanderConfig.packaged_indictment(), Slop.catalog() / "README.md"): + for source in (OWNER_INSTRUCTIONS, CommanderPackage.indictment(), Slop.catalog() / "README.md"): assert source.read_text(encoding="utf-8").strip() in text, source rules = RuleFile.packaged() fleet = [rule for rule in rules if rule.audience is FleetAudience] @@ -23,7 +23,7 @@ def test_unit_prompt_carries_fleet_rules_and_never_commander_process(tmp_path): def test_rule_file_without_an_audience_is_refused(tmp_path): - source = CommanderConfig.packaged_rules_files()[0].read_text(encoding="utf-8") + source = CommanderPackage.rules_files()[0].read_text(encoding="utf-8") path = tmp_path / "undeclared.md" path.write_text("".join(line for line in source.splitlines(keepends=True) if not line.startswith("audience:")), encoding="utf-8") From 1410495e46f40128d0f2fb2a5af381b6c5389a81 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:34:46 -0400 Subject: [PATCH 16/17] Resolve Pi event phase declarations through inherited attributes --- src/agent_comms/pi_events.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/agent_comms/pi_events.py b/src/agent_comms/pi_events.py index c1f08e013a..dc7a782804 100644 --- a/src/agent_comms/pi_events.py +++ b/src/agent_comms/pi_events.py @@ -59,24 +59,19 @@ def retry_progress(self) -> bool: class PiEvent(OpenPayload, affix=""): """One declaration-owned record received by the Pi RPC channel.""" - _start_phases: ClassVar[dict[type[object], tuple[type[phases.TurnPhase], ...]]] = {} - _end_events: ClassVar[dict[type[object], frozenset[type[object]]]] = {} + starts: ClassVar[type[phases.TurnPhase] | None] = None + ends: ClassVar[tuple[type[phases.TurnPhase], ...]] = () def on(self, current: phases.TurnPhase) -> phases.TurnPhase: - for event_type in type(self).__mro__: - for member in self._start_phases.get(event_type, ()): - return self.enter(member, current) + if self.starts is not None: + return self.enter(self.starts, current) return self.leave(current) if self.matches_end(current) else current def enter(self, member: type[phases.TurnPhase], current: phases.TurnPhase) -> phases.TurnPhase: return member() def matches_end(self, current: phases.TurnPhase) -> bool: - return any( - event_type in self._end_events.get(member, frozenset()) - for member in type(current).__mro__ - for event_type in type(self).__mro__ - ) + return isinstance(current, self.ends) def leave(self, current: phases.TurnPhase) -> phases.TurnPhase: return phases.ModelWaitPhase() @@ -87,9 +82,9 @@ def __init_subclass__( ) -> None: super().__init_subclass__(**kwargs) if starts is not None: - cls._start_phases[cls] = (starts,) + cls.starts = starts if ends is not None: - cls._end_events[ends] = cls._end_events.get(ends, frozenset()) | {cls} + cls.ends = (*cls.ends, ends) @staticmethod def unowned_event(event): From f252251bbb8525abd952f34c52b3543510280cf5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 04:35:17 -0400 Subject: [PATCH 17/17] Delete the scalar WireOrigin DDL and the disclosure import cycle WireOrigin.matches_sql kept a second SQL spelling of one membership fact (`origin = 'wire'` beside `origin IN (...)`) only so retained DDL stayed byte-identical (TIME-1). It now always derives the IN list from the family; the thread-name-owners carry rebuilds ExecutionRecord's generated columns, and reconcile rebuilds the triggers and views. The disclosure policies move into annotation_disclosure.py beside the capabilities that return them, so the capabilities import them at module level and declare their return type instead of importing inside each call to dodge the working_memory_disclosure -> turn_context cycle. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/annotation_disclosure.py | 45 ++++++++++++++++--- .../coordination_tables/executions.py | 5 --- src/agent_comms/working_memory_disclosure.py | 33 -------------- src/agent_comms/working_memory_questions.py | 3 +- src/agent_comms/working_memory_worker.py | 3 +- 5 files changed, 41 insertions(+), 48 deletions(-) diff --git a/src/agent_comms/annotation_disclosure.py b/src/agent_comms/annotation_disclosure.py index 2122111ebe..4a8f2bd21a 100644 --- a/src/agent_comms/annotation_disclosure.py +++ b/src/agent_comms/annotation_disclosure.py @@ -1,26 +1,57 @@ -"""Disclosure capabilities shared by native messages and context segments.""" +"""Disclosure policies, and the capabilities that give a message or segment its policy.""" from __future__ import annotations +import re +from abc import abstractmethod +from dataclasses import dataclass from typing import TYPE_CHECKING +from .declared_family import DeclaredFamily + if TYPE_CHECKING: from .turn_context import ContextSpan +class DisclosureWithheld(ValueError): # noqa: N818 - nominal refusal state + """The original segment's policy does not permit this text to leave.""" + + +class DisclosurePolicy(DeclaredFamily, affix="Disclosure"): + @abstractmethod + def require_text(self, text: str) -> str: ... + + +@dataclass(frozen=True) +class WithheldDisclosure(DisclosurePolicy): + def require_text(self, text: str) -> str: + raise DisclosureWithheld("This original segment does not permit third-party disclosure") + + +@dataclass(frozen=True) +class PublicInstructionDisclosure(DisclosurePolicy): + # Credential-shaped values are withheld as a whole. Replacing them would + # make the classifier label prose which is not the addressed original span. + credential = re.compile( + r"-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|ghp|github_pat)[-_][A-Za-z0-9_-]+" + r"|\b(?:api[_ -]?key|access[_ -]?token|password|secret)\s*[:=]\s*\S+" + r"|\bBearer\s+\S+", re.IGNORECASE) + + def require_text(self, text: str) -> str: + if self.credential.search(text): + raise DisclosureWithheld("Credential-shaped original text cannot be disclosed") + return text + + class WithheldAnnotations: """Source text cannot be disclosed to an annotation provider.""" - def disclosure_for(self, span: ContextSpan): - from .working_memory_disclosure import WithheldDisclosure - + def disclosure_for(self, span: ContextSpan) -> DisclosurePolicy: return WithheldDisclosure() class PublicInstructionAnnotations: """Instruction text uses the existing credential-filtering disclosure policy.""" - def disclosure_for(self, span: ContextSpan): - from .working_memory_disclosure import PublicInstructionDisclosure - + def disclosure_for(self, span: ContextSpan) -> DisclosurePolicy: return PublicInstructionDisclosure() diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index 3517702107..63c33e1052 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -52,7 +52,6 @@ NonIncreasing, OrdinalTransitions, TextStorage, - sql_literal, ) @@ -131,10 +130,6 @@ def require_obligation(cls) -> None: @classmethod def matches_sql(cls, column: str) -> str: - members = cls.members_with(cls) - # Preserve the existing scalar DDL; additional sourced cases derive a set. - if len(members) == 1: - return f"{column} = {sql_literal(members[0])}" return f"{column} IN {cls.sql_choices()}" @classmethod diff --git a/src/agent_comms/working_memory_disclosure.py b/src/agent_comms/working_memory_disclosure.py index 07c6d29778..c7be0fbcb7 100644 --- a/src/agent_comms/working_memory_disclosure.py +++ b/src/agent_comms/working_memory_disclosure.py @@ -1,48 +1,15 @@ """Segment-owned disclosure selects literal addressed text, never a corpus.""" from __future__ import annotations -import re from typing import Annotated -from abc import abstractmethod from dataclasses import dataclass from agent_comms import field_codec -from .declared_family import DeclaredFamily from .turn_context import ContextSegment, ContextSpan from .working_memory_questions import SpanQuestion -class DisclosureWithheld(ValueError): # noqa: N818 - nominal refusal state - """The original segment's policy does not permit this text to leave.""" - - -class DisclosurePolicy(DeclaredFamily, affix="Disclosure"): - @abstractmethod - def require_text(self, text: str) -> str: ... - - -@dataclass(frozen=True) -class WithheldDisclosure(DisclosurePolicy): - def require_text(self, text: str) -> str: - raise DisclosureWithheld("This original segment does not permit third-party disclosure") - - -@dataclass(frozen=True) -class PublicInstructionDisclosure(DisclosurePolicy): - # Credential-shaped values are withheld as a whole. Replacing them would - # make the classifier label prose which is not the addressed original span. - credential = re.compile( - r"-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|ghp|github_pat)[-_][A-Za-z0-9_-]+" - r"|\b(?:api[_ -]?key|access[_ -]?token|password|secret)\s*[:=]\s*\S+" - r"|\bBearer\s+\S+", re.IGNORECASE) - - def require_text(self, text: str) -> str: - if self.credential.search(text): - raise DisclosureWithheld("Credential-shaped original text cannot be disclosed") - return text - - @dataclass(frozen=True) class DisclosureState: span: str diff --git a/src/agent_comms/working_memory_questions.py b/src/agent_comms/working_memory_questions.py index 64a5334e04..9b54222294 100644 --- a/src/agent_comms/working_memory_questions.py +++ b/src/agent_comms/working_memory_questions.py @@ -4,6 +4,7 @@ from abc import abstractmethod from typing import ClassVar +from .annotation_disclosure import PublicInstructionDisclosure from .declared_family import DeclaredFamily @@ -147,8 +148,6 @@ class RelationToOwnerRulesQuestion(SpanQuestion): @classmethod def disclosed_owner_rules(cls, rules: tuple[str, ...]) -> tuple[str, ...]: - from .working_memory_disclosure import PublicInstructionDisclosure - policy = PublicInstructionDisclosure() return tuple(policy.require_text(rule) for rule in rules) diff --git a/src/agent_comms/working_memory_worker.py b/src/agent_comms/working_memory_worker.py index 88c291b2d5..668217127a 100644 --- a/src/agent_comms/working_memory_worker.py +++ b/src/agent_comms/working_memory_worker.py @@ -12,7 +12,8 @@ from .coordination_database import CoordinationStore from .coordinator import Coordination from .working_memory_annotations import PreviouslyRequestedAnnotation, WorkingMemoryAnnotations -from .working_memory_disclosure import DisclosureState, DisclosureWithheld +from .annotation_disclosure import DisclosureWithheld +from .working_memory_disclosure import DisclosureState from .working_memory_labels import QuestionVersion from .working_memory_policy import AnnotationPolicy from .working_memory_questions import KindQuestion