feat(mcp): post-restart reconciliation and completion proof (Closes #662)
Add pure post_restart_reconcile.reconcile_after_restart classifier with a machine-readable completion proof covering service health, sessions, leases, capabilities, worktrees, interrupted mutations (never auto-resumed), duplicates, and queue state. Soft-depends on #660 checkpoints (skipped with reason when the schema module is absent). Wire read-only MCP tool gitea_reconcile_after_restart, boot-once hook via gitea_assess_master_parity, log_only/enforce modes (mutation_hold), and docs. Closes #662 Related: #655 #652 #653 #660 #661 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -17880,6 +17880,17 @@ def gitea_assess_master_parity(
|
||||
}
|
||||
if parity["restart_required"] and enforced:
|
||||
out["report"] = master_parity_gate.parity_report(parity)
|
||||
# #662 AC1: first post-restart master-parity probe also runs the boot
|
||||
# reconcile once (log-only by default; never raises).
|
||||
boot_proof = _ensure_boot_post_restart_reconcile()
|
||||
if boot_proof is not None:
|
||||
out["post_restart_reconcile"] = {
|
||||
"reconcile_id": boot_proof.get("reconcile_id"),
|
||||
"overall_status": boot_proof.get("overall_status"),
|
||||
"mutation_hold": boot_proof.get("mutation_hold"),
|
||||
"unresolved_count": boot_proof.get("unresolved_count"),
|
||||
"mode": boot_proof.get("mode"),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
@@ -22328,6 +22339,249 @@ def gitea_request_mcp_restart(
|
||||
return payload
|
||||
|
||||
|
||||
# --- #662 post-restart reconciliation ---------------------------------------
|
||||
|
||||
_POST_RESTART_LAST_PROOF: dict | None = None
|
||||
_POST_RESTART_BOOT_RAN = False
|
||||
|
||||
|
||||
def _post_restart_reconcile_mode() -> str:
|
||||
"""Return log_only (default) or enforce from process environment."""
|
||||
raw = (os.environ.get("GITEA_POST_RESTART_RECONCILE_MODE") or "").strip().lower()
|
||||
if raw in {"enforce", "enforced", "hold"}:
|
||||
return "enforce"
|
||||
return "log_only"
|
||||
|
||||
|
||||
def _gather_post_restart_inventory(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
limit: int = 200,
|
||||
) -> dict:
|
||||
"""Gather live control-plane facts for post-restart reconcile (#662).
|
||||
|
||||
Best-effort and fail-closed: any inventory section that cannot be read is
|
||||
recorded on ``incomplete_reasons`` and ``inventory_complete`` is cleared.
|
||||
Never mutates Gitea or the control-plane DB.
|
||||
"""
|
||||
import master_parity_gate
|
||||
import post_restart_reconcile as prr
|
||||
|
||||
inventory_complete = True
|
||||
incomplete_reasons: list[str] = []
|
||||
sessions: list[dict] = []
|
||||
leases: list[dict] = []
|
||||
worktree_bindings: list[dict] = []
|
||||
|
||||
db, db_errs = _control_plane_db_or_error()
|
||||
if db is None:
|
||||
inventory_complete = False
|
||||
incomplete_reasons.extend(
|
||||
db_errs or ["control-plane DB unavailable; cannot reconcile after restart"]
|
||||
)
|
||||
else:
|
||||
try:
|
||||
sessions = db.list_sessions(statuses=("active",), limit=max(1, int(limit)))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
inventory_complete = False
|
||||
incomplete_reasons.append(
|
||||
f"session inventory failed: {_redact(str(exc))}"
|
||||
)
|
||||
try:
|
||||
lease_result = lease_lifecycle.list_active_leases(
|
||||
db,
|
||||
remote=remote if remote in REMOTES else remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
role=None,
|
||||
include_non_active=True,
|
||||
limit=max(1, int(limit)),
|
||||
)
|
||||
leases = list(lease_result.get("leases") or [])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
inventory_complete = False
|
||||
incomplete_reasons.append(
|
||||
f"lease inventory failed: {_redact(str(exc))}"
|
||||
)
|
||||
leases = []
|
||||
|
||||
# Derive worktree bindings from live leases that carry a path.
|
||||
for row in leases:
|
||||
wt = (row.get("worktree_path") or "").strip()
|
||||
if not wt:
|
||||
continue
|
||||
exists = bool(lease_lifecycle.worktree_exists(wt))
|
||||
worktree_bindings.append(
|
||||
{
|
||||
"path": wt,
|
||||
"exists": exists,
|
||||
"missing": not exists,
|
||||
"lease_id": row.get("lease_id"),
|
||||
"session_id": row.get("session_id"),
|
||||
"work_kind": row.get("work_kind"),
|
||||
"work_number": row.get("work_number"),
|
||||
}
|
||||
)
|
||||
|
||||
# Capability / master-parity dimension (code parity after restart).
|
||||
try:
|
||||
parity = _current_master_parity()
|
||||
capabilities = {
|
||||
"stale": bool(parity.get("stale")),
|
||||
"startup_head": parity.get("startup_head"),
|
||||
"current_head": parity.get("current_head"),
|
||||
"in_parity": parity.get("in_parity"),
|
||||
"mutation_safe": parity.get("mutation_safe"),
|
||||
}
|
||||
service_health = {
|
||||
"healthy": bool(parity.get("mutation_safe", not parity.get("stale"))),
|
||||
"parity_summary": master_parity_gate.format_parity(parity),
|
||||
}
|
||||
boot_head = parity.get("startup_head") or _process_boot_head_sha
|
||||
current_head = parity.get("current_head")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
inventory_complete = False
|
||||
incomplete_reasons.append(
|
||||
f"master-parity inventory failed: {_redact(str(exc))}"
|
||||
)
|
||||
capabilities = {"stale": None}
|
||||
service_health = {"healthy": None, "error": "parity assessment failed"}
|
||||
boot_head = _process_boot_head_sha
|
||||
current_head = None
|
||||
|
||||
# #660 soft dependency: checkpoint schema not landed → skip dimension.
|
||||
checkpoints_available = False
|
||||
try:
|
||||
import importlib
|
||||
|
||||
importlib.import_module("session_checkpoint_schema")
|
||||
checkpoints_available = True
|
||||
except Exception: # noqa: BLE001
|
||||
checkpoints_available = False
|
||||
|
||||
return {
|
||||
"inventory_complete": inventory_complete,
|
||||
"incomplete_reasons": incomplete_reasons,
|
||||
"service_health": service_health,
|
||||
"clients": [], # client transport inventory is host/IDE-owned
|
||||
"sessions": sessions,
|
||||
"leases": leases,
|
||||
"checkpoints_available": checkpoints_available,
|
||||
"checkpoints": [] if checkpoints_available else None,
|
||||
"worktree_bindings": worktree_bindings,
|
||||
"pending_mutations": [],
|
||||
"capabilities": capabilities,
|
||||
"boot_head_sha": boot_head,
|
||||
"current_head_sha": current_head,
|
||||
"queue_state": {"safe_to_resume": inventory_complete},
|
||||
"reconcile_version": prr.RECONCILE_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def _run_post_restart_reconcile(
|
||||
*,
|
||||
remote: str = "prgs",
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
mode: str | None = None,
|
||||
limit: int = 200,
|
||||
) -> dict:
|
||||
"""Gather + classify post-restart state; cache the latest proof (#662)."""
|
||||
global _POST_RESTART_LAST_PROOF, _POST_RESTART_BOOT_RAN
|
||||
import post_restart_reconcile as prr
|
||||
|
||||
try:
|
||||
_h, o, r = _resolve(remote, None, org, repo)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"read_only": True,
|
||||
"reasons": [str(exc)],
|
||||
}
|
||||
|
||||
inventory = _gather_post_restart_inventory(
|
||||
remote=remote, org=o, repo=r, limit=limit
|
||||
)
|
||||
proof = prr.reconcile_after_restart(
|
||||
inventory,
|
||||
mode=mode or _post_restart_reconcile_mode(),
|
||||
)
|
||||
payload = proof.as_dict()
|
||||
payload["success"] = True
|
||||
payload["read_only"] = True
|
||||
payload["remote"] = remote
|
||||
payload["org"] = o
|
||||
payload["repo"] = r
|
||||
payload["follow_up_create_supported"] = False
|
||||
payload["follow_up_create_note"] = (
|
||||
"proposed_follow_ups lists durable issues the apply path may create; "
|
||||
"this tool never creates them (log-only by default, #662 rollout)"
|
||||
)
|
||||
_POST_RESTART_LAST_PROOF = payload
|
||||
_POST_RESTART_BOOT_RAN = True
|
||||
return payload
|
||||
|
||||
|
||||
def _ensure_boot_post_restart_reconcile() -> dict | None:
|
||||
"""Run post-restart reconcile once per process (boot hook, #662 AC1)."""
|
||||
global _POST_RESTART_BOOT_RAN
|
||||
if _POST_RESTART_BOOT_RAN:
|
||||
return _POST_RESTART_LAST_PROOF
|
||||
# Best-effort: never raise from the boot hook.
|
||||
try:
|
||||
return _run_post_restart_reconcile()
|
||||
except Exception: # noqa: BLE001
|
||||
_POST_RESTART_BOOT_RAN = True
|
||||
return None
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_reconcile_after_restart(
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
mode: str | None = None,
|
||||
limit: int = 200,
|
||||
) -> dict:
|
||||
"""Run post-restart MCP reconciliation and return a completion proof (#662).
|
||||
|
||||
Gathers live control-plane sessions, leases, worktree bindings, and
|
||||
master-parity evidence, then classifies them with the pure
|
||||
``post_restart_reconcile.reconcile_after_restart`` assessor. Returns a
|
||||
machine-readable completion proof listing resolved / unresolved dimensions
|
||||
and proposed durable follow-up issues.
|
||||
|
||||
Read-only by design: never restarts MCP, never auto-resumes write
|
||||
mutations, and never creates Gitea issues (those are a separate apply
|
||||
path). Default mode is ``log_only``; set
|
||||
``GITEA_POST_RESTART_RECONCILE_MODE=enforce`` (or pass ``mode='enforce'``)
|
||||
to set ``mutation_hold`` when anything remains unresolved.
|
||||
|
||||
Soft-depends on #660 for session checkpoints: when the checkpoint schema
|
||||
module is absent the checkpoints dimension is ``skipped`` with an explicit
|
||||
reason rather than inventing a schema.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"read_only": True,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
|
||||
return _run_post_restart_reconcile(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
mode=mode,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_inspect_workflow_lease(
|
||||
lease_id: str,
|
||||
|
||||
Reference in New Issue
Block a user