integ: merge ORCH-068 reconciler livelock fix
# Conflicts: # docs/architecture/README.md # src/reconciler.py
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Plane API sync — update issue state and add comments."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import httpx
|
||||
from .config import settings
|
||||
|
||||
@@ -172,18 +173,42 @@ _STATE_ALIAS_FALLBACK: dict[str, str] = {
|
||||
"monitoring": "done",
|
||||
}
|
||||
|
||||
# Per-project state cache: {project_id: {logical_key: state_uuid}}
|
||||
_STATES_CACHE: dict[str, dict[str, str]] = {}
|
||||
# Per-project state cache (ORCH-10 + ORCH-068).
|
||||
#
|
||||
# Each entry is a RECORD, not a bare mapping:
|
||||
# {"states": {logical_key: state_uuid}, # the ORCH-10 mapping (unchanged shape)
|
||||
# "groups": {state_uuid: group}, # ORCH-068 D1: {uuid -> Plane state.group}
|
||||
# "ts": monotonic timestamp} # ORCH-068 TR-4: for TTL self-heal
|
||||
# get_project_states() still RETURNS the bare {logical_key: state_uuid} mapping
|
||||
# (backward compatible — AC-13); the richer record is internal.
|
||||
_STATES_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _cache_record_fresh(record: dict) -> bool:
|
||||
"""ORCH-068 (TR-4): is a cache record still within its TTL?
|
||||
|
||||
``plane_states_ttl_s <= 0`` disables the TTL -> a record never expires
|
||||
(strictly the previous lifetime-cache behaviour, back-compat escape hatch).
|
||||
"""
|
||||
ttl = settings.plane_states_ttl_s
|
||||
if ttl <= 0:
|
||||
return True
|
||||
ts = record.get("ts", 0.0)
|
||||
return (time.monotonic() - ts) <= ttl
|
||||
|
||||
|
||||
def get_project_states(project_id: str) -> dict[str, str]:
|
||||
"""ORCH-10: resolve {logical_key -> state_uuid} for a specific Plane project.
|
||||
|
||||
Source of truth: Plane API GET /projects/<project_id>/states/.
|
||||
Results are cached per project_id for the lifetime of the process.
|
||||
Results are cached per project_id. ORCH-068 (TR-4): a cached entry is
|
||||
re-fetched once it is older than ``plane_states_ttl_s`` (default 300s) so a
|
||||
status added to Plane after start self-heals without a process restart;
|
||||
``plane_states_ttl_s = 0`` keeps the previous lifetime cache.
|
||||
|
||||
Falls back to _DEFAULT_STATES (enduro-trails values) if:
|
||||
* project_id is empty/None,
|
||||
* the API call fails (network error, non-2xx),
|
||||
* the API call fails (network error, non-2xx) AND nothing is cached,
|
||||
* the response contains no recognisable states.
|
||||
|
||||
The enduro-trails project therefore returns the same UUIDs as before
|
||||
@@ -193,8 +218,9 @@ def get_project_states(project_id: str) -> dict[str, str]:
|
||||
if not project_id:
|
||||
return _DEFAULT_STATES
|
||||
|
||||
if project_id in _STATES_CACHE:
|
||||
return _STATES_CACHE[project_id]
|
||||
cached = _STATES_CACHE.get(project_id)
|
||||
if cached is not None and _cache_record_fresh(cached):
|
||||
return cached["states"]
|
||||
|
||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/states/"
|
||||
try:
|
||||
@@ -207,12 +233,21 @@ def get_project_states(project_id: str) -> dict[str, str]:
|
||||
raise ValueError(f"unexpected states response shape: {type(items)}")
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
groups: dict[str, str] = {}
|
||||
for item in items:
|
||||
name = item.get("name", "")
|
||||
uid = item.get("id", "")
|
||||
key = _PLANE_NAME_TO_KEY.get(name)
|
||||
if key and uid:
|
||||
resolved[key] = uid
|
||||
# ORCH-068 D1: capture {uuid -> group} for terminal-state detection
|
||||
# (a single API fetch — no extra network cost). The group is the
|
||||
# authoritative, project-independent discriminator of terminal
|
||||
# (completed/cancelled) vs review/work statuses, robust to UUID
|
||||
# aliasing after status renames (ORCH-066).
|
||||
grp = item.get("group", "")
|
||||
if uid and grp:
|
||||
groups[uid] = grp
|
||||
|
||||
if not resolved:
|
||||
raise ValueError("no recognisable states in API response")
|
||||
@@ -232,13 +267,26 @@ def get_project_states(project_id: str) -> dict[str, str]:
|
||||
for k, v in _DEFAULT_STATES.items():
|
||||
resolved.setdefault(k, v)
|
||||
|
||||
_STATES_CACHE[project_id] = resolved
|
||||
_STATES_CACHE[project_id] = {
|
||||
"states": resolved,
|
||||
"groups": groups,
|
||||
"ts": time.monotonic(),
|
||||
}
|
||||
logger.debug(
|
||||
f"get_project_states: cached {len(resolved)} states for project {project_id[:8]}..."
|
||||
f"get_project_states: cached {len(resolved)} states / "
|
||||
f"{len(groups)} groups for project {project_id[:8]}..."
|
||||
)
|
||||
return resolved
|
||||
|
||||
except Exception as e:
|
||||
# On a transient API failure keep serving the stale (but project-correct)
|
||||
# set if we have one — far safer than reverting to enduro defaults.
|
||||
if cached is not None:
|
||||
logger.warning(
|
||||
f"get_project_states: API refresh failed for project "
|
||||
f"{project_id[:8]}..., serving stale cached set. Error: {e}"
|
||||
)
|
||||
return cached["states"]
|
||||
logger.warning(
|
||||
f"get_project_states: API failed for project {project_id[:8]}..., "
|
||||
f"falling back to _DEFAULT_STATES. Error: {e}"
|
||||
@@ -246,6 +294,23 @@ def get_project_states(project_id: str) -> dict[str, str]:
|
||||
return _DEFAULT_STATES
|
||||
|
||||
|
||||
def get_project_state_groups(project_id: str) -> dict[str, str]:
|
||||
"""ORCH-068 (D1): return {state_uuid -> group} for a Plane project.
|
||||
|
||||
Reads the SAME cache record populated by ``get_project_states`` (no extra
|
||||
network call). Call ``get_project_states(project_id)`` first to ensure the
|
||||
record is fresh/populated. Returns ``{}`` when nothing is cached (e.g. the
|
||||
API was unreachable and the caller fell back to ``_DEFAULT_STATES``); the
|
||||
reconciler then falls back to logical terminal keys.
|
||||
"""
|
||||
record = _STATES_CACHE.get(project_id)
|
||||
if isinstance(record, dict):
|
||||
groups = record.get("groups")
|
||||
if isinstance(groups, dict):
|
||||
return groups
|
||||
return {}
|
||||
|
||||
|
||||
def reload_project_states(project_id: str = None) -> None:
|
||||
"""ORCH-10: clear the per-project states cache.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user