From 56cfe9f1212dc86013edac54b98f9b80c6e67e1b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:26:07 -0400 Subject: [PATCH 01/32] Add nominal ID values and preserve spelling through codec declarations --- src/agent_comms/backend.py | 4 +- src/agent_comms/field_codec.py | 106 +++++++++++++++++++--- src/agent_comms/installer/launch.py | 4 +- src/agent_comms/maintenance_barrier.py | 9 +- src/agent_comms/messages.py | 4 +- src/agent_comms/owned_turn.py | 4 +- src/agent_comms/private_registry_guard.py | 4 +- src/agent_comms/private_send_stage.py | 4 +- src/agent_comms/publisher.py | 4 +- src/agent_comms/queued_input.py | 6 +- src/agent_comms/selected_actions.py | 4 +- src/agent_comms/selected_write_plan.py | 4 +- src/agent_comms/turn_inputs.py | 4 +- tests/test_field_codec.py | 4 +- tests/test_owned_turn_goal_replacement.py | 4 +- 15 files changed, 126 insertions(+), 43 deletions(-) diff --git a/src/agent_comms/backend.py b/src/agent_comms/backend.py index bf9e040779..76f886c406 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -46,7 +46,7 @@ from .agent_backend import TurnInbox from .coordinator import Coordination from .extension_ui import ExtensionUiSession -from .field_codec import Id128Text, MalformedRecord, boundary_decoder +from .field_codec import InputId, MalformedRecord, boundary_decoder from .image_input import ImageInput from .mro_dispatch import MroDispatch, handles from .native_attestation import AttestationError @@ -162,7 +162,7 @@ async def stream_agent_events( launch, partial(commands.Prompt, id=RequestId(f"agent-comms-prompt-{secrets.token_hex(16)}"), - input_id=Id128Text.mint(), message=task, images=tuple(images), + input_id=InputId.mint(), message=task, images=tuple(images), context_contributions=context_contributions, ), tool_mode=tool_mode, diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index fe35badfbd..e3838949dd 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -1326,6 +1326,36 @@ def schema(cls) -> dict[str, object]: return {"type": "string", "pattern": f"^(?:{cls.spelling.pattern})$"} +class NominalText(str): + """Text whose declared class carries its identity.""" + + __slots__ = () + spelling: ClassVar[type[NominalSpelling]] + + +class NominalSpelling[V: NominalText](SpelledText): + """One spelling declared for a nominal value and inherited by its kinds.""" + + value_type: type[V] + + def __init_subclass__(cls, **options: object) -> None: + super().__init_subclass__(**options) + cls.value_type = next( + get_args(base)[0] + for base in types.get_original_bases(cls) + if get_origin(base) is NominalSpelling + ) + cls.value_type.spelling = cls + + @classmethod + def from_text(cls, value: str) -> V: + return cls.value_type(super().from_text(value)) + + @classmethod + def encode(cls, value: object) -> str: + return str(cls.decode(value)) + + class PathText(TextRepresentation[Path]): @classmethod def encode(cls, value: object) -> str: @@ -1427,17 +1457,46 @@ def from_text(cls, value: str) -> RecordedTimestamp: return recorded -class Id128Text(SpelledText): +class Id128(NominalText): + """A 128-bit identity; subclasses declare the kind it belongs to.""" + + @classmethod + def mint(cls) -> Self: + return cls(uuid4().hex) + + +class ClaimId(Id128): + pass + + +class TurnId(Id128): + pass + + +class GoalId(Id128): + pass + + +class RootId(Id128): + pass + + +class InputId(Id128): + pass + + +class Id128Text(NominalSpelling[Id128]): """A 128-bit identity: an input, turn, bus root, recipient lookup or claim generation.""" spelling = re.compile("[0-9a-f]{32}") +class MessageId(NominalText): @classmethod - def mint(cls) -> str: - return uuid4().hex + def mint(cls, text: str) -> Self: + return cls(hashlib.sha256(text.encode()).hexdigest()[:MessageIdText.digits]) -class MessageIdText(SpelledText): +class MessageIdText(NominalSpelling[MessageId]): """A bus message's content identity, the first twelve hex digits of the SHA-256 of sender, target, timestamp and body. """ @@ -1445,11 +1504,6 @@ class MessageIdText(SpelledText): digits: ClassVar[int] = 12 spelling = re.compile(f"[0-9a-f]{{{digits}}}") - @classmethod - def mint(cls, text: str) -> str: - return hashlib.sha256(text.encode()).hexdigest()[:cls.digits] - - class Hex256Text(SpelledText): """256 bits written as 64 lowercase hex digits.""" @@ -1553,8 +1607,14 @@ def _representation(annotation: object) -> tuple[object, type[FieldRepresentatio return target, representations[0] if target is object: return object, _JsonValueRepresentation - if isinstance(target, type) and issubclass(target, WireValue): - return target, target + if isinstance(target, type): + if issubclass(target, NominalText): + return target, next( + vars(owner)["spelling"] for owner in target.__mro__ + if "spelling" in vars(owner) + ) + if issubclass(target, WireValue): + return target, target return target, None @classmethod @@ -2042,7 +2102,9 @@ def with_representation( cls, annotation: object, target: object, args: tuple[object, ...], representation: type[FieldRepresentation], ) -> ValueDecoder[T]: - return _RepresentedDecoder( + decoder = (_NominalTextDecoder if issubclass(representation, NominalSpelling) + else _RepresentedDecoder) + return decoder( annotation, representation, cast(ValueDecoder[object], cls.representation_plan(target, args, representation)), ) @@ -2204,6 +2266,13 @@ class _TextDecoder(_ExactScalarDecoder[str]): __slots__ = () annotations = (str,) + def accepts(self, data: object) -> TypeGuard[str]: + # RedecodesDeletedPhase removes nominal values from plain str fields. + return super().accepts(data) or isinstance(data, NominalText) + + def encode(self, value: str) -> str: + return str(super().encode(value)) + def encode_editor(self, value): return value @@ -2298,6 +2367,19 @@ def decode(self, data: object) -> T: return self.representation.decode_field(data, self.structural) +class _NominalTextDecoder[T](_RepresentedDecoder[T]): + """The field owns the decoded kind; its inherited spelling owns wire text.""" + + def decode(self, data: object) -> T: + return self.structural.target(super().decode(data)) + + def encode(self, value: T) -> object: + target = self.structural.target + if target is not str and type(value) is not target: + raise TypeError(f"Expected {target}, received {type(value).__name__}") + return self.representation.encode(value) + + class _JsonValueRepresentation(FieldRepresentation[JsonValue]): """An opaque JSON declaration validates JSON, not Python object representations.""" diff --git a/src/agent_comms/installer/launch.py b/src/agent_comms/installer/launch.py index 20c0b4887d..44e764f80f 100644 --- a/src/agent_comms/installer/launch.py +++ b/src/agent_comms/installer/launch.py @@ -24,7 +24,7 @@ from agent_comms.cli_bridges.shared import SessionBridgeResumes from agent_comms.cli_commands import InstallCliCommand from agent_comms.installer.probes.harnesses import InstallResult -from agent_comms.field_codec import FieldCodec, Id128Text, PathText +from agent_comms.field_codec import FieldCodec, Id128, PathText from agent_comms.installer.backup import StoreBackup from agent_comms.installer.refusals import UpgradeRefused from agent_comms.installer.commands import InstallCommands @@ -129,7 +129,7 @@ def prefix(self) -> str: def minted(self) -> Path: """A new install's own directory; older ones past the backups' count are retired.""" - logs = self.root.parent / f"{self.prefix}{Id128Text.mint()}" + logs = self.root.parent / f"{self.prefix}{Id128.mint()}" logs.mkdir(mode=0o700) self.retire() return logs diff --git a/src/agent_comms/maintenance_barrier.py b/src/agent_comms/maintenance_barrier.py index 695c614b0a..5e38f29b2f 100644 --- a/src/agent_comms/maintenance_barrier.py +++ b/src/agent_comms/maintenance_barrier.py @@ -23,6 +23,7 @@ from .errors import RelationViolationError, ToolRefusedError from .field_codec import ( FieldCodec, + ClaimId, Id128Text, MalformedRecord, NullMember, @@ -464,7 +465,7 @@ def _release_dead_unlocked(self, expected: MaintenanceState) -> MaintenanceStatu current = self._current_unlocked() if current != expected: return current - self._write_unlocked(expected.in_phase(DeadHolderReleasedPhase, Id128Text.mint())) + self._write_unlocked(expected.in_phase(DeadHolderReleasedPhase, ClaimId.mint())) raise MaintenanceHeldError( f"Maintenance released dead holder pid={expected.operator.pid} " f"start_time={expected.operator.start_time}; ingress reopened" @@ -515,7 +516,7 @@ def begin(self, operator: MaintenanceOperator) -> MaintenanceState: current.require_open() hold = MaintenanceState(version=2, root=str(self.registry_path.parent.resolve()), generation=current.generation + 1, operator=operator, - nonce=Id128Text.mint(), phase=DrainingPhase) + nonce=ClaimId.mint(), phase=DrainingPhase) self._write_unlocked(hold) return hold @@ -523,7 +524,7 @@ def retarget(self, expected: MaintenanceState, runtime: LaunchRuntime) -> Mainte """Transfer a drained hold after its outgoing readers have stopped.""" with _store_lock(self.registry_path): self.require_draining_unlocked(expected) - hold = expected.advanced(Id128Text.mint(), target=TargetRuntime(runtime.interpreter)) + hold = expected.advanced(ClaimId.mint(), target=TargetRuntime(runtime.interpreter)) self._write_unlocked(hold) return hold @@ -531,7 +532,7 @@ def _advance_unlocked(self, expected: MaintenanceState, phase: type[MaintenancePhase]) -> MaintenanceState: if self.current_unlocked() != expected: raise RelationViolationError('Maintenance generation/operator changed') - hold = expected.successor(phase, Id128Text.mint()) + hold = expected.successor(phase, ClaimId.mint()) self._write_unlocked(hold) return hold diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index b08d61c539..db83d08966 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -40,7 +40,7 @@ from .field_codec import ( FieldCodec, InlineRecord, - MessageIdText, + MessageId, NullMember, PublicationExclude, WireName, @@ -949,7 +949,7 @@ def view_order(self) -> tuple[int, int, int]: @projected(view="wire", name="id") def message_id(self) -> str: - return MessageIdText.mint(f"{self.sender}:{self.target}:{self.timestamp}:{self.body}") + return MessageId.mint(f"{self.sender}:{self.target}:{self.timestamp}:{self.body}") @property def history_source(self) -> HistorySource | None: diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index 2af91b7358..ecad4e7fa8 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -19,7 +19,7 @@ from .channel_input_batch import InputBatch from .controller import UNBOUND_CONTROLLER from .coordinator import Coordination -from .field_codec import Id128Text +from . import field_codec from .goals import Goal from .image_input import ImageInput from .input_attempt import ReservedInput @@ -132,7 +132,7 @@ def authorize(self): return True def begin(self, resources: AsyncExitStack, permits: ExitStack) -> bool: - self.turn_id = Id128Text.mint() + self.turn_id = field_codec.TurnId.mint() self.routing = TurnRouting( tuple(origin.reference for origin in self.origins), MessageRoute(self.thread_name, self.reply_targets) if self.reply_targets else None, diff --git a/src/agent_comms/private_registry_guard.py b/src/agent_comms/private_registry_guard.py index 8ec98802a2..4a93c28be3 100644 --- a/src/agent_comms/private_registry_guard.py +++ b/src/agent_comms/private_registry_guard.py @@ -33,7 +33,7 @@ from .coordination_tables.participants import Participants from .coordinator import Coordination from .errors import RelationViolationError -from .field_codec import Id128Text, MalformedRecord +from .field_codec import Id128Text, MalformedRecord, RootId from .locked_store import DurableFile, WireWakeFile from .native_runtime_input import NativeRuntimeInput, NativeRuntimeTable from .private_path import PrivateFileRole @@ -228,7 +228,7 @@ def launch(self, root: Path) -> RootLaunch: return SharedRootLaunch(root) def prepare_native(self, registry_path: Path) -> PrivateRegistryGuard: - guard = PrivateRegistryGuard(registry_path, Id128Text.mint()) + guard = PrivateRegistryGuard(registry_path, RootId.mint()) guard._trusted_root() # Install the new private capabilities while the existing public schema # still owns opening this file, before publishing the private guard. diff --git a/src/agent_comms/private_send_stage.py b/src/agent_comms/private_send_stage.py index 66562be4bd..b2b4ad18bb 100644 --- a/src/agent_comms/private_send_stage.py +++ b/src/agent_comms/private_send_stage.py @@ -20,7 +20,7 @@ from .coordination_tables.assignments import WakeAssignment from .coordinator import Coordination from .durable_turn import DurableTurn -from .field_codec import Id128Text +from .field_codec import InputId from .private_send_rules import ( NativeBindingCheck, NativeClaimCheck, @@ -92,7 +92,7 @@ async def fail_unknown(self, bus, owner, witness) -> None: await Coordination.run_worker(lambda: owner.require_registry(bus._registry)) def reserve(self, store: Coordination, owner: ParticipantOwner, token_digest: str) -> str: - input_id = Id128Text.mint() + input_id = InputId.mint() with store.session.writing() as db: NativeRuntimeTable.require_installed(db) owner.require(store, self.recipient_lookup) diff --git a/src/agent_comms/publisher.py b/src/agent_comms/publisher.py index 554dee50c7..42155ebcc6 100644 --- a/src/agent_comms/publisher.py +++ b/src/agent_comms/publisher.py @@ -19,7 +19,7 @@ from agent_comms.coordination_tables.publications import ( PublicationIntents, ) -from agent_comms.field_codec import Id128Text +from agent_comms.field_codec import ClaimId from agent_comms.messages import BusRecord, ClaimNoticeKind, Message from agent_comms.response_acknowledgement import canonical_publication_key @@ -173,7 +173,7 @@ def _publish_acquisition_unlocked( build = self._claim_builder( snapshot, ClaimNoticeKind.claimed(owner), partial(WakeClaims, incarnation=str(owner.created_at), claims=(resource,), - generation=Id128Text.mint(), resource_scope=claim), activity=activity, + generation=ClaimId.mint(), resource_scope=claim), activity=activity, ) return self._claim_result(lambda: self.bus.acquire_resource_claim(resource, claim, snapshot, build)) diff --git a/src/agent_comms/queued_input.py b/src/agent_comms/queued_input.py index 42fab843f1..7b0b375d51 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -23,7 +23,7 @@ from .coordination_session import ReadNativeCoordinationSession from .coordinator import Coordination from .errors import RelationViolationError -from .field_codec import Id128Text +from . import field_codec from .goals import GoalDeclaration, NoGoal from .image_input import ImageInput from .input_attempt import InputAttempt, ReservedInput @@ -133,7 +133,7 @@ def capture( text or prompt or "[image prompt]", echo, context, - Id128Text.mint() if input_id is None else input_id, + field_codec.InputId.mint() if input_id is None else input_id, prompt, images, controller, @@ -375,7 +375,7 @@ def reserve_pending( reserved.append(( cls( text=row.source_text, echo=False, context=context, - input_id=Id128Text.mint(), prompt=rendered.text, + input_id=field_codec.InputId.mint(), prompt=rendered.text, bus_key=key, message=message, assignment=paused, ), InputContent(text=rendered.text, contributions=rendered.contributions), diff --git a/src/agent_comms/selected_actions.py b/src/agent_comms/selected_actions.py index 42c651628d..b469f2d65d 100644 --- a/src/agent_comms/selected_actions.py +++ b/src/agent_comms/selected_actions.py @@ -9,7 +9,7 @@ from .coordination_errors import IdentityConflict from .envelope_claim_transitions import ExistingFileClaim -from .field_codec import Id128Text +from .field_codec import ClaimId from .native_tool_call import NativeToolMode from .native_tool_transport import ToolTransport from .turn_context import InstructionFile @@ -38,7 +38,7 @@ def prepare(self, thread: Thread) -> SelectedAction: return self def operation_id(self) -> str: - return Id128Text.mint() + return ClaimId.mint() def with_plan( self, diff --git a/src/agent_comms/selected_write_plan.py b/src/agent_comms/selected_write_plan.py index fbcb2bb41f..4af2e424db 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -33,7 +33,7 @@ from .coordination_cohort import sealed_cohort_assignments from .declared_family import DeclaredFamily from .envelope_claim_transitions import ExistingFileClaim -from .field_codec import FieldCodec, Id128Text, MalformedRecord +from .field_codec import ClaimId, FieldCodec, Id128Text, MalformedRecord from .message_reference import MessageReference from .private_path import PrivateDirectoryRole, PrivateFileRole from .thread_identity import ClaimedIdentity, ClaimScoped, RecordedThreadIncarnation @@ -207,7 +207,7 @@ def submit( path = ExistingFileClaim(Path(resource)).normalized(Path(owner.worktree)) self._prepare_dir() record_path = self.path_in(self.directory, source_seq=str(source_seq), lookup=lookup) - operation_id = Id128Text.mint() + operation_id = ClaimId.mint() record = AcceptedWriteIntent( schema=1, root_id=self.root_id, source_seq=source_seq, source_message_id=source_message_id, claim_id=selected[0].assignment_id, diff --git a/src/agent_comms/turn_inputs.py b/src/agent_comms/turn_inputs.py index 8b2aeb465c..94c0bfcfc4 100644 --- a/src/agent_comms/turn_inputs.py +++ b/src/agent_comms/turn_inputs.py @@ -23,7 +23,7 @@ TurnInboxItem, UseSettings, ) -from .field_codec import Id128Text +from . import field_codec from .locked_store import RegistryFile from .maintenance_barrier import MaintenanceBarrier from .mro_dispatch import MroDispatch, handles @@ -196,7 +196,7 @@ async def send_now(self, item: SendNow, session: TurnSession) -> bool: @handles(InputRequest) async def place(self, item: InputRequest, session: TurnSession) -> bool: public_id = item.input_id.value - native_id = Id128Text.mint() + native_id = field_codec.InputId.mint() text = item.content.text command = commands.Prompt( id=RequestId(public_id), diff --git a/tests/test_field_codec.py b/tests/test_field_codec.py index 9915889ca4..8ab5749bef 100644 --- a/tests/test_field_codec.py +++ b/tests/test_field_codec.py @@ -937,11 +937,11 @@ class TwoRepresentations(SourcePage): def test_identity_and_digest_text_is_one_rule_at_every_reader(tmp_path: Path) -> None: from agent_comms.bus_source_page import AddressedPage from agent_comms.errors import RelationViolationError - from agent_comms.field_codec import Id128Text + from agent_comms.field_codec import Id128 from agent_comms.private_registry_guard import PrivateRegistryGuard from agent_comms.text_digest import TextDigest - minted = Id128Text.mint() + minted = Id128.mint() # A declared field decodes through FieldCodec; the guard calls the representation itself. assert AddressedPage.capture(lookup=minted).lookup == minted assert PrivateRegistryGuard(tmp_path / "registry.json", minted).root_id.hex() == minted diff --git a/tests/test_owned_turn_goal_replacement.py b/tests/test_owned_turn_goal_replacement.py index add10c3f7b..9c46d1b6fa 100644 --- a/tests/test_owned_turn_goal_replacement.py +++ b/tests/test_owned_turn_goal_replacement.py @@ -13,7 +13,7 @@ from agent_comms.channel_input_batch import InputBatch from agent_comms.child_process import ProcessIdentity from conftest import installed_comms -from agent_comms.field_codec import Id128Text +from agent_comms.field_codec import TurnId from agent_comms.goal_actions import ( BlockedGoalAction, CreateProblemGoalAction, OrderGoalAction, ) @@ -149,7 +149,7 @@ async def test_goal_published_during_preparation_is_current_at_reservation(tmp_p current = OwnedTurn(agent.turns, 'worker-session', 'worker', 'next input', original_owner_input=True, original_goal_id=replaced.id) assert current.authorize() - current.turn_id = Id128Text.mint() + current.turn_id = TurnId.mint() async with AsyncExitStack() as resources: current.registry_owner = agent.turns.acquire_turn( resources, current.session_id, current.thread_name, current.turn_id, current.task, From 5f5bb4040c59d997542fe68ea0000cd939d0ef3e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:28:38 -0400 Subject: [PATCH 02/32] Pin nominal ID wire behavior and declare per-kind migration evidence --- plans/ID128-NOMINAL-PLAN.md | 28 ++++++++--------- src/agent_comms/field_codec.py | 4 +++ src/agent_comms/plan_phases.py | 27 +++++++++------- tests/test_field_codec.py | 56 +++++++++++++++++++++++++++++++++- 4 files changed, 89 insertions(+), 26 deletions(-) diff --git a/plans/ID128-NOMINAL-PLAN.md b/plans/ID128-NOMINAL-PLAN.md index 64c6997e03..eba02876b8 100644 --- a/plans/ID128-NOMINAL-PLAN.md +++ b/plans/ID128-NOMINAL-PLAN.md @@ -4,7 +4,7 @@ Audited head: `dfc7d8976` (main, 2026-10-23). Every count below is `git grep -cE ## The thesis -An id is a nominal value type, decoded once by FieldCodec where it enters: a store row, a file, the wire, a CLI argument. Every field and parameter carries that type instead of `str`. Then a repeated decode has nothing left to check, so it cannot be written, and the constructor checks that exist only because the field is `str` delete themselves. +An id is a nominal value type, decoded once by FieldCodec where it enters: a store row, a file, the wire, a CLI argument. Each kind has its own subclass (`ClaimId`, `TurnId`, `GoalId`, `RootId`, `InputId`), and every field and parameter carries its kind instead of `str` or bare `Id128`. A turn id cannot encode as an input id. Then a repeated decode has nothing left to check, so it cannot be written, and the constructor checks that exist only because the field is `str` delete themselves. ## Why this is slop today @@ -29,7 +29,7 @@ These facts decide the plan's order: - **Kinds go in order, one PR each.** An id kind is one set of field names that carry one kind of value. Its fields, parameters, mints and re-decodes change together, so the value never crosses into an unconverted field of its own kind. The kinds run smallest first, by the counts below. They share modules (`acp_extension.py` holds both input and root ids), so they run in series, not in parallel. - **Mechanical rewrites go through one codemod per kind.** The annotation edits are all one shape. Re-decode deletions are by hand, each citing the boundary line where the value entered. - **Delete, never wrap.** No helper, no alias, no `cast`, no `try`. A site whose caller passes raw text is a boundary: it decodes once with the kind's type and stays. -- **Kinds stay one type.** Every 128-bit kind uses `Id128`. Distinct per-kind types (`InputId`, `TurnId`) would also make passing a turn id as an input id a type error. That is a new distinction, not the deletion of a restated fact, so this plan leaves it out; Tristan decides it separately. +- **Kinds have nominal identity.** Tristan decided that a kind is a required fact: `ClaimId`, `TurnId`, `GoalId`, `RootId` and `InputId` inherit the shared `Id128` mint and spelling. Each kind is a bare class, with no own mint or spelling declaration. `MessageId` remains a separate 12-hex nominal type. A field decodes to its declared kind and refuses a different kind on encode. ## Phases @@ -37,19 +37,19 @@ Each phase, its predecessors and its evidence on `origin/main` are declared in ` | Phase | Done when, on main | Now | Done | |---|---|---|---| -| 1 `NominalIdTypePhase` | `NominalText`, `NominalSpelling`, `Id128`, `MessageId` declared in `field_codec.py`; `def mint\(cls(, text: str)?\) -> str:` absent there | 2 | 0 | +| 1 `NominalIdTypePhase` | `NominalText`, `NominalSpelling`, `Id128`, `MessageId` and all five kind classes declared in `field_codec.py`; `def mint\(cls(, text: str)?\) -> str:` absent there | 2 | 0 | | 2 `PlainStrIdRatchetPhase` | `^class PlainStrIdAnnotation\(` in `tools/debt_ratchet.py` | 0 | 1 | -| 3 `ClaimIdsPhase` | `\b(nonce\|operation_id\|generation): (str\b\|Annotated\[str)` | 35 in 10 modules | 0 | +| 3 `ClaimIdsPhase` | `\b(nonce\|operation_id\|generation): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 35 in 10 modules | 0 | | | `Id128Text\.decode\(self\.(nonce\|operation_id)\)` | 2 | 0 | -| 4 `MessageIdsPhase` | `\b(message_id\|source_message_id): (str\b\|Annotated\[str)` | 37 in 21 | 0 | +| 4 `MessageIdsPhase` | `\b(message_id\|source_message_id): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 37 in 21 | 0 | | | `Annotated\[str, *MessageIdText\]` | 1 | 0 | -| 5 `TurnIdsPhase` | `\b(turn_id\|finished_turn_id\|report_turn_id\|current_turn_id): (str\b\|Annotated\[str)` | 71 in 24 | 0 | +| 5 `TurnIdsPhase` | `\b(turn_id\|finished_turn_id\|report_turn_id\|current_turn_id): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 71 in 24 | 0 | | | `Id128Text\.(decode\|from_text)\((self\.)?turn_id\)` | 2 | 0 | -| 6 `GoalIdsPhase` | `\b(goal_id\|original_goal_id): (str\b\|Annotated\[str)` | 85 in 24 | 0 | -| 7 `RootIdsPhase` | `\b(wire_root_id\|root_id\|lookup\|recipient_lookup): (str\b\|Annotated\[str)` | 127 in 45 | 0 | +| 6 `GoalIdsPhase` | `\b(goal_id\|original_goal_id): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 85 in 24 | 0 | +| 7 `RootIdsPhase` | `\b(wire_root_id\|root_id\|lookup\|recipient_lookup): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 127 in 45 | 0 | | | `Id128Text\.decode\((self\.)?(owner\.)?(wire_root_id\|root_id\|lookup)\)` | 4 | 0 | | | `FieldCodec\.decode\(Annotated\[str, *Id128Text\]` | 2 | 0 | -| 8 `InputIdsPhase` | `\b(input_id\|accepted_input_id\|native_id): (str\b\|Annotated\[str)` | 129 in 39 | 0 | +| 8 `InputIdsPhase` | `\b(input_id\|accepted_input_id\|native_id): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 129 in 39 | 0 | | | `Id128Text\.(decode\|from_text)\((self\.)?(input_id\|native_id)\)` | 6 | 0 | | 9 `RedecodesDeletedPhase` | `Id128Text\.(decode\|from_text)\(` | 15 in 14 | 0 | | | `Annotated\[str, *(Id128Text\|MessageIdText)\]` | 15 in 7 | 0 | @@ -62,11 +62,11 @@ The kind patterns count fields and parameters together, so each kind phase cover - Declare `class NominalText(str)` with `__slots__ = ()`: text whose type is its identity. - Declare `class NominalSpelling[V: NominalText](SpelledText)`. Its `__init_subclass__` reads `V` from the subscripted base with `types.get_original_bases` (as `cli_bridges/monitor.py:258` does) and records the pair once, value type to spelling. `from_text` returns `V(super().from_text(value))`. `encode` returns the validated text as exact `str` (fact 2). -- Declare `class Id128(NominalText)` with `mint() -> Id128`, holding the `uuid4().hex` now at `field_codec.py:1435-1437`. Declare `class MessageId(NominalText)` with `mint(text) -> MessageId`, holding the hash now at `:1448-1450`. -- `Id128Text` (`:1430`) and `MessageIdText` (`:1440`) become `NominalSpelling[Id128]` and `NominalSpelling[MessageId]`. Their `mint`s are deleted; callers use `Id128.mint()` / `MessageId.mint(text)`. That is 15 `Id128Text.mint(` sites in 11 modules and 1 at `messages.py:952`, edited in this phase. -- `FieldCodec._representation` (`:1534`) resolves a bare `NominalText` subclass to its recorded spelling, beside the `WireValue` branch at `:1556`. A field is then declared `input_id: Id128`. +- Declare `class Id128(NominalText)` with `mint() -> Id128`, holding the `uuid4().hex` now at `field_codec.py:1435-1437`. It returns `cls(uuid4().hex)`, so inherited mints preserve the kind. Declare the five bare subclasses `ClaimId`, `TurnId`, `GoalId`, `RootId` and `InputId`. Declare `class MessageId(NominalText)` with `mint(text) -> MessageId`, holding the hash now at `:1448-1450`. +- `Id128Text` (`:1430`) and `MessageIdText` (`:1440`) become `NominalSpelling[Id128]` and `NominalSpelling[MessageId]`. Their `mint`s are deleted; callers use the named kind's inherited mint (`InputId.mint()`, `ClaimId.mint()`, `RootId.mint()`, `TurnId.mint()`) or `MessageId.mint(text)`. A temporary name with no semantic kind uses `Id128.mint()`. That is 15 `Id128Text.mint(` sites in 11 modules and 1 at `messages.py:952`, edited in this phase. +- `FieldCodec._representation` (`:1534`) walks a bare `NominalText` subclass's MRO to its nearest recorded spelling, beside the `WireValue` branch at `:1556`. Every `Id128` kind therefore uses `Id128Text` with no registration per kind. A field declared `input_id: InputId` decodes to `InputId`, not the spelling's generic `Id128`. The encoder checks the declared kind and raises `TypeError` for an `InputId` in a `TurnId` field. - `_TextDecoder` (`:2203`) accepts a `NominalText` and encodes it as exact `str`. This is the migration allowance, and phase 9 deletes it. -- Wire, schema and stored rows stay byte-identical (fact 1). The test is a round trip of a record with an `Id128` field, an `Annotated[str, Id128Text]` field and a plain `str` field holding an `Id128`. +- Wire, schema and stored rows stay byte-identical (fact 1). The test is a round trip of a record with an `Id128` field, an `Annotated[str, Id128Text]` field and a plain `str` field holding an `Id128`, a decode of each kind and a wrong-kind encode refusal. Three fixed inputs pin the exact pre-move `MessageIdText.mint` output, including Unicode, so stored message ids remain identical. ### 2. The ratchet @@ -74,7 +74,7 @@ The kind patterns count fields and parameters together, so each kind phase cover ### 3-8. One id kind per phase, smallest first -For each kind: retype its fields and parameters to `Id128` (`MessageId` in phase 4) and its `Optional`s to `Id128 | None`. Delete its re-decodes and constructor checks, citing the boundary line each value already passed. Route its hand-written `uuid4().hex` mints through `Id128.mint()`. A parameter fed by raw text (a CLI argument, a file name, a foreign record) is a boundary: it decodes once there and nowhere downstream. +For each kind: retype its fields and parameters to that kind (`ClaimId`, `MessageId`, `TurnId`, `GoalId`, `RootId`, `InputId`, respectively) and its `Optional`s to the same kind unioned with `None`. Delete its re-decodes and constructor checks, citing the boundary line each value already passed. Route its hand-written `uuid4().hex` mints through that kind's inherited mint. Count both plain `str` and bare `Id128` annotations: neither states the kind, and both must reach zero in that phase. A parameter fed by raw text (a CLI argument, a file name, a foreign record) is a boundary: it decodes once there and nowhere downstream. - **3 Claims** (`maintenance_barrier.py`, `selected_write_plan.py`, `envelope_claim_transitions.py`, `todos.py` generations, `publisher.py`). These are Installer's two boundaries, where the constructor check is the only validation (`maintenance_barrier.py:305,316`; `selected_write_plan.py:71,89`). - **4 Messages.** `commander/order.py:784`, `messages.py:952`, and the 37 `message_id` annotations. diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index e3838949dd..196c010ff6 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -1490,6 +1490,7 @@ class Id128Text(NominalSpelling[Id128]): spelling = re.compile("[0-9a-f]{32}") + class MessageId(NominalText): @classmethod def mint(cls, text: str) -> Self: @@ -1504,6 +1505,7 @@ class MessageIdText(NominalSpelling[MessageId]): digits: ClassVar[int] = 12 spelling = re.compile(f"[0-9a-f]{{{digits}}}") + class Hex256Text(SpelledText): """256 bits written as 64 lowercase hex digits.""" @@ -2370,6 +2372,8 @@ def decode(self, data: object) -> T: class _NominalTextDecoder[T](_RepresentedDecoder[T]): """The field owns the decoded kind; its inherited spelling owns wire text.""" + __slots__ = () + def decode(self, data: object) -> T: return self.structural.target(super().decode(data)) diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index a73bee5a20..3fdde424b1 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -805,6 +805,11 @@ class NominalIdTypePhase(Id128NominalPhase): ClassDeclared("NominalSpelling", ("src/agent_comms/field_codec.py",)), ClassDeclared("Id128", ("src/agent_comms/field_codec.py",)), ClassDeclared("MessageId", ("src/agent_comms/field_codec.py",)), + ClassDeclared("ClaimId", ("src/agent_comms/field_codec.py",)), + ClassDeclared("TurnId", ("src/agent_comms/field_codec.py",)), + ClassDeclared("GoalId", ("src/agent_comms/field_codec.py",)), + ClassDeclared("RootId", ("src/agent_comms/field_codec.py",)), + ClassDeclared("InputId", ("src/agent_comms/field_codec.py",)), PatternAbsent( r"def mint\(cls(, text: str)?\) -> str:", ("src/agent_comms/field_codec.py",) ), @@ -820,12 +825,12 @@ class PlainStrIdRatchetPhase(Id128NominalPhase): class ClaimIdsPhase(Id128NominalPhase): - """3: claim nonces, operation ids and generations are ``Id128``; their checks go.""" + """3: claim nonces, operation ids and generations are ``ClaimId``; their checks go.""" after = (NominalIdTypePhase,) evidence = AllEvidence( ( - PatternAbsent(r"\b(nonce|operation_id|generation): (str\b|Annotated\[str)"), + PatternAbsent(r"\b(nonce|operation_id|generation): (str\b|Id128\b|Annotated\[(str|Id128)\b)"), PatternAbsent(r"Id128Text\.decode\(self\.(nonce|operation_id)\)"), ) ) @@ -837,21 +842,21 @@ class MessageIdsPhase(Id128NominalPhase): after = (ClaimIdsPhase,) evidence = AllEvidence( ( - PatternAbsent(r"\b(message_id|source_message_id): (str\b|Annotated\[str)"), + PatternAbsent(r"\b(message_id|source_message_id): (str\b|Id128\b|Annotated\[(str|Id128)\b)"), PatternAbsent(r"Annotated\[str, *MessageIdText\]"), ) ) class TurnIdsPhase(Id128NominalPhase): - """5: Core turn ids are ``Id128``; their re-decodes go.""" + """5: Core turn ids are ``TurnId``; their re-decodes go.""" after = (MessageIdsPhase,) evidence = AllEvidence( ( PatternAbsent( r"\b(turn_id|finished_turn_id|report_turn_id|current_turn_id)" - r": (str\b|Annotated\[str)" + r": (str\b|Id128\b|Annotated\[(str|Id128)\b)" ), PatternAbsent(r"Id128Text\.(decode|from_text)\((self\.)?turn_id\)"), ) @@ -859,20 +864,20 @@ class TurnIdsPhase(Id128NominalPhase): class GoalIdsPhase(Id128NominalPhase): - """6: goal ids are ``Id128``, minted by it, never by a hand-written ``uuid4().hex``.""" + """6: goal ids are ``GoalId``, minted by it, never by a hand-written ``uuid4().hex``.""" after = (TurnIdsPhase,) - evidence = PatternAbsent(r"\b(goal_id|original_goal_id): (str\b|Annotated\[str)") + evidence = PatternAbsent(r"\b(goal_id|original_goal_id): (str\b|Id128\b|Annotated\[(str|Id128)\b)") class RootIdsPhase(Id128NominalPhase): - """7: bus roots and recipient lookups are ``Id128``; their re-decodes go.""" + """7: bus roots and recipient lookups are ``RootId``; their re-decodes go.""" after = (GoalIdsPhase,) evidence = AllEvidence( ( PatternAbsent( - r"\b(wire_root_id|root_id|lookup|recipient_lookup): (str\b|Annotated\[str)" + r"\b(wire_root_id|root_id|lookup|recipient_lookup): (str\b|Id128\b|Annotated\[(str|Id128)\b)" ), PatternAbsent(r"Id128Text\.decode\((self\.)?(owner\.)?(wire_root_id|root_id|lookup)\)"), PatternAbsent(r"FieldCodec\.decode\(Annotated\[str, *Id128Text\]"), @@ -881,12 +886,12 @@ class RootIdsPhase(Id128NominalPhase): class InputIdsPhase(Id128NominalPhase): - """8: input ids are ``Id128``; their re-decodes and constructor checks go.""" + """8: input ids are ``InputId``; their re-decodes and constructor checks go.""" after = (RootIdsPhase,) evidence = AllEvidence( ( - PatternAbsent(r"\b(input_id|accepted_input_id|native_id): (str\b|Annotated\[str)"), + PatternAbsent(r"\b(input_id|accepted_input_id|native_id): (str\b|Id128\b|Annotated\[(str|Id128)\b)"), PatternAbsent(r"Id128Text\.(decode|from_text)\((self\.)?(input_id|native_id)\)"), ) ) diff --git a/tests/test_field_codec.py b/tests/test_field_codec.py index 8ab5749bef..fffe49894e 100644 --- a/tests/test_field_codec.py +++ b/tests/test_field_codec.py @@ -11,20 +11,28 @@ from agent_comms.declared_family import DeclaredFamily, UnknownFamilyMember from agent_comms.field_codec import ( + ClaimId, DeclaredWireType, FieldCodec, + GoalId, + Id128, + Id128Text, + InputId, InlineRecord, JsonShapeFamily, JsonShapeMember, MalformedRecord, + MessageId, NullMember, PathText, RecordMember, RecordedTimestamp, + RootId, SnapshotExclude, SubclassUnion, TextRepresentation, TimestampText, + TurnId, WireValue, FlagDirective, WireAbsentNone, @@ -937,7 +945,6 @@ class TwoRepresentations(SourcePage): def test_identity_and_digest_text_is_one_rule_at_every_reader(tmp_path: Path) -> None: from agent_comms.bus_source_page import AddressedPage from agent_comms.errors import RelationViolationError - from agent_comms.field_codec import Id128 from agent_comms.private_registry_guard import PrivateRegistryGuard from agent_comms.text_digest import TextDigest @@ -955,6 +962,53 @@ def test_identity_and_digest_text_is_one_rule_at_every_reader(tmp_path: Path) -> TextDigest("a" * 63) +@dataclass(frozen=True) +class _NominalIdentityRow: + nominal: Id128 + annotated: Annotated[str, Id128Text] + plain: str + turn: TurnId + + +def test_nominal_ids_roundtrip_preserves_wire_schema_and_refuses_wrong_kind() -> None: + value = Id128.mint() + row = _NominalIdentityRow(value, value, value, TurnId(value)) + wire = {"nominal": str(value), "annotated": str(value), "plain": str(value), "turn": str(value)} + encoded = FieldCodec.encode(row) + assert encoded == wire + assert all(type(item) is str for item in encoded.values()) + assert FieldCodec.compact(encoded) == FieldCodec.compact(wire) + decoded = FieldCodec.decode(_NominalIdentityRow, wire) + assert decoded == row + assert type(decoded.nominal) is Id128 + assert type(decoded.annotated) is str + assert type(decoded.plain) is str + assert type(decoded.turn) is TurnId + + spelling = {"type": "string", "pattern": "^(?:[0-9a-f]{32})$"} + assert FieldCodec.value_schema(Id128) == FieldCodec.value_schema(Annotated[str, Id128Text]) == spelling + assert FieldCodec.record_schema(_NominalIdentityRow)["properties"] == { + "nominal": spelling, "annotated": spelling, "plain": {"type": "string"}, "turn": spelling, + } + for kind in (ClaimId, TurnId, GoalId, RootId, InputId): + assert type(kind.mint()) is kind + assert type(FieldCodec.decode(kind, str(value))) is kind + assert FieldCodec.value_schema(kind) == spelling + with pytest.raises(TypeError): + FieldCodec.encode(_NominalIdentityRow(value, value, value, InputId(value))) + + # Fixed outputs of MessageIdText.mint at origin/main b966250de, before moving it. + for text, expected in ( + ("", "e3b0c44298fc"), + ("sender:target:0:hello", "a83ada163b6a"), + ("λ/native input\nmessage:body", "c2fad731397c"), + ): + message = MessageId.mint(text) + assert type(message) is MessageId + assert message == expected + assert FieldCodec.encode(message, MessageId) == expected + + @dataclass(frozen=True) class SecretMember(RecordMember): name: str From 4dc23bbfce0a3256a2445ab19bf0917a9fcd20b4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:30:42 -0400 Subject: [PATCH 03/32] Keep expanded ID phase patterns readable --- src/agent_comms/plan_phases.py | 22 +++++++++++++++++----- tests/test_field_codec.py | 3 ++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index 3fdde424b1..4379d92a63 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -830,7 +830,10 @@ class ClaimIdsPhase(Id128NominalPhase): after = (NominalIdTypePhase,) evidence = AllEvidence( ( - PatternAbsent(r"\b(nonce|operation_id|generation): (str\b|Id128\b|Annotated\[(str|Id128)\b)"), + PatternAbsent( + r"\b(nonce|operation_id|generation): " + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)" + ), PatternAbsent(r"Id128Text\.decode\(self\.(nonce|operation_id)\)"), ) ) @@ -842,7 +845,10 @@ class MessageIdsPhase(Id128NominalPhase): after = (ClaimIdsPhase,) evidence = AllEvidence( ( - PatternAbsent(r"\b(message_id|source_message_id): (str\b|Id128\b|Annotated\[(str|Id128)\b)"), + PatternAbsent( + r"\b(message_id|source_message_id): " + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)" + ), PatternAbsent(r"Annotated\[str, *MessageIdText\]"), ) ) @@ -867,7 +873,9 @@ class GoalIdsPhase(Id128NominalPhase): """6: goal ids are ``GoalId``, minted by it, never by a hand-written ``uuid4().hex``.""" after = (TurnIdsPhase,) - evidence = PatternAbsent(r"\b(goal_id|original_goal_id): (str\b|Id128\b|Annotated\[(str|Id128)\b)") + evidence = PatternAbsent( + r"\b(goal_id|original_goal_id): (str\b|Id128\b|Annotated\[(str|Id128)\b)" + ) class RootIdsPhase(Id128NominalPhase): @@ -877,7 +885,8 @@ class RootIdsPhase(Id128NominalPhase): evidence = AllEvidence( ( PatternAbsent( - r"\b(wire_root_id|root_id|lookup|recipient_lookup): (str\b|Id128\b|Annotated\[(str|Id128)\b)" + r"\b(wire_root_id|root_id|lookup|recipient_lookup): " + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)" ), PatternAbsent(r"Id128Text\.decode\((self\.)?(owner\.)?(wire_root_id|root_id|lookup)\)"), PatternAbsent(r"FieldCodec\.decode\(Annotated\[str, *Id128Text\]"), @@ -891,7 +900,10 @@ class InputIdsPhase(Id128NominalPhase): after = (RootIdsPhase,) evidence = AllEvidence( ( - PatternAbsent(r"\b(input_id|accepted_input_id|native_id): (str\b|Id128\b|Annotated\[(str|Id128)\b)"), + PatternAbsent( + r"\b(input_id|accepted_input_id|native_id): " + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)" + ), PatternAbsent(r"Id128Text\.(decode|from_text)\((self\.)?(input_id|native_id)\)"), ) ) diff --git a/tests/test_field_codec.py b/tests/test_field_codec.py index fffe49894e..979cb4e4e4 100644 --- a/tests/test_field_codec.py +++ b/tests/test_field_codec.py @@ -986,7 +986,8 @@ def test_nominal_ids_roundtrip_preserves_wire_schema_and_refuses_wrong_kind() -> assert type(decoded.turn) is TurnId spelling = {"type": "string", "pattern": "^(?:[0-9a-f]{32})$"} - assert FieldCodec.value_schema(Id128) == FieldCodec.value_schema(Annotated[str, Id128Text]) == spelling + assert FieldCodec.value_schema(Id128) == spelling + assert FieldCodec.value_schema(Annotated[str, Id128Text]) == spelling assert FieldCodec.record_schema(_NominalIdentityRow)["properties"] == { "nominal": spelling, "annotated": spelling, "plain": {"type": "string"}, "turn": spelling, } From 6406ba357566fa161110e35656d15d2d6b1a7451 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:35:31 -0400 Subject: [PATCH 04/32] Let nominal spelling own its decoder and inherit the value-side link --- src/agent_comms/field_codec.py | 515 +++++++++++++++++---------------- 1 file changed, 260 insertions(+), 255 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 196c010ff6..73afe3f3bc 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -880,6 +880,261 @@ def decode_external_value(cls, value: object, record: Mapping[str, object]) -> o return cls.from_external(value) +@dataclass(frozen=True, slots=True) +class DecoderPlan(ABC): + """Wire shape and record traversal, independent of the decoded value type.""" + + target: object + + @property + def wire_children(self) -> tuple[DecoderPlan, ...]: + return () + + def fits_wire(self, data: object) -> bool: + """Check outer wire shape without decoding nested values. + + This may pass a value decoding rejects, but must never reject one decoding accepts. + """ + return True + + def shape_refusal(self, data: object) -> MalformedRecord: + """Why fits_wire refused data; it states a refusal and never decides one.""" + return MalformedRecord(f"Expected {self.target}, received {type(data).__name__}") + + def fits_external(self, data: object) -> bool: + """A sibling-aware external reader may accept more than the stored wire shape.""" + return any(self.external_decoders(data)) or self.fits_wire(data) + + def external_decoders(self, data: object) -> Iterable[ExternalDecoder]: + return () + + def selects_record(self, select: WireRecordFilter) -> bool: + return False + + def contains_records( + self, select: WireRecordFilter, seen: frozenset[object] = frozenset(), + ) -> bool: + """Whether this immutable plan reaches a selected declared record; no payload read.""" + if self in seen: + return False + return self.selects_record(select) or any( + child.contains_records(select, seen | {self}) for child in self.wire_children + ) + + def rewrite_records( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + """Transform declared raw records without decoding opaque JSON or losing other keys. + + The caller owns the record change. This decoder owns wire layout, child + membership and union selection. ``select`` is a pure declaration predicate. + """ + if data is None or not self.contains_records(select): + return data + return self._rewrite_records(data, change, select) + + def _rewrite_records( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + return data + + def rewrite_document( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + return self.rewrite_records(data, change, select) + + +@dataclass(frozen=True, slots=True) +class ValueDecoder[T](DecoderPlan): + """One annotation owns its wire conversion, draft projection and choice domain.""" + + _by_origin: ClassVar[dict[object, type[ValueDecoder[object]]]] = {} + _by_annotation: ClassVar[dict[object, type[ValueDecoder[object]]]] = {} + + def __init_subclass__(cls): + super(ValueDecoder, cls).__init_subclass__() + for origin in cls.__dict__.get("origins", ()): + cls._by_origin[origin] = cls + for annotation in cls.__dict__.get("annotations", ()): + cls._by_annotation[annotation] = cls + + @classmethod + def from_annotation(cls, target: object, args: tuple[object, ...]) -> ValueDecoder[T]: + return cls(target) + + @classmethod + def annotation_class(cls, annotation: object) -> type[ValueDecoder[object]]: + try: + return cls._by_annotation[annotation] + except KeyError: + return cls._by_origin[get_origin(annotation) or annotation] + + @classmethod + def with_representation( + cls, annotation: object, target: object, args: tuple[object, ...], + representation: type[FieldRepresentation], + ) -> ValueDecoder[T]: + return representation.decoder( + annotation, representation, + cast(ValueDecoder[object], cls.representation_plan(target, args, representation)), + ) + + @classmethod + def representation_plan( + cls, target: object, args: tuple[object, ...], representation: type[FieldRepresentation], + ) -> ValueDecoder[T]: + return cls.from_annotation(target, args) + + @classmethod + @cache + def for_members[M](cls, members: tuple[type[M], ...]) -> UnionDecoder[M]: + return UnionDecoder( + reduce(or_, members), + tuple(compile_decoder(member) for member in members), + ) + + @abstractmethod + def decode(self, data: object) -> T: + """Decode raw wire input; callers enter through decode_bound.""" + ... + + @abstractmethod + def encode(self, value: T) -> object: ... + + def decode_external(self, data: object, record: Mapping[str, object]) -> T: + for decode in self.external_decoders(data): + return self.decode_resolved(cast(T, decode(data, record))) + return self.decode_bound(data, record) + + def decode_resolved(self, value: T) -> T: + if not self.accepts_value(value): + raise MalformedRecord(f"Resolved value does not match {self.target}") + return value + + def accepts_value(self, value: object) -> bool: + """Select an in-process union case using its compiled declaration.""" + return isinstance(value, self.target) + + def accepts_external_value(self, value: object) -> bool: + """A structural container match does not validate its contents.""" + return False + + def encode_document(self, value: T) -> object: + return self.encode(value) + + def decode_document(self, data: object) -> T: + return self.decode_bound(data, {}) + + def decode_bound(self, data: object, context: Mapping[str, object]) -> T: + return self.decode(data) + + field_names: ClassVar[tuple[str, ...]] = () + + @property + def uses_representation(self) -> bool: + return False + + def decode_selected(self, data, selections, *, fields: tuple[str, ...]): + return self.decode_bound(data, {}) + + def decode_keys(self, data, keys, selections): + raise TypeError("Selected keys require a mapping field") + + editor_annotation = str + + def encode_editor(self, value): + encoded = FieldCodec.encode(value, self.target) + return json.dumps(encoded) + + @boundary_decoder + def decode_editor(self, value): + return None if value is None else json.loads(value) + + def editor_choices(self) -> tuple[tuple[str, object], ...]: + raise TypeError("The declared decoder does not offer finite choices") + + def argument_options(self, argument: Argument) -> dict[str, object]: + """argparse options for this annotation's command-line text.""" + return {} + + def argument_text(self, encoded: object) -> object: + """The command-line spelling of an encoded value.""" + return encoded + + def argument_payload(self, text: object) -> object: + """The payload value that command-line text spells.""" + return text + + +@dataclass(frozen=True, slots=True) +class _RepresentedDecoder[T](ValueDecoder[T]): + """A declared representation capability; null without one decodes structurally.""" + + representation: type[FieldRepresentation[T]] + structural: ValueDecoder[T] + + @property + def wire_structure(self) -> DecoderPlan: + return self.representation.wire_structure(self.structural) + + @property + def wire_children(self) -> tuple[DecoderPlan, ...]: + return (self.wire_structure,) + + @boundary_decoder + def fits_wire(self, data: object) -> bool: + # Null handlers can change the structural null contract; leave that decision to decode. + return data is None or self.wire_structure.fits_wire(data) + + def fits_external(self, data: object) -> bool: + # The representation reads through its wire structure, so an external read takes + # whatever that structure's external reader takes. + return ValueDecoder.fits_external(self, data) or self.wire_structure.fits_external(data) + + def shape_refusal(self, data: object) -> MalformedRecord: + return self.wire_structure.shape_refusal(data) + + def _rewrite_records( + self, data: object, change: WireRecordChange, select: WireRecordFilter, + ) -> object: + return self.wire_structure.rewrite_records(data, change, select) + + def external_decoders(self, data: object) -> Iterable[ExternalDecoder]: + return FieldCodec.external_decoders(self.representation) + + @property + def uses_representation(self) -> bool: + return True + + def argument_options(self, argument: Argument) -> dict[str, object]: + return self.representation.argument_options(argument) + + def encode(self, value: T) -> object: + return None if value is None else self.representation.encode(value) + + def accepts_value(self, value: object) -> bool: + return self.structural.accepts_value(value) + + @boundary_decoder + def decode(self, data: object) -> T: + return self.representation.decode_field(data, self.structural) + + +class _NominalTextDecoder[T](_RepresentedDecoder[T]): + """The field owns the decoded kind; its inherited spelling owns wire text.""" + + __slots__ = () + + def decode(self, data: object) -> T: + return self.structural.target(super().decode(data)) + + def encode(self, value: T) -> object: + target = self.structural.target + if not isinstance(value, target): + raise TypeError(f"Expected {target}, received {type(value).__name__}") + return self.representation.encode(value) + + class FieldRepresentation[T](MroDispatch, ABC): """A field capability, declared as ``Annotated[ValueType, Representation]``. @@ -888,6 +1143,7 @@ class FieldRepresentation[T](MroDispatch, ABC): """ field_plan: ClassVar[type[FieldPlan]] = DeclaredFieldPlan + decoder: ClassVar[type[ValueDecoder]] = _RepresentedDecoder @classmethod def wire_structure(cls, structural: DecoderPlan) -> DecoderPlan: @@ -1330,13 +1586,14 @@ class NominalText(str): """Text whose declared class carries its identity.""" __slots__ = () - spelling: ClassVar[type[NominalSpelling]] + spelled_by: ClassVar[type[NominalSpelling]] class NominalSpelling[V: NominalText](SpelledText): """One spelling declared for a nominal value and inherited by its kinds.""" value_type: type[V] + decoder = _NominalTextDecoder def __init_subclass__(cls, **options: object) -> None: super().__init_subclass__(**options) @@ -1345,7 +1602,7 @@ def __init_subclass__(cls, **options: object) -> None: for base in types.get_original_bases(cls) if get_origin(base) is NominalSpelling ) - cls.value_type.spelling = cls + cls.value_type.spelled_by = cls @classmethod def from_text(cls, value: str) -> V: @@ -1611,10 +1868,7 @@ def _representation(annotation: object) -> tuple[object, type[FieldRepresentatio return object, _JsonValueRepresentation if isinstance(target, type): if issubclass(target, NominalText): - return target, next( - vars(owner)["spelling"] for owner in target.__mro__ - if "spelling" in vars(owner) - ) + return target, target.spelled_by if issubclass(target, WireValue): return target, target return target, None @@ -2011,191 +2265,7 @@ def _projections(declaration: type, view: str) -> types.MappingProxyType[str, Pr }) -@dataclass(frozen=True, slots=True) -class DecoderPlan(ABC): - """Wire shape and record traversal, independent of the decoded value type.""" - - target: object - - @property - def wire_children(self) -> tuple[DecoderPlan, ...]: - return () - - def fits_wire(self, data: object) -> bool: - """Check outer wire shape without decoding nested values. - - This may pass a value decoding rejects, but must never reject one decoding accepts. - """ - return True - - def shape_refusal(self, data: object) -> MalformedRecord: - """Why fits_wire refused data; it states a refusal and never decides one.""" - return MalformedRecord(f"Expected {self.target}, received {type(data).__name__}") - - def fits_external(self, data: object) -> bool: - """A sibling-aware external reader may accept more than the stored wire shape.""" - return any(self.external_decoders(data)) or self.fits_wire(data) - def external_decoders(self, data: object) -> Iterable[ExternalDecoder]: - return () - - def selects_record(self, select: WireRecordFilter) -> bool: - return False - - def contains_records( - self, select: WireRecordFilter, seen: frozenset[object] = frozenset(), - ) -> bool: - """Whether this immutable plan reaches a selected declared record; no payload read.""" - if self in seen: - return False - return self.selects_record(select) or any( - child.contains_records(select, seen | {self}) for child in self.wire_children - ) - - def rewrite_records( - self, data: object, change: WireRecordChange, select: WireRecordFilter, - ) -> object: - """Transform declared raw records without decoding opaque JSON or losing other keys. - - The caller owns the record change. This decoder owns wire layout, child - membership and union selection. ``select`` is a pure declaration predicate. - """ - if data is None or not self.contains_records(select): - return data - return self._rewrite_records(data, change, select) - - def _rewrite_records( - self, data: object, change: WireRecordChange, select: WireRecordFilter, - ) -> object: - return data - - def rewrite_document( - self, data: object, change: WireRecordChange, select: WireRecordFilter, - ) -> object: - return self.rewrite_records(data, change, select) - -@dataclass(frozen=True, slots=True) -class ValueDecoder[T](DecoderPlan): - """One annotation owns its wire conversion, draft projection and choice domain.""" - - _by_origin: ClassVar[dict[object, type[ValueDecoder[object]]]] = {} - _by_annotation: ClassVar[dict[object, type[ValueDecoder[object]]]] = {} - - def __init_subclass__(cls): - super(ValueDecoder, cls).__init_subclass__() - for origin in cls.__dict__.get("origins", ()): - cls._by_origin[origin] = cls - for annotation in cls.__dict__.get("annotations", ()): - cls._by_annotation[annotation] = cls - - @classmethod - def from_annotation(cls, target: object, args: tuple[object, ...]) -> ValueDecoder[T]: - return cls(target) - - @classmethod - def annotation_class(cls, annotation: object) -> type[ValueDecoder[object]]: - try: - return cls._by_annotation[annotation] - except KeyError: - return cls._by_origin[get_origin(annotation) or annotation] - - @classmethod - def with_representation( - cls, annotation: object, target: object, args: tuple[object, ...], - representation: type[FieldRepresentation], - ) -> ValueDecoder[T]: - decoder = (_NominalTextDecoder if issubclass(representation, NominalSpelling) - else _RepresentedDecoder) - return decoder( - annotation, representation, - cast(ValueDecoder[object], cls.representation_plan(target, args, representation)), - ) - - @classmethod - def representation_plan( - cls, target: object, args: tuple[object, ...], representation: type[FieldRepresentation], - ) -> ValueDecoder[T]: - return cls.from_annotation(target, args) - - @classmethod - @cache - def for_members[M](cls, members: tuple[type[M], ...]) -> UnionDecoder[M]: - return UnionDecoder( - reduce(or_, members), - tuple(compile_decoder(member) for member in members), - ) - - @abstractmethod - def decode(self, data: object) -> T: - """Decode raw wire input; callers enter through decode_bound.""" - ... - - @abstractmethod - def encode(self, value: T) -> object: ... - - def decode_external(self, data: object, record: Mapping[str, object]) -> T: - for decode in self.external_decoders(data): - return self.decode_resolved(cast(T, decode(data, record))) - return self.decode_bound(data, record) - - def decode_resolved(self, value: T) -> T: - if not self.accepts_value(value): - raise MalformedRecord(f"Resolved value does not match {self.target}") - return value - - def accepts_value(self, value: object) -> bool: - """Select an in-process union case using its compiled declaration.""" - return isinstance(value, self.target) - - def accepts_external_value(self, value: object) -> bool: - """A structural container match does not validate its contents.""" - return False - - def encode_document(self, value: T) -> object: - return self.encode(value) - - def decode_document(self, data: object) -> T: - return self.decode_bound(data, {}) - - def decode_bound(self, data: object, context: Mapping[str, object]) -> T: - return self.decode(data) - - field_names: ClassVar[tuple[str, ...]] = () - - @property - def uses_representation(self) -> bool: - return False - - def decode_selected(self, data, selections, *, fields: tuple[str, ...]): - return self.decode_bound(data, {}) - - def decode_keys(self, data, keys, selections): - raise TypeError("Selected keys require a mapping field") - - editor_annotation = str - - def encode_editor(self, value): - encoded = FieldCodec.encode(value, self.target) - return json.dumps(encoded) - - @boundary_decoder - def decode_editor(self, value): - return None if value is None else json.loads(value) - - def editor_choices(self) -> tuple[tuple[str, object], ...]: - raise TypeError("The declared decoder does not offer finite choices") - - def argument_options(self, argument: Argument) -> dict[str, object]: - """argparse options for this annotation's command-line text.""" - return {} - - def argument_text(self, encoded: object) -> object: - """The command-line spelling of an encoded value.""" - return encoded - - def argument_payload(self, text: object) -> object: - """The payload value that command-line text spells.""" - return text class _ContextDecoder[T](ValueDecoder[T]): @@ -2315,73 +2385,8 @@ def accepts(self, data: object) -> TypeGuard[float]: return self.fits_wire(data) and math.isfinite(data) -@dataclass(frozen=True, slots=True) -class _RepresentedDecoder[T](ValueDecoder[T]): - """A declared representation capability; null without one decodes structurally.""" - - representation: type[FieldRepresentation[T]] - structural: ValueDecoder[T] - - @property - def wire_structure(self) -> DecoderPlan: - return self.representation.wire_structure(self.structural) - - @property - def wire_children(self) -> tuple[DecoderPlan, ...]: - return (self.wire_structure,) - - @boundary_decoder - def fits_wire(self, data: object) -> bool: - # Null handlers can change the structural null contract; leave that decision to decode. - return data is None or self.wire_structure.fits_wire(data) - - def fits_external(self, data: object) -> bool: - # The representation reads through its wire structure, so an external read takes - # whatever that structure's external reader takes. - return ValueDecoder.fits_external(self, data) or self.wire_structure.fits_external(data) - - def shape_refusal(self, data: object) -> MalformedRecord: - return self.wire_structure.shape_refusal(data) - - def _rewrite_records( - self, data: object, change: WireRecordChange, select: WireRecordFilter, - ) -> object: - return self.wire_structure.rewrite_records(data, change, select) - - def external_decoders(self, data: object) -> Iterable[ExternalDecoder]: - return FieldCodec.external_decoders(self.representation) - - @property - def uses_representation(self) -> bool: - return True - - def argument_options(self, argument: Argument) -> dict[str, object]: - return self.representation.argument_options(argument) - - def encode(self, value: T) -> object: - return None if value is None else self.representation.encode(value) - def accepts_value(self, value: object) -> bool: - return self.structural.accepts_value(value) - @boundary_decoder - def decode(self, data: object) -> T: - return self.representation.decode_field(data, self.structural) - - -class _NominalTextDecoder[T](_RepresentedDecoder[T]): - """The field owns the decoded kind; its inherited spelling owns wire text.""" - - __slots__ = () - - def decode(self, data: object) -> T: - return self.structural.target(super().decode(data)) - - def encode(self, value: T) -> object: - target = self.structural.target - if target is not str and type(value) is not target: - raise TypeError(f"Expected {target}, received {type(value).__name__}") - return self.representation.encode(value) class _JsonValueRepresentation(FieldRepresentation[JsonValue]): From e782e3887325952be200d8578e723df47452111f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:39:54 -0400 Subject: [PATCH 05/32] Separate lookup and assignment identities and unblock independent kind phases --- plans/ID128-NOMINAL-PLAN.md | 16 +++++---- src/agent_comms/field_codec.py | 8 +++++ src/agent_comms/plan_phases.py | 60 ++++++++++++++++++++++++---------- tests/test_field_codec.py | 10 +++--- 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/plans/ID128-NOMINAL-PLAN.md b/plans/ID128-NOMINAL-PLAN.md index eba02876b8..cb15bffd95 100644 --- a/plans/ID128-NOMINAL-PLAN.md +++ b/plans/ID128-NOMINAL-PLAN.md @@ -4,7 +4,7 @@ Audited head: `dfc7d8976` (main, 2026-10-23). Every count below is `git grep -cE ## The thesis -An id is a nominal value type, decoded once by FieldCodec where it enters: a store row, a file, the wire, a CLI argument. Each kind has its own subclass (`ClaimId`, `TurnId`, `GoalId`, `RootId`, `InputId`), and every field and parameter carries its kind instead of `str` or bare `Id128`. A turn id cannot encode as an input id. Then a repeated decode has nothing left to check, so it cannot be written, and the constructor checks that exist only because the field is `str` delete themselves. +An id is a nominal value type, decoded once by FieldCodec where it enters: a store row, a file, the wire, a CLI argument. Each kind has its own subclass (`ClaimId`, `AssignmentGeneration`, `TurnId`, `GoalId`, `RootId`, `ThreadLookup`, `InputId`), and every field and parameter carries its kind instead of `str` or bare `Id128`. A turn id cannot encode as an input id. Then a repeated decode has nothing left to check, so it cannot be written, and the constructor checks that exist only because the field is `str` delete themselves. ## Why this is slop today @@ -26,10 +26,10 @@ These facts decide the plan's order: ## How to execute without churn - **No stored data changes** (fact 1). Each phase is one PR through the normal batch. -- **Kinds go in order, one PR each.** An id kind is one set of field names that carry one kind of value. Its fields, parameters, mints and re-decodes change together, so the value never crosses into an unconverted field of its own kind. The kinds run smallest first, by the counts below. They share modules (`acp_extension.py` holds both input and root ids), so they run in series, not in parallel. +- **Kinds go in order, one PR each.** An id kind is one set of field names that carry one kind of value. Its fields, parameters, mints and re-decodes change together, so the value never crosses into an unconverted field of its own kind. All six kind phases depend only on phase 1 and can run in parallel. They share modules (`acp_extension.py` holds both input and root ids), so shared files need one editor at a time. - **Mechanical rewrites go through one codemod per kind.** The annotation edits are all one shape. Re-decode deletions are by hand, each citing the boundary line where the value entered. - **Delete, never wrap.** No helper, no alias, no `cast`, no `try`. A site whose caller passes raw text is a boundary: it decodes once with the kind's type and stays. -- **Kinds have nominal identity.** Tristan decided that a kind is a required fact: `ClaimId`, `TurnId`, `GoalId`, `RootId` and `InputId` inherit the shared `Id128` mint and spelling. Each kind is a bare class, with no own mint or spelling declaration. `MessageId` remains a separate 12-hex nominal type. A field decodes to its declared kind and refuses a different kind on encode. +- **Kinds have nominal identity.** Tristan decided that a kind is a required fact: `ClaimId`, `AssignmentGeneration`, `TurnId`, `GoalId`, `RootId`, `ThreadLookup` and `InputId` inherit the shared `Id128` mint and spelling. Each kind is a bare class, with no own mint or spelling declaration. `MessageId` remains a separate 12-hex nominal type. A field decodes to its declared kind and refuses a different kind on encode. ## Phases @@ -37,7 +37,7 @@ Each phase, its predecessors and its evidence on `origin/main` are declared in ` | Phase | Done when, on main | Now | Done | |---|---|---|---| -| 1 `NominalIdTypePhase` | `NominalText`, `NominalSpelling`, `Id128`, `MessageId` and all five kind classes declared in `field_codec.py`; `def mint\(cls(, text: str)?\) -> str:` absent there | 2 | 0 | +| 1 `NominalIdTypePhase` | `NominalText`, `NominalSpelling`, `Id128`, `MessageId` and all seven kind classes declared in `field_codec.py`; `def mint\(cls(, text: str)?\) -> str:` absent there | 2 | 0 | | 2 `PlainStrIdRatchetPhase` | `^class PlainStrIdAnnotation\(` in `tools/debt_ratchet.py` | 0 | 1 | | 3 `ClaimIdsPhase` | `\b(nonce\|operation_id\|generation): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 35 in 10 modules | 0 | | | `Id128Text\.decode\(self\.(nonce\|operation_id)\)` | 2 | 0 | @@ -56,13 +56,13 @@ Each phase, its predecessors and its evidence on `origin/main` are declared in ` | | `uuid4\(\)\.hex([^\[]\|$)` outside `field_codec.py` | 23 in 19 | 0 | | | `isinstance\(\w+, NominalText\)` in `field_codec.py` (the phase 1 allowance) | 0, 1 after phase 1 | 0 | -The kind patterns count fields and parameters together, so each kind phase covers both. +The kind patterns count fields and parameters together, so each kind phase covers both. Every kind check excludes `:!src/agent_comms/skills/`: pinned audit examples are historical input, not production id fields. Foreign-id exclusions stay with the integration owner. ### 1. The type (`field_codec.py` only) - Declare `class NominalText(str)` with `__slots__ = ()`: text whose type is its identity. - Declare `class NominalSpelling[V: NominalText](SpelledText)`. Its `__init_subclass__` reads `V` from the subscripted base with `types.get_original_bases` (as `cli_bridges/monitor.py:258` does) and records the pair once, value type to spelling. `from_text` returns `V(super().from_text(value))`. `encode` returns the validated text as exact `str` (fact 2). -- Declare `class Id128(NominalText)` with `mint() -> Id128`, holding the `uuid4().hex` now at `field_codec.py:1435-1437`. It returns `cls(uuid4().hex)`, so inherited mints preserve the kind. Declare the five bare subclasses `ClaimId`, `TurnId`, `GoalId`, `RootId` and `InputId`. Declare `class MessageId(NominalText)` with `mint(text) -> MessageId`, holding the hash now at `:1448-1450`. +- Declare `class Id128(NominalText)` with `mint() -> Id128`, holding the `uuid4().hex` now at `field_codec.py:1435-1437`. It returns `cls(uuid4().hex)`, so inherited mints preserve the kind. Declare the seven bare subclasses `ClaimId`, `AssignmentGeneration`, `TurnId`, `GoalId`, `RootId`, `ThreadLookup` and `InputId`. Declare `class MessageId(NominalText)` with `mint(text) -> MessageId`, holding the hash now at `:1448-1450`. - `Id128Text` (`:1430`) and `MessageIdText` (`:1440`) become `NominalSpelling[Id128]` and `NominalSpelling[MessageId]`. Their `mint`s are deleted; callers use the named kind's inherited mint (`InputId.mint()`, `ClaimId.mint()`, `RootId.mint()`, `TurnId.mint()`) or `MessageId.mint(text)`. A temporary name with no semantic kind uses `Id128.mint()`. That is 15 `Id128Text.mint(` sites in 11 modules and 1 at `messages.py:952`, edited in this phase. - `FieldCodec._representation` (`:1534`) walks a bare `NominalText` subclass's MRO to its nearest recorded spelling, beside the `WireValue` branch at `:1556`. Every `Id128` kind therefore uses `Id128Text` with no registration per kind. A field declared `input_id: InputId` decodes to `InputId`, not the spelling's generic `Id128`. The encoder checks the declared kind and raises `TypeError` for an `InputId` in a `TurnId` field. - `_TextDecoder` (`:2203`) accepts a `NominalText` and encodes it as exact `str`. This is the migration allowance, and phase 9 deletes it. @@ -74,7 +74,9 @@ The kind patterns count fields and parameters together, so each kind phase cover ### 3-8. One id kind per phase, smallest first -For each kind: retype its fields and parameters to that kind (`ClaimId`, `MessageId`, `TurnId`, `GoalId`, `RootId`, `InputId`, respectively) and its `Optional`s to the same kind unioned with `None`. Delete its re-decodes and constructor checks, citing the boundary line each value already passed. Route its hand-written `uuid4().hex` mints through that kind's inherited mint. Count both plain `str` and bare `Id128` annotations: neither states the kind, and both must reach zero in that phase. A parameter fed by raw text (a CLI argument, a file name, a foreign record) is a boundary: it decodes once there and nowhere downstream. +For each kind: retype its fields and parameters to that kind (`ClaimId`/`AssignmentGeneration`, `MessageId`, `TurnId`, `GoalId`, `RootId`/`ThreadLookup`, `InputId`, respectively) and its `Optional`s to the same kind unioned with `None`. Delete its re-decodes and constructor checks, citing the boundary line each value already passed. Route its hand-written `uuid4().hex` mints through that kind's inherited mint. Count both plain `str` and bare `Id128` annotations: neither states the kind, and both must reach zero in that phase. A parameter fed by raw text (a CLI argument, a file name, a foreign record) is a boundary: it decodes once there and nowhere downstream. + +- `AssignmentGeneration` names a Todo assignment generation (goal_actions.py:364; todos.py:2571), while `ClaimId` names an envelope claim generation (publisher.py:176). `ThreadLookup` names a thread incarnation lookup (registry_provenance.py:79); `RootId` names the bus root. Phase 1 declares the two additional kinds; the kind phases retype their use sites. - **3 Claims** (`maintenance_barrier.py`, `selected_write_plan.py`, `envelope_claim_transitions.py`, `todos.py` generations, `publisher.py`). These are Installer's two boundaries, where the constructor check is the only validation (`maintenance_barrier.py:305,316`; `selected_write_plan.py:71,89`). - **4 Messages.** `commander/order.py:784`, `messages.py:952`, and the 37 `message_id` annotations. diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 73afe3f3bc..85972a7e99 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -1726,6 +1726,10 @@ class ClaimId(Id128): pass +class AssignmentGeneration(Id128): + pass + + class TurnId(Id128): pass @@ -1738,6 +1742,10 @@ class RootId(Id128): pass +class ThreadLookup(Id128): + pass + + class InputId(Id128): pass diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index 4379d92a63..00af50ba8f 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -806,9 +806,11 @@ class NominalIdTypePhase(Id128NominalPhase): ClassDeclared("Id128", ("src/agent_comms/field_codec.py",)), ClassDeclared("MessageId", ("src/agent_comms/field_codec.py",)), ClassDeclared("ClaimId", ("src/agent_comms/field_codec.py",)), + ClassDeclared("AssignmentGeneration", ("src/agent_comms/field_codec.py",)), ClassDeclared("TurnId", ("src/agent_comms/field_codec.py",)), ClassDeclared("GoalId", ("src/agent_comms/field_codec.py",)), ClassDeclared("RootId", ("src/agent_comms/field_codec.py",)), + ClassDeclared("ThreadLookup", ("src/agent_comms/field_codec.py",)), ClassDeclared("InputId", ("src/agent_comms/field_codec.py",)), PatternAbsent( r"def mint\(cls(, text: str)?\) -> str:", ("src/agent_comms/field_codec.py",) @@ -832,9 +834,13 @@ class ClaimIdsPhase(Id128NominalPhase): ( PatternAbsent( r"\b(nonce|operation_id|generation): " - r"(str\b|Id128\b|Annotated\[(str|Id128)\b)" + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)", + ("src", ":!src/agent_comms/skills/"), + ), + PatternAbsent( + r"Id128Text\.decode\(self\.(nonce|operation_id)\)", + ("src", ":!src/agent_comms/skills/"), ), - PatternAbsent(r"Id128Text\.decode\(self\.(nonce|operation_id)\)"), ) ) @@ -842,14 +848,18 @@ class ClaimIdsPhase(Id128NominalPhase): class MessageIdsPhase(Id128NominalPhase): """4: bus message ids are ``MessageId``.""" - after = (ClaimIdsPhase,) + after = (NominalIdTypePhase,) evidence = AllEvidence( ( PatternAbsent( r"\b(message_id|source_message_id): " - r"(str\b|Id128\b|Annotated\[(str|Id128)\b)" + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)", + ("src", ":!src/agent_comms/skills/"), + ), + PatternAbsent( + r"Annotated\[str, *MessageIdText\]", + ("src", ":!src/agent_comms/skills/"), ), - PatternAbsent(r"Annotated\[str, *MessageIdText\]"), ) ) @@ -857,14 +867,18 @@ class MessageIdsPhase(Id128NominalPhase): class TurnIdsPhase(Id128NominalPhase): """5: Core turn ids are ``TurnId``; their re-decodes go.""" - after = (MessageIdsPhase,) + after = (NominalIdTypePhase,) evidence = AllEvidence( ( PatternAbsent( r"\b(turn_id|finished_turn_id|report_turn_id|current_turn_id)" - r": (str\b|Id128\b|Annotated\[(str|Id128)\b)" + r": (str\b|Id128\b|Annotated\[(str|Id128)\b)", + ("src", ":!src/agent_comms/skills/"), + ), + PatternAbsent( + r"Id128Text\.(decode|from_text)\((self\.)?turn_id\)", + ("src", ":!src/agent_comms/skills/"), ), - PatternAbsent(r"Id128Text\.(decode|from_text)\((self\.)?turn_id\)"), ) ) @@ -872,24 +886,32 @@ class TurnIdsPhase(Id128NominalPhase): class GoalIdsPhase(Id128NominalPhase): """6: goal ids are ``GoalId``, minted by it, never by a hand-written ``uuid4().hex``.""" - after = (TurnIdsPhase,) + after = (NominalIdTypePhase,) evidence = PatternAbsent( - r"\b(goal_id|original_goal_id): (str\b|Id128\b|Annotated\[(str|Id128)\b)" + r"\b(goal_id|original_goal_id): (str\b|Id128\b|Annotated\[(str|Id128)\b)", + ("src", ":!src/agent_comms/skills/"), ) class RootIdsPhase(Id128NominalPhase): """7: bus roots and recipient lookups are ``RootId``; their re-decodes go.""" - after = (GoalIdsPhase,) + after = (NominalIdTypePhase,) evidence = AllEvidence( ( PatternAbsent( r"\b(wire_root_id|root_id|lookup|recipient_lookup): " - r"(str\b|Id128\b|Annotated\[(str|Id128)\b)" + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)", + ("src", ":!src/agent_comms/skills/"), + ), + PatternAbsent( + r"Id128Text\.decode\((self\.)?(owner\.)?(wire_root_id|root_id|lookup)\)", + ("src", ":!src/agent_comms/skills/"), + ), + PatternAbsent( + r"FieldCodec\.decode\(Annotated\[str, *Id128Text\]", + ("src", ":!src/agent_comms/skills/"), ), - PatternAbsent(r"Id128Text\.decode\((self\.)?(owner\.)?(wire_root_id|root_id|lookup)\)"), - PatternAbsent(r"FieldCodec\.decode\(Annotated\[str, *Id128Text\]"), ) ) @@ -897,14 +919,18 @@ class RootIdsPhase(Id128NominalPhase): class InputIdsPhase(Id128NominalPhase): """8: input ids are ``InputId``; their re-decodes and constructor checks go.""" - after = (RootIdsPhase,) + after = (NominalIdTypePhase,) evidence = AllEvidence( ( PatternAbsent( r"\b(input_id|accepted_input_id|native_id): " - r"(str\b|Id128\b|Annotated\[(str|Id128)\b)" + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)", + ("src", ":!src/agent_comms/skills/"), + ), + PatternAbsent( + r"Id128Text\.(decode|from_text)\((self\.)?(input_id|native_id)\)", + ("src", ":!src/agent_comms/skills/"), ), - PatternAbsent(r"Id128Text\.(decode|from_text)\((self\.)?(input_id|native_id)\)"), ) ) diff --git a/tests/test_field_codec.py b/tests/test_field_codec.py index 979cb4e4e4..effa50eced 100644 --- a/tests/test_field_codec.py +++ b/tests/test_field_codec.py @@ -11,10 +11,8 @@ from agent_comms.declared_family import DeclaredFamily, UnknownFamilyMember from agent_comms.field_codec import ( - ClaimId, DeclaredWireType, FieldCodec, - GoalId, Id128, Id128Text, InputId, @@ -27,7 +25,6 @@ PathText, RecordMember, RecordedTimestamp, - RootId, SnapshotExclude, SubclassUnion, TextRepresentation, @@ -991,10 +988,15 @@ def test_nominal_ids_roundtrip_preserves_wire_schema_and_refuses_wrong_kind() -> assert FieldCodec.record_schema(_NominalIdentityRow)["properties"] == { "nominal": spelling, "annotated": spelling, "plain": {"type": "string"}, "turn": spelling, } - for kind in (ClaimId, TurnId, GoalId, RootId, InputId): + for kind in Id128.__subclasses__(): assert type(kind.mint()) is kind assert type(FieldCodec.decode(kind, str(value))) is kind assert FieldCodec.value_schema(kind) == spelling + + class SpecializedTurnId(TurnId): + pass + + assert FieldCodec.encode(SpecializedTurnId(value), TurnId) == str(value) with pytest.raises(TypeError): FieldCodec.encode(_NominalIdentityRow(value, value, value, InputId(value))) From ce0aedee1da5fccf3a9c14f28408402e1a8471c9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:49:33 -0400 Subject: [PATCH 06/32] Rename the turn registration record and type its stored identity --- src/agent_comms/envelope_claim_transitions.py | 6 +-- src/agent_comms/following_inputs.py | 4 +- src/agent_comms/goal_turn_permit.py | 4 +- src/agent_comms/goals.py | 8 +-- src/agent_comms/input_attempt.py | 6 +-- src/agent_comms/input_disposition.py | 4 +- src/agent_comms/manual_compaction_bridge.py | 4 +- src/agent_comms/native_turn_context.py | 4 +- src/agent_comms/ordinary_turn_rules.py | 4 +- src/agent_comms/owned_send.py | 4 +- src/agent_comms/owned_turn.py | 14 ++--- src/agent_comms/registration.py | 8 +-- src/agent_comms/selected_participant.py | 4 +- src/agent_comms/selected_turn.py | 4 +- src/agent_comms/task_sources.py | 6 +-- src/agent_comms/thread_identity.py | 18 ++----- src/agent_comms/threads.py | 8 +-- src/agent_comms/turn_context.py | 4 +- src/agent_comms/turn_goal_account.py | 6 +-- src/agent_comms/turn_input_source.py | 4 +- src/agent_comms/turn_lease.py | 51 ++++++++++--------- tests/test_claim_bus_read.py | 4 +- tests/test_context_manifest_deltas.py | 6 +-- tests/test_context_source_inspection.py | 4 +- tests/test_goal_answers.py | 4 +- tests/test_input_documents.py | 6 +-- tests/test_task_decisions.py | 4 +- tests/test_turn_context_wire.py | 4 +- tests/test_turn_declaration.py | 6 +-- tests/test_turn_goal_account.py | 4 +- 30 files changed, 103 insertions(+), 114 deletions(-) diff --git a/src/agent_comms/envelope_claim_transitions.py b/src/agent_comms/envelope_claim_transitions.py index 8a9b0ec60f..82688372b5 100644 --- a/src/agent_comms/envelope_claim_transitions.py +++ b/src/agent_comms/envelope_claim_transitions.py @@ -19,6 +19,7 @@ EnvelopeSequence, FieldCodec, Id128Text, + TurnId, NullMember, ProjectionSequence, RecordMember, @@ -30,7 +31,7 @@ WireRequired, ) from .message_reference import MessageReference -from .thread_identity import GenerationCounter, RecordedThreadIncarnation, TurnId +from .thread_identity import GenerationCounter, RecordedThreadIncarnation if TYPE_CHECKING: from .coordination_tables.attempts import AttemptRecord @@ -178,11 +179,10 @@ class RecordedScope(ClaimScope, declared_name=None): owner_claimed_generation: int operation_id: Annotated[str, Id128Text] - turn_id: str + turn_id: TurnId def __post_init__(self) -> None: GenerationCounter.require_positive(self.owner_claimed_generation) - TurnId.for_registration(self.turn_id) def held_at(self, claimed_generation: int) -> bool: return claimed_generation == self.owner_claimed_generation diff --git a/src/agent_comms/following_inputs.py b/src/agent_comms/following_inputs.py index 68f1e202df..0fc71e8813 100644 --- a/src/agent_comms/following_inputs.py +++ b/src/agent_comms/following_inputs.py @@ -12,7 +12,7 @@ from .owned_send import OwnedSend from agent_comms.goal_turn_permit import NoLaunchPermit from .queued_input import DeliveredInput, InputHandoffRefused -from .thread_identity import ClaimedIdentity, TurnId +from .thread_identity import ClaimedIdentity, RegisteredTurn from .turn_input_source import AcceptedFollowingInput if TYPE_CHECKING: @@ -89,7 +89,7 @@ async def open(self, session: TrackedTurnSession) -> None: goal_store=await Coordination.run_worker(inputs.comms.goals.open_goal_store), session_id=self.session_id, thread=thread, - turn=TurnId(lease.turn_id), + turn=RegisteredTurn(lease.turn_id), claim_generation=lease.claimed_generation, original=original, goal_permit=NoLaunchPermit(), diff --git a/src/agent_comms/goal_turn_permit.py b/src/agent_comms/goal_turn_permit.py index c03ddf9bb9..4d825de5b2 100644 --- a/src/agent_comms/goal_turn_permit.py +++ b/src/agent_comms/goal_turn_permit.py @@ -13,7 +13,7 @@ from agent_comms.goal_management import Goals from agent_comms.goals import GoalDeclaration from agent_comms.pi_payloads import PiUsage - from agent_comms.thread_identity import TurnId + from agent_comms.thread_identity import RegisteredTurn from agent_comms.threads import Thread from agent_comms.turn_context import TurnContext from agent_comms.turn_goal_account import TurnGoalAccount, VerifiedGoalSettlement @@ -45,7 +45,7 @@ def stop(self, store: GoalAttemptStore) -> None: permit.stop(store) def settle_goal(self, settlement: type[VerifiedGoalSettlement], store: GoalAttemptStore, - goal: GoalDeclaration, turn: TurnId) -> None: + goal: GoalDeclaration, turn: RegisteredTurn) -> None: for permit in self.claims: goal.settle(settlement, store, permit, turn) diff --git a/src/agent_comms/goals.py b/src/agent_comms/goals.py index c97347f13e..1c812b27c2 100644 --- a/src/agent_comms/goals.py +++ b/src/agent_comms/goals.py @@ -45,7 +45,7 @@ from .goal_management import Goals from .goal_attempts import GoalAttemptStore, LaunchPermit from .turn_goal_account import VerifiedGoalSettlement - from .thread_identity import TurnId + from .thread_identity import RegisteredTurn from .todos import Todo @@ -586,7 +586,7 @@ def stopped_permit(self, store: GoalAttemptStore) -> GoalTurnPermit: ... @abstractmethod def settle(self, settlement: type[VerifiedGoalSettlement], store: GoalAttemptStore, - permit: LaunchPermit, turn: TurnId) -> None: ... + permit: LaunchPermit, turn: RegisteredTurn) -> None: ... @abstractmethod def instructions_for(self, goals: Goals, owner: ThreadIncarnation) -> tuple[str, ...]: ... @@ -676,7 +676,7 @@ def registration_value(self, previous: GoalDeclaration) -> GoalDeclaration: return previous def settle(self, settlement: type[VerifiedGoalSettlement], store: GoalAttemptStore, - permit: LaunchPermit, turn: TurnId) -> None: + permit: LaunchPermit, turn: RegisteredTurn) -> None: pass def instructions_for(self, goals: Goals, owner: ThreadIncarnation) -> tuple[str, ...]: @@ -783,7 +783,7 @@ def registration_value(self, previous: GoalDeclaration) -> GoalDeclaration: return self def settle(self, settlement: type[VerifiedGoalSettlement], store: GoalAttemptStore, - permit: LaunchPermit, turn: TurnId) -> None: + permit: LaunchPermit, turn: RegisteredTurn) -> None: settlement(store, permit, self, turn).dispatch_sync(self.state) def instructions_for(self, goals: Goals, owner: ThreadIncarnation) -> tuple[str, ...]: diff --git a/src/agent_comms/input_attempt.py b/src/agent_comms/input_attempt.py index 44177f2f07..ae2b3035a9 100644 --- a/src/agent_comms/input_attempt.py +++ b/src/agent_comms/input_attempt.py @@ -13,6 +13,7 @@ from .field_codec import ( FieldCodec, Id128Text, + TurnId, NullMember, PublicExclude, WireName, @@ -357,7 +358,7 @@ def clear_unbound(self) -> InputTransition: @dataclass(frozen=True) class SentInput(StoredInput): has_native_binding = True - turn_id: Annotated[str, PublicExclude(), WireRequired()] + turn_id: Annotated[TurnId, PublicExclude(), WireRequired()] native_id: Annotated[ str, PublicExclude(), WireRequired() ] @@ -369,9 +370,6 @@ class SentInput(StoredInput): def __post_init__(self) -> None: super().__post_init__() try: - from .thread_identity import TurnId - - TurnId.for_registration(self.turn_id) Id128Text.decode(self.native_id) except ValueError as error: raise ValueError("Invalid native input attempt") from error diff --git a/src/agent_comms/input_disposition.py b/src/agent_comms/input_disposition.py index dbe07ecfc2..ef8c380417 100644 --- a/src/agent_comms/input_disposition.py +++ b/src/agent_comms/input_disposition.py @@ -39,7 +39,7 @@ from .channel_input_batch import SingleInputBatch from .private_registry_guard import RegistryPrivacy from .registry_document import RegistrySnapshot - from .thread_identity import TurnId + from .thread_identity import RegisteredTurn from .turn_lease import TurnLeaseFence @@ -298,7 +298,7 @@ def reserve(original: InputDocument) -> DocumentChange[InputDocument, InputDocum return self._update(*_members("key", (row.key for row in originals)), reserve) def reserve_turn( - self, owner: str, turn: TurnId, claim_scope: int, text: str, *, ownership: ExitStack, + self, owner: str, turn: RegisteredTurn, claim_scope: int, text: str, *, ownership: ExitStack, ) -> SingleInputBatch: """Enlist rollback before publication; caller retains the original wire scope.""" from .channel_input_batch import SingleInputBatch diff --git a/src/agent_comms/manual_compaction_bridge.py b/src/agent_comms/manual_compaction_bridge.py index 66ecab8c0f..b430faabef 100644 --- a/src/agent_comms/manual_compaction_bridge.py +++ b/src/agent_comms/manual_compaction_bridge.py @@ -6,12 +6,12 @@ from contextlib import AsyncExitStack from functools import partial from typing import TYPE_CHECKING -from uuid import uuid4 from agent_comms.coordination_errors import RequestRefused from acp.schema import AgentMessageChunk, PromptResponse, TextContentBlock from . import agent_events as events +from . import field_codec from .acp_extension import CompactionCommittedUpdate, TranscriptChangedUpdate, encode_updates from .agent_backend import ( CompactionOutcome, @@ -88,7 +88,7 @@ async def _compact_started(runner, session_id, thread_name, instructions): async with AsyncExitStack() as resources: owner = await Coordination.run_worker(partial( runner.acquire_turn, resources, session_id, thread_name, - f"compaction-{uuid4().hex}", "Compacting context", task=task, + field_codec.TurnId.mint(), "Compacting context", task=task, )) runner.turn_tasks[session_id] = task await runner.transition_turn( diff --git a/src/agent_comms/native_turn_context.py b/src/agent_comms/native_turn_context.py index 04d786df01..d1702226d0 100644 --- a/src/agent_comms/native_turn_context.py +++ b/src/agent_comms/native_turn_context.py @@ -47,10 +47,10 @@ async def record(self, contexts, prior, thread, lease) -> CapturedContext: """Publish the original SDK observation under its leased owner turn, after the prior this turn holds; the result is the turn's next prior.""" from .coordinator import Coordination - from .thread_identity import TurnId + from .thread_identity import RegisteredTurn from .turn_context import RecordedContextTurn - turn = RecordedContextTurn(TurnId(lease.turn_id), lease.identity) + turn = RecordedContextTurn(RegisteredTurn(lease.turn_id), lease.identity) manifest = self.for_turn(thread.incarnation, turn) return await Coordination.run_worker( partial(contexts.record_after, prior, manifest) diff --git a/src/agent_comms/ordinary_turn_rules.py b/src/agent_comms/ordinary_turn_rules.py index 0c67b6b695..263c5b077e 100644 --- a/src/agent_comms/ordinary_turn_rules.py +++ b/src/agent_comms/ordinary_turn_rules.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from .reservation_rules import ReservationRule, RuleCheck -from .thread_identity import TurnId +from .thread_identity import RegisteredTurn if TYPE_CHECKING: from .comms import Comms @@ -39,7 +39,7 @@ class OrdinaryOwnerCheck(RuleCheck): registry: RegistrySnapshot canonical: str claim_generation: int - turn: TurnId + turn: RegisteredTurn class OrdinaryIncarnationRule(ReservationRule): diff --git a/src/agent_comms/owned_send.py b/src/agent_comms/owned_send.py index b5e7dae93b..209a58b15c 100644 --- a/src/agent_comms/owned_send.py +++ b/src/agent_comms/owned_send.py @@ -28,7 +28,7 @@ from .reservation_rules import ReservationRule, ReservationViolationError from .routing import TurnRouting from agent_comms.send_outcome import AuthorityChangedSend, DeferredSend, GrantedSend, RefusedSend, SendOutcome -from .thread_identity import TurnId +from .thread_identity import RegisteredTurn from .turn_input_source import ( OriginalTurnInput, TurnInputSource, @@ -52,7 +52,7 @@ class OwnedSend(CodingToolOwner): goal_store: GoalAttemptStore session_id: str thread: Thread - turn: TurnId + turn: RegisteredTurn claim_generation: int original: OriginalTurnInput goal_permit: GoalTurnPermit diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index 0aa5951491..f318843e52 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -18,7 +18,7 @@ from .agent_backend import InputContent, TurnDelivery from .channel_input_batch import InputBatch from .coordinator import Coordination -from . import field_codec +from .field_codec import TurnId from .goals import Goal from .image_input import ImageInput from .input_attempt import ReservedInput @@ -28,7 +28,7 @@ from .native_input_owner import RegistryOwner from .owned_send import OwnedSend from .routing import MessageRoute, ScheduledTurn, TurnRouting -from .thread_identity import TurnId +from .thread_identity import RegisteredTurn from .turn_goal_account import TurnGoalAccount from .turn_goal_permission import ( OwnerGoalPermission, @@ -131,7 +131,7 @@ def authorize(self): return True def begin(self, resources: AsyncExitStack, permits: ExitStack) -> bool: - self.turn_id = field_codec.TurnId.mint() + self.turn_id = TurnId.mint() self.routing = TurnRouting( tuple(origin.reference for origin in self.origins), MessageRoute(self.thread_name, self.reply_targets) if self.reply_targets else None, @@ -152,7 +152,7 @@ def begin(self, resources: AsyncExitStack, permits: ExitStack) -> bool: origin_claims = ExitStack() permits.callback(origin_claims.close) self.goals = TurnGoalAccount( - comms=self.runner.comms, owner=self.thread, turn=TurnId(self.turn_id), + comms=self.runner.comms, owner=self.thread, turn=RegisteredTurn(self.turn_id), lease=self.turn_lease, permit=self.goal_permit, pending_origins=self.runner.goals.pending_goal_origins, claims=origin_claims, ) @@ -220,7 +220,7 @@ def prepare_prompt(self, batch: InputBatch): self.relations = self.runner.comms.relationships.context_relations(self.thread.name) self.context = TurnContext.for_owner( self.thread, - RecordedContextTurn(TurnId(self.turn_id), self.turn_lease.identity), + RecordedContextTurn(RegisteredTurn(self.turn_id), self.turn_lease.identity), self.task, self.runner.comms.views.thread_views(), self.relations, @@ -269,7 +269,7 @@ def reserve_input(self, batch: InputBatch): self.original = original.reserve( self.runner.inputs.dispositions, self.thread, - TurnId(self.turn_id), + RegisteredTurn(self.turn_id), self.turn_lease.claimed_generation, ownership=reservation, ) @@ -345,7 +345,7 @@ async def stream(self): goal_store=await Coordination.run_worker(self.runner.comms.goals.open_goal_store), session_id=self.session_id, thread=self.thread, - turn=TurnId(self.turn_id), + turn=RegisteredTurn(self.turn_id), claim_generation=self.turn_lease.claimed_generation, goal_permit=self.goal_permit, original=replace(self.original, prompt=rendered.text), diff --git a/src/agent_comms/registration.py b/src/agent_comms/registration.py index 8aa3f1f8e9..50c54650c6 100644 --- a/src/agent_comms/registration.py +++ b/src/agent_comms/registration.py @@ -20,7 +20,7 @@ from .routing import TurnRouting from .storage_rules import StorageRule from .thread_execution import CommanderForkExecution -from .thread_identity import GenerationCounter, TurnId +from .thread_identity import GenerationCounter, RegisteredTurn from .thread_status import ( ConnectedThreadStatus, NoBridgeThreadStatus, @@ -325,7 +325,7 @@ def lease_live_turn_with_claim( ) -> tuple[Thread, int]: """Claim a turn against the input generation, allowing unrelated metadata changes.""" Thread.require_declaration(expected) - turn = TurnId.for_registration(turn_id) + turn = RegisteredTurn(turn_id) generation = GenerationCounter.require_positive(expected_generation) with self.store.editing() as edit: owner = RegistryOwner.capture_local(edit.original.snapshot, expected.name) @@ -344,7 +344,7 @@ def lease_live_turn_with_generation( ) -> tuple[Thread, int]: """Claim against the exact captured declaration and stable owner generation.""" Thread.require_declaration(expected) - turn = TurnId.for_registration(turn_id) + turn = RegisteredTurn(turn_id) generation = GenerationCounter.require_positive(expected_owner_generation) with self.store.editing() as edit: snapshot = edit.original.snapshot @@ -359,7 +359,7 @@ def lease_local_turn( self, name: str, turn_id: str, *, routing: TurnRouting | None = None ) -> tuple[Thread, int]: """Atomic local begin-turn, never reviving a stopped or replaced owner.""" - turn = TurnId.for_registration(turn_id) + turn = RegisteredTurn(turn_id) with self.store.editing() as edit: owner = RegistryOwner.capture_local(edit.original.snapshot, name) owner.thread.turn_record.read().require_idle() diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index c8113da216..a52f939892 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -2,7 +2,6 @@ from __future__ import annotations -import secrets from collections.abc import Awaitable, Callable, Generator, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass, field @@ -24,6 +23,7 @@ from .coordinator import Coordination from .cursor_publication import CursorDelivery from .errors import RelationViolationError +from .field_codec import TurnId from .message_bus import MessageBus from .message_reference import MessageReference from .mro_dispatch import MroDispatch, handles @@ -268,7 +268,7 @@ def lease(comms: Comms, owner: RegistryOwner) -> Generator[RegistryOwner, None, owner.thread.turn_record.read().require_idle() thread, generation = comms.registry.lease_live_turn_with_claim( owner.thread, - secrets.token_hex(16), + TurnId.mint(), expected_generation=owner.claimed_generation, ) except RelationViolationError as error: diff --git a/src/agent_comms/selected_turn.py b/src/agent_comms/selected_turn.py index f64e32b87c..20993af9ab 100644 --- a/src/agent_comms/selected_turn.py +++ b/src/agent_comms/selected_turn.py @@ -29,7 +29,7 @@ from .wake_candidate_index import WakeCandidateIndex from .context_segments.wake import SelectedWakeSegment from .context_segments.selected import SelectedTriageSegment, SelectedWorkSegment -from .thread_identity import TurnId +from .thread_identity import RegisteredTurn from .turn_context import RecordedContextTurn, RenderedInput, TurnContext from .turn_phase import PreparingPhase, PromptAcceptancePhase, PublishingPhase @@ -43,7 +43,7 @@ def context(self, segments) -> TurnContext: lease = owner.require_turn_lease() return TurnContext( owner.incarnation, - RecordedContextTurn(TurnId(lease.turn_id), lease.identity), + RecordedContextTurn(RegisteredTurn(lease.turn_id), lease.identity), tuple(segments), ) diff --git a/src/agent_comms/task_sources.py b/src/agent_comms/task_sources.py index db46bcce06..b5e7768132 100644 --- a/src/agent_comms/task_sources.py +++ b/src/agent_comms/task_sources.py @@ -17,7 +17,7 @@ from .thread_identity import ( RecordedThreadIncarnation, ThreadIncarnation, - TurnId, + RegisteredTurn, TurnIdentity, UserThreadRole, ) @@ -406,14 +406,14 @@ def require_correction(self, original: Message, registry: RegistrySnapshot) -> N @dataclass(frozen=True, kw_only=True) class ModelTaskDeclaration(ScopedTaskDeclaration): source_turn: TurnIdentity - source_turn_id: TurnId + source_turn_id: RegisteredTurn permits_agent_revision = True @classmethod def from_claim(cls, owner: Thread, scope: TaskScopeSelection, change: TaskChange, **content) -> Self: lease = owner.require_turn_lease() return cls(scope=scope.select(owner), source_turn=lease.identity, - source_turn_id=TurnId(lease.turn_id), change=change, **content) + source_turn_id=RegisteredTurn(lease.turn_id), change=change, **content) def __post_init__(self): self.author.require_recorded() diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 98faa2abd3..56fc4c146f 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -27,6 +27,7 @@ from .errors import RelationViolationError from .field_codec import ( FieldCodec, + TurnId, SingleFieldValue, WireName, WireValue, @@ -442,19 +443,8 @@ def decode(cls, name: str, birth: float) -> ThreadIncarnation: @dataclass(frozen=True, slots=True) -class TurnId(ProjectionSource): - value: str - - @classmethod - def for_registration(cls, value: str) -> TurnId: - value = FieldCodec.decode(str, value) - if not 0 < len(value) <= 128: - raise ValueError("live owner turn requires a bounded ID") - return cls(value) - - def __post_init__(self) -> None: - if not self.value: - raise ValueError("Turn ID cannot be empty") +class RegisteredTurn(ProjectionSource): + value: TurnId @dataclass(frozen=True, slots=True) @@ -524,7 +514,7 @@ class TurnFence: """One exact turn identity and its recorded input generation.""" identity: TurnIdentity - turn_id: str + turn_id: TurnId claimed_generation: int def renamed(self, name: str) -> Self: diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 6918564842..9133643da1 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -67,7 +67,7 @@ ThreadIncarnationRecord, ThreadRole, TurnFence, - TurnId, + RegisteredTurn, TurnIdentity, UnobservedTools, ) @@ -488,7 +488,7 @@ def require_idle(self) -> None: def require_current_turn(self, claim_scope: int) -> None: self.turn_record.read().require_current(claim_scope, self.turn_generation) - def require_turn(self, turn_id: TurnId, claim_scope: int) -> None: + def require_turn(self, turn_id: RegisteredTurn, claim_scope: int) -> None: if not self.turn_lease.attests(claim_scope, turn_id): raise RelationViolationError("live owner does not hold the requested turn") @@ -576,10 +576,10 @@ def turn_lease(self) -> TurnLease: def require_turn_lease(self) -> TurnLeaseFence: return self.turn_lease.require(RelationViolationError("An authorized original turn is required")) - def has_authored_turn(self, identity: TurnIdentity, turn: TurnId) -> bool: + def has_authored_turn(self, identity: TurnIdentity, turn: RegisteredTurn) -> bool: return self.turn_lease.authored(identity, turn) - def has_observed_task_turn(self, identity: TurnIdentity, turn: TurnId) -> bool: + def has_observed_task_turn(self, identity: TurnIdentity, turn: RegisteredTurn) -> bool: """Check whether a task record can refer to this thread's current or preceding turn. Reject a different thread registration. During a turn, accept its exact diff --git a/src/agent_comms/turn_context.py b/src/agent_comms/turn_context.py index 35f9f40c03..5d059ebe0c 100644 --- a/src/agent_comms/turn_context.py +++ b/src/agent_comms/turn_context.py @@ -31,7 +31,7 @@ from .message_reference import MessageReference from .native_session_reopen import NativeSessionIdentity from .peer_relevance import AlwaysListed, GoalShown, PeerRelevance -from .thread_identity import ThreadIncarnation, TurnId, TurnIdentity +from .thread_identity import ThreadIncarnation, RegisteredTurn, TurnIdentity from .tool_result import DeclaredReply if TYPE_CHECKING: @@ -1194,7 +1194,7 @@ def matches_generation(self, generation: int) -> bool: @dataclass(frozen=True) class RecordedContextTurn(ContextTurn): - identity: TurnId + identity: RegisteredTurn occurrence: TurnIdentity def __post_init__(self): diff --git a/src/agent_comms/turn_goal_account.py b/src/agent_comms/turn_goal_account.py index 812bbdfece..ed8bdb835f 100644 --- a/src/agent_comms/turn_goal_account.py +++ b/src/agent_comms/turn_goal_account.py @@ -24,7 +24,7 @@ from .comms import Comms from .goals import Goal, GoalDeclaration from .pi_payloads import PiUsage - from .thread_identity import TurnId + from .thread_identity import RegisteredTurn from .threads import Thread from .turn_lease import TurnLeaseFence @@ -34,7 +34,7 @@ class VerifiedGoalSettlement(MroDispatch): store: GoalAttemptStore permit: LaunchPermit goal: Goal - turn: TurnId + turn: RegisteredTurn @classmethod def settle_current(cls, account: TurnGoalAccount) -> None: @@ -83,7 +83,7 @@ def completion_witness(self) -> str: class TurnGoalAccount: comms: Comms owner: Thread - turn: TurnId + turn: RegisteredTurn lease: TurnLeaseFence permit: GoalTurnPermit pending_origins: dict[str, str] diff --git a/src/agent_comms/turn_input_source.py b/src/agent_comms/turn_input_source.py index f92e6b0574..7e369274fd 100644 --- a/src/agent_comms/turn_input_source.py +++ b/src/agent_comms/turn_input_source.py @@ -18,7 +18,7 @@ from .goal_waits import GoalWait from .goals import GoalDeclaration from .input_disposition import InputDispositions - from .thread_identity import RecordedThreadIncarnation, TurnId + from .thread_identity import RecordedThreadIncarnation, RegisteredTurn from .threads import Thread @@ -119,7 +119,7 @@ def valid_keys(self, text: str) -> bool: return super().valid_keys(text) or (self.batch.allows_multiple and text == self.prompt) def reserve( - self, dispositions: InputDispositions, owner: Thread, turn: TurnId, claim_scope: int, + self, dispositions: InputDispositions, owner: Thread, turn: RegisteredTurn, claim_scope: int, *, ownership: ExitStack, ) -> OriginalTurnInput: """Every original uses the same durable input authority before native preparation. diff --git a/src/agent_comms/turn_lease.py b/src/agent_comms/turn_lease.py index 4f54e6b0a1..2b068e515d 100644 --- a/src/agent_comms/turn_lease.py +++ b/src/agent_comms/turn_lease.py @@ -14,6 +14,7 @@ from .errors import RelationViolationError from .field_codec import ( MalformedRecord, + TurnId, NullMember, RecordMember, SingleFieldValue, @@ -26,7 +27,7 @@ from .locked_store import DurableFile, JsonDocument, PatternFile from .routing import TurnRouting from .store_files import _store_lock -from .thread_identity import ThreadIncarnation, TurnFence, TurnId, TurnIdentity +from .thread_identity import ThreadIncarnation, TurnFence, RegisteredTurn, TurnIdentity from .turn_phase import IdlePhase, PreparingPhase, TurnPhase if TYPE_CHECKING: @@ -68,7 +69,7 @@ def require_idle(self) -> None: ... def require_current(self, claim_scope: int, generation: int) -> None: ... @abstractmethod - def require_turn(self, turn_id: TurnId) -> None: ... + def require_turn(self, turn_id: RegisteredTurn) -> None: ... @abstractmethod def without_claim(self) -> TurnDeclaration: ... @@ -83,7 +84,7 @@ def started_by(self, updated_at_ms: int) -> bool: ... def phase_effects(self, phase: TurnPhase) -> Iterator[TurnDeclaration]: ... @abstractmethod - def matches(self, turn_id: TurnId) -> bool: ... + def matches(self, turn_id: RegisteredTurn) -> bool: ... @dataclass(frozen=True) @@ -111,7 +112,7 @@ def require_idle(self) -> None: def require_current(self, claim_scope: int, generation: int) -> None: pass - def require_turn(self, turn_id: TurnId) -> None: + def require_turn(self, turn_id: RegisteredTurn) -> None: raise RelationViolationError("live owner does not hold the requested turn") def without_claim(self) -> NoTurn: @@ -126,7 +127,7 @@ def started_by(self, updated_at_ms: int) -> bool: def phase_effects(self, phase: TurnPhase) -> Iterator[TurnDeclaration]: return iter(()) - def matches(self, turn_id: TurnId) -> bool: + def matches(self, turn_id: RegisteredTurn) -> bool: return False @@ -146,13 +147,13 @@ def adopted_by(self, claimed: int) -> TurnGeneration: @abstractmethod def fence( - self, claimed: TurnGeneration, incarnation: ThreadIncarnation, turn_id: str, + self, claimed: TurnGeneration, incarnation: ThreadIncarnation, turn_id: TurnId, record: StoredTurnRecord, ) -> TurnLease: ... @abstractmethod def claim_fence( - self, identity: TurnIdentity, turn_id: str, record: StoredTurnRecord, + self, identity: TurnIdentity, turn_id: TurnId, record: StoredTurnRecord, ) -> TurnLease: ... @@ -170,13 +171,13 @@ def adopted_by(self, claimed: int) -> RecordedGeneration: return self def fence( - self, claimed: TurnGeneration, incarnation: ThreadIncarnation, turn_id: str, + self, claimed: TurnGeneration, incarnation: ThreadIncarnation, turn_id: TurnId, record: StoredTurnRecord, ) -> TurnLease: return claimed.claim_fence(TurnIdentity(incarnation, self.value), turn_id, record) def claim_fence( - self, identity: TurnIdentity, turn_id: str, record: StoredTurnRecord, + self, identity: TurnIdentity, turn_id: TurnId, record: StoredTurnRecord, ) -> TurnLeaseFence: return TurnLeaseFence(identity, turn_id, self.value, record=record) @@ -195,20 +196,20 @@ def adopted_by(self, claimed: int) -> RecordedGeneration: return RecordedGeneration(claimed) def fence( - self, claimed: TurnGeneration, incarnation: ThreadIncarnation, turn_id: str, + self, claimed: TurnGeneration, incarnation: ThreadIncarnation, turn_id: TurnId, record: StoredTurnRecord, ) -> NoTurnLease: return NoTurnLease() def claim_fence( - self, identity: TurnIdentity, turn_id: str, record: StoredTurnRecord, + self, identity: TurnIdentity, turn_id: TurnId, record: StoredTurnRecord, ) -> NoTurnLease: return NoTurnLease() @dataclass(frozen=True) class ActiveTurn(Executing, RecordMember, TurnDeclaration): - id: str + id: TurnId owner: ProcessIdentity started_at: float = field(default_factory=time.time) routing: TurnRouting | None = None @@ -243,12 +244,12 @@ def require_current(self, claim_scope: int, generation: int) -> None: if not self.current(claim_scope, generation): raise RelationViolationError("live owner turn claim_scope is no longer current") - def require_turn(self, turn_id: TurnId) -> None: + def require_turn(self, turn_id: RegisteredTurn) -> None: if not self.matches(turn_id): raise RelationViolationError("live owner does not hold the requested turn") - def matches(self, turn_id: TurnId) -> bool: - return TurnId(self.id) == turn_id + def matches(self, turn_id: RegisteredTurn) -> bool: + return RegisteredTurn(self.id) == turn_id def without_claim(self) -> ActiveTurn: return replace(self, claimed_generation=UnrecordedGeneration()) @@ -330,14 +331,14 @@ def require(self, refusal: Exception) -> TurnLeaseFence: ... def held(self) -> tuple[TurnLeaseFence, ...]: ... @abstractmethod - def attests(self, claim_scope: int, turn_id: TurnId) -> bool: + def attests(self, claim_scope: int, turn_id: RegisteredTurn) -> bool: """This lease holds ``turn_id`` under ``claim_scope``.""" @abstractmethod - def authored(self, identity: TurnIdentity, turn: TurnId) -> bool: ... + def authored(self, identity: TurnIdentity, turn: RegisteredTurn) -> bool: ... @abstractmethod - def observes_task_turn(self, identity: TurnIdentity, turn: TurnId, idle: bool) -> bool: + def observes_task_turn(self, identity: TurnIdentity, turn: RegisteredTurn, idle: bool) -> bool: """During a turn, its exact ID or the preceding generation; idle, the caller's answer.""" @abstractmethod @@ -368,13 +369,13 @@ def require(self, refusal: Exception) -> TurnLeaseFence: def held(self) -> tuple[TurnLeaseFence, ...]: return () - def attests(self, claim_scope: int, turn_id: TurnId) -> bool: + def attests(self, claim_scope: int, turn_id: RegisteredTurn) -> bool: return False - def authored(self, identity: TurnIdentity, turn: TurnId) -> bool: + def authored(self, identity: TurnIdentity, turn: RegisteredTurn) -> bool: return False - def observes_task_turn(self, identity: TurnIdentity, turn: TurnId, idle: bool) -> bool: + def observes_task_turn(self, identity: TurnIdentity, turn: RegisteredTurn, idle: bool) -> bool: return idle @boundary_decoder @@ -403,13 +404,13 @@ def require(self, refusal: Exception) -> TurnLeaseFence: def held(self) -> tuple[TurnLeaseFence, ...]: return (self,) - def attests(self, claim_scope: int, turn_id: TurnId) -> bool: - return self.can_attest(claim_scope) and TurnId(self.turn_id) == turn_id + def attests(self, claim_scope: int, turn_id: RegisteredTurn) -> bool: + return self.can_attest(claim_scope) and RegisteredTurn(self.turn_id) == turn_id - def authored(self, identity: TurnIdentity, turn: TurnId) -> bool: + def authored(self, identity: TurnIdentity, turn: RegisteredTurn) -> bool: return (self.identity, self.turn_id) == (identity, turn.value) - def observes_task_turn(self, identity: TurnIdentity, turn: TurnId, idle: bool) -> bool: + def observes_task_turn(self, identity: TurnIdentity, turn: RegisteredTurn, idle: bool) -> bool: return self.authored(identity, turn) or identity.generation == self.identity.generation - 1 @boundary_decoder diff --git a/tests/test_claim_bus_read.py b/tests/test_claim_bus_read.py index df2c5bf306..653e4bb79f 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -40,7 +40,7 @@ from agent_comms.owner_fence import prepare_fence_token from agent_comms.read_basis import AllChannelDisplayScope, DMDisplayScope from agent_comms.selected_source_batch import SelectedSource -from agent_comms.thread_identity import TurnId, TurnIdentity +from agent_comms.thread_identity import RegisteredTurn, TurnIdentity from agent_comms.turn_context import ( ContextManifest, OwnerProvenance, @@ -66,7 +66,7 @@ def record_context(comms: Comms, name: str, generation: int) -> None: ) comms.contexts.record_after(UNREAD_CONTEXT, ContextManifest( owner.incarnation, - RecordedContextTurn(TurnId(f"turn-{generation}"), TurnIdentity(owner.incarnation, generation)), + RecordedContextTurn(RegisteredTurn(f"turn-{generation}"), TurnIdentity(owner.incarnation, generation)), (segment,), "pi.estimateTokens", )) diff --git a/tests/test_context_manifest_deltas.py b/tests/test_context_manifest_deltas.py index 08b4b97f70..c807d8aadb 100644 --- a/tests/test_context_manifest_deltas.py +++ b/tests/test_context_manifest_deltas.py @@ -11,7 +11,7 @@ from agent_comms.field_codec import FieldCodec from agent_comms.native_session_reopen import NativeSessionIdentity -from agent_comms.thread_identity import TurnId, TurnIdentity +from agent_comms.thread_identity import RegisteredTurn, TurnIdentity from agent_comms.turn_context import ObservedDispatchRequest from agent_comms.turn_context import ( ContextManifest, JournalProvenance, NativeProvenance, RecordedContextPredecessor, @@ -34,7 +34,7 @@ def segment(kind, text, sources=provenance, contributors=()): transcript = segment(TranscriptSegment, '\n'.join(f'Entry {i}' for i in range(count)), contributors=contributors) return ContextManifest(owner.incarnation, - RecordedContextTurn(TurnId(f'turn-{turn}'), TurnIdentity(owner.incarnation, turn)), + RecordedContextTurn(RegisteredTurn(f'turn-{turn}'), TurnIdentity(owner.incarnation, turn)), (segment(SystemLayerSegment, 's' * 27000), segment(ToolCatalogSegment, 't' * 64000), transcript), 'counter', ObservedDispatchRequest(f'request-{turn}')) @@ -46,7 +46,7 @@ def test_context_deltas_restore_original_text_and_incremental_membership(tmp_pat first = context(owner, tmp_path, 328, 1) second = context(owner, tmp_path, 330, 2) prior = UNREAD_CONTEXT - for manifest in (first, second, replace(second, turn=replace(second.turn, identity=TurnId('turn-3')), + for manifest in (first, second, replace(second, turn=replace(second.turn, identity=RegisteredTurn('turn-3')), request_id=ObservedDispatchRequest('request-3'))): # The writer keeps the request it recorded as the next request's prior. prior = comms.contexts.record_after(prior, manifest) diff --git a/tests/test_context_source_inspection.py b/tests/test_context_source_inspection.py index e74907caf3..7a0a95b173 100644 --- a/tests/test_context_source_inspection.py +++ b/tests/test_context_source_inspection.py @@ -14,7 +14,7 @@ from agent_comms.native_turn_context import NativeContextData, NativeContextManifestData from agent_comms.runtime_requests import (RuntimeRequest, ContextRecordedSegmentRuntimeRequest, ContextCoreRuntimeRequest, ContextCoreSourceRuntimeRequest) -from agent_comms.thread_identity import TurnId, TurnIdentity +from agent_comms.thread_identity import RegisteredTurn, TurnIdentity from agent_comms.thread_identity import RecordedThreadIncarnation, ReportedTools from agent_comms.turn_context import ObservedDispatchRequest, UnobservedDispatchRequest from agent_comms.turn_context import ( @@ -36,7 +36,7 @@ def identity(tmp_path): def recorded(segments): owner = RecordedThreadIncarnation("original-owner", 17002.0) - turn = RecordedContextTurn(TurnId("original-turn"), TurnIdentity(owner, 2)) + turn = RecordedContextTurn(RegisteredTurn("original-turn"), TurnIdentity(owner, 2)) return ContextManifest(owner, turn, tuple(segments), "pi.estimateTokens", ObservedDispatchRequest("request-one")) diff --git a/tests/test_goal_answers.py b/tests/test_goal_answers.py index 45d74eec05..f06e3352a0 100644 --- a/tests/test_goal_answers.py +++ b/tests/test_goal_answers.py @@ -13,7 +13,7 @@ from agent_comms.goal_turn_permit import NoLaunchPermit from agent_comms.goals import NoGoal from agent_comms.pi_payloads import PiUsage -from agent_comms.thread_identity import TurnId +from agent_comms.thread_identity import RegisteredTurn from agent_comms.turn_goal_account import TurnGoalAccount from agent_comms.turn_terminal import EndedWithoutResult, OperatorStopped from agent_comms.threads import Thread @@ -27,7 +27,7 @@ def goal_turn(comms, original_goal): )) if original_goal: goal = comms.goals.update_goal("worker", SetGoalAction(text="Original work")) - turn = TurnId(uuid4().hex) + turn = RegisteredTurn(uuid4().hex) owner = comms.agents.begin_turn("worker", turn.value) lease = owner.thread.require_turn_lease() try: diff --git a/tests/test_input_documents.py b/tests/test_input_documents.py index f70ad22cb1..60785200ed 100644 --- a/tests/test_input_documents.py +++ b/tests/test_input_documents.py @@ -21,7 +21,7 @@ ) from agent_comms.input_disposition import InputDispositionRow, InputDispositions, InputDocument from agent_comms.errors import RelationViolationError -from agent_comms.thread_identity import TurnId +from agent_comms.thread_identity import RegisteredTurn from agent_comms.thread_identity import RecordedThreadIncarnation from stored_inputs import insert_inputs, stored_inputs from agent_comms.threads import Thread @@ -71,12 +71,12 @@ def test_failure_after_original_publication_retires_the_batch_and_schedule(tmp_p tuple(row.key for row in rows) )) with pytest.raises(OSError), ExitStack() as ownership: - store.reserve_turn("worker", TurnId("scheduled"), 1, "Original schedule", ownership=ownership) + store.reserve_turn("worker", RegisteredTurn("scheduled"), 1, "Original schedule", ownership=ownership) raise OSError("later failure") scheduled = stored_inputs(store.root) assert all(isinstance(row, NotSentInput) for row in scheduled.rows.values()) with ExitStack() as ownership, pytest.raises(RelationViolationError): - store.reserve_turn("worker", TurnId("scheduled"), 1, "Replacement", ownership=ownership) + store.reserve_turn("worker", RegisteredTurn("scheduled"), 1, "Replacement", ownership=ownership) assert stored_inputs(store.root) == scheduled diff --git a/tests/test_task_decisions.py b/tests/test_task_decisions.py index c50aa0c903..a32b0623b2 100644 --- a/tests/test_task_decisions.py +++ b/tests/test_task_decisions.py @@ -19,7 +19,7 @@ from agent_comms.goals import Goal from agent_comms.messages import Message, InfoKind from agent_comms.task_sources import Decision -from agent_comms.thread_identity import TurnId +from agent_comms.thread_identity import RegisteredTurn from agent_comms.tools import invoke_tool from agent_comms.tool_caller import ThreadCaller from agent_comms.turn_lease import NoTurnLease @@ -298,7 +298,7 @@ def test_stale_goal_scope_cannot_publish_even_through_original_publisher(comms): declaration = Decision( chosen="A", rejected=("B",), scope=owner.task_scope, source_turn=owner.require_turn_lease().identity, - source_turn_id=TurnId(owner.require_turn_lease().turn_id), + source_turn_id=RegisteredTurn(owner.require_turn_lease().turn_id), ) comms.registry.register(replace(owner, goal=replace(owner.goal, revision=2))) message = Message("alpha", "#team", declaration.text, InfoKind, task=declaration) diff --git a/tests/test_turn_context_wire.py b/tests/test_turn_context_wire.py index fe2e26db3a..e56623dae8 100644 --- a/tests/test_turn_context_wire.py +++ b/tests/test_turn_context_wire.py @@ -6,7 +6,7 @@ from agent_comms.image_input import ImageInput from agent_comms.native_tool_call import OwnerToolConnection from agent_comms.pi_commands import Prompt -from agent_comms.thread_identity import TurnId, TurnIdentity +from agent_comms.thread_identity import RegisteredTurn, TurnIdentity from agent_comms.turn_context import ( ContextManifest, OwnerProvenance, @@ -23,7 +23,7 @@ def manifest(owner, generation=1): source=OwnerProvenance(owner.incarnation, 'original-input-source') segment=SegmentManifest(TranscriptSegment,(source,),hashlib.sha256(b'PRIVATE INPUT').hexdigest(),13,4) - return ContextManifest(owner.incarnation,RecordedContextTurn(TurnId('original-turn'),TurnIdentity(owner.incarnation,generation)),(segment,),'pi.estimateTokens') + return ContextManifest(owner.incarnation,RecordedContextTurn(RegisteredTurn('original-turn'),TurnIdentity(owner.incarnation,generation)),(segment,),'pi.estimateTokens') def corrupt_context_kind(comms, name, kind): diff --git a/tests/test_turn_declaration.py b/tests/test_turn_declaration.py index a76e0cb9dc..566c6994c4 100644 --- a/tests/test_turn_declaration.py +++ b/tests/test_turn_declaration.py @@ -5,7 +5,7 @@ from agent_comms.child_process import NoProcess, ProcessIdentity from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec -from agent_comms.thread_identity import TurnId +from agent_comms.thread_identity import RegisteredTurn from agent_comms.turn_lease import ActiveTurn, NoTurn, TurnDeclaration from agent_comms.turn_phase import ToolRunningPhase @@ -22,7 +22,7 @@ def test_idle_turn_answers_without_an_active_record(): assert FieldCodec.encode(idle) is None idle.require_idle() assert idle.report_turn == '' - assert not idle.matches(TurnId('turn')) + assert not idle.matches(RegisteredTurn('turn')) assert not idle.started_by(10000) assert idle.observed_id('finished') == 'finished' assert idle.without_claim() is idle @@ -33,6 +33,6 @@ def test_idle_turn_answers_without_an_active_record(): with pytest.raises(RelationViolationError): idle.require_active() with pytest.raises(RelationViolationError): - idle.require_turn(TurnId('turn')) + idle.require_turn(RegisteredTurn('turn')) diff --git a/tests/test_turn_goal_account.py b/tests/test_turn_goal_account.py index 954164b9ad..f1fb9b7808 100644 --- a/tests/test_turn_goal_account.py +++ b/tests/test_turn_goal_account.py @@ -10,7 +10,7 @@ ) from agent_comms.goal_states import ActiveGoal, CompletedGoal, PausedGoal from agent_comms.goals import Goal, ReportedTurn -from agent_comms.thread_identity import TurnId +from agent_comms.thread_identity import RegisteredTurn from agent_comms.turn_goal_account import OriginGoalSettlement, VerifiedGoalSettlement @@ -21,7 +21,7 @@ class SpecializedPausedGoal(PausedGoal): (tmp_path / "attempts").mkdir(mode=0o700) store = GoalAttemptStore.initialize(tmp_path / "attempts") - turn = TurnId("turn") + turn = RegisteredTurn("turn") for settlement in (VerifiedGoalSettlement, OriginGoalSettlement): for state in (ActiveGoal(), PausedGoal(), CompletedGoal(), SpecializedPausedGoal()): goal = Goal( From 6cef37d92c20f66e9ceb3c3a58507d93b70f21b1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:56:23 -0400 Subject: [PATCH 07/32] Use nominal turn identities in fixtures and the prompt default factory --- src/agent_comms/acp_extension.py | 3 +- tests/test_acp.py | 3 +- tests/test_acp_extension.py | 3 +- tests/test_acp_queue_contract.py | 7 ++-- tests/test_active_turn.py | 7 ++-- tests/test_activity_text.py | 7 ++-- tests/test_atomic_turn_source_owner.py | 9 +++-- tests/test_bus_files.py | 3 +- tests/test_channels.py | 3 +- tests/test_claim_admission_verifier.py | 3 +- tests/test_claim_bus_read.py | 5 ++- tests/test_cli.py | 3 +- tests/test_context_manifest_deltas.py | 5 ++- tests/test_context_source_inspection.py | 3 +- tests/test_coordination_response.py | 3 +- tests/test_current_input_origin.py | 7 ++-- tests/test_declarations.py | 19 +++++----- tests/test_envelope_claim_transitions.py | 5 ++- tests/test_goal_answers.py | 4 +- tests/test_goal_failure_observation.py | 3 +- tests/test_goal_input_review.py | 5 ++- tests/test_goal_reply_lock_scope.py | 15 ++++---- tests/test_goal_resume.py | 5 ++- tests/test_goal_standby.py | 23 ++++++------ tests/test_goal_standby_liveness.py | 41 +++++++++++---------- tests/test_goal_wait_certified_reply.py | 5 ++- tests/test_historical_notifications.py | 3 +- tests/test_history_view_declaration.py | 3 +- tests/test_holder_standing_rules.py | 3 +- tests/test_input_documents.py | 33 +++++++++-------- tests/test_install.py | 3 +- tests/test_interrupted_delivery_recovery.py | 3 +- tests/test_l0_goal_input_closure.py | 3 +- tests/test_live_drain_failure.py | 5 ++- tests/test_maintenance_barrier.py | 13 ++++--- tests/test_message_notifications.py | 5 ++- tests/test_native_unknown_recovery.py | 3 +- tests/test_optional_awareness_projection.py | 4 +- tests/test_project_resume.py | 7 ++-- tests/test_read_ledger.py | 3 +- tests/test_registration_authority.py | 19 +++++----- tests/test_registration_inheritance.py | 5 ++- tests/test_registration_process_identity.py | 7 ++-- tests/test_registration_transitions.py | 11 +++--- tests/test_roster_restoration.py | 3 +- tests/test_selected_tool_broker.py | 3 +- tests/test_task_decisions.py | 5 ++- tests/test_thread_identity.py | 15 ++++---- tests/test_tool_output.py | 7 ++-- tests/test_turn_context_wire.py | 3 +- tests/test_turn_declaration.py | 7 ++-- tests/test_turn_goal_account.py | 3 +- tests/test_turn_identity_equality.py | 11 +++--- tests/test_turn_lease_release.py | 9 +++-- tests/test_turn_record.py | 5 ++- tests/test_unscoped_claim_history.py | 3 +- tests/verify_connector_tools.py | 3 +- 57 files changed, 233 insertions(+), 176 deletions(-) diff --git a/src/agent_comms/acp_extension.py b/src/agent_comms/acp_extension.py index c4e9009466..8ae886031c 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -29,6 +29,7 @@ ExternalJsonShape, FieldCodec, Id128Text, + InputId, MalformedRecord, WireValue, WireName, @@ -672,7 +673,7 @@ def draft_text(self) -> str | None: class PromptRequest(CommsRequest): user_text: str | None = None defer_display: bool = False - input_id: Annotated[str, Id128Text] = field(default_factory=Id128Text.mint) + input_id: Annotated[str, Id128Text] = field(default_factory=InputId.mint) origin: Annotated[InputOrigin, WireOmitDefault()] = field( default_factory=UnattributedInputOrigin, kw_only=True ) diff --git a/tests/test_acp.py b/tests/test_acp.py index bf9ffdcd7a..161279b354 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -3,6 +3,7 @@ Exercises the agent the way real clients (Toad, Zed) do: through Core's ACP connection over real stdio pipes, plus direct handler-level tests. """ +from agent_comms.field_codec import TurnId from agent_comms.controller import SessionUpdateSink import asyncio @@ -433,7 +434,7 @@ def test_emit_and_read_current(self, wired): "activity-owner", frozenset(), str(wired.root), process_identity=ProcessIdentity.capture(os.getpid()), )) - owner = wired.agents.begin_turn("activity-owner", "activity-test") + owner = wired.agents.begin_turn("activity-owner", TurnId('fb5e210a764598099d8e8d358c83aadc')) try: before = WireRevision.capture(wired.views, expiry_tick=0) wired.agents.transition_turn(owner.thread.turn_lease, ToolRunningPhase(detail="bash: echo hi")) diff --git a/tests/test_acp_extension.py b/tests/test_acp_extension.py index 40776dff5d..97f5a80067 100644 --- a/tests/test_acp_extension.py +++ b/tests/test_acp_extension.py @@ -1,3 +1,4 @@ +from agent_comms.field_codec import TurnId from agent_comms.child_process import ProcessIdentity from agent_comms.acp_failure import ACPFailure @@ -96,7 +97,7 @@ def test_declared_family_roundtrip_and_strict_boundary(tmp_path): McpClientAcknowledgementUpdate( "turn", McpLiveAcknowledgement(1, "pi-mcp-client", "a" * 32, "running", "turn", ()) ), - TurnChangedUpdate(TurnState(ActiveTurn("turn", ProcessIdentity(123, 1), started_at=1.0))), + TurnChangedUpdate(TurnState(ActiveTurn(TurnId('9b473845f684444f80a26586ae298d2b'), ProcessIdentity(123, 1), started_at=1.0))), TurnChangedUpdate(TurnState(finished_turn_id="turn")), TextRouteUpdate(None), TranscriptChangedUpdate(NativeTranscriptCursor("session.jsonl", 42)), diff --git a/tests/test_acp_queue_contract.py b/tests/test_acp_queue_contract.py index 21f676346e..c0079f8a21 100644 --- a/tests/test_acp_queue_contract.py +++ b/tests/test_acp_queue_contract.py @@ -1,6 +1,7 @@ """Provider-free exact-ID ACP queue projection; no Pi consumption inference.""" from __future__ import annotations +from agent_comms.field_codec import TurnId import os from pathlib import Path @@ -44,7 +45,7 @@ def _owner(tmp_path: Path) -> tuple[Comms, CommsAgent, RecordedThreadIncarnation async def test_real_acp_surrogate_queue_ingress_stays_unknown_and_attachable(tmp_path): comms, agent, _, _ = _owner(tmp_path) - comms.agents.begin_turn("beta", "active", "Held native fixture") + comms.agents.begin_turn("beta", TurnId('96879611650f80a81392a52e0db9b023'), "Held native fixture") inbox = agent.inputs.state('beta').open_inbox() response = await agent.prompt( "beta", @@ -69,7 +70,7 @@ async def test_real_acp_surrogate_queue_ingress_stays_unknown_and_attachable(tmp async def test_request_identity_tracks_equal_queue_and_immediate_inputs_without_replay(tmp_path): comms, agent, _, _ = _owner(tmp_path) - comms.agents.begin_turn("beta", "active", "Held native fixture") + comms.agents.begin_turn("beta", TurnId('96879611650f80a81392a52e0db9b023'), "Held native fixture") inbox = agent.inputs.state('beta').open_inbox() requests = [QueuePromptRequest("same"), SteerPromptRequest("same")] for request in requests: @@ -108,7 +109,7 @@ async def test_request_identity_tracks_equal_queue_and_immediate_inputs_without_ @pytest.mark.parametrize("user_text", [["list"], [], {"text": "dict"}, 7, False]) async def test_real_acp_rejects_nonstring_user_text_before_unknown_or_enqueue(tmp_path, user_text): comms, agent, _, _ = _owner(tmp_path) - comms.agents.begin_turn("beta", "active", "Held native fixture") + comms.agents.begin_turn("beta", TurnId('96879611650f80a81392a52e0db9b023'), "Held native fixture") inbox = agent.inputs.state('beta').open_inbox() metadata = encode_request(QueuePromptRequest("valid", True)) metadata["agentComms"]["request"]["user_text"] = user_text diff --git a/tests/test_active_turn.py b/tests/test_active_turn.py index 27303894bb..4fe881ffca 100644 --- a/tests/test_active_turn.py +++ b/tests/test_active_turn.py @@ -1,3 +1,4 @@ +from agent_comms.field_codec import TurnId import os import time from agent_comms.activity import ( @@ -20,7 +21,7 @@ def test_running_native_activity_is_derived_from_current_turn_phase(tmp_path): comms.registry.declare( Thread('worker', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) - first = comms.agents.begin_turn("worker", "first", "Long-running work").turn_lease + first = comms.agents.begin_turn("worker", TurnId('a7937b64b8caa58f03721bb6bacf5c78'), "Long-running work").turn_lease before = comms.registry.snapshot() comms.registry.declare(Thread('worker', frozenset(), str(tmp_path))) after = comms.registry.snapshot() @@ -55,8 +56,8 @@ def test_running_native_activity_is_derived_from_current_turn_phase(tmp_path): comms.agents.finish_turn(first) assert comms.agents.activity_of("worker", snapshot=comms.registry.snapshot()).presentation("Worker").summary == "Ready" assert not comms.views.thread_views()[0].presentation.status.working - comms.agents.begin_turn("worker", "second") + comms.agents.begin_turn("worker", TurnId('16367aacb67a4a017c8da8ab95682ccb')) comms.agents.finish_turn(first) - assert comms.registry.require("worker").turn_record.read().require_active().id == "second" + assert comms.registry.require("worker").turn_record.read().require_active().id == '16367aacb67a4a017c8da8ab95682ccb' comms.owners.stop("worker") assert not comms.registry.require('worker').executing diff --git a/tests/test_activity_text.py b/tests/test_activity_text.py index 3a3843af9f..d83b291739 100644 --- a/tests/test_activity_text.py +++ b/tests/test_activity_text.py @@ -1,4 +1,5 @@ """New status text is bounded by activity; saved text is never repaired on read.""" +from agent_comms.field_codec import TurnId from agent_comms.pi_payloads import ReportedText import os @@ -61,7 +62,7 @@ def test_projection_preserves_source_identity_and_strict_record_validation(activ def test_native_status_reads_long_preparation_first_and_parallel_tools(comms): thread = comms.registry.declare(Thread('worker', frozenset(), str(comms.root), process_identity=ProcessIdentity.capture(os.getpid()))) detail = "preparing_" + "p" * Activity.detail_limit - lease = comms.agents.begin_turn(thread.name, "long-status", detail).turn_lease + lease = comms.agents.begin_turn(thread.name, TurnId('a9f5f927d05a07e19a018da42f972ef5'), detail).turn_lease try: assert comms.registry.require(thread.name).turn_state.active.phase.detail == detail assert comms.agents.activity_of(thread.name, snapshot=comms.registry.snapshot()).detail.endswith("…") @@ -81,7 +82,7 @@ def test_native_status_reads_long_preparation_first_and_parallel_tools(comms): def test_a_provider_failure_shows_its_phase_and_leaves_the_turn_to_its_owner(comms): thread = comms.registry.declare(Thread('provider-failure', frozenset(), str(comms.root), process_identity=ProcessIdentity.capture(os.getpid()))) - lease = comms.agents.begin_turn(thread.name, "provider-failure").turn_lease + lease = comms.agents.begin_turn(thread.name, TurnId('dcd2bcad2e6989f2c586e96751d5379d')).turn_lease try: failed = FailedPhase(error=CodelessProviderDiagnosticError("WebSocket error")) comms.agents.observe_native_phase(lease, failed) @@ -109,7 +110,7 @@ def members(result): return result.derived[0]["#clock"].members output.enter(ToolRunningPhase("unchanged")) - lease = comms.agents.begin_turn(native.name, "clock-order").turn_lease + lease = comms.agents.begin_turn(native.name, TurnId('aa9a56b081b3220f2cdf16fe278c351d')).turn_lease try: started = observed() assert members(started) == (native.name, cli.name) diff --git a/tests/test_atomic_turn_source_owner.py b/tests/test_atomic_turn_source_owner.py index 6d6cfdddcb..5d60aa11ac 100644 --- a/tests/test_atomic_turn_source_owner.py +++ b/tests/test_atomic_turn_source_owner.py @@ -1,4 +1,5 @@ """Original begin/attachment ownership with real registry and callback resources.""" +from agent_comms.field_codec import TurnId from contextlib import AsyncExitStack @@ -67,11 +68,11 @@ async def test_begin_callback_and_native_attachment_share_original_owner(tmp_pat def test_source_attachment_refuses_replaced_original_owner(tmp_path, replacement): comms = wire(tmp_path) comms.registry.declare(Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) - original = comms.agents.begin_turn("owner", "same-id") + original = comms.agents.begin_turn("owner", TurnId('058be655fae2a4c37a21d2c091ba7661')) assert comms.agents.finish_turn(original.turn_lease) if replacement == "new-claim_scope": comms.registry.register(comms.registry.require("owner"), new_owner=True) - current = comms.agents.begin_turn("owner", "same-id") + current = comms.agents.begin_turn("owner", TurnId('058be655fae2a4c37a21d2c091ba7661')) before = comms.registry.store.path.read_bytes() with pytest.raises(StaleFence): comms.registry.attach_native_session(original, str(tmp_path / "wrong.jsonl")) @@ -85,7 +86,7 @@ def test_native_source_publication_retains_original_claim_across_rename(tmp_path 'owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()), )) - original = comms.agents.begin_turn('owner', 'renamed-source') + original = comms.agents.begin_turn('owner', TurnId('9848d76b0bc0fadfe6c6463059c2c99b')) comms.registry.rename('owner', 'renamed') snapshot = comms.registry.snapshot() assert original.require_source_snapshot(snapshot).name == 'renamed' @@ -101,7 +102,7 @@ def test_native_source_publication_retains_original_claim_across_rename(tmp_path def test_native_source_publication_retains_captured_configuration(tmp_path): comms = wire(tmp_path) comms.registry.declare(Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()), model=DeclaredModel('test/old'))) - original = comms.agents.begin_turn("owner", "configuration-source-check") + original = comms.agents.begin_turn("owner", TurnId('353fe4ec44fe236b973ecec2489ac459')) comms.threads.set_thread_configuration( comms.registry.require('owner'), NextTurnConfiguration(model=ChosenModel(DeclaredModel('test/next')))) original.require_snapshot(comms.registry.snapshot(), "The claimed turn keeps its configuration") diff --git a/tests/test_bus_files.py b/tests/test_bus_files.py index b5fd11e112..c6e0d4f985 100644 --- a/tests/test_bus_files.py +++ b/tests/test_bus_files.py @@ -1,4 +1,5 @@ """The real bus publishes only files declared by their existing state owners.""" +from agent_comms.field_codec import TurnId from contextlib import closing @@ -55,7 +56,7 @@ def test_every_bus_file_is_a_declared_root_file(tmp_path: Path, source_repositor ExistingFileClaim(resource).normalized(tmp_path), NativeClaim( owner_claimed_generation=boundary.registry.claim_generation(owner.name), - operation_id="a" * 32, turn_id="declared-files", input_id="declared-file-input", + operation_id="a" * 32, turn_id=TurnId('76716892c3b5bb4cc8b0f07da1976c7e'), input_id="declared-file-input", ), activity=boundary.activity, ) diff --git a/tests/test_channels.py b/tests/test_channels.py index cf3568d764..331ff1c9f5 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -1,3 +1,4 @@ +from agent_comms.field_codec import TurnId from agent_comms.session_file import RegistrySessionLocation from agent_comms.pi_vocabulary import HighThinkingLevel import os @@ -505,7 +506,7 @@ def test_membership_notices_follow_exact_membership_and_do_not_wake(tmp_path): 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") + comms.agents.begin_turn("peer", TurnId('e99af36cdfcffbc191cce98c5d771afb')) assert [ person.thread.name for person in comms.views.viewer_snapshot(str(tmp_path)).participants("#api") ] == ["peer"] diff --git a/tests/test_claim_admission_verifier.py b/tests/test_claim_admission_verifier.py index b8fb89d5e6..1096ee4229 100644 --- a/tests/test_claim_admission_verifier.py +++ b/tests/test_claim_admission_verifier.py @@ -1,6 +1,7 @@ """Native claims refuse stale authority and redirected filesystem paths.""" from __future__ import annotations +from agent_comms.field_codec import TurnId from bus_record_fixture import prepare_native_comms @@ -74,7 +75,7 @@ def test_selected_wake_verifier_refuses_no_wake_and_stale_authority( assert type(assignment.lifecycle) is FullPendingAssignment owner, claimed_generation = comms.registry.live_owner_with_claim("Alice") owner, claimed_generation = comms.registry.lease_live_turn_with_claim( - owner, "selected-turn", expected_generation=claimed_generation + owner, TurnId('088fbdccfa8d303a5026987cf37e23b8'), expected_generation=claimed_generation ) person = store.participants.get(alice_lookup) execution_id = "verifier-execution" diff --git a/tests/test_claim_bus_read.py b/tests/test_claim_bus_read.py index 653e4bb79f..0dea62cc3a 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +from agent_comms.field_codec import TurnId from agent_comms.context_store import UNREAD_CONTEXT @@ -66,7 +67,7 @@ def record_context(comms: Comms, name: str, generation: int) -> None: ) comms.contexts.record_after(UNREAD_CONTEXT, ContextManifest( owner.incarnation, - RecordedContextTurn(RegisteredTurn(f"turn-{generation}"), TurnIdentity(owner.incarnation, generation)), + RecordedContextTurn(RegisteredTurn(TurnId(f"{generation:032x}")), TurnIdentity(owner.incarnation, generation)), (segment,), "pi.estimateTokens", )) @@ -98,7 +99,7 @@ def selected_turn(private_comms, private_directory): assignment = sealed_cohort_assignments(store, lookup)[0] owner, generation = comms.registry.live_owner_with_claim("Alice") owner, generation = comms.registry.lease_live_turn_with_claim( - owner, "selected-turn", expected_generation=generation + owner, TurnId('088fbdccfa8d303a5026987cf37e23b8'), expected_generation=generation ) person = store.participants.get(lookup) store.create( diff --git a/tests/test_cli.py b/tests/test_cli.py index ab12fd62bc..0cbedb96a8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +from agent_comms.field_codec import TurnId from agent_comms.bus import Bus @@ -375,7 +376,7 @@ def test_restart_catalog_uses_original_owner_and_idle_requirements(self, tmp_pat busy = TurnIdentity(owner.incarnation, owner.turn_generation + 1) record = StoredTurnRecord.begin( comms.registry.store.path, busy, - ActiveTurn('busy', owner.require_process(), turn_generation=RecordedGeneration(busy.generation)), + ActiveTurn(TurnId('c9bc072f4fa8189466c2a8f2c36a56a4'), owner.require_process(), turn_generation=RecordedGeneration(busy.generation)), ) assert not RestartCliCommand.thread_bindings(comms, replace(owner, turn_record=record, turn_generation=busy.generation), status) diff --git a/tests/test_context_manifest_deltas.py b/tests/test_context_manifest_deltas.py index c807d8aadb..f2ff8f0656 100644 --- a/tests/test_context_manifest_deltas.py +++ b/tests/test_context_manifest_deltas.py @@ -1,4 +1,5 @@ """Recorded context reuses source-bound text and transcript prefixes.""" +from agent_comms.field_codec import TurnId from agent_comms.context_store import UNREAD_CONTEXT from dataclasses import replace import hashlib @@ -34,7 +35,7 @@ def segment(kind, text, sources=provenance, contributors=()): transcript = segment(TranscriptSegment, '\n'.join(f'Entry {i}' for i in range(count)), contributors=contributors) return ContextManifest(owner.incarnation, - RecordedContextTurn(RegisteredTurn(f'turn-{turn}'), TurnIdentity(owner.incarnation, turn)), + RecordedContextTurn(RegisteredTurn(TurnId(f"{turn:032x}")), TurnIdentity(owner.incarnation, turn)), (segment(SystemLayerSegment, 's' * 27000), segment(ToolCatalogSegment, 't' * 64000), transcript), 'counter', ObservedDispatchRequest(f'request-{turn}')) @@ -46,7 +47,7 @@ def test_context_deltas_restore_original_text_and_incremental_membership(tmp_pat first = context(owner, tmp_path, 328, 1) second = context(owner, tmp_path, 330, 2) prior = UNREAD_CONTEXT - for manifest in (first, second, replace(second, turn=replace(second.turn, identity=RegisteredTurn('turn-3')), + for manifest in (first, second, replace(second, turn=replace(second.turn, identity=RegisteredTurn(TurnId('431ad49b3e0129a11ea5a0320c188024'))), request_id=ObservedDispatchRequest('request-3'))): # The writer keeps the request it recorded as the next request's prior. prior = comms.contexts.record_after(prior, manifest) diff --git a/tests/test_context_source_inspection.py b/tests/test_context_source_inspection.py index 7a0a95b173..2ebb13cae9 100644 --- a/tests/test_context_source_inspection.py +++ b/tests/test_context_source_inspection.py @@ -1,4 +1,5 @@ """Source membership, public-only capture, and selected recorded values.""" +from agent_comms.field_codec import TurnId from agent_comms.context_store import UNREAD_CONTEXT from bus_history import read_history @@ -36,7 +37,7 @@ def identity(tmp_path): def recorded(segments): owner = RecordedThreadIncarnation("original-owner", 17002.0) - turn = RecordedContextTurn(RegisteredTurn("original-turn"), TurnIdentity(owner, 2)) + turn = RecordedContextTurn(RegisteredTurn(TurnId('ab87f599dc0a50a56b7c65de71c39426')), TurnIdentity(owner, 2)) return ContextManifest(owner, turn, tuple(segments), "pi.estimateTokens", ObservedDispatchRequest("request-one")) diff --git a/tests/test_coordination_response.py b/tests/test_coordination_response.py index 537c420142..2f6dfd0b9b 100644 --- a/tests/test_coordination_response.py +++ b/tests/test_coordination_response.py @@ -1,6 +1,7 @@ """Actual fsynced bus + SQLite response boundaries, default OFF outside opted-in roots.""" from __future__ import annotations +from agent_comms.field_codec import TurnId from bus_record_fixture import prepare_native_comms @@ -128,7 +129,7 @@ def _ready(tmp_path: Path, *, direct: bool = False, extra_targets: tuple[str, .. ).value.fence owner, claim_scope = comms.registry.live_owner_with_claim("owner") owner, claim_scope = comms.registry.lease_live_turn_with_claim( - owner, "fixture-response-turn", expected_generation=claim_scope + owner, TurnId('005ef9d6f4a78b0d26efd11059aa75c2'), expected_generation=claim_scope ) witness = LiveResponseOwner(thread=owner, claimed_generation=claim_scope) return Fixture( diff --git a/tests/test_current_input_origin.py b/tests/test_current_input_origin.py index 988988880a..41d112c22e 100644 --- a/tests/test_current_input_origin.py +++ b/tests/test_current_input_origin.py @@ -1,4 +1,5 @@ """Original authored ingress and neutral historical evidence share one input store.""" +from agent_comms.field_codec import TurnId from agent_comms.controller import Controller, SessionUpdateSink from agent_comms.channel_targets import CommandTarget @@ -84,7 +85,7 @@ def unavailable_scope(): async def test_original_human_followup_survives_forwarding_reservation_and_native_disposition(live_owner): comms, agent = live_owner agent.on_connect(Client()) - comms.agents.begin_turn("beta", "held-native", "Existing original") + comms.agents.begin_turn("beta", TurnId('f6651da6017ff80246152c3c01c5507c'), "Existing original") agent.inputs.state('beta').open_inbox() origin = capture(comms) before = comms.bus.bus.revision() @@ -106,8 +107,8 @@ async def test_original_human_followup_survives_forwarding_reservation_and_nativ assert row.context_provenance() in contribution.provenance native_id = "b" * 32 sent = "User follow-up:\n" + command.user_text - assert inputs.bind(key, claim_scope=row.claim_generation, turn_id="held-native", native_id=native_id, text=sent) - assert inputs.started(key, turn_id="held-native", native_id=native_id, text=sent) + assert inputs.bind(key, claim_scope=row.claim_generation, turn_id=TurnId('f6651da6017ff80246152c3c01c5507c'), native_id=native_id, text=sent) + assert inputs.started(key, turn_id=TurnId('f6651da6017ff80246152c3c01c5507c'), native_id=native_id, text=sent) # Existing terminal settlement never rewrites a started original as # unsent or grants a replay; the native completion owns that. assert inputs.settle_unbound((key,)).lookup(key).has_started diff --git a/tests/test_declarations.py b/tests/test_declarations.py index 59fc687639..675a5aaee0 100644 --- a/tests/test_declarations.py +++ b/tests/test_declarations.py @@ -1,3 +1,4 @@ +from agent_comms.field_codec import TurnId from bus_record_fixture import prepare_native_comms @@ -207,7 +208,7 @@ def test_stop_then_heartbeat_cannot_reclaim_initial_owner_generation(self, tmp_p assert registry.snapshot().owner_identity("a").generation > claimed_generation with pytest.raises(RelationViolationError): registry.lease_live_turn_with_generation( - expected, "new-turn", expected_owner_generation=claimed_generation + expected, TurnId('18ab1892f9f48635ac465d11156044ce'), expected_owner_generation=claimed_generation ) def test_owner_claim_survives_session_metadata_and_rotates_on_restart( @@ -245,7 +246,7 @@ def test_rename_reclaims_own_alias_without_losing_owner_or_turn(self, tmp_path: original = registry.require("agent-comms-ux") registry.rename("agent-comms-ux", "pr17") claim_scope = registry.snapshot().claim_generation("pr17") - registry.lease_local_turn("pr17", "goal-turn") + registry.lease_local_turn("pr17", TurnId('3de341077ab881901aed7df98057d67f')) assert registry.rename("pr17", "agent-comms-ux") == ("pr17", "agent-comms-ux") @@ -271,13 +272,13 @@ def test_other_owner_writes_do_not_invalidate_private_generation(self, tmp_path: other = RegistryOwner.capture_local(snapshot, "b").thread other_generation = snapshot.owner_identity("b").generation registry.lease_live_turn_with_generation( - other, "other", expected_owner_generation=other_generation + other, TurnId('d9298a10d1b0735837dc4bd85dac641b'), expected_owner_generation=other_generation ) snapshot = registry.snapshot() assert RegistryOwner.capture_local(snapshot, "a").thread == owner assert snapshot.owner_identity("a").generation == claimed_generation turn, _ = registry.lease_live_turn_with_generation( - owner, "mine", expected_owner_generation=claimed_generation + owner, TurnId('3fd30542fe3f61b14bd4a4b2dc0b6fb3'), expected_owner_generation=claimed_generation ) lease = turn.require_turn_lease() assert registry.release_turn(lease)[0] @@ -294,7 +295,7 @@ def test_registering_saved_turn_never_attests_a_revoked_incarnation( owner = RegistryOwner.capture_local(snapshot, "a").thread claimed_generation = snapshot.owner_identity("a").generation leased, owner_generation = registry.lease_live_turn_with_generation( - owner, "claimed", expected_owner_generation=claimed_generation + owner, TurnId('d79d3458a58877cd8e48c18ebaf4c27b'), expected_owner_generation=claimed_generation ) snapshot = registry.snapshot() assert RegistryOwner.capture_local(snapshot, "a").thread == leased @@ -319,7 +320,7 @@ def test_comms_begin_turn_cannot_revive_stopped_owner(self, tmp_path: Path) -> N comms.registry.declare(Thread(name='a', tags=frozenset(), worktree='/wt', process_identity=ProcessIdentity.capture(os.getpid()))) comms.registry.unregister("a") with pytest.raises(RelationViolationError): - comms.agents.begin_turn("a", "revived") + comms.agents.begin_turn("a", TurnId('eb1c7f738bbe6d0fd106bf4a2fe97600')) assert comms.registry.status("a") == StoppedThreadStatus() @@ -348,7 +349,7 @@ def test_native_guard_does_not_bootstrap_stripped_owner_generation( data.pop(FieldCodec.record_key(RegistryDocument, field)) registry_path.write_text(json.dumps(data)) with pytest.raises(RelationViolationError): - comms.agents.begin_turn("a", "must-not-claim") + comms.agents.begin_turn("a", TurnId('18e37571ddd8a9cd1f2b7ffa63493bb6')) with pytest.raises(RelationViolationError): comms.registry.register(Thread(name='a', tags=frozenset(), worktree='/wt')) @@ -427,10 +428,10 @@ def test_release_turn_resolves_retained_alias_only_for_exact_lease(self, tmp_pat claimed_generation = snapshot.owner_identity("a").generation assert owner.name == "b" leased, _ = registry.lease_live_turn_with_generation( - owner, "claimed", expected_owner_generation=claimed_generation + owner, TurnId('d79d3458a58877cd8e48c18ebaf4c27b'), expected_owner_generation=claimed_generation ) registry.rename("b", "c") - assert not registry.release_turn(replace(leased.turn_lease, turn_id="other"))[0] + assert not registry.release_turn(replace(leased.turn_lease, turn_id=TurnId('d9298a10d1b0735837dc4bd85dac641b')))[0] assert registry.release_turn(leased.turn_lease)[0] diff --git a/tests/test_envelope_claim_transitions.py b/tests/test_envelope_claim_transitions.py index 4213dd0e43..2bbda0419f 100644 --- a/tests/test_envelope_claim_transitions.py +++ b/tests/test_envelope_claim_transitions.py @@ -1,4 +1,5 @@ """ClaimTransition owns atomic application; durability belongs to its checkpoint.""" +from agent_comms.field_codec import TurnId import os from dataclasses import replace @@ -42,7 +43,7 @@ def test_same_name_can_take_a_previous_birth_only_with_current_registry_evidence process_identity=ProcessIdentity.capture(os.getpid())), RunningThreadStatus()) registry = comms.registry.snapshot() scope = NativeClaim(owner_claimed_generation=1, operation_id="c" * 32, - turn_id="turn-owner", input_id="input-owner") + turn_id=TurnId('5d1970910e79a04eb98c8e9533701156'), input_id="input-owner") held = WakeClaims("owner", "1.0", 1, "msg-1", claims=(resource,), generation=G1, resource_scope=scope).project(ClaimProjection()) original = held[resource] @@ -72,7 +73,7 @@ def test_selected_wake_binding_survives_claim_projection(tmp_path): wire_root_id="c" * 32, source_seq=7, source_message_id="source-7", wake_assignment_id="cohort-v1:" + "d" * 64, wake_revision=3, recipient_lookup="e" * 32, execution_id="execution-7", operation_id="f" * 32, - owner_claimed_generation=2, turn_id="turn-7", participant_generation=1, + owner_claimed_generation=2, turn_id=TurnId('c8196fb128b89f486c374c42dba8df8e'), participant_generation=1, attempt_ordinal=1, ) claimed = WakeClaims("owner", "epoch-1", 8, "msg-8", claims=(resource,), diff --git a/tests/test_goal_answers.py b/tests/test_goal_answers.py index f06e3352a0..705ca46bf2 100644 --- a/tests/test_goal_answers.py +++ b/tests/test_goal_answers.py @@ -1,8 +1,8 @@ """Goal absence is dispatched before work; a clear never revives a turn's goal.""" +from agent_comms.field_codec import TurnId import os from contextlib import ExitStack, contextmanager -from uuid import uuid4 import pytest @@ -27,7 +27,7 @@ def goal_turn(comms, original_goal): )) if original_goal: goal = comms.goals.update_goal("worker", SetGoalAction(text="Original work")) - turn = RegisteredTurn(uuid4().hex) + turn = RegisteredTurn(TurnId.mint()) owner = comms.agents.begin_turn("worker", turn.value) lease = owner.thread.require_turn_lease() try: diff --git a/tests/test_goal_failure_observation.py b/tests/test_goal_failure_observation.py index ed673e01bd..d8aeb90c3e 100644 --- a/tests/test_goal_failure_observation.py +++ b/tests/test_goal_failure_observation.py @@ -1,4 +1,5 @@ """Failure evidence is passive; only its fenced attempt owns terminal writes.""" +from agent_comms.field_codec import TurnId from agent_comms.errors import StaleAttemptError import json @@ -35,7 +36,7 @@ def bound(tmp_path, *, thread_registration): permit = store.claim_launch(store.reserve(goal.id, 1)) identity = TurnIdentity(owner.incarnation, 7) lease = TurnLeaseFence( - identity, "a" * 32, 3, + identity, TurnId('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 3, record=StoredTurnRecord.for_identity(thread_registration.store.path, identity), ) observation = FailedTurnObservation.from_terminal( diff --git a/tests/test_goal_input_review.py b/tests/test_goal_input_review.py index 9a2c33dea8..ef908369b6 100644 --- a/tests/test_goal_input_review.py +++ b/tests/test_goal_input_review.py @@ -1,4 +1,5 @@ """Canonical inspection unblocks standby without fabricating native delivery.""" +from agent_comms.field_codec import TurnId from agent_comms.lifecycle_capability import Unresolved from agent_comms.channel_targets import CommandTarget @@ -36,7 +37,7 @@ def test_inspected_canonical_dependencies_allow_standby_without_replay(tmp_path, comms.registry.declare( Thread(name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) - comms.agents.begin_turn("parent", "parent-delegation-in-flight") + comms.agents.begin_turn("parent", TurnId('aa7988bb017ade70857b6b2966e64ea2')) goal = comms.goals.update_goal("worker", SetGoalAction(text="Delegate and wait")) messages = [ publish_initial(comms, "parent", "worker", text) @@ -141,7 +142,7 @@ def test_standby_excludes_retained_rebound_history_but_retains_rename(tmp_path, comms.registry.declare( Thread(rebound, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) - comms.agents.begin_turn("parent", "new-turn") + comms.agents.begin_turn("parent", TurnId('18ab1892f9f48635ac465d11156044ce')) goal = comms.goals.update_goal("worker", SetGoalAction(text="Current goal")) assert comms.goals.goal_input_review("worker", goal.id, ["parent"]).reviewed_inputs == () action = StandbyGoalAction(expect=GoalPrecondition(goal_id=goal.id), wait_for=("parent",)) diff --git a/tests/test_goal_reply_lock_scope.py b/tests/test_goal_reply_lock_scope.py index 9a122bca37..71ce67dbf2 100644 --- a/tests/test_goal_reply_lock_scope.py +++ b/tests/test_goal_reply_lock_scope.py @@ -1,4 +1,5 @@ """Reply reads leave the registry free, and publication keeps its observed state.""" +from agent_comms.field_codec import TurnId import fcntl import os @@ -90,7 +91,7 @@ def reviewing(tmp_path): name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()), )) - comms.agents.begin_turn("child", "child-work") + comms.agents.begin_turn("child", TurnId('83dcc89110e0b7e93d50dfc4d8f66d0f')) comms.goals.update_goal("owner", SetGoalAction(text="Review the child's work")) return comms @@ -137,7 +138,7 @@ def test_completed_reply_is_decoded_without_holding_registry(probed): probed.goals.update_goal("owner", standby(probed)) reply = probed.messaging.send("child", "owner", "Finished").message probed.bus.bus.checks.clear() - witness = probed.agents.begin_turn("owner", "handling-reply") + witness = probed.agents.begin_turn("owner", TurnId('8443600f04e8b6b0a977082a8a88321d')) assert probed.goals.consume_reply_wait(witness, reply.reference) assert probed.bus.bus.checks == [0] @@ -174,12 +175,12 @@ def replace_wait(comms, prepared): def begin_owner_turn(comms, prepared): - comms.agents.begin_turn("owner", "new-owner-turn") + comms.agents.begin_turn("owner", TurnId('85a98504553ba87ec3fe28eb889fab23')) def begin_dependency_turn(comms, prepared): comms.agents.finish_turn(comms.registry.require("child").require_turn_lease()) - comms.agents.begin_turn("child", "new-child-turn") + comms.agents.begin_turn("child", TurnId('25b63a30f24cf9d67463f931d800c73a')) def review_inputs(comms, prepared): @@ -263,7 +264,7 @@ def test_terminal_reply_read_cannot_clear_a_newer_wait(reviewing): def completed_reply(comms): comms.goals.update_goal("owner", standby(comms)) reply = comms.messaging.send("child", "owner", "The actual completed reply").message - witness = comms.agents.begin_turn("owner", "original-handling-turn") + witness = comms.agents.begin_turn("owner", TurnId('14ef4c75692efd61f1b3f105c8af23d2')) return witness, reply, comms.goals.goal_wait("owner") @@ -282,7 +283,7 @@ def test_completed_reply_rechecks_its_original_turn(reviewing): def advance_turn(): reviewing.agents.finish_turn(witness.thread.require_turn_lease()) - reviewing.agents.begin_turn("owner", "a-later-turn") + reviewing.agents.begin_turn("owner", TurnId('13e021d6bd76f1eca99b0268508e0542')) reviewing.bus.bus = AfterReadBus(reviewing.bus.bus.path, advance_turn) with pytest.raises(StaleFence): @@ -426,7 +427,7 @@ def test_terminal_callback_reobserves_same_wait_after_waiter_turn_changes(review fence = reviewing.agents.finish_turn(reviewing.registry.require("child").require_turn_lease()) def finish_waiter_turn(): - witness = reviewing.agents.begin_turn("owner", "intervening-owner-turn") + witness = reviewing.agents.begin_turn("owner", TurnId('0ab2ad7eead38307a0a663568aed1d8b')) reviewing.agents.finish_turn(witness.thread.require_turn_lease()) assert reviewing.goals.goal_wait("owner") == wait diff --git a/tests/test_goal_resume.py b/tests/test_goal_resume.py index 5d56b4bb58..19583405e1 100644 --- a/tests/test_goal_resume.py +++ b/tests/test_goal_resume.py @@ -1,4 +1,5 @@ """Same-ID, explicitly requested goal resume through the public tool boundary.""" +from agent_comms.field_codec import TurnId import os @@ -61,7 +62,7 @@ def test_second_goal_report_in_one_turn_is_rejected(comms, monkeypatch, tmp_path Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) goal = invoke_tool(comms, "comms_set_goal", {"text": "finish this"})["goal"] - comms.agents.begin_turn("owner", "same-assistant-turn") + comms.agents.begin_turn("owner", TurnId('d7f9429f60abc2787fc3e9717054ea41')) first = invoke_tool( comms, @@ -84,7 +85,7 @@ def test_replacing_or_clearing_goal_cannot_reset_turn_report_guard(comms, monkey Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) first = invoke_tool(comms, "comms_set_goal", {"text": "first"})["goal"] - comms.agents.begin_turn("owner", "same-assistant-turn") + comms.agents.begin_turn("owner", TurnId('d7f9429f60abc2787fc3e9717054ea41')) invoke_tool( comms, "comms_goal", diff --git a/tests/test_goal_standby.py b/tests/test_goal_standby.py index 6f18b9867d..2ee07255d9 100644 --- a/tests/test_goal_standby.py +++ b/tests/test_goal_standby.py @@ -1,4 +1,5 @@ """Declared goal dependencies control scheduling without changing legacy registry rows.""" +from agent_comms.field_codec import TurnId import os from dataclasses import replace @@ -40,7 +41,7 @@ def test_standby_rejects_closed_wait_cycle_while_both_turns_are_active(tmp_path) comms.registry.declare( Thread(name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) - comms.agents.begin_turn(name, f"{name}-turn") + comms.agents.begin_turn(name, TurnId.mint()) alice = comms.goals.update_goal("alice", SetGoalAction(text="Wait for Bob")) bob = comms.goals.update_goal("bob", SetGoalAction(text="Wait for Alice")) assert alice is not None and bob is not None @@ -50,7 +51,7 @@ def test_standby_rejects_closed_wait_cycle_while_both_turns_are_active(tmp_path) ) alice_wait = comms.goals.goal_wait("alice") assert alice_wait is not None - assert alice_wait.report_turn_id == "alice-turn" + assert alice_wait.report_turn_id == comms.registry.require("alice").turn_lease.turn_id assert alice_wait.report_turn_generation == comms.registry.require("alice").turn_generation with pytest.raises(ValueError): comms.goals.update_goal( @@ -68,7 +69,7 @@ def test_standby_allows_independent_alternative_to_wait_cycle(tmp_path): comms.registry.declare( Thread(name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) - comms.agents.begin_turn(name, f"{name}-turn") + comms.agents.begin_turn(name, TurnId.mint()) alice = comms.goals.update_goal("alice", SetGoalAction(text="Wait for Bob or Carol")) bob = comms.goals.update_goal("bob", SetGoalAction(text="Wait for Alice")) assert alice is not None and bob is not None @@ -92,7 +93,7 @@ def test_idle_live_alternative_keeps_wait_cycle_open(tmp_path): Thread(name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) for name in ("alice", "bob"): - comms.agents.begin_turn(name, f"{name}-turn") + comms.agents.begin_turn(name, TurnId.mint()) alice = comms.goals.update_goal("alice", SetGoalAction(text="Wait for Bob or Carol")) bob = comms.goals.update_goal("bob", SetGoalAction(text="Wait for Alice")) carol = comms.goals.update_goal("carol", SetGoalAction(text="Idle goal")) @@ -116,11 +117,11 @@ def test_dead_active_turn_does_not_make_wait_cycle_runnable(tmp_path): comms.registry.declare( Thread(name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) - comms.agents.begin_turn(name, f"{name}-turn") + comms.agents.begin_turn(name, TurnId.mint()) comms.registry.declare( Thread('carol', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) - comms.agents.begin_turn("carol", "carol-turn") + comms.agents.begin_turn("carol", TurnId('91a3ae9c092f86bc92b86f06b3eb613a')) carol_thread = comms.registry.require("carol") assert carol_thread.executing record = carol_thread.require_turn_lease().record @@ -169,7 +170,7 @@ def test_idle_live_dependency_keeps_wait_group_open(tmp_path): comms.registry.declare(Thread( name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) - comms.agents.begin_turn(name, f"{name}-turn") + comms.agents.begin_turn(name, TurnId.mint()) alice = comms.goals.update_goal("alice", SetGoalAction(text="Wait for Bob")) comms.goals.update_goal("bob", SetGoalAction(text="Finish work and answer later")) comms.goals.update_goal("alice", StandbyGoalAction( @@ -192,7 +193,7 @@ def test_liveness_check_releases_preexisting_closed_wait_group(tmp_path, pending comms.registry.declare( Thread(name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) - comms.agents.begin_turn(name, f"{name}-turn") + comms.agents.begin_turn(name, TurnId.mint()) alice = comms.goals.update_goal("alice", SetGoalAction(text="Wait for Bob")) bob = comms.goals.update_goal("bob", SetGoalAction(text="Wait for Alice")) assert alice is not None and bob is not None @@ -240,7 +241,7 @@ def test_new_live_dependency_turn_keeps_old_wait_group_open(tmp_path, bound_old_ comms.registry.declare( Thread(name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) - comms.agents.begin_turn(name, f"{name}-first") + comms.agents.begin_turn(name, TurnId.mint()) alice = comms.goals.update_goal("alice", SetGoalAction(text="Wait for Bob")) bob = comms.goals.update_goal("bob", SetGoalAction(text="Wait for Alice")) assert alice is not None and bob is not None @@ -258,13 +259,13 @@ def test_new_live_dependency_turn_keeps_old_wait_group_open(tmp_path, bound_old_ (GoalWaitTarget(peer.incarnation.require_recorded()),), owner=owner.incarnation.require_recorded(), target_turn_generations=(peer.turn_generation,), - report_turn_id="bob-first" if bound_old_turn else None, + report_turn_id=owner.turn_lease.turn_id if bound_old_turn else None, report_turn_generation=owner.turn_generation if bound_old_turn else None, ) ) comms.agents.finish_turn(comms.registry.require("alice").turn_lease) comms.agents.finish_turn(comms.registry.require("bob").turn_lease) - comms.agents.begin_turn("bob", "bob-independent-new") + comms.agents.begin_turn("bob", TurnId('51baa1dde83052cee4eb9bef5397a63c')) wait = comms.goals.goal_wait("alice") assert wait is not None assert comms.goals.recover_closed_goal_wait("alice") == () diff --git a/tests/test_goal_standby_liveness.py b/tests/test_goal_standby_liveness.py index 86c4217209..dc8a015987 100644 --- a/tests/test_goal_standby_liveness.py +++ b/tests/test_goal_standby_liveness.py @@ -1,4 +1,5 @@ """Standby liveness preserves goal authority and never replays an input.""" +from agent_comms.field_codec import TurnId from agent_comms.messages import ConversationRole, NoticeRole @@ -56,10 +57,10 @@ def _waiting(tmp_path, *, second=False): comms._test_leases = {} _thread(comms, "owner", tmp_path) _thread(comms, "child", tmp_path) - _begin(comms, "child", "child-turn") + _begin(comms, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) if second: _thread(comms, "other", tmp_path) - _begin(comms, "other", "other-turn") + _begin(comms, "other", TurnId('e2141dc9c71046570fc0e6a19ad6f5c1')) goal = comms.goals.update_goal("owner", SetGoalAction(text="Review the delegated work")) assert goal is not None targets = ["child", "other"] if second else ["child"] @@ -96,7 +97,7 @@ def test_stopped_target_refusal_is_side_effect_free(tmp_path): def test_mixed_targets_project_idle_without_suppressing_active_alternative(tmp_path): comms, goal = _waiting(tmp_path, second=True) - other_fence = _finish(comms, "other", "other-turn") + other_fence = _finish(comms, "other", TurnId('e2141dc9c71046570fc0e6a19ad6f5c1')) execution = comms.goals.goal_execution("owner") assert [target.incarnation.name for target in execution.inactive_wait_for] == ["other"] assert "no active turn: @other" in execution.presentation("owner").summary @@ -108,9 +109,9 @@ def test_mixed_targets_project_idle_without_suppressing_active_alternative(tmp_p def test_last_target_finishes_silently_releases_once_and_reopens(tmp_path): comms, goal = _waiting(tmp_path, second=True) - other_fence = _finish(comms, "other", "other-turn") + other_fence = _finish(comms, "other", TurnId('e2141dc9c71046570fc0e6a19ad6f5c1')) assert comms.goals.release_waits_after_terminal_turn(other_fence) == () - child_fence = _finish(comms, "child", "child-turn") + child_fence = _finish(comms, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) assert comms.goals.release_waits_after_terminal_turn(child_fence) == ("owner",) reopened = wire(comms.root) continued = reopened.registry.require("owner").goal @@ -136,7 +137,7 @@ def test_only_substantive_direct_reply_prevents_release( ): comms, _goal = _waiting(tmp_path) comms.messaging.send("child", "owner", body, role=role).message - child_fence = _finish(comms, "child", "child-turn") + child_fence = _finish(comms, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) changed = comms.goals.release_waits_after_terminal_turn(child_fence) assert changed == expected_changed assert comms.registry.require("owner").goal.state.declared_name == "active" @@ -146,7 +147,7 @@ def test_only_substantive_direct_reply_prevents_release( def test_renamed_target_keeps_incarnation_but_replacement_fails_closed(tmp_path): comms, goal = _waiting(tmp_path) comms.registry.rename("child", "renamed-child") - child_fence = _finish(comms, "renamed-child", "child-turn") + child_fence = _finish(comms, "renamed-child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) assert comms.goals.release_waits_after_terminal_turn(child_fence) == ("owner",) assert comms.registry.require("owner").goal.state.declared_name == "active" assert comms.registry.require("owner").goal.id == goal.id @@ -171,7 +172,7 @@ def test_renamed_target_keeps_incarnation_but_replacement_fails_closed(tmp_path) def test_replaced_target_cannot_report_for_old_incarnation(tmp_path): comms, goal = _waiting(tmp_path) old = comms.registry.require("child") - old_fence = _finish(comms, "child", "child-turn") + old_fence = _finish(comms, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) comms.registry.unregister("child") comms.registry.remove("child") _thread(comms, "child", tmp_path) @@ -191,7 +192,7 @@ def test_owner_pause_still_prevents_quiet_dependency_release(tmp_path): "owner", PausedGoalAction(expect=GoalPrecondition(goal_id=goal.id)), actor=OwnerInvocable ) assert paused is not None - fence = _finish(comms, "child", "child-turn") + fence = _finish(comms, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) assert comms.goals.release_waits_after_terminal_turn(fence) == () assert comms.registry.require("owner").goal == paused assert comms.registry.require("owner").goal.state.pause_source.declared_name == "owner" @@ -200,7 +201,7 @@ def test_owner_pause_still_prevents_quiet_dependency_release(tmp_path): def test_terminal_hook_rechecks_goal_and_owner_identity(tmp_path): comms, goal = _waiting(tmp_path) - child_fence = _finish(comms, "child", "child-turn") + child_fence = _finish(comms, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) newer = comms.goals.update_goal( "owner", EditGoalAction(expect=GoalPrecondition(goal_id=goal.id), text="new exact objective"), @@ -217,7 +218,7 @@ def test_stopped_dependency_cannot_be_declared_live(tmp_path): comms = canonical_goal_wire(tmp_path / "wire") _thread(comms, "owner", tmp_path) _thread(comms, "child", tmp_path) - comms.agents.begin_turn("child", "work") + comms.agents.begin_turn("child", TurnId('00e13ed7af55b27622f1d6eab5bec014')) child = comms.registry.require("child") comms.registry.register(child.without_turn(), StoppedThreadStatus()) goal = comms.goals.update_goal("owner", SetGoalAction(text="Review")) @@ -232,8 +233,8 @@ def test_stopped_dependency_cannot_be_declared_live(tmp_path): def test_delayed_old_finish_cannot_release_new_active_same_id(tmp_path): comms, _goal = _waiting(tmp_path) old_claim = comms._test_leases["child"] - old_fence = _finish(comms, "child", "child-turn") - new_claim = _begin(comms, "child", "child-turn") + old_fence = _finish(comms, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) + new_claim = _begin(comms, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) assert new_claim.identity.generation == old_claim.identity.generation + 1 active = comms.registry.require("child").turn_record.read() assert comms.agents.finish_turn(old_claim) == NoFinishedTurn() @@ -249,9 +250,9 @@ def test_delayed_old_finish_cannot_release_new_active_same_id(tmp_path): @pytest.mark.parametrize("reuse_turn_id", [False, True]) def test_delayed_old_terminal_cannot_release_newer_turn_before_reply(tmp_path, reuse_turn_id): comms, goal = _waiting(tmp_path) - old_fence = _finish(comms, "child", "child-turn") + old_fence = _finish(comms, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) assert old_fence is not None - newer_id = "child-turn" if reuse_turn_id else "new-child-turn" + newer_id = old_fence.turn_id if reuse_turn_id else TurnId.mint() _begin(comms, "child", newer_id) new_fence = _finish(comms, "child", newer_id) assert new_fence is not None @@ -269,9 +270,9 @@ def test_delayed_old_terminal_cannot_release_newer_turn_before_reply(tmp_path, r def test_newer_silent_terminal_releases_waiter(tmp_path): comms, _goal = _waiting(tmp_path) - old_fence = _finish(comms, "child", "child-turn") - _begin(comms, "child", "new-child-turn") - new_fence = _finish(comms, "child", "new-child-turn") + old_fence = _finish(comms, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) + _begin(comms, "child", TurnId('25b63a30f24cf9d67463f931d800c73a')) + new_fence = _finish(comms, "child", TurnId('25b63a30f24cf9d67463f931d800c73a')) assert comms.goals.release_waits_after_terminal_turn(old_fence) == () assert comms.goals.release_waits_after_terminal_turn(new_fence) == ("owner",) assert isinstance(comms.goals.goal_wait("owner"), NoGoalWait) @@ -279,7 +280,7 @@ def test_newer_silent_terminal_releases_waiter(tmp_path): def test_terminal_fence_survives_rename_not_stop_or_metadata_edit(tmp_path): comms, _goal = _waiting(tmp_path) - fence = _finish(comms, "child", "child-turn") + fence = _finish(comms, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) assert fence is not None child = comms.registry.require("child") comms.registry.register(replace(child, title=GivenThreadText("New title")), RunningThreadStatus()) @@ -293,7 +294,7 @@ def test_terminal_fence_survives_rename_not_stop_or_metadata_edit(tmp_path): assert comms.goals.release_waits_after_terminal_turn(fence) == ("owner",) another, _goal = _waiting(tmp_path / "stop") - stale = _finish(another, "child", "child-turn") + stale = _finish(another, "child", TurnId('9782df8c75cffb6a483d963ccaefc23b')) child = another.registry.require("child") another.registry.unregister("child") another.registry.register(child.without_turn(), RunningThreadStatus()) diff --git a/tests/test_goal_wait_certified_reply.py b/tests/test_goal_wait_certified_reply.py index 04f5bc313b..c32e23978b 100644 --- a/tests/test_goal_wait_certified_reply.py +++ b/tests/test_goal_wait_certified_reply.py @@ -1,4 +1,5 @@ """Dependency replies retain original checked identities across registry changes.""" +from agent_comms.field_codec import TurnId from agent_comms.messages import NoticeRole import os @@ -66,7 +67,7 @@ def test_original_reply_is_not_reassigned_to_same_name_replacement(tmp_path): comms = canonical_goal_wire(tmp_path / "wire") owner = register(comms, "owner") peer = register(comms, "peer") - lease = comms.agents.begin_turn("peer", "original-work").turn_lease + lease = comms.agents.begin_turn("peer", TurnId('6f42e257badc3c5ab4a67e743d8ab487')).turn_lease goal = comms.goals.update_goal("owner", SetGoalAction(text="Wait for original peer")) comms.goals.update_goal( "owner", StandbyGoalAction(expect=GoalPrecondition(goal_id=goal.id), wait_for=("peer",)) @@ -101,7 +102,7 @@ def test_original_recipient_and_direct_reply_semantics_cannot_be_replaced(tmp_pa owner = register(comms, "owner") register(comms, "other") register(comms, "peer") - comms.agents.begin_turn("peer", "original-work") + comms.agents.begin_turn("peer", TurnId('6f42e257badc3c5ab4a67e743d8ab487')) goal = comms.goals.update_goal("owner", SetGoalAction(text="Wait for original owner reply")) comms.goals.update_goal( "owner", StandbyGoalAction(expect=GoalPrecondition(goal_id=goal.id), wait_for=("peer",)) diff --git a/tests/test_historical_notifications.py b/tests/test_historical_notifications.py index 25a39b304d..340aab1fef 100644 --- a/tests/test_historical_notifications.py +++ b/tests/test_historical_notifications.py @@ -1,4 +1,5 @@ """Authored archived deliveries use recorded owners, not live availability.""" +from agent_comms.field_codec import TurnId from agent_comms.bus import Bus @@ -274,7 +275,7 @@ def test_delivered_assignment_uses_its_recorded_conversation_response(archived_n ) owner, generation = old.registry.live_owner_with_claim("agent") owner, _ = old.registry.lease_live_turn_with_claim( - owner, "reply-turn", expected_generation=generation + owner, TurnId('9ec9723c1d103dd9122ca0797881e236'), expected_generation=generation ) lease = owner.require_turn_lease() inputs = InputDispositions(old.root) diff --git a/tests/test_history_view_declaration.py b/tests/test_history_view_declaration.py index 49cd30939d..33bf06f841 100644 --- a/tests/test_history_view_declaration.py +++ b/tests/test_history_view_declaration.py @@ -1,4 +1,5 @@ """A new history declaration crosses real current and retained-source boundaries.""" +from agent_comms.field_codec import TurnId from agent_comms.historical_views import HistoryView from agent_comms.message_page import PageBounds @@ -49,7 +50,7 @@ def test_viewer_snapshot_exposes_actual_active_channel_participant(tmp_path): from test_coordinated_runtime import _root _path, _root_id, comms, _initial, _people = _root(tmp_path) - lease = comms.agents.begin_turn("beta", "ordinary-snapshot-active-participant").turn_lease + lease = comms.agents.begin_turn("beta", TurnId('2863939642c6213365179d5204d162ce')).turn_lease try: snapshot = comms.views.viewer_snapshot(str(comms.root)) assert [person.thread.name for person in snapshot.participants("#team")] == ["beta"] diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 34047c1d56..4e09b2ce5b 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -1,4 +1,5 @@ """Held-order recovery and unanswered DMs on an independently copied bus.""" +from agent_comms.field_codec import TurnId from agent_comms.channel_targets import CommandTarget @@ -340,7 +341,7 @@ def test_a_running_turn_is_not_a_stalled_holder(held_bus): blocked_holder(held_bus) thread = held_bus.registry.require("worker") held_bus.registry.register(replace(thread, process_identity=ProcessIdentity.capture(os.getpid()))) - owner = held_bus.agents.begin_turn("worker", "current-direct-task") + owner = held_bus.agents.begin_turn("worker", TurnId('3e748b426825aaaad5def94520fa5858')) lease = owner.thread.turn_lease assert lease is not None try: diff --git a/tests/test_input_documents.py b/tests/test_input_documents.py index 60785200ed..24663b7d38 100644 --- a/tests/test_input_documents.py +++ b/tests/test_input_documents.py @@ -1,4 +1,5 @@ """Typed saved boundaries and durable acceptance precede process-local scheduling.""" +from agent_comms.field_codec import TurnId from agent_comms.lifecycle_capability import Unresolved import json @@ -71,12 +72,12 @@ def test_failure_after_original_publication_retires_the_batch_and_schedule(tmp_p tuple(row.key for row in rows) )) with pytest.raises(OSError), ExitStack() as ownership: - store.reserve_turn("worker", RegisteredTurn("scheduled"), 1, "Original schedule", ownership=ownership) + store.reserve_turn("worker", RegisteredTurn(TurnId('6aef76c6cb359f6ffaf9c6e9881c902b')), 1, "Original schedule", ownership=ownership) raise OSError("later failure") scheduled = stored_inputs(store.root) assert all(isinstance(row, NotSentInput) for row in scheduled.rows.values()) with ExitStack() as ownership, pytest.raises(RelationViolationError): - store.reserve_turn("worker", RegisteredTurn("scheduled"), 1, "Replacement", ownership=ownership) + store.reserve_turn("worker", RegisteredTurn(TurnId('6aef76c6cb359f6ffaf9c6e9881c902b')), 1, "Replacement", ownership=ownership) assert stored_inputs(store.root) == scheduled @@ -90,7 +91,7 @@ def test_saved_discriminator_and_optional_notices_roundtrip_without_replay(tmp_p saved = FieldCodec.encode(BoundUnknownInput( key="acp:old", sequence=None, owner="worker", claim_generation=1, - target="worker", source_text="Retain me", turn_id="turn", native_id="a" * 32, + target="worker", source_text="Retain me", turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, sent_digest=TextDigest.of("Exact sent text"), notice_dismissed=True, goal_reviews={"goal": GoalInputDecision(goal_revision=7, turn_id="review")}, )) @@ -103,9 +104,9 @@ def test_saved_discriminator_and_optional_notices_roundtrip_without_replay(tmp_p assert store.unresolved(frozenset({"worker"})).rows == {"acp:old": row} assert not store.bind(row.key, claim_scope=1, turn_id="again", native_id="b" * 32, text="again") assert not store.started(row.key, turn_id="wrong", native_id="a" * 32, text="Exact sent text") - assert store.started(row.key, turn_id="turn", native_id="a" * 32, text="Exact sent text") + assert store.started(row.key, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="Exact sent text") assert isinstance(stored_inputs(store.root).rows[row.key], StartedInput) - assert not store.started(row.key, turn_id="turn", native_id="a" * 32, text="Exact sent text") + assert not store.started(row.key, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="Exact sent text") row.require(Unresolved, ValueError("review required")) # prior snapshot is immutable and unchanged @@ -117,23 +118,23 @@ def test_original_native_user_receipts_include_group_and_corrected_final_only(tm registry = Registration(tmp_path / "registry.json") registry.register(Thread('worker', frozenset(), str(tmp_path), created_at=123.0, process_identity=ProcessIdentity.capture(os.getpid()))) - registry.lease_local_turn("worker", "turn") + registry.lease_local_turn("worker", TurnId('9b473845f684444f80a26586ae298d2b')) lease = registry.require("worker").turn_lease claim_scope = lease.claimed_generation store = InputDispositions(tmp_path) for key, sequence, text in (("bus:1", 1, "First original"), ("bus:2", 2, "Second original")): assert store.record(key, seq=sequence, owner="worker", claim_scope=claim_scope, target="worker", text=text) - assert store.bind(key, claim_scope=claim_scope, turn_id="turn", native_id="a" * 32, + assert store.bind(key, claim_scope=claim_scope, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="Actual grouped native user") - assert store.started(key, turn_id="turn", native_id="a" * 32, + assert store.started(key, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="Actual grouped native user") registry.rename("worker", "renamed") assert store.record("acp:correction", seq=None, owner="renamed", claim_scope=claim_scope, target="renamed", text="Distinct correction") - assert store.bind("acp:correction", claim_scope=claim_scope, turn_id="turn", native_id="b" * 32, + assert store.bind("acp:correction", claim_scope=claim_scope, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="b" * 32, text="Actual corrected native user") - assert store.started("acp:correction", turn_id="turn", native_id="b" * 32, + assert store.started("acp:correction", turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="b" * 32, text="Actual corrected native user") document = store.in_turn("turn") assert document == stored_inputs(store.root) @@ -272,7 +273,7 @@ def test_invalid_bind_keeps_durable_reservation_intact(tmp_path, native_id): store.record("acp:x", seq=None, owner="owner", claim_scope=1, target="owner", text="retained") before = stored_inputs(store.root) with pytest.raises(ValueError): - store.bind("acp:x", claim_scope=1, turn_id="turn", native_id=native_id, text="sent") + store.bind("acp:x", claim_scope=1, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id=native_id, text="sent") assert stored_inputs(store.root) == before @@ -280,7 +281,7 @@ def test_reserved_input_is_queued_only_for_its_own_owner_claim_and_text(): from agent_comms.text_digest import TextDigest reserved = ReservedInput("acp:x", None, "owner", 1, "owner", "original") - bound = reserved.bind(claim_scope=1, turn_id="turn", native_id="a" * 32, text="wrapped input") + bound = reserved.bind(claim_scope=1, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="wrapped input") bound = bound.require_moved("Reserved input stayed reserved").state owner = RecordedThreadIncarnation("owner", 1.0) assert reserved.digest == TextDigest.of("original") @@ -301,8 +302,8 @@ def test_started_delivery_and_original_ingress_use_distinct_owned_relations( assert store.record( key, seq=sequence, owner="owner", claim_scope=1, target=target, text="original" ) - assert store.bind(key, claim_scope=1, turn_id="turn", native_id="a" * 32, text="wrapped input") - assert store.started(key, turn_id="turn", native_id="a" * 32, text="wrapped input") + assert store.bind(key, claim_scope=1, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="wrapped input") + assert store.started(key, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="wrapped input") assert stored_inputs(store.root).lookup(key).has_started original = stored_inputs(store.root) # Routing cannot be rewritten by recording another target at the original @@ -319,8 +320,8 @@ def test_reads_select_only_the_rows_their_index_names(tmp_path): store = InputDispositions(tmp_path) for key, owner in (("acp:a", "owner"), ("acp:b", "owner"), ("acp:c", "other")): assert store.record(key, seq=None, owner=owner, claim_scope=1, target=owner, text=key) - assert store.bind("acp:a", claim_scope=1, turn_id="turn", native_id="a" * 32, text="sent") - assert store.started("acp:a", turn_id="turn", native_id="a" * 32, text="sent") + assert store.bind("acp:a", claim_scope=1, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="sent") + assert store.started("acp:a", turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="sent") assert store.in_turn("turn").rows.keys() == {"acp:a"} assert store.unresolved(frozenset({"owner"})).rows.keys() == {"acp:b"} assert store.read(("acp:c", "acp:missing")).rows.keys() == {"acp:c"} diff --git a/tests/test_install.py b/tests/test_install.py index 80635a1848..27efc03235 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1,5 +1,6 @@ """agent-comms install: the bus it creates or practises on, and the route to it.""" from __future__ import annotations +from agent_comms.field_codec import TurnId import json @@ -246,7 +247,7 @@ def test_practice_copy_takes_turn_files_in_the_registry_cut(private_directory): (root / "storage.json").write_text(json.dumps({"floor_gib": 0})) comms.registry.declare(Thread( "owner", frozenset(), str(root), process_identity=ProcessIdentity.capture(os.getpid()))) - lease = comms.agents.begin_turn("owner", "turn").turn_lease + lease = comms.agents.begin_turn("owner", TurnId('9b473845f684444f80a26586ae298d2b')).turn_lease turn = comms.registry.require("owner").turn_record.path assert turn in comms.registry.store.cut_paths diff --git a/tests/test_interrupted_delivery_recovery.py b/tests/test_interrupted_delivery_recovery.py index 01aa6cb943..d1563995fc 100644 --- a/tests/test_interrupted_delivery_recovery.py +++ b/tests/test_interrupted_delivery_recovery.py @@ -1,6 +1,7 @@ """A following message interrupted before native send returns to selected work.""" from __future__ import annotations +from agent_comms.field_codec import TurnId from conftest import publish_initial from functools import partial @@ -59,7 +60,7 @@ async def test_interrupted_following_delivery_recovers_only_before_native_send( row = stored_inputs(agent.inputs.dispositions.root).lookup(item.key) assert not row.has_native_binding if native_bound: - leased = comms.agents.begin_turn(owner.name, "interrupted-turn") + leased = comms.agents.begin_turn(owner.name, TurnId('9c4c7984ba66b4cb4fc11ce230b963d3')) assert agent.inputs.dispositions.bind( item.key, claim_scope=row.claim_generation, diff --git a/tests/test_l0_goal_input_closure.py b/tests/test_l0_goal_input_closure.py index 7c44a321c2..65ebe4f99a 100644 --- a/tests/test_l0_goal_input_closure.py +++ b/tests/test_l0_goal_input_closure.py @@ -1,4 +1,5 @@ """Current goal/input boundaries through durable stores and the real owner socket.""" +from agent_comms.field_codec import TurnId import os import sqlite3 @@ -112,7 +113,7 @@ def test_standby_uses_native_receipts_and_explicit_reviews_not_read_ack(tmp_path comms = canonical_goal_wire(tmp_path) register(comms, "owner") register(comms, "peer") - comms.agents.begin_turn("peer", "peer-turn") + comms.agents.begin_turn("peer", TurnId('6f8eb8c4b59c9b52ce7c7d823f5d7b84')) goal = comms.goals.update_goal("owner", SetGoalAction(text="Wait for the peer")) message = comms.messaging.send("peer", "owner", "Review this uncertain reply").message store = InputDispositions(tmp_path) diff --git a/tests/test_live_drain_failure.py b/tests/test_live_drain_failure.py index ec7134ccb9..504be91da9 100644 --- a/tests/test_live_drain_failure.py +++ b/tests/test_live_drain_failure.py @@ -1,4 +1,5 @@ """The actual background drain exposes broken SQLite state and observes recovery.""" +from agent_comms.field_codec import TurnId import asyncio @@ -85,7 +86,7 @@ async def test_live_drain_schema_failure_visible_until_real_recovery(tmp_path, b replace(failed.activity.source_event(), timestamp=time.time() - 1000) ) assert owner_view(comms).presentation.status.details == (diagnostic.summary,) - lease = comms.agents.begin_turn("beta", "ordinary-unrelated-turn").turn_lease + lease = comms.agents.begin_turn("beta", TurnId('8cd49e85997e272cc5c54e3ea84e658d')).turn_lease comms.agents.finish_turn(lease) assert owner_view(comms).presentation.status.details == (diagnostic.summary,) # An explicit operator restores the actual current schema; readers do no repair. @@ -109,7 +110,7 @@ async def test_successful_drain_clears_failure_during_active_turn(tmp_path): with pytest.raises(OSError): async with drain_failures(comms, "beta", owner): raise OSError("past drain failed") - lease = comms.agents.begin_turn("beta", "later-successful-turn").turn_lease + lease = comms.agents.begin_turn("beta", TurnId('028a74a690b644b1557f0f9b70b9d9a2')).turn_lease try: async with drain_failures(comms, "beta", owner): await agent.inputs.drain_inbox("beta") diff --git a/tests/test_maintenance_barrier.py b/tests/test_maintenance_barrier.py index 1a71c57f10..7282cf6d46 100644 --- a/tests/test_maintenance_barrier.py +++ b/tests/test_maintenance_barrier.py @@ -1,4 +1,5 @@ """Provider-free disposable checks of the default-off maintenance claim seam.""" +from agent_comms.field_codec import TurnId import asyncio @@ -113,7 +114,7 @@ def _claim_other_process(registry_path: str, ready: mp.Event, result: mp.Queue) result.put(("timeout", "")) return try: - Registration(Path(registry_path)).lease_local_turn("owner", "other-process") + Registration(Path(registry_path)).lease_local_turn("owner", TurnId('da7607de21a76790f4facdb9960c47db')) except RelationViolationError as error: result.put(("denied", str(error))) else: @@ -127,18 +128,18 @@ def test_default_off_then_close_reopen_and_no_stale_transition(tmp_path: Path) - comms.registry.declare( Thread(name='owner', tags=frozenset(), worktree=str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) - assert comms.agents.begin_turn("owner", "before") + assert comms.agents.begin_turn("owner", TurnId('6db7d803e74f1ffa7d8f5adc0bf95b3e')) comms.agents.finish_turn(comms.registry.require("owner").turn_lease) control = gate first = control.begin(MaintenanceOperator.current()) assert first.phase == MaintenancePhase.member_named("draining") and first.generation == 1 assert MaintenanceBarrier(comms.registry.store.path).read() == first with pytest.raises(RelationViolationError): - comms.agents.begin_turn("owner", "after") + comms.agents.begin_turn("owner", TurnId('f39592393ef0859cb196a52693d2cea0')) with pytest.raises(RelationViolationError): comms.registry.lease_live_turn_with_claim( comms.registry.require("owner"), - "direct", + TurnId('d15690f08a575024650b01ffac892cfd'), expected_generation=comms.registry.snapshot().claim_generation("owner"), ) with pytest.raises(RelationViolationError): @@ -150,7 +151,7 @@ def test_default_off_then_close_reopen_and_no_stale_transition(tmp_path: Path) - with pytest.raises(ValueError): control.advance(second, MaintenancePhase.member_named("ready")) with pytest.raises(RelationViolationError): - Registration(comms.registry.store.path).lease_local_turn("owner", "cold") + Registration(comms.registry.store.path).lease_local_turn("owner", TurnId('e272deecde720ffc773d7a6a81adabbc')) assert control.advance( second, MaintenancePhase.member_named("installing") ).phase == MaintenancePhase.member_named("installing") @@ -265,7 +266,7 @@ def test_cross_process_claim_races_pause_at_registry_lock(tmp_path: Path) -> Non assert bool(current.turn_state.active.items_with(Executing, current.turn_state.active)) == (result == "claimed") assert comms.owners.maintenance.read() == hold with pytest.raises(RelationViolationError): - comms.registry.lease_local_turn("owner", "never-after-pause") + comms.registry.lease_local_turn("owner", TurnId('799f93502fa088f2e1f1ce3acc633320')) async def test_native_ingress_waits_without_blocking_exclusive_owner(tmp_path: Path) -> None: diff --git a/tests/test_message_notifications.py b/tests/test_message_notifications.py index b461d2ab35..b8900bac5a 100644 --- a/tests/test_message_notifications.py +++ b/tests/test_message_notifications.py @@ -1,4 +1,5 @@ """Read real persisted notification assignments without scheduling a model.""" +from agent_comms.field_codec import TurnId from dataclasses import replace from test_coordinated_runtime import _root, tmp_path # noqa: F401 from conftest import installed_comms @@ -32,7 +33,7 @@ def test_live_turn_never_presents_ready_between_activity_events(tmp_path): # no from agent_comms.activity import ActivityState _path, _root_id, comms, _initial, _people = _root(tmp_path) - lease = comms.agents.begin_turn('beta', 'test-active-turn').turn_lease + lease = comms.agents.begin_turn('beta', TurnId('3c5469ccca7ada4828e500d32e2025e7')).turn_lease comms.agents.set_activity('beta', ActivityState.IDLE) view = next(v for v in comms.views.thread_views() if v.thread.name == 'beta') assert view.presentation.status.working @@ -58,7 +59,7 @@ def test_individual_view_keeps_alias_goal_and_active_owner_semantics(tmp_path): assert individual.title == view.presentation.title assert individual.summary == view.presentation.summary assert individual.read_identity.thread.name == view.thread.name - lease = comms.agents.begin_turn('renamed', 'individual-active-turn').turn_lease + lease = comms.agents.begin_turn('renamed', TurnId('6d520fcb678df3e43b0fa663d3e618be')).turn_lease comms.agents.set_activity('renamed', ActivityState.IDLE) assert comms.views.thread_presentations(('beta',)).require('beta').status.working state = comms.registry.require('renamed').turn_state diff --git a/tests/test_native_unknown_recovery.py b/tests/test_native_unknown_recovery.py index 9ccbfe5392..df5ec7efbb 100644 --- a/tests/test_native_unknown_recovery.py +++ b/tests/test_native_unknown_recovery.py @@ -1,6 +1,7 @@ """Explicit abandonment releases a dead slot without replaying an UNKNOWN input.""" from __future__ import annotations +from agent_comms.field_codec import TurnId from agent_comms.lifecycle_capability import Failed @@ -134,7 +135,7 @@ def running_successor(comms): replace(previous, process_identity=ProcessIdentity.capture(os.getpid())), new_owner=True, ) - successor, _ = comms.registry.lease_local_turn("beta", "productive-successor") + successor, _ = comms.registry.lease_local_turn("beta", TurnId('2f82e5f1baad91cc244b943f3e4dd487')) assert successor.executing return successor diff --git a/tests/test_optional_awareness_projection.py b/tests/test_optional_awareness_projection.py index d7f515c869..ead15eb78d 100644 --- a/tests/test_optional_awareness_projection.py +++ b/tests/test_optional_awareness_projection.py @@ -2,6 +2,8 @@ from __future__ import annotations +from agent_comms.field_codec import TurnId + from bus_record_fixture import prepare_native_comms from agent_comms.lifecycle_capability import Executing @@ -74,7 +76,7 @@ def _accepted( def _owner(comms: Comms, name: str) -> Thread: original, claimed_generation = comms.registry.live_owner_with_claim(name) owner, _ = comms.registry.lease_live_turn_with_claim( - original, f"selected-{name}", expected_generation=claimed_generation + original, TurnId.mint(), expected_generation=claimed_generation ) return owner diff --git a/tests/test_project_resume.py b/tests/test_project_resume.py index 5353102fd4..af162ee836 100644 --- a/tests/test_project_resume.py +++ b/tests/test_project_resume.py @@ -3,6 +3,7 @@ Real registry and coordination stores in temporary directories. The native test runs the real Pi child against a local provider. """ +from agent_comms.field_codec import TurnId import asyncio import json import os @@ -36,7 +37,7 @@ def test_project_change_while_idle_owes_no_turn(tmp_path): def test_project_change_during_turn_is_recorded_until_the_next_turn_starts(tmp_path): comms, old, new = declared_owner(tmp_path) - turn = comms.agents.begin_turn("owner", "changing-turn") + turn = comms.agents.begin_turn("owner", TurnId('1056fa0e005902e019b8d1d4fd75d3cb')) comms.threads.set_project("owner", str(new)) assert comms.registry.require("owner").project_resume_due assert comms.agents.finish_turn(turn.turn_lease) @@ -46,7 +47,7 @@ def test_project_change_during_turn_is_recorded_until_the_next_turn_starts(tmp_p current = comms.registry.require("owner") assert current.project_resume_due and current.worktree == str(new) assert json.loads(comms.registry.store.path.read_text())["threads"]["owner"]["project_resume_due"] - following = comms.agents.begin_turn("owner", "next-turn") + following = comms.agents.begin_turn("owner", TurnId('bf5eb504b7221133c4f3cf2aa87008a3')) assert following.thread.worktree == str(new) assert not comms.registry.require("owner").project_resume_due assert comms.agents.finish_turn(following.turn_lease) @@ -56,7 +57,7 @@ def test_project_change_during_turn_is_recorded_until_the_next_turn_starts(tmp_p def test_cancelled_turn_withdraws_the_recorded_resume(tmp_path): comms, _old, new = declared_owner(tmp_path) - turn = comms.agents.begin_turn("owner", "changing-turn") + turn = comms.agents.begin_turn("owner", TurnId('1056fa0e005902e019b8d1d4fd75d3cb')) comms.threads.set_project("owner", str(new)) comms.agents.transition_turn(turn.turn_lease, CancellingPhase()) assert comms.agents.finish_turn(turn.turn_lease) diff --git a/tests/test_read_ledger.py b/tests/test_read_ledger.py index 4324faf2a2..bd22b9f561 100644 --- a/tests/test_read_ledger.py +++ b/tests/test_read_ledger.py @@ -1,4 +1,5 @@ """Read evidence follows displayed messages across projections and restarts.""" +from agent_comms.field_codec import TurnId import random @@ -190,7 +191,7 @@ def test_turn_lease_and_unrelated_registry_changes_do_not_invalidate_dm(tmp_path comms.messaging.send("alice", viewer, "painted").message.message_id page = comms.views.dm_display_page(ThreadTarget("alice"), worktree=str(tmp_path)) assert page.selection.basis.peer_identity == peer.incarnation - comms.registry.lease_local_turn("alice", "ordinary-turn") + comms.registry.lease_local_turn("alice", TurnId('0b225c32b06b9107d59b5d92d13b83a3')) fresh = comms.views.dm_display_page(ThreadTarget("alice"), worktree=str(tmp_path)) assert fresh.selection.basis.peer_identity == page.selection.basis.peer_identity comms.registry.register(Thread('unrelated', frozenset(), str(tmp_path))) diff --git a/tests/test_registration_authority.py b/tests/test_registration_authority.py index 75d6bda9b3..e98364ebdd 100644 --- a/tests/test_registration_authority.py +++ b/tests/test_registration_authority.py @@ -1,4 +1,5 @@ """Actual persisted claim boundaries distinguish declaration from claim_scope drift.""" +from agent_comms.field_codec import TurnId import os from dataclasses import replace @@ -26,7 +27,7 @@ def registered(root): def test_claim_claim_tolerates_metadata_and_finished_captured_turn(tmp_path, capture_idle): registry = registered(tmp_path) initial, claim_scope = registry.live_owner_with_claim('owner') - leased, _ = registry.lease_local_turn('owner', 'first') + leased, _ = registry.lease_local_turn('owner', TurnId('a7937b64b8caa58f03721bb6bacf5c78')) captured = initial if capture_idle else leased generation = registry.snapshot().owner_generations['owner'] assert registry.release_turn(leased.turn_lease)[0] @@ -35,14 +36,14 @@ def test_claim_claim_tolerates_metadata_and_finished_captured_turn(tmp_path, cap RelationViolationError, ): registry.lease_live_turn_with_generation( - captured, 'second', expected_owner_generation=generation, + captured, TurnId('16367aacb67a4a017c8da8ab95682ccb'), expected_owner_generation=generation, ) claimed, current_claim = registry.lease_live_turn_with_claim( - captured, 'second', expected_generation=claim_scope, + captured, TurnId('16367aacb67a4a017c8da8ab95682ccb'), expected_generation=claim_scope, ) assert current_claim == claim_scope assert claimed.title == GivenThreadText('metadata') - assert claimed.turn_record.read().id == 'second' + assert claimed.turn_record.read().id == '16367aacb67a4a017c8da8ab95682ccb' assert registry.release_turn(claimed.turn_lease)[0] @@ -59,7 +60,7 @@ def test_raw_registry_writer_cannot_cross_captured_claim_identity(tmp_path, muta edit.commit() before = registry.store.path.read_bytes() with pytest.raises(RelationViolationError): - registry.lease_live_turn_with_claim(captured, 'denied', expected_generation=claim_scope) + registry.lease_live_turn_with_claim(captured, TurnId('62d6c2330036f64bcf71b95791743d6c'), expected_generation=claim_scope) assert registry.store.path.read_bytes() == before @@ -72,7 +73,7 @@ def test_a_live_owner_cannot_be_declared_human(tmp_path): def test_live_owner_read_rejects_raw_stale_turn_witness(tmp_path): registry = registered(tmp_path) - leased, _ = registry.lease_local_turn('owner', 'turn') + leased, _ = registry.lease_local_turn('owner', TurnId('9b473845f684444f80a26586ae298d2b')) with registry.store.editing() as edit: record = edit.document.threads['owner'].turn_record record._publish_unlocked(replace(record.read(), claimed_generation=RecordedGeneration(99))) @@ -84,11 +85,11 @@ def test_live_owner_read_rejects_raw_stale_turn_witness(tmp_path): def test_a_superseded_generation_is_deleted_and_its_late_transition_changes_nothing(tmp_path): registry = registered(tmp_path) registry.live_owner_with_claim('owner') - first, _ = registry.lease_local_turn('owner', 'first') + first, _ = registry.lease_local_turn('owner', TurnId('a7937b64b8caa58f03721bb6bacf5c78')) stale = first.turn_lease assert registry.release_turn(stale)[0] - second, _ = registry.lease_local_turn('owner', 'second') + second, _ = registry.lease_local_turn('owner', TurnId('16367aacb67a4a017c8da8ab95682ccb')) assert not stale.record.path.exists() and second.turn_record.path.exists() assert stale.record.transition(stale, PreparingPhase()) == () assert stale.record.observe_native_phase(stale, PreparingPhase()) == () - assert second.turn_record.read().id == 'second' + assert second.turn_record.read().id == '16367aacb67a4a017c8da8ab95682ccb' diff --git a/tests/test_registration_inheritance.py b/tests/test_registration_inheritance.py index 193b1deace..1aa81667a6 100644 --- a/tests/test_registration_inheritance.py +++ b/tests/test_registration_inheritance.py @@ -1,4 +1,5 @@ """Current registration declarations, incarnation and saved relationship ownership.""" +from agent_comms.field_codec import TurnId from typing import Annotated from dataclasses import dataclass, replace @@ -105,7 +106,7 @@ def test_project_change_retains_executing_source_but_refuses_another_input(tmp_p new.mkdir() comms = wire(tmp_path / 'wire') comms.registry.declare(Thread('owner', frozenset(), str(old), process_identity=ProcessIdentity.capture(os.getpid()))) - original = comms.agents.begin_turn('owner', 'original-turn') + original = comms.agents.begin_turn('owner', TurnId('ab87f599dc0a50a56b7c65de71c39426')) try: comms.threads.set_project('owner', str(new)) with pytest.raises(StaleFence): @@ -140,7 +141,7 @@ def test_source_publication_refuses_unrecorded_project_replacement(tmp_path): comms = wire(tmp_path) comms.registry.declare(Thread('owner', frozenset(), str(tmp_path / 'old'), process_identity=ProcessIdentity.capture(os.getpid()))) - original = comms.agents.begin_turn('owner', 'original-turn') + original = comms.agents.begin_turn('owner', TurnId('ab87f599dc0a50a56b7c65de71c39426')) current = comms.registry.require('owner') comms.registry.register(replace(current, worktree=str(tmp_path / 'other'))) before = comms.registry.store.path.read_bytes() diff --git a/tests/test_registration_process_identity.py b/tests/test_registration_process_identity.py index a04f8f348a..4dabed023c 100644 --- a/tests/test_registration_process_identity.py +++ b/tests/test_registration_process_identity.py @@ -1,4 +1,5 @@ """Actual process birth is the registration/claim_scope authority, never PID alone.""" +from agent_comms.field_codec import TurnId import os from dataclasses import replace @@ -124,7 +125,7 @@ def test_same_pid_different_birth_advances_both_authority_domains(tmp_path): with pytest.raises(RelationViolationError): comms.registry.live_owner_with_claim(owner.name) with pytest.raises(RelationViolationError): - comms.registry.lease_local_turn(owner.name, "reused-pid") + comms.registry.lease_local_turn(owner.name, TurnId('b777bd9e97665a2b2def8639667b0e72')) with pytest.raises(RelationViolationError): comms.threads.rename_managed_thread(owner.name, "renamed", owner=ProcessIdentity.capture(os.getpid())) assert identity.alive() @@ -153,7 +154,7 @@ def test_active_executor_cannot_be_replaced_by_same_pid_different_birth(tmp_path owner = comms.threads.claim_thread( "owner", tags=frozenset(), worktree=str(tmp_path), process=identity ) - leased, _ = comms.registry.lease_local_turn(owner.name, "active-turn") + leased, _ = comms.registry.lease_local_turn(owner.name, TurnId('e99af36cdfcffbc191cce98c5d771afb')) stale = replace(identity, start_time=identity.start_time + 1) with pytest.raises(RelationViolationError): comms.registry.declare(replace(owner, process_identity=stale)) @@ -161,5 +162,5 @@ def test_active_executor_cannot_be_replaced_by_same_pid_different_birth(tmp_path comms.registry.declare(Thread('owner', frozenset(), str(tmp_path), title=GivenThreadText('metadata'))) current = comms.registry.require(owner.name) assert current.process_identity == identity and current.turn_record == leased.turn_record - assert current.turn_record.read().id == 'active-turn' + assert current.turn_record.read().id == 'e99af36cdfcffbc191cce98c5d771afb' comms.registry.release_turn(current.turn_lease) diff --git a/tests/test_registration_transitions.py b/tests/test_registration_transitions.py index f75c861c2f..f5c53b5f9f 100644 --- a/tests/test_registration_transitions.py +++ b/tests/test_registration_transitions.py @@ -1,4 +1,5 @@ """Transition-family consequences on the actual canonical registry and locks.""" +from agent_comms.field_codec import TurnId import os from dataclasses import replace @@ -21,7 +22,7 @@ def test_registration_transition_preserves_or_revokes_exact_lease(tmp_path, tran registry = Registration(tmp_path / 'registry.json') thread = Thread('owner', frozenset({'team'}), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) registry.register(thread) - leased, _ = registry.lease_local_turn('owner', 'turn') + leased, _ = registry.lease_local_turn('owner', TurnId('9b473845f684444f80a26586ae298d2b')) lease = leased.require_turn_lease() before = registry.snapshot() updated = leased @@ -40,7 +41,7 @@ def test_registration_transition_preserves_or_revokes_exact_lease(tmp_path, tran advances_owner = True elif transition == 'new-turn': assert registry.release_turn(lease)[0] - updated, _ = registry.lease_local_turn('owner', 'imported') + updated, _ = registry.lease_local_turn('owner', TurnId('5f54227b74fbba7743c47cd286b4873f')) preserves_record = False registry.register(updated, **kwargs) after = registry.snapshot() @@ -85,12 +86,12 @@ def test_rename_preserves_exact_turn_release_but_not_reused_identity(tmp_path): registry = Registration(tmp_path / 'registry.json') thread = Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) registry.register(thread) - leased, _ = registry.lease_local_turn('owner', 'same-id') + leased, _ = registry.lease_local_turn('owner', TurnId('058be655fae2a4c37a21d2c091ba7661')) lease = leased.require_turn_lease() registry.rename('owner', 'renamed') released, finished = registry.release_turn(lease) assert released and finished.identity.incarnation.name == 'renamed' - next_turn, _ = registry.lease_local_turn('renamed', 'same-id') + next_turn, _ = registry.lease_local_turn('renamed', TurnId('058be655fae2a4c37a21d2c091ba7661')) assert registry.release_turn(lease) == (False, NoFinishedTurn()) assert registry.require('renamed').turn_lease == next_turn.turn_lease @@ -119,7 +120,7 @@ def test_batch_idle_fence_refuses_busy_second_without_partial_commit(tmp_path): registry.register(Thread(name, frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) before = registry.snapshot() selected = tuple((before.threads[name], before.claim_generation(name)) for name in ("first", "second")) - registry.lease_local_turn('second', 'busy') + registry.lease_local_turn('second', TurnId('c9bc072f4fa8189466c2a8f2c36a56a4')) protected = registry.snapshot() with pytest.raises(RelationViolationError): registry.fence_idle_owners(selected) diff --git a/tests/test_roster_restoration.py b/tests/test_roster_restoration.py index c68ed9b0b6..19d8672294 100644 --- a/tests/test_roster_restoration.py +++ b/tests/test_roster_restoration.py @@ -1,4 +1,5 @@ """Restoration changes visibility without replay or transferring live ownership.""" +from agent_comms.field_codec import TurnId from agent_comms.channel_targets import CommandTarget @@ -32,7 +33,7 @@ def test_restore_keeps_live_owner_and_bus_while_importing_saved_stopped_identity missing = Thread('missing', frozenset({'nra', 'openhcs'}), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()), session_file=RegistrySessionLocation.from_path(str(tmp_path / 'saved.jsonl'), old.root), title=GivenThreadText('Preserved title'), task=GivenThreadText('Original task')) old.registry.declare(live) old.registry.declare(missing) - missing = old.agents.begin_turn("missing", "old-turn").thread + missing = old.agents.begin_turn("missing", TurnId('310c74d7ac243239a928d4021c709acb')).thread old.messaging.send("live", "missing", "Old pending work must not be replayed").message.message_id current.registry.declare(replace(live, title=GivenThreadText("Current live title"))) current.messaging.send_user_message(CommandTarget.named('#comms'), "Current message", worktree=str(tmp_path)) diff --git a/tests/test_selected_tool_broker.py b/tests/test_selected_tool_broker.py index 420894cbdc..8d86284873 100644 --- a/tests/test_selected_tool_broker.py +++ b/tests/test_selected_tool_broker.py @@ -1,6 +1,7 @@ """Provider-free selected-tool contract tests; no native Pi/provider launch.""" from __future__ import annotations +from agent_comms.field_codec import TurnId import asyncio @@ -108,7 +109,7 @@ def test_owner_mode_denies_unreserved_input_before_consuming_ledger(private_comm execution_id="execution", operation_id="d" * 32, owner_claimed_generation=1, - turn_id="turn", + turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), participant_generation=1, attempt_ordinal=1, ) diff --git a/tests/test_task_decisions.py b/tests/test_task_decisions.py index a32b0623b2..351e792b4c 100644 --- a/tests/test_task_decisions.py +++ b/tests/test_task_decisions.py @@ -1,4 +1,5 @@ """One original decision/correction journey through declared tools and wire ownership.""" +from agent_comms.field_codec import TurnId from agent_comms.channel_targets import CommandTarget @@ -46,7 +47,7 @@ def admit(comms, name): comms.registry.declare(Thread(name, frozenset({'team'}), str(comms.root), process_identity=ProcessIdentity.capture(os.getpid()))) owner, claim_scope = comms.registry.live_owner_with_claim(name) owner, _ = comms.registry.lease_live_turn_with_claim( - owner, name + "-turn", expected_generation=claim_scope + owner, TurnId.mint(), expected_generation=claim_scope ) return owner @@ -323,7 +324,7 @@ def test_turn_scope_correction_cannot_revive_a_finished_turn_choice(comms, monke first = invoke_tool(comms, "comms_decision", request) comms.registry.release_turn(owner.turn_lease) owner, claim_scope = comms.registry.live_owner_with_claim("alpha") - comms.registry.lease_live_turn_with_claim(owner, "next-turn", expected_generation=claim_scope) + comms.registry.lease_live_turn_with_claim(owner, TurnId('bf5eb504b7221133c4f3cf2aa87008a3'), expected_generation=claim_scope) before = read_history(comms) with pytest.raises(RelationViolationError): invoke_tool(comms, "comms_decision", { diff --git a/tests/test_thread_identity.py b/tests/test_thread_identity.py index e02594ae69..f0de078b4c 100644 --- a/tests/test_thread_identity.py +++ b/tests/test_thread_identity.py @@ -1,4 +1,5 @@ """S5 identity boundaries: process, turn, provenance and saved-data preservation.""" +from agent_comms.field_codec import TurnId import json @@ -70,7 +71,7 @@ def test_metadata_and_turns_do_not_replace_process_owner(tmp_path): identity = before.owner_identity("owner") registry.register(replace(owner, title=GivenThreadText("new metadata"))) registry.heartbeat("owner") - leased, generation = registry.lease_local_turn("owner", "turn") + leased, generation = registry.lease_local_turn("owner", TurnId('9b473845f684444f80a26586ae298d2b')) assert generation == identity.generation assert leased.require_turn_lease().identity == TurnIdentity(identity.incarnation, 1) assert registry.snapshot().owner_identity("owner") == identity @@ -87,7 +88,7 @@ def test_metadata_and_turns_do_not_replace_process_owner(tmp_path): def test_owner_replacement_preserves_turn_counter_and_historical_incarnation(tmp_path): registry = registry_with_owner(tmp_path) - registry.lease_local_turn("owner", "turn") + registry.lease_local_turn("owner", TurnId('9b473845f684444f80a26586ae298d2b')) registry.release_turn(registry.require("owner").turn_lease)[0] before = registry.snapshot() registry.register(before.threads["owner"], new_owner=True) @@ -102,7 +103,7 @@ def test_registry_cannot_forge_turn_counter(tmp_path): registry = registry_with_owner(tmp_path) registry.register(replace(registry.require("owner"), turn_generation=300)) assert registry.require("owner").turn_generation == 0 - registry.lease_local_turn("owner", "turn") + registry.lease_local_turn("owner", TurnId('9b473845f684444f80a26586ae298d2b')) registry.release_turn(registry.require("owner").turn_lease)[0] registry.register(replace(registry.require("owner"), turn_generation=500)) assert registry.require("owner").turn_generation == 1 @@ -132,11 +133,11 @@ def test_same_process_idle_presence_does_not_rotate_identity(tmp_path): def test_exact_turn_identity_survives_alias_but_not_reused_turn_id(tmp_path): comms = wire(tmp_path) comms.registry.declare(Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) - first = comms.agents.begin_turn("owner", "reused").turn_lease + first = comms.agents.begin_turn("owner", TurnId('7ad47c54939ef72323893e6c952c5f3a')).turn_lease assert first.identity.incarnation == comms.registry.require("owner").incarnation comms.registry.rename("owner", "renamed") assert isinstance(comms.agents.finish_turn(first), FinishedTurnFence) - second = comms.agents.begin_turn("renamed", "reused").turn_lease + second = comms.agents.begin_turn("renamed", TurnId('7ad47c54939ef72323893e6c952c5f3a')).turn_lease assert second.identity.generation == first.identity.generation + 1 assert isinstance(comms.agents.finish_turn(first), NoFinishedTurn) assert isinstance(comms.agents.finish_turn(second), FinishedTurnFence) @@ -145,7 +146,7 @@ def test_exact_turn_identity_survives_alias_but_not_reused_turn_id(tmp_path): @pytest.mark.parametrize("revocation", ["finish", "stop"]) def test_restored_active_turn_has_no_claim_authority(tmp_path, revocation): registry = registry_with_owner(tmp_path) - saved, _ = registry.lease_local_turn("owner", "old") + saved, _ = registry.lease_local_turn("owner", TurnId('cba06b5736faf67e54b07b561eae9439')) if revocation == "stop": registry.unregister("owner") else: @@ -162,7 +163,7 @@ def test_read_ledger_roundtrip_preserves_historical_identity(tmp_path, *, thread conversation = FieldCodec.decode(Conversation, original) assert conversation.participants == (owner.incarnation,) assert FieldCodec.decode(Conversation, FieldCodec.encode(conversation)) == conversation - registry.lease_local_turn("owner", "new") + registry.lease_local_turn("owner", TurnId('11507a0e2f5e69d5dfa40a62a1bd7b6e')) assert conversation.current(registry.snapshot()) registry.release_turn(registry.require("owner").turn_lease)[0] registry.register(registry.require("owner"), new_owner=True) diff --git a/tests/test_tool_output.py b/tests/test_tool_output.py index 2942bb08a6..055abb1a71 100644 --- a/tests/test_tool_output.py +++ b/tests/test_tool_output.py @@ -1,4 +1,5 @@ """Inbox pages drain oldest first without changing uncertain inputs.""" +from agent_comms.field_codec import TurnId import asyncio import json @@ -27,7 +28,7 @@ def seed_unknown(comms, count, text): rows = { f"bus:{index}": FieldCodec.encode(BoundUnknownInput( key=f"bus:{index}", sequence=index, owner="b", claim_generation=index, - target="b", source_text=f"{index}: {text}", turn_id="private-turn", + target="b", source_text=f"{index}: {text}", turn_id=TurnId('9f73871819f6ae95a1bbcac63e1880f4'), native_id="f" * 32, sent_digest=TextDigest.of("private-native-prompt"), )) for index in range(1, count + 1) @@ -168,7 +169,7 @@ def test_large_standby_review_returns_complete_messages_without_files( comms.registry.register( replace(comms.registry.require("a"), process_identity=ProcessIdentity.capture(os.getpid())) ) - comms.agents.begin_turn("a", "next-a-result-in-flight") + comms.agents.begin_turn("a", TurnId('4daf63889e45125a5366649d6a99e6f1')) invoke_tool(comms, "comms_goal", {**report, "reviewed_inputs": full_review["reviewed_inputs"]}, caller=ThreadCaller("b")) assert isinstance(comms.goals.goal_wait("b"), RecordedGoalWait) assert all( @@ -189,7 +190,7 @@ def test_dependency_review_and_ack_survive_large_excluded_history( comms.registry.declare( replace(comms.registry.require("a"), process_identity=ProcessIdentity.capture(os.getpid())) ) - comms.agents.begin_turn("a", "a-review-in-flight") + comms.agents.begin_turn("a", TurnId('90b11de4e45af4329532e30ce2beb544')) goal = comms.goals.update_goal("b", SetGoalAction(text="Review a and wait for its next reply")) dispositions = InputDispositions(comms.root) for index in range(930): diff --git a/tests/test_turn_context_wire.py b/tests/test_turn_context_wire.py index e56623dae8..9ee5d7ee26 100644 --- a/tests/test_turn_context_wire.py +++ b/tests/test_turn_context_wire.py @@ -1,4 +1,5 @@ """Original sealed messaging survives silent context rows through every projection.""" +from agent_comms.field_codec import TurnId import hashlib import json @@ -23,7 +24,7 @@ def manifest(owner, generation=1): source=OwnerProvenance(owner.incarnation, 'original-input-source') segment=SegmentManifest(TranscriptSegment,(source,),hashlib.sha256(b'PRIVATE INPUT').hexdigest(),13,4) - return ContextManifest(owner.incarnation,RecordedContextTurn(RegisteredTurn('original-turn'),TurnIdentity(owner.incarnation,generation)),(segment,),'pi.estimateTokens') + return ContextManifest(owner.incarnation,RecordedContextTurn(RegisteredTurn(TurnId('ab87f599dc0a50a56b7c65de71c39426')),TurnIdentity(owner.incarnation,generation)),(segment,),'pi.estimateTokens') def corrupt_context_kind(comms, name, kind): diff --git a/tests/test_turn_declaration.py b/tests/test_turn_declaration.py index 566c6994c4..5ce6d7ecf7 100644 --- a/tests/test_turn_declaration.py +++ b/tests/test_turn_declaration.py @@ -1,4 +1,5 @@ """Idle and active turns keep the registry's null-or-record format.""" +from agent_comms.field_codec import TurnId import pytest @@ -10,7 +11,7 @@ from agent_comms.turn_phase import ToolRunningPhase -@pytest.mark.parametrize('turn', [NoTurn(), ActiveTurn('turn', ProcessIdentity(123, 1), started_at=1.0)]) +@pytest.mark.parametrize('turn', [NoTurn(), ActiveTurn(TurnId('9b473845f684444f80a26586ae298d2b'), ProcessIdentity(123, 1), started_at=1.0)]) def test_turn_family_round_trip_keeps_its_original_wire_form(turn): encoded = FieldCodec.encode(turn, TurnDeclaration) assert FieldCodec.decode(TurnDeclaration, encoded) == turn @@ -22,7 +23,7 @@ def test_idle_turn_answers_without_an_active_record(): assert FieldCodec.encode(idle) is None idle.require_idle() assert idle.report_turn == '' - assert not idle.matches(RegisteredTurn('turn')) + assert not idle.matches(RegisteredTurn(TurnId('9b473845f684444f80a26586ae298d2b'))) assert not idle.started_by(10000) assert idle.observed_id('finished') == 'finished' assert idle.without_claim() is idle @@ -33,6 +34,6 @@ def test_idle_turn_answers_without_an_active_record(): with pytest.raises(RelationViolationError): idle.require_active() with pytest.raises(RelationViolationError): - idle.require_turn(RegisteredTurn('turn')) + idle.require_turn(RegisteredTurn(TurnId('9b473845f684444f80a26586ae298d2b'))) diff --git a/tests/test_turn_goal_account.py b/tests/test_turn_goal_account.py index f1fb9b7808..e3f7c864a6 100644 --- a/tests/test_turn_goal_account.py +++ b/tests/test_turn_goal_account.py @@ -1,4 +1,5 @@ """Goal settlement reuses the real SQLite grants and nominal state hierarchy.""" +from agent_comms.field_codec import TurnId from agent_comms.lifecycle_capability import Terminal @@ -21,7 +22,7 @@ class SpecializedPausedGoal(PausedGoal): (tmp_path / "attempts").mkdir(mode=0o700) store = GoalAttemptStore.initialize(tmp_path / "attempts") - turn = RegisteredTurn("turn") + turn = RegisteredTurn(TurnId('9b473845f684444f80a26586ae298d2b')) for settlement in (VerifiedGoalSettlement, OriginGoalSettlement): for state in (ActiveGoal(), PausedGoal(), CompletedGoal(), SpecializedPausedGoal()): goal = Goal( diff --git a/tests/test_turn_identity_equality.py b/tests/test_turn_identity_equality.py index c6e420be85..4e7816841b 100644 --- a/tests/test_turn_identity_equality.py +++ b/tests/test_turn_identity_equality.py @@ -1,20 +1,21 @@ """Different observations of one turn share its three-field identity.""" +from agent_comms.field_codec import TurnId from agent_comms.thread_identity import RecordedThreadIncarnation, TurnFence, TurnIdentity from agent_comms.turn_lease import FinishedTurnFence, StoredTurnRecord, TurnLeaseFence def test_turn_identity_equality_across_observation_types(tmp_path): identity = TurnIdentity(RecordedThreadIncarnation("worker", 1.0), 1) - observed = TurnFence(identity, "a" * 32, 1) - finished = FinishedTurnFence(identity, "a" * 32, 1) + observed = TurnFence(identity, TurnId('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 1) + finished = FinishedTurnFence(identity, TurnId('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 1) lease = TurnLeaseFence( - identity, "a" * 32, 1, + identity, TurnId('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 1, record=StoredTurnRecord.for_identity(tmp_path / "registry.json", identity), ) assert observed == finished == lease assert lease == finished == observed assert hash(observed) == hash(finished) == hash(lease) - assert observed != TurnFence(identity, "b" * 32, 1) - assert observed != TurnFence(identity, "a" * 32, 2) + assert observed != TurnFence(identity, TurnId('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'), 1) + assert observed != TurnFence(identity, TurnId('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 2) assert observed.__eq__(None) is NotImplemented assert not hasattr(observed, "matches") diff --git a/tests/test_turn_lease_release.py b/tests/test_turn_lease_release.py index eca54cbe0d..43a3ffa91f 100644 --- a/tests/test_turn_lease_release.py +++ b/tests/test_turn_lease_release.py @@ -1,4 +1,5 @@ """An ending attempt cannot release a replacement just because its ID is reused.""" +from agent_comms.field_codec import TurnId import os @@ -13,11 +14,11 @@ def test_old_lease_cannot_clear_same_id_after_delete_rebind(tmp_path): comms = wire(tmp_path) comms.registry.declare(Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) - original = comms.agents.begin_turn("owner", "same-id").turn_lease + original = comms.agents.begin_turn("owner", TurnId('058be655fae2a4c37a21d2c091ba7661')).turn_lease comms.registry.unregister("owner") comms.registry.remove("owner") comms.registry.declare(Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) - successor = comms.agents.begin_turn("owner", "same-id").turn_lease + successor = comms.agents.begin_turn("owner", TurnId('058be655fae2a4c37a21d2c091ba7661')).turn_lease assert original.identity.generation == successor.identity.generation saved = comms.registry.store.path.read_bytes() assert comms.registry.release_turn(original) == (False, NoFinishedTurn()) @@ -29,7 +30,7 @@ def test_old_lease_cannot_clear_same_id_after_delete_rebind(tmp_path): def test_revoked_claim_cleanup_cannot_attest_goal_completion(tmp_path): comms = wire(tmp_path) comms.registry.declare(Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) - lease = comms.agents.begin_turn("owner", "turn").turn_lease + lease = comms.agents.begin_turn("owner", TurnId('9b473845f684444f80a26586ae298d2b')).turn_lease comms.registry.archive("owner") assert comms.registry.release_turn(lease) == (True, NoFinishedTurn()) assert not comms.registry.require('owner').executing @@ -38,7 +39,7 @@ def test_revoked_claim_cleanup_cannot_attest_goal_completion(tmp_path): def test_unattested_saved_turn_remains_data_without_a_lease(tmp_path, *, thread_registration): owner = Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) thread_registration.register(owner) - retained, _ = thread_registration.lease_local_turn('owner', 'retained') + retained, _ = thread_registration.lease_local_turn('owner', TurnId('ca67bd59cc4116ac80db5f64cbf4747f')) expected = retained.turn_record.read().without_claim() thread_registration.register(retained, new_owner=True) # P482 stores the turn separately; the registry binds a decoded reference. diff --git a/tests/test_turn_record.py b/tests/test_turn_record.py index 23b1d99a18..702441a039 100644 --- a/tests/test_turn_record.py +++ b/tests/test_turn_record.py @@ -1,4 +1,5 @@ """Real registry/turn files and the public Comms phase entrypoint.""" +from agent_comms.field_codec import TurnId import json import os @@ -21,7 +22,7 @@ def test_copied_registry_resolves_turn_from_its_new_location(tmp_path): 'worker', frozenset(), str(source), process_identity=ProcessIdentity.capture(os.getpid()), )) - original = comms.agents.begin_turn('worker', 'preserved').turn_lease.record + original = comms.agents.begin_turn('worker', TurnId('0d8da7dc169a0c12f0a6f121b819ca38')).turn_lease.record raw = json.loads((source / 'registry.json').read_text()) reference = raw['threads']['worker']['turn_record'] assert reference == {} @@ -29,7 +30,7 @@ def test_copied_registry_resolves_turn_from_its_new_location(tmp_path): moved = RegistryStore(copied / 'registry.json') entry = moved.read_entry('worker').entry.thread - assert entry.turn_record.read().id == 'preserved' + assert entry.turn_record.read().id == '0d8da7dc169a0c12f0a6f121b819ca38' assert entry.turn_record.path == copied / 'turns' / original.path.name assert moved.read().threads['worker'].turn_record.path == entry.turn_record.path assert moved.read().threads['worker'].turn_record.registry_path == copied / 'registry.json' diff --git a/tests/test_unscoped_claim_history.py b/tests/test_unscoped_claim_history.py index ac06b0ceb6..43e5c62e36 100644 --- a/tests/test_unscoped_claim_history.py +++ b/tests/test_unscoped_claim_history.py @@ -1,4 +1,5 @@ """Bus history holds tool acquisitions written before claims recorded a scope.""" +from agent_comms.field_codec import TurnId import pytest @@ -40,7 +41,7 @@ def test_unscoped_history_row_decodes_projects_never_live_and_releases(tmp_path) owner="peer", incarnation=str(peer.created_at), seq=2, message_id="msg-2", claims=(resource,), generation=G2, resource_scope=NativeClaim(owner_claimed_generation=1, operation_id="c" * 32, - turn_id="turn-2", input_id="input-2"), + turn_id=TurnId('ff33c94032d9f01447a779689c1dff6a'), input_id="input-2"), ).apply(held, registry) assert taken[resource].owner == "peer" diff --git a/tests/verify_connector_tools.py b/tests/verify_connector_tools.py index 8104e1edd3..443523dc94 100644 --- a/tests/verify_connector_tools.py +++ b/tests/verify_connector_tools.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +from agent_comms.field_codec import TurnId from agent_comms.repository_name import RepositoryName @@ -138,7 +139,7 @@ def arguments(comms: Comms, name: str, project: Path, checkout: Path) -> dict: replace(thread, process_identity=ProcessIdentity.capture(os.getpid())) ) owner, claim = comms.registry.live_owner_with_claim("claude-ai") - comms.registry.lease_live_turn_with_claim(owner, "probe-turn", expected_generation=claim) + comms.registry.lease_live_turn_with_claim(owner, TurnId('5e1f1a1948276272eb5c186e77e8285b'), expected_generation=claim) cases = { "comms_pin_channel": {"name": "#probe", "pinned": True}, "comms_pin_thread": {"channel": "#probe", "name": "worker", "pinned": True}, From b8674f84ff0d769967878bf38b51898232e4b5b4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:29:32 -0400 Subject: [PATCH 08/32] Include declared column values and codecs in producer digests --- src/agent_comms/typed_table.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 2b7b968f1a..506b859e3a 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -45,6 +45,7 @@ FieldCodec, FieldDirective, FieldPlan, + FieldRepresentation, MalformedRecord, NullMember, ValueDecoder, @@ -2084,7 +2085,7 @@ def _producer_digest(cls) -> str: @classmethod def producer_owners(cls) -> tuple[type, ...]: - """Only this table's writers and their inherited implementations. + """This table's writers, column values and their inherited implementations. An ancestor declaring other outputs owns those outputs alone. Inherited producer capabilities follow the writer; unrelated mechanisms do not. @@ -2102,6 +2103,19 @@ def producer_owners(cls) -> tuple[type, ...]: if issubclass(parent, ProjectionSource) and ("writes" not in vars(parent) or parent in writers) ) + annotations = [column.annotation for column in cls._columns] + while annotations: + value, representation = FieldCodec._representation(annotations.pop()) + annotations.extend(get_args(value)) + for owner in (value, representation): + if not isinstance(owner, type): + continue + for parent in owner.__mro__: + if parent.__module__ == object.__module__: + break + owners.add(parent) + if parent is FieldRepresentation: + break return tuple(sorted(owners, key=lambda owner: (owner.__module__, owner.__qualname__))) From 42eb3f425d72694dad6f0e285497d849e9e4f263 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:31:58 -0400 Subject: [PATCH 09/32] Prove column spelling changes invalidate only their declared table --- tests/test_bus.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_bus.py b/tests/test_bus.py index 0649423d1b..da92e17bb3 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -131,6 +131,28 @@ def test_projection_digest_ignores_infrastructure_source( assert projection_source_digest(projection_source) == before +def test_projection_digest_tracks_declared_column_spelling(projection_source: Path) -> None: + reference = projection_source / "message_reference.py" + reference.write_text(reference.read_text().replace( + "from dataclasses import dataclass", + "from dataclasses import dataclass\nfrom typing import Annotated", + ).replace( + "from .field_codec import NullMember,", + "from .field_codec import MessageIdText, NullMember,", + ).replace("message_id: str", "message_id: Annotated[str, MessageIdText]")) + before = json.loads(projection_source_digest(projection_source))["message_identity_source"] + codec = projection_source / "field_codec.py" + original = codec.read_text() + related = "digits: ClassVar[int] = 12" + assert original.count(related) == 1 + codec.write_text(original.replace(related, "digits: ClassVar[int] = 13")) + assert json.loads(projection_source_digest(projection_source))["message_identity_source"] != before + unrelated = 'spelling = re.compile("[0-9a-f]{7,40}")' + assert original.count(unrelated) == 1 + codec.write_text(original.replace(unrelated, 'spelling = re.compile("[0-9a-f]{8,40}")')) + assert json.loads(projection_source_digest(projection_source))["message_identity_source"] == before + + @pytest.mark.parametrize( ("module", "old", "new"), ( From df17df7abad3d3ced348735697a9bbdcfe295510 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:37:58 -0400 Subject: [PATCH 10/32] Preserve nominal TEXT storage and codec mixins in the owner walk --- src/agent_comms/typed_table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 506b859e3a..3a9b78ea2d 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -181,7 +181,7 @@ class TextStorage(SqlStorage): @classmethod def accepts(cls, annotation: object) -> bool: - return annotation is str + return isinstance(annotation, type) and issubclass(annotation, str) class IntegerStorage(SqlStorage): @@ -2112,7 +2112,7 @@ def producer_owners(cls) -> tuple[type, ...]: continue for parent in owner.__mro__: if parent.__module__ == object.__module__: - break + continue owners.add(parent) if parent is FieldRepresentation: break From 1c679cb4a3bc9ce5cb224dff2ccd152a15248d3e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:48:32 -0400 Subject: [PATCH 11/32] Derive nominal TEXT storage from its declared text representation --- src/agent_comms/typed_table.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 3a9b78ea2d..6c70004ebc 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -48,6 +48,7 @@ FieldRepresentation, MalformedRecord, NullMember, + TextRepresentation, ValueDecoder, WireName, WireValue, @@ -181,7 +182,10 @@ class TextStorage(SqlStorage): @classmethod def accepts(cls, annotation: object) -> bool: - return isinstance(annotation, type) and issubclass(annotation, str) + if annotation is str: + return True + _, representation = FieldCodec._representation(annotation) + return representation is not None and issubclass(representation, TextRepresentation) class IntegerStorage(SqlStorage): From 7c35a8cc7e855eaab81812a47c377a01a3b1402a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:57:26 -0400 Subject: [PATCH 12/32] Track the real message ID kind and expose codec representation ownership --- src/agent_comms/field_codec.py | 27 ++++++++++++++------------- src/agent_comms/message_reference.py | 4 ++-- src/agent_comms/typed_table.py | 19 +++++++++---------- tests/test_bus.py | 25 +++++++++++++++++-------- 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 85972a7e99..edcaf9a2a5 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -584,7 +584,7 @@ def rewrite_wire( def schema_fields(self): annotation, declared = self.annotation, self.declared - base = FieldCodec._representation(annotation)[0] + base = FieldCodec.representation(annotation)[0] if self.reject_null and get_origin(base) in (Union, types.UnionType): members = tuple(item for item in get_args(base) if item is not type(None)) annotation = members[0] if len(members) == 1 else Union[members] # noqa: UP007 - runtime tuple @@ -659,7 +659,7 @@ class DeclaredFieldPlan(FieldPlan): @classmethod def claims(cls, declared: Field, annotation: object) -> bool: - representation = FieldCodec._representation(annotation)[1] or FieldRepresentation + representation = FieldCodec.representation(annotation)[1] or FieldRepresentation return super().claims(declared, annotation) or representation.field_plan is cls @@ -747,7 +747,7 @@ class InlineFieldPlan(DeclaredFieldPlan): @cached_property def inlined(self) -> type: - declared, representation = FieldCodec._representation(self.annotation) + declared, representation = FieldCodec.representation(self.annotation) match representation: case type() as inline if issubclass(inline, InlineRecord): return inline.wire_record() @@ -784,7 +784,7 @@ class InlineFamilyPlan(InlineFieldPlan): @classmethod def claims(cls, declared: Field, annotation: object) -> bool: - value_type = FieldCodec._representation(annotation)[0] + value_type = FieldCodec.representation(annotation)[0] return ( super().claims(declared, annotation) and isinstance(value_type, type) @@ -1851,8 +1851,9 @@ def external_decoders(cls, target: type) -> Iterator[ExternalDecoder]: def external_decoder(cls, target: type[ExternalField]) -> ExternalDecoder: return target.decode_external - @staticmethod - def _representation(annotation: object) -> tuple[object, type[FieldRepresentation] | None]: + @classmethod + def representation(cls, annotation: object) -> tuple[object, type[FieldRepresentation] | None]: + """The declared value type and the codec owning its wire representation.""" if annotation is None: return None, None if annotation is object: @@ -1884,10 +1885,10 @@ def _representation(annotation: object) -> tuple[object, type[FieldRepresentatio @classmethod def _value_representation(cls, value: object, annotation: object): if annotation is None: - return cls._representation(type(value))[1] - _, representation = cls._representation(annotation) + return cls.representation(type(value))[1] + _, representation = cls.representation(annotation) if representation is None: - _, representation = cls._representation(type(value)) + _, representation = cls.representation(type(value)) return representation @classmethod @@ -1977,7 +1978,7 @@ def decode_record( """Decode declared fields, with nominal narrowing supplied by their contextual owner.""" decoder = _RecordDecoder(target) for name, narrowed in field_types.items(): - annotation, _ = cls._representation(cls.field_plan(target, name).annotation) + annotation, _ = cls.representation(cls.field_plan(target, name).annotation) if not issubclass(narrowed, annotation): raise TypeError(f"{target.__name__}.{name} cannot widen its declared type") return cast(T, decoder.decode_selected( @@ -2104,7 +2105,7 @@ def _record_schema( @classmethod def value_schema(cls, annotation: object) -> dict[str, JsonValue]: """Derive external scalar/array choices from the same decode declarations.""" - annotation, representation = cls._representation(annotation) + annotation, representation = cls.representation(annotation) if representation is not None: schema = representation.schema() if type(None) in get_args(annotation): @@ -2158,7 +2159,7 @@ def project(cls, value: object, view: str, annotation: object = None) -> JsonVal if is_dataclass(value) and not isinstance(value, type): return cls._project_record(value, view) if isinstance(value, (tuple, list)): - arguments = get_args(cls._representation(annotation)[0]) + arguments = get_args(cls.representation(annotation)[0]) return [cls.project(item, view, arguments[0] if arguments else None) for item in value] return cls.encode(value) @@ -3090,7 +3091,7 @@ def compile_decoder(annotation: object) -> ValueDecoder[object]: ... @lru_cache(maxsize=None) def compile_decoder(annotation: object) -> ValueDecoder[object]: """Compile each immutable declaration once; value paths never parse it again.""" - declared, representation = FieldCodec._representation(annotation) + declared, representation = FieldCodec.representation(annotation) if representation is None: return _structural_decoder(declared) try: diff --git a/src/agent_comms/message_reference.py b/src/agent_comms/message_reference.py index b8455a6b6c..26c251e60f 100644 --- a/src/agent_comms/message_reference.py +++ b/src/agent_comms/message_reference.py @@ -7,7 +7,7 @@ from abc import abstractmethod from dataclasses import dataclass -from .field_codec import NullMember, RecordMember, SingleFieldValue, SubclassUnion +from .field_codec import MessageId, NullMember, RecordMember, SingleFieldValue, SubclassUnion from .page_traversal import LiveCursor, NewestCursor, PageCursor, PageTraversal @@ -20,7 +20,7 @@ def writes(cls) -> tuple[type[ProducedRow], ...]: return (MessageIdentitySource, AnsweredSource) seq: int - message_id: str + message_id: MessageId class InboxStart(SubclassUnion, affix="Start"): diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 6c70004ebc..ef52785821 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -17,7 +17,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence from contextlib import AbstractContextManager, ExitStack, closing, contextmanager -from dataclasses import dataclass, field +from dataclasses import dataclass, field, is_dataclass from enum import Enum, IntEnum, IntFlag from functools import cache from importlib import import_module @@ -47,6 +47,7 @@ FieldPlan, FieldRepresentation, MalformedRecord, + NominalText, NullMember, TextRepresentation, ValueDecoder, @@ -184,7 +185,7 @@ class TextStorage(SqlStorage): def accepts(cls, annotation: object) -> bool: if annotation is str: return True - _, representation = FieldCodec._representation(annotation) + _, representation = FieldCodec.representation(annotation) return representation is not None and issubclass(representation, TextRepresentation) @@ -2107,19 +2108,17 @@ def producer_owners(cls) -> tuple[type, ...]: if issubclass(parent, ProjectionSource) and ("writes" not in vars(parent) or parent in writers) ) - annotations = [column.annotation for column in cls._columns] + annotations = [column.annotation for column in cls._columns] if is_dataclass(cls) else [] while annotations: - value, representation = FieldCodec._representation(annotations.pop()) + value, representation = FieldCodec.representation(annotations.pop()) annotations.extend(get_args(value)) for owner in (value, representation): if not isinstance(owner, type): continue - for parent in owner.__mro__: - if parent.__module__ == object.__module__: - continue - owners.add(parent) - if parent is FieldRepresentation: - break + owners.update( + parent for parent in owner.__mro__ + if issubclass(parent, (FieldRepresentation, NominalText)) + ) return tuple(sorted(owners, key=lambda owner: (owner.__module__, owner.__qualname__))) diff --git a/tests/test_bus.py b/tests/test_bus.py index da92e17bb3..28b3a9f0f1 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -39,6 +39,7 @@ RelationViolationError, UnclaimedError, ) +from agent_comms.field_codec import MessageId from agent_comms.message_reference import MessageReference from agent_comms.read_basis import RepliesDisplayScope from agent_comms.response_acknowledgement import ( @@ -132,14 +133,6 @@ def test_projection_digest_ignores_infrastructure_source( def test_projection_digest_tracks_declared_column_spelling(projection_source: Path) -> None: - reference = projection_source / "message_reference.py" - reference.write_text(reference.read_text().replace( - "from dataclasses import dataclass", - "from dataclasses import dataclass\nfrom typing import Annotated", - ).replace( - "from .field_codec import NullMember,", - "from .field_codec import MessageIdText, NullMember,", - ).replace("message_id: str", "message_id: Annotated[str, MessageIdText]")) before = json.loads(projection_source_digest(projection_source))["message_identity_source"] codec = projection_source / "field_codec.py" original = codec.read_text() @@ -153,6 +146,22 @@ def test_projection_digest_tracks_declared_column_spelling(projection_source: Pa assert json.loads(projection_source_digest(projection_source))["message_identity_source"] == before +def test_nominal_message_id_column_stores_plain_text() -> None: + identifier = MessageId.mint("nominal column storage") + row = MessageIdentitySource(seq=1, message_id=identifier, sender="sender") + with sqlite3.connect(":memory:") as db: + MessageIdentitySource.create(db) + row.insert(db) + value, storage = db.execute( + f'SELECT message_id, typeof(message_id) FROM "{MessageIdentitySource.declared_name}"', + ).fetchone() + assert storage == "text" + assert value == str(identifier) + restored, = MessageIdentitySource.select(db) + assert type(restored.message_id) is MessageId + assert restored == row + + @pytest.mark.parametrize( ("module", "old", "new"), ( From 63031a4d62d4e7338544539f26a57039789eaf42 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:58:50 -0400 Subject: [PATCH 13/32] Keep stored-input fixtures on valid turn identities --- tests/test_claim_admission_verifier.py | 2 +- tests/test_holder_standing_rules.py | 2 +- tests/test_input_delivery_history.py | 5 +++-- tests/test_input_disposition.py | 21 +++++++++++---------- tests/test_input_documents.py | 23 +++++++++++------------ tests/test_l0_goal_input_closure.py | 4 ++-- tests/test_preflight_feedback.py | 3 ++- tests/test_reservation_rules.py | 5 +++-- 8 files changed, 34 insertions(+), 31 deletions(-) diff --git a/tests/test_claim_admission_verifier.py b/tests/test_claim_admission_verifier.py index 1096ee4229..76194d4b01 100644 --- a/tests/test_claim_admission_verifier.py +++ b/tests/test_claim_admission_verifier.py @@ -187,7 +187,7 @@ def swap_after_open(path, flags, *args, **kwargs): (replace(claim_scope, recipient_lookup=bob_lookup), "Bob"), (replace(claim_scope, wake_revision=engaged.revision + 1), "Alice"), (replace(claim_scope, owner_claimed_generation=claimed_generation + 1), "Alice"), - (replace(claim_scope, turn_id="old-turn"), "Alice"), + (replace(claim_scope, turn_id=TurnId('310c74d7ac243239a928d4021c709acb')), "Alice"), ): with pytest.raises(IdentityConflict): verify_selected_wake(comms, store, candidate, name) diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 4e09b2ce5b..e3e4e7fd3e 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -106,7 +106,7 @@ def held_bus(comms, tmp_path, source_repository, configured_repository): inputs = InputDispositions(comms.root) inputs.record("unresolved-input", seq=None, owner="worker", claim_scope=1, target="worker", text="Do not replay this input") - assert inputs.bind("unresolved-input", claim_scope=1, turn_id="old-turn", + assert inputs.bind("unresolved-input", claim_scope=1, turn_id=TurnId('310c74d7ac243239a928d4021c709acb'), native_id=uuid4().hex, text="Do not replay this input") configured_repository(comms) copied = tmp_path / "copied-bus" diff --git a/tests/test_input_delivery_history.py b/tests/test_input_delivery_history.py index 8045f7ebff..08abd63e36 100644 --- a/tests/test_input_delivery_history.py +++ b/tests/test_input_delivery_history.py @@ -1,4 +1,5 @@ """Current owner queues scope notices without altering durable input evidence.""" +from agent_comms.field_codec import TurnId from dataclasses import replace @@ -17,7 +18,7 @@ def seed(comms, name): ledger = InputDispositions(comms.root) for key, seq in [("bus:1", 1), ("bus:2", 2), ("bus:7", 7), ("bus:8", 8), ("acp:ui", None)]: ledger.record(key, seq=seq, owner=name, claim_scope=1, target=name, text=f"Text {key}") - ledger.bind("bus:2", claim_scope=1, turn_id="t", native_id="a" * 32, text="Text bus:2") + ledger.bind("bus:2", claim_scope=1, turn_id=TurnId('e3b98a4da31a127d4bde6e43033f66ba'), native_id="a" * 32, text="Text bus:2") ledger.review_for_goal(("bus:1",), owners=owners, goal_id="goal", goal_revision=1, turn_id="t") return ledger @@ -63,7 +64,7 @@ def test_history_clear_is_notice_only_and_survives_rename_and_reopen(tmp_path): ).historical_inputs ) assert reopened.goals.dismiss_historical_inputs("renamed", scope=scope) == result - assert ledger.bind("bus:7", claim_scope=1, turn_id="t2", native_id="b" * 32, text="Text bus:7") + assert ledger.bind("bus:7", claim_scope=1, turn_id=TurnId('c44474038d459e40e4714afefa7bf8da'), native_id="b" * 32, text="Text bus:7") assert "bus:7" not in [ r.input_id for r in reopened.goals.input_delivery("renamed", scope=scope).inputs diff --git a/tests/test_input_disposition.py b/tests/test_input_disposition.py index 35053ae35d..82a28a0f3b 100644 --- a/tests/test_input_disposition.py +++ b/tests/test_input_disposition.py @@ -1,4 +1,5 @@ """Direct input state survives a process exit without replaying an attempt.""" +from agent_comms.field_codec import TurnId import multiprocessing from concurrent.futures import ThreadPoolExecutor @@ -56,11 +57,11 @@ def test_unknown_is_durable_and_native_start_is_a_cas(tmp_path: Path) -> None: ) native_id = "a" * 32 - assert store.bind("bus:7", claim_scope=3, turn_id="turn-1", native_id=native_id, text="full ask") - assert not store.started("bus:7", turn_id="turn-1", native_id="b" * 32, text="full ask") - assert not store.started("bus:7", turn_id="turn-1", native_id=native_id, text="wrong") - assert store.started("bus:7", turn_id="turn-1", native_id=native_id, text="full ask") - assert not store.started("bus:7", turn_id="turn-1", native_id=native_id, text="full ask") + assert store.bind("bus:7", claim_scope=3, turn_id=TurnId('974cad2dd603827b06e9e6affa3de03d'), native_id=native_id, text="full ask") + assert not store.started("bus:7", turn_id=TurnId('974cad2dd603827b06e9e6affa3de03d'), native_id="b" * 32, text="full ask") + assert not store.started("bus:7", turn_id=TurnId('974cad2dd603827b06e9e6affa3de03d'), native_id=native_id, text="wrong") + assert store.started("bus:7", turn_id=TurnId('974cad2dd603827b06e9e6affa3de03d'), native_id=native_id, text="full ask") + assert not store.started("bus:7", turn_id=TurnId('974cad2dd603827b06e9e6affa3de03d'), native_id=native_id, text="full ask") assert ( stored_inputs(tmp_path).unknown(frozenset({"kid"})) == () @@ -80,12 +81,12 @@ def test_unresolved_projection_follows_rename_without_private_receipts(tmp_path: store = InputDispositions(comms.root) store.record("bus:7", seq=7, owner="kid", claim_scope=1, target="#review", text="exact source") store.record("bus:8", seq=8, owner="peer", claim_scope=1, target="#review", text="other owner") - store.bind("bus:7", claim_scope=1, turn_id="turn", native_id="a" * 32, text="private wrapper") + store.bind("bus:7", claim_scope=1, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="private wrapper") comms.threads.rename_managed_thread("kid", "new-kid", owner=ProcessIdentity.capture(os.getpid())) expected = (UnresolvedInput(7, "#review", "exact source", "bus:7", "unknown"),) assert comms.goals.unresolved_inputs("new-kid") == expected assert comms.goals.unresolved_inputs("kid") == expected - assert store.started("bus:7", turn_id="turn", native_id="a" * 32, text="private wrapper") + assert store.started("bus:7", turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="private wrapper") assert comms.goals.unresolved_inputs("new-kid") == () @@ -143,11 +144,11 @@ def test_admitted_snapshot_retains_cut_without_blocking_writer(tmp_path): REFERENCE = MessageReference(7, "original-id") RESERVED = ReservedInput("bus:7", 7, "owner", 1, "owner", "ask", origin=WireInputOrigin(ROOT, REFERENCE)) -BOUND = RESERVED._transition(BoundUnknownInput, turn_id="turn-1", native_id=NATIVE, +BOUND = RESERVED._transition(BoundUnknownInput, turn_id=TurnId('974cad2dd603827b06e9e6affa3de03d'), native_id=NATIVE, sent_digest=TextDigest.of(SENT)) TRANSITIONS = { - "bind": lambda row: row.bind(claim_scope=1, turn_id="turn-1", native_id=NATIVE, text=SENT), - "started": lambda row: row.started(turn_id="turn-1", native_id=NATIVE, text=SENT), + "bind": lambda row: row.bind(claim_scope=1, turn_id=TurnId('974cad2dd603827b06e9e6affa3de03d'), native_id=NATIVE, text=SENT), + "started": lambda row: row.started(turn_id=TurnId('974cad2dd603827b06e9e6affa3de03d'), native_id=NATIVE, text=SENT), "finish_unbound": lambda row: row.finish_unbound(), "clear_unbound": lambda row: row.clear_unbound(), "clear_native": lambda row: row.clear_native(NATIVE, SENT), diff --git a/tests/test_input_documents.py b/tests/test_input_documents.py index 24663b7d38..b310b786ec 100644 --- a/tests/test_input_documents.py +++ b/tests/test_input_documents.py @@ -33,7 +33,7 @@ def test_original_batch_publication_preserves_prior_rows_and_reserves_only_new_i args = dict(seq=None, owner="worker", claim_scope=1, target="worker", text="Prior input") assert store.record("prior", **args) assert store.record("unknown", **args) - assert store.bind("unknown", claim_scope=1, turn_id="old", native_id="a" * 32, + assert store.bind("unknown", claim_scope=1, turn_id=TurnId('cba06b5736faf67e54b07b561eae9439'), native_id="a" * 32, text="Original sent input") prior = stored_inputs(store.root) first = ReservedInput("first", None, "worker", 1, "worker", "First input") @@ -93,7 +93,7 @@ def test_saved_discriminator_and_optional_notices_roundtrip_without_replay(tmp_p key="acp:old", sequence=None, owner="worker", claim_generation=1, target="worker", source_text="Retain me", turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, sent_digest=TextDigest.of("Exact sent text"), notice_dismissed=True, - goal_reviews={"goal": GoalInputDecision(goal_revision=7, turn_id="review")}, + goal_reviews={"goal": GoalInputDecision(goal_revision=7, turn_id=TurnId('c97ace4c8fef2cee8fa0f3c9f52aab18'))}, )) _insert_raw(store, "acp:old", saved) row = store.lookup("acp:old") @@ -102,8 +102,8 @@ def test_saved_discriminator_and_optional_notices_roundtrip_without_replay(tmp_p assert FieldCodec.encode(row) == saved assert store.in_turn("turn").rows == {"acp:old": row} assert store.unresolved(frozenset({"worker"})).rows == {"acp:old": row} - assert not store.bind(row.key, claim_scope=1, turn_id="again", native_id="b" * 32, text="again") - assert not store.started(row.key, turn_id="wrong", native_id="a" * 32, text="Exact sent text") + assert not store.bind(row.key, claim_scope=1, turn_id=TurnId('b4c9e14061c2fd453b36700e3b0da008'), native_id="b" * 32, text="again") + assert not store.started(row.key, turn_id=TurnId('8810ad581e59f2bc3928b261707a7130'), native_id="a" * 32, text="Exact sent text") assert store.started(row.key, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="Exact sent text") assert isinstance(stored_inputs(store.root).rows[row.key], StartedInput) assert not store.started(row.key, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="Exact sent text") @@ -151,7 +151,7 @@ def test_original_native_user_receipts_include_group_and_corrected_final_only(tm lease, "b" * 32, "Actual corrected native user", snapshot=snapshot ) == (document.rows["acp:correction"],) assert not document.started_for_native( - replace(lease, turn_id="foreign"), "a" * 32, "Actual grouped native user", snapshot=snapshot + replace(lease, turn_id=TurnId('656771905e1ef731f65cd0a0d9fb0612')), "a" * 32, "Actual grouped native user", snapshot=snapshot ) assert not document.started_for_native( replace(lease, claimed_generation=claim_scope + 1), "a" * 32, @@ -190,7 +190,7 @@ def test_distinct_lifecycle_and_missing_state_never_supply_sent_evidence(tmp_pat missing = stored_inputs(store.root).lookup("acp:missing") assert isinstance(missing, MissingInput) and fields(missing) == () assert not missing.exists and not missing.accepts_reservation and not missing.has_started - assert not store.bind("acp:missing", claim_scope=1, turn_id="t", native_id="a" * 32, text="text") + assert not store.bind("acp:missing", claim_scope=1, turn_id=TurnId('e3b98a4da31a127d4bde6e43033f66ba'), native_id="a" * 32, text="text") assert not stored_inputs(store.root).all_started(("acp:missing",)) assert store.record( "acp:input", seq=None, owner="owner", claim_scope=4, target="owner", text="keep" @@ -209,7 +209,7 @@ def test_distinct_lifecycle_and_missing_state_never_supply_sent_evidence(tmp_pat unsent.require(Unresolved, ValueError("review required")) assert isinstance(unsent, NotSentInput) assert not unsent.accepts_reservation - assert not store.bind("acp:input", claim_scope=4, turn_id="new", native_id="b" * 32, text="keep") + assert not store.bind("acp:input", claim_scope=4, turn_id=TurnId('11507a0e2f5e69d5dfa40a62a1bd7b6e'), native_id="b" * 32, text="keep") assert UnresolvedInput.of(unsent).status == "not_sent" assert "unknown" not in InputAttempt.names() with pytest.raises(ValueError): @@ -223,9 +223,9 @@ def test_batch_retirement_returns_published_cut_without_changing_native_evidence assert store.record(key, seq=None, owner="owner", claim_scope=1, target="owner", text=key) for key in ("acp:bound", "acp:started"): - assert store.bind(key, claim_scope=1, turn_id="original-turn", + assert store.bind(key, claim_scope=1, turn_id=TurnId('ab87f599dc0a50a56b7c65de71c39426'), native_id="a" * 32, text=key) - assert store.started("acp:started", turn_id="original-turn", native_id="a" * 32, + assert store.started("acp:started", turn_id=TurnId('ab87f599dc0a50a56b7c65de71c39426'), native_id="a" * 32, text="acp:started") store.settle_unbound(("acp:unsent",)) original = stored_inputs(store.root) @@ -250,10 +250,9 @@ def test_batch_retirement_returns_published_cut_without_changing_native_evidence @pytest.mark.parametrize( "sent", [ - {"turn_id": "turn"}, + {"turn_id": "9b473845f684444f80a26586ae298d2b"}, {"native_id": "a" * 32}, - {"turn_id": "turn", "native_id": "a" * 32}, - {"turn_id": "", "native_id": "a" * 32, "sent_digest": {"value": "0" * 64}}, + {"turn_id": "9b473845f684444f80a26586ae298d2b", "native_id": "a" * 32}, ], ) def test_incomplete_sent_evidence_is_rejected_without_overwriting_history(tmp_path, sent): diff --git a/tests/test_l0_goal_input_closure.py b/tests/test_l0_goal_input_closure.py index 65ebe4f99a..ce119a5415 100644 --- a/tests/test_l0_goal_input_closure.py +++ b/tests/test_l0_goal_input_closure.py @@ -145,9 +145,9 @@ def test_standby_uses_native_receipts_and_explicit_reviews_not_read_ack(tmp_path text=second.body, ) assert store.bind( - second_key, claim_scope=claim_scope, turn_id="native-turn", native_id="a" * 32, text=second.body + second_key, claim_scope=claim_scope, turn_id=TurnId('d139335a14223f343b5786bbd9e20434'), native_id="a" * 32, text=second.body ) - assert store.started(second_key, turn_id="native-turn", native_id="a" * 32, text=second.body) + assert store.started(second_key, turn_id=TurnId('d139335a14223f343b5786bbd9e20434'), native_id="a" * 32, text=second.body) comms.goals.update_goal("owner", action) wait = comms.goals.goal_wait("owner") assert wait.owner == comms.registry.require("owner").incarnation diff --git a/tests/test_preflight_feedback.py b/tests/test_preflight_feedback.py index 8091e32927..d40a900744 100644 --- a/tests/test_preflight_feedback.py +++ b/tests/test_preflight_feedback.py @@ -1,4 +1,5 @@ """Failure diagnostics and durable delivery projection remain independent authorities.""" +from agent_comms.field_codec import TurnId import pytest from agent_comms.input_attempt import ReservedInput, NotSentInput, MissingInput from agent_comms.input_disposition import InputDocument @@ -42,7 +43,7 @@ def test_post_dispatch_failure_preserves_bound_uncertainty(tmp_path): store = InputDispositions(tmp_path) store.record('acp:partial-write', seq=None, owner='owner', claim_scope=1, target='owner', text='original input') - assert store.bind('acp:partial-write', claim_scope=1, turn_id='turn', + assert store.bind('acp:partial-write', claim_scope=1, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id='a' * 32, text='original input') before = stored_inputs(store.root).lookup('acp:partial-write') assert isinstance(before, SentInput) diff --git a/tests/test_reservation_rules.py b/tests/test_reservation_rules.py index 80b26cd852..abd2022e6a 100644 --- a/tests/test_reservation_rules.py +++ b/tests/test_reservation_rules.py @@ -1,4 +1,5 @@ """Named refusal behavior through the rule family and the real input ledger.""" +from agent_comms.field_codec import TurnId @@ -70,9 +71,9 @@ def test_plural_originals_preserve_order_and_bind_without_partial_transition(tmp for key, text in zip(keys, ("first", "second"), strict=True): inputs.record(key, seq=None, owner=owner, claim_scope=1, target=owner, text=text) assert tuple(row.source_text for row in stored_inputs(inputs.root).originals(keys)) == ("first", "second") - assert inputs.bind(keys[1], claim_scope=1, turn_id="other", native_id="a" * 32, text="second") + assert inputs.bind(keys[1], claim_scope=1, turn_id=TurnId('d9298a10d1b0735837dc4bd85dac641b'), native_id="a" * 32, text="second") before = stored_inputs(inputs.root) - assert not inputs.bind_originals(keys, claim_scope=1, turn_id="turn", native_id="b" * 32, + assert not inputs.bind_originals(keys, claim_scope=1, turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="b" * 32, text="first\n\nsecond") assert stored_inputs(inputs.root) == before assert stored_inputs(inputs.root).lookup(keys[0]).accepts_reservation From 4d21f63c5d687454e6e9f030206d1f8e51b97148 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:59:49 -0400 Subject: [PATCH 14/32] Carry nominal turn values through the registration writers --- src/agent_comms/agent_activity.py | 3 ++- src/agent_comms/registration.py | 7 ++++--- src/agent_comms/registry_store.py | 4 ++-- src/agent_comms/turn_runner.py | 3 ++- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/agent_comms/agent_activity.py b/src/agent_comms/agent_activity.py index c9c7b7ef8f..d6cc457305 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -36,6 +36,7 @@ from .errors import RelationViolationError from .routing import TurnRouting from .runtime_info import RuntimeInfo, RuntimeInfoStore +from .field_codec import TurnId from .thread_identity import OwnerIdentity, ThreadIncarnation from .threads import Thread from .turn_lease import NoFinishedTurn, TurnCompletion, TurnLeaseFence, TurnState @@ -254,7 +255,7 @@ def recover_completed_turn(self, lease: TurnLeaseFence, backend: type[AgentBacke return self._recover_drain(thread.name, owner, diagnostic) def begin_turn( - self, name: str, turn_id: str, detail: str = "", routing: TurnRouting | None = None + self, name: str, turn_id: TurnId, detail: str = "", routing: TurnRouting | None = None ) -> RegistryOwner: """Return the exact owner installed by this atomic begin, including its lease.""" leased, _ = self.registry.lease_local_turn(name, turn_id, routing=routing) diff --git a/src/agent_comms/registration.py b/src/agent_comms/registration.py index 50c54650c6..fbdde421bb 100644 --- a/src/agent_comms/registration.py +++ b/src/agent_comms/registration.py @@ -20,6 +20,7 @@ from .routing import TurnRouting from .storage_rules import StorageRule from .thread_execution import CommanderForkExecution +from .field_codec import TurnId from .thread_identity import GenerationCounter, RegisteredTurn from .thread_status import ( ConnectedThreadStatus, @@ -321,7 +322,7 @@ def live_owner_with_claim(self, name: str) -> tuple[Thread, int]: return owner.thread, owner.claimed_generation def lease_live_turn_with_claim( - self, expected: Thread, turn_id: str, *, expected_generation: int + self, expected: Thread, turn_id: TurnId, *, expected_generation: int ) -> tuple[Thread, int]: """Claim a turn against the input generation, allowing unrelated metadata changes.""" Thread.require_declaration(expected) @@ -337,7 +338,7 @@ def lease_live_turn_with_claim( def lease_live_turn_with_generation( self, expected: Thread, - turn_id: str, + turn_id: TurnId, *, expected_owner_generation: int, routing: TurnRouting | None = None, @@ -356,7 +357,7 @@ def lease_live_turn_with_generation( return result def lease_local_turn( - self, name: str, turn_id: str, *, routing: TurnRouting | None = None + self, name: str, turn_id: TurnId, *, routing: TurnRouting | None = None ) -> tuple[Thread, int]: """Atomic local begin-turn, never reviving a stopped or replaced owner.""" turn = RegisteredTurn(turn_id) diff --git a/src/agent_comms/registry_store.py b/src/agent_comms/registry_store.py index 832505f681..38413141fc 100644 --- a/src/agent_comms/registry_store.py +++ b/src/agent_comms/registry_store.py @@ -16,7 +16,7 @@ from .coordination_database import CoordinationStore from .errors import RelationViolationError -from .field_codec import FieldCodec, NullMember +from .field_codec import FieldCodec, NullMember, TurnId from .locked_store import RegistryFile from .maintenance_barrier import CarriedDocument from .native_input_owner import RegistryOwner @@ -166,7 +166,7 @@ def prepare_native_source(self, owner: RegistryOwner, session_file: str) -> Nati def lease_turn( self, current: Thread, - turn_id: str, + turn_id: TurnId, routing: TurnRouting | None, ) -> tuple[Thread, int]: """Allocate the original record under this bound registry transaction.""" diff --git a/src/agent_comms/turn_runner.py b/src/agent_comms/turn_runner.py index eff3d89f28..dea5e1e73f 100644 --- a/src/agent_comms/turn_runner.py +++ b/src/agent_comms/turn_runner.py @@ -21,6 +21,7 @@ from .channels import AllChannel from .controller import Controller from .coordinator import Coordination +from .field_codec import TurnId from .errors import RelationViolationError from .following_inputs import OwnerFollowingInputs from .goal_actions import ( @@ -304,7 +305,7 @@ async def extension_ui_permission( def acquire_turn( self, resources: AsyncExitStack, session_id: str, thread_name: str, - turn_id: str, detail: str, routing: TurnRouting | None = None, *, + turn_id: TurnId, detail: str, routing: TurnRouting | None = None, *, task: TurnTask | None = None, ) -> RegistryOwner: """Bind the exact installed lease to cleanup before worker delivery. From c766da5a2c213253690ed194955d3d1cbfe2f83a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 19:02:57 -0400 Subject: [PATCH 15/32] Resolve the codec merge and retain nominal comparison content --- src/agent_comms/field_codec.py | 273 +++------------------------------ 1 file changed, 23 insertions(+), 250 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 1d44f1d4b8..99a93c489d 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -967,7 +967,14 @@ def annotation_class(cls, annotation: object) -> type[ValueDecoder[object]]: try: return cls._by_annotation[annotation] except KeyError: - return cls._by_origin[get_origin(annotation) or annotation] + pass + declarations = annotation.__mro__ if isinstance(annotation, type) else (annotation,) + for declaration in declarations: + try: + return cls._by_origin[get_origin(declaration) or declaration] + except KeyError: + continue + raise KeyError(annotation) @classmethod def with_representation( @@ -1001,6 +1008,14 @@ def decode(self, data: object) -> T: @abstractmethod def encode(self, value: T) -> object: ... + def comparison(self, value: T) -> object: + """The nominal value and its declaration-owned comparison content.""" + declaration = type(value) + return (declaration.__module__, declaration.__qualname__, self.compared(value)) + + def compared(self, value: T) -> object: + return self.encode(value) + def decode_external(self, data: object, record: Mapping[str, object]) -> T: for decode in self.external_decoders(data): return self.decode_resolved(cast(T, decode(data, record))) @@ -1112,6 +1127,10 @@ def argument_options(self, argument: Argument) -> dict[str, object]: def encode(self, value: T) -> object: return None if value is None else self.representation.encode(value) + def compared(self, value: T) -> object: + # Wire spellings (including rename labels) do not own value comparison. + return _structural_decoder(type(value)).compared(value) + def accepts_value(self, value: object) -> bool: return self.structural.accepts_value(value) @@ -1134,6 +1153,9 @@ def encode(self, value: T) -> object: raise TypeError(f"Expected {target}, received {type(value).__name__}") return self.representation.encode(value) + def compared(self, value: T) -> object: + return self.encode(value) + class FieldRepresentation[T](MroDispatch, ABC): """A field capability, declared as ``Annotated[ValueType, Representation]``. @@ -2284,203 +2306,6 @@ def _projections(declaration: type, view: str) -> types.MappingProxyType[str, Pr -<<<<<<< HEAD -======= - target: object - - @property - def wire_children(self) -> tuple[DecoderPlan, ...]: - return () - - def fits_wire(self, data: object) -> bool: - """Check outer wire shape without decoding nested values. - - This may pass a value decoding rejects, but must never reject one decoding accepts. - """ - return True - - def shape_refusal(self, data: object) -> MalformedRecord: - """Why fits_wire refused data; it states a refusal and never decides one.""" - return MalformedRecord(f"Expected {self.target}, received {type(data).__name__}") - - def fits_external(self, data: object) -> bool: - """A sibling-aware external reader may accept more than the stored wire shape.""" - return any(self.external_decoders(data)) or self.fits_wire(data) - - def external_decoders(self, data: object) -> Iterable[ExternalDecoder]: - return () - - def selects_record(self, select: WireRecordFilter) -> bool: - return False - - def contains_records( - self, select: WireRecordFilter, seen: frozenset[object] = frozenset(), - ) -> bool: - """Whether this immutable plan reaches a selected declared record; no payload read.""" - if self in seen: - return False - return self.selects_record(select) or any( - child.contains_records(select, seen | {self}) for child in self.wire_children - ) - - def rewrite_records( - self, data: object, change: WireRecordChange, select: WireRecordFilter, - ) -> object: - """Transform declared raw records without decoding opaque JSON or losing other keys. - - The caller owns the record change. This decoder owns wire layout, child - membership and union selection. ``select`` is a pure declaration predicate. - """ - if data is None or not self.contains_records(select): - return data - return self._rewrite_records(data, change, select) - - def _rewrite_records( - self, data: object, change: WireRecordChange, select: WireRecordFilter, - ) -> object: - return data - - def rewrite_document( - self, data: object, change: WireRecordChange, select: WireRecordFilter, - ) -> object: - return self.rewrite_records(data, change, select) - -@dataclass(frozen=True, slots=True) -class ValueDecoder[T](DecoderPlan): - """One annotation owns its wire conversion, draft projection and choice domain.""" - - _by_origin: ClassVar[dict[object, type[ValueDecoder[object]]]] = {} - _by_annotation: ClassVar[dict[object, type[ValueDecoder[object]]]] = {} - - def __init_subclass__(cls): - super(ValueDecoder, cls).__init_subclass__() - for origin in cls.__dict__.get("origins", ()): - cls._by_origin[origin] = cls - for annotation in cls.__dict__.get("annotations", ()): - cls._by_annotation[annotation] = cls - - @classmethod - def from_annotation(cls, target: object, args: tuple[object, ...]) -> ValueDecoder[T]: - return cls(target) - - @classmethod - def annotation_class(cls, annotation: object) -> type[ValueDecoder[object]]: - try: - return cls._by_annotation[annotation] - except KeyError: - pass - declarations = annotation.__mro__ if isinstance(annotation, type) else (annotation,) - for declaration in declarations: - try: - return cls._by_origin[get_origin(declaration) or declaration] - except KeyError: - continue - raise KeyError(annotation) - - @classmethod - def with_representation( - cls, annotation: object, target: object, args: tuple[object, ...], - representation: type[FieldRepresentation], - ) -> ValueDecoder[T]: - return _RepresentedDecoder( - annotation, representation, - cast(ValueDecoder[object], cls.representation_plan(target, args, representation)), - ) - - @classmethod - def representation_plan( - cls, target: object, args: tuple[object, ...], representation: type[FieldRepresentation], - ) -> ValueDecoder[T]: - return cls.from_annotation(target, args) - - @classmethod - @cache - def for_members[M](cls, members: tuple[type[M], ...]) -> UnionDecoder[M]: - return UnionDecoder( - reduce(or_, members), - tuple(compile_decoder(member) for member in members), - ) - - @abstractmethod - def decode(self, data: object) -> T: - """Decode raw wire input; callers enter through decode_bound.""" - ... - - @abstractmethod - def encode(self, value: T) -> object: ... - - def comparison(self, value: T) -> object: - """The nominal value and its declaration-owned comparison content.""" - declaration = type(value) - return (declaration.__module__, declaration.__qualname__, self.compared(value)) - - def compared(self, value: T) -> object: - return self.encode(value) - - def decode_external(self, data: object, record: Mapping[str, object]) -> T: - for decode in self.external_decoders(data): - return self.decode_resolved(cast(T, decode(data, record))) - return self.decode_bound(data, record) - - def decode_resolved(self, value: T) -> T: - if not self.accepts_value(value): - raise MalformedRecord(f"Resolved value does not match {self.target}") - return value - - def accepts_value(self, value: object) -> bool: - """Select an in-process union case using its compiled declaration.""" - return isinstance(value, self.target) - - def accepts_external_value(self, value: object) -> bool: - """A structural container match does not validate its contents.""" - return False - - def encode_document(self, value: T) -> object: - return self.encode(value) - - def decode_document(self, data: object) -> T: - return self.decode_bound(data, {}) - - def decode_bound(self, data: object, context: Mapping[str, object]) -> T: - return self.decode(data) - - field_names: ClassVar[tuple[str, ...]] = () - - @property - def uses_representation(self) -> bool: - return False - - def decode_selected(self, data, selections, *, fields: tuple[str, ...]): - return self.decode_bound(data, {}) - - def decode_keys(self, data, keys, selections): - raise TypeError("Selected keys require a mapping field") - - editor_annotation = str - - def encode_editor(self, value): - encoded = FieldCodec.encode(value, self.target) - return json.dumps(encoded) - - @boundary_decoder - def decode_editor(self, value): - return None if value is None else json.loads(value) - - def editor_choices(self) -> tuple[tuple[str, object], ...]: - raise TypeError("The declared decoder does not offer finite choices") - - def argument_options(self, argument: Argument) -> dict[str, object]: - """argparse options for this annotation's command-line text.""" - return {} - - def argument_text(self, encoded: object) -> object: - """The command-line spelling of an encoded value.""" - return encoded - - def argument_payload(self, text: object) -> object: - """The payload value that command-line text spells.""" - return text ->>>>>>> origin/main class _ContextDecoder[T](ValueDecoder[T]): @@ -2608,58 +2433,6 @@ def accepts(self, data: object) -> TypeGuard[float]: -<<<<<<< HEAD -======= - @property - def wire_structure(self) -> DecoderPlan: - return self.representation.wire_structure(self.structural) - - @property - def wire_children(self) -> tuple[DecoderPlan, ...]: - return (self.wire_structure,) - - @boundary_decoder - def fits_wire(self, data: object) -> bool: - # Null handlers can change the structural null contract; leave that decision to decode. - return data is None or self.wire_structure.fits_wire(data) - - def fits_external(self, data: object) -> bool: - # The representation reads through its wire structure, so an external read takes - # whatever that structure's external reader takes. - return ValueDecoder.fits_external(self, data) or self.wire_structure.fits_external(data) - - def shape_refusal(self, data: object) -> MalformedRecord: - return self.wire_structure.shape_refusal(data) - - def _rewrite_records( - self, data: object, change: WireRecordChange, select: WireRecordFilter, - ) -> object: - return self.wire_structure.rewrite_records(data, change, select) - - def external_decoders(self, data: object) -> Iterable[ExternalDecoder]: - return FieldCodec.external_decoders(self.representation) - - @property - def uses_representation(self) -> bool: - return True - - def argument_options(self, argument: Argument) -> dict[str, object]: - return self.representation.argument_options(argument) - - def encode(self, value: T) -> object: - return None if value is None else self.representation.encode(value) - - def compared(self, value: T) -> object: - # Wire spellings (including rename labels) do not own value comparison. - return _structural_decoder(type(value)).compared(value) - - def accepts_value(self, value: object) -> bool: - return self.structural.accepts_value(value) - - @boundary_decoder - def decode(self, data: object) -> T: - return self.representation.decode_field(data, self.structural) ->>>>>>> origin/main class _JsonValueRepresentation(FieldRepresentation[JsonValue]): From d09e1b5d76c215504b8bcf6662ede7641b048a56 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 20:31:16 -0400 Subject: [PATCH 16/32] Wrap the two digest assertions within the line limit --- tests/test_bus.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_bus.py b/tests/test_bus.py index 28b3a9f0f1..77fdafc584 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -139,11 +139,15 @@ def test_projection_digest_tracks_declared_column_spelling(projection_source: Pa related = "digits: ClassVar[int] = 12" assert original.count(related) == 1 codec.write_text(original.replace(related, "digits: ClassVar[int] = 13")) - assert json.loads(projection_source_digest(projection_source))["message_identity_source"] != before + assert ( + json.loads(projection_source_digest(projection_source))["message_identity_source"] != before + ) unrelated = 'spelling = re.compile("[0-9a-f]{7,40}")' assert original.count(unrelated) == 1 codec.write_text(original.replace(unrelated, 'spelling = re.compile("[0-9a-f]{8,40}")')) - assert json.loads(projection_source_digest(projection_source))["message_identity_source"] == before + assert ( + json.loads(projection_source_digest(projection_source))["message_identity_source"] == before + ) def test_nominal_message_id_column_stores_plain_text() -> None: From d1bb4bccaac7b2a3510e23b76406b81aa2ddab81 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 20:42:33 -0400 Subject: [PATCH 17/32] Mint valid message IDs in reference and missing-lookup fixtures --- tests/test_bus.py | 3 ++- tests/test_bus_history_seq.py | 4 ++-- tests/test_current_input_origin.py | 13 ++++++++----- tests/test_historical_notifications.py | 9 +++++---- tests/test_holder_standing_rules.py | 7 ++++--- tests/test_input_disposition.py | 4 ++-- tests/test_observer_death.py | 3 ++- 7 files changed, 25 insertions(+), 18 deletions(-) diff --git a/tests/test_bus.py b/tests/test_bus.py index 77fdafc584..a7bcc314a2 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -554,7 +554,8 @@ def test_unanswered_reference_selection_requires_the_recorded_original_and_exact with pytest.raises(RelationViolationError): bus.append(lambda seq, db: PublicRecord.from_message( Message("lead", "#comms", "Wrong original reference", InfoKind, seq=seq, - answers=(MessageReference(original.seq + offset, "wrong-hash"),)))) + answers=(MessageReference( + original.seq + offset, MessageId.mint("wrong-hash")),)))) assert bus.revision() == original.seq bus.append(lambda seq, db: PublicRecord.from_message( Message("another-unit", "#comms", "Someone else's answer", InfoKind, seq=seq, diff --git a/tests/test_bus_history_seq.py b/tests/test_bus_history_seq.py index 2b3ea23dd4..713a1af57c 100644 --- a/tests/test_bus_history_seq.py +++ b/tests/test_bus_history_seq.py @@ -15,7 +15,7 @@ from agent_comms.messages import BusRow, InfoKind, Message from agent_comms.comms import wire from agent_comms.errors import HumanSendRefusedError -from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.field_codec import FieldCodec, MalformedRecord, MessageId from agent_comms.historical_views import HistoryArchive from agent_comms.locked_store import RegistryFile from agent_comms.message_bus import MessageBus @@ -104,7 +104,7 @@ def test_message_identity_lookup_preserves_missing_and_ambiguous_refusal(tmp_pat original = messages.bus.lookup(1) assert messages.bus.lookup_message(original.message_id).reference == original.reference with pytest.raises(RowNotFoundError): - messages.bus.lookup_message("missing") + messages.bus.lookup_message(MessageId.mint("missing")) messages.bus.append(lambda seq, db: replace(original, seq=seq)) with pytest.raises(MalformedRecord, match="Expected one"): messages.bus.lookup_message(original.message_id) diff --git a/tests/test_current_input_origin.py b/tests/test_current_input_origin.py index 24c5c6981d..824e27abb0 100644 --- a/tests/test_current_input_origin.py +++ b/tests/test_current_input_origin.py @@ -14,7 +14,7 @@ from agent_comms.acp_extension import QueuePromptRequest, decode_request, encode_request from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import FieldCodec, TurnId +from agent_comms.field_codec import FieldCodec, MessageId, TurnId from agent_comms.goal_states import ActiveGoal, CompletedGoal, PausedGoal from agent_comms.goals import AbsentGoalCheckpoint, Goal, GoalRevision, NoGoal, PresentGoalCheckpoint from agent_comms.input_attempt import ReservedInput @@ -133,7 +133,8 @@ async def test_foreign_or_changed_origin_refused_before_any_reservation(live_own invalid = (replace(origin, root_id="f" * 32), replace(origin, author=replace(origin.author, created_at=origin.author.created_at + 1)), replace(origin, project="/another/project"), - WireInputOrigin(origin.root_id, MessageReference(1, "forged-reference"))) + WireInputOrigin(origin.root_id, MessageReference( + 1, MessageId.mint("forged-reference")))) for witness in invalid: with comms.registry.store.locked(), pytest.raises(RelationViolationError): with ExitStack() as ownership: @@ -166,7 +167,8 @@ async def test_historical_unknown_author_stays_neutral_and_human_scope_invalidat assert fact.for_owner(owner, comms.registry.snapshot()) == CurrentHumanInputTaskFact(authored) changed = replace(owner, worktree="/another/project") assert fact.for_owner(changed, comms.registry.snapshot()) == HumanInputTaskFact(authored) - routed = replace(restored, origin=WireInputOrigin(origin.root_id, MessageReference(1, "original-id"))) + routed = replace(restored, origin=WireInputOrigin( + origin.root_id, MessageReference(1, MessageId.mint("original-id")))) assert routed.origin.retained_fact(routed) == InputTaskFact(routed) assert FieldCodec.decode(ReservedInput, FieldCodec.encode(routed)) == routed @@ -272,8 +274,9 @@ def captured(name): comms.messaging.pin_input_constraint('beta', replace(subject, origin=replace(origin, root_id='f'*32)), worktree=origin.project) with pytest.raises(RelationViolationError): - comms.messaging.pin_input_constraint('beta', WireProvenance(MessageReference(1, 'not-input')), - worktree=origin.project) + comms.messaging.pin_input_constraint( + 'beta', WireProvenance(MessageReference(1, MessageId.mint('not-input'))), + worktree=origin.project) assert comms.bus.bus.revision() == before assert inputs.path.read_bytes() == original_inputs diff --git a/tests/test_historical_notifications.py b/tests/test_historical_notifications.py index 15488c73a2..c262b36cb2 100644 --- a/tests/test_historical_notifications.py +++ b/tests/test_historical_notifications.py @@ -19,7 +19,7 @@ ) from agent_comms.comms import wire from agent_comms.coordination_errors import DMScopeChanged, SchemaVersionError -from agent_comms.field_codec import TurnId +from agent_comms.field_codec import MessageId, TurnId from agent_comms.read_basis import HistoricalDisplay, ReadCursor from agent_comms.read_ledger import ReadLedger from agent_comms.thread_identity import AgentThreadRole, UserThreadRole @@ -192,9 +192,10 @@ def test_sent_recipient_progresses_through_durable_read_and_answer(archived_noti activity=read_activity, updated_at_ms=1_000, ) + reply_id = MessageId.mint("durable-reply") answered_activity = replace( read_activity, - response=AnsweredResponse(MessageReference(99, "durable-reply"), 3_000), + response=AnsweredResponse(MessageReference(99, reply_id), 3_000), ) answered = CompletedAssignment.build( FullWake(), "durable-execution", "sender" @@ -219,7 +220,7 @@ def test_sent_recipient_progresses_through_durable_read_and_answer(archived_noti observation=RecordedRecipientActivity(), activity=replace( sent_activity, - response=AnsweredResponse(MessageReference(99, "durable-reply"), 3_000), + response=AnsweredResponse(MessageReference(99, reply_id), 3_000), ), updated_at_ms=2_000, ) @@ -230,7 +231,7 @@ def test_sent_recipient_progresses_through_durable_read_and_answer(archived_noti assert answered.label.startswith("Answered ") and answered.answered_at_ms == 3_000 assert delivered.label.startswith("Read ") and delivered.state == ShownToModelProgress(2_000) assert delivered_answer.label.startswith("Answered ") - assert delivered_answer.answer == MessageReference(99, "durable-reply") + assert delivered_answer.answer == MessageReference(99, reply_id) assert sent.recipient_identity == read.recipient_identity == answered.recipient_identity diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index f6bf0f006a..f56088fff7 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -33,7 +33,7 @@ from agent_comms.service_passes import ServicePassStore from agent_comms.coordinator import Coordination from agent_comms.errors import CommittedMessageError, HumanSendRefusedError, RelationViolationError -from agent_comms.field_codec import FieldCodec, TurnId +from agent_comms.field_codec import FieldCodec, MessageId, TurnId from agent_comms.goal_actions import ( CreateProblemGoalAction, ReissuedOrderGoalAction, @@ -383,7 +383,7 @@ def test_latest_answer_uses_the_complete_original_reference_and_time_cut(held_bu publish(held_bus, "lead", "worker", since + 3) with pytest.raises(RelationViolationError): publish(held_bus, "lead", "worker", since + 4, - answers=(MessageReference(question.seq, "wrong-original"),)) + answers=(MessageReference(question.seq, MessageId.mint("wrong-original")),)) expired = publish(held_bus, "worker", "lead", since - 1) publish(held_bus, "lead", "worker", since + 5, answers=(expired.reference,)) scope = RepliesDisplayScope(frozenset({"lead"}), frozenset({"worker"})) @@ -406,7 +406,8 @@ def test_unanswered_pages_match_the_complete_answer_reference(held_bus): for offset in range(105)) with pytest.raises(RelationViolationError): publish(held_bus, "lead", "worker", now, - answers=(MessageReference(questions[0].seq, "not-the-original-hash"),)) + answers=(MessageReference( + questions[0].seq, MessageId.mint("not-the-original-hash")),)) answered = (questions[1].reference, questions[-1].reference) held_bus.messaging.send("lead", "outsider", "Answers retain their original references", answers=answered) diff --git a/tests/test_input_disposition.py b/tests/test_input_disposition.py index bd68bc309a..a556eba221 100644 --- a/tests/test_input_disposition.py +++ b/tests/test_input_disposition.py @@ -7,7 +7,7 @@ import pytest from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import TurnId +from agent_comms.field_codec import MessageId, TurnId from agent_comms.input_attempt import ( BoundUnknownInput, ClearedNativeInput, @@ -178,7 +178,7 @@ def test_admitted_snapshot_retains_cut_without_blocking_writer(tmp_path): ROOT, NATIVE, SENT = "c" * 32, "a" * 32, "sent text" -REFERENCE = MessageReference(7, "original-id") +REFERENCE = MessageReference(7, MessageId.mint("original-id")) RESERVED = ReservedInput("bus:7", 7, "owner", 1, "owner", "ask", origin=WireInputOrigin(ROOT, REFERENCE)) BOUND = RESERVED._transition( diff --git a/tests/test_observer_death.py b/tests/test_observer_death.py index c59c228696..5431fa97dd 100644 --- a/tests/test_observer_death.py +++ b/tests/test_observer_death.py @@ -10,6 +10,7 @@ from agent_comms.active_route import LocalRoute from agent_comms.coordination_errors import StaleRevision from agent_comms.errors import RelationViolationError, UnregisteredThreadError +from agent_comms.field_codec import MessageId from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.owner_connection import OwnerIdentityChangedError from agent_comms.message_reference import MessageReference @@ -63,7 +64,7 @@ async def test_an_invalid_notification_reference_does_not_kill_the_observer(comm with ObservationProcess.awaited(comms.route) as process: with pytest.raises(RelationViolationError): await process.read(ReadNotifications( - str(comms.root), (MessageReference(1, "not-the-original"),) + str(comms.root), (MessageReference(1, MessageId.mint("not-the-original")),) )) assert await process.read(ReadNotifications(str(comms.root), ())) == {} From 70d24a460a68fe7d2dd935b2461314c07e068012 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 20:43:51 -0400 Subject: [PATCH 18/32] Require the minted input ID and declare the absence of an observed turn --- src/agent_comms/queued_input.py | 8 ++-- src/agent_comms/thread_identity.py | 17 ++++++- src/agent_comms/threads.py | 3 +- src/agent_comms/turn_lease.py | 19 +++++--- src/agent_comms/turn_runner.py | 2 +- tests/goal_stop_journey.py | 9 +++- tests/test_current_input_origin.py | 50 ++++++++++++++++---- tests/test_mcp_acceptance.py | 24 ++++++---- tests/test_native_channel_reply_roundtrip.py | 3 +- tests/test_owned_turn_goal_replacement.py | 6 +-- tests/test_retained_context_operations.py | 4 +- tests/test_stack_native_compaction.py | 5 +- tests/test_stack_persistent_compaction.py | 20 ++++++-- tests/test_turn_declaration.py | 9 +++- 14 files changed, 133 insertions(+), 46 deletions(-) diff --git a/src/agent_comms/queued_input.py b/src/agent_comms/queued_input.py index 4a6f8f44aa..5fea25c358 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -118,7 +118,7 @@ def capture( images: tuple[ImageInput, ...], controller: Controller | None, ownership: ExitStack, - input_id: InputId | None = None, + input_id: InputId, origin: InputOrigin = UnattributedInputOrigin(), ) -> tuple[Self, InputAttempt]: """Called under the registry lock; acceptance follows the durable reservation.""" @@ -134,7 +134,7 @@ def capture( text or prompt or "[image prompt]", echo, context, - InputId.mint() if input_id is None else input_id, + input_id, prompt, images, controller, @@ -165,7 +165,7 @@ def require_capacity(cls, inputs: InputDrain, sources: tuple[TurnInputSource, .. async def reserve( cls, inputs: InputDrain, session_id: str, name: str, *, text: str, prompt: str, echo: bool, images: tuple[ImageInput, ...], controller: Controller | None, - input_id: InputId | None = None, + input_id: InputId, origin: InputOrigin = UnattributedInputOrigin(), ) -> AsyncIterator[InputReservation[Self]]: """Serialize local queue placement; reserve durable input in a worker.""" @@ -547,7 +547,7 @@ def require_capacity(cls, inputs: InputDrain, sources: tuple[TurnInputSource, .. async def run( cls, inputs: InputDrain, session_id: str, thread_name: str, task: str, *, images: tuple[ImageInput, ...] = (), display_text: str | None = None, - input_id: InputId | None = None, + input_id: InputId, origin: InputOrigin = UnattributedInputOrigin(), ) -> None: """Own original reservation, dispatch and unbound retirement as one lifetime.""" diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 56fc4c146f..b6d614cdb2 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -509,8 +509,15 @@ def resolved(self, snapshot: RegistryProvenance) -> TurnIdentity: return replace(self, incarnation=self.incarnation.resolved(snapshot)) +class ObservedTurn(ABC): + """What a thread can witness about its current or last-completed turn.""" + + @abstractmethod + def can_attest(self, claim_scope: int) -> bool: ... + + @dataclass(frozen=True, slots=True) -class TurnFence: +class TurnFence(ObservedTurn): """One exact turn identity and its recorded input generation.""" identity: TurnIdentity @@ -544,6 +551,14 @@ def __eq__(self, other: object) -> bool: ) +@dataclass(frozen=True, slots=True) +class NoObservedTurn(ObservedTurn): + """No turn has run on this thread; it attests nothing and matches no fence.""" + + def can_attest(self, claim_scope: int) -> bool: + return False + + @dataclass(slots=True) class GenerationCounter(ProjectionSource): """One monotonic allocation domain, including deleted-name tombstones.""" diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index e8df36ca57..5b8d7a9c17 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -62,6 +62,7 @@ from .thread_execution import NativeThreadExecution, ThreadExecution from .thread_identity import ( AgentThreadRole, + ObservedTurn, OfferedTools, RegisteredTurn, ThreadIncarnation, @@ -599,7 +600,7 @@ def turn_identity(self) -> TurnIdentity: """The turn generation this thread's registry entry currently names.""" return TurnIdentity(self.incarnation, self.turn_generation) - def observed_turn(self, claim_scope: int) -> TurnFence | None: + def observed_turn(self, claim_scope: int) -> ObservedTurn: """Passive current/last-completed witness; never a begin-turn grant.""" return self.turn_state.active.observed_turn( self.turn_state.finished, self.turn_identity, claim_scope, diff --git a/src/agent_comms/turn_lease.py b/src/agent_comms/turn_lease.py index 8fca4b1c60..8c687049a7 100644 --- a/src/agent_comms/turn_lease.py +++ b/src/agent_comms/turn_lease.py @@ -26,7 +26,14 @@ from .locked_store import DurableFile, JsonDocument, PatternFile from .routing import TurnRouting from .store_files import _store_lock -from .thread_identity import RegisteredTurn, ThreadIncarnation, TurnFence, TurnIdentity +from .thread_identity import ( + NoObservedTurn, + ObservedTurn, + RegisteredTurn, + ThreadIncarnation, + TurnFence, + TurnIdentity, +) from .turn_phase import IdlePhase, PreparingPhase, TurnPhase if TYPE_CHECKING: @@ -76,7 +83,7 @@ def without_claim(self) -> TurnDeclaration: ... @abstractmethod def observed_turn( self, finished: TurnRecord, identity: TurnIdentity, claim_scope: int, - ) -> TurnFence | None: ... + ) -> ObservedTurn: ... @abstractmethod def started_by(self, updated_at_ms: int) -> bool: ... @@ -121,7 +128,7 @@ def without_claim(self) -> NoTurn: def observed_turn( self, finished: TurnRecord, identity: TurnIdentity, claim_scope: int, - ) -> TurnFence | None: + ) -> ObservedTurn: return finished.observed_turn(identity, claim_scope) def started_by(self, updated_at_ms: int) -> bool: @@ -438,7 +445,7 @@ def require_generation(self, generation: int) -> None: @abstractmethod - def observed_turn(self, identity: TurnIdentity, claim_scope: int) -> TurnFence | None: ... + def observed_turn(self, identity: TurnIdentity, claim_scope: int) -> ObservedTurn: ... @dataclass(frozen=True) class NoTurnRecord(NullMember, TurnRecord): @@ -457,8 +464,8 @@ def lease(self, incarnation: ThreadIncarnation, process: ProcessState, return NoTurnLease() - def observed_turn(self, identity: TurnIdentity, claim_scope: int) -> None: - return None + def observed_turn(self, identity: TurnIdentity, claim_scope: int) -> NoObservedTurn: + return NoObservedTurn() @dataclass(frozen=True) class FinishedTurn(RecordMember, TurnRecord): diff --git a/src/agent_comms/turn_runner.py b/src/agent_comms/turn_runner.py index 91725041d4..4e7c818b42 100644 --- a/src/agent_comms/turn_runner.py +++ b/src/agent_comms/turn_runner.py @@ -184,7 +184,7 @@ async def prompt_owned( *, images: tuple[ImageInput, ...], display_text: str | None = None, - input_id: InputId | None = None, + input_id: InputId, origin: InputOrigin = UnattributedInputOrigin(), ) -> PromptResponse: session_id = request.session_id diff --git a/tests/goal_stop_journey.py b/tests/goal_stop_journey.py index fd026e2b44..06cfad2180 100644 --- a/tests/goal_stop_journey.py +++ b/tests/goal_stop_journey.py @@ -20,6 +20,7 @@ from agent_comms.native_arguments import GivenArguments from native_backend_fixture import native_backend_fixture, wait_for from source_programs import SourcePrograms +from agent_comms.thread_identity import NoObservedTurn from agent_comms.threads import Thread @@ -70,12 +71,16 @@ async def stopped_goal_journey( def continued_turn_finished(): thread = comms.registry.require("stop-copy") observed = thread.observed_turn(thread.claimed_generation) - return not thread.executing and observed is not None and observed != stopped_turn + return ( + not thread.executing + and observed != NoObservedTurn() + and observed != stopped_turn + ) await wait_for(continued_turn_finished) continued = comms.registry.require("stop-copy") continued_turn = continued.observed_turn(continued.claimed_generation) - assert continued_turn is not None + assert continued_turn != NoObservedTurn() with closing(store._connect()) as db: finished = AttemptRecord.one(db, attempt_id=resumed.attempt_id) assert finished is not None and isinstance(finished.phase, SucceededAttempt) diff --git a/tests/test_current_input_origin.py b/tests/test_current_input_origin.py index 24c5c6981d..a05e6e4dfe 100644 --- a/tests/test_current_input_origin.py +++ b/tests/test_current_input_origin.py @@ -14,7 +14,7 @@ from agent_comms.acp_extension import QueuePromptRequest, decode_request, encode_request from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import FieldCodec, TurnId +from agent_comms.field_codec import InputId, FieldCodec, TurnId from agent_comms.goal_states import ActiveGoal, CompletedGoal, PausedGoal from agent_comms.goals import AbsentGoalCheckpoint, Goal, GoalRevision, NoGoal, PresentGoalCheckpoint from agent_comms.input_attempt import ReservedInput @@ -137,13 +137,33 @@ async def test_foreign_or_changed_origin_refused_before_any_reservation(live_own for witness in invalid: with comms.registry.store.locked(), pytest.raises(RelationViolationError): with ExitStack() as ownership: - QueuedInput.capture(agent.inputs, "beta", text="Original", prompt="Original", - echo=True, images=(), controller=Client(), origin=witness, ownership=ownership) + QueuedInput.capture( + agent.inputs, + "beta", + text="Original", + prompt="Original", + echo=True, + images=(), + controller=Client(), + origin=witness, + ownership=ownership, + input_id=InputId.mint(), + ) ownership.pop_all() with comms.registry.store.locked(), pytest.raises(RelationViolationError): with ExitStack() as ownership: - QueuedInput.capture(agent.inputs, "beta", text="Original", prompt="Original", - echo=True, images=(), controller=None, origin=origin, ownership=ownership) + QueuedInput.capture( + agent.inputs, + "beta", + text="Original", + prompt="Original", + echo=True, + images=(), + controller=None, + origin=origin, + ownership=ownership, + input_id=InputId.mint(), + ) ownership.pop_all() assert stored_inputs(agent.inputs.dispositions.root).rows == {} @@ -199,15 +219,25 @@ async def test_original_goal_checkpoint_survives_codec_and_refuses_changed_scope with comms.registry.store.locked(), pytest.raises(RelationViolationError): with ExitStack() as ownership: - QueuedInput.capture(agent.inputs, "beta", text="Original", prompt="Original", - echo=True, images=(), controller=Client(), origin=absent, ownership=ownership) + QueuedInput.capture( + agent.inputs, + "beta", + text="Original", + prompt="Original", + echo=True, + images=(), + controller=Client(), + origin=absent, + ownership=ownership, + input_id=InputId.mint(), + ) ownership.pop_all() assert stored_inputs(agent.inputs.dispositions.root).rows == {} async with QueuedInput.reserve( agent.inputs, "beta", "beta", text="Original", prompt="Original", echo=True, images=(), controller=Client(), origin=restored, - ) as reservation: + input_id=InputId.mint()) as reservation: queued = reservation.item reservation.release() assert stored_inputs(agent.inputs.dispositions.root).lookup(queued.key).origin == restored @@ -232,7 +262,7 @@ async def test_direct_human_input_pin_shares_original_source_lineage_without_rep async with QueuedInput.reserve( agent.inputs, "beta", "beta", text=exact, prompt=exact, echo=True, images=(), controller=Client(), origin=origin, - ) as reservation: + input_id=InputId.mint()) as reservation: queued = reservation.item reservation.release() inputs = agent.inputs.dispositions @@ -281,7 +311,7 @@ def captured(name): async with QueuedInput.reserve( agent.inputs, "beta", "beta", text=exact, prompt=exact, echo=True, images=(), controller=Client(), origin=origin, - ) as reservation: + input_id=InputId.mint()) as reservation: second = reservation.item reservation.release() second_subject = stored_inputs(inputs.root).lookup(second.key).context_provenance() diff --git a/tests/test_mcp_acceptance.py b/tests/test_mcp_acceptance.py index f17135d156..86b1b45981 100644 --- a/tests/test_mcp_acceptance.py +++ b/tests/test_mcp_acceptance.py @@ -39,6 +39,7 @@ from agent_comms.field_codec import FieldCodec from agent_comms.native_arguments import GivenArguments, GivenBinary from agent_comms.runtime import RuntimeProxy +from agent_comms.thread_identity import NoObservedTurn, TurnFence from delivery_owner_fixture import canonical_agent pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX PTY acceptance") @@ -358,7 +359,7 @@ async def test_real_pi_mcp_acp_link(case, tmp_path, monkeypatch, native_runtime_ if case != "revoke_midturn": release_final.set() attachment_settled = asyncio.Event() - attachment_turn = None + attachment_turn = NoObservedTurn() updates, permissions = [], [] entered, release = asyncio.Event(), asyncio.Event() task = proxy = owner = None @@ -456,11 +457,11 @@ async def publish_update(self, session_id, update): thread.claimed_generation, ) if fact.state.active.phase.busy: - if attachment_turn is None: + if attachment_turn == NoObservedTurn(): attachment_turn = observed else: assert observed == attachment_turn - elif attachment_turn is not None: + elif attachment_turn != NoObservedTurn(): assert observed == attachment_turn attachment_settled.set() return True @@ -569,7 +570,7 @@ async def request_permission(self, **kwargs): assert calls.exists() == (case == "allow") if calls.exists(): assert calls.read_text().splitlines() == ["ACCEPTANCE_ECHO"] - active, receipts, settlements = None, [], [] + active, receipts, settlements = NoObservedTurn(), [], [] for row in updates: assert row["sessionId"] == session_id for fact in decode_updates(row["update"].get("_meta")): @@ -582,18 +583,23 @@ async def request_permission(self, **kwargs): thread.claimed_generation, ) if fact.state.active.phase.busy: - if active is None: + if active == NoObservedTurn(): active = observed else: assert observed == active - elif active is not None: + elif active != NoObservedTurn(): assert observed == active settlements.append(fact.state) - active = None + active = NoObservedTurn() if isinstance(fact, McpClientAcknowledgementUpdate): - assert active is not None and active.turn_id == fact.turn_id + assert active != NoObservedTurn() + assert active == TurnFence( + thread.turn_identity, fact.turn_id, thread.claimed_generation, + ) receipts.append(fact.acknowledgement) - assert active is None and len(settlements) == len(receipts) == 1, updates + assert ( + active == NoObservedTurn() and len(settlements) == len(receipts) == 1 + ), updates assert [FieldCodec.encode(server) for server in receipts[0].servers] == [ { "id": "fixture", diff --git a/tests/test_native_channel_reply_roundtrip.py b/tests/test_native_channel_reply_roundtrip.py index a8423ec4b0..cfe4ff8024 100644 --- a/tests/test_native_channel_reply_roundtrip.py +++ b/tests/test_native_channel_reply_roundtrip.py @@ -33,6 +33,7 @@ from agent_comms.pi_vocabulary import OffThinkingLevel from agent_comms.restart_scope import OwnersNamed from agent_comms.thread_model import DeclaredModel +from agent_comms.thread_identity import NoObservedTurn from agent_comms.threads import Thread from bus_history import read_history from bus_record_fixture import prepare_native_comms @@ -392,7 +393,7 @@ def selected_turn_finished(): not bool(state.active.items_with(Executing, state.active)) and state.active.observed_turn( state.finished, owner.turn_identity, owner.claimed_generation, - ) is not None + ) != NoObservedTurn() for state in states ) diff --git a/tests/test_owned_turn_goal_replacement.py b/tests/test_owned_turn_goal_replacement.py index 9c46d1b6fa..ae00a344df 100644 --- a/tests/test_owned_turn_goal_replacement.py +++ b/tests/test_owned_turn_goal_replacement.py @@ -13,7 +13,7 @@ from agent_comms.channel_input_batch import InputBatch from agent_comms.child_process import ProcessIdentity from conftest import installed_comms -from agent_comms.field_codec import TurnId +from agent_comms.field_codec import InputId, TurnId from agent_comms.goal_actions import ( BlockedGoalAction, CreateProblemGoalAction, OrderGoalAction, ) @@ -67,7 +67,7 @@ async def test_turn_after_order_replacement_is_allowed(tmp_path, native_runtime_ task = asyncio.create_task(InitialInput.run( agent.inputs, session_id, worker, 'Original input before replacement', display_text='Original input before replacement', - )) + input_id=InputId.mint())) try: await wait_for(lambda: saved.provider.posts == 1) sender = backend.TurnSession.active[task].send_boundary @@ -86,7 +86,7 @@ async def test_turn_after_order_replacement_is_allowed(tmp_path, native_runtime_ async with QueuedInput.reserve( agent.inputs, session_id, worker, text='Following input after replacement', prompt='Following input after replacement', echo=True, images=(), controller=None, - ) as reservation: + input_id=InputId.mint()) as reservation: item = reservation.item item.place(agent.inputs, session_id, agent.inputs.state(session_id).require_inbox(), InputContent(text=item.prompt)) diff --git a/tests/test_retained_context_operations.py b/tests/test_retained_context_operations.py index ee4da923ae..2e9fccccaf 100644 --- a/tests/test_retained_context_operations.py +++ b/tests/test_retained_context_operations.py @@ -13,7 +13,7 @@ from agent_comms.session_file import RegistrySessionLocation from agent_comms.cli import main from agent_comms.cli_commands import ConstraintPinnedResult -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import InputId, FieldCodec from agent_comms.context_segments.retained import RetainedSegment from agent_comms.turn_lease import NoTurnLease from stored_inputs import stored_inputs @@ -142,7 +142,7 @@ async def original_input_consumer_journey(tmp_path, command): async with QueuedInput.reserve( agent.inputs, "beta", "beta", text=wording, prompt=wording, echo=True, images=(), controller=Client(), origin=origin, - ) as reservation: + input_id=InputId.mint()) as reservation: originals.append(reservation.source) reservation.release() assert agent.inputs.dispositions.record( diff --git a/tests/test_stack_native_compaction.py b/tests/test_stack_native_compaction.py index 32bc1aeb32..0118f76bcb 100644 --- a/tests/test_stack_native_compaction.py +++ b/tests/test_stack_native_compaction.py @@ -1,6 +1,7 @@ """Pi's own compaction engine, seen through Core's turn path: pinned Pi + localhost provider.""" from __future__ import annotations +from agent_comms.field_codec import InputId from agent_comms.controller import SessionUpdateSink from agent_comms.lifecycle_capability import Unresolved @@ -319,7 +320,9 @@ async def publish_update(self, session_id, update): owner.inputs.state("project").drain_task.cancel() await asyncio.gather(owner.inputs.state("project").drain_task, return_exceptions=True) comms.threads.attach_session((attachment_owner := comms.registry.require("project")), str(session), process=attachment_owner.process_identity) - turn = asyncio.create_task(InitialInput.run(owner.inputs, "project", "project", "Reply OK.")) + turn = asyncio.create_task( + InitialInput.run(owner.inputs, "project", "project", "Reply OK.", input_id=InputId.mint()) + ) try: entered = await asyncio.to_thread(summary_entered.wait, 10) assert entered diff --git a/tests/test_stack_persistent_compaction.py b/tests/test_stack_persistent_compaction.py index 078facbd07..fec5f62279 100644 --- a/tests/test_stack_persistent_compaction.py +++ b/tests/test_stack_persistent_compaction.py @@ -1,4 +1,5 @@ """A manual /compact runs Pi's own engine in the retained child, which keeps serving.""" +from agent_comms.field_codec import InputId from agent_comms.controller import SessionUpdateSink import asyncio @@ -224,12 +225,22 @@ async def publish_update(self, **kwargs): try: await owner.new_session(str(project)) comms.threads.attach_session((attachment_owner := comms.registry.require("worker")), str(session), process=attachment_owner.process_identity) - await asyncio.wait_for(InitialInput.run(owner.inputs, "worker", "worker", "WARMUP"), 20) + await asyncio.wait_for( + InitialInput.run( + owner.inputs, "worker", "worker", "WARMUP", input_id=InputId.mint() + ), + 20, + ) retained = owner.turns.persistent_backends["worker"].child_state.idle().child.proc assert retained is not None and retained.returncode is None, json.dumps( updates, indent=2 ) - await asyncio.wait_for(InitialInput.run(owner.inputs, "worker", "worker", "REUSE"), 20) + await asyncio.wait_for( + InitialInput.run( + owner.inputs, "worker", "worker", "REUSE", input_id=InputId.mint() + ), + 20, + ) assert owner.turns.persistent_backends["worker"].child_state.idle().child.proc is retained assert retained.returncode is None assert len(requests) == 2 @@ -297,7 +308,10 @@ async def publish_update(self, **kwargs): assert summary in compactions[0]["summary"] assert compactions[0]["firstKeptEntryId"] == committed[0].first_kept await asyncio.wait_for( - InitialInput.run(owner.inputs, "worker", "worker", "AFTER_COMPACT"), 20 + InitialInput.run( + owner.inputs, "worker", "worker", "AFTER_COMPACT", input_id=InputId.mint() + ), + 20, ) resumed = owner.turns.persistent_backends["worker"].child_state.idle().child.proc assert resumed is retained and resumed.returncode is None diff --git a/tests/test_turn_declaration.py b/tests/test_turn_declaration.py index 1ca31d7a09..916ee8bc92 100644 --- a/tests/test_turn_declaration.py +++ b/tests/test_turn_declaration.py @@ -6,6 +6,7 @@ from agent_comms.errors import RelationViolationError from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.thread_identity import ( + NoObservedTurn, RecordedThreadIncarnation, RegisteredTurn, TurnFence, @@ -35,8 +36,12 @@ def test_idle_turn_answers_without_an_active_record(): assert not idle.started_by(10000) identity = TurnIdentity(RecordedThreadIncarnation("owner", 1.0), 1) turn_id = TurnId.mint() - assert idle.observed_turn(NoTurnRecord(), identity, 1) is None - assert idle.observed_turn(FinishedTurn(turn_id), identity, 1) == TurnFence(identity, turn_id, 1) + missing = NoObservedTurn() + fence = TurnFence(identity, turn_id, 1) + assert idle.observed_turn(NoTurnRecord(), identity, 1) == missing + assert idle.observed_turn(FinishedTurn(turn_id), identity, 1) == fence + assert missing == NoObservedTurn() and not missing.can_attest(1) + assert missing != fence and fence != missing assert idle.without_claim() is idle assert not tuple(idle.phase_effects(ToolRunningPhase())) idle.validate_owner(NoProcess(), 0) From 25f60e50eb1c4ee30e3f473df82e4423b7d22132 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 21:06:33 -0400 Subject: [PATCH 19/32] Give accepted turns their queue behavior and pass native start listeners directly --- src/agent_comms/agent_backend.py | 27 ++++- src/agent_comms/backend.py | 53 +--------- src/agent_comms/following_inputs.py | 2 +- src/agent_comms/owned_send.py | 51 +++++---- src/agent_comms/owned_turn.py | 19 ++-- src/agent_comms/pi_native_backend.py | 2 +- src/agent_comms/plan_phases.py | 2 +- src/agent_comms/queued_input.py | 8 +- src/agent_comms/schedule_rules.py | 2 + src/agent_comms/started_input.py | 15 +-- src/agent_comms/tracked_turn.py | 4 +- src/agent_comms/turn_input_source.py | 100 +++++++++++++++++- src/agent_comms/turn_runner.py | 5 +- src/agent_comms/worker.py | 2 + tests/native_backend_fixture.py | 24 ++++- tests/pi_session_turn.py | 2 + tests/test_absence_owners.py | 23 +++- tests/test_atomic_turn_source_owner.py | 6 +- tests/test_channel_bus_ownership.py | 7 +- tests/test_goal_execution.py | 3 + .../test_native_retry_exhaustion_installed.py | 4 +- tests/test_ordinary_admission_rules.py | 3 +- tests/test_owned_turn_goal_replacement.py | 10 +- tests/test_private_idle_drain.py | 5 +- tests/test_stack_native_compaction.py | 4 +- tests/test_tracked_native_retry.py | 4 +- 26 files changed, 254 insertions(+), 133 deletions(-) diff --git a/src/agent_comms/agent_backend.py b/src/agent_comms/agent_backend.py index 8f053662e8..310f4d86fc 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -281,6 +281,31 @@ class StaleCompaction(CompactionOutcome): # --------------------------------------------------------------------------- +class InputStartListener(ABC): + """The listener owns acknowledgement and the corresponding start notification.""" + + started_events: tuple[AgentEvent, ...] + + @abstractmethod + def original(self, native_id: str, text: str) -> bool: ... + + @abstractmethod + def following(self, public_id: InputId, native_id: str, text: str) -> bool: ... + + +class NoInputStartListener(InputStartListener): + started_events = () + + def original(self, native_id: str, text: str) -> bool: + return True + + def following(self, public_id: InputId, native_id: str, text: str) -> bool: + return True + + +NO_INPUT_START_LISTENER = NoInputStartListener() + + @dataclass(frozen=True, kw_only=True) class TurnDelivery: """Core's bindings for one turn, handed to the backend that runs it. @@ -299,7 +324,7 @@ class TurnDelivery: finish_event: asyncio.Event send_boundary: SendBoundary interrupt_boundary: SendBoundary - input_started: Callable[..., bool] + input_start: InputStartListener request_progress: Callable[..., None] ui_request: Callable[[DialogUiRequest], Awaitable[ExtensionUiChoice]] tool_mode: NativeToolMode diff --git a/src/agent_comms/backend.py b/src/agent_comms/backend.py index 76f886c406..61da3c0354 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -43,7 +43,7 @@ from . import pi_events as pi from . import turn_failure as failures from .acp_failure import UnrecordedFailureSource -from .agent_backend import TurnInbox +from .agent_backend import NO_INPUT_START_LISTENER, InputStartListener, TurnInbox from .coordinator import Coordination from .extension_ui import ExtensionUiSession from .field_codec import InputId, MalformedRecord, boundary_decoder @@ -118,8 +118,8 @@ async def stream_agent_events( rpc_abort_grace: float = RPC_ABORT_GRACE_SECONDS, send_boundary: SendBoundary | None = None, interrupt_boundary: SendBoundary | None = None, - native_start: Callable[[str | None, str, str], bool] | None = None, *, + input_start: InputStartListener, persistent_session: PersistentPiSession, package: VerifiedNativePackage, root: Path, @@ -171,7 +171,7 @@ async def stream_agent_events( model_wait=model_wait, rpc_abort_grace=rpc_abort_grace, send_boundary=send_boundary, - input_start=InputStartListener.from_callback(native_start), + input_start=input_start, interrupt_boundary=interrupt_boundary, native_session=persistent_session, ui_request=ui_request, @@ -183,53 +183,6 @@ async def stream_agent_events( yield event -class InputStartListener(ABC): - """The listener owns acknowledgement and the corresponding start notification.""" - - started_events: tuple[events.AgentEvent, ...] - - @classmethod - @boundary_decoder - def from_callback( - cls, report: Callable[[str | None, str, str], bool] | None, - ) -> InputStartListener: - if report is None: - return NoInputStartListener() - return ReportInputStart(partial(report, None), report) - - @abstractmethod - def original(self, native_id: str, text: str) -> bool: ... - - @abstractmethod - def following(self, public_id: str, native_id: str, text: str) -> bool: ... - - -class NoInputStartListener(InputStartListener): - started_events = () - - def original(self, native_id: str, text: str) -> bool: - return True - - def following(self, public_id: str, native_id: str, text: str) -> bool: - return True - - -@dataclass(frozen=True) -class ReportInputStart(InputStartListener): - on_original: Callable[[str, str], bool] - on_following: Callable[[str, str, str], bool] - started_events = (events.OriginalInputStarted(),) - - def original(self, native_id: str, text: str) -> bool: - return self.on_original(native_id, text) - - def following(self, public_id: str, native_id: str, text: str) -> bool: - return self.on_following(public_id, native_id, text) - - -NO_INPUT_START_LISTENER = NoInputStartListener() - - class FinishSignal(ABC): """Own the optional external finish wait, without a nullable task in the reader.""" diff --git a/src/agent_comms/following_inputs.py b/src/agent_comms/following_inputs.py index 0c7eb387b4..f23dea0d43 100644 --- a/src/agent_comms/following_inputs.py +++ b/src/agent_comms/following_inputs.py @@ -92,7 +92,7 @@ async def open(self, session: TrackedTurnSession) -> None: thread=thread, turn=RegisteredTurn(lease.turn_id), claim_generation=lease.claimed_generation, - original=original, + original_source=original, goal_permit=NoLaunchPermit(), ) self.scope = LiveQueueScope( diff --git a/src/agent_comms/owned_send.py b/src/agent_comms/owned_send.py index 1f61f8249d..0fefc01b74 100644 --- a/src/agent_comms/owned_send.py +++ b/src/agent_comms/owned_send.py @@ -10,15 +10,15 @@ from typing import TYPE_CHECKING from .field_codec import InputId +from .agent_backend import InputStartListener +from . import agent_events as events from .channel_coding_tools import CodingToolOwner from .coordination_session import NativeBoundary from .coordinator import Coordination from .envelope_claim_transitions import NativeClaim from .input_attempt import InputAlreadyCleared from agent_comms.ordinary_turn_rules import ( - AcceptedInputCheck, InputKeysCheck, - MissingAcceptedInputCheck, MissingAcceptedInputRule, MissingTurnOwnerCheck, OrdinaryContextCheck, @@ -48,19 +48,21 @@ @dataclass(frozen=True, kw_only=True) -class OwnedSend(CodingToolOwner): +class OwnedSend(CodingToolOwner, InputStartListener): + started_events = (events.OriginalInputStarted(),) + inputs: InputDrain goal_store: GoalAttemptStore session_id: str thread: Thread turn: RegisteredTurn claim_generation: int - original: OriginalTurnInput + original_source: OriginalTurnInput goal_permit: GoalTurnPermit def source(self, public_id: InputId | None) -> TurnInputSource: if public_id is None: - return self.original + return self.original_source try: return self.inputs.state(self.session_id).following[public_id] except KeyError as error: @@ -112,24 +114,12 @@ def _prepare( self._check_owner(current, snapshot, canonical, source, sent_text) # The queue grants a send, not a running tool's lifetime. Once Pi # starts this exact input, StartedInput removes its queue entry. - if source.accepted_id is not None: - accepted = self.inputs.state(self.session_id).queued.get(source.accepted_id) - if accepted is None: - for row in self.inputs.dispositions.read(source.keys).rows.values(): - row.require_not_cleared() - MissingAcceptedInputCheck().require_valid() - AcceptedInputCheck( - acknowledgement=accepted, - current=current, - claim_generation=self.claim_generation, - input_id=source.accepted_id, - keys=source.keys, - inputs=self.inputs, - session_id=self.session_id, - ).require_valid() + source.acceptance.require_current( + self.inputs, self.session_id, source, current, self.claim_generation, + ) except ReservationViolationError as error: return self._refusal(error.rule) - if source.defers_for_goal(self.original.goal_permission, current.goal): + if source.defers_for_goal(self.original_source.goal_permission, current.goal): return DeferredSend() wait = self.comms.goals.goal_wait(canonical) try: @@ -172,7 +162,7 @@ def __call__( @property def goal_permission(self) -> TurnGoalPermission: - return self.original.goal_permission + return self.original_source.goal_permission def verify(self, store, turn, snapshot) -> None: """Recheck the running original without binding it or consuming its wait again.""" @@ -183,8 +173,12 @@ def verify(self, store, turn, snapshot) -> None: canonical = snapshot.canonical_name(self.thread.name) current = snapshot.threads.get(canonical) try: - self._check_owner(current, snapshot, canonical, self.original, turn.command.message) - self._require_context(self.original, current, self.comms.goals.goal_wait(canonical)) + self._check_owner( + current, snapshot, canonical, self.original_source, turn.command.message, + ) + self._require_context( + self.original_source, current, self.comms.goals.goal_wait(canonical), + ) except (ReservationViolationError, InputAlreadyCleared) as error: raise SelectedToolDenied(str(error)) from error @@ -210,8 +204,13 @@ def release() -> None: await Coordination.run_worker(release) - def native_start(self, public_id: InputId | None, native_id: str, sent_text: str) -> bool: - source = self.source(public_id) + def original(self, native_id: str, text: str) -> bool: + return self._started(self.original_source, native_id, text) + + def following(self, public_id: InputId, native_id: str, text: str) -> bool: + return self._started(self.source(public_id), native_id, text) + + def _started(self, source: TurnInputSource, native_id: str, sent_text: str) -> bool: return source.valid_keys(sent_text) and all( self.inputs.dispositions.started( key, turn_id=self.turn.value, native_id=native_id, text=sent_text diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index e7af0e96c1..fe49ce1820 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -18,7 +18,7 @@ from .agent_backend import InputContent, TurnDelivery from .channel_input_batch import InputBatch from .coordinator import Coordination -from .field_codec import InputId, TurnId +from .field_codec import TurnId from .goals import Goal from .image_input import ImageInput from .input_attempt import ReservedInput @@ -41,6 +41,7 @@ OwnerOriginalInput, RoutedOriginalInput, ScheduledOriginalInput, + TurnAcceptance, ) from .turn_phase import CancellingPhase, PromptAcceptancePhase from .turn_progress import TurnProgress @@ -90,7 +91,7 @@ def __init__( autonomous_goal: bool = False, original_owner_input: bool = False, original_goal_id: str | None = None, - accepted_input_id: InputId | None = None, + acceptance: TurnAcceptance, dependency_wait_id: str | None = None, ) -> None: self.runner = runner @@ -104,7 +105,7 @@ def __init__( self.autonomous_goal = autonomous_goal self.original_owner_input = original_owner_input self.original_goal_id = original_goal_id - self.accepted_input_id = accepted_input_id + self.acceptance = acceptance self.dependency_wait_id = dependency_wait_id self.terminal: TurnTerminal = EndedWithoutResult() @@ -253,7 +254,7 @@ def reserve_input(self, batch: InputBatch): else: source_type = RoutedOriginalInput original = source_type( - accepted_id=self.accepted_input_id, + acceptance=self.acceptance, goal_permission=TurnGoalPermission(permission), prompt=self.context.render().text, original_display=self.original_display, @@ -284,10 +285,8 @@ def open_stream(self, resources: AsyncExitStack, permits: ExitStack): self.runner.inputs.bind_native_turn(self.session_id, self.thread), ) self.finish_event = asyncio.Event() - self.controller = ( - self.runner.inputs.state(self.session_id).queued[self.accepted_input_id].controller - if self.accepted_input_id is not None - else self.runner.runtime.controller.get() + self.controller = self.acceptance.controller( + self.runner.inputs.state(self.session_id), self.runner.runtime, ) if self.controller is not None: self.controller = self.controller.attached(None) # Autonomous/channel/goal turns have no controller. @@ -348,7 +347,7 @@ async def stream(self): turn=RegisteredTurn(self.turn_id), claim_generation=self.turn_lease.claimed_generation, goal_permit=self.goal_permit, - original=replace(self.original, prompt=rendered.text), + original_source=replace(self.original, prompt=rendered.text), ) from .channel_coding_tools import CodingToolMode @@ -362,7 +361,7 @@ async def stream(self): interrupt_boundary=lambda public_id, native_id, text: sender( public_id, native_id, text, already_bound=True ), - input_started=sender.native_start, + input_start=sender, request_progress=self.progress.record_request_progress, ui_request=lambda request: self.runner.extension_ui_permission( self.session_id, self.turn_id, self.controller, request diff --git a/src/agent_comms/pi_native_backend.py b/src/agent_comms/pi_native_backend.py index 01d43da2e2..5da25e87c8 100644 --- a/src/agent_comms/pi_native_backend.py +++ b/src/agent_comms/pi_native_backend.py @@ -274,7 +274,7 @@ async def run_turn( finish_event=delivery.finish_event, send_boundary=delivery.send_boundary, interrupt_boundary=delivery.interrupt_boundary, - native_start=delivery.input_started, + input_start=delivery.input_start, request_observer=delivery.request_progress, persistent_session=self, package=package, diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index aa41adec3a..9e03dc99be 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -921,7 +921,7 @@ class InputIdsPhase(Id128NominalPhase): evidence = AllEvidence( ( PatternAbsent( - r"\b(input_id|accepted_input_id|native_id): " + r"\b(input_id|native_id): " r"(str\b|Id128\b|Annotated\[(str|Id128)\b)", Id128NominalPhase.paths, ), diff --git a/src/agent_comms/queued_input.py b/src/agent_comms/queued_input.py index 5fea25c358..729dc37ba5 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -33,7 +33,7 @@ from .thread_identity import ClaimedIdentity, ClaimScoped from .threads import Thread from .turn_goal_permission import AcceptedGoalPermission, TurnGoalPermission -from .turn_input_source import AcceptedFollowingInput, DeliveredFollowingInput +from .turn_input_source import AcceptedFollowingInput, AcceptedTurn, DeliveredFollowingInput if TYPE_CHECKING: from .acp_extension import QueueItem, QueueScope @@ -102,7 +102,7 @@ def queue_items(self, scope: QueueScope) -> tuple[QueueItem, ...]: def source(self) -> AcceptedFollowingInput: return AcceptedFollowingInput( keys=(self.key,), - accepted_id=self.input_id, + acceptance=AcceptedTurn(self.input_id), goal_permission=TurnGoalPermission(AcceptedGoalPermission(self.context.active_goal_id)), ) @@ -228,7 +228,7 @@ def verify(): initial_display_text=self.text if self.echo else None, original_owner_input=True, original_goal_id=self.context.active_goal_id, - accepted_input_id=self.input_id, + acceptance=AcceptedTurn(self.input_id), ) def is_live(self, inputs: InputDrain, session_id: str) -> bool: @@ -315,7 +315,7 @@ def queue_items(self, scope: QueueScope) -> tuple[QueueItem, ...]: def source(self) -> DeliveredFollowingInput: return DeliveredFollowingInput( keys=(self.key,), - accepted_id=self.input_id, + acceptance=AcceptedTurn(self.input_id), goal_permission=TurnGoalPermission(AcceptedGoalPermission(self.context.active_goal_id)), origins=(self.message,), ) diff --git a/src/agent_comms/schedule_rules.py b/src/agent_comms/schedule_rules.py index 0bca7e657a..78cced0bab 100644 --- a/src/agent_comms/schedule_rules.py +++ b/src/agent_comms/schedule_rules.py @@ -17,6 +17,7 @@ from .reservation_rules import ReservationRule, RuleCheck, ReservationViolationError from .routing import ScheduledTurn from .turn_outcome import TurnOutcome +from .turn_input_source import NoTurnAcceptance if TYPE_CHECKING: from .goals import GoalDeclaration @@ -138,6 +139,7 @@ async def run(self) -> WakeOutcome: session_id, inputs.sessions.require(session_id), "\n\n".join(turn.prompt for turn in pending), + acceptance=NoTurnAcceptance(), origins=tuple(origin for turn in pending for origin in turn.origin.messages()), autonomous_goal=len(pending) == 1 and pending[0].autonomous_goal, dependency_wait_id=pending[0].goal_wait_id, diff --git a/src/agent_comms/started_input.py b/src/agent_comms/started_input.py index a077e1e40b..b1357bfc70 100644 --- a/src/agent_comms/started_input.py +++ b/src/agent_comms/started_input.py @@ -6,7 +6,6 @@ from agent_comms.lifecycle_capability import Unresolved -from .acp_extension import InputStartedUpdate, NoQueueScope from .coordinator import Coordination if TYPE_CHECKING: @@ -29,16 +28,10 @@ async def started(self: TurnInputSource, inputs: InputDrain, session_id: str, sc stored = document.rows.get(key) if stored is not None and not stored.items_with(Unresolved, stored): await inputs.emit_input_disposition(session_id, stored) - item = inputs.state(session_id).queued.get(self.accepted_id) - text = await item.started(inputs) if item is not None else self.notice_text - public_id = row.public_id if self.notice_keys else self.accepted_id - current = await inputs.queue_binding(session_id) - if not scope.current_for(current): - current = NoQueueScope() await inputs.runtime.publish_update( - session_id=session_id, update=InputStartedUpdate( - public_id, text, current, current.start_revision(inputs, session_id), row.native_id, - ).acp_chunk(), + session_id=session_id, update=(await self.acceptance.started_update( + self, inputs, session_id, row, scope, + )).acp_chunk(), ) - inputs.state(session_id).queued.pop(self.accepted_id, None) + self.acceptance.release(inputs, session_id) await inputs.emit_queue_state(session_id) diff --git a/src/agent_comms/tracked_turn.py b/src/agent_comms/tracked_turn.py index 41431a8547..e45ee4419d 100644 --- a/src/agent_comms/tracked_turn.py +++ b/src/agent_comms/tracked_turn.py @@ -16,7 +16,7 @@ from . import pi_commands as commands from . import pi_events as pi from .agent_events import AgentEvent -from .backend import InputStartListener, TurnSession +from .backend import TurnSession from .coordinator import Coordination from .errors import RelationViolationError from .field_codec import MalformedRecord @@ -475,7 +475,7 @@ def forward_inbox(self, inbox: TurnInbox, sender: OwnedSend) -> None: self.inputs.queue = inbox self.send_boundary = sender self.interrupt_boundary = partial(sender, already_bound=True) - self.input_start = InputStartListener.from_callback(sender.native_start) + self.input_start = sender self.ensure_input_forwarding() async def accept_user_message( diff --git a/src/agent_comms/turn_input_source.py b/src/agent_comms/turn_input_source.py index 696818b51e..02dfdf52d0 100644 --- a/src/agent_comms/turn_input_source.py +++ b/src/agent_comms/turn_input_source.py @@ -7,25 +7,117 @@ from dataclasses import dataclass, replace from typing import TYPE_CHECKING, ClassVar -from .field_codec import InputId +from agent_comms.turn_goal_permission import GoalPermission, TurnGoalPermission + +from . import input_attempt +from .acp_extension import InputStartedUpdate, NoQueueScope from .agent_backend import BusyInputPolicy, SteerInputPolicy from .channel_input_batch import InputBatch, SingleInputBatch +from .field_codec import InputId from .messages import Message +from .ordinary_turn_rules import AcceptedInputCheck, MissingAcceptedInputCheck from .started_input import StartedInput -from agent_comms.turn_goal_permission import GoalPermission, TurnGoalPermission if TYPE_CHECKING: + from .acp_extension import QueueScope from .comms import Comms + from .controller import Controller from .goal_waits import GoalWait from .goals import GoalDeclaration from .input_disposition import InputDispositions + from .input_drain import DrainedSession, InputDrain + from .runtime import RuntimeServer from .thread_identity import RecordedThreadIncarnation, RegisteredTurn from .threads import Thread +class TurnAcceptance(ABC): + """The live ACP acknowledgement, when this turn has one.""" + + def controller(self, state: DrainedSession, runtime: RuntimeServer) -> Controller | None: + return runtime.controller.get() + + @abstractmethod + def require_current( + self, inputs: InputDrain, session_id: str, source: TurnInputSource, + current: Thread, claim_generation: int, + ) -> None: ... + + async def started_update( + self, source: TurnInputSource, inputs: InputDrain, session_id: str, + row: input_attempt.StartedInput, scope: QueueScope, + ) -> InputStartedUpdate: + current = await inputs.queue_binding(session_id) + if not scope.current_for(current): + current = NoQueueScope() + return InputStartedUpdate( + row.public_id if source.notice_keys else None, source.notice_text, + current, current.start_revision(inputs, session_id), row.native_id, + ) + + @abstractmethod + def release(self, inputs: InputDrain, session_id: str) -> None: ... + + +class NoTurnAcceptance(TurnAcceptance): + """A scheduled or selected turn has no queued ACP acceptance.""" + + def require_current( + self, inputs: InputDrain, session_id: str, source: TurnInputSource, + current: Thread, claim_generation: int, + ) -> None: + return + + def release(self, inputs: InputDrain, session_id: str) -> None: + return + + +@dataclass(frozen=True) +class AcceptedTurn(TurnAcceptance): + input_id: InputId + + def controller(self, state: DrainedSession, runtime: RuntimeServer) -> Controller | None: + return state.queued[self.input_id].controller + + def require_current( + self, inputs: InputDrain, session_id: str, source: TurnInputSource, + current: Thread, claim_generation: int, + ) -> None: + try: + accepted = inputs.state(session_id).queued[self.input_id] + except KeyError: + for row in inputs.dispositions.read(source.keys).rows.values(): + row.require_not_cleared() + MissingAcceptedInputCheck().require_valid() + raise + AcceptedInputCheck( + acknowledgement=accepted, current=current, claim_generation=claim_generation, + input_id=self.input_id, keys=source.keys, inputs=inputs, session_id=session_id, + ).require_valid() + + async def started_update( + self, source: TurnInputSource, inputs: InputDrain, session_id: str, + row: input_attempt.StartedInput, scope: QueueScope, + ) -> InputStartedUpdate: + text = source.notice_text + try: + item = inputs.state(session_id).queued[self.input_id] + except KeyError: + pass + else: + text = await item.started(inputs) + update = await super().started_update(source, inputs, session_id, row, scope) + return replace( + update, input_id=row.public_id if source.notice_keys else self.input_id, text=text, + ) + + def release(self, inputs: InputDrain, session_id: str) -> None: + inputs.state(session_id).queued.pop(self.input_id, None) + + @dataclass(frozen=True, kw_only=True) class TurnInputSource(StartedInput, ABC): - accepted_id: InputId | None + acceptance: TurnAcceptance goal_permission: TurnGoalPermission bypasses_goal_permit: ClassVar[bool] = False origins: ClassVar[tuple[Message, ...]] = () @@ -184,7 +276,7 @@ class SelectedOriginalInput(RoutedOriginalInput): @classmethod def answering(cls, prompt: str, permission: TurnGoalPermission) -> SelectedOriginalInput: return cls( - accepted_id=None, + acceptance=NoTurnAcceptance(), goal_permission=permission, prompt=prompt, original_display=None, diff --git a/src/agent_comms/turn_runner.py b/src/agent_comms/turn_runner.py index 4e7c818b42..385be6df51 100644 --- a/src/agent_comms/turn_runner.py +++ b/src/agent_comms/turn_runner.py @@ -47,6 +47,7 @@ from .transcript_updates import TurnTranscriptUpdate from .turn_effects import TurnEffects from .turn_lease import TurnLeaseFence, TurnState +from .turn_input_source import TurnAcceptance from .turn_outcome import TurnOutcome from .turn_phase import CancellingPhase, TurnPhase from .turn_terminal import TurnInterruption @@ -440,7 +441,7 @@ async def run_agent_turn( autonomous_goal: bool = False, original_owner_input: bool = False, original_goal_id: str | None = None, - accepted_input_id: InputId | None = None, + acceptance: TurnAcceptance, dependency_wait_id: str | None = None, ) -> None: from .owned_turn import OwnedTurn @@ -457,7 +458,7 @@ async def run_agent_turn( autonomous_goal=autonomous_goal, original_owner_input=original_owner_input, original_goal_id=original_goal_id, - accepted_input_id=accepted_input_id, + acceptance=acceptance, dependency_wait_id=dependency_wait_id, ).run() diff --git a/src/agent_comms/worker.py b/src/agent_comms/worker.py index 58d078a2ca..323f9418d8 100644 --- a/src/agent_comms/worker.py +++ b/src/agent_comms/worker.py @@ -16,6 +16,7 @@ from .launch_root import LaunchRoot from .launch_runtime import LaunchRuntime, ThreadLaunch from .native_arguments import GivenBinary, NativeArguments +from .turn_input_source import NoTurnAcceptance from .turn_terminal import OwnerStop @@ -71,6 +72,7 @@ def capture(): name, name, row.source_text, + acceptance=NoTurnAcceptance(), original_keys=(key,), original_owner_input=True, ) diff --git a/tests/native_backend_fixture.py b/tests/native_backend_fixture.py index b73c6848e8..557469685f 100644 --- a/tests/native_backend_fixture.py +++ b/tests/native_backend_fixture.py @@ -1,5 +1,7 @@ """Acquired original native fixture shared by pytest and installed drivers.""" +from agent_comms.turn_input_source import AcceptedTurn + from agent_comms.native_package import VerifiedNativePackage from agent_comms.native_arguments import NativeArguments @@ -14,7 +16,9 @@ import agent_comms.agent_events as events import agent_comms.backend as backend -from agent_comms.agent_backend import InputContent, InputRequest, TurnInbox, SteerInputPolicy +from agent_comms.agent_backend import ( + InputContent, InputRequest, NoInputStartListener, TurnInbox, SteerInputPolicy, +) from agent_comms.field_codec import InputId from agent_comms.comms import wire from agent_comms.fresh_private_session import create_fresh_private_session @@ -41,7 +45,11 @@ async def wait_for(predicate): await asyncio.sleep(0.01) -class NativeBackendFixture: +class AnnouncedInputStart(NoInputStartListener): + started_events = (events.OriginalInputStarted(),) + + +class NativeBackendFixture(AnnouncedInputStart): @staticmethod def participant_at(root: Path, project: Path) -> RecordedThreadIncarnation: """The real thread incarnation used by a direct native turn on this bus.""" @@ -135,6 +143,7 @@ async def original_input(self, agent, session_id, text): async with InitialInput.reserve( agent.inputs, session_id, agent.sessions.require(session_id), text=text, prompt=text, echo=True, images=(), controller=None, + input_id=InputId.mint(), ) as reservation: item = reservation.item resources.push_async_callback(reservation.release().aclose) @@ -144,6 +153,7 @@ async def original_input(self, agent, session_id, text): agent.turns, session_id, agent.sessions.require(session_id), text, original_keys=(item.key,), initial_display_text=text, original_owner_input=True, original_goal_id=item.context.active_goal_id, + acceptance=AcceptedTurn(item.input_id), ) async with AsyncExitStack() as permits: assert await turn.acquire(resources, permits) @@ -154,7 +164,13 @@ async def force_reopen(self): self.persistent.child_state.child.attestation.require_identity() await self.persistent.close_idle() - def started(self, public_id, native_id, text): + def original(self, native_id: str, text: str) -> bool: + return self._observe_start(None, native_id, text) + + def following(self, public_id: InputId, native_id: str, text: str) -> bool: + return self._observe_start(public_id, native_id, text) + + def _observe_start(self, public_id, native_id, text): self.starts.append((public_id, native_id, text)) child = backend.TurnSession.active[asyncio.current_task()].native # Cleanup already belongs to the saved-session owner at native input start; @@ -179,7 +195,7 @@ async def run(self, text, *, followup=None, queue=None, **options): package=self.package, tool_mode=NoSelectedTools(), steering_queue=queue, - native_start=self.started, + input_start=self, **options, session_directory=self.root): result.append(event) diff --git a/tests/pi_session_turn.py b/tests/pi_session_turn.py index efeef683a2..08913a060d 100644 --- a/tests/pi_session_turn.py +++ b/tests/pi_session_turn.py @@ -8,6 +8,7 @@ import agent_comms.backend as backend from agent_comms.comms import wire +from agent_comms.agent_backend import NoInputStartListener from agent_comms.pi_native_backend import PersistentPiSession from agent_comms.native_package import VerifiedNativePackage from agent_comms.selected_actions import NoSelectedTools @@ -26,6 +27,7 @@ async def one_turn_events(agent_bin, agent_args, task, cwd, env_extra=None, async with aclosing( backend.stream_agent_events(agent_bin, agent_args, task, cwd, env_extra, persistent_session=session, root=comms.root, owner=owner, tool_mode=tool_mode, + input_start=NoInputStartListener(), package=VerifiedNativePackage.acquire(package), session_directory=Path(cwd), **kwargs) ) as stream: diff --git a/tests/test_absence_owners.py b/tests/test_absence_owners.py index 0eff520f6c..3de32017e9 100644 --- a/tests/test_absence_owners.py +++ b/tests/test_absence_owners.py @@ -1,13 +1,14 @@ """Behaviour-bearing absence belongs to the state that owns the operation.""" +from agent_comms import agent_events as events from agent_comms.pi_payloads import AnonymousRequest, RequestId import asyncio import pytest -from agent_comms.agent_backend import SendNow, TurnInbox +from agent_comms.agent_backend import InputStartListener, NoInputStartListener, SendNow, TurnInbox from agent_comms.field_codec import InputId -from agent_comms.backend import EventFinishSignal, FinishSignal, InputStartListener, NoFinishSignal, NoInputStartListener +from agent_comms.backend import EventFinishSignal, FinishSignal, NoFinishSignal from agent_comms.change_report import FileLine, NoSourceReference from agent_comms.field_codec import FieldCodec from agent_comms.input_drain import DrainedSession @@ -77,10 +78,22 @@ async def test_native_absence_does_not_admit_a_wait_or_a_forwarding_job(): assert listener.original("native", "input") and listener.started_events == () assert listener.following("public", "native", "input") seen = [] - reporting = InputStartListener.from_callback(lambda *args: seen.append(args) or False) + class RecordingStarts(InputStartListener): + started_events = (events.OriginalInputStarted(),) + + def original(self, native_id: str, text: str) -> bool: + seen.append((None, native_id, text)) + return False + + def following(self, public_id: InputId, native_id: str, text: str) -> bool: + seen.append((public_id, native_id, text)) + return False + + public_id = InputId.mint() + reporting = RecordingStarts() assert not reporting.original("native", "input") - assert not reporting.following("public", "following-native", "next") - assert seen == [(None, "native", "input"), ("public", "following-native", "next")] + assert not reporting.following(public_id, "following-native", "next") + assert seen == [(None, "native", "input"), (public_id, "following-native", "next")] assert len(reporting.started_events) == 1 calls = [] async def forward(): diff --git a/tests/test_atomic_turn_source_owner.py b/tests/test_atomic_turn_source_owner.py index b1e897c020..711f52f1be 100644 --- a/tests/test_atomic_turn_source_owner.py +++ b/tests/test_atomic_turn_source_owner.py @@ -1,5 +1,7 @@ """Original begin/attachment ownership with real registry and callback resources.""" +from agent_comms.turn_input_source import NoTurnAcceptance + from contextlib import AsyncExitStack import os @@ -30,7 +32,9 @@ async def test_begin_callback_and_native_attachment_share_original_owner(tmp_pat comms = installed_comms(tmp_path) comms.registry.declare(Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) agent = CommsAgent(comms, auto_wake=False) - turn = OwnedTurn(agent.turns, "owner-session", "owner", "new original input") + turn = OwnedTurn( + agent.turns, "owner-session", "owner", "new original input", acceptance=NoTurnAcceptance(), + ) async with AsyncExitStack() as resources: async with AsyncExitStack() as permits: assert comms.registry.require("owner").turn_lease == NoTurnLease() diff --git a/tests/test_channel_bus_ownership.py b/tests/test_channel_bus_ownership.py index 1f0129fe2e..992b154f84 100644 --- a/tests/test_channel_bus_ownership.py +++ b/tests/test_channel_bus_ownership.py @@ -1,5 +1,7 @@ """Channel membership and notifications have one canonical bus authority.""" +from agent_comms.turn_input_source import NoTurnAcceptance + from bus_history import read_history from dataclasses import replace from contextlib import AsyncExitStack @@ -82,7 +84,10 @@ async def test_natural_owned_turn_prepares_observer_pointer_without_native_start agent_bin=GivenBinary("pi"), auto_wake=False, ) - turn = OwnedTurn(agent.turns, "alpha", "alpha", "Independently authorized task") + turn = OwnedTurn( + agent.turns, "alpha", "alpha", "Independently authorized task", + acceptance=NoTurnAcceptance(), + ) try: async with AsyncExitStack() as resources: async with AsyncExitStack() as permits: diff --git a/tests/test_goal_execution.py b/tests/test_goal_execution.py index 9ec58cba9e..cc891ba44c 100644 --- a/tests/test_goal_execution.py +++ b/tests/test_goal_execution.py @@ -1,5 +1,7 @@ """Real turn acquisition and terminal handling share the execution owner.""" +from agent_comms.turn_input_source import NoTurnAcceptance + import os import sqlite3 from contextlib import AsyncExitStack, asynccontextmanager, closing @@ -66,6 +68,7 @@ async def turn_on(comms, *, original_owner_input=False): turn = OwnedTurn( agent.turns, session, owner.name, "Continue original work", original_owner_input=original_owner_input, original_goal_id=owner.goal.require().id, + acceptance=NoTurnAcceptance(), ) async with AsyncExitStack() as resources, AsyncExitStack() as permits: assert await turn.acquire(resources, permits) diff --git a/tests/test_native_retry_exhaustion_installed.py b/tests/test_native_retry_exhaustion_installed.py index 3d7ba6a5ea..8346122bf3 100644 --- a/tests/test_native_retry_exhaustion_installed.py +++ b/tests/test_native_retry_exhaustion_installed.py @@ -1,4 +1,6 @@ """Installed native 429 exhaustion reaches one completed user-visible failure.""" + +from agent_comms.turn_input_source import NoTurnAcceptance from agent_comms.controller import SessionUpdateSink import asyncio import json @@ -94,7 +96,7 @@ async def publish_update(self, session_id, update): with pytest.raises(NativeTurnError) as caught: await agent.turns.run_agent_turn( "project", "project", "One isolated request that remains rate limited", - original_owner_input=True, + original_owner_input=True, acceptance=NoTurnAcceptance(), ) reports = [item for update in updates for item in decode_updates(update.field_meta) if isinstance(item, RequestFailedUpdate)] diff --git a/tests/test_ordinary_admission_rules.py b/tests/test_ordinary_admission_rules.py index 6f19acfb93..fdf9785956 100644 --- a/tests/test_ordinary_admission_rules.py +++ b/tests/test_ordinary_admission_rules.py @@ -20,6 +20,7 @@ def test_routed_dependency_source_uses_existing_goal_and_wait_authorities(tmp_pa CapturedInputDependency, DependencyOriginalInput, NoInputDependency, + NoTurnAcceptance, RoutedOriginalInput, ) @@ -44,7 +45,7 @@ def test_routed_dependency_source_uses_existing_goal_and_wait_authorities(tmp_pa report_turn_generation=None, ) common = dict( - accepted_id=None, + acceptance=NoTurnAcceptance(), goal_permission=TurnGoalPermission(ContinuationGoalPermission(goal)), prompt="Result", original_display="Result", diff --git a/tests/test_owned_turn_goal_replacement.py b/tests/test_owned_turn_goal_replacement.py index ae00a344df..8b3d577863 100644 --- a/tests/test_owned_turn_goal_replacement.py +++ b/tests/test_owned_turn_goal_replacement.py @@ -1,5 +1,7 @@ """A replaced order keeps the running turn's authority, so following input joins that turn; a new original captures the current goal.""" +from agent_comms.turn_input_source import NoTurnAcceptance + import asyncio import os from contextlib import AsyncExitStack @@ -132,7 +134,8 @@ async def test_goal_published_during_preparation_is_current_at_reservation(tmp_p actor = comms.registry.require('lead').incarnation agent = CommsAgent(comms, auto_wake=False) old = OwnedTurn(agent.turns, 'worker-session', 'worker', 'first input', - original_owner_input=True, original_goal_id=initial.id) + original_owner_input=True, original_goal_id=initial.id, + acceptance=NoTurnAcceptance()) async with AsyncExitStack() as resources, AsyncExitStack() as permits: assert await old.acquire(resources, permits) old.original.goal_permission.require_current(initial) @@ -147,7 +150,8 @@ async def test_goal_published_during_preparation_is_current_at_reservation(tmp_p # Exercise the real lease-to-reservation boundary without replacing any methods. # A second commander publication during preparation makes the leased snapshot stale. current = OwnedTurn(agent.turns, 'worker-session', 'worker', 'next input', - original_owner_input=True, original_goal_id=replaced.id) + original_owner_input=True, original_goal_id=replaced.id, + acceptance=NoTurnAcceptance()) assert current.authorize() current.turn_id = TurnId.mint() async with AsyncExitStack() as resources: @@ -201,7 +205,7 @@ async def test_order_reactivating_a_blocked_goal_keeps_the_running_turn(tmp_path # QueuedInputContext.active_goal_id of a blocked goal, as queued_input.py passes it. turn = OwnedTurn(agent.turns, 'worker-session', 'worker', 'retry order message', original_owner_input=True, - original_goal_id=blocked.active().reported_id()) + original_goal_id=blocked.active().reported_id(), acceptance=NoTurnAcceptance()) async with AsyncExitStack() as resources, AsyncExitStack() as permits: assert await turn.acquire(resources, permits) todo = comms.goals.todos.get('P1') diff --git a/tests/test_private_idle_drain.py b/tests/test_private_idle_drain.py index da3066ee38..b3fc233773 100644 --- a/tests/test_private_idle_drain.py +++ b/tests/test_private_idle_drain.py @@ -1,5 +1,7 @@ """Idle observations avoid repeated work without becoming delivery authority.""" +from agent_comms.turn_input_source import NoTurnAcceptance + from bus_history import read_history from conftest import publish_initial from agent_comms.messages import NoticeRole @@ -84,7 +86,8 @@ async def test_turn_publication_joins_cancellation_before_lease_release(tmp_path comms, agent, _root_id = _session(tmp_path) origin = Message("user", "#team", "publication ownership", InfoKind) execution = OwnedTurn( - agent.turns, "beta", "beta", "publication ownership", origins=(origin,) + agent.turns, "beta", "beta", "publication ownership", origins=(origin,), + acceptance=NoTurnAcceptance(), ) async with AsyncExitStack() as resources: async with AsyncExitStack() as permits: diff --git a/tests/test_stack_native_compaction.py b/tests/test_stack_native_compaction.py index 0118f76bcb..7607f2d696 100644 --- a/tests/test_stack_native_compaction.py +++ b/tests/test_stack_native_compaction.py @@ -31,7 +31,7 @@ from agent_comms.pi_vocabulary import OverflowCompactionReason, ThresholdCompactionReason from agent_comms.native_arguments import GivenArguments, GivenBinary from stored_inputs import stored_inputs -from native_backend_fixture import NativeBackendFixture +from native_backend_fixture import AnnouncedInputStart, NativeBackendFixture pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="native Pi uses POSIX fsync") @@ -224,7 +224,7 @@ def log_message(self, *_args): native_bin, native_args, "Reply OK.", str(project), root=project, owner=NativeBackendFixture.participant_at(project, project), env_extra=child_env, session_file=str(session), - native_start=lambda *_: True, + input_start=AnnouncedInputStart(), persistent_session=pi_session, package=native_runtime_package, tool_mode=NoSelectedTools(), diff --git a/tests/test_tracked_native_retry.py b/tests/test_tracked_native_retry.py index 8bc7629bd7..0a0e57c600 100644 --- a/tests/test_tracked_native_retry.py +++ b/tests/test_tracked_native_retry.py @@ -1,3 +1,5 @@ +from agent_comms.field_codec import InputId +from agent_comms.turn_input_source import AcceptedTurn import pytest from agent_comms.native_arguments import SelectedModel @@ -87,7 +89,7 @@ def test_failed_native_retry_end_requires_final_error_at_decode(): def following(kind): - values = dict(keys=(), accepted_id="fresh-following", + values = dict(keys=(), acceptance=AcceptedTurn(InputId.mint()), goal_permission=TurnGoalPermission(InactiveGoalPermission())) return kind(**values, origins=()) if kind is DeliveredFollowingInput else kind(**values) From 86aef8b61d233833c23ed86ea1aec9c4e4816eca Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 21:15:13 -0400 Subject: [PATCH 20/32] Separate native write permission from committed-input acknowledgement --- src/agent_comms/agent_backend.py | 5 +- src/agent_comms/backend.py | 23 ++-- src/agent_comms/native_input_record.py | 12 +- src/agent_comms/owned_send.py | 115 ++++++------------ src/agent_comms/owned_turn.py | 3 - src/agent_comms/pi_native_backend.py | 1 - src/agent_comms/send_outcome.py | 45 ++++++- src/agent_comms/tracked_turn.py | 14 +-- src/agent_comms/turn_input_source.py | 82 +++++++++++-- src/agent_comms/turn_inputs.py | 49 ++++---- tests/native_backend_fixture.py | 35 +++--- tests/test_absence_owners.py | 11 +- tests/test_acp_extension.py | 3 +- tests/test_atomic_turn_source_owner.py | 15 +-- tests/test_channel_bus_ownership.py | 9 +- tests/test_goal_execution.py | 5 +- .../test_native_retry_exhaustion_installed.py | 10 +- tests/test_owned_turn_goal_replacement.py | 13 +- tests/test_private_idle_drain.py | 14 +-- tests/test_stack_persistent_compaction.py | 5 +- tests/test_tracked_native_retry.py | 24 ++-- 21 files changed, 277 insertions(+), 216 deletions(-) diff --git a/src/agent_comms/agent_backend.py b/src/agent_comms/agent_backend.py index 310f4d86fc..d4dc7d004e 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -312,8 +312,8 @@ class TurnDelivery: ``inbox`` carries :class:`InputRequest` and :class:`SendNow` records that arrive while the turn runs. Core checks input permission at these boundaries: the - backend enters ``send_boundary`` around writing each input and - ``interrupt_boundary`` around a send-now; ``input_started`` reports that the + backend asks ``send_boundary`` before writing each input or interrupt; + ``input_start`` reports that the runtime began an input; ``request_progress`` and ``ui_request`` observe provider progress and answer runtime dialogs. """ @@ -323,7 +323,6 @@ class TurnDelivery: inbox: TurnInbox finish_event: asyncio.Event send_boundary: SendBoundary - interrupt_boundary: SendBoundary input_start: InputStartListener request_progress: Callable[..., None] ui_request: Callable[[DialogUiRequest], Awaitable[ExtensionUiChoice]] diff --git a/src/agent_comms/backend.py b/src/agent_comms/backend.py index 61da3c0354..044d4a15f8 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -61,7 +61,7 @@ from .pi_rpc import PiRpcChannel from .prompt_state import UnwrittenPrompt from .request_progress import NO_REQUEST_OBSERVER, RequestObserver, RequestProgress -from .send_outcome import SendBoundary +from .send_outcome import NO_SEND_BOUNDARY, SendBoundary from .tool_denial import SelectedToolDenied from .turn_context import InputContributionCoordinates from .turn_inputs import InputForwarding @@ -116,8 +116,7 @@ async def stream_agent_events( images: Sequence[ImageInput] = (), model_wait: TurnWait = DEFAULT_MODEL_WAIT, rpc_abort_grace: float = RPC_ABORT_GRACE_SECONDS, - send_boundary: SendBoundary | None = None, - interrupt_boundary: SendBoundary | None = None, + send_boundary: SendBoundary = NO_SEND_BOUNDARY, *, input_start: InputStartListener, persistent_session: PersistentPiSession, @@ -172,7 +171,6 @@ async def stream_agent_events( rpc_abort_grace=rpc_abort_grace, send_boundary=send_boundary, input_start=input_start, - interrupt_boundary=interrupt_boundary, native_session=persistent_session, ui_request=ui_request, request_observer=request_observer, @@ -228,8 +226,7 @@ def __init__( finish_signal: FinishSignal = NO_FINISH_SIGNAL, model_wait: TurnWait = DEFAULT_MODEL_WAIT, rpc_abort_grace: float = RPC_ABORT_GRACE_SECONDS, - send_boundary: SendBoundary | None = None, - interrupt_boundary: SendBoundary | None = None, + send_boundary: SendBoundary = NO_SEND_BOUNDARY, input_start: InputStartListener = NO_INPUT_START_LISTENER, ui_request: Callable[[pi.DialogUiRequest], Awaitable[pi.ExtensionUiChoice]] | None = None, request_observer: RequestObserver = NO_REQUEST_OBSERVER, @@ -245,7 +242,6 @@ def __init__( ) self.rpc_abort_grace = rpc_abort_grace self.send_boundary = send_boundary - self.interrupt_boundary = interrupt_boundary self.input_start = input_start self.native_session = native_session self.extension_ui = ExtensionUiSession(ui_request) @@ -345,9 +341,10 @@ def ensure_input_forwarding(self): def observe_input_during_abort(self, event): if self.accepts_output: - matched, identity = self.inputs.mark_started(self, event) - if matched: - self.started_during_abort.append(events.FollowingInputStarted(id=identity)) + self.started_during_abort.extend( + events.FollowingInputStarted(id=identity) + for identity in self.inputs.mark_started(self, event) + ) def permits_live_status(self) -> bool: """Only tracked, live input may publish an informational package acknowledgement.""" @@ -645,9 +642,9 @@ async def accept_user_message( self.ensure_input_forwarding() return self.input_start.started_events if self.started_input: - matched, input_id = self.inputs.mark_started(self, event) - if matched: - return (events.FollowingInputStarted(id=input_id),) + started = self.inputs.mark_started(self, event) + if started: + return tuple(events.FollowingInputStarted(id=input_id) for input_id in started) self.inputs.uncertain = True self.output.final_assistant_stop = False self.output.record_failure( diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index 617069ee51..f347e4a9cb 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -219,7 +219,9 @@ def source_assignment_ids(self, db: Connection, input_id: InputId) -> tuple[str, return tuple(original.assignment_id for original in originals) @abstractmethod - def record_sources(self, db: Connection, input_id: InputId, assignments: tuple[WakeAssignment, ...]) -> None: + def record_sources( + self, db: Connection, input_id: InputId, assignments: tuple[WakeAssignment, ...], + ) -> None: """Record only membership which is not already owned by execution claims.""" @abstractmethod @@ -257,7 +259,9 @@ def source_membership_sql(cls) -> str: return (f"SELECT s.input_id, member.value AS assignment_id, CAST(member.key AS INTEGER) AS ordinal " f"FROM {TriageNativeSources.declared_name} s, json_each(s.assignment_ids) member") - def record_sources(self, db: Connection, input_id: InputId, assignments: tuple[WakeAssignment, ...]) -> None: + def record_sources( + self, db: Connection, input_id: InputId, assignments: tuple[WakeAssignment, ...], + ) -> None: from .triage_native_sources import TriageNativeSources TriageNativeSources(input_id, tuple(row.assignment_id for row in assignments)).insert(db) @@ -293,7 +297,9 @@ def source_membership_sql(cls) -> str: f"JOIN {ExecutionAssignmentLink.declared_name} c ON c.execution_id=n.execution_id " f"WHERE n.stage='{cls.declared_name}'") - def record_sources(self, db: Connection, input_id: InputId, assignments: tuple[WakeAssignment, ...]) -> None: + def record_sources( + self, db: Connection, input_id: InputId, assignments: tuple[WakeAssignment, ...], + ) -> None: if self.source_assignment_ids(db, input_id) != tuple(row.assignment_id for row in assignments): raise IdentityConflict("Full native input differs from its original execution membership") diff --git a/src/agent_comms/owned_send.py b/src/agent_comms/owned_send.py index 0fefc01b74..2b7edbba9f 100644 --- a/src/agent_comms/owned_send.py +++ b/src/agent_comms/owned_send.py @@ -3,32 +3,29 @@ from __future__ import annotations import logging -from collections.abc import Iterator -from contextlib import contextmanager +from contextlib import AbstractContextManager from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING -from .field_codec import InputId -from .agent_backend import InputStartListener -from . import agent_events as events -from .channel_coding_tools import CodingToolOwner -from .coordination_session import NativeBoundary -from .coordinator import Coordination -from .envelope_claim_transitions import NativeClaim -from .input_attempt import InputAlreadyCleared from agent_comms.ordinary_turn_rules import ( InputKeysCheck, MissingAcceptedInputRule, MissingTurnOwnerCheck, OrdinaryContextCheck, OrdinaryOwnerCheck, - UnboundOrdinaryInputRule, - UnconsumedDependencyRule, ) +from agent_comms.send_outcome import AuthorityChangedSend, SendBoundary, SendOutcome + +from . import agent_events as events +from .agent_backend import InputStartListener +from .channel_coding_tools import CodingToolOwner +from .coordination_session import NativeBoundary +from .coordinator import Coordination +from .envelope_claim_transitions import NativeClaim +from .field_codec import InputId +from .input_attempt import InputAlreadyCleared from .reservation_rules import ReservationRule, ReservationViolationError -from .routing import TurnRouting -from agent_comms.send_outcome import AuthorityChangedSend, DeferredSend, GrantedSend, RefusedSend, SendOutcome from .thread_identity import RegisteredTurn from .turn_input_source import ( OriginalTurnInput, @@ -38,6 +35,7 @@ if TYPE_CHECKING: from agent_comms.goal_attempts import GoalAttemptStore from agent_comms.goal_turn_permit import GoalTurnPermit + from .goal_waits import GoalWait from .input_drain import InputDrain from .registry_document import RegistrySnapshot @@ -48,7 +46,7 @@ @dataclass(frozen=True, kw_only=True) -class OwnedSend(CodingToolOwner, InputStartListener): +class OwnedSend(CodingToolOwner, InputStartListener, SendBoundary): started_events = (events.OriginalInputStarted(),) inputs: InputDrain @@ -60,24 +58,27 @@ class OwnedSend(CodingToolOwner, InputStartListener): original_source: OriginalTurnInput goal_permit: GoalTurnPermit - def source(self, public_id: InputId | None) -> TurnInputSource: - if public_id is None: - return self.original_source + def source(self, public_id: InputId) -> TurnInputSource: try: return self.inputs.state(self.session_id).following[public_id] except KeyError as error: raise ReservationViolationError(MissingAcceptedInputRule()) from error + def require_owner(self, snapshot: RegistrySnapshot, canonical: str) -> Thread: + try: + return snapshot.threads[canonical] + except KeyError: + MissingTurnOwnerCheck().require_valid() + raise + def _check_owner( self, - current: Thread | None, + current: Thread, snapshot: RegistrySnapshot, canonical: str, source: TurnInputSource, text: str, ) -> None: - if current is None: - MissingTurnOwnerCheck().require_valid() OrdinaryOwnerCheck( expected=self.thread, current=current, @@ -101,64 +102,20 @@ def _refusal(self, rule: ReservationRule) -> SendOutcome: _LOG.info("Ordinary input refused: %s", ReservationViolationError(rule)) return AuthorityChangedSend() - def _prepare( - self, public_id: InputId | None, native_id: str, sent_text: str, - *, already_bound: bool = False - ) -> SendOutcome: - # Backend input handling holds shared locks through this check and write. - snapshot = self.comms.registry.snapshot() - canonical = snapshot.canonical_name(self.thread.name) - current = snapshot.threads.get(canonical) - source = self.source(public_id) - try: - self._check_owner(current, snapshot, canonical, source, sent_text) - # The queue grants a send, not a running tool's lifetime. Once Pi - # starts this exact input, StartedInput removes its queue entry. - source.acceptance.require_current( - self.inputs, self.session_id, source, current, self.claim_generation, - ) - except ReservationViolationError as error: - return self._refusal(error.rule) - if source.defers_for_goal(self.original_source.goal_permission, current.goal): - return DeferredSend() - wait = self.comms.goals.goal_wait(canonical) - try: - self._require_context(source, current, wait) - except ReservationViolationError as error: - return self._refusal(error.rule) - # Binding failures may be uncertain. Exceptions here still propagate; - # never reinterpret a failed durable operation as a retryable policy refusal. - if not self.inputs.dispositions.bind_turn_input( - source.keys, - claim_scope=self.claim_generation, - turn_id=self.turn.value, - native_id=native_id, - text=sent_text, - already_bound=already_bound, - ): - return self._refusal(UnboundOrdinaryInputRule()) - if not source.consume_wait(self.comms, canonical, wait): - return self._refusal(UnconsumedDependencyRule()) - self.comms.transcripts.routes.record_input_display( - native_id, - source.display(self.inputs.dispositions, sent_text), - sent_text=sent_text, - routing=TurnRouting( - tuple(origin.reference for origin in source.origins), None - ) if source.origins else None, - ) - return GrantedSend() + def send_original(self, native_id: str, text: str) -> AbstractContextManager[SendOutcome]: + return self.original_source.prepare_send(self, native_id, text) - @contextmanager - def __call__( - self, public_id: InputId | None, native_id: str, sent_text: str, - *, already_bound: bool = False - ) -> Iterator[SendOutcome]: - try: - decision = self._prepare(public_id, native_id, sent_text, already_bound=already_bound) - except InputAlreadyCleared: - decision = RefusedSend() - yield decision + def send_following( + self, public_id: InputId, native_id: str, text: str, + ) -> AbstractContextManager[SendOutcome]: + return self.source(public_id).prepare_send(self, native_id, text) + + def send_interrupt( + self, public_id: InputId, native_id: str, text: str, + ) -> AbstractContextManager[SendOutcome]: + return self.source(public_id).prepare_send( + self, native_id, text, already_bound=True, + ) @property def goal_permission(self) -> TurnGoalPermission: @@ -171,8 +128,8 @@ def verify(self, store, turn, snapshot) -> None: if not turn.started_input or not turn.accepts_output: raise SelectedToolDenied("Native tool has no started original input") canonical = snapshot.canonical_name(self.thread.name) - current = snapshot.threads.get(canonical) try: + current = self.require_owner(snapshot, canonical) self._check_owner( current, snapshot, canonical, self.original_source, turn.command.message, ) diff --git a/src/agent_comms/owned_turn.py b/src/agent_comms/owned_turn.py index fe49ce1820..9d2b0f6f76 100644 --- a/src/agent_comms/owned_turn.py +++ b/src/agent_comms/owned_turn.py @@ -358,9 +358,6 @@ async def stream(self): inbox=self.following.inbox, finish_event=self.finish_event, send_boundary=sender, - interrupt_boundary=lambda public_id, native_id, text: sender( - public_id, native_id, text, already_bound=True - ), input_start=sender, request_progress=self.progress.record_request_progress, ui_request=lambda request: self.runner.extension_ui_permission( diff --git a/src/agent_comms/pi_native_backend.py b/src/agent_comms/pi_native_backend.py index 5da25e87c8..6e01447912 100644 --- a/src/agent_comms/pi_native_backend.py +++ b/src/agent_comms/pi_native_backend.py @@ -273,7 +273,6 @@ async def run_turn( steering_queue=delivery.inbox, finish_event=delivery.finish_event, send_boundary=delivery.send_boundary, - interrupt_boundary=delivery.interrupt_boundary, input_start=delivery.input_start, request_observer=delivery.request_progress, persistent_session=self, diff --git a/src/agent_comms/send_outcome.py b/src/agent_comms/send_outcome.py index 0f7c3b076d..dd8e02cb66 100644 --- a/src/agent_comms/send_outcome.py +++ b/src/agent_comms/send_outcome.py @@ -1,10 +1,11 @@ """Send decisions dispatch effects; their awaitables only control forwarding.""" import asyncio from abc import ABC, abstractmethod -from contextlib import AbstractContextManager -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Iterator +from contextlib import AbstractContextManager, contextmanager from . import turn_failure as failures +from .field_codec import InputId class SendOutcome(ABC): @@ -55,4 +56,42 @@ def dispatch(self, inputs, session, item, write): return asyncio.sleep(0, result=False) -SendBoundary = Callable[[str | None, str, str], AbstractContextManager[SendOutcome]] +class SendBoundary(ABC): + """Permission to write original, following and interrupt commands.""" + + @abstractmethod + def send_original(self, native_id: str, text: str) -> AbstractContextManager[SendOutcome]: ... + + @abstractmethod + def send_following( + self, public_id: InputId, native_id: str, text: str, + ) -> AbstractContextManager[SendOutcome]: ... + + @abstractmethod + def send_interrupt( + self, public_id: InputId, native_id: str, text: str, + ) -> AbstractContextManager[SendOutcome]: ... + + +class NoSendBoundary(SendBoundary): + """A direct native session has no ordinary-owner permission to check.""" + + @contextmanager + def _granted(self) -> Iterator[SendOutcome]: + yield GrantedSend() + + def send_original(self, native_id: str, text: str) -> AbstractContextManager[SendOutcome]: + return self._granted() + + def send_following( + self, public_id: InputId, native_id: str, text: str, + ) -> AbstractContextManager[SendOutcome]: + return self._granted() + + def send_interrupt( + self, public_id: InputId, native_id: str, text: str, + ) -> AbstractContextManager[SendOutcome]: + return self._granted() + + +NO_SEND_BOUNDARY = NoSendBoundary() diff --git a/src/agent_comms/tracked_turn.py b/src/agent_comms/tracked_turn.py index e45ee4419d..d51e21fc44 100644 --- a/src/agent_comms/tracked_turn.py +++ b/src/agent_comms/tracked_turn.py @@ -474,7 +474,6 @@ def forward_inbox(self, inbox: TurnInbox, sender: OwnedSend) -> None: """Later inputs go to the running child under the owner's write checks.""" self.inputs.queue = inbox self.send_boundary = sender - self.interrupt_boundary = partial(sender, already_bound=True) self.input_start = sender self.ensure_input_forwarding() @@ -484,13 +483,12 @@ async def accept_user_message( """The original input starts when Pi commits it; any other user message is a later input.""" if message.matches_input(self.command.message, self.command.input_id): return () - matched, input_id = self.inputs.mark_started(self, event) - if not matched: - raise NativePiUnavailable("Native Pi started a user message this turn did not send") - source = await self.following.started(input_id) - self.terminal = source.continue_response(self.terminal, self.output.text_parts) - self.output.discard_text() - return () + for input_id in self.inputs.mark_started(self, event): + source = await self.following.started(input_id) + self.terminal = source.continue_response(self.terminal, self.output.text_parts) + self.output.discard_text() + return () + raise NativePiUnavailable("Native Pi started a user message this turn did not send") @handles(pi.ContextCommitted) async def committed_context(self, event: pi.ContextCommitted) -> None: diff --git a/src/agent_comms/turn_input_source.py b/src/agent_comms/turn_input_source.py index 02dfdf52d0..74d1d1cee4 100644 --- a/src/agent_comms/turn_input_source.py +++ b/src/agent_comms/turn_input_source.py @@ -3,7 +3,8 @@ from __future__ import annotations from abc import ABC, abstractmethod -from contextlib import ExitStack +from collections.abc import Iterator +from contextlib import ExitStack, contextmanager from dataclasses import dataclass, replace from typing import TYPE_CHECKING, ClassVar @@ -14,8 +15,17 @@ from .agent_backend import BusyInputPolicy, SteerInputPolicy from .channel_input_batch import InputBatch, SingleInputBatch from .field_codec import InputId +from .input_attempt import InputAlreadyCleared from .messages import Message -from .ordinary_turn_rules import AcceptedInputCheck, MissingAcceptedInputCheck +from .ordinary_turn_rules import ( + AcceptedInputCheck, + MissingAcceptedInputCheck, + UnboundOrdinaryInputRule, + UnconsumedDependencyRule, +) +from .reservation_rules import ReservationViolationError +from .routing import TurnRouting +from .send_outcome import DeferredSend, GrantedSend, RefusedSend, SendOutcome from .started_input import StartedInput if TYPE_CHECKING: @@ -26,6 +36,7 @@ from .goals import GoalDeclaration from .input_disposition import InputDispositions from .input_drain import DrainedSession, InputDrain + from .owned_send import OwnedSend from .runtime import RuntimeServer from .thread_identity import RecordedThreadIncarnation, RegisteredTurn from .threads import Thread @@ -100,12 +111,9 @@ async def started_update( row: input_attempt.StartedInput, scope: QueueScope, ) -> InputStartedUpdate: text = source.notice_text - try: - item = inputs.state(session_id).queued[self.input_id] - except KeyError: - pass - else: - text = await item.started(inputs) + queued = inputs.state(session_id).queued + if self.input_id in queued: + text = await queued[self.input_id].started(inputs) update = await super().started_update(source, inputs, session_id, row, scope) return replace( update, input_id=row.public_id if source.notice_keys else self.input_id, text=text, @@ -125,6 +133,64 @@ class TurnInputSource(StartedInput, ABC): if TYPE_CHECKING: keys: tuple[str, ...] + @contextmanager + def prepare_send( + self, sender: OwnedSend, native_id: str, sent_text: str, + *, already_bound: bool = False, + ) -> Iterator[SendOutcome]: + try: + decision = self._prepare(sender, native_id, sent_text, already_bound=already_bound) + except InputAlreadyCleared: + decision = RefusedSend() + yield decision + + def _prepare( + self, sender: OwnedSend, native_id: str, sent_text: str, + *, already_bound: bool = False, + ) -> SendOutcome: + # Backend input handling holds shared locks through this check and write. + snapshot = sender.comms.registry.snapshot() + canonical = snapshot.canonical_name(sender.thread.name) + try: + current = sender.require_owner(snapshot, canonical) + sender._check_owner(current, snapshot, canonical, self, sent_text) + # The queue grants a send, not a running tool's lifetime. Once Pi + # starts this exact input, StartedInput removes its queue entry. + self.acceptance.require_current( + sender.inputs, sender.session_id, self, current, sender.claim_generation, + ) + except ReservationViolationError as error: + return sender._refusal(error.rule) + if self.defers_for_goal(sender.goal_permission, current.goal): + return DeferredSend() + wait = sender.comms.goals.goal_wait(canonical) + try: + sender._require_context(self, current, wait) + except ReservationViolationError as error: + return sender._refusal(error.rule) + # Binding failures may be uncertain. Exceptions here still propagate; + # never reinterpret a failed durable operation as a retryable policy refusal. + if not sender.inputs.dispositions.bind_turn_input( + self.keys, + claim_scope=sender.claim_generation, + turn_id=sender.turn.value, + native_id=native_id, + text=sent_text, + already_bound=already_bound, + ): + return sender._refusal(UnboundOrdinaryInputRule()) + if not self.consume_wait(sender.comms, canonical, wait): + return sender._refusal(UnconsumedDependencyRule()) + sender.comms.transcripts.routes.record_input_display( + native_id, + self.display(sender.inputs.dispositions, sent_text), + sent_text=sent_text, + routing=TurnRouting( + tuple(origin.reference for origin in self.origins), None + ) if self.origins else None, + ) + return GrantedSend() + def valid_keys(self, text: str) -> bool: return len(self.keys) <= 1 diff --git a/src/agent_comms/turn_inputs.py b/src/agent_comms/turn_inputs.py index 1cd213cb98..307ad9ec9b 100644 --- a/src/agent_comms/turn_inputs.py +++ b/src/agent_comms/turn_inputs.py @@ -30,8 +30,8 @@ @dataclass class InputForwarding(MroDispatch): queue: TurnInbox = field(default_factory=TurnInbox) - pending: dict[str, commands.Prompt] = field(default_factory=dict) - accepted: set[str] = field(default_factory=set) + pending: dict[str, InputRequest] = field(default_factory=dict) + accepted: set[InputId] = field(default_factory=set) changed: asyncio.Event = field(default_factory=asyncio.Event) current: TurnInboxItem | None = None generation: int = 0 @@ -60,15 +60,14 @@ def settled(self) -> bool: def acknowledge(self, response: Response) -> None: if response.succeeded: for item in self.pending.values(): - public_id = item.require_id() - if item.id == response.id: - self.accepted.add(public_id) + if item.input_id in response.id.present(): + self.accepted.add(item.input_id) self.changed.set() - def retire(self, native_id: str) -> commands.Prompt: + def retire(self, native_id: str) -> InputRequest: """Remove one exact native identity from the sole pending index.""" item = self.pending.pop(native_id) - self.accepted.discard(item.require_id()) + self.accepted.discard(item.input_id) self.changed.set() return item @@ -99,7 +98,8 @@ def write(): barrier = MaintenanceBarrier(RegistryFile.path_in(session.startup.root)) async with barrier.ingress_async() as status: with status.phase.send_new( - session.send_boundary, None, session.command.input_id, session.command.message, + session.send_boundary.send_original, + session.command.input_id, session.command.message, ) as outcome: completion = outcome.dispatch(self, session, item, write) return await completion @@ -131,10 +131,10 @@ async def queued(self, input_ids: tuple[InputId, ...]) -> tuple[str, ...]: while True: self.changed.clear() candidates = tuple( - key for key, entry in self.pending.items() if entry.require_id() in selected + key for key, entry in self.pending.items() if entry.input_id in selected ) if not candidates or all( - self.pending[key].require_id() in self.accepted for key in candidates + self.pending[key].input_id in self.accepted for key in candidates ): return candidates await self.changed.wait() @@ -153,7 +153,7 @@ async def clear_queue(self, item: ClearQueue, session: TurnSession) -> bool: for native_id in response.data.require_payload().require_requested(command.input_ids): removed = self.retire(native_id) session.rejected_commands.append(events.InputCleared( - id=removed.require_id(), native_id=native_id, text=removed.message, + id=removed.input_id, native_id=native_id, text=removed.content.text, )) session.rejected_signal.set() return True @@ -178,7 +178,8 @@ async def send_now(self, item: SendNow, session: TurnSession) -> bool: first = self.pending[candidates[0]] async with MaintenanceBarrier(RegistryFile.path_in(session.startup.root)).ingress_async() as status: with status.phase.send_running( - session.interrupt_boundary, first.require_id(), candidates[0], first.message, + session.send_boundary.send_interrupt, first.input_id, + candidates[0], first.content.text, ) as outcome: completion = outcome.dispatch(self, session, item, lambda: session.native.send( commands.InterruptSteering(input_ids=list(candidates)) @@ -203,11 +204,13 @@ def write(): if command.images: session.output.sensitive = True session.native.send(command) - self.pending[native_id] = command + self.pending[native_id] = item self.generation += 1 async with MaintenanceBarrier(RegistryFile.path_in(session.startup.root)).ingress_async() as status: - with status.phase.send_running(session.send_boundary, public_id, native_id, text) as outcome: + with status.phase.send_running( + session.send_boundary.send_following, public_id, native_id, text, + ) as outcome: completion = outcome.dispatch(self, session, item, write) return await completion @@ -216,17 +219,19 @@ def refuse(self, session: TurnSession, input_ids: tuple[InputId, ...]) -> None: session.rejected_commands.append(events.FollowingInputRefused(id=input_id)) session.rejected_signal.set() - def mark_started(self, session: TurnSession, payload: MessageStart) -> tuple[bool, str | None]: + def mark_started( + self, session: TurnSession, payload: MessageStart, + ) -> tuple[InputId] | tuple[()]: message = payload.message if not message.user or not message.input_id.reported: - return False, None + return () native_id = message.input_id.require() entry = self.pending.get(native_id) - if entry is None or message.text != entry.message: - return False, None - public_id = entry.require_id() - if not session.input_start.following(public_id, native_id, entry.message): - return False, None + if entry is None or message.text != entry.content.text: + return () + public_id = entry.input_id + if not session.input_start.following(public_id, native_id, entry.content.text): + return () self.retire(native_id) self.started = True - return True, public_id + return (public_id,) diff --git a/tests/native_backend_fixture.py b/tests/native_backend_fixture.py index 557469685f..2d80aade00 100644 --- a/tests/native_backend_fixture.py +++ b/tests/native_backend_fixture.py @@ -1,42 +1,41 @@ """Acquired original native fixture shared by pytest and installed drivers.""" -from agent_comms.turn_input_source import AcceptedTurn - - -from agent_comms.native_package import VerifiedNativePackage -from agent_comms.native_arguments import NativeArguments - import asyncio import json import os -from pathlib import Path from contextlib import AsyncExitStack, asynccontextmanager from functools import partial +from pathlib import Path from unittest.mock import patch import agent_comms.agent_events as events import agent_comms.backend as backend from agent_comms.agent_backend import ( - InputContent, InputRequest, NoInputStartListener, TurnInbox, SteerInputPolicy, + InputContent, + InputRequest, + NoInputStartListener, + SteerInputPolicy, + TurnInbox, ) -from agent_comms.field_codec import InputId -from agent_comms.comms import wire -from agent_comms.fresh_private_session import create_fresh_private_session from agent_comms.child_process import BoundedRun, join_retirement +from agent_comms.comms import wire from agent_comms.coordinator import Coordination -from agent_comms.field_codec import FieldCodec -from agent_comms.pi_native_backend import PersistentPiSession +from agent_comms.field_codec import FieldCodec, InputId +from agent_comms.fresh_private_session import create_fresh_private_session +from agent_comms.native_arguments import GivenArguments, GivenBinary, NativeArguments from agent_comms.native_child import PiSessionChild -from agent_comms.selected_actions import NoSelectedTools +from agent_comms.native_package import VerifiedNativePackage from agent_comms.owned_turn import OwnedTurn -from agent_comms.thread_identity import RecordedThreadIncarnation +from agent_comms.pi_native_backend import PersistentPiSession from agent_comms.queued_input import InitialInput +from agent_comms.selected_actions import NoSelectedTools from agent_comms.storage_rules import StorageRule -from agent_comms.native_arguments import GivenArguments, GivenBinary -from delivery_owner_fixture import canonical_agent +from agent_comms.thread_identity import RecordedThreadIncarnation +from agent_comms.threads import Thread +from agent_comms.turn_input_source import AcceptedTurn from compaction_loopback import LoopbackProvider +from delivery_owner_fixture import canonical_agent from native_proof_cases import read_proof_rows -from agent_comms.threads import Thread async def wait_for(predicate): diff --git a/tests/test_absence_owners.py b/tests/test_absence_owners.py index 3de32017e9..c96a359959 100644 --- a/tests/test_absence_owners.py +++ b/tests/test_absence_owners.py @@ -1,23 +1,22 @@ """Behaviour-bearing absence belongs to the state that owns the operation.""" -from agent_comms import agent_events as events -from agent_comms.pi_payloads import AnonymousRequest, RequestId import asyncio import pytest +from agent_comms import agent_events as events from agent_comms.agent_backend import InputStartListener, NoInputStartListener, SendNow, TurnInbox -from agent_comms.field_codec import InputId from agent_comms.backend import EventFinishSignal, FinishSignal, NoFinishSignal from agent_comms.change_report import FileLine, NoSourceReference -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, InputId from agent_comms.input_drain import DrainedSession from agent_comms.native_child import NoInputForwarder -from agent_comms.native_startup import MeasuredSessionLoad, SessionLoad, UnmeasuredSessionLoad from agent_comms.native_session_reopen import SessionRetention +from agent_comms.native_startup import MeasuredSessionLoad, SessionLoad, UnmeasuredSessionLoad from agent_comms.pi_commands import GetState -from agent_comms.turn_input_source import SelectedOriginalInput +from agent_comms.pi_payloads import AnonymousRequest, RequestId from agent_comms.turn_goal_permission import InactiveGoalPermission, TurnGoalPermission +from agent_comms.turn_input_source import SelectedOriginalInput def test_drain_lifecycle_keeps_inbox_and_original_in_one_owner(): diff --git a/tests/test_acp_extension.py b/tests/test_acp_extension.py index 0996e21126..d532eb76cd 100644 --- a/tests/test_acp_extension.py +++ b/tests/test_acp_extension.py @@ -1,8 +1,9 @@ +"""Declared extension decoding is strict and shares its owner with consumers.""" + from agent_comms.child_process import ProcessIdentity from agent_comms.field_codec import TurnId from agent_comms.acp_failure import ACPFailure -"""Declared extension decoding is strict and shares its owner with consumers.""" import pytest from agent_comms.goal_presentation import IdleGoalExecution from agent_comms.goals import NoGoal diff --git a/tests/test_atomic_turn_source_owner.py b/tests/test_atomic_turn_source_owner.py index 711f52f1be..68ac988f8b 100644 --- a/tests/test_atomic_turn_source_owner.py +++ b/tests/test_atomic_turn_source_owner.py @@ -1,10 +1,7 @@ """Original begin/attachment ownership with real registry and callback resources.""" -from agent_comms.turn_input_source import NoTurnAcceptance - - -from contextlib import AsyncExitStack import os +from contextlib import AsyncExitStack import pytest @@ -12,18 +9,18 @@ from agent_comms.agent_events import AgentInfo from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire -from conftest import installed_comms from agent_comms.coordination_errors import StaleFence from agent_comms.field_codec import TurnId -from agent_comms.session_file import RegistrySessionLocation from agent_comms.goal_actions import SetGoalAction from agent_comms.native_input_owner import RegistryOwner from agent_comms.owned_turn import OwnedTurn -from agent_comms.turn_phase import PreparingPhase -from agent_comms.threads import ChosenModel, NextTurnConfiguration +from agent_comms.session_file import RegistrySessionLocation from agent_comms.thread_model import DeclaredModel +from agent_comms.threads import ChosenModel, NextTurnConfiguration, Thread +from agent_comms.turn_input_source import NoTurnAcceptance from agent_comms.turn_lease import NoTurnLease -from agent_comms.threads import Thread +from agent_comms.turn_phase import PreparingPhase +from conftest import installed_comms @pytest.mark.asyncio diff --git a/tests/test_channel_bus_ownership.py b/tests/test_channel_bus_ownership.py index 992b154f84..20da281e9d 100644 --- a/tests/test_channel_bus_ownership.py +++ b/tests/test_channel_bus_ownership.py @@ -1,17 +1,16 @@ """Channel membership and notifications have one canonical bus authority.""" -from agent_comms.turn_input_source import NoTurnAcceptance - -from bus_history import read_history -from dataclasses import replace from contextlib import AsyncExitStack +from dataclasses import replace from agent_comms.channel_targets import CommandTarget from agent_comms.coordination_cohort import accept_delivery_cohort from agent_comms.coordinator import Coordination +from agent_comms.native_arguments import GivenBinary from agent_comms.notification_progress import SentProgress +from agent_comms.turn_input_source import NoTurnAcceptance from agent_comms.wake_policy import BoundedTriageWake -from agent_comms.native_arguments import GivenBinary +from bus_history import read_history from test_coordinated_runtime import _root, tmp_path # noqa: F401 diff --git a/tests/test_goal_execution.py b/tests/test_goal_execution.py index cc891ba44c..92b046fe70 100644 --- a/tests/test_goal_execution.py +++ b/tests/test_goal_execution.py @@ -1,7 +1,5 @@ """Real turn acquisition and terminal handling share the execution owner.""" -from agent_comms.turn_input_source import NoTurnAcceptance - import os import sqlite3 from contextlib import AsyncExitStack, asynccontextmanager, closing @@ -29,8 +27,9 @@ ) from agent_comms.goal_states import ActiveGoal from agent_comms.owned_turn import OwnedTurn -from agent_comms.turn_terminal import OwnerStop from agent_comms.threads import Thread +from agent_comms.turn_input_source import NoTurnAcceptance +from agent_comms.turn_terminal import OwnerStop @pytest.fixture diff --git a/tests/test_native_retry_exhaustion_installed.py b/tests/test_native_retry_exhaustion_installed.py index 8346122bf3..b887cec8dc 100644 --- a/tests/test_native_retry_exhaustion_installed.py +++ b/tests/test_native_retry_exhaustion_installed.py @@ -1,7 +1,5 @@ """Installed native 429 exhaustion reaches one completed user-visible failure.""" -from agent_comms.turn_input_source import NoTurnAcceptance -from agent_comms.controller import SessionUpdateSink import asyncio import json from pathlib import Path @@ -10,13 +8,15 @@ import agent_comms.native_pi as native_pi from agent_comms.acp_extension import RequestFailedUpdate, decode_updates -from agent_comms.native_arguments import GivenArguments, GivenBinary -from agent_comms.pi_native_backend import PiNativeBackend from agent_comms.acp_failure import ( NativeMessageFailureSource, - ProviderQuotaFailure, NativeTurnError, + ProviderQuotaFailure, ) +from agent_comms.controller import SessionUpdateSink +from agent_comms.native_arguments import GivenArguments, GivenBinary +from agent_comms.pi_native_backend import PiNativeBackend +from agent_comms.turn_input_source import NoTurnAcceptance from delivery_owner_fixture import canonical_agent RATE_LIMIT = "Too many requests for this provider" diff --git a/tests/test_owned_turn_goal_replacement.py b/tests/test_owned_turn_goal_replacement.py index 8b3d577863..8041dd50a7 100644 --- a/tests/test_owned_turn_goal_replacement.py +++ b/tests/test_owned_turn_goal_replacement.py @@ -1,7 +1,5 @@ """A replaced order keeps the running turn's authority, so following input joins that turn; a new original captures the current goal.""" -from agent_comms.turn_input_source import NoTurnAcceptance - import asyncio import os from contextlib import AsyncExitStack @@ -14,10 +12,11 @@ from agent_comms.agent_backend import InputContent from agent_comms.channel_input_batch import InputBatch from agent_comms.child_process import ProcessIdentity -from conftest import installed_comms from agent_comms.field_codec import InputId, TurnId from agent_comms.goal_actions import ( - BlockedGoalAction, CreateProblemGoalAction, OrderGoalAction, + BlockedGoalAction, + CreateProblemGoalAction, + OrderGoalAction, ) from agent_comms.goal_states import ActiveGoal from agent_comms.owned_send import OwnedSend @@ -26,11 +25,13 @@ from agent_comms.recorded_time import RecordedEventTime from agent_comms.repository_name import RepositoryName from agent_comms.reservation_rules import ReservationViolationError +from agent_comms.threads import Thread +from agent_comms.turn_input_source import NoTurnAcceptance from compaction_loopback import LoopbackProvider +from conftest import installed_comms from native_backend_fixture import native_backend_fixture, wait_for -from test_todos import order from source_revision_fixture import SourceRepository -from agent_comms.threads import Thread +from test_todos import order class FollowingReadProvider(LoopbackProvider): diff --git a/tests/test_private_idle_drain.py b/tests/test_private_idle_drain.py index b3fc233773..f8c254ae25 100644 --- a/tests/test_private_idle_drain.py +++ b/tests/test_private_idle_drain.py @@ -1,10 +1,5 @@ """Idle observations avoid repeated work without becoming delivery authority.""" -from agent_comms.turn_input_source import NoTurnAcceptance - -from bus_history import read_history -from conftest import publish_initial -from agent_comms.messages import NoticeRole import asyncio import os import threading @@ -13,15 +8,18 @@ import pytest import agent_comms.agent_events as events -from agent_comms.coordinator import Coordination from agent_comms.child_process import ProcessIdentity +from agent_comms.coordinator import Coordination +from agent_comms.messages import AlertKind, InfoKind, Message, NoticeRole from agent_comms.owned_turn import OwnedTurn -from agent_comms.messages import AlertKind, InfoKind, Message from agent_comms.private_path import FileRevision from agent_comms.store_files import _store_lock +from agent_comms.threads import Thread +from agent_comms.turn_input_source import NoTurnAcceptance +from bus_history import read_history +from conftest import publish_initial from test_acp_private_nk_delivery import _session from test_coordinated_runtime import _root, tmp_path # noqa: F401 -from agent_comms.threads import Thread @pytest.mark.asyncio diff --git a/tests/test_stack_persistent_compaction.py b/tests/test_stack_persistent_compaction.py index fec5f62279..8b53980280 100644 --- a/tests/test_stack_persistent_compaction.py +++ b/tests/test_stack_persistent_compaction.py @@ -1,7 +1,4 @@ """A manual /compact runs Pi's own engine in the retained child, which keeps serving.""" -from agent_comms.field_codec import InputId -from agent_comms.controller import SessionUpdateSink - import asyncio import json import os @@ -25,6 +22,8 @@ ) from agent_comms.agent_events import CompactionEnd from agent_comms.comms import wire +from agent_comms.controller import SessionUpdateSink +from agent_comms.field_codec import InputId from agent_comms.launch_runtime import RestartEnvironment from agent_comms.native_arguments import GivenArguments, GivenBinary from agent_comms.pi_vocabulary import ManualCompactionReason diff --git a/tests/test_tracked_native_retry.py b/tests/test_tracked_native_retry.py index 0a0e57c600..3e6a13cd80 100644 --- a/tests/test_tracked_native_retry.py +++ b/tests/test_tracked_native_retry.py @@ -1,20 +1,26 @@ -from agent_comms.field_codec import InputId -from agent_comms.turn_input_source import AcceptedTurn import pytest +from agent_comms.agent_backend import SteerInputPolicy +from agent_comms.field_codec import InputId from agent_comms.native_arguments import SelectedModel +from agent_comms.native_failures import NativePiUnavailable +from agent_comms.native_pi import NativeEmptyAnswer, NativePiTerminalFailure from agent_comms.native_startup import NativeStartupPolicy - -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 -from agent_comms.turn_input_source import AcceptedFollowingInput, DeliveredFollowingInput +from agent_comms.tracked_turn import ( + CompletedTrackedTerminal, + PendingTrackedTerminal, + TrackedTurnSession, +) +from agent_comms.turn_failure import ModelRequestFailed, RetryableProviderFailure from agent_comms.turn_goal_permission import InactiveGoalPermission, TurnGoalPermission -from agent_comms.native_pi import NativeEmptyAnswer, NativePiTerminalFailure -from agent_comms.native_failures import NativePiUnavailable +from agent_comms.turn_input_source import ( + AcceptedFollowingInput, + AcceptedTurn, + DeliveredFollowingInput, +) from agent_comms.turn_output import TurnOutput -from agent_comms.turn_failure import ModelRequestFailed, RetryableProviderFailure from agent_comms.turn_usage import UsageAccount from agent_comms.turn_watchdog import DEFAULT_MODEL_WAIT, ProgressWatchdog From 7f04146993882f5a4c7d165807dc2e12f310b2f5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 21:24:11 -0400 Subject: [PATCH 21/32] Keep representation queries total and carry nominal message IDs to their callers --- src/agent_comms/envelope_claim_transitions.py | 3 +- src/agent_comms/field_codec.py | 72 +++++++++---------- src/agent_comms/selected_write_plan.py | 6 +- src/agent_comms/typed_table.py | 12 ++-- tests/test_carry_wake_claim_turn_ids.py | 4 +- tests/test_envelope_claim_transitions.py | 4 +- tests/test_selected_tool_broker.py | 4 +- 7 files changed, 51 insertions(+), 54 deletions(-) diff --git a/src/agent_comms/envelope_claim_transitions.py b/src/agent_comms/envelope_claim_transitions.py index f7133dfc84..0b497bf023 100644 --- a/src/agent_comms/envelope_claim_transitions.py +++ b/src/agent_comms/envelope_claim_transitions.py @@ -19,6 +19,7 @@ EnvelopeSequence, FieldCodec, Id128Text, + MessageId, TurnId, NullMember, ProjectionSequence, @@ -208,7 +209,7 @@ class WakeClaim(RecordMember, RecordedScope): wire_root_id: Annotated[str, Id128Text] source_seq: int - source_message_id: Annotated[str, BoundedText] + source_message_id: MessageId wake_assignment_id: Annotated[str, BoundedText, WireName("wake_claim_id")] wake_revision: int recipient_lookup: Annotated[str, Id128Text] diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index e8f4d37a83..918aed68b1 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -659,8 +659,10 @@ class DeclaredFieldPlan(FieldPlan): @classmethod def claims(cls, declared: Field, annotation: object) -> bool: - representation = FieldCodec.representation(annotation)[1] or FieldRepresentation - return super().claims(declared, annotation) or representation.field_plan is cls + _, representations = FieldCodec.representation(annotation) + return super().claims(declared, annotation) or any( + owner.field_plan is cls for owner in (representations or (FieldRepresentation,)) + ) class TransientFieldPlan(DeclaredFieldPlan): @@ -747,10 +749,10 @@ class InlineFieldPlan(DeclaredFieldPlan): @cached_property def inlined(self) -> type: - declared, representation = FieldCodec.representation(self.annotation) - match representation: - case type() as inline if issubclass(inline, InlineRecord): - return inline.wire_record() + declared, representations = FieldCodec.representation(self.annotation) + for representation in representations: + if issubclass(representation, InlineRecord): + return representation.wire_record() return cast(type, declared) @cached_property @@ -1884,41 +1886,38 @@ def external_decoder(cls, target: type[ExternalField]) -> ExternalDecoder: return target.decode_external @classmethod - def representation(cls, annotation: object) -> tuple[object, type[FieldRepresentation] | None]: - """The declared value type and the codec owning its wire representation.""" + def representation( + cls, annotation: object, + ) -> tuple[object, tuple[type[FieldRepresentation], ...]]: + """The value type and its zero or one declared wire representation classes.""" if annotation is None: - return None, None + return None, () if annotation is object: - return object, _JsonValueRepresentation + return object, (_JsonValueRepresentation,) if get_origin(annotation) is Annotated: target, *metadata = get_args(annotation) else: target, metadata = annotation, () - representations = [ + representations = tuple( item for item in metadata if isinstance(item, type) and issubclass(item, FieldRepresentation) - ] + ) if len(representations) > 1: raise TypeError("A field must have one representation") if isinstance(target, TypeAliasType): if target is JsonValue: - return object, representations[0] if representations else _JsonValueRepresentation + return object, representations or (_JsonValueRepresentation,) if representations: - return target, representations[0] + return target, representations if target is object: - return object, _JsonValueRepresentation + return object, (_JsonValueRepresentation,) if isinstance(target, type) and issubclass(target, (NominalText, WireValue)): - return target, target.representation() - return target, None + return target, (target.representation(),) + return target, () @classmethod def _value_representation(cls, value: object, annotation: object): - if annotation is None: - return cls.representation(type(value))[1] - _, representation = cls.representation(annotation) - if representation is None: - _, representation = cls.representation(type(value)) - return representation + return cls.representation(annotation)[1] or cls.representation(type(value))[1] @classmethod def record_fields(cls, record: type) -> tuple[tuple[Field[object], str], ...]: @@ -2064,11 +2063,6 @@ def record_member[Member: DeclaredFamily]( """Read a record's declared selector without decoding its payload.""" return _FamilyRecordDecoder(family).member(data) - @classmethod - def record_data(cls, record: type, data: object) -> dict: - """Validate the envelope independently of reading its declared payloads.""" - return _RecordDecoder(record).checked(data) - @classmethod def field_plan(cls, record: type, name: str) -> FieldPlan: return next(item for item in cls._record_plan(record).bindings if item.name == name) @@ -2144,8 +2138,8 @@ def _record_schema( @classmethod def value_schema(cls, annotation: object) -> dict[str, JsonValue]: """Derive external scalar/array choices from the same decode declarations.""" - annotation, representation = cls.representation(annotation) - if representation is not None: + annotation, representations = cls.representation(annotation) + for representation in representations: schema = representation.schema() if type(None) in get_args(annotation): return cls.alternatives_schema("anyOf", [schema, {"type": "null"}]) @@ -2193,7 +2187,7 @@ def project(cls, value: object, view: str, annotation: object = None) -> JsonVal Projection is deliberately one way. Excluded secrets cannot be rebuilt from a read-only view, and a view never serves as a persistence record. """ - if cls._value_representation(value, annotation) is not None: + if cls._value_representation(value, annotation): return RepresentedProjection.of(value, view, annotation) if is_dataclass(value) and not isinstance(value, type): return cls._project_record(value, view) @@ -3172,14 +3166,14 @@ def compile_decoder(annotation: object) -> ValueDecoder[object]: ... @lru_cache(maxsize=None) def compile_decoder(annotation: object) -> ValueDecoder[object]: """Compile each immutable declaration once; value paths never parse it again.""" - declared, representation = FieldCodec.representation(annotation) - if representation is None: - return _structural_decoder(declared) - try: - owner = ValueDecoder.annotation_class(declared) - except KeyError: - owner = _ScalarDecoder - return owner.with_representation(annotation, declared, get_args(declared), representation) + declared, representations = FieldCodec.representation(annotation) + for representation in representations: + try: + owner = ValueDecoder.annotation_class(declared) + except KeyError: + owner = _ScalarDecoder + return owner.with_representation(annotation, declared, get_args(declared), representation) + return _structural_decoder(declared) def _structural_decoder(annotation: object) -> ValueDecoder[object]: diff --git a/src/agent_comms/selected_write_plan.py b/src/agent_comms/selected_write_plan.py index 4af2e424db..e6fc1bb6fb 100644 --- a/src/agent_comms/selected_write_plan.py +++ b/src/agent_comms/selected_write_plan.py @@ -33,7 +33,7 @@ from .coordination_cohort import sealed_cohort_assignments from .declared_family import DeclaredFamily from .envelope_claim_transitions import ExistingFileClaim -from .field_codec import ClaimId, FieldCodec, Id128Text, MalformedRecord +from .field_codec import ClaimId, FieldCodec, Id128Text, MalformedRecord, MessageId from .message_reference import MessageReference from .private_path import PrivateDirectoryRole, PrivateFileRole from .thread_identity import ClaimedIdentity, ClaimScoped, RecordedThreadIncarnation @@ -63,7 +63,7 @@ class SelectedWriteIntent(DeclaredFamily, affix="WriteIntent"): schema: Literal[1] root_id: str source_seq: int - source_message_id: str + source_message_id: MessageId claim_id: str owner: RecordedThreadIncarnation claim_epoch: int @@ -172,7 +172,7 @@ def submit( if type(source_seq) is not int or source_seq <= 0: raise IdentityConflict("Selected write requires a positive source sequence") try: - FieldCodec.decode(str, source_message_id) + source_message_id = FieldCodec.decode(MessageId, source_message_id) FieldCodec.decode(str, resource) FieldCodec.decode(str, contents) except MalformedRecord as error: diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index ef52785821..bf0e39cbbc 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -185,8 +185,8 @@ class TextStorage(SqlStorage): def accepts(cls, annotation: object) -> bool: if annotation is str: return True - _, representation = FieldCodec.representation(annotation) - return representation is not None and issubclass(representation, TextRepresentation) + _, representations = FieldCodec.representation(annotation) + return any(issubclass(owner, TextRepresentation) for owner in representations) class IntegerStorage(SqlStorage): @@ -2108,11 +2108,13 @@ def producer_owners(cls) -> tuple[type, ...]: if issubclass(parent, ProjectionSource) and ("writes" not in vars(parent) or parent in writers) ) - annotations = [column.annotation for column in cls._columns] if is_dataclass(cls) else [] + annotations: list[object] = ( + [column.annotation for column in cls._columns] if is_dataclass(cls) else [] + ) while annotations: - value, representation = FieldCodec.representation(annotations.pop()) + value, representations = FieldCodec.representation(annotations.pop()) annotations.extend(get_args(value)) - for owner in (value, representation): + for owner in (value, *representations): if not isinstance(owner, type): continue owners.update( diff --git a/tests/test_carry_wake_claim_turn_ids.py b/tests/test_carry_wake_claim_turn_ids.py index 02b762c2e1..4247c62dee 100644 --- a/tests/test_carry_wake_claim_turn_ids.py +++ b/tests/test_carry_wake_claim_turn_ids.py @@ -10,7 +10,7 @@ from agent_comms.bus import Bus from agent_comms.envelope_claim_transitions import ClaimTransition, WakeClaim, WakeClaims -from agent_comms.field_codec import FieldCodec, MalformedRecord, TurnId +from agent_comms.field_codec import FieldCodec, MalformedRecord, MessageId, TurnId from agent_comms.messages import BusRow, Claimed @@ -23,7 +23,7 @@ def old_claim_bus(root: Path, *, turn_id: str = "a" * 32) -> Path: generation="b" * 32, resource_scope=WakeClaim( owner_claimed_generation=1, operation_id="c" * 32, turn_id=TurnId("a" * 32), - wire_root_id="d" * 32, source_seq=1, source_message_id="source-1", + wire_root_id="d" * 32, source_seq=1, source_message_id=MessageId.mint("source-1"), wake_assignment_id="cohort-v1:" + "e" * 64, wake_revision=1, recipient_lookup="f" * 32, execution_id="execution-1", participant_generation=1, attempt_ordinal=1, diff --git a/tests/test_envelope_claim_transitions.py b/tests/test_envelope_claim_transitions.py index fa14fc4403..b7ee6c8089 100644 --- a/tests/test_envelope_claim_transitions.py +++ b/tests/test_envelope_claim_transitions.py @@ -12,7 +12,7 @@ ClaimTransitionError, ExistingFileClaim, NativeClaim, ReleasedClaims, UnscopedClaims, WakeClaim, WakeClaims, ) -from agent_comms.field_codec import FieldCodec, MalformedRecord, TurnId +from agent_comms.field_codec import FieldCodec, MalformedRecord, MessageId, TurnId from agent_comms.messages import ClaimMessage, ClaimNoticeKind, Message G1 = "a" * 32 @@ -71,7 +71,7 @@ def test_selected_wake_binding_survives_claim_projection(tmp_path): claim_scope = WakeClaim( wire_root_id="c" * 32, source_seq=7, - source_message_id="source-7", + source_message_id=MessageId.mint("source-7"), wake_assignment_id="cohort-v1:" + "d" * 64, wake_revision=3, recipient_lookup="e" * 32, diff --git a/tests/test_selected_tool_broker.py b/tests/test_selected_tool_broker.py index 8990846b83..ae1c25996b 100644 --- a/tests/test_selected_tool_broker.py +++ b/tests/test_selected_tool_broker.py @@ -18,7 +18,7 @@ from agent_comms.coordinator import Coordination from agent_comms.coordination_database import CoordinationStore from agent_comms.envelope_claim_transitions import WakeClaim -from agent_comms.field_codec import TurnId +from agent_comms.field_codec import MessageId, TurnId from agent_comms.native_tool_call import ( DuplicateToolCallIdentity, OwnerToolConnection, @@ -102,7 +102,7 @@ def test_owner_mode_denies_unreserved_input_before_consuming_ledger(private_comm claim_scope = WakeClaim( wire_root_id="a" * 32, source_seq=1, - source_message_id="source", + source_message_id=MessageId.mint("source"), wake_assignment_id="cohort-v1:" + "b" * 64, wake_revision=1, recipient_lookup="c" * 32, From bfa24f66899df588427f7fe273b370f24681284a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 21:33:45 -0400 Subject: [PATCH 22/32] Give structural field representation its own behavior and decode whole wake claims --- src/agent_comms/field_codec.py | 118 ++++++++++++++------- src/agent_comms/selected_turn.py | 3 +- src/agent_comms/typed_table.py | 13 ++- tools/cutover/carry_wake_claim_turn_ids.py | 42 ++++---- 4 files changed, 111 insertions(+), 65 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 918aed68b1..17129799ec 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -659,10 +659,8 @@ class DeclaredFieldPlan(FieldPlan): @classmethod def claims(cls, declared: Field, annotation: object) -> bool: - _, representations = FieldCodec.representation(annotation) - return super().claims(declared, annotation) or any( - owner.field_plan is cls for owner in (representations or (FieldRepresentation,)) - ) + _, representation = FieldCodec.representation(annotation) + return super().claims(declared, annotation) or representation.field_plan is cls class TransientFieldPlan(DeclaredFieldPlan): @@ -749,10 +747,10 @@ class InlineFieldPlan(DeclaredFieldPlan): @cached_property def inlined(self) -> type: - declared, representations = FieldCodec.representation(self.annotation) - for representation in representations: - if issubclass(representation, InlineRecord): - return representation.wire_record() + declared, representation = FieldCodec.representation(self.annotation) + match representation: + case type() as inline if issubclass(inline, InlineRecord): + return inline.wire_record() return cast(type, declared) @cached_property @@ -1171,6 +1169,33 @@ class FieldRepresentation[T](MroDispatch, ABC): field_plan: ClassVar[type[FieldPlan]] = DeclaredFieldPlan decoder: ClassVar[type[_RepresentedDecoder]] = _RepresentedDecoder + @classmethod + def for_value(cls, value: object) -> type[FieldRepresentation]: + return cls + + @classmethod + def project(cls, value: object, view: str, annotation: object) -> JsonValue: + return RepresentedProjection.of(value, view, annotation) + + @classmethod + def producer_owners(cls) -> tuple[type[FieldRepresentation], ...]: + return tuple(parent for parent in cls.__mro__ if issubclass(parent, FieldRepresentation)) + + @classmethod + def decoder_for(cls, annotation: object, target: object) -> ValueDecoder[object]: + try: + owner = ValueDecoder.annotation_class(target) + except KeyError: + owner = _ScalarDecoder + return owner.with_representation(annotation, target, get_args(target), cls) + + @classmethod + def schema_for(cls, annotation: object) -> dict[str, JsonValue]: + schema = cls.schema() + if type(None) in get_args(annotation): + return FieldCodec.alternatives_schema("anyOf", [schema, {"type": "null"}]) + return schema + @classmethod def wire_structure(cls, structural: DecoderPlan) -> DecoderPlan: """A custom scalar form stays opaque; declared structural forms supply their plan.""" @@ -1210,6 +1235,30 @@ def argument_options(cls, argument: Argument) -> dict[str, object]: return {} +class StructuralRepresentation(FieldRepresentation): + """The value has no representation codec, so FieldCodec handles its structure.""" + + @classmethod + def for_value(cls, value: object) -> type[FieldRepresentation]: + return FieldCodec.representation(type(value))[1] + + @classmethod + def project(cls, value: object, view: str, annotation: object) -> JsonValue: + return FieldCodec._project_structure(value, view, annotation) + + @classmethod + def producer_owners(cls) -> tuple[type[FieldRepresentation], ...]: + return () + + @classmethod + def decoder_for(cls, annotation: object, target: object) -> ValueDecoder[object]: + return _structural_decoder(target) + + @classmethod + def schema_for(cls, annotation: object) -> dict[str, JsonValue]: + return FieldCodec._structure_schema(annotation) + + class AcceptsNull[T](FieldRepresentation[T]): """Null is this representation's input, not the structural target's.""" @@ -1888,36 +1937,38 @@ def external_decoder(cls, target: type[ExternalField]) -> ExternalDecoder: @classmethod def representation( cls, annotation: object, - ) -> tuple[object, tuple[type[FieldRepresentation], ...]]: - """The value type and its zero or one declared wire representation classes.""" + ) -> tuple[object, type[FieldRepresentation]]: + """The value type and its declared representation, structural when unencoded.""" if annotation is None: - return None, () + return None, StructuralRepresentation if annotation is object: - return object, (_JsonValueRepresentation,) + return object, _JsonValueRepresentation if get_origin(annotation) is Annotated: target, *metadata = get_args(annotation) else: target, metadata = annotation, () - representations = tuple( + representations = [ item for item in metadata if isinstance(item, type) and issubclass(item, FieldRepresentation) - ) + ] if len(representations) > 1: raise TypeError("A field must have one representation") if isinstance(target, TypeAliasType): if target is JsonValue: - return object, representations or (_JsonValueRepresentation,) + if representations: + return object, representations[0] + return object, _JsonValueRepresentation if representations: - return target, representations + return target, representations[0] if target is object: - return object, (_JsonValueRepresentation,) + return object, _JsonValueRepresentation if isinstance(target, type) and issubclass(target, (NominalText, WireValue)): - return target, (target.representation(),) - return target, () + return target, target.representation() + return target, StructuralRepresentation @classmethod def _value_representation(cls, value: object, annotation: object): - return cls.representation(annotation)[1] or cls.representation(type(value))[1] + return cls.representation(annotation)[1].for_value(value) @classmethod def record_fields(cls, record: type) -> tuple[tuple[Field[object], str], ...]: @@ -2138,12 +2189,11 @@ def _record_schema( @classmethod def value_schema(cls, annotation: object) -> dict[str, JsonValue]: """Derive external scalar/array choices from the same decode declarations.""" - annotation, representations = cls.representation(annotation) - for representation in representations: - schema = representation.schema() - if type(None) in get_args(annotation): - return cls.alternatives_schema("anyOf", [schema, {"type": "null"}]) - return schema + annotation, representation = cls.representation(annotation) + return representation.schema_for(annotation) + + @classmethod + def _structure_schema(cls, annotation: object) -> dict[str, JsonValue]: origin, args = get_origin(annotation), get_args(annotation) if origin in (Union, types.UnionType): members = list(args) @@ -2187,8 +2237,10 @@ def project(cls, value: object, view: str, annotation: object = None) -> JsonVal Projection is deliberately one way. Excluded secrets cannot be rebuilt from a read-only view, and a view never serves as a persistence record. """ - if cls._value_representation(value, annotation): - return RepresentedProjection.of(value, view, annotation) + return cls._value_representation(value, annotation).project(value, view, annotation) + + @classmethod + def _project_structure(cls, value: object, view: str, annotation: object) -> JsonValue: if is_dataclass(value) and not isinstance(value, type): return cls._project_record(value, view) if isinstance(value, (tuple, list)): @@ -3166,14 +3218,8 @@ def compile_decoder(annotation: object) -> ValueDecoder[object]: ... @lru_cache(maxsize=None) def compile_decoder(annotation: object) -> ValueDecoder[object]: """Compile each immutable declaration once; value paths never parse it again.""" - declared, representations = FieldCodec.representation(annotation) - for representation in representations: - try: - owner = ValueDecoder.annotation_class(declared) - except KeyError: - owner = _ScalarDecoder - return owner.with_representation(annotation, declared, get_args(declared), representation) - return _structural_decoder(declared) + declared, representation = FieldCodec.representation(annotation) + return representation.decoder_for(annotation, declared) def _structural_decoder(annotation: object) -> ValueDecoder[object]: diff --git a/src/agent_comms/selected_turn.py b/src/agent_comms/selected_turn.py index 20993af9ab..140e14d961 100644 --- a/src/agent_comms/selected_turn.py +++ b/src/agent_comms/selected_turn.py @@ -16,6 +16,7 @@ from .coordination_tables.responses import ResponseObligation from .durable_turn import DurableTurn from .envelope_claim_transitions import WakeClaim +from .field_codec import FieldCodec, MessageId from .optional_awareness_projection import OptionalAwarenessProjection from .native_failures import NativePiUnavailable from .owner_fence import prepare_fence_token @@ -151,7 +152,7 @@ def tool_owner(self, session, input_id, action) -> SelectedCodingToolOwner: claim_scope = WakeClaim( wire_root_id=participant.root_id, source_seq=assignment.wire_seq, - source_message_id=assignment.message_id, + source_message_id=FieldCodec.decode(MessageId, assignment.message_id), wake_assignment_id=assignment.assignment_id, wake_revision=assignment.revision, recipient_lookup=participant.lookup, diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index bf0e39cbbc..725c731be5 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -185,8 +185,8 @@ class TextStorage(SqlStorage): def accepts(cls, annotation: object) -> bool: if annotation is str: return True - _, representations = FieldCodec.representation(annotation) - return any(issubclass(owner, TextRepresentation) for owner in representations) + _, representation = FieldCodec.representation(annotation) + return issubclass(representation, TextRepresentation) class IntegerStorage(SqlStorage): @@ -2112,15 +2112,14 @@ def producer_owners(cls) -> tuple[type, ...]: [column.annotation for column in cls._columns] if is_dataclass(cls) else [] ) while annotations: - value, representations = FieldCodec.representation(annotations.pop()) + value, representation = FieldCodec.representation(annotations.pop()) annotations.extend(get_args(value)) - for owner in (value, *representations): - if not isinstance(owner, type): - continue + if isinstance(value, type): owners.update( - parent for parent in owner.__mro__ + parent for parent in value.__mro__ if issubclass(parent, (FieldRepresentation, NominalText)) ) + owners.update(representation.producer_owners()) return tuple(sorted(owners, key=lambda owner: (owner.__module__, owner.__qualname__))) diff --git a/tools/cutover/carry_wake_claim_turn_ids.py b/tools/cutover/carry_wake_claim_turn_ids.py index 8fa61792f9..f0ed7f1374 100644 --- a/tools/cutover/carry_wake_claim_turn_ids.py +++ b/tools/cutover/carry_wake_claim_turn_ids.py @@ -1,6 +1,5 @@ """Validate version-one wake turn identities before stamping their narrowed format.""" -import sys from contextlib import closing from functools import partial from pathlib import Path @@ -8,7 +7,7 @@ from agent_comms.bus import Bus from agent_comms.envelope_claim_transitions import WakeClaim -from agent_comms.field_codec import FieldCodec, TurnId +from agent_comms.field_codec import FieldCodec, MalformedRecord from agent_comms.historical_views import HistoryArchive from agent_comms.installer.probes.carry import ArchivedInputs, Carried, Carry from agent_comms.locked_store import RootFile @@ -27,22 +26,17 @@ def roots(self) -> tuple[Path, ...]: @staticmethod def rewrite(path: Path, record: type, payload: dict[str, object]) -> dict[str, object]: - if record is not WakeClaim or payload["version"] != 1: - return payload - value = payload["turn_id"] try: - turn_id = FieldCodec.decode(TurnId, value) - finally: - # Keep the codec's refusal and traceback; add the old file/value without catching it. - if (error := sys.exception()) is not None: - error.add_note(f"{path}: WakeClaim.turn_id={value!r}") - return { - **payload, "turn_id": FieldCodec.encode(turn_id, TurnId), "version": WakeClaim.version, - } + return FieldCodec.encode( + FieldCodec.decode(WakeClaim, {**payload, "version": WakeClaim.version}) + ) + except MalformedRecord as error: + error.add_note(f"{path}: {payload!r}") + raise def carry(self, *, apply: bool) -> Carried: table = Claimed.table - column, = table._fields_named(("claim_transition",)) + (column,) = table._fields_named(("claim_transition",)) changed = 0 rewritten: list[Path] = [] for path in self.input_files(): @@ -50,20 +44,26 @@ def carry(self, *, apply: bool) -> Carried: with closing(Bus.read_snapshot(path)) as db: if not table.present(db): continue - for seq, stored in db.execute(table._sql( - f'SELECT {{seq}}, {{claim_transition}} FROM "{table.declared_name}"' - )): + for seq, stored in db.execute( + table._sql(f'SELECT {{seq}}, {{claim_transition}} FROM "{table.declared_name}"') + ): original = column.storage.from_sql(stored) current = column.value.rewrite_document( - original, partial(self.rewrite, path), lambda record: record is WakeClaim, + original, + partial(self.rewrite, path), + lambda record: record is WakeClaim, ) if current != original: updates.append((column.storage.to_sql(current), seq)) changed += len(updates) if apply and updates: with closing(Bus.writer(path)) as db, Bus.write_transaction(db): - db.executemany(table._sql( - f'UPDATE "{table.declared_name}" SET {{claim_transition}}=? WHERE {{seq}}=?' - ), updates) + db.executemany( + table._sql( + f'UPDATE "{table.declared_name}" SET ' + "{claim_transition}=? WHERE {seq}=?" + ), + updates, + ) rewritten.append(path.relative_to(self.root)) return Carried(changed, rewritten=tuple(rewritten)) From 386d6259681e0f1b83cba8fb906f7aadf326d54d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 21:40:50 -0400 Subject: [PATCH 23/32] Type the selected representation before dispatching its projection --- src/agent_comms/field_codec.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 17129799ec..d016c0d7e4 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -1967,7 +1967,9 @@ def representation( return target, StructuralRepresentation @classmethod - def _value_representation(cls, value: object, annotation: object): + def _value_representation( + cls, value: object, annotation: object, + ) -> type[FieldRepresentation]: return cls.representation(annotation)[1].for_value(value) @classmethod From 8d2a9adebca141d2f9b1036534b2f2f88e2b832b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 21:41:57 -0400 Subject: [PATCH 24/32] Resolve represented decoder candidates without swallowed lookup errors --- src/agent_comms/field_codec.py | 41 +++++++++++++--------- tools/cutover/carry_wake_claim_turn_ids.py | 8 +++-- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index d016c0d7e4..511a0834e1 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -963,18 +963,28 @@ def from_annotation(cls, target: object, args: tuple[object, ...]) -> ValueDecod return cls(target) @classmethod - def annotation_class(cls, annotation: object) -> type[ValueDecoder[object]]: - try: - return cls._by_annotation[annotation] - except KeyError: - pass + def _registered(cls, annotation: object) -> tuple[type[ValueDecoder[object]], ...]: + matches: list[type[ValueDecoder[object]]] = [] + if annotation in cls._by_annotation: + matches.append(cls._by_annotation[annotation]) declarations = annotation.__mro__ if isinstance(annotation, type) else (annotation,) for declaration in declarations: - try: - return cls._by_origin[get_origin(declaration) or declaration] - except KeyError: - continue - raise KeyError(annotation) + origin = get_origin(declaration) or declaration + if origin in cls._by_origin: + matches.append(cls._by_origin[origin]) + return tuple(matches) + + @classmethod + def annotation_class(cls, annotation: object) -> type[ValueDecoder[object]]: + matches = cls._registered(annotation) + if not matches: + raise KeyError(annotation) + return matches[0] + + @classmethod + def represented_class(cls, annotation: object) -> type[ValueDecoder[object]]: + """A represented value with no registered decoder is a scalar of its representation.""" + return next(iter(cls._registered(annotation)), _ScalarDecoder) @classmethod def with_representation( @@ -1174,7 +1184,7 @@ def for_value(cls, value: object) -> type[FieldRepresentation]: return cls @classmethod - def project(cls, value: object, view: str, annotation: object) -> JsonValue: + def project_view(cls, value: object, view: str, annotation: object) -> JsonValue: return RepresentedProjection.of(value, view, annotation) @classmethod @@ -1183,10 +1193,7 @@ def producer_owners(cls) -> tuple[type[FieldRepresentation], ...]: @classmethod def decoder_for(cls, annotation: object, target: object) -> ValueDecoder[object]: - try: - owner = ValueDecoder.annotation_class(target) - except KeyError: - owner = _ScalarDecoder + owner = ValueDecoder.represented_class(target) return owner.with_representation(annotation, target, get_args(target), cls) @classmethod @@ -1243,7 +1250,7 @@ def for_value(cls, value: object) -> type[FieldRepresentation]: return FieldCodec.representation(type(value))[1] @classmethod - def project(cls, value: object, view: str, annotation: object) -> JsonValue: + def project_view(cls, value: object, view: str, annotation: object) -> JsonValue: return FieldCodec._project_structure(value, view, annotation) @classmethod @@ -2239,7 +2246,7 @@ def project(cls, value: object, view: str, annotation: object = None) -> JsonVal Projection is deliberately one way. Excluded secrets cannot be rebuilt from a read-only view, and a view never serves as a persistence record. """ - return cls._value_representation(value, annotation).project(value, view, annotation) + return cls._value_representation(value, annotation).project_view(value, view, annotation) @classmethod def _project_structure(cls, value: object, view: str, annotation: object) -> JsonValue: diff --git a/tools/cutover/carry_wake_claim_turn_ids.py b/tools/cutover/carry_wake_claim_turn_ids.py index f0ed7f1374..e00f514326 100644 --- a/tools/cutover/carry_wake_claim_turn_ids.py +++ b/tools/cutover/carry_wake_claim_turn_ids.py @@ -27,9 +27,11 @@ def roots(self) -> tuple[Path, ...]: @staticmethod def rewrite(path: Path, record: type, payload: dict[str, object]) -> dict[str, object]: try: - return FieldCodec.encode( - FieldCodec.decode(WakeClaim, {**payload, "version": WakeClaim.version}) - ) + return { + **FieldCodec.encode( + FieldCodec.decode(WakeClaim, {**payload, "version": WakeClaim.version}) + ) + } except MalformedRecord as error: error.add_note(f"{path}: {payload!r}") raise From 5c41d99b8f816f0bdc18d342e9b810deb3fd2136 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 21:45:29 -0400 Subject: [PATCH 25/32] Keep structural schema generation on the structural representation --- src/agent_comms/field_codec.py | 86 +++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 511a0834e1..f19ef43a2f 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -1263,7 +1263,55 @@ def decoder_for(cls, annotation: object, target: object) -> ValueDecoder[object] @classmethod def schema_for(cls, annotation: object) -> dict[str, JsonValue]: - return FieldCodec._structure_schema(annotation) + origin, args = get_origin(annotation), get_args(annotation) + if origin in (Union, types.UnionType): + members = list(args) + if len(members) == 1: + return FieldCodec.value_schema(members[0]) + return FieldCodec.alternatives_schema( + "anyOf", [FieldCodec.value_schema(item) for item in members], + ) + if origin is Literal: + return { + **FieldCodec.value_schema(type(args[0])), + "enum": [value for _, value in compile_decoder(annotation).editor_choices()], + } + if origin in (list, tuple, frozenset): + return {"type": "array", "items": FieldCodec.value_schema(args[0])} + if origin is dict: + if args[0] is str: + return {"type": "object", "additionalProperties": FieldCodec.value_schema(args[1])} + return { + "type": "array", + "items": { + "type": "array", + "prefixItems": [FieldCodec.value_schema(arg) for arg in args], + "minItems": 2, + "maxItems": 2, + }, + } + if origin is type and args and issubclass(args[0], DeclaredFamily): + return { + "type": "string", + "enum": [value for _, value in compile_decoder(annotation).editor_choices()], + } + if isinstance(annotation, type) and issubclass(annotation, Enum): + values = [value for _, value in compile_decoder(annotation).editor_choices()] + return {**FieldCodec.value_schema(type(values[0])), "enum": values} + primitive = { + str: "string", + bool: "boolean", + int: "integer", + float: "number", + type(None): "null", + } + if annotation in primitive: + return {"type": primitive[annotation]} + if isinstance(annotation, type) and issubclass(annotation, DeclaredFamily): + return FieldCodec._family_schema(annotation) + if isinstance(annotation, type) and is_dataclass(annotation): + return FieldCodec.record_schema(annotation) + raise TypeError(f"No declared JSON schema for {annotation}") class AcceptsNull[T](FieldRepresentation[T]): @@ -2201,42 +2249,6 @@ def value_schema(cls, annotation: object) -> dict[str, JsonValue]: annotation, representation = cls.representation(annotation) return representation.schema_for(annotation) - @classmethod - def _structure_schema(cls, annotation: object) -> dict[str, JsonValue]: - origin, args = get_origin(annotation), get_args(annotation) - if origin in (Union, types.UnionType): - members = list(args) - if len(members) == 1: - return cls.value_schema(members[0]) - return cls.alternatives_schema("anyOf", [cls.value_schema(item) for item in members]) - if origin is Literal: - return {**cls.value_schema(type(args[0])), "enum": [value for _, value in compile_decoder(annotation).editor_choices()]} - if origin in (list, tuple, frozenset): - return {"type": "array", "items": cls.value_schema(args[0])} - if origin is dict: - if args[0] is str: - return {"type": "object", "additionalProperties": cls.value_schema(args[1])} - return {"type": "array", "items": {"type": "array", "prefixItems": [cls.value_schema(arg) for arg in args], "minItems": 2, "maxItems": 2}} - if origin is type and args and issubclass(cast(type, args[0]), DeclaredFamily): - return {"type": "string", "enum": [value for _, value in compile_decoder(annotation).editor_choices()]} - if isinstance(annotation, type) and issubclass(annotation, Enum): - values = [value for _, value in compile_decoder(annotation).editor_choices()] - return {**cls.value_schema(type(values[0])), "enum": values} - primitive = { - str: "string", - bool: "boolean", - int: "integer", - float: "number", - type(None): "null", - } - if annotation in primitive: - return {"type": primitive[annotation]} - if isinstance(annotation, type) and issubclass(annotation, DeclaredFamily): - return cls._family_schema(annotation) - if isinstance(annotation, type) and is_dataclass(annotation): - return cls.record_schema(annotation) - raise TypeError(f"No declared JSON schema for {annotation}") - @classmethod def project(cls, value: object, view: str, annotation: object = None) -> JsonValue: """Encode a redacted view using field exclusions and owned properties. From d0a24e2f7de1330de0f2a157c665ea611b71536f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 21:49:23 -0400 Subject: [PATCH 26/32] Mint the selected-tool owner fixture input ID at its declared kind --- tests/test_selected_tool_broker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_selected_tool_broker.py b/tests/test_selected_tool_broker.py index ae1c25996b..9dc194992c 100644 --- a/tests/test_selected_tool_broker.py +++ b/tests/test_selected_tool_broker.py @@ -18,7 +18,7 @@ from agent_comms.coordinator import Coordination from agent_comms.coordination_database import CoordinationStore from agent_comms.envelope_claim_transitions import WakeClaim -from agent_comms.field_codec import MessageId, TurnId +from agent_comms.field_codec import InputId, MessageId, TurnId from agent_comms.native_tool_call import ( DuplicateToolCallIdentity, OwnerToolConnection, @@ -114,7 +114,7 @@ def test_owner_mode_denies_unreserved_input_before_consuming_ledger(private_comm attempt_ordinal=1, ) with Coordination(str(CoordinationStore.path_in(comms.root))) as store: - input_id = secrets.token_hex(16) + input_id = InputId.mint() mode = broker.selected_tool_mode_for_owner( comms, store, claim_scope, "owner", input_id ) From 5229d593cb6edd9046e80885889b2aedd31124f2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 21:56:02 -0400 Subject: [PATCH 27/32] Keep input and message kinds at SQL and fixture entry points --- src/agent_comms/bus.py | 6 +++--- src/agent_comms/commander/judge.py | 8 +++++--- src/agent_comms/coordination_response.py | 3 ++- src/agent_comms/coordination_tables/publications.py | 6 +++--- src/agent_comms/coordination_tables/responses.py | 4 ++-- src/agent_comms/native_runtime_input.py | 2 +- src/agent_comms/obligation_states.py | 12 ++++-------- src/agent_comms/todos.py | 2 +- tests/test_bus_files.py | 6 +++--- tests/test_cli.py | 5 +++-- tests/test_coordination_nominal.py | 4 ++-- tests/test_native_context_reference.py | 9 +++++---- tests/test_native_unknown_recovery.py | 4 ++-- 13 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index d878ed6146..35aa454c74 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -46,7 +46,7 @@ ResourceClaim, ) from agent_comms.errors import CommittedMessageError, RelationViolationError, UnclaimedError -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, MessageId from agent_comms.locked_store import BusFile, SQLiteFile, StoreCommittedError from agent_comms.message_reference import MessageReference from agent_comms.private_path import FileIdentity, PresentPath @@ -181,13 +181,13 @@ def delivery(self, seq: int) -> Delivered: return delivery raise RelationViolationError("Bus sequence has no frozen delivery") - def message_reference(self, message_id: str) -> MessageReference: + def message_reference(self, message_id: MessageId) -> MessageReference: """Resolve one content identity; absent or ambiguous identities are refused.""" with self.observing() as db: identity = MessageIdentitySource.require(db, message_id=message_id) return MessageReference(identity.seq, identity.message_id) - def lookup_message(self, message_id: str) -> BusRecord: + def lookup_message(self, message_id: MessageId) -> BusRecord: with self.reading() as source: return source.lookup(source.message_reference(message_id).seq) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index a0a7425be0..e82e0276d7 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -32,7 +32,7 @@ from agent_comms.comms import Comms from agent_comms.change_report import FileLine -from agent_comms.field_codec import Argument, FieldCodec +from agent_comms.field_codec import Argument, FieldCodec, MessageId from agent_comms.declared_family import DeclaredFamily from agent_comms.head_review import PullRequestHead from agent_comms.repo import Repo @@ -323,8 +323,10 @@ def head_operation(self) -> HeadVerdict: def operation(self, ctx: order.Commander, todo: Todo, edit: Edit) -> AcceptedVerdict: actor = ctx.actor_identity if self.peer: - return PeerAcceptedVerdict(todo.revision, actor, todo.held(), edit.id, - quote=self.serves, peer=self.peer) + return PeerAcceptedVerdict( + todo.revision, actor, todo.held(), edit.id, + quote=self.serves, peer=FieldCodec.decode(MessageId, self.peer), + ) return AcceptedVerdict(todo.revision, actor, todo.held(), edit.id, quote=self.serves) def check_edits(self, edits: list[Edit]) -> None: diff --git a/src/agent_comms/coordination_response.py b/src/agent_comms/coordination_response.py index 99a3684a0a..4f937e6f5a 100644 --- a/src/agent_comms/coordination_response.py +++ b/src/agent_comms/coordination_response.py @@ -57,6 +57,7 @@ WithoutRowid, ) +from .field_codec import MessageId from .locked_store import RegistryFile @@ -194,7 +195,7 @@ def complete( @dataclass(frozen=True) class SelectedResponseRoute(TypedRow): - message_id: str + message_id: MessageId exact_target: str envelope_digest: str audience_digest: str diff --git a/src/agent_comms/coordination_tables/publications.py b/src/agent_comms/coordination_tables/publications.py index bb399a880c..5f1d1d7217 100644 --- a/src/agent_comms/coordination_tables/publications.py +++ b/src/agent_comms/coordination_tables/publications.py @@ -15,7 +15,7 @@ from agent_comms.coordination_errors import CoordinationError, IntegrityViolationError from agent_comms.coordination_schema import CoordinatorRow from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import FieldCodec, MalformedRecord, WireName +from agent_comms.field_codec import FieldCodec, MalformedRecord, MessageId, WireName from agent_comms.message_reference import MessageReference from agent_comms.messages import Message from agent_comms.response_acknowledgement import canonical_publication_key @@ -102,7 +102,7 @@ def validate_acknowledgement(self, acknowledgement: PublicationEvidence) -> None ), ] expected_message_id: Annotated[ - str, + MessageId, Column( storage=ExactStorage, unique=True, @@ -199,7 +199,7 @@ def reference(self) -> MessageReference: ] seq: Annotated[int, Column(unique=True, check="seq > 0")] message_id: Annotated[ - str, + MessageId, Column( storage=ExactStorage, unique=True, diff --git a/src/agent_comms/coordination_tables/responses.py b/src/agent_comms/coordination_tables/responses.py index 6ee63d8c12..02dde855e4 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -13,7 +13,7 @@ ) from agent_comms.coordination_schema import CoordinatorRow from agent_comms.coordination_tables.executions import ExecutionReference, RetryPartition, WireOrigin -from agent_comms.field_codec import SnapshotExclude, WireName, projected +from agent_comms.field_codec import MessageId, SnapshotExclude, WireName, projected from agent_comms.obligation_states import ResponseState from agent_comms.reason_codes import ReasonCode from agent_comms.typed_table import ( @@ -123,7 +123,7 @@ def __post_init__(self) -> None: Column(generated="json_extract(lifecycle, '$.kind')", check="state IN ({response_names})"), ) published_message_id: ClassVar[Generated] = Generated( - Annotated[str, WireName("receipt_message_id")], + Annotated[MessageId, WireName("receipt_message_id")], Column( record_key=True, generated="json_extract(lifecycle, '$.message_id')", diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index ea5eb3f289..e903ae80a8 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -436,7 +436,7 @@ def owner_identity(self): input_id: Annotated[ NativeColumn, Column(references=TableReference(NativeRuntimeInput, ("input_id",)), storage=TextStorage, nullable=True), - NativeContext(str), + NativeContext(InputId), ] stage: Annotated[ NativeColumn, diff --git a/src/agent_comms/obligation_states.py b/src/agent_comms/obligation_states.py index 2f8d687135..620b63880c 100644 --- a/src/agent_comms/obligation_states.py +++ b/src/agent_comms/obligation_states.py @@ -5,10 +5,9 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar -from .coordination_contracts import MAX_IDENTIFIER_CHARS, require_bounded from .coordination_errors import IdentityConflict, IntegrityViolationError from .coordinator_vocabulary import CoordinatorVocabulary -from .field_codec import NullMember +from .field_codec import FieldCodec, MessageId, NullMember from .lifecycle import LifecycleState from .lifecycle_capability import Deferred, Failed, Pending, Successful, Terminal from .message_reference import MessageReference @@ -50,7 +49,7 @@ def sql_vocabulary(cls) -> dict[str, str]: ) @property - def published_message_id(self) -> str | Unpublished: + def published_message_id(self) -> MessageId | Unpublished: return Unpublished() @property @@ -150,16 +149,13 @@ def successors(cls): @dataclass(frozen=True) class PublishedResponse(Successful, ResponseState): - message_id: str + message_id: MessageId seq: int allows_intent = True requires_intent = True published = True def __post_init__(self): - if not isinstance(self.message_id, str) or not self.message_id: - raise IntegrityViolationError("published obligations require a acknowledgement") - require_bounded(self.message_id, "receipt_message_id", MAX_IDENTIFIER_CHARS) if type(self.seq) is not int or self.seq <= 0: raise ValueError("receipt_seq must be positive") @@ -184,7 +180,7 @@ def validate_acknowledgement(self, acknowledgement) -> None: def load(cls, message_id, seq): if message_id is None or seq is None: raise IntegrityViolationError("published obligations require a acknowledgement") - return cls(message_id, seq) + return cls(FieldCodec.decode(MessageId, message_id), seq) class SilentResponse(Successful, ResponseState): diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 2f4f5d5dd0..2cf7064ea3 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -3269,7 +3269,7 @@ def write(self, db: sqlite3.Connection, current: Todo, store: TodoStore) -> Todo @dataclass(frozen=True) class PeerAcceptedVerdict(AcceptedVerdict): - peer: str + peer: MessageId def prepare(self, goals, current): message = goals.bus.bus.lookup_message(self.peer) diff --git a/tests/test_bus_files.py b/tests/test_bus_files.py index f5597a7d3e..b758449e90 100644 --- a/tests/test_bus_files.py +++ b/tests/test_bus_files.py @@ -7,7 +7,7 @@ import pytest -from agent_comms.field_codec import TurnId +from agent_comms.field_codec import InputId, TurnId from agent_comms.installer.backup import bus_files from bus_record_fixture import prepare_native_comms from agent_comms.native_pi import NativeContextJournal @@ -92,7 +92,7 @@ def test_parameterized_sqlite_resources_follow_their_file_owner(tmp_path: Path, db.execute(statement) db.commit() NativeContextJournal( - input_id="b" * 32, session_id="session", session_entry_id="entry", + input_id=InputId.mint(), session_id="session", session_entry_id="entry", request_generation=1, llm_context_digest="c" * 64, committed_at_ms=1_791_964_800_001, schema=2, type="context_committed", ).insert(db) @@ -109,7 +109,7 @@ def test_installer_reconciles_a_native_proof_journal_onto_its_declaration(tmp_pa for statement in NativeContextJournal.schema_objects().values(): db.execute(statement) NativeContextJournal( - input_id="b" * 32, session_id="session", session_entry_id="entry", + input_id=InputId.mint(), session_id="session", session_entry_id="entry", request_generation=1, llm_context_digest="c" * 64, committed_at_ms=1_791_964_800_001, schema=2, type="context_committed", ).insert(db) diff --git a/tests/test_cli.py b/tests/test_cli.py index 441b12b5b9..4c8a0b2c54 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,7 +9,7 @@ import pytest -from agent_comms.field_codec import TurnId +from agent_comms.field_codec import FieldCodec, MessageId, TurnId from agent_comms.session_file import RegistrySessionLocation from agent_comms.channel_targets import CommandTarget, PerTargetChannels from agent_comms.cli import main @@ -127,7 +127,8 @@ def test_send_notice_and_conversation_record_channel_wake_modes(self, cli, comms code, sent = cli(comms.root, "send", "--from", "sender", "--to", "#comms", "--body", "public record", "--role", role) assert code == 0 - delivery, = comms.bus.bus.lookup_message(sent["id"]).deliveries() + message_id = FieldCodec.decode(MessageId, sent["id"]) + delivery, = comms.bus.bus.lookup_message(message_id).deliveries() assert len(delivery.audience.recipients) == 2 assert [decision.wake_policy() for decision in delivery.decisions] == [expected] * 2 parser = argparse.ArgumentParser() diff --git a/tests/test_coordination_nominal.py b/tests/test_coordination_nominal.py index ccae48eadd..7f1b1b500d 100644 --- a/tests/test_coordination_nominal.py +++ b/tests/test_coordination_nominal.py @@ -16,7 +16,7 @@ ExecutionState, PendingExecution, ) -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, MessageId from agent_comms.obligation_states import PublishedResponse, ResponseState, SilentResponse from agent_comms.wake_policy import FullWake, MessageAudience @@ -203,7 +203,7 @@ def test_original_target_and_receipt_legality_is_owned_by_current_variants(): FieldCodec.decode(AssignmentState, damaged) # A publication has a complete acknowledgement; non-publication variants have none. - published = FieldCodec.encode(PublishedResponse("message", 1)) + published = FieldCodec.encode(PublishedResponse(MessageId.mint("message"), 1)) for field in ("message_id", "seq"): damaged = dict(published) del damaged[field] diff --git a/tests/test_native_context_reference.py b/tests/test_native_context_reference.py index 806241b4a0..0889e0e05d 100644 --- a/tests/test_native_context_reference.py +++ b/tests/test_native_context_reference.py @@ -18,7 +18,7 @@ RecordedCursor, UnstartedCursor, ) -from agent_comms.field_codec import InputId +from agent_comms.field_codec import FieldCodec, InputId from agent_comms.historical_native_inputs import ( FullHistoricalNativeInput, TriageHistoricalNativeInput, @@ -70,7 +70,8 @@ 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(), + input_id=FieldCodec.decode(InputId, "c" * 32), stage=TriageNativeExecution, + execution_id=UnrecordedNativeColumn(), attempt_ordinal=UnrecordedNativeColumn(), owner_lookup="b" * 32, owner_generation=1, owner_token_digest="e" * 64, ) @@ -167,7 +168,7 @@ def test_original_native_sql_acquires_required_triage_or_full_recorded_proof(rec with pytest.raises((TypeError, ValueError)): missing.execution.historical_proof(missing, lifecycle=TriagePendingAssignment(), **source) for identity, decision in (("9", IgnoreSelectedTriage), ("8", FullSelectedTriage)): - original = replace(recorded, input_id=identity * 32, + original = replace(recorded, input_id=FieldCodec.decode(InputId, identity * 32), verdict=DecidedTriageRecord(decision)) original.insert(db) assert db.execute("SELECT verdict FROM native_runtime_input WHERE input_id=?", @@ -183,7 +184,7 @@ def test_original_native_sql_acquires_required_triage_or_full_recorded_proof(rec with pytest.raises(sqlite3.IntegrityError): NativeRuntimeInput.update(db, where="input_id=?", parameters=(original.input_id,), verdict=AbsentTriageDecisionRecord()) - full = replace(recorded, input_id="7" * 32, + full = replace(recorded, input_id=FieldCodec.decode(InputId, "7" * 32), stage=FullNativeExecution, **FullNativeExecution("5" * 32, 1).binding_fields(), verdict=AbsentTriageDecisionRecord()) full.insert(db) diff --git a/tests/test_native_unknown_recovery.py b/tests/test_native_unknown_recovery.py index 1ca2617b8f..30e3664f91 100644 --- a/tests/test_native_unknown_recovery.py +++ b/tests/test_native_unknown_recovery.py @@ -3,7 +3,7 @@ from __future__ import annotations -from agent_comms.field_codec import TurnId +from agent_comms.field_codec import InputId, TurnId from agent_comms.lifecycle_capability import Failed import asyncio import multiprocessing @@ -233,7 +233,7 @@ def test_recover_owner_refuses_a_conflicting_input_identity(released_starting, c before = store.get(execution_id) with store.session.writing() as db: NativeRuntimeInput( - input_id="c" * 32, stage=FullNativeExecution, + input_id=InputId.mint(), stage=FullNativeExecution, execution_id=TextNativeColumn(execution_id), attempt_ordinal=NumberNativeColumn(before.attempt.attempt_ordinal), owner_lookup=owner.incarnation.lookup, From b9c22bdb86f5b26854546c4a9ed7a3981a3c790f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 21:57:02 -0400 Subject: [PATCH 28/32] Give the wake carry fixture a canonical absolute resource path --- tests/test_carry_wake_claim_turn_ids.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_carry_wake_claim_turn_ids.py b/tests/test_carry_wake_claim_turn_ids.py index 4247c62dee..0858181c80 100644 --- a/tests/test_carry_wake_claim_turn_ids.py +++ b/tests/test_carry_wake_claim_turn_ids.py @@ -19,7 +19,8 @@ def old_claim_bus(root: Path, *, turn_id: str = "a" * 32) -> Path: path = Bus.path_in(root) path.touch(mode=0o600) claim = WakeClaims( - "owner", "epoch-1", 1, "message-1", claims=("src/worker.py",), + "owner", "epoch-1", 1, "message-1", + claims=(str((root / "src" / "worker.py").resolve()),), generation="b" * 32, resource_scope=WakeClaim( owner_claimed_generation=1, operation_id="c" * 32, turn_id=TurnId("a" * 32), From 7b4d7f037da3ccb17971d4855058317813cfe054 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 22:00:22 -0400 Subject: [PATCH 29/32] Keep publication mismatch fixture a valid message identity --- tests/test_coordination.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_coordination.py b/tests/test_coordination.py index a9a6934034..42d195351f 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -66,7 +66,7 @@ PendingExecution, ) from agent_comms.attempt_states import AttemptState, LiveAttempt -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, MessageId from agent_comms.typed_table import JsonStorage from agent_comms.messages import InfoKind, Message from agent_comms.obligation_states import ( @@ -431,7 +431,7 @@ def test_wire_claim_obligation_and_publication_contract(): expected_message_id=message.message_id, ) with pytest.raises(IntegrityViolationError): - replace(intent, expected_message_id="fabricated") + replace(intent, expected_message_id=MessageId.mint("fabricated")) From 2818c2d686757dfb0e517258465654c61b1a75b6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 22:20:29 -0400 Subject: [PATCH 30/32] Decode assignment and historical IDs once and share test turn identities --- .../coordination_tables/assignments.py | 4 +- src/agent_comms/historical_native_inputs.py | 8 ++-- src/agent_comms/input_disposition.py | 3 +- src/agent_comms/notification_assignment.py | 4 +- src/agent_comms/selected_turn.py | 3 +- tests/test_coordination.py | 4 +- tests/test_coordination_nominal.py | 4 +- tests/test_coordination_store.py | 6 +-- tests/test_goal_resume.py | 5 ++- tests/test_input_documents.py | 37 ++++++++++--------- tests/test_native_channel_reply_roundtrip.py | 6 +-- tests/test_native_context_reference.py | 4 +- 12 files changed, 45 insertions(+), 43 deletions(-) diff --git a/src/agent_comms/coordination_tables/assignments.py b/src/agent_comms/coordination_tables/assignments.py index cc25d84dda..ef63b48063 100644 --- a/src/agent_comms/coordination_tables/assignments.py +++ b/src/agent_comms/coordination_tables/assignments.py @@ -17,7 +17,7 @@ ) from agent_comms.coordination_schema import CoordinatorRow from agent_comms.envelope_claim_transitions import AssignmentBinding -from agent_comms.field_codec import SnapshotExclude, WireName, projected +from agent_comms.field_codec import MessageId, SnapshotExclude, WireName, projected from agent_comms.message_reference import MessageReference from agent_comms.typed_table import ( Column, @@ -144,7 +144,7 @@ def require_engaged_binding(self) -> AssignmentBinding: recipient: Annotated[str, FrozenColumn(check="length(recipient) BETWEEN 1 AND 256")] recipient_lookup: Annotated[str, FrozenColumn()] wire_seq: Annotated[int, FrozenColumn(check="wire_seq > 0")] - message_id: Annotated[str, FrozenColumn(check="length(message_id) BETWEEN 1 AND 256")] + message_id: Annotated[MessageId, FrozenColumn(check="length(message_id) BETWEEN 1 AND 256")] audience: Annotated[MessageAudience, FrozenColumn()] lifecycle: Annotated[AssignmentState, SnapshotExclude()] accepted_at_ms: Annotated[int, FrozenColumn(check="accepted_at_ms >= 0")] diff --git a/src/agent_comms/historical_native_inputs.py b/src/agent_comms/historical_native_inputs.py index dda896adb7..41e21f3f0f 100644 --- a/src/agent_comms/historical_native_inputs.py +++ b/src/agent_comms/historical_native_inputs.py @@ -19,7 +19,7 @@ from .cohort_schema import CohortTable from agent_comms.native_runtime_input import NativeRuntimeTable from .declared_family import DeclaredFamily -from .field_codec import InputId, WireName +from .field_codec import InputId, MessageId, WireName from .native_input_record import ( FullNativeExecution, NativeInputExecution, @@ -41,7 +41,7 @@ class HistoricalNativeInput(NativeInputRecord, DeclaredFamily, affix="HistoricalNativeInput"): wire_root_id: str source_seq: int - source_message_id: str + source_message_id: MessageId assignment_id: Annotated[str, WireName("claim_id")] execution: NativeInputExecution input_id: InputId @@ -91,10 +91,10 @@ def proves_triage_source(self, evidence) -> bool: @dataclass(frozen=True) class _HistoricalSource(TypedRow): - input_id: str + input_id: InputId assignment_id: str wire_seq: int - message_id: str + message_id: MessageId def read_historical_native_inputs( diff --git a/src/agent_comms/input_disposition.py b/src/agent_comms/input_disposition.py index 26f36930b8..003445e96b 100644 --- a/src/agent_comms/input_disposition.py +++ b/src/agent_comms/input_disposition.py @@ -13,6 +13,7 @@ from .channel_targets import CommandTarget from .errors import RelationViolationError +from .field_codec import TurnId from .input_attempt import ( UNOBSERVED_DELIVERY_SCOPE, GoalInputDecision, @@ -264,7 +265,7 @@ def unresolved(self, owners: frozenset[str]) -> InputDocument: where, parameters = _members("owner", sorted(owners)) return self._select(f"unresolved = 1 AND {where}", parameters) - def in_turn(self, turn_id: str) -> InputDocument: + def in_turn(self, turn_id: TurnId) -> InputDocument: return self._select('"turn_id" = ?', (turn_id,)) diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index 9fa4bd4288..370b747ac4 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -13,7 +13,7 @@ from .coordination_tables.assignments import WakeAssignment from .coordination_tables.publications import PublicationEvidence from .declared_family import DeclaredFamily -from .field_codec import FieldCodec, MalformedRecord +from .field_codec import FieldCodec, MalformedRecord, MessageId from .message_reference import MessageReference from .notification_progress import ( AnsweredProgress, @@ -75,7 +75,7 @@ def from_columns( cls, seq: object, message_id: object, received_at_ms: object ) -> AssignmentResponse: seq = FieldCodec.decode(int | None, seq) - message_id = FieldCodec.decode(str | None, message_id) + message_id = FieldCodec.decode(MessageId | None, message_id) received_at_ms = FieldCodec.decode(int | None, received_at_ms) if seq is None and message_id is None and received_at_ms is None: return UnansweredResponse() diff --git a/src/agent_comms/selected_turn.py b/src/agent_comms/selected_turn.py index 140e14d961..20993af9ab 100644 --- a/src/agent_comms/selected_turn.py +++ b/src/agent_comms/selected_turn.py @@ -16,7 +16,6 @@ from .coordination_tables.responses import ResponseObligation from .durable_turn import DurableTurn from .envelope_claim_transitions import WakeClaim -from .field_codec import FieldCodec, MessageId from .optional_awareness_projection import OptionalAwarenessProjection from .native_failures import NativePiUnavailable from .owner_fence import prepare_fence_token @@ -152,7 +151,7 @@ def tool_owner(self, session, input_id, action) -> SelectedCodingToolOwner: claim_scope = WakeClaim( wire_root_id=participant.root_id, source_seq=assignment.wire_seq, - source_message_id=FieldCodec.decode(MessageId, assignment.message_id), + source_message_id=assignment.message_id, wake_assignment_id=assignment.assignment_id, wake_revision=assignment.revision, recipient_lookup=participant.lookup, diff --git a/tests/test_coordination.py b/tests/test_coordination.py index 42d195351f..b175054dfb 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -112,7 +112,7 @@ def assignment(): recipient="worker", recipient_lookup="owner-1", wire_seq=2, - message_id="message-1", + message_id=MessageId.mint("message-1"), audience=MessageAudience.DIRECT, accepted_at_ms=100, updated_at_ms=100, @@ -202,7 +202,7 @@ def sql_wire_execution(db, *, pending=True, name="e", target="requester"): sql_execution(db, name=name, status="queued", origin="wire") WakeAssignment( assignment_id="a", recipient="worker", recipient_lookup="p", - wire_seq=2, message_id="m", audience=MessageAudience.DIRECT, + wire_seq=2, message_id=MessageId.mint("m"), audience=MessageAudience.DIRECT, lifecycle=EngagedAssignment.build(FullWake(), name, target), accepted_at_ms=0, updated_at_ms=0, revision=1, ).insert(db) diff --git a/tests/test_coordination_nominal.py b/tests/test_coordination_nominal.py index 7f1b1b500d..4cf02513c5 100644 --- a/tests/test_coordination_nominal.py +++ b/tests/test_coordination_nominal.py @@ -126,7 +126,7 @@ def successors(cls): ).insert(db) WakeAssignment( assignment_id="c", recipient="owner", recipient_lookup="p", - wire_seq=1, message_id="m", audience=MessageAudience.DIRECT, + wire_seq=1, message_id=MessageId.mint("m"), audience=MessageAudience.DIRECT, lifecycle=EngagedAssignment.build(FullWake(), "e", "requester"), accepted_at_ms=0, updated_at_ms=0, ).insert(db) @@ -163,7 +163,7 @@ class AwaitingAssignment(FullPendingAssignment): recipient="owner", recipient_lookup="owner", wire_seq=1, - message_id="message", + message_id=MessageId.mint("message"), audience=MessageAudience.DIRECT, lifecycle=AwaitingAssignment(), accepted_at_ms=0, diff --git a/tests/test_coordination_store.py b/tests/test_coordination_store.py index 194f1b9edd..852a93ceca 100644 --- a/tests/test_coordination_store.py +++ b/tests/test_coordination_store.py @@ -70,7 +70,7 @@ FailedExecution, PendingExecution, ) -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, MessageId from agent_comms.messages import Message, InfoKind from agent_comms.owner_fence import prepare_fence_token from agent_comms.recovery_states import FailedRecovery, ModelStalledRecovery, RecoveredRecovery @@ -280,7 +280,7 @@ def assignment( recipient="Owner", recipient_lookup="owner", wire_seq=index, - message_id=f"message-{index}", + message_id=MessageId.mint(f"message-{index}"), audience=MessageAudience.DIRECT, accepted_at_ms=1_000, updated_at_ms=1_000, @@ -1073,7 +1073,7 @@ def test_unstarted_failure_and_claim_requeue(db_path: Path) -> None: recipient="Owner", recipient_lookup="owner", wire_seq=1, - message_id="message", + message_id=MessageId.mint("message"), audience=MessageAudience.DIRECT, accepted_at_ms=1_000, updated_at_ms=1_000, diff --git a/tests/test_goal_resume.py b/tests/test_goal_resume.py index 44b45be16a..0d99a7886e 100644 --- a/tests/test_goal_resume.py +++ b/tests/test_goal_resume.py @@ -84,7 +84,8 @@ def test_replacing_or_clearing_goal_cannot_reset_turn_report_guard(comms, monkey Thread('owner', frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid())) ) first = invoke_tool(comms, "comms_set_goal", {"text": "first"})["goal"] - comms.agents.begin_turn("owner", TurnId('d7f9429f60abc2787fc3e9717054ea41')) + turn = TurnId('d7f9429f60abc2787fc3e9717054ea41') + comms.agents.begin_turn("owner", turn) invoke_tool( comms, "comms_goal", @@ -105,7 +106,7 @@ def test_replacing_or_clearing_goal_cannot_reset_turn_report_guard(comms, monkey after_clear = invoke_tool(comms, "comms_set_goal", {"text": "after clear"})["goal"] reopened = wire(comms.root) assert any( - entry.reports_turn("same-assistant-turn") + entry.reports_turn(turn) for entry in reopened.registry.goal_history("owner") ) with pytest.raises(ValueError): diff --git a/tests/test_input_documents.py b/tests/test_input_documents.py index fcd01c49b8..716bfb3531 100644 --- a/tests/test_input_documents.py +++ b/tests/test_input_documents.py @@ -27,6 +27,9 @@ from agent_comms.threads import Thread +TURN = TurnId('9b473845f684444f80a26586ae298d2b') + + def test_original_batch_publication_preserves_prior_rows_and_reserves_only_new_inputs(tmp_path): store = InputDispositions(tmp_path) args = dict(seq=None, owner="worker", claim_scope=1, target="worker", text="Prior input") @@ -113,7 +116,7 @@ def test_saved_discriminator_and_optional_notices_roundtrip_without_replay(tmp_p claim_generation=1, target="worker", source_text="Retain me", - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id="a" * 32, sent_digest=TextDigest.of("Exact sent text"), notice_dismissed=True, @@ -129,7 +132,7 @@ def test_saved_discriminator_and_optional_notices_roundtrip_without_replay(tmp_p assert isinstance(row, BoundUnknownInput) and not row.accepts_reservation assert row.reviewed_for_goal("goal") assert FieldCodec.encode(row) == saved - assert store.in_turn("turn").rows == {"acp:old": row} + assert store.in_turn(TURN).rows == {"acp:old": row} assert store.unresolved(frozenset({"worker"})).rows == {"acp:old": row} assert not store.bind( row.key, @@ -146,14 +149,14 @@ def test_saved_discriminator_and_optional_notices_roundtrip_without_replay(tmp_p ) assert store.started( row.key, - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id="a" * 32, text="Exact sent text", ) assert isinstance(stored_inputs(store.root).rows[row.key], StartedInput) assert not store.started( row.key, - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id="a" * 32, text="Exact sent text", ) @@ -168,7 +171,7 @@ def test_original_native_user_receipts_include_group_and_corrected_final_only(tm registry = Registration(tmp_path / "registry.json") registry.register(Thread('worker', frozenset(), str(tmp_path), created_at=123.0, process_identity=ProcessIdentity.capture(os.getpid()))) - registry.lease_local_turn("worker", TurnId('9b473845f684444f80a26586ae298d2b')) + registry.lease_local_turn("worker", TURN) lease = registry.require("worker").turn_lease claim_scope = lease.claimed_generation store = InputDispositions(tmp_path) @@ -178,13 +181,13 @@ def test_original_native_user_receipts_include_group_and_corrected_final_only(tm assert store.bind( key, claim_scope=claim_scope, - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id="a" * 32, text="Actual grouped native user", ) assert store.started( key, - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id="a" * 32, text="Actual grouped native user", ) @@ -194,17 +197,17 @@ def test_original_native_user_receipts_include_group_and_corrected_final_only(tm assert store.bind( "acp:correction", claim_scope=claim_scope, - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id="b" * 32, text="Actual corrected native user", ) assert store.started( "acp:correction", - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id="b" * 32, text="Actual corrected native user", ) - document = store.in_turn("turn") + document = store.in_turn(TURN) assert document == stored_inputs(store.root) snapshot = registry.snapshot() group = document.started_for_native( @@ -362,7 +365,7 @@ def test_invalid_bind_keeps_durable_reservation_intact(tmp_path, native_id): store.bind( "acp:x", claim_scope=1, - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id=native_id, text="sent", ) @@ -375,7 +378,7 @@ def test_reserved_input_is_queued_only_for_its_own_owner_claim_and_text(): reserved = ReservedInput("acp:x", None, "owner", 1, "owner", "original") bound = reserved.bind( claim_scope=1, - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id="a" * 32, text="wrapped input", ) @@ -402,13 +405,13 @@ def test_started_delivery_and_original_ingress_use_distinct_owned_relations( assert store.bind( key, claim_scope=1, - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id="a" * 32, text="wrapped input", ) assert store.started( key, - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id="a" * 32, text="wrapped input", ) @@ -431,14 +434,14 @@ def test_reads_select_only_the_rows_their_index_names(tmp_path): assert store.bind( "acp:a", claim_scope=1, - turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), + turn_id=TURN, native_id="a" * 32, text="sent", ) assert store.started( - "acp:a", turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), native_id="a" * 32, text="sent" + "acp:a", turn_id=TURN, native_id="a" * 32, text="sent" ) - assert store.in_turn("turn").rows.keys() == {"acp:a"} + assert store.in_turn(TURN).rows.keys() == {"acp:a"} assert store.unresolved(frozenset({"owner"})).rows.keys() == {"acp:b"} assert store.read(("acp:c", "acp:missing")).rows.keys() == {"acp:c"} with closing(sqlite3.connect(store.path)) as connection: diff --git a/tests/test_native_channel_reply_roundtrip.py b/tests/test_native_channel_reply_roundtrip.py index cfe4ff8024..253d968bef 100644 --- a/tests/test_native_channel_reply_roundtrip.py +++ b/tests/test_native_channel_reply_roundtrip.py @@ -33,7 +33,7 @@ from agent_comms.pi_vocabulary import OffThinkingLevel from agent_comms.restart_scope import OwnersNamed from agent_comms.thread_model import DeclaredModel -from agent_comms.thread_identity import NoObservedTurn +from agent_comms.turn_lease import NoTurnRecord from agent_comms.threads import Thread from bus_history import read_history from bus_record_fixture import prepare_native_comms @@ -391,9 +391,7 @@ def selected_turn_finished(): states = [fact.state for fact in facts if isinstance(fact, TurnChangedUpdate)] return any( not bool(state.active.items_with(Executing, state.active)) - and state.active.observed_turn( - state.finished, owner.turn_identity, owner.claimed_generation, - ) != NoObservedTurn() + and state.finished != NoTurnRecord() for state in states ) diff --git a/tests/test_native_context_reference.py b/tests/test_native_context_reference.py index 0889e0e05d..72a336dfbe 100644 --- a/tests/test_native_context_reference.py +++ b/tests/test_native_context_reference.py @@ -18,7 +18,7 @@ RecordedCursor, UnstartedCursor, ) -from agent_comms.field_codec import FieldCodec, InputId +from agent_comms.field_codec import FieldCodec, InputId, MessageId from agent_comms.historical_native_inputs import ( FullHistoricalNativeInput, TriageHistoricalNativeInput, @@ -121,7 +121,7 @@ def test_original_unrecorded_context_and_reserved_input_remain_distinct_from_cor @pytest.fixture def historical_source(recorded): return dict( - wire_root_id="a" * 32, source_seq=1, source_message_id="original-message", + wire_root_id="a" * 32, source_seq=1, source_message_id=MessageId.mint("original-message"), assignment_id="d" * 32, input_id=recorded.input_id, owner_lookup=recorded.owner_lookup, owner_generation=recorded.owner_generation, From 40781f4a8f97a930d0c3ce8228f660eb206f7c61 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 22:22:40 -0400 Subject: [PATCH 31/32] Apply stored-record rewrites only to selected records under declared guards --- src/agent_comms/field_codec.py | 9 ++++++--- src/agent_comms/installer/probes/carry.py | 21 +++++++++++++++++++++ tools/cutover/carry_wake_claim_turn_ids.py | 8 +------- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index f19ef43a2f..89a3a3f509 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -984,7 +984,8 @@ def annotation_class(cls, annotation: object) -> type[ValueDecoder[object]]: @classmethod def represented_class(cls, annotation: object) -> type[ValueDecoder[object]]: """A represented value with no registered decoder is a scalar of its representation.""" - return next(iter(cls._registered(annotation)), _ScalarDecoder) + scalar: type[ValueDecoder[object]] = _ScalarDecoder + return next(iter(cls._registered(annotation)), scalar) @classmethod def with_representation( @@ -2665,8 +2666,10 @@ def _rewrite_records( ) -> object: answers = [] for alternative in self.alternatives: + if not alternative.fits_wire(data): + continue + current = alternative.rewrite_records(data, change, select) try: - current = alternative.rewrite_records(data, change, select) alternative.decode_bound(current, {}) except MalformedRecord: continue @@ -3131,7 +3134,7 @@ def _rewrite_records( ) -> object: if not isinstance(data, dict): raise MalformedRecord(f"Expected {self.target.__name__} record") - result = change(self.target, data) + result = change(self.target, data) if self.selects_record(select) else data for entry in self.plan.bindings: result = entry.rewrite_wire(result, change, select) return result diff --git a/src/agent_comms/installer/probes/carry.py b/src/agent_comms/installer/probes/carry.py index 4561819919..193716f14c 100644 --- a/src/agent_comms/installer/probes/carry.py +++ b/src/agent_comms/installer/probes/carry.py @@ -3,6 +3,7 @@ from __future__ import annotations import inspect +import sqlite3 from abc import abstractmethod from dataclasses import dataclass, field, replace from pathlib import Path @@ -21,6 +22,7 @@ ) from agent_comms.locked_store import RootFile from agent_comms.store_files import is_store_lock +from agent_comms.typed_table import TriggerKind, TypedTable, _Field @dataclass(frozen=True) @@ -79,6 +81,25 @@ def after_acceptance(self) -> Carried: """Retire obsolete inputs after the installation's rollback window closes.""" return Carried(0) + @staticmethod + def rewrite_stored( + db: sqlite3.Connection, + row: type[TypedTable], + column: _Field, + updates: list[tuple[object, int]], + ) -> None: + """Inside the caller's write transaction, carry stored values of a guarded row: + its declared mutation guards are lifted and recreated from the same declaration.""" + guards = row.triggers() + for name in guards: + TriggerKind.drop(db, name) + db.executemany( + row._sql(f'UPDATE "{row.declared_name}" SET {{{column.name}}}=? WHERE {{seq}}=?'), + updates, + ) + for sql in guards.values(): + db.execute(sql) + @staticmethod def require_confined(root: Path, path: Path) -> None: """A carry must never follow a copied path or alias into the source bus.""" diff --git a/tools/cutover/carry_wake_claim_turn_ids.py b/tools/cutover/carry_wake_claim_turn_ids.py index e00f514326..86ccdb262d 100644 --- a/tools/cutover/carry_wake_claim_turn_ids.py +++ b/tools/cutover/carry_wake_claim_turn_ids.py @@ -60,12 +60,6 @@ def carry(self, *, apply: bool) -> Carried: changed += len(updates) if apply and updates: with closing(Bus.writer(path)) as db, Bus.write_transaction(db): - db.executemany( - table._sql( - f'UPDATE "{table.declared_name}" SET ' - "{claim_transition}=? WHERE {seq}=?" - ), - updates, - ) + self.rewrite_stored(db, table, column, updates) rewritten.append(path.relative_to(self.root)) return Carried(changed, rewritten=tuple(rewritten)) From a953c92c789c2c177154b0672636b1fb0b5231d5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 22:36:19 -0400 Subject: [PATCH 32/32] Declare nullable response-column decoding at its SQL boundary --- src/agent_comms/notification_assignment.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index 370b747ac4..800ed8109a 100644 --- a/src/agent_comms/notification_assignment.py +++ b/src/agent_comms/notification_assignment.py @@ -13,7 +13,7 @@ from .coordination_tables.assignments import WakeAssignment from .coordination_tables.publications import PublicationEvidence from .declared_family import DeclaredFamily -from .field_codec import FieldCodec, MalformedRecord, MessageId +from .field_codec import FieldCodec, MalformedRecord, MessageId, boundary_decoder from .message_reference import MessageReference from .notification_progress import ( AnsweredProgress, @@ -71,6 +71,7 @@ def recorded(self, message: Delivered) -> AssignmentResponse: return self @classmethod + @boundary_decoder def from_columns( cls, seq: object, message_id: object, received_at_ms: object ) -> AssignmentResponse: