feat(mcp-health): add MCP restart coordinator and impact analysis (Closes #658)
Child of umbrella #655 (governed MCP restart coordination); builds on the #657 restart-path inventory. Adds a central coordinator that evaluates live control-plane state before a restart and returns a blast-radius impact preview, so operators and the web console (#642/#652) can see what a restart would disrupt before concurrent LLM work is destroyed. Changes - restart_coordinator.py (new) — pure classification: inventory -> impact report DTO (RestartImpactReport/SessionImpact/LeaseImpact). Verdicts: safe / unsafe / override. Never restarts anything; fails closed on an incomplete inventory. - control_plane_db.py — additive ControlPlaneDB.list_sessions() read-only session inventory (the process-level unit a restart kills). - gitea_mcp_server.py — new dry-run MCP tool gitea_request_mcp_restart: gathers sessions/leases/terminal-lock from the #613 DB, calls the coordinator, returns the report. Override authority is read from the environment, never self-asserted (#630/#710 F1 pattern). Apply is gated by a later drain proof (non-goal here). - docs/mcp-restart-coordinator.md + docs/mcp-restart-impact-sample.json — doc and a real dry-run sample report. - docs/mcp-tool-inventory.md — register the new tool (inventory sync). - tests/test_restart_coordinator.py (new) — 15 tests: multi-session fixtures, deny-when-critical-section-open, fail-closed deny, override, terminal lock, stale heartbeat, JSON-serializable DTO, list_sessions. Tests: pytest tests/test_restart_coordinator.py -> 15 passed. Full suite: 13 failed / 4753 passed; all 13 reproduce identically on clean master @ef14622 (0 regressions). The residual test_issue_781 doc-registry failure is a pre-existing baseline gap for gitea_rebind_dirty_same_claimant_author_session (merged #864, undocumented on master) — out of scope for #658. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
"""MCP restart coordinator and impact analysis (#658).
|
||||
|
||||
Before any sanctioned MCP restart, a central coordinator must evaluate the
|
||||
live control-plane state — active sessions, leases/locks, in-flight issue/PR
|
||||
work, mutations, worktrees, and recovery history — and produce an *impact
|
||||
preview* so operators (and the web console, #642/#652) can see the blast
|
||||
radius **before** concurrent LLM work is disrupted.
|
||||
|
||||
Design rules (mirrors the read-only posture of ``workflow_dashboard`` /
|
||||
``lease_lifecycle``):
|
||||
|
||||
* **Pure classification.** :func:`evaluate_restart_impact` takes an already
|
||||
gathered inventory and returns a structured report. It never touches the
|
||||
network, the filesystem, or a live process, so multi-session fixtures can
|
||||
drive every branch in unit tests. The coordinator *never restarts anything*;
|
||||
a mutative apply path is a later child gated by a drain proof (non-goal here).
|
||||
* **Fail closed.** If the inventory is not explicitly complete, the verdict is
|
||||
``unsafe`` / deny — an incomplete evaluation must never green-light a restart.
|
||||
* **No secrets.** Session ids, pids, and profiles are operational metadata, not
|
||||
credentials; nothing secret flows through this module.
|
||||
|
||||
The single sanctioned entry point post-#657 is the MCP tool
|
||||
``gitea_request_mcp_restart`` (dry-run by default), which gathers the inventory
|
||||
from the #613 control-plane DB and calls :func:`evaluate_restart_impact`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import lease_lifecycle
|
||||
|
||||
COORDINATOR_VERSION = "1.0.0-issue-658"
|
||||
|
||||
# Restart verdicts. Exactly the three the acceptance criteria name.
|
||||
VERDICT_SAFE = "safe"
|
||||
VERDICT_UNSAFE = "unsafe"
|
||||
VERDICT_OVERRIDE = "override"
|
||||
|
||||
# Blast-radius severity bands.
|
||||
BLAST_NONE = "none"
|
||||
BLAST_LOW = "low"
|
||||
BLAST_MEDIUM = "medium"
|
||||
BLAST_HIGH = "high"
|
||||
|
||||
# A live lease with a live owner process is treated as active in-flight work.
|
||||
LEASE_FRESHNESS_LIVE = "active"
|
||||
|
||||
# Default staleness window for a session heartbeat (seconds). A session whose
|
||||
# last heartbeat is older than this is not counted as live even if its row is
|
||||
# still marked ``active`` — it is assumed dead/detached.
|
||||
DEFAULT_SESSION_HEARTBEAT_STALE_SECONDS = 900
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_ts(value: str | None) -> datetime | None:
|
||||
return lease_lifecycle._parse_ts(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionImpact:
|
||||
"""One MCP session a restart would terminate."""
|
||||
|
||||
session_id: str
|
||||
role: str | None
|
||||
profile: str | None
|
||||
pid: int | None
|
||||
status: str | None
|
||||
alive: bool | None
|
||||
heartbeat_stale: bool
|
||||
is_requester: bool
|
||||
live: bool
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"role": self.role,
|
||||
"profile": self.profile,
|
||||
"pid": self.pid,
|
||||
"status": self.status,
|
||||
"alive": self.alive,
|
||||
"heartbeat_stale": self.heartbeat_stale,
|
||||
"is_requester": self.is_requester,
|
||||
"live": self.live,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LeaseImpact:
|
||||
"""One control-plane lease a restart would disrupt."""
|
||||
|
||||
lease_id: str | None
|
||||
session_id: str | None
|
||||
role: str | None
|
||||
phase: str | None
|
||||
freshness: str | None
|
||||
work_kind: str | None
|
||||
work_number: int | None
|
||||
worktree_path: str | None
|
||||
disruptive: bool
|
||||
is_mutation: bool
|
||||
is_critical_section: bool
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"lease_id": self.lease_id,
|
||||
"session_id": self.session_id,
|
||||
"role": self.role,
|
||||
"phase": self.phase,
|
||||
"freshness": self.freshness,
|
||||
"work_kind": self.work_kind,
|
||||
"work_number": self.work_number,
|
||||
"worktree_path": self.worktree_path,
|
||||
"disruptive": self.disruptive,
|
||||
"is_mutation": self.is_mutation,
|
||||
"is_critical_section": self.is_critical_section,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RestartImpactReport:
|
||||
"""Impact preview DTO returned to the console / operator (#642/#652)."""
|
||||
|
||||
coordinator_version: str
|
||||
evaluated_at: str
|
||||
dry_run: bool
|
||||
restart_performed: bool
|
||||
inventory_complete: bool
|
||||
verdict: str
|
||||
allow_restart: bool
|
||||
override_would_allow: bool
|
||||
operator_override: bool
|
||||
blast_radius: str
|
||||
reasons: list[str]
|
||||
affected_sessions: list[SessionImpact]
|
||||
affected_leases: list[LeaseImpact]
|
||||
critical_sections: list[LeaseImpact]
|
||||
affected_issues: list[int]
|
||||
affected_prs: list[int]
|
||||
mutations: list[LeaseImpact]
|
||||
terminal_lock: dict[str, Any] | None
|
||||
ack_state: dict[str, str]
|
||||
prior_recovery_attempts: list[dict[str, Any]]
|
||||
counts: dict[str, int]
|
||||
audit_record: dict[str, Any]
|
||||
incomplete_reasons: list[str] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"coordinator_version": self.coordinator_version,
|
||||
"evaluated_at": self.evaluated_at,
|
||||
"dry_run": self.dry_run,
|
||||
"restart_performed": self.restart_performed,
|
||||
"inventory_complete": self.inventory_complete,
|
||||
"incomplete_reasons": list(self.incomplete_reasons),
|
||||
"verdict": self.verdict,
|
||||
"allow_restart": self.allow_restart,
|
||||
"override_would_allow": self.override_would_allow,
|
||||
"operator_override": self.operator_override,
|
||||
"blast_radius": self.blast_radius,
|
||||
"reasons": list(self.reasons),
|
||||
"affected_sessions": [s.as_dict() for s in self.affected_sessions],
|
||||
"affected_leases": [l.as_dict() for l in self.affected_leases],
|
||||
"critical_sections": [l.as_dict() for l in self.critical_sections],
|
||||
"affected_issues": list(self.affected_issues),
|
||||
"affected_prs": list(self.affected_prs),
|
||||
"mutations": [l.as_dict() for l in self.mutations],
|
||||
"terminal_lock": self.terminal_lock,
|
||||
"ack_state": dict(self.ack_state),
|
||||
"prior_recovery_attempts": list(self.prior_recovery_attempts),
|
||||
"counts": dict(self.counts),
|
||||
"audit_record": dict(self.audit_record),
|
||||
}
|
||||
|
||||
|
||||
def _classify_session(
|
||||
row: Mapping[str, Any],
|
||||
*,
|
||||
now: datetime,
|
||||
requesting_session_id: str | None,
|
||||
heartbeat_stale_seconds: int,
|
||||
) -> SessionImpact:
|
||||
session_id = str(row.get("session_id") or "")
|
||||
pid = row.get("pid")
|
||||
status = (row.get("status") or "").strip().lower() or None
|
||||
alive = lease_lifecycle.is_process_alive(pid) if pid is not None else None
|
||||
hb = _parse_ts(row.get("last_heartbeat_at"))
|
||||
heartbeat_stale = bool(
|
||||
hb is not None and (now - hb).total_seconds() > heartbeat_stale_seconds
|
||||
)
|
||||
live = bool(status == "active" and alive is not False and not heartbeat_stale)
|
||||
return SessionImpact(
|
||||
session_id=session_id,
|
||||
role=row.get("role"),
|
||||
profile=row.get("profile"),
|
||||
pid=pid,
|
||||
status=status,
|
||||
alive=alive,
|
||||
heartbeat_stale=heartbeat_stale,
|
||||
is_requester=bool(
|
||||
requesting_session_id and session_id == requesting_session_id
|
||||
),
|
||||
live=live,
|
||||
)
|
||||
|
||||
|
||||
# Lease phases that represent an active mutation in flight (as opposed to a
|
||||
# mere allocation/claim with no work committed yet). An active lease in any of
|
||||
# these phases is a critical section a restart must not sever.
|
||||
_MUTATING_PHASES = frozenset(
|
||||
{
|
||||
"implementing",
|
||||
"publishing",
|
||||
"pushing",
|
||||
"committing",
|
||||
"reviewing",
|
||||
"merging",
|
||||
"reconciling",
|
||||
"conflict_fix",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _classify_lease(row: Mapping[str, Any]) -> LeaseImpact:
|
||||
freshness_obj = row.get("freshness")
|
||||
if isinstance(freshness_obj, Mapping):
|
||||
freshness = str(freshness_obj.get("freshness") or "").strip().lower() or None
|
||||
else:
|
||||
freshness = str(freshness_obj or "").strip().lower() or None
|
||||
phase = (row.get("phase") or "").strip().lower() or None
|
||||
worktree = row.get("worktree_path")
|
||||
disruptive = freshness == LEASE_FRESHNESS_LIVE
|
||||
# A live lease is a mutation-in-flight if it carries an author worktree or
|
||||
# its phase names a mutating step. All disruptive leases are critical
|
||||
# sections a restart would sever regardless.
|
||||
is_mutation = bool(
|
||||
disruptive and (bool(worktree) or (phase in _MUTATING_PHASES))
|
||||
)
|
||||
number = row.get("work_number")
|
||||
try:
|
||||
number = int(number) if number is not None else None
|
||||
except (TypeError, ValueError):
|
||||
number = None
|
||||
return LeaseImpact(
|
||||
lease_id=row.get("lease_id"),
|
||||
session_id=row.get("session_id"),
|
||||
role=row.get("role"),
|
||||
phase=phase,
|
||||
freshness=freshness,
|
||||
work_kind=(str(row.get("work_kind") or "").strip().lower() or None),
|
||||
work_number=number,
|
||||
worktree_path=worktree,
|
||||
disruptive=disruptive,
|
||||
is_mutation=is_mutation,
|
||||
is_critical_section=disruptive,
|
||||
)
|
||||
|
||||
|
||||
def _blast_radius(*, session_count: int, work_count: int, mutation_count: int) -> str:
|
||||
if mutation_count > 0 or work_count >= 3 or session_count >= 3:
|
||||
return BLAST_HIGH
|
||||
if work_count > 0 or session_count == 2:
|
||||
return BLAST_MEDIUM
|
||||
if session_count == 1:
|
||||
return BLAST_LOW
|
||||
return BLAST_NONE
|
||||
|
||||
|
||||
def evaluate_restart_impact(
|
||||
inventory: Mapping[str, Any],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
operator_override: bool = False,
|
||||
requesting_session_id: str | None = None,
|
||||
dry_run: bool = True,
|
||||
session_heartbeat_stale_seconds: int = DEFAULT_SESSION_HEARTBEAT_STALE_SECONDS,
|
||||
) -> RestartImpactReport:
|
||||
"""Evaluate a proposed MCP restart and return an impact preview.
|
||||
|
||||
``inventory`` is a mapping with:
|
||||
|
||||
* ``sessions`` — session rows (session_id, role, profile, pid, status,
|
||||
last_heartbeat_at).
|
||||
* ``leases`` — control-plane lease rows, each ideally carrying an enriched
|
||||
``freshness`` dict (as :func:`lease_lifecycle.list_active_leases` returns);
|
||||
a bare string freshness is also accepted.
|
||||
* ``terminal_lock`` — the active terminal (merge) lock, if any.
|
||||
* ``prior_recovery_attempts`` — narrower recovery attempts already tried
|
||||
(e.g. sanctioned reconnects) so the operator sees escalation history.
|
||||
* ``inventory_complete`` — bool. **Must** be explicitly True; a missing or
|
||||
falsy value forces a deny (fail closed).
|
||||
* ``incomplete_reasons`` — optional reasons the inventory is incomplete.
|
||||
|
||||
The coordinator never restarts anything: ``restart_performed`` is always
|
||||
False and the mutative apply path is a later drain-gated child.
|
||||
"""
|
||||
moment = now or _utc_now()
|
||||
reasons: list[str] = []
|
||||
|
||||
inventory_complete = bool(inventory.get("inventory_complete", False))
|
||||
incomplete_reasons = [str(r) for r in (inventory.get("incomplete_reasons") or [])]
|
||||
|
||||
sessions_raw: Sequence[Mapping[str, Any]] = inventory.get("sessions") or []
|
||||
leases_raw: Sequence[Mapping[str, Any]] = inventory.get("leases") or []
|
||||
terminal_lock = inventory.get("terminal_lock") or None
|
||||
prior_recovery_attempts = [
|
||||
dict(a) for a in (inventory.get("prior_recovery_attempts") or [])
|
||||
]
|
||||
|
||||
session_impacts = [
|
||||
_classify_session(
|
||||
s,
|
||||
now=moment,
|
||||
requesting_session_id=requesting_session_id,
|
||||
heartbeat_stale_seconds=session_heartbeat_stale_seconds,
|
||||
)
|
||||
for s in sessions_raw
|
||||
]
|
||||
lease_impacts = [_classify_lease(l) for l in leases_raw]
|
||||
|
||||
# Only *other* live sessions and live leases constitute blast radius: a
|
||||
# restart that would kill only the requesting session with no other work in
|
||||
# flight is safe.
|
||||
other_live_sessions = [
|
||||
s for s in session_impacts if s.live and not s.is_requester
|
||||
]
|
||||
disruptive_leases = [l for l in lease_impacts if l.disruptive]
|
||||
critical_sections = [l for l in lease_impacts if l.is_critical_section]
|
||||
mutations = [l for l in lease_impacts if l.is_mutation]
|
||||
|
||||
affected_issues = sorted(
|
||||
{
|
||||
l.work_number
|
||||
for l in disruptive_leases
|
||||
if l.work_kind == "issue" and l.work_number is not None
|
||||
}
|
||||
)
|
||||
affected_prs = sorted(
|
||||
{
|
||||
l.work_number
|
||||
for l in disruptive_leases
|
||||
if l.work_kind == "pr" and l.work_number is not None
|
||||
}
|
||||
)
|
||||
|
||||
disruptive = bool(disruptive_leases or other_live_sessions or terminal_lock)
|
||||
|
||||
if not inventory_complete:
|
||||
verdict = VERDICT_UNSAFE
|
||||
allow_restart = False
|
||||
reasons.append(
|
||||
"inventory incomplete: restart evaluation cannot confirm blast "
|
||||
"radius — deny (fail closed, #658)"
|
||||
)
|
||||
reasons.extend(incomplete_reasons)
|
||||
elif not disruptive:
|
||||
verdict = VERDICT_SAFE
|
||||
allow_restart = True
|
||||
reasons.append("no other live sessions, live leases, or terminal lock")
|
||||
elif operator_override:
|
||||
verdict = VERDICT_OVERRIDE
|
||||
allow_restart = True
|
||||
reasons.append(
|
||||
"live work present; operator override accepts the blast radius"
|
||||
)
|
||||
else:
|
||||
verdict = VERDICT_UNSAFE
|
||||
allow_restart = False
|
||||
reasons.append(
|
||||
"live work would be disrupted; restart denied without operator "
|
||||
"override"
|
||||
)
|
||||
|
||||
if critical_sections and inventory_complete:
|
||||
reasons.append(
|
||||
f"{len(critical_sections)} critical section(s) in flight "
|
||||
"(active lease with a live owner)"
|
||||
)
|
||||
if terminal_lock:
|
||||
reasons.append("active terminal (merge) lock present")
|
||||
|
||||
override_would_allow = bool(inventory_complete and disruptive)
|
||||
|
||||
blast_radius = _blast_radius(
|
||||
session_count=len(other_live_sessions),
|
||||
work_count=len(affected_issues) + len(affected_prs),
|
||||
mutation_count=len(mutations),
|
||||
)
|
||||
|
||||
# Acknowledgement is a later child (drain protocol); expose per-session
|
||||
# placeholders so the console can render the ack column now.
|
||||
ack_state = {s.session_id: "pending" for s in other_live_sessions}
|
||||
|
||||
counts = {
|
||||
"sessions_total": len(session_impacts),
|
||||
"sessions_live_other": len(other_live_sessions),
|
||||
"leases_total": len(lease_impacts),
|
||||
"leases_disruptive": len(disruptive_leases),
|
||||
"critical_sections": len(critical_sections),
|
||||
"mutations": len(mutations),
|
||||
"affected_issues": len(affected_issues),
|
||||
"affected_prs": len(affected_prs),
|
||||
"prior_recovery_attempts": len(prior_recovery_attempts),
|
||||
}
|
||||
|
||||
audit_record = {
|
||||
"event": "restart_impact_evaluated",
|
||||
"coordinator_version": COORDINATOR_VERSION,
|
||||
"evaluated_at": moment.isoformat(),
|
||||
"dry_run": dry_run,
|
||||
"operator_override": bool(operator_override),
|
||||
"requesting_session_id": requesting_session_id,
|
||||
"inventory_complete": inventory_complete,
|
||||
"verdict": verdict,
|
||||
"allow_restart": allow_restart,
|
||||
"blast_radius": blast_radius,
|
||||
"counts": counts,
|
||||
}
|
||||
|
||||
return RestartImpactReport(
|
||||
coordinator_version=COORDINATOR_VERSION,
|
||||
evaluated_at=moment.isoformat(),
|
||||
dry_run=dry_run,
|
||||
restart_performed=False,
|
||||
inventory_complete=inventory_complete,
|
||||
verdict=verdict,
|
||||
allow_restart=allow_restart,
|
||||
override_would_allow=override_would_allow,
|
||||
operator_override=bool(operator_override),
|
||||
blast_radius=blast_radius,
|
||||
reasons=reasons,
|
||||
affected_sessions=session_impacts,
|
||||
affected_leases=lease_impacts,
|
||||
critical_sections=critical_sections,
|
||||
affected_issues=affected_issues,
|
||||
affected_prs=affected_prs,
|
||||
mutations=mutations,
|
||||
terminal_lock=dict(terminal_lock)
|
||||
if isinstance(terminal_lock, Mapping)
|
||||
else terminal_lock,
|
||||
ack_state=ack_state,
|
||||
prior_recovery_attempts=prior_recovery_attempts,
|
||||
counts=counts,
|
||||
audit_record=audit_record,
|
||||
incomplete_reasons=incomplete_reasons,
|
||||
)
|
||||
Reference in New Issue
Block a user