|
22 | 22 | import uuid |
23 | 23 | from collections.abc import Generator |
24 | 24 | from contextlib import contextmanager |
25 | | -from contextvars import ContextVar |
| 25 | +from contextvars import ContextVar, Token |
26 | 26 |
|
27 | 27 | # Context variables for workflow/trace propagation. |
28 | 28 | _workflow_id_var: ContextVar[str | None] = ContextVar("workflow_id", default=None) |
@@ -160,6 +160,145 @@ def set_chain_op(op: str) -> None: |
160 | 160 | _chain_op_var.set(op) |
161 | 161 |
|
162 | 162 |
|
| 163 | +# --------------------------------------------------------------------------- |
| 164 | +# Server-minted execution_id (2026-07-04 — CLAUDE.md §24, §29) |
| 165 | +# --------------------------------------------------------------------------- |
| 166 | +# |
| 167 | +# Pre-0.12.0 the SDK sent a client-supplied ``execution_id`` (usually |
| 168 | +# ``workflow_id``) in /check requests and IGNORED the server's response. |
| 169 | +# This left two problems: |
| 170 | +# |
| 171 | +# 1. CLAUDE.md §24 ownership — the backend's `gate_reserve_v3` |
| 172 | +# generates a uuidv7 internally, persists |
| 173 | +# ``execution:{execution_id}`` (24h TTL) and creates |
| 174 | +# ``reservation:{execution_id}`` (300s TTL). The client-minted |
| 175 | +# id never matched, so on the v3 path the gate rejected /track |
| 176 | +# with 503 RESERVATION_NOT_FOUND (§29 — fail-CLOSED). |
| 177 | +# |
| 178 | +# 2. CLAUDE.md §23 idempotency — /track's ``idempotency_key`` |
| 179 | +# contract depends on the server-minted UUID being reused |
| 180 | +# on retry. Without picking it up at /check the SDK has no |
| 181 | +# way to compute a stable key. |
| 182 | +# |
| 183 | +# Fix: capture the ``reservation_id`` field from the /check |
| 184 | +# response into this contextvar. The runtime sets it on every |
| 185 | +# successful /check; the runtime's ``_enrich_event`` reads it on |
| 186 | +# the way out and tags the /track payload with ``execution_id``. |
| 187 | +# |
| 188 | +# Lifetime: scoped automatically by ``with workflow(...)`` / |
| 189 | +# ``with chain(...)`` — the runtime resets the contextvar on |
| 190 | +# block exit so a /check in one block never leaks into a /track |
| 191 | +# in a sibling block. Tests can drive it manually with |
| 192 | +# ``set_/reset_server_minted_execution_id`` (Token-based API |
| 193 | +# mirrors the user-facing audit spec; ``clear_`` is a |
| 194 | +# no-token convenience for the runtime's ``_enrich_event`` |
| 195 | +# after a /track has been issued). |
| 196 | +# |
| 197 | +# The reservation TTL (300s) is shorter than the chain id's 24h |
| 198 | +# binding TTL, so we also record the capture timestamp — |
| 199 | +# ``get_server_minted_reservation_at`` returns ``time.monotonic()`` |
| 200 | +# at the moment /check returned 200. The runtime ignores the |
| 201 | +# contextvar when the age exceeds 295s (5s margin below the |
| 202 | +# 300s backend reservation TTL) so an exceptionally long LLM |
| 203 | +# call never ships a doomed ``execution_id``. |
| 204 | +_server_minted_execution_id_var: ContextVar[str | None] = ContextVar( |
| 205 | + "server_minted_execution_id", default=None |
| 206 | +) |
| 207 | +_server_minted_reservation_at_var: ContextVar[float] = ContextVar( |
| 208 | + "server_minted_reservation_at", default=0.0 |
| 209 | +) |
| 210 | + |
| 211 | + |
| 212 | +def get_server_minted_execution_id() -> str | None: |
| 213 | + """Return the server-minted execution_id from the last /check, or |
| 214 | + ``None`` if none captured in scope. |
| 215 | +
|
| 216 | + Read by ``NullRunRuntime._enrich_event`` to tag the /track |
| 217 | + payload. ``None`` is the legacy / v1-v2 path — the wire spec |
| 218 | + allows the field to be omitted when the backend has not |
| 219 | + minted one (capability ``server_minted_execution_id=False``). |
| 220 | + """ |
| 221 | + return _server_minted_execution_id_var.get() |
| 222 | + |
| 223 | + |
| 224 | +def get_server_minted_reservation_at() -> float: |
| 225 | + """Return ``time.monotonic()`` at the moment of /check capture, |
| 226 | + or ``0.0`` if no capture in scope. |
| 227 | +
|
| 228 | + Used by ``NullRunRuntime._enrich_event`` to refuse a /track |
| 229 | + whose /check has aged past the v3 reservation TTL (300s — |
| 230 | + CLAUDE.md §29). The runtime captures the timestamp at the |
| 231 | + same instant the id is captured, so the two values always |
| 232 | + refer to the same /check. |
| 233 | + """ |
| 234 | + return _server_minted_reservation_at_var.get() |
| 235 | + |
| 236 | + |
| 237 | +def set_server_minted_execution_id(value: str | None) -> Token[str | None]: |
| 238 | + """Capture the server-minted execution_id returned by /check. |
| 239 | +
|
| 240 | + Returns the ``Token`` so the caller can restore the previous |
| 241 | + value via :func:`reset_server_minted_execution_id`. The |
| 242 | + runtime drives the lifetime explicitly (it owns the |
| 243 | + capture/reset cycle around the user-function call) — user |
| 244 | + code does not need to call this directly. |
| 245 | +
|
| 246 | + Args: |
| 247 | + value: UUID v7 string returned on ``GateResponse. |
| 248 | + reservation_id`` (server-minted per §24). Pass |
| 249 | + ``None`` to clear (e.g. on a hard block response |
| 250 | + which carries no reservation_id). |
| 251 | + """ |
| 252 | + return _server_minted_execution_id_var.set(value) |
| 253 | + |
| 254 | + |
| 255 | +def set_server_minted_reservation_at(value: float) -> Token[float]: |
| 256 | + """Capture the ``time.monotonic()`` instant corresponding to |
| 257 | + ``set_server_minted_execution_id``. |
| 258 | +
|
| 259 | + Called by the runtime immediately after :func:`set_server_minted_execution_id` |
| 260 | + so the two timestamps stay in lockstep. Returns the matching |
| 261 | + Token for symmetric :func:`reset_server_minted_reservation_at`. |
| 262 | + """ |
| 263 | + return _server_minted_reservation_at_var.set(value) |
| 264 | + |
| 265 | + |
| 266 | +def reset_server_minted_execution_id(token: Token[str | None]) -> None: |
| 267 | + """Restore the previous server-minted execution_id value. |
| 268 | +
|
| 269 | + Pair with :func:`set_server_minted_execution_id`. The runtime |
| 270 | + stores the token at capture time and resets it on the matching |
| 271 | + /track emission (or at workflow/chain block exit, whichever |
| 272 | + comes first). |
| 273 | + """ |
| 274 | + _server_minted_execution_id_var.reset(token) |
| 275 | + |
| 276 | + |
| 277 | +def reset_server_minted_reservation_at(token: Token[float]) -> None: |
| 278 | + """Restore the previous reservation capture timestamp. |
| 279 | +
|
| 280 | + Pair with :func:`set_server_minted_reservation_at`. |
| 281 | + """ |
| 282 | + _server_minted_reservation_at_var.reset(token) |
| 283 | + |
| 284 | + |
| 285 | +def clear_server_minted_execution_id() -> None: |
| 286 | + """Erase the captured server-minted execution_id + timestamp. |
| 287 | +
|
| 288 | + No-token convenience for the runtime's "block exited, drop the |
| 289 | + capture" code path. Equivalent to:: |
| 290 | +
|
| 291 | + _server_minted_execution_id_var.set(None) |
| 292 | + _server_minted_reservation_at_var.set(0.0) |
| 293 | +
|
| 294 | + Use :func:`reset_server_minted_execution_id` instead when you |
| 295 | + have a Token to consume — that path restores the previous |
| 296 | + scope's value, ``clear_`` strictly forgets it. |
| 297 | + """ |
| 298 | + _server_minted_execution_id_var.set(None) |
| 299 | + _server_minted_reservation_at_var.set(0.0) |
| 300 | + |
| 301 | + |
163 | 302 | def set_attempt_index(index: int) -> None: |
164 | 303 | """Set current attempt index for retry correlation.""" |
165 | 304 | _attempt_index_var.set(index) |
|
0 commit comments