c0d974b19f
A gateway session whose summary model keeps timing out no longer retries compaction on the same fixed interval forever. The in-agent compressor already escalates repeat summary timeouts 60 -> 300 -> 900s (ContextCompressor.record_timeout_failure), but that ladder reads the in-memory _consecutive_timeout_failures counter and bind_session_state() zeroes it (context_compressor.py:1645). Session hygiene constructs a FRESH AIAgent for every run (gateway/run.py:16820) and re-binds state each time, so from the gateway that streak is structurally always 0 -- only the flat hygiene_failure_cooldown_seconds (300s) could ever be recorded. Issue #79624 reported exactly that steady state: an oversized session (1053 messages, ~119.5k tokens) whose aux model always timed out, re-attempting compaction every 300s across five days until the reporter deleted the session by hand. Track the streak on PersistentState instead, which outlives the per-run agent and is not cleared by turn/boundary resets, so consecutive hygiene failures climb 300 -> 900 -> 2700s and then saturate. Both failure sites (progress timeout and aborted compression) feed it; a real compression resets it, so a session that recovers starts from the first rung again. The ladder multiplies the configured base, so operators who tuned hygiene_failure_cooldown_seconds keep their first rung. Per-session, so one wedged chat cannot penalize other conversations. Deliberately NOT changed, since each is a maintainer policy call rather than a defect (all three are written up on #79624): - no durable failure-streak column, so escalation still resets on restart - the gateway 30s / in-agent 120s / aux-client 300s-floor timeout mismatch - no `hermes doctor` check or `hermes sessions list` marker for a session stuck in a compression-failure cooldown Note the reported exit(1) is NOT a crash: it is the deliberate _signal_initiated_shutdown path (gateway/run.py:26746-26751, #5646) that lets systemd Restart=on-failure revive the gateway after a bare SIGTERM, and it fires on every `systemctl restart` independently of compaction. The compaction log lines appear after the shutdown line because the gateway-owned executor is torn down with shutdown(wait=False, cancel_futures=True) (run.py:21164), so an in-flight turn keeps logging during teardown. Full analysis on the issue. Post-review hardening (Phase 2c + /simplify-code found five real defects in the first cut): - the recovery gate hand-rolled `_new_tokens < _approx_tokens` when a canonical predicate already existed: `compression_made_progress` (agent/turn_context.py, #39548). They disagree on 3 of 5 cases -- the hand-rolled form misses a row-count win when the summary keeps the token estimate flat, misses one where the summary is slightly MORE verbose (so a genuinely recovered session would keep escalating forever), and counts a sub-5% wobble as recovery. Now reuses the shared predicate, promoted from `_compression_made_progress` to a public name with the old private name kept as a back-compat alias so the existing importer (tests/agent/test_protected_tail_pressure_61932.py) and any patcher of that symbol keep working. - the reset was gated on "not aborted", but the degenerate "did not rotate or compact in place" branch (#21301) is NOT aborted and yields zero reduction, so a session wedged there reset its streak every run and could never escalate -- silently defeating the fix. Now gated on real progress. - no absolute ceiling: base * 9 reaches 9h at an operator base of 3600s, indistinguishable from "compaction switched off". Added _HYGIENE_COOLDOWN_MAX_SECONDS = 3600, mirroring the in-file _RECONNECT_BACKOFF_CAP precedent. - the reset used the get-or-create accessor to write a 0 that was already 0, materialising a _sessions entry (never evicted). Now peeks. - the abort verdict was probed twice, leaving the reset/record mutual exclusion implicit; a future await between the probes would have broken it silently. Computed once into _hyg_aborted. Tests: 19 new in tests/gateway/test_hygiene_failure_cooldown_ladder.py -- ladder escalation, saturation, the absolute cap, per-session isolation, reset-on-recovery, custom/zero base, PersistentState scoping (a mutation moving the field to TurnState fails), degraded runners, the progress gate, the exact progress-predicate semantics the gate depends on, and end-to-end that the escalated value is what reaches the state DB. All 12 mutations caught, including ones that restore the flat cooldown (the original bug), ungate the reset, swap the canonical predicate back for the hand-rolled comparison, remove the cap, and share the streak globally; the harness hard-errors when a mutation cannot be applied, since a silently no-op mutation check is worse than none -- an earlier version of it WAS silently no-opping after a refactor. The gate's contract test slices by AST node span rather than a fixed character count, which had already truncated once as the block grew. gateway hygiene + session-state + the three touched agent compression suites: 50 passed; ruff clean. E2E with real imports demonstrates the premise rather than asserting it: bind_session_state zeroes the in-agent counter, and the recorded deadlines go 300 -> 900 -> 2700 -> 2700 -> 2700s where they were previously a flat 300s. Reported by @yucezerey (#79624), whose state.db column dump and "deleting the session fixed it" datapoint made the real mechanism findable.
467 lines
18 KiB
Python
467 lines
18 KiB
Python
"""Per-session gateway state consolidated into one container.
|
|
|
|
GatewayRunner historically carried ~19 separate ``Dict[str, ...]`` attributes
|
|
keyed by session_key, each with its own ad-hoc lifecycle. Three failure
|
|
classes grew out of that shape:
|
|
|
|
1. Boundary drift — every conversation boundary carried a hand-copied
|
|
pop-list that went stale when a new dict was added (#48031, #58403,
|
|
#10702, #35809). Mitigated by the ``_CONVERSATION_SCOPED_STATE`` registry,
|
|
now structurally fixed: the fields live in one ``ConversationState``
|
|
dataclass with a single ``clear()``.
|
|
2. Turn-release drift — ad-hoc ``del self._running_agents[key]`` sites that
|
|
popped different subsets of the turn dicts. Mitigated by
|
|
``_release_running_agent_state``, now ``TurnState.clear()``.
|
|
3. Wholesale-reset races — lazy-init paths like
|
|
``self._session_reasoning_overrides = {}`` replaced the ENTIRE dict,
|
|
discarding concurrent sessions' entries when raced. Structurally
|
|
impossible now: state is per-session, resets touch one field of one
|
|
``SessionState``.
|
|
|
|
Scopes (placement follows where each dict is CLEARED today):
|
|
|
|
- ``SessionState.turn`` — reset at end of every running turn.
|
|
- ``SessionState.conversation`` — reset at conversation boundaries
|
|
(/new, /resume, auto-reset, expiry, compression-exhausted reset).
|
|
- ``SessionState.persistent`` — own lifecycles (approval resolution, update
|
|
prompt answer, native-image consumption); ``run_generation`` is monotonic
|
|
and NEVER reset (#28686).
|
|
|
|
Entries in ``GatewayRunner._sessions`` are never evicted (matching the old
|
|
dicts, most of which also leaked empty/stale entries for dead sessions —
|
|
see the migration table in the consolidating commit). Follow-up work may
|
|
add eviction of fully-default SessionStates.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import MutableMapping
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
|
|
|
|
# Presence-sensitive sentinel: /fast stores "priority" or None (explicit
|
|
# normal), so key PRESENCE — not value truthiness — decides whether the
|
|
# override applies. ``_UNSET_TIER`` means "no override recorded".
|
|
_UNSET_TIER = object()
|
|
|
|
# Public alias for callers outside this module.
|
|
SERVICE_TIER_UNSET = _UNSET_TIER
|
|
|
|
|
|
@dataclass
|
|
class TurnState:
|
|
"""State scoped to one running gateway turn.
|
|
|
|
Cleared by ``GatewayRunner._release_running_agent_state`` (via
|
|
``clear()``) at every site that ends a running turn. ``lease_token`` /
|
|
``lease_generation`` are deliberately NOT cleared here — they are owned
|
|
by ``_release_turn_lease`` (#64934), which must release the registry
|
|
lease exactly once per acquiring turn.
|
|
"""
|
|
|
|
# Running AIAgent instance (or _AGENT_PENDING_SENTINEL); None = idle.
|
|
agent: Any = None
|
|
# Turn start timestamp (0.0 = not running).
|
|
started_ts: float = 0.0
|
|
# Cross-process active-session slot lease (None = none held).
|
|
lease: Any = None
|
|
# Last busy-ack timestamp (debounce; 0.0 = never acked).
|
|
busy_ack_ts: float = 0.0
|
|
# Held turn-lease token + the run generation that acquired it. The old
|
|
# ``_turn_lease_tokens`` dict was keyed by (session_key, generation) so a
|
|
# stale unwind could never free a newer turn's lease; the pair encoding
|
|
# preserves that: release/rebind only match when generation is current.
|
|
lease_token: Any = None
|
|
lease_generation: Optional[int] = None
|
|
|
|
def clear(self) -> None:
|
|
"""Reset the per-turn slot (agent / start ts / lease / busy-ack).
|
|
|
|
Mirrors the exact clear set of the old _release_running_agent_state:
|
|
_running_agents, _running_agents_ts, _active_session_leases (popped
|
|
by the caller so it can call ``lease.release()``), _busy_ack_ts.
|
|
"""
|
|
self.agent = None
|
|
self.started_ts = 0.0
|
|
self.lease = None
|
|
self.busy_ack_ts = 0.0
|
|
|
|
|
|
@dataclass
|
|
class ConversationState:
|
|
"""State scoped to one conversation (survives turns, not boundaries)."""
|
|
|
|
# /model per-session override (model/provider/api_key/base_url/api_mode).
|
|
model_override: Optional[Dict[str, Any]] = None
|
|
# /model --once restore snapshot.
|
|
one_turn_restore: Optional[Dict[str, Any]] = None
|
|
# /reasoning per-session override.
|
|
reasoning_override: Optional[Dict[str, Any]] = None
|
|
# /fast per-session override: "priority" or None; _UNSET_TIER = absent.
|
|
service_tier_override: Any = _UNSET_TIER
|
|
# Last successfully-resolved non-empty model (#35314 recovery).
|
|
last_resolved_model: str = ""
|
|
# /queue overflow FIFO (adapter slot holds the head).
|
|
queued_events: List[Any] = field(default_factory=list)
|
|
# Per-turn must-deliver sidecar notes (one-shot).
|
|
sidecar_notes: List[str] = field(default_factory=list)
|
|
# Pinned session-context bytes: (change_key, text).
|
|
ephemeral_pin: Optional[Tuple[Any, ...]] = None
|
|
# Last voice-channel context delivered (None = never delivered).
|
|
vc_last: Optional[str] = None
|
|
|
|
def clear(self) -> None:
|
|
"""Reset every conversation-scoped field to its default.
|
|
|
|
The structural successor of the ``_CONVERSATION_SCOPED_STATE``
|
|
pop-loop: adding a field here means every boundary clears it
|
|
automatically.
|
|
"""
|
|
self.model_override = None
|
|
self.one_turn_restore = None
|
|
self.reasoning_override = None
|
|
self.service_tier_override = _UNSET_TIER
|
|
self.last_resolved_model = ""
|
|
self.queued_events = []
|
|
self.sidecar_notes = []
|
|
self.ephemeral_pin = None
|
|
self.vc_last = None
|
|
|
|
|
|
@dataclass
|
|
class PersistentState:
|
|
"""State with its own lifecycle — NOT cleared by turn or boundary resets
|
|
wholesale (approvals/update prompts ARE cleared by the boundary
|
|
*security* funnel, but individually, matching the old behavior)."""
|
|
|
|
# Pending exec approval ({"command": ..., "pattern_key": ...}).
|
|
approvals: Optional[Dict[str, Any]] = None
|
|
# /update prompt awaiting a user response.
|
|
update_prompt_pending: bool = False
|
|
# Image paths staged for native (inline) attachment; consumed one-shot.
|
|
native_image_paths: List[str] = field(default_factory=list)
|
|
# Legacy runner-level pending message text (write-mostly; flushed to
|
|
# disk on shutdown — see #72680). NOTE: distinct from the adapter-level
|
|
# ``_pending_messages`` (Dict[str, MessageEvent]) in gateway/base.py,
|
|
# which is a different store that happens to share the old name.
|
|
pending_command_text: Optional[str] = None
|
|
# Monotonic run-generation counter (#28686). NEVER reset: clearing it
|
|
# would break stale-run detection.
|
|
run_generation: int = 0
|
|
# Consecutive session-hygiene compression failures for this session
|
|
# (#79624). The in-agent compressor escalates repeat timeouts via
|
|
# ContextCompressor._consecutive_timeout_failures, but hygiene builds a
|
|
# FRESH AIAgent per run and bind_session_state() zeroes that counter, so
|
|
# the in-agent ladder is structurally unreachable from the gateway.
|
|
# Tracking the streak here — outside the per-run agent — lets hygiene
|
|
# escalate its cooldown instead of retrying on a flat interval forever.
|
|
# Reset on a successful compression, not by turn/boundary resets.
|
|
hygiene_failure_streak: int = 0
|
|
|
|
|
|
@dataclass
|
|
class SessionState:
|
|
"""All per-session gateway state, grouped by lifecycle scope."""
|
|
|
|
turn: TurnState = field(default_factory=TurnState)
|
|
conversation: ConversationState = field(default_factory=ConversationState)
|
|
persistent: PersistentState = field(default_factory=PersistentState)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Legacy dict-view adapters.
|
|
#
|
|
# Dozens of tests construct bare runners (object.__new__) and read/write the
|
|
# old dict attributes directly (``runner._running_agents = {}``,
|
|
# ``assert key in runner._pending_approvals``...). These views keep that
|
|
# surface working: each is a LIVE MutableMapping over one SessionState field
|
|
# across all sessions. Production code in gateway/run.py accesses state via
|
|
# ``self._session_state(key).<scope>.<field>`` — the views exist for tests
|
|
# and for the thin remaining mixin/adapter call sites documented in the
|
|
# consolidating commit.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FieldSpec:
|
|
"""Describes one legacy dict: scope attr, field name, absence test."""
|
|
|
|
__slots__ = ("scope", "name", "default", "is_present")
|
|
|
|
def __init__(
|
|
self,
|
|
scope: str,
|
|
name: str,
|
|
default: Callable[[], Any],
|
|
is_present: Callable[[Any], bool],
|
|
) -> None:
|
|
self.scope = scope
|
|
self.name = name
|
|
self.default = default
|
|
self.is_present = is_present
|
|
|
|
|
|
def _present_not_none(v: Any) -> bool:
|
|
return v is not None
|
|
|
|
|
|
def _present_nonzero(v: Any) -> bool:
|
|
return bool(v)
|
|
|
|
|
|
def _present_not_unset(v: Any) -> bool:
|
|
return v is not _UNSET_TIER
|
|
|
|
|
|
class SessionFieldView(MutableMapping):
|
|
"""Live dict-like view of one SessionState field across sessions."""
|
|
|
|
__slots__ = ("_runner", "_spec")
|
|
|
|
def __init__(self, runner: Any, spec: _FieldSpec) -> None:
|
|
self._runner = runner
|
|
self._spec = spec
|
|
|
|
# -- internals --------------------------------------------------------
|
|
def _sessions(self) -> Dict[str, SessionState]:
|
|
return self._runner.__dict__.get("_sessions") or {}
|
|
|
|
def _value(self, state: SessionState) -> Any:
|
|
return getattr(getattr(state, self._spec.scope), self._spec.name)
|
|
|
|
# -- mapping protocol ---------------------------------------------------
|
|
def __getitem__(self, key: str) -> Any:
|
|
state = self._sessions().get(key)
|
|
if state is None:
|
|
raise KeyError(key)
|
|
value = self._value(state)
|
|
if not self._spec.is_present(value):
|
|
raise KeyError(key)
|
|
return value
|
|
|
|
def __setitem__(self, key: str, value: Any) -> None:
|
|
state = self._runner._session_state(key)
|
|
setattr(getattr(state, self._spec.scope), self._spec.name, value)
|
|
|
|
def __delitem__(self, key: str) -> None:
|
|
state = self._sessions().get(key)
|
|
if state is None or not self._spec.is_present(self._value(state)):
|
|
raise KeyError(key)
|
|
setattr(
|
|
getattr(state, self._spec.scope),
|
|
self._spec.name,
|
|
self._spec.default(),
|
|
)
|
|
|
|
def __iter__(self) -> Iterator[str]:
|
|
for key, state in list(self._sessions().items()):
|
|
if self._spec.is_present(self._value(state)):
|
|
yield key
|
|
|
|
def __len__(self) -> int:
|
|
return sum(1 for _ in self)
|
|
|
|
def __contains__(self, key: object) -> bool:
|
|
state = self._sessions().get(key) # type: ignore[arg-type]
|
|
return state is not None and self._spec.is_present(self._value(state))
|
|
|
|
def clear(self) -> None: # avoid MutableMapping's popitem loop
|
|
for state in list(self._sessions().values()):
|
|
setattr(
|
|
getattr(state, self._spec.scope),
|
|
self._spec.name,
|
|
self._spec.default(),
|
|
)
|
|
|
|
# Mapping doesn't provide __eq__; tests compare against plain dicts.
|
|
def __eq__(self, other: object) -> bool:
|
|
if isinstance(other, (dict, MutableMapping)):
|
|
return dict(self.items()) == dict(other)
|
|
return NotImplemented
|
|
|
|
def __ne__(self, other: object) -> bool:
|
|
result = self.__eq__(other)
|
|
return NotImplemented if result is NotImplemented else not result
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
return f"SessionFieldView({self._spec.scope}.{self._spec.name}, {dict(self.items())!r})"
|
|
|
|
|
|
class TurnLeaseTokenView(MutableMapping):
|
|
"""Legacy view of ``_turn_lease_tokens``: keyed by (session_key, generation).
|
|
|
|
The pair now lives on ``TurnState.lease_token`` / ``lease_generation``;
|
|
at most one held token exists per session key (the lease registry
|
|
serializes acquisition per session), so the single slot is equivalent to
|
|
the old tuple-keyed dict.
|
|
"""
|
|
|
|
__slots__ = ("_runner",)
|
|
|
|
def __init__(self, runner: Any) -> None:
|
|
self._runner = runner
|
|
|
|
def _sessions(self) -> Dict[str, SessionState]:
|
|
return self._runner.__dict__.get("_sessions") or {}
|
|
|
|
@staticmethod
|
|
def _split(key: Any) -> Tuple[str, int]:
|
|
if not isinstance(key, tuple) or len(key) != 2:
|
|
raise KeyError(key)
|
|
return key[0], key[1]
|
|
|
|
def __getitem__(self, key: Any) -> Any:
|
|
session_key, generation = self._split(key)
|
|
state = self._sessions().get(session_key)
|
|
if (
|
|
state is None
|
|
or state.turn.lease_token is None
|
|
or state.turn.lease_generation != generation
|
|
):
|
|
raise KeyError(key)
|
|
return state.turn.lease_token
|
|
|
|
def __setitem__(self, key: Any, value: Any) -> None:
|
|
session_key, generation = self._split(key)
|
|
state = self._runner._session_state(session_key)
|
|
state.turn.lease_token = value
|
|
state.turn.lease_generation = generation
|
|
|
|
def __delitem__(self, key: Any) -> None:
|
|
session_key, generation = self._split(key)
|
|
state = self._sessions().get(session_key)
|
|
if (
|
|
state is None
|
|
or state.turn.lease_token is None
|
|
or state.turn.lease_generation != generation
|
|
):
|
|
raise KeyError(key)
|
|
state.turn.lease_token = None
|
|
state.turn.lease_generation = None
|
|
|
|
def __iter__(self) -> Iterator[Tuple[str, Any]]:
|
|
for key, state in list(self._sessions().items()):
|
|
if state.turn.lease_token is not None:
|
|
yield (key, state.turn.lease_generation)
|
|
|
|
def __len__(self) -> int:
|
|
return sum(1 for _ in self)
|
|
|
|
def __eq__(self, other: object) -> bool:
|
|
if isinstance(other, (dict, MutableMapping)):
|
|
return dict(self.items()) == dict(other)
|
|
return NotImplemented
|
|
|
|
def __ne__(self, other: object) -> bool:
|
|
result = self.__eq__(other)
|
|
return NotImplemented if result is NotImplemented else not result
|
|
|
|
|
|
# One spec per legacy dict attribute (see the consolidating commit's
|
|
# migration table for declared-line / clear-path provenance).
|
|
LEGACY_FIELD_SPECS: Dict[str, _FieldSpec] = {
|
|
"_running_agents": _FieldSpec("turn", "agent", lambda: None, _present_not_none),
|
|
"_running_agents_ts": _FieldSpec("turn", "started_ts", float, _present_nonzero),
|
|
"_active_session_leases": _FieldSpec("turn", "lease", lambda: None, _present_not_none),
|
|
"_busy_ack_ts": _FieldSpec("turn", "busy_ack_ts", float, _present_nonzero),
|
|
"_session_model_overrides": _FieldSpec(
|
|
"conversation", "model_override", lambda: None, _present_not_none
|
|
),
|
|
"_pending_one_turn_model_restores": _FieldSpec(
|
|
"conversation", "one_turn_restore", lambda: None, _present_not_none
|
|
),
|
|
"_session_reasoning_overrides": _FieldSpec(
|
|
"conversation", "reasoning_override", lambda: None, _present_not_none
|
|
),
|
|
"_session_service_tier_overrides": _FieldSpec(
|
|
"conversation", "service_tier_override", lambda: _UNSET_TIER, _present_not_unset
|
|
),
|
|
"_last_resolved_model": _FieldSpec(
|
|
"conversation", "last_resolved_model", str, _present_nonzero
|
|
),
|
|
"_queued_events": _FieldSpec("conversation", "queued_events", list, _present_nonzero),
|
|
"_pending_turn_sidecar_notes": _FieldSpec(
|
|
"conversation", "sidecar_notes", list, _present_nonzero
|
|
),
|
|
"_session_ephemeral_pin": _FieldSpec(
|
|
"conversation", "ephemeral_pin", lambda: None, _present_not_none
|
|
),
|
|
"_session_vc_last": _FieldSpec(
|
|
"conversation", "vc_last", lambda: None, _present_not_none
|
|
),
|
|
"_pending_approvals": _FieldSpec(
|
|
"persistent", "approvals", lambda: None, _present_not_none
|
|
),
|
|
"_update_prompt_pending": _FieldSpec(
|
|
"persistent", "update_prompt_pending", bool, _present_nonzero
|
|
),
|
|
"_pending_native_image_paths_by_session": _FieldSpec(
|
|
"persistent", "native_image_paths", list, _present_nonzero
|
|
),
|
|
"_pending_messages": _FieldSpec(
|
|
"persistent", "pending_command_text", lambda: None, _present_not_none
|
|
),
|
|
"_session_run_generation": _FieldSpec(
|
|
"persistent", "run_generation", int, _present_nonzero
|
|
),
|
|
}
|
|
|
|
|
|
def legacy_dict_property(attr_name: str) -> property:
|
|
"""Build a legacy dict-shaped @property for one migrated attribute.
|
|
|
|
Getter returns a live :class:`SessionFieldView`; setter accepts a plain
|
|
dict (the ubiquitous test pattern ``runner._X = {...}``), resetting the
|
|
field on every known session and then applying the given entries.
|
|
"""
|
|
spec = LEGACY_FIELD_SPECS[attr_name]
|
|
|
|
def fget(self: Any) -> SessionFieldView:
|
|
return SessionFieldView(self, spec)
|
|
|
|
def fset(self: Any, mapping: Optional[Dict[str, Any]]) -> None:
|
|
view = SessionFieldView(self, spec)
|
|
view.clear()
|
|
for key, value in (mapping or {}).items():
|
|
view[key] = value
|
|
|
|
def fdel(self: Any) -> None:
|
|
# ``del runner._busy_ack_ts`` in older tests simulated a runner
|
|
# without the attribute; the closest equivalent is "no entries".
|
|
SessionFieldView(self, spec).clear()
|
|
|
|
return property(
|
|
fget,
|
|
fset,
|
|
fdel,
|
|
doc=(
|
|
f"Legacy dict view over SessionState.{spec.scope}.{spec.name} "
|
|
"(kept for tests that access the pre-SessionState attribute)."
|
|
),
|
|
)
|
|
|
|
|
|
def legacy_lease_token_property() -> property:
|
|
"""Legacy (session_key, generation)-keyed view of held turn-lease tokens."""
|
|
|
|
def fget(self: Any) -> TurnLeaseTokenView:
|
|
return TurnLeaseTokenView(self)
|
|
|
|
def fset(self: Any, mapping: Optional[Dict[Tuple[str, int], Any]]) -> None:
|
|
view = TurnLeaseTokenView(self)
|
|
for key in list(view):
|
|
del view[key]
|
|
for key, value in (mapping or {}).items():
|
|
view[key] = value
|
|
|
|
def fdel(self: Any) -> None:
|
|
view = TurnLeaseTokenView(self)
|
|
for key in list(view):
|
|
del view[key]
|
|
|
|
return property(
|
|
fget,
|
|
fset,
|
|
fdel,
|
|
doc="Legacy (session_key, generation)-keyed turn-lease token view.",
|
|
)
|