feat(mcp): implement graceful maintenance-drain mode (Closes #659)
Add durable per-repo drain state, allocator assignment stop, mutation deferral with a safety allowlist, observable status, and capability-gated enter/exit tools. Drain proof/restart gate remain #661. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -1472,6 +1472,9 @@ def verify_preflight_purity(
|
||||
# contaminated by manual MCP daemon process killing (reconciler-exempt).
|
||||
_enforce_runtime_recovery_contamination_gate(task, remote)
|
||||
|
||||
# #659 AC3: defer non-allowlisted mutations while maintenance drain is active.
|
||||
_enforce_maintenance_drain_gate(task, remote=remote, org=org, repo=repo)
|
||||
|
||||
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||||
workspace = ctx["workspace_path"]
|
||||
canonical_root = ctx["canonical_repo_root"]
|
||||
@@ -2004,6 +2007,55 @@ def _enforce_runtime_recovery_contamination_gate(
|
||||
)
|
||||
|
||||
|
||||
def _enforce_maintenance_drain_gate(
|
||||
task: str | None,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> None:
|
||||
"""#659 AC3: defer non-allowlisted mutations while drain is active.
|
||||
|
||||
The single mutation chokepoint already used by every gated task, so drain
|
||||
coverage cannot drift per-tool. Allowlisted safety operations (heartbeat,
|
||||
release/abandon, checkpoint, drain exit) pass through so an in-flight
|
||||
session can still finish and hand off; everything else is deferred with a
|
||||
typed blocker. Unreadable drain state fails closed — a drain that cannot be
|
||||
read is not evidence that no drain is running.
|
||||
"""
|
||||
if _preflight_in_test_mode() and not os.environ.get(
|
||||
"GITEA_TEST_FORCE_MAINTENANCE_DRAIN"
|
||||
):
|
||||
return
|
||||
if maintenance_drain.is_allowlisted_task(task):
|
||||
return
|
||||
|
||||
try:
|
||||
_h, o, r = _resolve(remote, None, org, repo)
|
||||
except Exception: # noqa: BLE001 — scope resolution is best-effort here
|
||||
o, r = (org or ""), (repo or "")
|
||||
|
||||
db, errs = _control_plane_db_or_error()
|
||||
if db is None:
|
||||
raise RuntimeError(
|
||||
"maintenance-drain state could not be read: "
|
||||
f"{'; '.join(errs) or 'control-plane DB unavailable'} (fail closed, #659)"
|
||||
)
|
||||
try:
|
||||
record = db.read_maintenance_drain(remote=remote or "", org=o, repo=r)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(
|
||||
f"maintenance-drain state could not be read: {_redact(str(exc))} "
|
||||
"(fail closed, #659)"
|
||||
) from exc
|
||||
|
||||
decision = maintenance_drain.classify_mutation(task, record)
|
||||
if not decision["allowed"]:
|
||||
raise maintenance_drain.MaintenanceDrainError(
|
||||
maintenance_drain.format_drain_block_error(decision),
|
||||
decision=decision,
|
||||
)
|
||||
|
||||
|
||||
def _enforce_stable_branch_contamination_gate(
|
||||
task: str | None,
|
||||
remote: str | None = None,
|
||||
@@ -2064,6 +2116,7 @@ import allocator_service # noqa: E402
|
||||
import allocator_dependencies # noqa: E402
|
||||
import dependency_graph # noqa: E402 # #784 durable dependency edges
|
||||
import control_plane_db # noqa: E402
|
||||
import maintenance_drain # noqa: E402 # #659 graceful maintenance-drain mode
|
||||
import lease_lifecycle # noqa: E402
|
||||
import lease_policy # noqa: E402
|
||||
import workflow_dashboard # noqa: E402 # #605 live queue/lease dashboard
|
||||
@@ -22559,6 +22612,193 @@ def gitea_workflow_dashboard(
|
||||
return payload
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_maintenance_drain_status(
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: current maintenance-drain state for a repository scope (#659 AC4).
|
||||
|
||||
Every session must be able to observe drain so it can stop creating new work
|
||||
and finish only allowlisted safety operations. Never mutates; never restarts.
|
||||
"""
|
||||
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"),
|
||||
}
|
||||
try:
|
||||
_h, o, r = _resolve(remote, host, org, repo)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "read_only": True, "reasons": [str(exc)]}
|
||||
db, errs = _control_plane_db_or_error()
|
||||
if db is None:
|
||||
return {
|
||||
"success": False,
|
||||
"read_only": True,
|
||||
"reasons": errs or ["control-plane DB unavailable"],
|
||||
"maintenance_drain": maintenance_drain.status_payload(
|
||||
None, remote=remote, org=o, repo=r
|
||||
),
|
||||
}
|
||||
try:
|
||||
record = db.read_maintenance_drain(remote=remote, org=o, repo=r)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"success": False,
|
||||
"read_only": True,
|
||||
"reasons": [f"drain state unreadable: {_redact(str(exc))}"],
|
||||
}
|
||||
payload = maintenance_drain.status_payload(
|
||||
record, remote=remote, org=o, repo=r
|
||||
)
|
||||
return {"success": True, "read_only": True, "maintenance_drain": payload}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_enter_maintenance_drain(
|
||||
reason: str = "",
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Enter graceful maintenance-drain mode for a repository scope (#659 AC1).
|
||||
|
||||
Stops new assignment and defers non-allowlisted mutations until exit. Requires
|
||||
``runtime.maintenance_drain`` (controller/lifecycle capability — not granted
|
||||
by ordinary Gitea author profiles). Audited in the control-plane event log.
|
||||
"""
|
||||
cap_block = _profile_operation_gate("runtime.maintenance_drain")
|
||||
if cap_block:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": cap_block,
|
||||
"permission_report": _permission_block_report(
|
||||
"runtime.maintenance_drain"
|
||||
),
|
||||
}
|
||||
try:
|
||||
_h, o, r = _resolve(remote, host, org, repo)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "performed": False, "reasons": [str(exc)]}
|
||||
db, errs = _control_plane_db_or_error()
|
||||
if db is None:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": errs or ["control-plane DB unavailable"],
|
||||
}
|
||||
profile = get_profile() or {}
|
||||
try:
|
||||
result = db.set_maintenance_drain(
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
state=maintenance_drain.STATE_DRAINING,
|
||||
reason=reason or "operator-entered maintenance drain",
|
||||
requested_by=str(
|
||||
(profile.get("identity") or {}).get("username")
|
||||
or profile.get("expected_username")
|
||||
or ""
|
||||
),
|
||||
requested_by_profile=str(profile.get("profile_name") or ""),
|
||||
session_id=str(session_id or ""),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": [f"enter drain failed: {_redact(str(exc))}"],
|
||||
}
|
||||
record = result.get("record") or {}
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"transitioned": bool(result.get("transitioned")),
|
||||
"state": result.get("state"),
|
||||
"prior_state": result.get("prior_state"),
|
||||
"drain_id": result.get("drain_id"),
|
||||
"maintenance_drain": maintenance_drain.status_payload(
|
||||
record, remote=remote, org=o, repo=r
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_exit_maintenance_drain(
|
||||
reason: str = "",
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Exit graceful maintenance-drain mode (#659 AC1). Restores assignment and mutations."""
|
||||
cap_block = _profile_operation_gate("runtime.maintenance_drain")
|
||||
if cap_block:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": cap_block,
|
||||
"permission_report": _permission_block_report(
|
||||
"runtime.maintenance_drain"
|
||||
),
|
||||
}
|
||||
try:
|
||||
_h, o, r = _resolve(remote, host, org, repo)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "performed": False, "reasons": [str(exc)]}
|
||||
db, errs = _control_plane_db_or_error()
|
||||
if db is None:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": errs or ["control-plane DB unavailable"],
|
||||
}
|
||||
profile = get_profile() or {}
|
||||
try:
|
||||
result = db.set_maintenance_drain(
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
state=maintenance_drain.STATE_INACTIVE,
|
||||
reason=reason or "operator-exited maintenance drain",
|
||||
requested_by=str(
|
||||
(profile.get("identity") or {}).get("username")
|
||||
or profile.get("expected_username")
|
||||
or ""
|
||||
),
|
||||
requested_by_profile=str(profile.get("profile_name") or ""),
|
||||
session_id=str(session_id or ""),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": [f"exit drain failed: {_redact(str(exc))}"],
|
||||
}
|
||||
record = result.get("record") or {}
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"transitioned": bool(result.get("transitioned")),
|
||||
"state": result.get("state"),
|
||||
"prior_state": result.get("prior_state"),
|
||||
"drain_id": result.get("drain_id"),
|
||||
"maintenance_drain": maintenance_drain.status_payload(
|
||||
record, remote=remote, org=o, repo=r
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_request_mcp_restart(
|
||||
remote: str = "dadeschools",
|
||||
|
||||
Reference in New Issue
Block a user