"""Scoped MCP recovery playbook (#669). Operational recovery must prefer the *narrowest* action that can fix the symptom. Full MCP / host restarts are last-resort rungs on a documented ladder; the coordinator refuses those rungs unless a prior attempt log shows narrower recoveries already failed (or break-glass is authorized). This module is pure classification and recommendation: * No network, filesystem, or process I/O. * Never restarts anything. * Narrow recovery *execution* is delegated to existing tools/docs (linked per rung) — the playbook records which rung to try next and whether escalation to a broad restart is allowed. Design lineage: umbrella #655, class matrix #663, coordinator #658, auto-reconnect #584, stale-runtime #610, contamination #630, audit #665. Vision #652 / roadmap #653. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum from typing import Any, Mapping, Sequence PLAYBOOK_VERSION = "1.0.0-issue-669" # Attempt outcomes that count as "tried and insufficient" for escalation. INSUFFICIENT_OUTCOMES = frozenset( { "failed", "insufficient", "denied", "unresolved", "timeout", "error", } ) # Break-glass / operator override still records that the ladder was skipped. OUTCOME_BREAK_GLASS = "break_glass" OUTCOME_SUCCESS = "success" OUTCOME_SKIPPED = "skipped" class RecoveryAction(str, Enum): """Ordered recovery ladder (narrow → broad).""" CLIENT_RECONNECT = "client_reconnect" CAPABILITY_REFRESH = "capability_refresh" SESSION_RECONNECT = "session_reconnect" CONFIGURATION_RELOAD = "configuration_reload" LEASE_RECOVERY = "lease_recovery" WORKER_RESTART = "worker_restart" ROLE_RUNTIME_RESTART = "role_runtime_restart" CONNECTOR_RESTART = "connector_restart" ROLLING_MCP_RESTART = "rolling_mcp_restart" FULL_MCP_RESTART = "full_mcp_restart" HOST_RESTART = "host_restart" # Classes that require a prior narrow-attempt log (unless break-glass). BROAD_RESTART_ACTIONS: frozenset[RecoveryAction] = frozenset( { RecoveryAction.ROLLING_MCP_RESTART, RecoveryAction.FULL_MCP_RESTART, RecoveryAction.HOST_RESTART, } ) # Map #663 restart_class strings onto playbook actions. RESTART_CLASS_TO_ACTION: dict[str, RecoveryAction] = { "client_reconnect": RecoveryAction.CLIENT_RECONNECT, "session_reconnect": RecoveryAction.SESSION_RECONNECT, "configuration_reload": RecoveryAction.CONFIGURATION_RELOAD, "worker_restart": RecoveryAction.WORKER_RESTART, "role_runtime_restart": RecoveryAction.ROLE_RUNTIME_RESTART, "connector_restart": RecoveryAction.CONNECTOR_RESTART, "rolling_mcp_restart": RecoveryAction.ROLLING_MCP_RESTART, "full_mcp_restart": RecoveryAction.FULL_MCP_RESTART, "host_restart": RecoveryAction.HOST_RESTART, } @dataclass(frozen=True) class RecoveryRung: """One rung on the recovery ladder.""" action: RecoveryAction rank: int summary: str # Existing implementation or explicit delegation target. implementation: str issue_links: tuple[str, ...] self_service: bool # Restart-class permission when this rung is requested via coordinator. restart_class: str | None = None def as_dict(self) -> dict[str, Any]: return { "action": self.action.value, "rank": self.rank, "summary": self.summary, "implementation": self.implementation, "issue_links": list(self.issue_links), "self_service": self.self_service, "restart_class": self.restart_class, } # Canonical ladder. Rank 0 is narrowest. RECOVERY_LADDER: tuple[RecoveryRung, ...] = ( RecoveryRung( RecoveryAction.CLIENT_RECONNECT, 0, "Reconnect the IDE/client MCP transport (EOF / transport flap).", "Host auto-reconnect or explicit client reconnect; " "docs/mcp-namespace-eof-recovery.md", ("#584", "#655"), True, "client_reconnect", ), RecoveryRung( RecoveryAction.CAPABILITY_REFRESH, 1, "Re-resolve task capability and clear stale permission context.", "Delegated: gitea_resolve_task_capability + gitea_whoami " "(no process change).", ("#610", "#685", "#655"), True, None, ), RecoveryRung( RecoveryAction.SESSION_RECONNECT, 2, "Rebind identity, workspace, and namespace for one session.", "Delegated: gitea_get_runtime_context + explicit worktree_path " "rebind (#618); docs/mcp-namespace-health.md", ("#543", "#618", "#655"), True, "session_reconnect", ), RecoveryRung( RecoveryAction.CONFIGURATION_RELOAD, 3, "Gracefully reload configuration without replacing the daemon.", "restart_coordinator class configuration_reload; console " "system.reload_namespace (#642).", ("#642", "#663", "#655"), False, "configuration_reload", ), RecoveryRung( RecoveryAction.LEASE_RECOVERY, 4, "Recover or rebind stale leases/locks without a process restart.", "Delegated: issue lock recovery / lease lifecycle paths " "(#702, #753, #790).", ("#702", "#753", "#790", "#655"), False, None, ), RecoveryRung( RecoveryAction.WORKER_RESTART, 5, "Restart one worker after its own lease and mutation scope drains.", "restart_coordinator class worker_restart (#663).", ("#663", "#655"), False, "worker_restart", ), RecoveryRung( RecoveryAction.ROLE_RUNTIME_RESTART, 6, "Restart one role runtime and re-probe that namespace only.", "restart_coordinator class role_runtime_restart; console " "system.restart_namespace (#642).", ("#642", "#663", "#655"), False, "role_runtime_restart", ), RecoveryRung( RecoveryAction.CONNECTOR_RESTART, 7, "Restart one connector while unrelated runtimes stay available.", "restart_coordinator class connector_restart (#663).", ("#663", "#655"), False, "connector_restart", ), RecoveryRung( RecoveryAction.ROLLING_MCP_RESTART, 8, "Drain/restart/verify one instance at a time (HA path).", "restart_coordinator class rolling_mcp_restart; design #668.", ("#668", "#663", "#655"), False, "rolling_mcp_restart", ), RecoveryRung( RecoveryAction.FULL_MCP_RESTART, 9, "Full stable-control MCP process restart after verified full drain.", "restart_coordinator class full_mcp_restart; requires attempt log " "unless break-glass (#669).", ("#658", "#661", "#663", "#669", "#655"), False, "full_mcp_restart", ), RecoveryRung( RecoveryAction.HOST_RESTART, 10, "Host/infrastructure restart — broadest last-resort action.", "restart_coordinator class host_restart; operator-owned.", ("#663", "#669", "#655"), False, "host_restart", ), ) _LADDER_BY_ACTION: dict[RecoveryAction, RecoveryRung] = { rung.action: rung for rung in RECOVERY_LADDER } # Symptom tokens → preferred first rung (decision tree, #663 lineage). SYMPTOM_TO_FIRST_ACTION: dict[str, RecoveryAction] = { "transport_eof": RecoveryAction.CLIENT_RECONNECT, "client_closing_eof": RecoveryAction.CLIENT_RECONNECT, "transport_flap": RecoveryAction.CLIENT_RECONNECT, "namespace_disconnected": RecoveryAction.CLIENT_RECONNECT, "stale_capability": RecoveryAction.CAPABILITY_REFRESH, "permission_stale": RecoveryAction.CAPABILITY_REFRESH, "runtime_reconnect_required": RecoveryAction.CAPABILITY_REFRESH, "stale_runtime": RecoveryAction.SESSION_RECONNECT, "worktree_unbound": RecoveryAction.SESSION_RECONNECT, "namespace_unhealthy": RecoveryAction.SESSION_RECONNECT, "config_drift": RecoveryAction.CONFIGURATION_RELOAD, "profile_misbound": RecoveryAction.CONFIGURATION_RELOAD, "stale_lease": RecoveryAction.LEASE_RECOVERY, "dead_pid_lock": RecoveryAction.LEASE_RECOVERY, "orphan_worktree": RecoveryAction.LEASE_RECOVERY, "single_worker_stuck": RecoveryAction.WORKER_RESTART, "role_runtime_dead": RecoveryAction.ROLE_RUNTIME_RESTART, "connector_dead": RecoveryAction.CONNECTOR_RESTART, "ha_instance_unhealthy": RecoveryAction.ROLLING_MCP_RESTART, "daemon_corrupt": RecoveryAction.FULL_MCP_RESTART, "full_process_deadlock": RecoveryAction.FULL_MCP_RESTART, "host_unresponsive": RecoveryAction.HOST_RESTART, } def _utc_now() -> datetime: return datetime.now(timezone.utc) def resolve_action(value: RecoveryAction | str) -> RecoveryAction: """Resolve a recovery action or fail closed for unknown values.""" if isinstance(value, RecoveryAction): return value text = str(value or "").strip() # Accept #663 restart_class aliases. if text in RESTART_CLASS_TO_ACTION: return RESTART_CLASS_TO_ACTION[text] try: return RecoveryAction(text) except ValueError as exc: raise ValueError( f"unknown recovery action {value!r}; deny (fail closed, #669)" ) from exc def ladder_rank(action: RecoveryAction | str) -> int: resolved = resolve_action(action) return _LADDER_BY_ACTION[resolved].rank def rung_for(action: RecoveryAction | str) -> RecoveryRung: return _LADDER_BY_ACTION[resolve_action(action)] def normalize_attempt(raw: Mapping[str, Any]) -> dict[str, Any] | None: """Normalize one prior-recovery attempt record; return None if unusable.""" if not isinstance(raw, Mapping): return None action_raw = raw.get("action") or raw.get("recovery_action") or raw.get( "restart_class" ) if not action_raw: return None try: action = resolve_action(str(action_raw)) except ValueError: return None outcome = str( raw.get("outcome") or raw.get("status") or raw.get("result") or "" ).strip().lower() if not outcome: return None recorded_at = raw.get("recorded_at") or raw.get("at") or raw.get("timestamp") reason = str(raw.get("reason") or raw.get("detail") or "").strip() actor = str(raw.get("actor") or raw.get("session_id") or "").strip() return { "action": action.value, "outcome": outcome, "reason": reason, "actor": actor, "recorded_at": recorded_at, "rank": ladder_rank(action), "raw": dict(raw), } def normalize_attempt_log( attempts: Sequence[Mapping[str, Any]] | None, ) -> list[dict[str, Any]]: """Return usable attempt records in ladder order.""" out: list[dict[str, Any]] = [] for raw in attempts or (): norm = normalize_attempt(raw) if norm is not None: out.append(norm) out.sort(key=lambda a: (a["rank"], str(a.get("recorded_at") or ""))) return out def narrower_insufficient_attempts( attempts: Sequence[Mapping[str, Any]] | None, *, requested: RecoveryAction | str, ) -> list[dict[str, Any]]: """Return prior attempts narrower than *requested* that were insufficient.""" target_rank = ladder_rank(requested) usable = [] for attempt in normalize_attempt_log(attempts): if attempt["rank"] >= target_rank: continue if attempt["outcome"] in INSUFFICIENT_OUTCOMES: usable.append(attempt) return usable @dataclass(frozen=True) class EscalationAssessment: """Whether a requested broad recovery may proceed given the attempt log.""" requested_action: str allowed: bool require_attempt_log: bool break_glass: bool reasons: list[str] = field(default_factory=list) qualifying_attempts: list[dict[str, Any]] = field(default_factory=list) recommended_next: list[dict[str, Any]] = field(default_factory=list) playbook_version: str = PLAYBOOK_VERSION def as_dict(self) -> dict[str, Any]: return { "playbook_version": self.playbook_version, "requested_action": self.requested_action, "allowed": self.allowed, "require_attempt_log": self.require_attempt_log, "break_glass": self.break_glass, "reasons": list(self.reasons), "qualifying_attempts": list(self.qualifying_attempts), "recommended_next": list(self.recommended_next), } def assess_escalation( requested: RecoveryAction | str, *, prior_recovery_attempts: Sequence[Mapping[str, Any]] | None = None, break_glass: bool = False, ) -> EscalationAssessment: """Gate broad restarts on a prior narrow-attempt log (#669 AC3). Narrow / mid-ladder actions do not require a prior attempt log. ``full_mcp_restart``, ``host_restart``, and ``rolling_mcp_restart`` require at least one *insufficient* narrower attempt unless ``break_glass`` is true. """ action = resolve_action(requested) require_log = action in BROAD_RESTART_ACTIONS reasons: list[str] = [] qualifying = narrower_insufficient_attempts( prior_recovery_attempts, requested=action ) if not require_log: return EscalationAssessment( requested_action=action.value, allowed=True, require_attempt_log=False, break_glass=bool(break_glass), reasons=["narrow recovery; attempt log not required"], qualifying_attempts=qualifying, recommended_next=[], ) if break_glass: return EscalationAssessment( requested_action=action.value, allowed=True, require_attempt_log=True, break_glass=True, reasons=[ "break-glass authorized; broad restart permitted without " "narrow-attempt log (#669)" ], qualifying_attempts=qualifying, recommended_next=[], ) if qualifying: return EscalationAssessment( requested_action=action.value, allowed=True, require_attempt_log=True, break_glass=False, reasons=[ f"{len(qualifying)} narrower recovery attempt(s) recorded as " "insufficient; escalation permitted" ], qualifying_attempts=qualifying, recommended_next=[], ) # Deny: recommend the next untried narrow rung(s). recommended = recommend_actions( symptoms=(), prior_recovery_attempts=prior_recovery_attempts, max_actions=3, ) reasons.append( f"{action.value} requires a prior attempt log of insufficient " "narrower recoveries (or break-glass); none found — deny (fail " "closed, #669)" ) return EscalationAssessment( requested_action=action.value, allowed=False, require_attempt_log=True, break_glass=False, reasons=reasons, qualifying_attempts=[], recommended_next=recommended.get("recommended_actions") or [], ) def recommend_actions( *, symptoms: Sequence[str] = (), prior_recovery_attempts: Sequence[Mapping[str, Any]] | None = None, max_actions: int = 5, ) -> dict[str, Any]: """Return ordered recommended recovery actions for the given symptoms. Soft mode (rollout): recommendations only — callers decide whether to hard-gate. Hard mode for broad restarts is :func:`assess_escalation`. """ attempted_success = { a["action"] for a in normalize_attempt_log(prior_recovery_attempts) if a["outcome"] == OUTCOME_SUCCESS } attempted_any = { a["action"] for a in normalize_attempt_log(prior_recovery_attempts) } first_actions: list[RecoveryAction] = [] for symptom in symptoms: key = str(symptom or "").strip().lower().replace(" ", "_").replace("-", "_") mapped = SYMPTOM_TO_FIRST_ACTION.get(key) if mapped is not None and mapped not in first_actions: first_actions.append(mapped) # Default entry: client reconnect then walk the ladder. if not first_actions: first_actions = [RecoveryAction.CLIENT_RECONNECT] recommended: list[dict[str, Any]] = [] seen: set[str] = set() min_rank = min(ladder_rank(a) for a in first_actions) for rung in RECOVERY_LADDER: if rung.rank < min_rank: continue if rung.action.value in attempted_success: continue if rung.action.value in seen: continue # Prefer rungs not yet attempted; still list previously-failed ones # only if nothing else remains. entry = rung.as_dict() entry["already_attempted"] = rung.action.value in attempted_any recommended.append(entry) seen.add(rung.action.value) if len(recommended) >= max(1, int(max_actions)): break return { "playbook_version": PLAYBOOK_VERSION, "symptoms": [str(s) for s in symptoms], "recommended_actions": recommended, "ladder": [r.as_dict() for r in RECOVERY_LADDER], "read_only": True, "hard_gate_note": ( "Broad restarts (rolling/full/host) still require " "assess_escalation / coordinator attempt-log enforcement." ), } def build_attempt_record( action: RecoveryAction | str, *, outcome: str, reason: str = "", actor: str = "", recorded_at: str | None = None, extra: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Build a durable-shaped attempt log entry for inventory/audit (#665).""" resolved = resolve_action(action) record = { "action": resolved.value, "outcome": str(outcome or "").strip().lower(), "reason": str(reason or "").strip(), "actor": str(actor or "").strip(), "recorded_at": recorded_at or _utc_now().isoformat(), "rank": ladder_rank(resolved), "playbook_version": PLAYBOOK_VERSION, } if extra: record["extra"] = dict(extra) return record def recovery_metrics( attempts: Sequence[Mapping[str, Any]] | None, ) -> dict[str, Any]: """Compute the fraction of recoveries that avoided full/host restart. A recovery *episode* is approximated as one attempt with ``outcome=success``. Successes on non-broad rungs count as avoided full restart; successes on full/host count as full-restart recoveries. """ norms = normalize_attempt_log(attempts) successes = [a for a in norms if a["outcome"] == OUTCOME_SUCCESS] broad_success = [ a for a in successes if resolve_action(a["action"]) in {RecoveryAction.FULL_MCP_RESTART, RecoveryAction.HOST_RESTART} ] avoided = [a for a in successes if a not in broad_success] total = len(successes) fraction_avoided = (len(avoided) / total) if total else None return { "playbook_version": PLAYBOOK_VERSION, "attempts_total": len(norms), "successes_total": total, "successes_avoided_full_restart": len(avoided), "successes_full_or_host_restart": len(broad_success), "fraction_avoided_full_restart": fraction_avoided, "insufficient_attempts": sum( 1 for a in norms if a["outcome"] in INSUFFICIENT_OUTCOMES ), } def ladder_document() -> dict[str, Any]: """Machine-readable ladder for docs/tools inventory.""" return { "playbook_version": PLAYBOOK_VERSION, "parent_issues": ["#655", "#652", "#653"], "enforcement_issue": "#669", "ladder": [r.as_dict() for r in RECOVERY_LADDER], "broad_restart_actions": [a.value for a in sorted(BROAD_RESTART_ACTIONS, key=lambda x: x.value)], "insufficient_outcomes": sorted(INSUFFICIENT_OUTCOMES), "symptom_map": {k: v.value for k, v in sorted(SYMPTOM_TO_FIRST_ACTION.items())}, }