From 36069dddfceea4632929ab81c596b0ca053b570a Mon Sep 17 00:00:00 2001 From: actionq-dispatcher Date: Sun, 23 Aug 2026 13:05:31 +0300 Subject: [PATCH] fix(served): send an idempotency key on work.reservation.* writes (0.3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter declares every work.reservation.* operation idempotency=required, but reservation_operation() never sent a key, so the authority rejected each reserve/touch/release with idempotency-key-required — after the authority check, so a correctly granted identity looked like a missing grant. Keys are unique per call by default (touch must advance activity every time), an explicit key passes through for retries, and work.read.reservation stays unkeyed because reads reject one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hwq1vFjhdXjLPP97cZVkJa --- pyproject.toml | 2 +- sprintctl/__init__.py | 2 +- sprintctl/served.py | 29 ++++++++++++++++++++--- tests/test_served.py | 19 +++++++++++++++ uv.lock | 2 +- verification/validate_release_contract.py | 2 +- 6 files changed, 49 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b8a67e4..ce4eb2c 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sprintctl" -version = "0.3.1" +version = "0.3.2" requires-python = ">=3.11" dependencies = [ "click>=8.1", diff --git a/sprintctl/__init__.py b/sprintctl/__init__.py index 0a0a168..3f646bd 100755 --- a/sprintctl/__init__.py +++ b/sprintctl/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.3.1" +__version__ = "0.3.2" # Keep these identifiers stable: the doctor command compares the running # package with the capabilities declared by a checked-out source tree. diff --git a/sprintctl/served.py b/sprintctl/served.py index e438a44..d1f0e28 100644 --- a/sprintctl/served.py +++ b/sprintctl/served.py @@ -23,6 +23,7 @@ from __future__ import annotations import asyncio +import uuid from typing import Any from . import reservation as _reservation @@ -275,10 +276,32 @@ def read_next_work( def reservation_operation( - served_profile: ServedProfile, operation: str, arguments: dict[str, Any], *, repo_id: str + served_profile: ServedProfile, + operation: str, + arguments: dict[str, Any], + *, + repo_id: str, + idempotency_key: str | None = None, ) -> dict[str, Any]: - """Invoke one v0.3 reservation operation through the served authority.""" - return asyncio.run(_invoke_operation(served_profile, operation, arguments, repo_id=repo_id)) + """Invoke one v0.3 reservation operation through the served authority. + + Every ``work.reservation.*`` operation is declared ``idempotency: required`` + by the adapter, so the authority rejects a call without a key + (``idempotency-key-required``) *after* the authority check has passed -- + which is how a correctly granted identity was first seen failing here. A + reservation is a coordination signal, and ``touch`` in particular must be + able to advance ``last_activity_at`` on every call, so the default key is + unique per invocation (the same convention as the per-event key used by + ``publish_events``) rather than derived from the arguments. Callers that + retry a single logical call pass their own key to deduplicate. Reads + (``work.read.reservation``) are never keyed. + """ + kwargs: dict[str, Any] = {"repo_id": repo_id} + if operation.startswith("work.reservation."): + # Only the mutations are keyed: ``work.read.reservation`` travels + # through here too and the authority rejects a key on a read. + kwargs["idempotency_key"] = idempotency_key or uuid.uuid4().hex + return asyncio.run(_invoke_operation(served_profile, operation, arguments, **kwargs)) def read_records( diff --git a/tests/test_served.py b/tests/test_served.py index f2b9b66..abbdec9 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -287,6 +287,25 @@ def test_reservation_operation_sends_credential_free_shape(fake_vuoro_client): assert "claim_token" not in args +def test_reservation_operation_always_sends_an_idempotency_key(fake_vuoro_client): + """Every work.reservation.* operation is ``idempotency: required`` on the + adapter, and the authority rejects a keyless call only *after* the + authority check -- so a missing key looked like a grant problem in the + field. Default keys are unique per call (touch must advance activity every + time); an explicit key is passed through for callers that retry.""" + profile = _profile() + args = {"item_id": 5, "actor": "worker", "session_id": "session-1"} + served.reservation_operation(profile, "work.reservation.reserve", dict(args), repo_id="repo-x") + served.reservation_operation(profile, "work.reservation.touch", dict(args), repo_id="repo-x") + served.reservation_operation( + profile, "work.reservation.release", dict(args), repo_id="repo-x", idempotency_key="retry-7" + ) + keys = [inv[2].get("idempotency_key") for client in fake_vuoro_client.instances for inv in client.invocations] + assert len(keys) == 3 and all(keys), keys + assert keys[0] != keys[1] + assert keys[2] == "retry-7" + + def test_item_note_sends_full_shape_and_never_an_actor_field(fake_vuoro_client): profile = _profile() result = served.item_note( diff --git a/uv.lock b/uv.lock index 2f7421b..3431894 100755 --- a/uv.lock +++ b/uv.lock @@ -517,7 +517,7 @@ wheels = [ [[package]] name = "sprintctl" -version = "0.3.1" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/verification/validate_release_contract.py b/verification/validate_release_contract.py index 673947a..664182d 100644 --- a/verification/validate_release_contract.py +++ b/verification/validate_release_contract.py @@ -14,7 +14,7 @@ ROOT = Path(__file__).resolve().parents[1] -RELEASE_VERSION = "0.3.1" +RELEASE_VERSION = "0.3.2" ADAPTER_NAME = "vuoro-adapter-kit" ADAPTER_DIGEST_RE = re.compile(r"^sha256=(?P[0-9a-f]{64})$") ADAPTER_PATH_RE = re.compile(