diff --git a/plans/ID128-NOMINAL-PLAN.md b/plans/ID128-NOMINAL-PLAN.md index 64c6997e03..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. 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`, `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 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`, `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,36 +37,36 @@ 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 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\|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 | | | `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`. 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 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. -- 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,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 `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`/`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/acp_extension.py b/src/agent_comms/acp_extension.py index c4e9009466..7630fd6b7a 100644 --- a/src/agent_comms/acp_extension.py +++ b/src/agent_comms/acp_extension.py @@ -18,7 +18,7 @@ FailureSource, UnrecordedFailureSource, ) -from .agent_backend import InputId, SendNow, TurnInbox +from .agent_backend import SendNow, TurnInbox from .agent_events import CompactionEvent from .child_process import ProcessIdentity from .context_reading import ContextReading @@ -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: InputId = field(default_factory=InputId.mint) origin: Annotated[InputOrigin, WireOmitDefault()] = field( default_factory=UnattributedInputOrigin, kw_only=True ) @@ -685,20 +686,20 @@ def draft_text(self) -> str | None: return self.user_text @abstractmethod - def enqueue_control(self, inbox: TurnInbox, input_id: str) -> None: ... + def enqueue_control(self, inbox: TurnInbox, input_id: InputId) -> None: ... async def publish_acceptance(self, inputs: InputDrain, session_id: str) -> None: await inputs.emit_queue_state(session_id) class QueuePromptRequest(PromptRequest): - def enqueue_control(self, inbox: TurnInbox, input_id: str) -> None: + def enqueue_control(self, inbox: TurnInbox, input_id: InputId) -> None: pass class SteerPromptRequest(PromptRequest): - def enqueue_control(self, inbox: TurnInbox, input_id: str) -> None: - inbox.put_nowait(SendNow((InputId(input_id),))) + def enqueue_control(self, inbox: TurnInbox, input_id: InputId) -> None: + inbox.put_nowait(SendNow((input_id,))) @dataclass(frozen=True) diff --git a/src/agent_comms/agent_activity.py b/src/agent_comms/agent_activity.py index c9c7b7ef8f..32a166bc7a 100644 --- a/src/agent_comms/agent_activity.py +++ b/src/agent_comms/agent_activity.py @@ -34,6 +34,7 @@ from .coordinator import Coordination from .declared_family import DeclaredFamily from .errors import RelationViolationError +from .field_codec import TurnId from .routing import TurnRouting from .runtime_info import RuntimeInfo, RuntimeInfoStore from .thread_identity import OwnerIdentity, ThreadIncarnation @@ -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/agent_backend.py b/src/agent_comms/agent_backend.py index 1dbf9a7e38..d4dc7d004e 100644 --- a/src/agent_comms/agent_backend.py +++ b/src/agent_comms/agent_backend.py @@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, ClassVar, Self from .declared_family import DeclaredFamily +from .field_codec import InputId from .pi_counts import PiCount from .pi_vocabulary import CompactionReason, ThinkingLevelField from .send_outcome import SendBoundary @@ -110,17 +111,6 @@ def model_choice(self): # --------------------------------------------------------------------------- -@dataclass(frozen=True) -class InputId: - """Core's identity for one input; a durable backend uses it as its request ID.""" - - value: str - - def __post_init__(self) -> None: - if type(self.value) is not str or not self.value: - raise ValueError("An input ID must be a nonempty string") - - class BusyInputPolicy(DeclaredFamily, affix="InputPolicy"): """What the backend does with an input that arrives while a run is going.""" @@ -291,14 +281,39 @@ 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. ``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. """ @@ -308,8 +323,7 @@ class TurnDelivery: inbox: TurnInbox 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 bf9e040779..044d4a15f8 100644 --- a/src/agent_comms/backend.py +++ b/src/agent_comms/backend.py @@ -43,10 +43,10 @@ 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 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 @@ -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,10 +116,9 @@ 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, - native_start: Callable[[str | None, str, str], bool] | None = None, + send_boundary: SendBoundary = NO_SEND_BOUNDARY, *, + input_start: InputStartListener, persistent_session: PersistentPiSession, package: VerifiedNativePackage, root: Path, @@ -162,7 +161,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, @@ -171,8 +170,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), - interrupt_boundary=interrupt_boundary, + input_start=input_start, native_session=persistent_session, ui_request=ui_request, request_observer=request_observer, @@ -183,53 +181,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.""" @@ -275,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, @@ -292,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) @@ -392,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.""" @@ -692,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/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/commander/order.py b/src/agent_comms/commander/order.py index aebdc9fae8..8e105b1903 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -40,6 +40,7 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.errors import RelationViolationError from agent_comms.field_codec import ( + MessageId, Argument, FieldCodec, MalformedRecord, @@ -360,7 +361,7 @@ def actor_identity(self) -> ThreadIncarnation: return self.actor.as_thread(self.commander).identity(self.comms) def post(self, audience: VerdictAudience, body: str, - answers: tuple[MessageReference, ...] = ()) -> str: + answers: tuple[MessageReference, ...] = ()) -> MessageId: target = audience.target published = self.comms.messaging.send(self.actor_name, target.name, target.addressed(audience.threads, body), answers=answers) 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/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/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/cursor_owner.py b/src/agent_comms/cursor_owner.py index 877914495b..62dc1f1ebb 100644 --- a/src/agent_comms/cursor_owner.py +++ b/src/agent_comms/cursor_owner.py @@ -7,10 +7,10 @@ from functools import partial from typing import TYPE_CHECKING +from agent_comms.field_codec import InputId from agent_comms.native_runtime_input import NativeRuntimeTable from .acp_extension import CursorObservation, EmptyCursorObservation, VerifiedCursorObservation -from .agent_backend import InputId from .coordination_database import CoordinationStore from .coordination_errors import IdentityConflict, StaleFence from .coordinator import Coordination diff --git a/src/agent_comms/cursor_publication.py b/src/agent_comms/cursor_publication.py index 2aa47bb8b2..eff360fa49 100644 --- a/src/agent_comms/cursor_publication.py +++ b/src/agent_comms/cursor_publication.py @@ -22,11 +22,11 @@ UnavailableCursorObservation, encode_updates, ) -from .agent_backend import InputId from .comms import Comms from .coordinator import Coordination from .cursor_owner import CommittedCursorOwner, CursorOwner, CursorState from .diagnostics import PublicationMeasurements +from .field_codec import InputId from .message_bus import MessageBus from .native_entries import NativeEvidenceScope from .private_registry_guard import RegistryPrivacy @@ -64,7 +64,7 @@ async def reading(self) -> AsyncIterator[NativeEvidenceScope]: yield self.source_reads async def advance( - self, participant: SelectedParticipant, input_id: str, + self, participant: SelectedParticipant, input_id: InputId, ) -> CursorState: """Selected settlement borrows this recipient's serialized reader lifetime.""" async with self.reading() as source_reads: @@ -73,7 +73,7 @@ async def advance( claimed_generation=participant.owner.claimed_generation, wire_root_id=participant.root_id, generation=participant.identity.generation, - input_id=InputId(input_id), + input_id=input_id, ) return await owner.advance_async(participant.bus, source_reads=source_reads) diff --git a/src/agent_comms/envelope_claim_transitions.py b/src/agent_comms/envelope_claim_transitions.py index 8a9b0ec60f..0b497bf023 100644 --- a/src/agent_comms/envelope_claim_transitions.py +++ b/src/agent_comms/envelope_claim_transitions.py @@ -19,6 +19,8 @@ EnvelopeSequence, FieldCodec, Id128Text, + MessageId, + TurnId, NullMember, ProjectionSequence, RecordMember, @@ -30,7 +32,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 +180,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 @@ -208,14 +209,14 @@ 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] execution_id: Annotated[str, BoundedText] participant_generation: int attempt_ordinal: int - version: Annotated[Literal[1], WireRequired()] = 1 + version: Annotated[Literal[2], WireRequired()] = 2 def belongs_to(self, attempt: AttemptRecord) -> bool: # The attempts table's primary key fixes its immutable participant identity. diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index f509a79799..89a3a3f509 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) 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) @@ -880,6 +880,296 @@ 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 _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: + 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.""" + scalar: type[ValueDecoder[object]] = _ScalarDecoder + return next(iter(cls._registered(annotation)), scalar) + + @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 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 + + +@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 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) + + +@dataclass(frozen=True, slots=True) +class _NominalTextDecoder[T: str](_RepresentedDecoder[T]): + """The field owns the decoded kind; its inherited spelling owns wire text.""" + + structural: _ScalarDecoder[T, type[T]] + + def decode(self, data: object) -> T: + return self.structural.target(super().decode(data)) + + def encode(self, value: T) -> object: + encoded = self.representation.encode(value) + target = self.structural.target + if not isinstance(value, target): + raise MalformedRecord(f"Expected {target}, received {type(value).__name__}") + return encoded + + def compared(self, value: T) -> object: + return self.encode(value) + + class FieldRepresentation[T](MroDispatch, ABC): """A field capability, declared as ``Annotated[ValueType, Representation]``. @@ -888,6 +1178,31 @@ 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_view(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]: + owner = ValueDecoder.represented_class(target) + 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: @@ -928,6 +1243,78 @@ 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_view(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]: + 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]): """Null is this representation's input, not the structural target's.""" @@ -958,6 +1345,10 @@ class WireValue(WireForm): __slots__ = () + @classmethod + def representation(cls) -> type[FieldRepresentation]: + return cls + @abstractmethod def to_wire(self) -> JsonValue: ... @@ -1326,6 +1717,41 @@ 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__ = () + spelled_by: ClassVar[type[NominalSpelling]] + + @classmethod + def representation(cls) -> type[FieldRepresentation]: + return cls.spelled_by + + +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) + 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.spelled_by = 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 +1853,55 @@ 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 AssignmentGeneration(Id128): + pass + + +class TurnId(Id128): + pass + + +class GoalId(Id128): + pass + + +class RootId(Id128): + pass + + +class ThreadLookup(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,10 +1909,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.""" @@ -1530,10 +1990,13 @@ 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]]: + """The value type and its declared representation, structural when unencoded.""" if annotation is None: - return None, None + return None, StructuralRepresentation if annotation is object: return object, _JsonValueRepresentation if get_origin(annotation) is Annotated: @@ -1548,23 +2011,22 @@ def _representation(annotation: object) -> tuple[object, type[FieldRepresentatio raise TypeError("A field must have one representation") if isinstance(target, TypeAliasType): if target is JsonValue: - return object, representations[0] if representations else _JsonValueRepresentation + if representations: + return object, representations[0] + return object, _JsonValueRepresentation if representations: return target, representations[0] if target is object: return object, _JsonValueRepresentation - if isinstance(target, type) and issubclass(target, WireValue): - return target, target - return target, None + if isinstance(target, type) and issubclass(target, (NominalText, WireValue)): + return target, target.representation() + return target, StructuralRepresentation @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 + def _value_representation( + cls, value: object, annotation: object, + ) -> type[FieldRepresentation]: + return cls.representation(annotation)[1].for_value(value) @classmethod def record_fields(cls, record: type) -> tuple[tuple[Field[object], str], ...]: @@ -1663,7 +2125,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( @@ -1710,11 +2172,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) @@ -1787,48 +2244,11 @@ def _record_schema( "additionalProperties": False, } - @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: - schema = representation.schema() - if type(None) in get_args(annotation): - return cls.alternatives_schema("anyOf", [schema, {"type": "null"}]) - return schema - 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 value_schema(cls, annotation: object) -> dict[str, JsonValue]: + """Derive external scalar/array choices from the same decode declarations.""" + annotation, representation = cls.representation(annotation) + return representation.schema_for(annotation) @classmethod def project(cls, value: object, view: str, annotation: object = None) -> JsonValue: @@ -1839,12 +2259,14 @@ 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: - return RepresentedProjection.of(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: 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) @@ -1959,204 +2381,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: - 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 class _ContextDecoder[T](ValueDecoder[T]): @@ -2182,9 +2407,11 @@ def argument_options(self, argument: Argument) -> dict[str, object]: @dataclass(frozen=True, slots=True) -class _ScalarDecoder[T](ValueDecoder[T]): +class _ScalarDecoder[T, Target = object](ValueDecoder[T]): """A plain target accepts no wire value unless a scalar below declares it.""" + target: Target + def decode(self, data: object) -> T: if self.accepts(data): return data @@ -2235,6 +2462,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 @@ -2275,62 +2509,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 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) class _JsonValueRepresentation(FieldRepresentation[JsonValue]): @@ -2486,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 @@ -2952,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 @@ -3060,14 +3242,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, 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, representation = FieldCodec.representation(annotation) + return representation.decoder_for(annotation, declared) def _structural_decoder(annotation: object) -> ValueDecoder[object]: diff --git a/src/agent_comms/following_inputs.py b/src/agent_comms/following_inputs.py index 68f1e202df..f23dea0d43 100644 --- a/src/agent_comms/following_inputs.py +++ b/src/agent_comms/following_inputs.py @@ -6,13 +6,14 @@ from functools import partial from typing import TYPE_CHECKING +from .field_codec import InputId from .acp_extension import LiveQueueScope from .agent_backend import TurnInbox from .coordinator import Coordination 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: @@ -42,7 +43,7 @@ async def ready(inputs: InputDrain, session_id: str, after_seq: int = 0) -> None async def open(self, session: TrackedTurnSession) -> None: return None - async def started(self, input_id: str) -> AcceptedFollowingInput: + async def started(self, input_id: InputId) -> AcceptedFollowingInput: raise AssertionError("A turn without an inbox cannot start a following input") async def retired(self, event: InputRefused) -> None: @@ -65,7 +66,7 @@ async def pass_on_unread(self, turns: TurnRunner) -> None: if await self.pass_on(turns, input_id): return # The next turn owns the remaining live acceptances. - async def pass_on(self, turns: TurnRunner, input_id: str) -> bool: + async def pass_on(self, turns: TurnRunner, input_id: InputId) -> bool: inputs, session_id = self.inputs, self.session_id item = inputs.state(session_id).queued.get(input_id) try: @@ -89,9 +90,9 @@ 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, + original_source=original, goal_permit=NoLaunchPermit(), ) self.scope = LiveQueueScope( @@ -106,7 +107,7 @@ async def open(self, session: TrackedTurnSession) -> None: after_seq=session.prompt_send_boundary.following_after_seq, # type: ignore[union-attr] ) - async def started(self, input_id: str) -> AcceptedFollowingInput: + async def started(self, input_id: InputId) -> AcceptedFollowingInput: source = self.inputs.state(self.session_id).following[input_id] await source.started(self.inputs, self.session_id, self.scope) await self.ready(self.inputs, self.session_id) 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 80b42ddc0c..32dd3e38b8 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 @@ -588,7 +588,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, ...]: ... @@ -678,7 +678,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, ...]: @@ -785,7 +785,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/historical_native_inputs.py b/src/agent_comms/historical_native_inputs.py index 1c68f90747..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 WireName +from .field_codec import InputId, MessageId, WireName from .native_input_record import ( FullNativeExecution, NativeInputExecution, @@ -41,10 +41,10 @@ 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: str + input_id: InputId owner_lookup: str owner_generation: int context: NativeContextProof @@ -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_attempt.py b/src/agent_comms/input_attempt.py index 2e59635671..d0ba3ad078 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, @@ -359,7 +360,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() ] @@ -371,9 +372,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..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, @@ -39,7 +40,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 @@ -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,)) @@ -298,7 +299,8 @@ 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/input_drain.py b/src/agent_comms/input_drain.py index 4858ee3b7e..44dadd582b 100644 --- a/src/agent_comms/input_drain.py +++ b/src/agent_comms/input_drain.py @@ -21,7 +21,7 @@ encode_updates, ) from .activity import FailedWakeDiagnostic -from .agent_backend import ClearQueue, InputContent, InputId, SendNow, TurnInbox, TurnInboxItem +from .agent_backend import ClearQueue, InputContent, SendNow, TurnInbox, TurnInboxItem from .bus import Bus from .child_process import join_retirement from .cohort_schema import AcceptanceFrontier @@ -38,6 +38,7 @@ record_drain_failure, ) from .errors import RelationViolationError, UnregisteredThreadError +from .field_codec import InputId from .image_input import ImageInput from .input_attempt import ( UNOBSERVED_DELIVERY_SCOPE, @@ -419,9 +420,9 @@ class DrainedSession: wake_task: asyncio.Task[WakeOutcome] | None = None observation_watcher: ChangeSignal | None = None turn: DrainTurn = field(default_factory=NoDrainTurn) - queued: dict[str, QueuedInput] = field(default_factory=dict) - restored: dict[str, QueuedInput] = field(default_factory=dict) - following: dict[str, AcceptedFollowingInput] = field(default_factory=dict) + queued: dict[InputId, QueuedInput] = field(default_factory=dict) + restored: dict[InputId, QueuedInput] = field(default_factory=dict) + following: dict[InputId, AcceptedFollowingInput] = field(default_factory=dict) queue_revision: int = 0 pending_turns: list[ScheduledTurn] = field(default_factory=list) # Only a completed, quiescent observation can suppress another scan. @@ -579,7 +580,7 @@ async def clear_queued_inputs(self, session_id: str) -> None: )) state.queued = retained if cleared: - state.to_turn(ClearQueue(tuple(InputId(item.input_id) for item in cleared))) + state.to_turn(ClearQueue(tuple(item.input_id for item in cleared))) state.restored = {} await self.emit_queue_state(session_id) @@ -749,7 +750,7 @@ async def send_now(self, session_id: str) -> None: async with self.queue_lock: state = self.state(session_id) if state.queued: - state.to_turn(SendNow(tuple(InputId(input_id) for input_id in state.queued))) + state.to_turn(SendNow(tuple(state.queued))) async def close(self) -> None: """Retire input producers; their turns settle from the runner's owner stop.""" @@ -763,7 +764,7 @@ async def close(self) -> None: for state in self.drained.values(): state.retire_producers() - async def input_refused(self, session_id: str, input_id: str) -> None: + async def input_refused(self, session_id: str, input_id: InputId) -> None: state = self.state(session_id) source = state.following.pop(input_id, None) if source is not None: @@ -774,7 +775,7 @@ async def input_refused(self, session_id: str, input_id: str) -> None: await self.emit_queue_state(session_id) async def input_cleared( - self, session_id: str, input_id: str, native_id: str, text: str, + self, session_id: str, input_id: InputId, native_id: str, text: str, ) -> None: source = self.state(session_id).following.get(input_id) if source is not None: 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/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/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/manual_compaction_bridge.py b/src/agent_comms/manual_compaction_bridge.py index 66ecab8c0f..c41f1af03c 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 .field_codec import TurnId 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, + TurnId.mint(), "Compacting context", task=task, )) runner.turn_tasks[session_id] = task await runner.transition_turn( 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/messages.py b/src/agent_comms/messages.py index b08d61c539..22d8c29740 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, @@ -948,8 +948,8 @@ def view_order(self) -> tuple[int, int, int]: return 1, 0, self.seq @projected(view="wire", name="id") - def message_id(self) -> str: - return MessageIdText.mint(f"{self.sender}:{self.target}:{self.timestamp}:{self.body}") + def message_id(self) -> MessageId: + 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/native_input_record.py b/src/agent_comms/native_input_record.py index b0414cc8d6..f347e4a9cb 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -17,7 +17,7 @@ from .declared_family import DeclaredFamily from .field_codec import ( FieldCodec, - Id128Text, + InputId, JsonShapeFamily, JsonShapeMember, MalformedRecord, @@ -46,8 +46,7 @@ class NativeInputCommit: """Original emitted input identity shared with its assembled context.""" input_id: Annotated[ - str, - Id128Text, + InputId, WireName("inputId"), Column(primary_key=True, check="length(input_id)=32 AND input_id NOT GLOB '*[^a-f0-9]*'"), ] @@ -208,7 +207,7 @@ def proves_full_source(self, proof: HistoricalNativeInput) -> bool: def source_membership_sql(cls) -> str: """Select the assignments included in this stage's input.""" - def source_assignment_ids(self, db: Connection, input_id: str) -> tuple[str, ...]: + def source_assignment_ids(self, db: Connection, input_id: InputId) -> tuple[str, ...]: from .coordination_tables.assignments import WakeAssignment columns = ",".join(f"c.{column}" for column in WakeAssignment.columns()) @@ -220,7 +219,9 @@ def source_assignment_ids(self, db: Connection, input_id: str) -> tuple[str, ... return tuple(original.assignment_id for original in originals) @abstractmethod - def record_sources(self, db: Connection, input_id: str, 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 @@ -258,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: str, 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) @@ -294,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: str, 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") @@ -367,7 +372,7 @@ def proves_full_source(self, proof: HistoricalNativeInput) -> bool: class NativeInputIdentity: """Original reservation identity, independent of registry allocation domains.""" - input_id: str + input_id: InputId execution: NativeInputExecution owner: OwnerGenerations @@ -393,7 +398,7 @@ class UnrecordedNativeInputReference(NativeContextReference): class NativeInputReference(NativeContextReference): """A cursor's bounded reference to original recorded native context.""" - input_id: Annotated[str, Id128Text] + input_id: InputId stage: type[NativeInputExecution] session_id: str request_generation: int @@ -427,7 +432,7 @@ def require_recorded_input(self, owner: CursorOwner, db: sqlite3.Connection) -> class NativeInputRecord(OwnerRecord, ABC): - input_id: str + input_id: InputId execution: NativeInputExecution owner_lookup: str owner_generation: int @@ -446,7 +451,7 @@ def identity(self) -> NativeInputIdentity: class NativeInputContext: """Decode the declared nullable SQL context group into its original state.""" - input_id: str | NativeColumn + input_id: InputId | NativeColumn stage: type[NativeInputExecution] | NativeColumn session_id: str | NativeColumn request_generation: NativeColumn diff --git a/src/agent_comms/native_prompt_binding.py b/src/agent_comms/native_prompt_binding.py index 5368b1a081..645fc4d1dc 100644 --- a/src/agent_comms/native_prompt_binding.py +++ b/src/agent_comms/native_prompt_binding.py @@ -9,7 +9,7 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path @@ -20,7 +20,7 @@ from agent_comms.coordinator import Coordination from .cohort_schema import CohortTable -from .field_codec import FieldCodec, Id128Text, MalformedRecord +from .field_codec import FieldCodec, Id128Text, InputId, MalformedRecord from .locked_store import DurableFile, SQLiteFile from .native_entries import NativeEvidenceRead from .native_input_owner import ParticipantOwner @@ -57,7 +57,7 @@ class PromptBinding( filename = "native_prompt_bindings.sqlite3" input_id: Annotated[ - str, + InputId, Column(primary_key=True, check="length(input_id)=32 AND input_id NOT GLOB '*[^0-9a-f]*'"), ] stage: type[NativeInputExecution] @@ -101,7 +101,7 @@ def _ensure_binding_schema(store: Coordination) -> None: def bind_expected_prompt( store: Coordination, *, - input_id: str, + input_id: InputId, stage: NativeSendStage, owner: Thread, generation: int, @@ -192,12 +192,10 @@ def _binding_wire_root(store: Coordination, assignment: WakeAssignment) -> str: @contextmanager def expected_prompt_bindings( - store: Coordination, input_ids -) -> Iterator[dict[str, PromptBinding]]: + store: Coordination, input_ids: Iterable[InputId] +) -> Iterator[dict[InputId, PromptBinding]]: """Keep one immutable binding snapshot open while validating input.""" input_ids = tuple(input_ids) - if any(type(input_id) is not str for input_id in input_ids): - raise ValueError("prompt binding lookup requires string input IDs") path = binding_store_path(store) if not input_ids or (not path.exists() and not path.is_symlink()): yield {} diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index c277a204e2..e903ae80a8 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -17,7 +17,7 @@ from .coordination_errors import IdentityConflict, PublicationActivationBlocked, StaleFence from .declared_family import DeclaredFamily -from .field_codec import FieldCodec, MalformedRecord, NativeContext +from .field_codec import FieldCodec, InputId, MalformedRecord, NativeContext from .native_claim_epoch import NativeClaimEpoch, UnrecordedNativeClaim from .tool_denial import SelectedToolDenied from .native_input_record import ( @@ -244,7 +244,7 @@ def published_replies(self, db, user, owner_lookup): return self.execution.published_replies(db, owner_lookup) input_id: Annotated[ - str, + InputId, FrozenColumn( primary_key=True, check="length(input_id)=32 AND input_id NOT GLOB '*[^0-9a-f]*'" ), @@ -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/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/nk_foreground.py b/src/agent_comms/nk_foreground.py index 7610c81587..9c7a19c886 100644 --- a/src/agent_comms/nk_foreground.py +++ b/src/agent_comms/nk_foreground.py @@ -31,7 +31,7 @@ from .coordinated_runtime import SelectedExecution from .cursor_publication import CursorDelivery from .errors import RelationViolationError -from .field_codec import FieldCodec, MalformedRecord +from .field_codec import FieldCodec, MalformedRecord, RootId from agent_comms.native_session_files import NativeSessionFiles from .native_package import VerifiedNativePackage from .selected_result import CoordinatedTurn @@ -74,7 +74,7 @@ async def run_go(self, line: str) -> CoordinatedTurn | None: def reserve_foreground_owner( root: Path, *, - wire_root_id: str, + wire_root_id: RootId, name: str, task: str, tags: frozenset[str], @@ -95,7 +95,7 @@ def reserve_foreground_owner( ): raise PublicationActivationBlocked("Foreground N/K requires a private /var/tmp root") NativeSessionFiles.require_private_directory(root) - if type(wire_root_id) is not str or not wire_root_id: + if not wire_root_id: raise MalformedRecord("A private wire-root ID is required") worktree = root / "work" NativeSessionFiles.require_private_directory(worktree) diff --git a/src/agent_comms/notification_assignment.py b/src/agent_comms/notification_assignment.py index 9fa4bd4288..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 +from .field_codec import FieldCodec, MalformedRecord, MessageId, boundary_decoder from .message_reference import MessageReference from .notification_progress import ( AnsweredProgress, @@ -71,11 +71,12 @@ 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: 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/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/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..2b7edbba9f 100644 --- a/src/agent_comms/owned_send.py +++ b/src/agent_comms/owned_send.py @@ -3,32 +3,30 @@ 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 .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, 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 TurnId +from .thread_identity import RegisteredTurn from .turn_input_source import ( OriginalTurnInput, TurnInputSource, @@ -37,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 @@ -47,34 +46,39 @@ @dataclass(frozen=True, kw_only=True) -class OwnedSend(CodingToolOwner): +class OwnedSend(CodingToolOwner, InputStartListener, SendBoundary): + started_events = (events.OriginalInputStarted(),) + inputs: InputDrain goal_store: GoalAttemptStore session_id: str thread: Thread - turn: TurnId + turn: RegisteredTurn claim_generation: int - original: OriginalTurnInput + original_source: OriginalTurnInput goal_permit: GoalTurnPermit - def source(self, public_id: str | None) -> TurnInputSource: - if public_id is None: - return self.original + 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, @@ -98,78 +102,24 @@ def _refusal(self, rule: ReservationRule) -> SendOutcome: _LOG.info("Ordinary input refused: %s", ReservationViolationError(rule)) return AuthorityChangedSend() - def _prepare( - self, public_id: str | 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. - 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() - except ReservationViolationError as error: - return self._refusal(error.rule) - if source.defers_for_goal(self.original.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: str | 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: - 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.""" @@ -178,10 +128,14 @@ 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: - self._check_owner(current, snapshot, canonical, self.original, turn.command.message) - self._require_context(self.original, current, self.comms.goals.goal_wait(canonical)) + current = self.require_owner(snapshot, 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 @@ -207,8 +161,13 @@ def release() -> None: await Coordination.run_worker(release) - def native_start(self, public_id: str | 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 d7da241573..9d2b0f6f76 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 Id128Text +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, @@ -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: str | 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() @@ -131,7 +132,7 @@ def authorize(self): return True def begin(self, resources: AsyncExitStack, permits: ExitStack) -> bool: - self.turn_id = Id128Text.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 +153,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 +221,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, @@ -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, @@ -269,7 +270,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, ) @@ -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. @@ -345,10 +344,10 @@ 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), + original_source=replace(self.original, prompt=rendered.text), ) from .channel_coding_tools import CodingToolMode @@ -359,10 +358,7 @@ 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_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..6e01447912 100644 --- a/src/agent_comms/pi_native_backend.py +++ b/src/agent_comms/pi_native_backend.py @@ -273,8 +273,7 @@ async def run_turn( steering_queue=delivery.inbox, 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 dacd14f84e..9e03dc99be 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, ClassVar from agent_comms.declared_family import DeclaredFamily +from agent_comms.field_codec import Id128, MessageId, NominalSpelling, NominalText from agent_comms.phase_evidence import ( AllEvidence, BusStorageAbsent, @@ -147,6 +148,7 @@ class RemainingSlopPhase(PlanPhase, declared_name=None): class Id128NominalPhase(PlanPhase, declared_name=None): plan = Id128NominalPlan + paths: ClassVar[tuple[str, ...]] = ("src", ":!src/agent_comms/skills/") class RetainedStorePhase(CommanderNativePhase): @@ -804,10 +806,10 @@ class NominalIdTypePhase(Id128NominalPhase): evidence = AllEvidence( ( - ClassDeclared("NominalText", ("src/agent_comms/field_codec.py",)), - 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(kind.__name__, ("src/agent_comms/field_codec.py",)) + for kind in ( + NominalText, NominalSpelling, Id128, MessageId, *Id128.__subclasses__(), + )), PatternAbsent( r"def mint\(cls(, text: str)?\) -> str:", ("src/agent_comms/field_codec.py",) ), @@ -823,13 +825,20 @@ 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"Id128Text\.decode\(self\.(nonce|operation_id)\)"), + PatternAbsent( + r"\b(nonce|operation_id|generation): " + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)", + Id128NominalPhase.paths, + ), + PatternAbsent( + r"Id128Text\.decode\(self\.(nonce|operation_id)\)", + Id128NominalPhase.paths, + ), ) ) @@ -837,60 +846,89 @@ 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): (str\b|Annotated\[str)"), - PatternAbsent(r"Annotated\[str, *MessageIdText\]"), + PatternAbsent( + r"\b(message_id|source_message_id): " + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)", + Id128NominalPhase.paths, + ), + PatternAbsent( + r"Annotated\[str, *MessageIdText\]", + Id128NominalPhase.paths, + ), ) ) 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,) + after = (NominalIdTypePhase,) 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)", + Id128NominalPhase.paths, + ), + PatternAbsent( + r"Id128Text\.(decode|from_text)\((self\.)?turn_id\)", + Id128NominalPhase.paths, ), - PatternAbsent(r"Id128Text\.(decode|from_text)\((self\.)?turn_id\)"), ) ) 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)") + after = (NominalIdTypePhase,) + evidence = PatternAbsent( + r"\b(goal_id|original_goal_id): (str\b|Id128\b|Annotated\[(str|Id128)\b)", + Id128NominalPhase.paths, + ) 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,) + after = (NominalIdTypePhase,) 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): " + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)", + Id128NominalPhase.paths, + ), + PatternAbsent( + r"Id128Text\.decode\((self\.)?(owner\.)?(wire_root_id|root_id|lookup)\)", + Id128NominalPhase.paths, + ), + PatternAbsent( + r"FieldCodec\.decode\(Annotated\[str, *Id128Text\]", + Id128NominalPhase.paths, ), - PatternAbsent(r"Id128Text\.decode\((self\.)?(owner\.)?(wire_root_id|root_id|lookup)\)"), - PatternAbsent(r"FieldCodec\.decode\(Annotated\[str, *Id128Text\]"), ) ) 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,) + after = (NominalIdTypePhase,) evidence = AllEvidence( ( - PatternAbsent(r"\b(input_id|accepted_input_id|native_id): (str\b|Annotated\[str)"), - PatternAbsent(r"Id128Text\.(decode|from_text)\((self\.)?(input_id|native_id)\)"), + PatternAbsent( + r"\b(input_id|native_id): " + r"(str\b|Id128\b|Annotated\[(str|Id128)\b)", + Id128NominalPhase.paths, + ), + PatternAbsent( + r"Id128Text\.(decode|from_text)\((self\.)?(input_id|native_id)\)", + Id128NominalPhase.paths, + ), ) ) 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_claim.py b/src/agent_comms/private_send_claim.py index 026d6f8f57..d7cd68d80e 100644 --- a/src/agent_comms/private_send_claim.py +++ b/src/agent_comms/private_send_claim.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING from agent_comms.native_runtime_input import NativeRuntimeTable +from .field_codec import InputId from .coordination_errors import IdentityConflict, RetryScheduled from .coordinator import Coordination from .diagnostics import ( @@ -55,7 +56,7 @@ class PrivateSendClaim(PromptBoundary): selected: SelectedParticipant stage: NativeSendStage - input_id: str + input_id: InputId token_digest: str prompt: RenderedInput session: SelectedSession diff --git a/src/agent_comms/private_send_stage.py b/src/agent_comms/private_send_stage.py index 66562be4bd..c759eb992e 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, @@ -73,7 +73,7 @@ def fail_terminal( self, store: Coordination, owner: ParticipantOwner, - input_id: str, + input_id: InputId, token_digest: str, context: NativeContextProof, failure: TurnFailure, @@ -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) @@ -108,13 +108,13 @@ def reserve(self, store: Coordination, owner: ParticipantOwner, token_digest: st self.execution.record_sources(db, input_id, self.assignments) return input_id - def identity(self, input_id: str, owner) -> NativeInputIdentity: + def identity(self, input_id: InputId, owner) -> NativeInputIdentity: return NativeInputIdentity( input_id, self.execution, owner, ) def pending_input( - self, store: Coordination, input_id: str, owner: ParticipantOwner, token_digest: str + self, store: Coordination, input_id: InputId, owner: ParticipantOwner, token_digest: str ) -> NativeRuntimeInput: """Read current durable ownership inside the caller's read/write transaction.""" db = store.session._connection @@ -139,7 +139,7 @@ def verify( self, store: Coordination, owner: ParticipantOwner, - input_id: str, + input_id: InputId, token_digest: str, context: NativeContextProof, *, @@ -181,7 +181,7 @@ def require_claim(self, store: Coordination) -> None: self.require_phase(store, current) def require_reservation( - self, db: sqlite3.Connection, input_id: str, owner: ParticipantOwner, token_digest: str + self, db: sqlite3.Connection, input_id: InputId, owner: ParticipantOwner, token_digest: str ) -> NativeRuntimeInput: reserved = NativeRuntimeInput.one(db, input_id=input_id) if reserved is None: @@ -206,7 +206,7 @@ def require_sources(self, db, input_id) -> None: @contextmanager def bound_prompt( - self, store: Coordination, input_id: str, owner: ParticipantOwner, + self, store: Coordination, input_id: InputId, owner: ParticipantOwner, wire_root_id: str, prompt: str, ) -> Iterator[PromptBinding]: with expected_prompt_bindings(store, (input_id,)) as bindings: @@ -303,7 +303,7 @@ def commit( self, store: Coordination, owner: ParticipantOwner, - input_id: str, + input_id: InputId, token_digest: str, context: NativeContextProof, decision: SelectedTriage, @@ -385,7 +385,7 @@ def commit( self, store: Coordination, owner: ParticipantOwner, - input_id: str, + input_id: InputId, token_digest: str, context: NativeContextProof, ) -> None: diff --git a/src/agent_comms/proven_source_coverage.py b/src/agent_comms/proven_source_coverage.py index 59f13b8627..8fd6ca48ac 100644 --- a/src/agent_comms/proven_source_coverage.py +++ b/src/agent_comms/proven_source_coverage.py @@ -19,7 +19,6 @@ from agent_comms.lifecycle_capability import Active from agent_comms.native_runtime_input import NativeRuntimeTable -from .agent_backend import InputId from .audience_manifest import FrozenRecipient from .bus import Bus from .bus_publication import Delivered @@ -143,7 +142,7 @@ def allows(self, owner: CursorOwner, db: sqlite3.Connection, state: CursorState) if self.original.owner_identity != owner.participant_identity: raise IdentityConflict('current cursor native proof belongs to another owner') state.require_input_progress(self.original) - if not owner.permits_input(InputId(self.original.input_id)): + if not owner.permits_input(self.original.input_id): return False row = NativeRuntimeInput.one(db, input_id=self.original.input_id) if row is not None and not row.sent_owner_claimed_generation.matches(owner.claimed_generation): 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..729dc37ba5 100644 --- a/src/agent_comms/queued_input.py +++ b/src/agent_comms/queued_input.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Generic, Self, TypeVar +from .agent_backend import InputRequest from .assignment_states import DeferredAssignment, DeliveredAssignment from .child_process import ProcessIdentity from .controller import Controller @@ -23,7 +24,7 @@ from .coordination_session import ReadNativeCoordinationSession from .coordinator import Coordination from .errors import RelationViolationError -from .field_codec import Id128Text +from .field_codec import InputId from .goals import GoalDeclaration, NoGoal from .image_input import ImageInput from .input_attempt import InputAttempt, ReservedInput @@ -32,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 @@ -83,7 +84,7 @@ class QueuedInput: text: str echo: bool context: QueuedInputContext - input_id: str + input_id: InputId prompt: str images: tuple[ImageInput, ...] = () controller: Controller | None = None @@ -101,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)), ) @@ -117,7 +118,7 @@ def capture( images: tuple[ImageInput, ...], controller: Controller | None, ownership: ExitStack, - input_id: str | None = None, + input_id: InputId, origin: InputOrigin = UnattributedInputOrigin(), ) -> tuple[Self, InputAttempt]: """Called under the registry lock; acceptance follows the durable reservation.""" @@ -133,7 +134,7 @@ def capture( text or prompt or "[image prompt]", echo, context, - Id128Text.mint() if input_id is None else input_id, + input_id, prompt, images, controller, @@ -164,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: str | None = None, + input_id: InputId, origin: InputOrigin = UnattributedInputOrigin(), ) -> AsyncIterator[InputReservation[Self]]: """Serialize local queue placement; reserve durable input in a worker.""" @@ -191,14 +192,12 @@ def place( inbox: TurnInbox, content: InputContent, ) -> None: """The accepted input owns transfer into this turn's existing native queue.""" - from .agent_backend import InputId, InputRequest - inputs.require_inbox(session_id, inbox) source = self.source() inputs.state(session_id).following[self.input_id] = source inputs.state(session_id).queued[self.input_id] = self inbox.put_nowait(InputRequest( - input_id=InputId(self.input_id), content=content, when_busy=source.when_busy, + input_id=self.input_id, content=content, when_busy=source.when_busy, )) async def dispatch(self, turns: TurnRunner, session_id: str, name: str) -> None: @@ -229,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: @@ -316,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,), ) @@ -375,7 +374,7 @@ def reserve_pending( reserved.append(( cls( text=row.source_text, echo=False, context=context, - input_id=Id128Text.mint(), prompt=rendered.text, + input_id=InputId.mint(), prompt=rendered.text, bus_key=key, message=message, assignment=paused, ), InputContent(text=rendered.text, contributions=rendered.contributions), @@ -548,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: str | 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/registration.py b/src/agent_comms/registration.py index 8aa3f1f8e9..0e3680ae78 100644 --- a/src/agent_comms/registration.py +++ b/src/agent_comms/registration.py @@ -11,6 +11,7 @@ from .catalog_store import ChannelCatalog from .child_process import NoProcess, ProcessIdentity from .errors import RelationViolationError +from .field_codec import TurnId from .goal_history import GoalHistoryEntry, GoalHistoryStore from .native_input_owner import RegistryOwner from .private_registry_guard import PrivateRegistryGuard @@ -20,7 +21,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, @@ -31,9 +32,9 @@ from .threads import Thread if TYPE_CHECKING: + from .agent_backend import RuntimeLocation from .goal_management import Goals from .selected_participant import SelectedParticipant - from .agent_backend import RuntimeLocation _DEFAULT_PRESENCE = ProcessThreadStatus() @@ -321,11 +322,11 @@ 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) - 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) @@ -337,14 +338,14 @@ 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, ) -> 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 @@ -356,10 +357,10 @@ 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 = 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/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/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/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_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/selected_write_plan.py b/src/agent_comms/selected_write_plan.py index fbcb2bb41f..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 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: @@ -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/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/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/task_sources.py b/src/agent_comms/task_sources.py index db46bcce06..71dfb73234 100644 --- a/src/agent_comms/task_sources.py +++ b/src/agent_comms/task_sources.py @@ -16,8 +16,8 @@ from .projection_source import ProjectionSource from .thread_identity import ( RecordedThreadIncarnation, + RegisteredTurn, ThreadIncarnation, - TurnId, 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..b6d614cdb2 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) @@ -519,12 +509,19 @@ 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 - turn_id: str + turn_id: TurnId claimed_generation: int def renamed(self, name: str) -> Self: @@ -554,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 6918564842..5b8d7a9c17 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -62,12 +62,13 @@ from .thread_execution import NativeThreadExecution, ThreadExecution from .thread_identity import ( AgentThreadRole, + ObservedTurn, OfferedTools, + RegisteredTurn, ThreadIncarnation, ThreadIncarnationRecord, ThreadRole, TurnFence, - TurnId, TurnIdentity, UnobservedTools, ) @@ -488,7 +489,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 +577,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 @@ -599,12 +600,11 @@ 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.""" - turn_id = self.turn_state.observed_id() - if turn_id is None: - return None - return TurnFence(self.turn_identity, turn_id, claim_scope) + return self.turn_state.active.observed_turn( + self.turn_state.finished, self.turn_identity, claim_scope, + ) def latest_idle_turn(self, fence: TurnFence, claim_scope: int) -> bool: """Only the exact latest completed turn can release a dependency.""" diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 42590dfa8d..2cf7064ea3 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -58,6 +58,7 @@ FieldCodec, GitCommitText, MalformedRecord, + MessageId, NullMember, SingleFieldValue, SubclassUnion, @@ -3268,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) @@ -3452,7 +3453,7 @@ def changed(self, current: Todo) -> Todo: @dataclass(frozen=True) class WordingCorrection(TodoOperation): """Core's answer to a published unit message whose words hide what happened; no strike.""" - message_id: str + message_id: MessageId words: tuple[str, ...] def authored_by(self, actor: ThreadIncarnation) -> Self: @@ -3467,11 +3468,10 @@ def changed(self, current: Todo) -> Todo: class OrderMessage(TodoOperation): """The bus message the commander sent this order in; a verdict citing it lands here.""" actor: ThreadIncarnation - message_id: str + message_id: MessageId def changed(self, current: Todo) -> Todo: current.require_commander(self.actor) - _text(self.message_id, "Order message ID", 200) return replace(current, messages=(*current.messages, self), revision=current.revision + 1) @@ -4374,7 +4374,9 @@ def apply(self, todo_id: str, command: TodoCommand) -> Todo: command.write(db, current, self) return self._current(db, todo_id) - def correct(self, unit: ThreadIncarnation, message_id: str, text: str) -> tuple[Todo, ...]: + def correct( + self, unit: ThreadIncarnation, message_id: MessageId, text: str, + ) -> tuple[Todo, ...]: """Correct every open order the unit holds when its published text uses jargon.""" if not self.path.exists() or not (words := tuple(PlainText.found(text))): return () diff --git a/src/agent_comms/tracked_turn.py b/src/agent_comms/tracked_turn.py index 41431a8547..d51e21fc44 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 @@ -474,8 +474,7 @@ 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 = InputStartListener.from_callback(sender.native_start) + self.input_start = sender self.ensure_input_forwarding() async def accept_user_message( @@ -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_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..74d1d1cee4 100644 --- a/src/agent_comms/turn_input_source.py +++ b/src/agent_comms/turn_input_source.py @@ -3,28 +3,129 @@ 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 +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 .input_attempt import InputAlreadyCleared from .messages import Message +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 -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 .thread_identity import RecordedThreadIncarnation, TurnId + 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 +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 + 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, + ) + + 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: str | None + acceptance: TurnAcceptance goal_permission: TurnGoalPermission bypasses_goal_permit: ClassVar[bool] = False origins: ClassVar[tuple[Message, ...]] = () @@ -32,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 @@ -119,7 +278,8 @@ 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. @@ -182,7 +342,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_inputs.py b/src/agent_comms/turn_inputs.py index 8b2aeb465c..307ad9ec9b 100644 --- a/src/agent_comms/turn_inputs.py +++ b/src/agent_comms/turn_inputs.py @@ -14,16 +14,8 @@ from . import agent_events as events from . import pi_commands as commands -from .agent_backend import ( - ClearQueue, - InputId, - InputRequest, - SendNow, - TurnInbox, - TurnInboxItem, - UseSettings, -) -from .field_codec import Id128Text +from .agent_backend import ClearQueue, InputRequest, SendNow, TurnInbox, TurnInboxItem, UseSettings +from .field_codec import InputId from .locked_store import RegistryFile from .maintenance_barrier import MaintenanceBarrier from .mro_dispatch import MroDispatch, handles @@ -38,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 @@ -68,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 @@ -107,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 @@ -135,14 +127,14 @@ async def write(self, session: NativeSession, *written: commands.PiCommand) -> N async def queued(self, input_ids: tuple[InputId, ...]) -> tuple[str, ...]: """A native prompt acknowledgement proves it reached the child's queue.""" - selected = frozenset(input_id.value for input_id in input_ids) + selected = frozenset(input_ids) 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() @@ -161,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 @@ -186,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)) @@ -195,8 +188,8 @@ 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() + public_id = item.input_id + native_id = InputId.mint() text = item.content.text command = commands.Prompt( id=RequestId(public_id), @@ -211,30 +204,34 @@ 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 def refuse(self, session: TurnSession, input_ids: tuple[InputId, ...]) -> None: for input_id in input_ids: - session.rejected_commands.append(events.FollowingInputRefused(id=input_id.value)) + 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/src/agent_comms/turn_lease.py b/src/agent_comms/turn_lease.py index 4f54e6b0a1..8c687049a7 100644 --- a/src/agent_comms/turn_lease.py +++ b/src/agent_comms/turn_lease.py @@ -13,11 +13,11 @@ from .child_process import ProcessIdentity, ProcessState from .errors import RelationViolationError from .field_codec import ( - MalformedRecord, NullMember, RecordMember, SingleFieldValue, SubclassUnion, + TurnId, WireTransient, boundary_decoder, ) @@ -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 ThreadIncarnation, TurnFence, TurnId, TurnIdentity +from .thread_identity import ( + NoObservedTurn, + ObservedTurn, + RegisteredTurn, + ThreadIncarnation, + TurnFence, + TurnIdentity, +) from .turn_phase import IdlePhase, PreparingPhase, TurnPhase if TYPE_CHECKING: @@ -68,13 +75,15 @@ 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: ... @abstractmethod - def observed_id(self, finished: str | None) -> str | None: ... + def observed_turn( + self, finished: TurnRecord, identity: TurnIdentity, claim_scope: int, + ) -> ObservedTurn: ... @abstractmethod def started_by(self, updated_at_ms: int) -> bool: ... @@ -83,7 +92,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,14 +120,16 @@ 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: return self - def observed_id(self, finished: str | None) -> str | None: - return finished + def observed_turn( + self, finished: TurnRecord, identity: TurnIdentity, claim_scope: int, + ) -> ObservedTurn: + return finished.observed_turn(identity, claim_scope) def started_by(self, updated_at_ms: int) -> bool: return False @@ -126,7 +137,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 +157,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 +181,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 +206,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 +254,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()) @@ -266,8 +277,10 @@ def lease( ) -> TurnLease: return self.turn_generation.fence(self.claimed_generation, incarnation, self.id, record) - def observed_id(self, finished: str | None) -> str: - return self.id + def observed_turn( + self, finished: TurnRecord, identity: TurnIdentity, claim_scope: int, + ) -> TurnFence: + return TurnFence(identity, self.id, claim_scope) def started_by(self, updated_at_ms: int) -> bool: return self.started_at * 1000 <= updated_at_ms + 1 @@ -297,27 +310,6 @@ def current(self, claimed_generation: int, turn_generation: int) -> bool: ) -@dataclass(frozen=True) -class TurnState: - """Read-only capture of the existing lease, not another state store.""" - - active: TurnDeclaration = field(default_factory=NoTurn) - finished_turn_id: str | None = None - - def phase_effects(self, phase: TurnPhase) -> Iterator[TurnState]: - for active in self.active.phase_effects(phase): - yield replace(self, active=active) - - def native_phase_effects(self, phase: TurnPhase) -> Iterator[TurnState]: - yield from self.phase_effects(self.active.phase.observed(phase)) - - def observed_id(self) -> str | None: - return self.active.observed_id(self.finished_turn_id) - - def matches(self, turn_id: str | None) -> bool: - return turn_id is None or self.observed_id() == turn_id - - class TurnLease(ABC): """A thread's exact local turn lease, or the declared absence of one.""" @@ -330,14 +322,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 +360,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 +395,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 @@ -452,6 +444,9 @@ def require_generation(self, generation: int) -> None: pass + @abstractmethod + def observed_turn(self, identity: TurnIdentity, claim_scope: int) -> ObservedTurn: ... + @dataclass(frozen=True) class NoTurnRecord(NullMember, TurnRecord): @@ -469,15 +464,14 @@ def lease(self, incarnation: ThreadIncarnation, process: ProcessState, return NoTurnLease() + def observed_turn(self, identity: TurnIdentity, claim_scope: int) -> NoObservedTurn: + return NoObservedTurn() + @dataclass(frozen=True) class FinishedTurn(RecordMember, TurnRecord): """No live turn; the last one finished with this ID.""" - turn_id: str - - def __post_init__(self) -> None: - if type(self.turn_id) is not str or not self.turn_id: - raise MalformedRecord("Finished turn requires its ID.") + turn_id: TurnId def bound_to(self, registry_path: Path, identity: TurnIdentity) -> FinishedTurn: return self @@ -486,7 +480,7 @@ def read(self) -> NoTurn: return NoTurn() def state(self) -> TurnState: - return TurnState(finished_turn_id=self.turn_id) + return TurnState(finished=self) def without_claim(self) -> None: pass @@ -500,6 +494,9 @@ def require_generation(self, generation: int) -> None: raise ValueError("Finished turn requires a prior turn generation.") + def observed_turn(self, identity: TurnIdentity, claim_scope: int) -> TurnFence: + return TurnFence(identity, self.turn_id, claim_scope) + @dataclass(frozen=True) class StoredTurnReference(RecordMember, TurnRecord): """Wire presence only; the registry's thread supplies the turn identity.""" @@ -521,6 +518,9 @@ def lease(self, incarnation: ThreadIncarnation, process: ProcessState, raise RelationViolationError("Turn reference must be bound by its registry") + def observed_turn(self, identity: TurnIdentity, claim_scope: int) -> TurnFence: + raise RelationViolationError("Turn reference must be bound by its registry") + @dataclass(frozen=True) class StoredTurnRecord(Executing, DurableFile, PatternFile, JsonDocument[ActiveTurn], StoredTurnReference, declared_name=None): @@ -596,6 +596,21 @@ def observe_native_phase( return self._turn_effects(lease, lambda state: state.native_phase_effects(phase)) +@dataclass(frozen=True) +class TurnState: + """Read-only capture of the existing lease, not another state store.""" + + active: TurnDeclaration = field(default_factory=NoTurn) + finished: TurnRecord = field(default_factory=NoTurnRecord) + + def phase_effects(self, phase: TurnPhase) -> Iterator[TurnState]: + for active in self.active.phase_effects(phase): + yield replace(self, active=active) + + def native_phase_effects(self, phase: TurnPhase) -> Iterator[TurnState]: + yield from self.phase_effects(self.active.phase.observed(phase)) + + class TurnCompletion(ABC): """Whether terminal publication attested an exact finished turn.""" diff --git a/src/agent_comms/turn_runner.py b/src/agent_comms/turn_runner.py index eff3d89f28..385be6df51 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 InputId, TurnId from .errors import RelationViolationError from .following_inputs import OwnerFollowingInputs from .goal_actions import ( @@ -46,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 @@ -183,7 +185,7 @@ async def prompt_owned( *, images: tuple[ImageInput, ...], display_text: str | None = None, - input_id: str | None = None, + input_id: InputId, origin: InputOrigin = UnattributedInputOrigin(), ) -> PromptResponse: session_id = request.session_id @@ -304,7 +306,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. @@ -439,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: str | None = None, + acceptance: TurnAcceptance, dependency_wait_id: str | None = None, ) -> None: from .owned_turn import OwnedTurn @@ -456,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/typed_table.py b/src/agent_comms/typed_table.py index 2b7b968f1a..725c731be5 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 @@ -45,8 +45,11 @@ FieldCodec, FieldDirective, FieldPlan, + FieldRepresentation, MalformedRecord, + NominalText, NullMember, + TextRepresentation, ValueDecoder, WireName, WireValue, @@ -180,7 +183,10 @@ class TextStorage(SqlStorage): @classmethod def accepts(cls, annotation: object) -> bool: - return annotation is str + if annotation is str: + return True + _, representation = FieldCodec.representation(annotation) + return issubclass(representation, TextRepresentation) class IntegerStorage(SqlStorage): @@ -2084,7 +2090,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 +2108,18 @@ def producer_owners(cls) -> tuple[type, ...]: if issubclass(parent, ProjectionSource) and ("writes" not in vars(parent) or parent in writers) ) + annotations: list[object] = ( + [column.annotation for column in cls._columns] if is_dataclass(cls) else [] + ) + while annotations: + value, representation = FieldCodec.representation(annotations.pop()) + annotations.extend(get_args(value)) + if isinstance(value, type): + owners.update( + 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/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/goal_stop_journey.py b/tests/goal_stop_journey.py index c160ea7073..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 @@ -65,12 +66,21 @@ async def stopped_goal_journey( resumed = store.snapshot(stopped.goal.require().id) assert resumed is not None and resumed.number == ready.number fixture.provider.response_gate.set() - await wait_for(lambda: ( - bool(comms.registry.require("stop-copy").turn_state.finished_turn_id) - and comms.registry.require("stop-copy").turn_state.finished_turn_id - != stopped.turn_state.finished_turn_id)) + stopped_turn = stopped.observed_turn(stopped.claimed_generation) + + def continued_turn_finished(): + thread = comms.registry.require("stop-copy") + observed = thread.observed_turn(thread.claimed_generation) + return ( + not thread.executing + and observed != NoObservedTurn() + and observed != stopped_turn + ) + + await wait_for(continued_turn_finished) continued = comms.registry.require("stop-copy") - assert continued.turn_state.finished_turn_id + continued_turn = continued.observed_turn(continued.claimed_generation) + 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) @@ -84,8 +94,8 @@ async def stopped_goal_journey( "before": before, "after": after, "continued": FieldCodec.encode(continued.goal), "provider_posts": fixture.provider.posts, - "stopped_turn": stopped.turn_state.finished_turn_id, - "continued_turn": continued.turn_state.finished_turn_id, + "stopped_turn": FieldCodec.encode(stopped_turn), + "continued_turn": FieldCodec.encode(continued_turn), "failed_turn_observations": failures, "stopped_attempt_phase": FieldCodec.encode(attempt.phase), "continued_attempt_phase": FieldCodec.encode(finished.phase), diff --git a/tests/native_backend_fixture.py b/tests/native_backend_fixture.py index 5b0a098fab..2d80aade00 100644 --- a/tests/native_backend_fixture.py +++ b/tests/native_backend_fixture.py @@ -1,37 +1,41 @@ """Acquired original native fixture shared by pytest and installed drivers.""" - -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, InputId, InputRequest, TurnInbox, SteerInputPolicy -from agent_comms.comms import wire -from agent_comms.fresh_private_session import create_fresh_private_session +from agent_comms.agent_backend import ( + InputContent, + InputRequest, + NoInputStartListener, + SteerInputPolicy, + TurnInbox, +) 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): @@ -40,7 +44,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.""" @@ -134,6 +142,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) @@ -143,6 +152,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) @@ -153,7 +163,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; @@ -178,14 +194,14 @@ 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) self.observed.append(event) if followup is not None and isinstance(event, events.InputStarted): queue.put_nowait(InputRequest( - input_id=InputId("queued"), + input_id=InputId.mint(), content=InputContent(text=followup), when_busy=SteerInputPolicy, )) 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 f2b4adecfc..c96a359959 100644 --- a/tests/test_absence_owners.py +++ b/tests/test_absence_owners.py @@ -1,21 +1,22 @@ """Behaviour-bearing absence belongs to the state that owns the operation.""" -from agent_comms.pi_payloads import AnonymousRequest, RequestId import asyncio import pytest -from agent_comms.agent_backend import InputId, SendNow, TurnInbox -from agent_comms.backend import EventFinishSignal, FinishSignal, InputStartListener, NoFinishSignal, NoInputStartListener +from agent_comms import agent_events as events +from agent_comms.agent_backend import InputStartListener, NoInputStartListener, SendNow, TurnInbox +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(): @@ -23,7 +24,7 @@ def test_drain_lifecycle_keeps_inbox_and_original_in_one_owner(): assert not state.turn_open() and not state.turn_started() with pytest.raises(KeyError): state.require_inbox() - state.to_turn(SendNow((InputId("before-open"),))) + state.to_turn(SendNow((InputId.mint(),))) inbox = state.open_inbox() assert state.open_inbox() is inbox and inbox.empty() assert state.turn_open() and not state.turn_started() @@ -33,7 +34,7 @@ def test_drain_lifecycle_keeps_inbox_and_original_in_one_owner(): "original input", TurnGoalPermission(InactiveGoalPermission())) state.capture_first_source(original) assert state.turn_started() and state.require_first_source() is original - item = SendNow((InputId("live"),)) + item = SendNow((InputId.mint(),)) state.to_turn(item) assert inbox.get_nowait() is item # A duplicate/stale cleanup cannot retire the next turn's inbox or source. @@ -76,10 +77,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_acp.py b/tests/test_acp.py index bf9ffdcd7a..9256272715 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -32,6 +32,7 @@ from agent_comms.comms import wire from agent_comms.context_reading import ContextUsage, UnmeasuredContext from agent_comms.errors import UnregisteredThreadError +from agent_comms.field_codec import TurnId from agent_comms.native_arguments import GivenArguments from agent_comms.pi_counts import PiCount from agent_comms.read_basis import NamedViewer @@ -433,7 +434,9 @@ 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..d532eb76cd 100644 --- a/tests/test_acp_extension.py +++ b/tests/test_acp_extension.py @@ -1,7 +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 @@ -43,7 +45,7 @@ from agent_comms.thread_identity import ClaimedIdentity from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.transcripts import NativeTranscriptCursor, TranscriptPage -from agent_comms.turn_lease import ActiveTurn, TurnState +from agent_comms.turn_lease import ActiveTurn, FinishedTurn, TurnState from agent_comms.turn_context import ContextManifest, NextContextTurn from agent_comms.threads import Thread @@ -59,18 +61,22 @@ def test_declared_family_roundtrip_and_strict_boundary(tmp_path): ) samples = ( ContextAnnotatedUpdate( - ContextManifest( - owner.incarnation, NextContextTurn(), (), "source-codec-control" - ), (), + ContextManifest(owner.incarnation, NextContextTurn(), (), "source-codec-control"), + (), ), RequestFailedUpdate(ACPFailure.from_detail(-32603, "The usage limit has been reached")), PromptCancelledUpdate(None), SelectedWriteAcceptedUpdate("operation", 1, "claim"), CursorAdvancedUpdate( CursorEnvelope( - LiveCursorScope(session_id="pilot", wire_root_id="a" * 32, - claimed_identity=owner, owner=ProcessIdentity(123, 1)), - 1, UnavailableCursorObservation() + LiveCursorScope( + session_id="pilot", + wire_root_id="a" * 32, + claimed_identity=owner, + owner=ProcessIdentity(123, 1), + ), + 1, + UnavailableCursorObservation(), ) ), QueueChangedUpdate(queue_scope, 1, AvailableQueueProjection((QueueItem("input", "text"),))), @@ -88,16 +94,22 @@ def test_declared_family_roundtrip_and_strict_boundary(tmp_path): GoalChangedUpdate(NoGoal(), IdleGoalExecution()), CompactionChangedUpdate(CompactionStart()), CompactionCommittedUpdate("summary", "first-kept"), - TranscriptSnapshotUpdate( - read.read(), read.identity - ), + TranscriptSnapshotUpdate(read.read(), read.identity), TranscriptDraftUpdate(read.identity, None), InputDeliveryChangedUpdate("input"), McpClientAcknowledgementUpdate( "turn", McpLiveAcknowledgement(1, "pi-mcp-client", "a" * 32, "running", "turn", ()) ), - TurnChangedUpdate(TurnState(ActiveTurn("turn", ProcessIdentity(123, 1), started_at=1.0))), - TurnChangedUpdate(TurnState(finished_turn_id="turn")), + TurnChangedUpdate( + TurnState( + ActiveTurn( + TurnId('9b473845f684444f80a26586ae298d2b'), + ProcessIdentity(123, 1), + started_at=1.0, + ) + ) + ), + TurnChangedUpdate(TurnState(finished=FinishedTurn(TurnId('9b473845f684444f80a26586ae298d2b')))), TextRouteUpdate(None), TranscriptChangedUpdate(NativeTranscriptCursor("session.jsonl", 42)), InputFailedUpdate("prompt", BackendDeliveryFailure("provider refused")), diff --git a/tests/test_acp_queue_contract.py b/tests/test_acp_queue_contract.py index 21f676346e..2d86c5d8d9 100644 --- a/tests/test_acp_queue_contract.py +++ b/tests/test_acp_queue_contract.py @@ -25,6 +25,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.comms import Comms, wire from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import TurnId from agent_comms.queued_input import QueuedInput, QueuedInputContext from agent_comms.runtime import present_session from agent_comms.thread_identity import ClaimedIdentity, RecordedThreadIncarnation @@ -44,14 +45,16 @@ 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", [TextContentBlock(type="text", text="valid task")], field_meta=encode_request(QueuePromptRequest("\ud800", True)), ) - exact = inbox.get_nowait().input_id.value + exact = inbox.get_nowait().input_id assert any( isinstance(f, InputDeliveryChangedUpdate) for f in decode_updates(response.field_meta) ) @@ -69,7 +72,9 @@ 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 +113,9 @@ 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..77c2500a60 100644 --- a/tests/test_active_turn.py +++ b/tests/test_active_turn.py @@ -6,6 +6,7 @@ ) from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire +from agent_comms.field_codec import TurnId from agent_comms.threads import Thread from agent_comms.turn_phase import ( CompactionPhase, @@ -20,7 +21,9 @@ 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 +58,11 @@ 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..b5e1abf4bc 100644 --- a/tests/test_activity_text.py +++ b/tests/test_activity_text.py @@ -13,7 +13,7 @@ from agent_comms.display_order import LastActivityThreadSort from agent_comms.ui_model.observation import ObserveSidebar, SidebarObserved from agent_comms.ui_model.sidebar import SidebarModel -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.pi_events import ToolExecutionStart from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.pi_payloads import CodelessProviderDiagnosticError @@ -61,7 +61,9 @@ 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 +83,9 @@ 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 +113,9 @@ 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_agent_backend.py b/tests/test_agent_backend.py index 436a51893c..fbab5e1dcf 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -12,6 +12,7 @@ import pytest import agent_comms.agent_backend as ab +from agent_comms.field_codec import InputId from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec from agent_comms.native_arguments import NativeArguments @@ -314,10 +315,9 @@ def _session_lines(path: Path) -> None: def test_inbox_records_say_which_inputs_they_carry(): + input_id = InputId.mint() request = ab.InputRequest( - input_id=ab.InputId("q1"), content=ab.InputContent(text="t"), when_busy=ab.SteerInputPolicy, + input_id=input_id, content=ab.InputContent(text="t"), when_busy=ab.SteerInputPolicy, ) - assert request.carried_inputs == (ab.InputId("q1"),) - assert ab.SendNow((ab.InputId("q1"),)).carried_inputs == () - with pytest.raises(ValueError): - ab.InputId("") + assert request.carried_inputs == (input_id,) + assert ab.SendNow((input_id,)).carried_inputs == () diff --git a/tests/test_atomic_turn_source_owner.py b/tests/test_atomic_turn_source_owner.py index 6d6cfdddcb..68ac988f8b 100644 --- a/tests/test_atomic_turn_source_owner.py +++ b/tests/test_atomic_turn_source_owner.py @@ -1,8 +1,7 @@ """Original begin/attachment ownership with real registry and callback resources.""" - -from contextlib import AsyncExitStack import os +from contextlib import AsyncExitStack import pytest @@ -10,17 +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.session_file import RegistrySessionLocation +from agent_comms.field_codec import TurnId 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 @@ -29,7 +29,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() @@ -67,11 +69,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 +87,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 +103,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_backend.py b/tests/test_backend.py index a14d79e4f2..74fe6d0e02 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -4,22 +4,22 @@ import json import sys from pathlib import Path -from uuid import uuid4 import pytest import agent_comms.agent_events as ae import agent_comms.backend as backend -from agent_comms.agent_backend import InputContent, InputId, InputRequest, SteerInputPolicy +from agent_comms.agent_backend import InputContent, InputRequest, SteerInputPolicy +from agent_comms.field_codec import InputId from agent_comms.pi_rpc import PiRpcChannel from agent_comms.turn_watchdog import ModelWait from pi_session_turn import one_turn_events -def steer(text: str, input_id: str | None = None) -> InputRequest: +def steer(text: str, input_id: InputId | None = None) -> InputRequest: """A queued steering input as Core hands it to the running turn.""" return InputRequest( - input_id=InputId(input_id or f"agent-comms-steer-{uuid4().hex}"), + input_id=InputId.mint() if input_id is None else input_id, content=InputContent(text=text), when_busy=SteerInputPolicy, ) diff --git a/tests/test_bus.py b/tests/test_bus.py index 0649423d1b..a7bcc314a2 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 ( @@ -131,6 +132,40 @@ 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: + 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 + ) + + +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"), ( @@ -519,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_files.py b/tests/test_bus_files.py index b5fd11e112..b758449e90 100644 --- a/tests/test_bus_files.py +++ b/tests/test_bus_files.py @@ -7,6 +7,7 @@ import pytest +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 @@ -51,11 +52,14 @@ def test_every_bus_file_is_a_declared_root_file(tmp_path: Path, source_repositor with NativeBoundary(comms.bus) as boundary: owner = boundary.registry.require("owner") claim = comms.bus.publisher._publish_acquisition_unlocked( - boundary.registry, owner, + boundary.registry, + owner, 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, ) @@ -88,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) @@ -105,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_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_carry_wake_claim_turn_ids.py b/tests/test_carry_wake_claim_turn_ids.py new file mode 100644 index 0000000000..0858181c80 --- /dev/null +++ b/tests/test_carry_wake_claim_turn_ids.py @@ -0,0 +1,86 @@ +"""The real carry validates old claim ids before changing stored version stamps.""" + +import json +import sqlite3 +from contextlib import closing +from pathlib import Path + +import pytest +from cutover.carry_wake_claim_turn_ids import WakeClaimTurnIds + +from agent_comms.bus import Bus +from agent_comms.envelope_claim_transitions import ClaimTransition, WakeClaim, WakeClaims +from agent_comms.field_codec import FieldCodec, MalformedRecord, MessageId, TurnId +from agent_comms.messages import BusRow, Claimed + + +def old_claim_bus(root: Path, *, turn_id: str = "a" * 32) -> Path: + root.mkdir(mode=0o700, parents=True) + path = Bus.path_in(root) + path.touch(mode=0o600) + claim = WakeClaims( + "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), + 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, + ), + ) + raw = FieldCodec.encode(claim, ClaimTransition) + raw["resource_scope"].update(version=1, turn_id=turn_id) + table = Claimed.table + with closing(sqlite3.connect(path)) as db, db: + db.execute("PRAGMA journal_mode=WAL") + BusRow.create(db) + table.create(db) + db.execute(table._sql( + f'INSERT INTO "{table.declared_name}" ({{seq}}, {{claim_transition}}) VALUES (?, ?)' + ), (1, FieldCodec.compact(raw))) + return path + + +def stored_claim(path: Path) -> dict: + table = Claimed.table + with closing(sqlite3.connect(path)) as db: + value, = db.execute(table._sql( + f'SELECT {{claim_transition}} FROM "{table.declared_name}" WHERE {{seq}}=1' + )).fetchone() + return json.loads(value) + + +def test_carry_validates_and_upgrades_main_and_archived_claims(tmp_path): + root = tmp_path / "root" + paths = (old_claim_bus(root), old_claim_bus(root / "history" / "old")) + step = WakeClaimTurnIds(root, root, tmp_path / "spill") + before = tuple(stored_claim(path) for path in paths) + assert step.carry(apply=False).changed == 2 + assert tuple(stored_claim(path) for path in paths) == before + result = step.carry(apply=True) + assert result.changed == 2 + assert set(result.rewritten) == {path.relative_to(root) for path in paths} + for path, original in zip(paths, before, strict=True): + expected = original.copy() + expected["resource_scope"] = {**original["resource_scope"], "version": WakeClaim.version} + current = stored_claim(path) + assert current == expected + decoded = FieldCodec.decode(ClaimTransition, current) + assert type(decoded.resource_scope.turn_id) is TurnId + assert decoded.resource_scope.version == WakeClaim.version + assert step.carry(apply=False).changed == 0 + + +@pytest.mark.parametrize("turn_id", ["not-a-turn-id", "A" * 32]) +def test_carry_refuses_bad_id_without_writing_and_names_the_old_value(tmp_path, turn_id): + root = tmp_path / "root" + path = old_claim_bus(root, turn_id=turn_id) + before = stored_claim(path) + step = WakeClaimTurnIds(root, root, tmp_path / "spill") + with pytest.raises(MalformedRecord) as failed: + step.carry(apply=True) + assert str(path) in "\n".join(failed.value.__notes__) + assert repr(turn_id) in "\n".join(failed.value.__notes__) + assert stored_claim(path) == before diff --git a/tests/test_channel_bus_ownership.py b/tests/test_channel_bus_ownership.py index 1f0129fe2e..20da281e9d 100644 --- a/tests/test_channel_bus_ownership.py +++ b/tests/test_channel_bus_ownership.py @@ -1,15 +1,16 @@ """Channel membership and notifications have one canonical bus authority.""" -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 @@ -82,7 +83,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_channels.py b/tests/test_channels.py index cf3568d764..bb8c1164fb 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -17,7 +17,7 @@ from agent_comms.bus import Bus from agent_comms.comms import wire from conftest import installed_comms -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.display_order import ( ChannelSort, CreatedThreadSort, LastActivityThreadSort, LastMessageThreadSort, ThreadSort, ) @@ -505,7 +505,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..6774ba78d7 100644 --- a/tests/test_claim_admission_verifier.py +++ b/tests/test_claim_admission_verifier.py @@ -19,6 +19,7 @@ from agent_comms.attempt_start import AttemptStart from agent_comms.attempt_states import ModelRunningAttempt, PromptAcceptedAttempt, SettlingAttempt from agent_comms.child_process import ProcessIdentity +from agent_comms.field_codec import TurnId from agent_comms.selected_claim import ( publish_selected_resource_claim, verify_selected_wake, @@ -74,7 +75,9 @@ 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" @@ -186,7 +189,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_claim_bus_read.py b/tests/test_claim_bus_read.py index df2c5bf306..473653fc98 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -19,6 +19,7 @@ import pytest +from agent_comms.field_codec import TurnId from agent_comms.reason_codes import NoReasonCode from agent_comms.attempt_start import AttemptStart from agent_comms.bus import BusReader @@ -40,7 +41,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, @@ -64,12 +65,18 @@ def record_context(comms: Comms, name: str, generation: int) -> None: 7, 2, ) - comms.contexts.record_after(UNREAD_CONTEXT, ContextManifest( - owner.incarnation, - RecordedContextTurn(TurnId(f"turn-{generation}"), TurnIdentity(owner.incarnation, generation)), - (segment,), - "pi.estimateTokens", - )) + comms.contexts.record_after( + UNREAD_CONTEXT, + ContextManifest( + owner.incarnation, + RecordedContextTurn( + RegisteredTurn(TurnId(f"{generation:032x}")), + TurnIdentity(owner.incarnation, generation), + ), + (segment,), + "pi.estimateTokens", + ), + ) @pytest.fixture @@ -98,7 +105,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..4c8a0b2c54 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ import pytest +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 @@ -126,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() @@ -374,8 +376,13 @@ def test_restart_catalog_uses_original_owner_and_idle_requirements(self, tmp_pat assert not RestartCliCommand.thread_bindings(comms, owner, StoppedThreadStatus()) 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)), + comms.registry.store.path, + busy, + 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 08b4b97f70..990ccbcddd 100644 --- a/tests/test_context_manifest_deltas.py +++ b/tests/test_context_manifest_deltas.py @@ -9,9 +9,9 @@ from agent_comms.context_store import ContextRequest import json -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId 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, @@ -33,11 +33,19 @@ def segment(kind, text, sources=provenance, contributors=()): for i in range(count)) 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)), - (segment(SystemLayerSegment, 's' * 27000), - segment(ToolCatalogSegment, 't' * 64000), transcript), 'counter', - ObservedDispatchRequest(f'request-{turn}')) + return ContextManifest( + owner.incarnation, + RecordedContextTurn( + RegisteredTurn(TurnId(f"{turn:032x}")), TurnIdentity(owner.incarnation, turn) + ), + ( + segment(SystemLayerSegment, 's' * 27000), + segment(ToolCatalogSegment, 't' * 64000), + transcript, + ), + 'counter', + ObservedDispatchRequest(f'request-{turn}'), + ) def test_context_deltas_restore_original_text_and_incremental_membership(tmp_path): @@ -46,8 +54,17 @@ 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')), - request_id=ObservedDispatchRequest('request-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) page = comms.contexts.page(owner.name, comms.registry) diff --git a/tests/test_context_source_inspection.py b/tests/test_context_source_inspection.py index e74907caf3..f6bcfa021c 100644 --- a/tests/test_context_source_inspection.py +++ b/tests/test_context_source_inspection.py @@ -9,12 +9,12 @@ import pytest from agent_comms.session_file import NoSessionFile -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.native_session_reopen import NativeSessionIdentity 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,9 @@ def identity(tmp_path): def recorded(segments): owner = RecordedThreadIncarnation("original-owner", 17002.0) - turn = RecordedContextTurn(TurnId("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.py b/tests/test_coordination.py index a9a6934034..b175054dfb 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 ( @@ -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) @@ -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")) diff --git a/tests/test_coordination_nominal.py b/tests/test_coordination_nominal.py index ccae48eadd..4cf02513c5 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 @@ -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, @@ -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_coordination_response.py b/tests/test_coordination_response.py index 537c420142..eac3d83a99 100644 --- a/tests/test_coordination_response.py +++ b/tests/test_coordination_response.py @@ -4,6 +4,7 @@ from bus_record_fixture import prepare_native_comms +from agent_comms.field_codec import TurnId from agent_comms.lifecycle_capability import Active, Complete from conftest import publish_initial, responder @@ -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_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_current_input_origin.py b/tests/test_current_input_origin.py index 988988880a..6609294231 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 +from agent_comms.field_codec import FieldCodec, InputId, 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 @@ -84,7 +84,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() @@ -99,15 +99,23 @@ async def test_original_human_followup_survives_forwarding_reservation_and_nativ assert len(stored_inputs(inputs.root).rows) == 1 and comms.bus.bus.revision() == before from agent_comms.turn_context import UserFollowupSegment queued = agent.inputs.state('beta').require_inbox().get_nowait() - assert queued.input_id.value == command.input_id + assert queued.input_id == command.input_id assert queued.content.text == 'User follow-up:\n' + command.user_text contribution, = queued.content.contributions assert contribution.kind is UserFollowupSegment 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 @@ -125,17 +133,38 @@ 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: - 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 == {} @@ -158,7 +187,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 @@ -191,15 +221,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 @@ -224,7 +264,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 @@ -264,8 +304,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 @@ -273,7 +314,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_declarations.py b/tests/test_declarations.py index 49928ba3b2..3d45437ba2 100644 --- a/tests/test_declarations.py +++ b/tests/test_declarations.py @@ -16,7 +16,7 @@ from agent_comms.comms import wire from agent_comms.context_reading import ContextUsage, UnmeasuredContext from agent_comms.errors import RelationViolationError, UnregisteredThreadError -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.goals import Goal from agent_comms.launch_runtime import CallingThreadUnavailable from agent_comms.messages import HandoffKind, InfoKind, Message @@ -207,7 +207,9 @@ 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 +247,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 +273,17 @@ 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 +300,9 @@ 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 +327,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 +356,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 +435,14 @@ 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..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 +from agent_comms.field_codec import FieldCodec, MalformedRecord, MessageId, TurnId from agent_comms.messages import ClaimMessage, ClaimNoticeKind, Message G1 = "a" * 32 @@ -42,7 +42,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] @@ -69,10 +69,17 @@ def test_same_name_can_take_a_previous_birth_only_with_current_registry_evidence def test_selected_wake_binding_survives_claim_projection(tmp_path): _, resource, _ = resources(tmp_path) claim_scope = WakeClaim( - 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, + wire_root_id="c" * 32, + source_seq=7, + source_message_id=MessageId.mint("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=TurnId('c8196fb128b89f486c374c42dba8df8e'), + participant_generation=1, attempt_ordinal=1, ) claimed = WakeClaims("owner", "epoch-1", 8, "msg-8", claims=(resource,), diff --git a/tests/test_field_codec.py b/tests/test_field_codec.py index 1a400314d6..13dc2fd4ee 100644 --- a/tests/test_field_codec.py +++ b/tests/test_field_codec.py @@ -13,10 +13,14 @@ from agent_comms.field_codec import ( DeclaredWireType, FieldCodec, + Id128, + Id128Text, + InputId, InlineRecord, JsonShapeFamily, JsonShapeMember, MalformedRecord, + MessageId, NullMember, PathText, RecordMember, @@ -25,6 +29,7 @@ SubclassUnion, TextRepresentation, TimestampText, + TurnId, WireValue, FlagDirective, WireAbsentNone, @@ -936,11 +941,10 @@ 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.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 @@ -954,6 +958,61 @@ 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) == spelling + assert 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 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(MalformedRecord): + FieldCodec.encode(_NominalIdentityRow(value, value, value, InputId(value))) + with pytest.raises(MalformedRecord): + FieldCodec.encode("not-an-id", Annotated[str, Id128Text]) + + # 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 diff --git a/tests/test_goal_answers.py b/tests/test_goal_answers.py index 45d74eec05..bba3e494dd 100644 --- a/tests/test_goal_answers.py +++ b/tests/test_goal_answers.py @@ -2,18 +2,18 @@ import os from contextlib import ExitStack, contextmanager -from uuid import uuid4 import pytest from agent_comms import agent_events as events from agent_comms.child_process import ProcessIdentity +from agent_comms.field_codec import TurnId from agent_comms.goal_actions import ClearGoalAction, SetGoalAction from agent_comms.goal_attempts import ProviderUsageTotal 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(TurnId.mint()) owner = comms.agents.begin_turn("worker", turn.value) lease = owner.thread.require_turn_lease() try: diff --git a/tests/test_goal_execution.py b/tests/test_goal_execution.py index 9ec58cba9e..92b046fe70 100644 --- a/tests/test_goal_execution.py +++ b/tests/test_goal_execution.py @@ -27,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 @@ -66,6 +67,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_goal_failure_observation.py b/tests/test_goal_failure_observation.py index ed673e01bd..a9a1371247 100644 --- a/tests/test_goal_failure_observation.py +++ b/tests/test_goal_failure_observation.py @@ -9,6 +9,7 @@ import pytest from agent_comms.child_process import ProcessIdentity +from agent_comms.field_codec import TurnId from agent_comms.turn_failure import BackendFailed, FinalStopMissing from agent_comms.goal_attempts import AttemptRecord, GoalAttemptStore from agent_comms.errors import UnresolvedAttemptError @@ -29,13 +30,13 @@ def bound(tmp_path, *, thread_registration): owner = Thread( 'owner', frozenset(), '/private-worktree', process_identity=ProcessIdentity.capture(os.getpid()), created_at=10.0, - goal=goal, turn_generation=7, turn_record=FinishedTurn('a' * 32), + goal=goal, turn_generation=7, turn_record=FinishedTurn(TurnId('a' * 32)), ) store.create_goal(goal.id) 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..9753beac33 100644 --- a/tests/test_goal_input_review.py +++ b/tests/test_goal_input_review.py @@ -10,7 +10,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire from conftest import installed_comms -from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.field_codec import FieldCodec, MalformedRecord, TurnId from agent_comms.goal_presentation import GoalExecution from agent_comms.goal_actions import ( ActiveGoalAction, @@ -36,7 +36,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 +141,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..51cddf2879 100644 --- a/tests/test_goal_reply_lock_scope.py +++ b/tests/test_goal_reply_lock_scope.py @@ -13,6 +13,7 @@ from agent_comms.bus_tables import ReceivedSource from agent_comms.child_process import ProcessIdentity from agent_comms.coordination_errors import StaleFence +from agent_comms.field_codec import TurnId from agent_comms.goal_actions import ( EditGoalAction, GoalActionContext, @@ -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): @@ -412,7 +413,7 @@ def test_new_dependency_turn_during_silence_read_keeps_wait(reviewing): wait = reviewing.goals.goal_wait("owner") fence = reviewing.agents.finish_turn(reviewing.registry.require("child").require_turn_lease()) reviewing.bus.bus = AfterReadBus( - reviewing.bus.bus.path, partial(reviewing.agents.begin_turn, "child", "new-child-turn"), + reviewing.bus.bus.path, partial(reviewing.agents.begin_turn, "child", TurnId.mint()), ) assert reviewing.goals.release_waits_after_terminal_turn(fence) == () assert reviewing.bus.bus.changed @@ -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..0d99a7886e 100644 --- a/tests/test_goal_resume.py +++ b/tests/test_goal_resume.py @@ -8,7 +8,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire from agent_comms.lifecycle_capability import Active -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.goal_actions import ( ActiveGoalAction, BlockedGoalAction, @@ -61,7 +61,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 +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", "same-assistant-turn") + 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_goal_standby.py b/tests/test_goal_standby.py index 6f18b9867d..b27c1b5e8f 100644 --- a/tests/test_goal_standby.py +++ b/tests/test_goal_standby.py @@ -6,6 +6,7 @@ import pytest from agent_comms.child_process import ProcessIdentity +from agent_comms.field_codec import TurnId from agent_comms.goal_actions import ( EditGoalAction, GoalPrecondition, @@ -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..fb2f62d503 100644 --- a/tests/test_goal_standby_liveness.py +++ b/tests/test_goal_standby_liveness.py @@ -11,6 +11,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire +from agent_comms.field_codec import TurnId from agent_comms.goal_actions import ( EditGoalAction, GoalPrecondition, @@ -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..3ee4d2f2c5 100644 --- a/tests/test_goal_wait_certified_reply.py +++ b/tests/test_goal_wait_certified_reply.py @@ -6,6 +6,7 @@ from agent_comms.child_process import ProcessIdentity +from agent_comms.field_codec import TurnId from agent_comms.goal_actions import GoalPrecondition, SetGoalAction, StandbyGoalAction from agent_comms.goal_presentation import GoalWaitTarget from goal_owner_fixture import canonical_goal_wire @@ -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..c262b36cb2 100644 --- a/tests/test_historical_notifications.py +++ b/tests/test_historical_notifications.py @@ -19,6 +19,7 @@ ) from agent_comms.comms import wire from agent_comms.coordination_errors import DMScopeChanged, SchemaVersionError +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 @@ -191,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" @@ -218,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, ) @@ -229,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 @@ -274,7 +276,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..f8a48bd4c9 100644 --- a/tests/test_history_view_declaration.py +++ b/tests/test_history_view_declaration.py @@ -1,5 +1,6 @@ """A new history declaration crosses real current and retained-source boundaries.""" +from agent_comms.field_codec import TurnId from agent_comms.historical_views import HistoryView from agent_comms.message_page import PageBounds from agent_comms.read_basis import ChannelDisplayScope, NoDisplaySelection @@ -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 0570f687a7..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 +from agent_comms.field_codec import FieldCodec, MessageId, TurnId from agent_comms.goal_actions import ( CreateProblemGoalAction, ReissuedOrderGoalAction, @@ -107,8 +107,13 @@ 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", - native_id=uuid4().hex, text="Do not replay this input") + 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" StoreBackup.copy_tree(comms.root, copied) @@ -342,7 +347,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: @@ -378,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"})) @@ -401,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_delivery_history.py b/tests/test_input_delivery_history.py index 8045f7ebff..cb95198d1e 100644 --- a/tests/test_input_delivery_history.py +++ b/tests/test_input_delivery_history.py @@ -4,6 +4,7 @@ from agent_comms.comms import wire +from agent_comms.field_codec import TurnId from agent_comms.input_attempt import ( OwnerQueueInputDeliveryScope, UnobservedInputDeliveryScope, UnresolvedInput, ) @@ -17,7 +18,13 @@ 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 +70,13 @@ 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..a556eba221 100644 --- a/tests/test_input_disposition.py +++ b/tests/test_input_disposition.py @@ -7,6 +7,7 @@ import pytest from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import MessageId, TurnId from agent_comms.input_attempt import ( BoundUnknownInput, ClearedNativeInput, @@ -56,11 +57,37 @@ 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 +107,23 @@ 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") == () @@ -140,14 +178,25 @@ 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(BoundUnknownInput, turn_id="turn-1", native_id=NATIVE, - sent_digest=TextDigest.of(SENT)) +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 f70ad22cb1..716bfb3531 100644 --- a/tests/test_input_documents.py +++ b/tests/test_input_documents.py @@ -8,7 +8,7 @@ import pytest -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.input_attempt import ( GoalInputDecision, BoundUnknownInput, @@ -21,19 +21,27 @@ ) 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 +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") assert store.record("prior", **args) assert store.record("unknown", **args) - assert store.bind("unknown", claim_scope=1, turn_id="old", native_id="a" * 32, - text="Original sent input") + 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") second = ReservedInput("second", None, "worker", 1, "worker", "Second input") @@ -71,12 +79,24 @@ 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(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", TurnId("scheduled"), 1, "Replacement", ownership=ownership) + store.reserve_turn( + "worker", + RegisteredTurn(TurnId('6aef76c6cb359f6ffaf9c6e9881c902b')), + 1, + "Replacement", + ownership=ownership, + ) assert stored_inputs(store.root) == scheduled @@ -88,24 +108,58 @@ def test_saved_discriminator_and_optional_notices_roundtrip_without_replay(tmp_p store = InputDispositions(tmp_path) from agent_comms.text_digest import TextDigest - 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, - sent_digest=TextDigest.of("Exact sent text"), notice_dismissed=True, - goal_reviews={"goal": GoalInputDecision(goal_revision=7, turn_id="review")}, - )) + 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, + sent_digest=TextDigest.of("Exact sent text"), + notice_dismissed=True, + goal_reviews={ + "goal": GoalInputDecision( + goal_revision=7, turn_id=TurnId('c97ace4c8fef2cee8fa0f3c9f52aab18') + ) + }, + ) + ) _insert_raw(store, "acp:old", saved) row = store.lookup("acp:old") 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, 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 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=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="turn", native_id="a" * 32, text="Exact sent text") + assert not store.started( + row.key, + turn_id=TURN, + native_id="a" * 32, + text="Exact sent text", + ) row.require(Unresolved, ValueError("review required")) # prior snapshot is immutable and unchanged @@ -117,25 +171,43 @@ 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", TURN) 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, - text="Actual grouped native user") - assert store.started(key, turn_id="turn", native_id="a" * 32, - text="Actual grouped native user") + assert store.bind( + key, + claim_scope=claim_scope, + turn_id=TURN, + native_id="a" * 32, + text="Actual grouped native user", + ) + assert store.started( + key, + turn_id=TURN, + 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, - text="Actual corrected native user") - assert store.started("acp:correction", turn_id="turn", native_id="b" * 32, - text="Actual corrected native user") - document = store.in_turn("turn") + assert store.bind( + "acp:correction", + claim_scope=claim_scope, + turn_id=TURN, + native_id="b" * 32, + text="Actual corrected native user", + ) + assert store.started( + "acp:correction", + turn_id=TURN, + native_id="b" * 32, + text="Actual corrected native user", + ) + document = store.in_turn(TURN) assert document == stored_inputs(store.root) snapshot = registry.snapshot() group = document.started_for_native( @@ -150,7 +222,10 @@ 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, @@ -189,7 +264,13 @@ 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" @@ -208,7 +289,13 @@ 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): @@ -222,10 +309,14 @@ 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, - text="acp:started") + 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) selected = (*keys[:-1], "acp:missing") @@ -249,10 +340,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): @@ -272,7 +362,13 @@ 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=TURN, + native_id=native_id, + text="sent", + ) assert stored_inputs(store.root) == before @@ -280,7 +376,12 @@ 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=TURN, + 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 +402,19 @@ 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=TURN, + native_id="a" * 32, + text="wrapped input", + ) + assert store.started( + key, + turn_id=TURN, + 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,9 +431,17 @@ 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.in_turn("turn").rows.keys() == {"acp:a"} + 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.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_install.py b/tests/test_install.py index 80635a1848..879d841f36 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -9,6 +9,7 @@ from pathlib import Path import pytest +from agent_comms.field_codec import TurnId from agent_comms.threads import ParkedThreadWorkState from agent_comms.active_route import ActiveRoute, read_active_route, route_new_bus @@ -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..ad840c1590 100644 --- a/tests/test_interrupted_delivery_recovery.py +++ b/tests/test_interrupted_delivery_recovery.py @@ -11,6 +11,7 @@ from agent_comms.cohort_foreground import _accept_visible_deliveries from agent_comms.coordination_cohort import pending_sealed_assignments from agent_comms.coordinator import Coordination +from agent_comms.field_codec import TurnId from agent_comms.input_attempt import NotSentInput from agent_comms.queued_input import DeliveredInput from test_acp_private_nk_delivery import _session @@ -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..0011b5909b 100644 --- a/tests/test_l0_goal_input_closure.py +++ b/tests/test_l0_goal_input_closure.py @@ -9,7 +9,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.goal_actions import ( GoalPrecondition, OwnerInvocable, @@ -112,7 +112,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) @@ -144,9 +144,18 @@ 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=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) 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_live_drain_failure.py b/tests/test_live_drain_failure.py index ec7134ccb9..00b5b5add6 100644 --- a/tests/test_live_drain_failure.py +++ b/tests/test_live_drain_failure.py @@ -17,6 +17,7 @@ from agent_comms.comms import wire from agent_comms.coordinator import Coordination from agent_comms.drain_failures import drain_failures +from agent_comms.field_codec import TurnId from test_acp_private_nk_delivery import _session from test_coordinated_runtime import tmp_path as private_root_fixture @@ -85,7 +86,9 @@ 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 +112,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..193942b6c6 100644 --- a/tests/test_maintenance_barrier.py +++ b/tests/test_maintenance_barrier.py @@ -14,7 +14,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.maintenance_barrier import ( HOLDER_NONCE, DrainingPhase, @@ -113,7 +113,9 @@ 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 +129,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 +152,9 @@ 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 +269,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_mcp_acceptance.py b/tests/test_mcp_acceptance.py index cbb3bfb170..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 @@ -448,13 +449,20 @@ async def publish_update(self, session_id, update): # cannot attest that the live view received it. receipt_seen.set() if isinstance(fact, TurnChangedUpdate): + thread = owner._comms.registry.require( + owner.sessions.bindings[session_id] + ) + observed = fact.state.active.observed_turn( + fact.state.finished, thread.turn_identity, + thread.claimed_generation, + ) if fact.state.active.phase.busy: - if attachment_turn is None: - attachment_turn = fact.state + if attachment_turn == NoObservedTurn(): + attachment_turn = observed else: - assert fact.state.matches(attachment_turn.active.id) - elif attachment_turn is not None: - assert fact.state.matches(attachment_turn.active.id) + assert observed == attachment_turn + elif attachment_turn != NoObservedTurn(): + assert observed == attachment_turn attachment_settled.set() return True @@ -562,24 +570,36 @@ 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")): if isinstance(fact, TurnChangedUpdate): + thread = owner._comms.registry.require( + owner.sessions.bindings[session_id] + ) + observed = fact.state.active.observed_turn( + fact.state.finished, thread.turn_identity, + thread.claimed_generation, + ) if fact.state.active.phase.busy: - if active is None: - active = fact.state + if active == NoObservedTurn(): + active = observed else: - assert fact.state.matches(active.active.id) - elif active is not None: - assert fact.state.matches(active.active.id) + assert observed == active + 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.matches(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_message_notifications.py b/tests/test_message_notifications.py index b461d2ab35..164cf453bb 100644 --- a/tests/test_message_notifications.py +++ b/tests/test_message_notifications.py @@ -2,6 +2,7 @@ from dataclasses import replace from test_coordinated_runtime import _root, tmp_path # noqa: F401 from conftest import installed_comms +from agent_comms.field_codec import TurnId from agent_comms.threads import Thread @@ -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,9 @@ 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_channel_reply_roundtrip.py b/tests/test_native_channel_reply_roundtrip.py index 5802e4b014..253d968bef 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.turn_lease import NoTurnRecord from agent_comms.threads import Thread from bus_history import read_history from bus_record_fixture import prepare_native_comms @@ -388,7 +389,11 @@ def settled(): 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.finished_turn_id for state in states) + return any( + not bool(state.active.items_with(Executing, state.active)) + and state.finished != NoTurnRecord() + for state in states + ) await until(selected_turn_finished, "Selected turn end published to the open session") await until( diff --git a/tests/test_native_context_reference.py b/tests/test_native_context_reference.py index 5d1ded1779..72a336dfbe 100644 --- a/tests/test_native_context_reference.py +++ b/tests/test_native_context_reference.py @@ -9,7 +9,6 @@ import pytest from agent_comms.acp_extension import EmptyCursorObservation, VerifiedCursorObservation -from agent_comms.agent_backend import InputId from agent_comms.assignment_states import TriagePendingAssignment from agent_comms.comms import wire from agent_comms.coordination_errors import IdentityConflict @@ -19,6 +18,7 @@ RecordedCursor, UnstartedCursor, ) +from agent_comms.field_codec import FieldCodec, InputId, MessageId 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, ) @@ -120,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, @@ -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_retry_exhaustion_installed.py b/tests/test_native_retry_exhaustion_installed.py index 3d7ba6a5ea..b887cec8dc 100644 --- a/tests/test_native_retry_exhaustion_installed.py +++ b/tests/test_native_retry_exhaustion_installed.py @@ -1,5 +1,5 @@ """Installed native 429 exhaustion reaches one completed user-visible failure.""" -from agent_comms.controller import SessionUpdateSink + import asyncio import json from pathlib import Path @@ -8,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" @@ -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_native_unknown_recovery.py b/tests/test_native_unknown_recovery.py index 8a908934a5..30e3664f91 100644 --- a/tests/test_native_unknown_recovery.py +++ b/tests/test_native_unknown_recovery.py @@ -3,6 +3,7 @@ from __future__ import annotations +from agent_comms.field_codec import InputId, TurnId from agent_comms.lifecycle_capability import Failed import asyncio import multiprocessing @@ -134,7 +135,9 @@ 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 @@ -230,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, 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), ())) == {} diff --git a/tests/test_optional_awareness_projection.py b/tests/test_optional_awareness_projection.py index d7f515c869..a6cf4c5c43 100644 --- a/tests/test_optional_awareness_projection.py +++ b/tests/test_optional_awareness_projection.py @@ -3,7 +3,9 @@ from __future__ import annotations + from bus_record_fixture import prepare_native_comms +from agent_comms.field_codec import TurnId from agent_comms.lifecycle_capability import Executing from conftest import publish_initial, without_mutation_guards @@ -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_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 add10c3f7b..8041dd50a7 100644 --- a/tests/test_owned_turn_goal_replacement.py +++ b/tests/test_owned_turn_goal_replacement.py @@ -12,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 Id128Text +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 @@ -24,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): @@ -67,7 +70,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 +89,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)) @@ -132,7 +135,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,9 +151,10 @@ 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 = 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, @@ -201,7 +206,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_preflight_feedback.py b/tests/test_preflight_feedback.py index 8091e32927..259be7e576 100644 --- a/tests/test_preflight_feedback.py +++ b/tests/test_preflight_feedback.py @@ -1,5 +1,6 @@ """Failure diagnostics and durable delivery projection remain independent authorities.""" import pytest +from agent_comms.field_codec import TurnId from agent_comms.input_attempt import ReservedInput, NotSentInput, MissingInput from agent_comms.input_disposition import InputDocument from agent_comms.turn_failure import InputIdUnavailable, NativePreflightExit, NativePreflightTimeout, PromptSendFailed @@ -42,8 +43,13 @@ 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', - native_id='a' * 32, text='original input') + 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) assert isinstance(before, BoundUnknownInput) diff --git a/tests/test_private_idle_drain.py b/tests/test_private_idle_drain.py index da3066ee38..f8c254ae25 100644 --- a/tests/test_private_idle_drain.py +++ b/tests/test_private_idle_drain.py @@ -1,8 +1,5 @@ """Idle observations avoid repeated work without becoming delivery authority.""" -from bus_history import read_history -from conftest import publish_initial -from agent_comms.messages import NoticeRole import asyncio import os import threading @@ -11,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 @@ -84,7 +84,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_project_resume.py b/tests/test_project_resume.py index 5353102fd4..b55a5601c5 100644 --- a/tests/test_project_resume.py +++ b/tests/test_project_resume.py @@ -12,7 +12,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire from agent_comms.coordinator import Coordination -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.messages import Message, InfoKind from agent_comms.turn_phase import CancellingPhase from native_backend_fixture import native_backend_fixture @@ -36,7 +36,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 +46,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 +56,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..4008265147 100644 --- a/tests/test_read_ledger.py +++ b/tests/test_read_ledger.py @@ -10,7 +10,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire from agent_comms.coordination_errors import DMPaintedBoundChanged, StaleRevision -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.message_page import MessagePageRequest, PageBounds from agent_comms.page_traversal import PageCursor from agent_comms.presentation import ReadThrough @@ -190,7 +190,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..b21038add8 100644 --- a/tests/test_registration_authority.py +++ b/tests/test_registration_authority.py @@ -8,6 +8,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import TurnId from agent_comms.goals import Goal from agent_comms.thread_identity import UserThreadRole from agent_comms.thread_text import GivenThreadText @@ -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,16 @@ 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 +62,9 @@ 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 +77,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 +89,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..d5124e2b8a 100644 --- a/tests/test_registration_inheritance.py +++ b/tests/test_registration_inheritance.py @@ -3,6 +3,7 @@ from typing import Annotated from dataclasses import dataclass, replace +from agent_comms.field_codec import TurnId from agent_comms.session_file import RegistrySessionLocation from agent_comms import field_codec from agent_comms.comms import wire @@ -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 65d3e8cb7a..1bad57a24e 100644 --- a/tests/test_registration_process_identity.py +++ b/tests/test_registration_process_identity.py @@ -8,6 +8,7 @@ from agent_comms.child_process import NoProcess, ProcessIdentity, SpawnedProcess from agent_comms.comms import wire from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import TurnId from agent_comms.registry_document import RegistryDocument from agent_comms.runtime_requests import ProjectRuntimeRequest from agent_comms.thread_execution import ExternalThreadExecution @@ -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,9 @@ 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 +164,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..c87cbbc4e2 100644 --- a/tests/test_registration_transitions.py +++ b/tests/test_registration_transitions.py @@ -5,6 +5,7 @@ import pytest +from agent_comms.field_codec import TurnId from agent_comms.session_file import RegistrySessionLocation from agent_comms.child_process import ProcessIdentity from agent_comms.errors import RelationViolationError @@ -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_reservation_rules.py b/tests/test_reservation_rules.py index 80b26cd852..bd9c401eb9 100644 --- a/tests/test_reservation_rules.py +++ b/tests/test_reservation_rules.py @@ -4,7 +4,7 @@ import pytest -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.input_disposition import InputDispositions from agent_comms.native_session_files import NativeSessionFiles from agent_comms.native_session_reopen import NativeSessionIdentity @@ -70,9 +70,20 @@ 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, - text="first\n\nsecond") + 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 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_roster_restoration.py b/tests/test_roster_restoration.py index c68ed9b0b6..e4a44c0ff6 100644 --- a/tests/test_roster_restoration.py +++ b/tests/test_roster_restoration.py @@ -11,6 +11,7 @@ import pytest +from agent_comms.field_codec import TurnId from agent_comms.registry_document import ThreadRows from agent_comms.session_file import RegistrySessionLocation @@ -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..9dc194992c 100644 --- a/tests/test_selected_tool_broker.py +++ b/tests/test_selected_tool_broker.py @@ -18,6 +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 InputId, MessageId, TurnId from agent_comms.native_tool_call import ( DuplicateToolCallIdentity, OwnerToolConnection, @@ -101,19 +102,19 @@ 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, execution_id="execution", operation_id="d" * 32, owner_claimed_generation=1, - turn_id="turn", + turn_id=TurnId('9b473845f684444f80a26586ae298d2b'), participant_generation=1, 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 ) diff --git a/tests/test_stack_native_compaction.py b/tests/test_stack_native_compaction.py index 32bc1aeb32..7607f2d696 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 @@ -30,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") @@ -223,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(), @@ -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..8b53980280 100644 --- a/tests/test_stack_persistent_compaction.py +++ b/tests/test_stack_persistent_compaction.py @@ -1,6 +1,4 @@ """A manual /compact runs Pi's own engine in the retained child, which keeps serving.""" -from agent_comms.controller import SessionUpdateSink - import asyncio import json import os @@ -24,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 @@ -224,12 +224,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 +307,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_task_decisions.py b/tests/test_task_decisions.py index c50aa0c903..114dd9759e 100644 --- a/tests/test_task_decisions.py +++ b/tests/test_task_decisions.py @@ -15,11 +15,11 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId 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 @@ -46,7 +46,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 @@ -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) @@ -323,7 +323,9 @@ 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..8fd4ba4656 100644 --- a/tests/test_thread_identity.py +++ b/tests/test_thread_identity.py @@ -13,7 +13,7 @@ from agent_comms.comms import wire from conftest import installed_comms from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.read_basis import Conversation from agent_comms.turn_lease import NoTurnLease from agent_comms.read_ledger import ReadDocument @@ -70,7 +70,9 @@ 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 +89,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 +104,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 +134,13 @@ 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 +149,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 +166,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..b87694bd81 100644 --- a/tests/test_tool_output.py +++ b/tests/test_tool_output.py @@ -15,7 +15,7 @@ from agent_comms.read_basis import ExplicitWatchAcknowledgment from agent_comms.tools import invoke_tool from agent_comms.tool_caller import ThreadCaller, NoThreadCaller -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.text_digest import TextDigest from stored_inputs import insert_inputs, stored_inputs from agent_comms.goal_waits import RecordedGoalWait @@ -25,11 +25,19 @@ def seed_unknown(comms, count, text): dispositions = InputDispositions(comms.root) rows = { - f"bus:{index}": FieldCodec.encode(BoundUnknownInput( - key=f"bus:{index}", sequence=index, owner="b", claim_generation=index, - target="b", source_text=f"{index}: {text}", turn_id="private-turn", - native_id="f" * 32, sent_digest=TextDigest.of("private-native-prompt"), - )) + 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=TurnId('9f73871819f6ae95a1bbcac63e1880f4'), + native_id="f" * 32, + sent_digest=TextDigest.of("private-native-prompt"), + ) + ) for index in range(1, count + 1) } insert_inputs(comms.root, rows) @@ -168,7 +176,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 +197,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_tracked_native_retry.py b/tests/test_tracked_native_retry.py index 8bc7629bd7..3e6a13cd80 100644 --- a/tests/test_tracked_native_retry.py +++ b/tests/test_tracked_native_retry.py @@ -1,18 +1,26 @@ 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 @@ -87,7 +95,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) diff --git a/tests/test_turn_context_wire.py b/tests/test_turn_context_wire.py index fe2e26db3a..ae084d8eda 100644 --- a/tests/test_turn_context_wire.py +++ b/tests/test_turn_context_wire.py @@ -2,11 +2,11 @@ import hashlib import json -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId 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,15 @@ 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(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 a76e0cb9dc..916ee8bc92 100644 --- a/tests/test_turn_declaration.py +++ b/tests/test_turn_declaration.py @@ -4,13 +4,23 @@ 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.turn_lease import ActiveTurn, NoTurn, TurnDeclaration +from agent_comms.field_codec import FieldCodec, TurnId +from agent_comms.thread_identity import ( + NoObservedTurn, + RecordedThreadIncarnation, + RegisteredTurn, + TurnFence, + TurnIdentity, +) +from agent_comms.turn_lease import ActiveTurn, FinishedTurn, NoTurn, NoTurnRecord, TurnDeclaration 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,9 +32,16 @@ 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(TurnId('9b473845f684444f80a26586ae298d2b'))) assert not idle.started_by(10000) - assert idle.observed_id('finished') == 'finished' + identity = TurnIdentity(RecordedThreadIncarnation("owner", 1.0), 1) + turn_id = TurnId.mint() + 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) @@ -33,6 +50,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(TurnId('9b473845f684444f80a26586ae298d2b'))) diff --git a/tests/test_turn_goal_account.py b/tests/test_turn_goal_account.py index 954164b9ad..76d0cbe882 100644 --- a/tests/test_turn_goal_account.py +++ b/tests/test_turn_goal_account.py @@ -5,12 +5,13 @@ from agent_comms.goal_attempts import GoalAttemptStore +from agent_comms.field_codec import TurnId from agent_comms.goal_generation import ( CompletedGeneration, ReadyGeneration, ) 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 +22,7 @@ class SpecializedPausedGoal(PausedGoal): (tmp_path / "attempts").mkdir(mode=0o700) store = GoalAttemptStore.initialize(tmp_path / "attempts") - turn = TurnId("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_inbox.py b/tests/test_turn_inbox.py index 006a22bbe5..394c5e8be8 100644 --- a/tests/test_turn_inbox.py +++ b/tests/test_turn_inbox.py @@ -2,14 +2,15 @@ -from agent_comms.agent_backend import InputContent, InputId, InputRequest, TurnInbox, FollowUpInputPolicy +from agent_comms.agent_backend import FollowUpInputPolicy, InputContent, InputRequest, TurnInbox +from agent_comms.field_codec import InputId from agent_comms.turn_inputs import InputForwarding def test_inbox_owns_ready_work_and_retains_parked_input(): inbox = TurnInbox() inputs = InputForwarding(inbox) - item = InputRequest(input_id=InputId("parked"), content=InputContent(text="later"), + item = InputRequest(input_id=InputId.mint(), content=InputContent(text="later"), when_busy=FollowUpInputPolicy) assert not inbox.has_ready_work and inputs.settled inbox.put_nowait(item) diff --git a/tests/test_turn_lease_release.py b/tests/test_turn_lease_release.py index eca54cbe0d..8a8ca7da4a 100644 --- a/tests/test_turn_lease_release.py +++ b/tests/test_turn_lease_release.py @@ -5,7 +5,7 @@ from agent_comms.comms import wire from agent_comms.child_process import ProcessIdentity -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.threads import Thread from agent_comms.turn_lease import FinishedTurnFence, NoFinishedTurn, NoTurnLease @@ -13,11 +13,15 @@ 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 +33,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 +42,9 @@ 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 ca96626fa8..108d81dacd 100644 --- a/tests/test_turn_record.py +++ b/tests/test_turn_record.py @@ -7,6 +7,7 @@ from shutil import copytree from agent_comms.errors import RelationViolationError +from agent_comms.field_codec import TurnId from agent_comms.registry_store import RegistryStore from agent_comms.child_process import ProcessIdentity from agent_comms.comms import wire @@ -21,7 +22,9 @@ 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 +32,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..dfc17f2663 100644 --- a/tests/test_unscoped_claim_history.py +++ b/tests/test_unscoped_claim_history.py @@ -7,7 +7,7 @@ ClaimProjection, ClaimRelease, ClaimTransition, NativeClaim, ReleasedClaims, UnrecordedScope, UnscopedClaims, WakeClaims, ) -from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.field_codec import FieldCodec, MalformedRecord, TurnId from agent_comms.thread_status import RunningThreadStatus from agent_comms.threads import Thread @@ -37,10 +37,18 @@ def test_unscoped_history_row_decodes_projects_never_live_and_releases(tmp_path) # A current acquisition by another owner is not blocked by the unscoped holder. peer = registry.require("peer") taken = WakeClaims( - 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"), + 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=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..1fc358f6f1 100644 --- a/tests/verify_connector_tools.py +++ b/tests/verify_connector_tools.py @@ -29,7 +29,7 @@ from agent_comms.comms import Comms, wire from agent_comms.connector import Connector from agent_comms.connector_store import ConnectorStore -from agent_comms.field_codec import FieldCodec +from agent_comms.field_codec import FieldCodec, TurnId from agent_comms.goal_actions import CreateProblemGoalAction, PausedGoalAction, SetGoalAction from agent_comms.goals import CountCheckResult, OrderCheck from agent_comms.owner_requests import NewestOpenRequest @@ -138,7 +138,9 @@ 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}, diff --git a/tools/cutover/carry_wake_claim_turn_ids.py b/tools/cutover/carry_wake_claim_turn_ids.py new file mode 100644 index 0000000000..86ccdb262d --- /dev/null +++ b/tools/cutover/carry_wake_claim_turn_ids.py @@ -0,0 +1,65 @@ +"""Validate version-one wake turn identities before stamping their narrowed format.""" + +from contextlib import closing +from functools import partial +from pathlib import Path +from typing import ClassVar + +from agent_comms.bus import Bus +from agent_comms.envelope_claim_transitions import WakeClaim +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 +from agent_comms.messages import Claimed + + +class WakeClaimTurnIds(ArchivedInputs, Carry): + opened_files: ClassVar[tuple[type[RootFile], ...]] = (Bus,) + + def roots(self) -> tuple[Path, ...]: + roots = [self.root] + for root in roots: + self.require_confined(self.root, root) + roots.extend(HistoryArchive.archived_roots(root)) + return tuple(roots) + + @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}) + ) + } + 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",)) + changed = 0 + rewritten: list[Path] = [] + for path in self.input_files(): + updates: list[tuple[object, int]] = [] + 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}"') + ): + original = column.storage.from_sql(stored) + current = column.value.rewrite_document( + 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): + self.rewrite_stored(db, table, column, updates) + rewritten.append(path.relative_to(self.root)) + return Carried(changed, rewritten=tuple(rewritten))