rein-aharness/rein_aharness/execution_cancel.py
custodian-sync f01b765668 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-09-04:
  - update .custodian-brief.md for rein-aharness

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06ba0-10aa-7ea0-b20a-4f3fac39efe9
2026-09-04 11:00:20 +02:00

141 lines
4.1 KiB
Python

"""One-shot cancellation for an in-flight adapter call.
Lease loss, run timeout, and process signals all need the same adapter-side
effect: stop the current subprocess or HTTP client without retaining provider
output. The claim loop owns the reason; adapters only observe and stop.
"""
from __future__ import annotations
import contextvars
import threading
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Any
_ALLOWED_REASONS = frozenset({"lease-loss", "timeout", "signal"})
_REASON_LIMIT = 32
_active: contextvars.ContextVar["ExecutionCancel | None"] = contextvars.ContextVar(
"rein_aharness_execution_cancel",
default=None,
)
class ExecutionCancelled(RuntimeError):
"""Bounded proof that an adapter stopped because the run was cancelled."""
def __init__(self, reason: str) -> None:
self.reason = _normalize_reason(reason)
super().__init__(f"execution cancelled ({self.reason})")
class ExecutionCancel:
"""Thread-safe one-shot cancel with optional process/HTTP stop callbacks."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._event = threading.Event()
self._reason: str | None = None
self._stops: list[Callable[[], None]] = []
@property
def cancelled(self) -> bool:
return self._event.is_set()
@property
def reason(self) -> str | None:
with self._lock:
return self._reason
def wait(self, timeout: float | None = None) -> bool:
return self._event.wait(timeout)
def check(self) -> None:
if self.cancelled:
raise ExecutionCancelled(self.reason or "cancelled")
def cancel(self, reason: str) -> str:
"""Record the first reason and invoke registered stop callbacks once."""
callbacks: list[Callable[[], None]] = []
with self._lock:
if self._reason is None:
self._reason = _normalize_reason(reason)
self._event.set()
callbacks = list(self._stops)
recorded = self._reason
for callback in callbacks:
_invoke_stop(callback)
return recorded
def register_stop(self, stop: Callable[[], None]) -> None:
"""Register a best-effort stopper; invoke immediately if already cancelled."""
invoke_now = False
with self._lock:
self._stops.append(stop)
invoke_now = self._reason is not None
if invoke_now:
_invoke_stop(stop)
def register_process(self, proc: Any) -> None:
"""Kill an in-flight subprocess. Safe against mock and exited processes."""
self.register_stop(lambda: _kill_process(proc))
def evidence(self) -> dict[str, str]:
if not self.cancelled:
return {}
return {"cancelled": "true", "reason": self.reason or "cancelled"}
def active_cancel() -> ExecutionCancel | None:
return _active.get()
def resolve_cancel(explicit: ExecutionCancel | None = None) -> ExecutionCancel | None:
return explicit if explicit is not None else active_cancel()
@contextmanager
def using_cancel(cancel: ExecutionCancel) -> Iterator[ExecutionCancel]:
token = _active.set(cancel)
try:
yield cancel
finally:
_active.reset(token)
def _normalize_reason(reason: str) -> str:
text = (reason or "").strip()[:_REASON_LIMIT]
if text in _ALLOWED_REASONS:
return text
return "cancelled"
def _invoke_stop(stop: Callable[[], None]) -> None:
try:
stop()
except Exception:
return
def _kill_process(proc: Any) -> None:
poll = getattr(proc, "poll", None)
if callable(poll):
try:
status = poll()
except Exception:
status = None
if isinstance(status, int):
return
kill = getattr(proc, "kill", None)
if callable(kill):
try:
kill()
except Exception:
return
wait = getattr(proc, "wait", None)
if callable(wait):
try:
wait(timeout=2)
except Exception:
return