"""Central lease policy configuration (#790 Slice A, AC-N7). The single authoritative source for every lease duration in the project. Before this module the numbers were scattered: a four-hour author TTL was declared twice (``issue_lock_store`` and ``gitea_mcp_server``), the reviewer/merger sliding window lived in ``reviewer_pr_lease``, the conflict-fix window in ``pr_work_lease``, and the control-plane default in ``control_plane_db``. Nothing tied them together, so tuning one class silently diverged from the others and no reader could answer "how long does a lease live?" without grepping four files. AC-N7 requires that this configuration exist *before* the first heartbeat and TTL behavior that reads from it, so it ships in Slice A rather than trailing the code it governs. Deliberate boundaries: * **Declaration is not rewiring.** Every task class is declared here, but only those with ``heartbeat_lifecycle_active`` were migrated onto the shared heartbeat lifecycle in Slice A — currently ``author_issue_work`` alone. Reviewer, merger, and conflict-fix leases keep their own existing behavior until Slice C moves them; their numbers are recorded here so the two cannot drift apart unnoticed, and ``tests/test_issue_790_lease_policy.py`` asserts the recorded values still equal the constants those modules use. * **No policy decision lives here.** This module answers "how long", never "may this session proceed". Freshness, reclaim, and renewal dispositions stay in ``issue_lock_store``. """ from __future__ import annotations import os from dataclasses import dataclass from typing import Any # Task classes. Only the first is migrated onto the shared lifecycle in Slice A. TASK_CLASS_AUTHOR_ISSUE_WORK = "author_issue_work" TASK_CLASS_REVIEWER_PR = "reviewer_pr" TASK_CLASS_MERGER_PR = "merger_pr" TASK_CLASS_CONFLICT_FIX = "conflict_fix" # Durable marker for a lease minted under the shared heartbeat lifecycle. # # #790 AC-N8: this explicit marker — never a timestamp comparison — is what # distinguishes a heartbeat-lifecycle lease from a legacy one. A lock written # before this lifecycle existed carries no marker and reads as # ``LIFECYCLE_LEGACY``. LIFECYCLE_HEARTBEAT_V1 = "heartbeat-v1" LIFECYCLE_LEGACY = "legacy" _ENV_PREFIX = "GITEA_LEASE_POLICY" @dataclass(frozen=True) class LeasePolicy: """Durations governing one task class. All intervals are minutes except ``absolute_cap_hours``. ``None`` for the cap means the class has no maximum continuous duration. """ task_class: str initial_ttl_minutes: float heartbeat_cadence_minutes: float stale_warning_minutes: float missed_heartbeat_grace_minutes: float absolute_cap_hours: float | None recovery_grace_minutes: float terminal_race_drain_minutes: float terminal_retirement_eligible: bool heartbeat_lifecycle_active: bool # Defaults. ``author_issue_work`` adopts the reviewer window proven by #747 # rather than inventing new numbers: a lease expires 10 minutes after its last # valid heartbeat, warns at half that, and an actively heartbeating session is # never evicted. The prior value was a fixed four hours (240 minutes) that no # heartbeat could shorten — the defect this issue exists to correct. _DEFAULTS: dict[str, LeasePolicy] = { TASK_CLASS_AUTHOR_ISSUE_WORK: LeasePolicy( task_class=TASK_CLASS_AUTHOR_ISSUE_WORK, initial_ttl_minutes=10.0, heartbeat_cadence_minutes=2.0, stale_warning_minutes=5.0, missed_heartbeat_grace_minutes=10.0, absolute_cap_hours=8.0, recovery_grace_minutes=10.0, terminal_race_drain_minutes=2.0, terminal_retirement_eligible=True, heartbeat_lifecycle_active=True, ), # Declared, not rewired. These mirror reviewer_pr_lease.LEASE_TTL_MINUTES # and STALE_WARNING_MINUTES; Slice C migrates the call sites. TASK_CLASS_REVIEWER_PR: LeasePolicy( task_class=TASK_CLASS_REVIEWER_PR, initial_ttl_minutes=10.0, heartbeat_cadence_minutes=2.0, stale_warning_minutes=5.0, missed_heartbeat_grace_minutes=10.0, absolute_cap_hours=None, recovery_grace_minutes=10.0, terminal_race_drain_minutes=2.0, terminal_retirement_eligible=False, heartbeat_lifecycle_active=False, ), TASK_CLASS_MERGER_PR: LeasePolicy( task_class=TASK_CLASS_MERGER_PR, initial_ttl_minutes=10.0, heartbeat_cadence_minutes=2.0, stale_warning_minutes=5.0, missed_heartbeat_grace_minutes=10.0, absolute_cap_hours=None, recovery_grace_minutes=10.0, terminal_race_drain_minutes=2.0, terminal_retirement_eligible=False, heartbeat_lifecycle_active=False, ), # Mirrors pr_work_lease.DEFAULT_CONFLICT_FIX_TTL_MINUTES. Deliberately left # at its current window; shortening it is Slice C's call, not this slice's. TASK_CLASS_CONFLICT_FIX: LeasePolicy( task_class=TASK_CLASS_CONFLICT_FIX, initial_ttl_minutes=120.0, heartbeat_cadence_minutes=2.0, stale_warning_minutes=5.0, missed_heartbeat_grace_minutes=10.0, absolute_cap_hours=None, recovery_grace_minutes=10.0, terminal_race_drain_minutes=2.0, terminal_retirement_eligible=False, heartbeat_lifecycle_active=False, ), } _NUMERIC_FIELDS = ( "initial_ttl_minutes", "heartbeat_cadence_minutes", "stale_warning_minutes", "missed_heartbeat_grace_minutes", "absolute_cap_hours", "recovery_grace_minutes", "terminal_race_drain_minutes", ) def env_var_name(task_class: str, field: str) -> str: """Environment variable that overrides one field of one task class.""" return f"{_ENV_PREFIX}_{task_class.upper()}_{field.upper()}" def _override(task_class: str, field: str, default: float | None) -> float | None: """Read one override, falling back to *default* on anything unusable. A malformed or non-positive override is ignored rather than raised: a typo in an environment variable must not be able to mint a zero-length lease that makes every claim instantly reclaimable, nor crash the server at import. """ raw = (os.environ.get(env_var_name(task_class, field)) or "").strip() if not raw: return default try: value = float(raw) except (TypeError, ValueError): return default if value <= 0: return default return value def policy_for(task_class: str) -> LeasePolicy: """Return the effective policy for *task_class*. Unknown task classes fall back to the author policy, which is the most conservative migrated class, rather than raising — a new caller must never be able to crash a lock write by naming a class this table has not learned. """ key = str(task_class or "").strip() or TASK_CLASS_AUTHOR_ISSUE_WORK base = _DEFAULTS.get(key) or _DEFAULTS[TASK_CLASS_AUTHOR_ISSUE_WORK] resolved = { field: _override(base.task_class, field, getattr(base, field)) for field in _NUMERIC_FIELDS } if all(resolved[field] == getattr(base, field) for field in _NUMERIC_FIELDS): return base return LeasePolicy( task_class=base.task_class, terminal_retirement_eligible=base.terminal_retirement_eligible, heartbeat_lifecycle_active=base.heartbeat_lifecycle_active, **resolved, ) def known_task_classes() -> tuple[str, ...]: """Every declared task class, migrated or not.""" return tuple(_DEFAULTS) def describe(task_class: str) -> dict[str, Any]: """Serializable view of a policy, for audit records and tool payloads.""" policy = policy_for(task_class) return { "task_class": policy.task_class, "initial_ttl_minutes": policy.initial_ttl_minutes, "heartbeat_cadence_minutes": policy.heartbeat_cadence_minutes, "stale_warning_minutes": policy.stale_warning_minutes, "missed_heartbeat_grace_minutes": policy.missed_heartbeat_grace_minutes, "absolute_cap_hours": policy.absolute_cap_hours, "recovery_grace_minutes": policy.recovery_grace_minutes, "terminal_race_drain_minutes": policy.terminal_race_drain_minutes, "terminal_retirement_eligible": policy.terminal_retirement_eligible, "heartbeat_lifecycle_active": policy.heartbeat_lifecycle_active, "lifecycle_version": LIFECYCLE_HEARTBEAT_V1, }