Merge remote-tracking branch 'prgs/master' into feat/issue-648-notifications-console
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
# ADR: High-availability and rolling-restart architecture for Gitea MCP control plane
|
||||
|
||||
- **Status:** Proposed (Design ADR under [#668](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/668))
|
||||
- **Date:** 2026-07-25
|
||||
- **Tracking Issue:** [#668](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/668)
|
||||
- **Policy Version:** `mcp-ha-rolling-restart/v1`
|
||||
- **Related:**
|
||||
- Parent: [#655](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/655) — Governed MCP restart coordination and zero-disruption recovery
|
||||
- Governance Policy: [#656](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/656) / `docs/architecture/mcp-restart-governance.md`
|
||||
- Control-Plane DB Substrate: [#613](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/613) / `docs/architecture/control-plane-db-substrate.md`
|
||||
- Runtime Policy: [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615) / `docs/architecture/mcp-stable-control-runtime-policy-adr.md`
|
||||
- Product Vision: [#652](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/652) (Phase 5 Maturity)
|
||||
- Delivery Roadmap: [#653](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/653)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context & Problem Statement
|
||||
|
||||
The Gitea MCP server operates as the authoritative **control plane** for managing issues, Pull Requests, code mutations, formal reviews, and workflow reconciliations. Under single-process governance ([#656](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/656)), process restarts are strictly controlled using pre-flight checks, drain phases, and operator approvals.
|
||||
|
||||
However, a single-instance control plane inherently presents fundamental constraints:
|
||||
|
||||
1. **Downtime during updates:** Even a perfectly executed single-process drain requires a window where incoming client requests must be paused or rejected while the server binary or python environment reloads.
|
||||
2. **Single point of failure:** Infrastructure issues, process crashes, or unhandled host-level terminations immediately disconnect active LLM sessions and leave transient workflows incomplete.
|
||||
3. **Multi-agent concurrency bottlenecks:** High volumes of concurrent multi-LLM tasks put all lock management, lease allocation, and Gitea API interactions through a single process event loop.
|
||||
|
||||
To achieve true zero-disruption operation and seamless rolling deployments without stopping active work, the system requires a high-availability (HA), multi-instance MCP architecture.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architectural Principles & Non-Goals
|
||||
|
||||
### 2.1 Core Architectural Principles
|
||||
* **Gitea as Canonical Work SoT:** Gitea remains the ultimate System of Record (SoT) for issue states, pull requests, labels, and audit comments. The MCP control plane does not duplicate domain entities.
|
||||
* **Control-Plane DB as Multi-Instance State Substrate:** The control-plane SQLite/durable database ([#613](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/613)) acts as the single source of truth for workflow leases, session tokens, assignment records, and lock fences across all MCP nodes.
|
||||
* **Stateless Worker Nodes:** MCP role server processes (`gitea-author`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, `gitea-controller`) maintain no unique in-memory state; any node can handle any request given a valid session resume token.
|
||||
* **Fail-Closed Split-Brain Defense:** In any network partition or quorum loss scenario, nodes must fail closed rather than risk double-mutations or conflicting Gitea states.
|
||||
|
||||
### 2.2 Non-Goals
|
||||
* **Replacing Gitea:** We do not replace Gitea issue/PR tracking with an independent database.
|
||||
* **Immediate Multi-Node Cluster Execution in v1:** This ADR defines the target architecture and phased roadmap; immediate implementation occurs incrementally post-[#655] v1.
|
||||
|
||||
---
|
||||
|
||||
## 3. High-Availability & Rolling-Restart Architecture
|
||||
|
||||
### 3.1 Architecture Overview
|
||||
|
||||
```
|
||||
+----------------------------+
|
||||
| LLM Clients / IDE Sessions |
|
||||
+--------------+-------------+
|
||||
|
|
||||
v
|
||||
+----------------------------+
|
||||
| HA Proxy / Router |
|
||||
| (Health-based & Affinity) |
|
||||
+------+--------------+------+
|
||||
| |
|
||||
+--------------+ +--------------+
|
||||
v v
|
||||
+--------------------+ +--------------------+
|
||||
| MCP Instance Node A| | MCP Instance Node B|
|
||||
| (Version N) | | (Version N+1) |
|
||||
+---------+----------+ +---------+----------+
|
||||
| |
|
||||
+----------------------+----------------------+
|
||||
|
|
||||
v
|
||||
+----------------------------+
|
||||
| Control-Plane DB Substrate|
|
||||
| (Shared Lease & Locks) |
|
||||
+--------------+-------------+
|
||||
|
|
||||
v
|
||||
+----------------------------+
|
||||
| Gitea API |
|
||||
+----------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Key System Components
|
||||
|
||||
#### A. Multiple MCP Instance Cohorts
|
||||
* The control plane runs across $N \ge 2$ redundant process nodes.
|
||||
* Dual-namespace deployment allows running the old version (Node A) alongside a updated version (Node B) during rolling upgrades.
|
||||
|
||||
#### B. Shared Durable Session Storage & Resume Tokens
|
||||
* Session context, preflight verification proofs, and capability resolution states are stored in the shared control-plane database.
|
||||
* Client requests carry an explicit `session_id` and `resume_token`. If an MCP instance restarts or a request routes to a different instance, the target node validates the token against the database without requiring full session re-initialization.
|
||||
|
||||
#### C. Shared Lease Authority & Fencing Counters
|
||||
* Workflow leases (`gitea_allocate_next_work`, `gitea_adopt_workflow_lease`) use monotonic fencing tokens (`lease_generation_id`).
|
||||
* When Node B acquires or renews a lease, it increments the generation counter. Any delayed or out-of-order write attempt from Node A using an older generation token is rejected by database constraints.
|
||||
|
||||
#### D. Leader Election & Coordinated Drain
|
||||
* Node clusters elect a primary coordinator node for administrative background tasks (such as stale lease cleanup or incident Watchdogs).
|
||||
* During a rolling deployment:
|
||||
1. Node B (new version) is launched and registers as healthy.
|
||||
2. Router directs new session creations to Node B.
|
||||
3. Node A enters `MAINTENANCE_DRAIN` status ([#659](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/659)), completing in-flight mutations while refusing new tasks.
|
||||
4. Once all active sessions migrate or complete, Node A shuts down cleanly.
|
||||
|
||||
#### E. Idempotent Mutations & Failover Safety
|
||||
* All state-changing tool executions (PR creation, review submission, merge operations, label changes) carry a deterministic `idempotency_key`.
|
||||
* If a network connection flaps or a node fails mid-mutation, the re-issued request with the same `idempotency_key` is recognized by the control-plane substrate, returning the existing recorded result without repeating side effects on Gitea.
|
||||
|
||||
#### F. Schema Version Compatibility
|
||||
* Database migrations follow non-breaking additive patterns.
|
||||
* During rolling upgrades where Node A (Version $N$) and Node B (Version $N+1$) run concurrently, both versions operate against the shared schema without structural conflicts.
|
||||
|
||||
---
|
||||
|
||||
## 4. Split-Brain & Failure Behavior
|
||||
|
||||
### 4.1 Split-Brain Risk Scenarios & Mitigation
|
||||
|
||||
| Scenario | Risk | Mitigation Strategy |
|
||||
|---|---|---|
|
||||
| **Network Partition between Nodes** | Both Node A and Node B attempt to process operations for the same issue/PR. | **Generation Fencing:** Lease renewal requires updating the DB generation counter. The node isolated from the DB fails closed immediately. |
|
||||
| **Stale Node Recovery** | Node A recovers after a long pause and executes a queued mutation. | **Lease Expiry & TTL Fencing:** Transactions verify that `expires_at > NOW()` within the atomic SQLite transaction boundaries. |
|
||||
| **Database Connection Loss** | Node loses access to shared control-plane DB substrate. | **Strict Fail-Closed:** The node immediately marks all task capabilities as `blocked` and rejects mutation tools until DB connectivity is re-established. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased Implementation Milestones
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
M1[Milestone 1: Shared Control-Plane DB Schema & Resume Tokens] --> M2[Milestone 2: Idempotent Mutation Layer]
|
||||
M2 --> M3[Milestone 3: Health Routing & Standby Failover]
|
||||
M3 --> M4[Milestone 4: Active-Active Rolling Deployment & Auto-Drain]
|
||||
```
|
||||
|
||||
### Milestone 1: Shared Control-Plane DB Schema & Resume Tokens (Post-#655)
|
||||
* Extend [#613] Control-Plane DB schema to store multi-instance node heartbeat records and session resume tokens.
|
||||
* Enable session lookup across instances via `session_id`.
|
||||
|
||||
### Milestone 2: Idempotent Mutation Layer & Lease Fencing
|
||||
* Add mandatory `idempotency_key` tracking to all Gitea mutation tools.
|
||||
* Implement monotonic lease fencing counters in `gitea_allocate_next_work` and `gitea_adopt_workflow_lease`.
|
||||
|
||||
### Milestone 3: Health-Based Routing & Active-Passive Standby
|
||||
* Introduce lightweight proxy/router capable of checking node health endpoints.
|
||||
* Implement active-standby failover where standby node automatically assumes work if active node fails health checks.
|
||||
|
||||
### Milestone 4: Active-Active Horizontal Deployment & Rolling Upgrade Automation
|
||||
* Enable true active-active multi-instance execution.
|
||||
* Integrate automated zero-downtime rolling upgrades coordinated with `gitea_request_mcp_restart` maintenance drain.
|
||||
|
||||
---
|
||||
|
||||
## 6. Observability & Audit Requirements
|
||||
|
||||
High-availability control plane operations must expose clear telemetry and audit trails:
|
||||
|
||||
* **Node Registry Telemetry:** Active nodes, version numbers, uptime, and heartbeat timestamps reported via `gitea_get_runtime_context`.
|
||||
* **Lease Fencing Metrics:** Tracking lease acquire latency, fence rejection counts, and lease handoff durations.
|
||||
* **Failover & Re-route Audit Logs:** Durable logging of session migrations between nodes, drain initiation, and process retirement events.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tradeoffs & Accepted Risks
|
||||
|
||||
* **Increased Architectural Complexity:** Moving from a single process to a multi-instance control plane requires robust DB locking, proxy routing, and migration governance.
|
||||
* **Database Dependency:** The control-plane database substrate becomes a critical shared dependency for multi-node deployments. High availability for the underlying SQLite file system / DB must be guaranteed.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Web Console: restart status, impact preview, and approval state (#667)
|
||||
|
||||
Phase 1 of the console restart surface. It consumes the #655 coordinator
|
||||
substrate and displays it. It performs no restart, reload, drain, approval, or
|
||||
process action, and it registers no write endpoint.
|
||||
|
||||
Issue #667's rollout is explicit — *status views first, write approval after the
|
||||
backend gates are green* — and this change delivers only the status half.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Path | Method | Purpose |
|
||||
|------|--------|---------|
|
||||
| `/runtime/restart` | GET | Restart status page |
|
||||
| `/api/v1/system/restart/status` | GET | Same snapshot as JSON |
|
||||
|
||||
Both accept an optional `restart_class` query parameter (default
|
||||
`full_mcp_restart`). An unrecognised class is not an error: the coordinator
|
||||
resolves it as unknown and fails closed, and the page shows the resulting deny.
|
||||
|
||||
Neither path accepts `POST`; a write attempt returns `405`, and a test asserts
|
||||
it.
|
||||
|
||||
## What it shows
|
||||
|
||||
* **Impact preview (#658)** — verdict, blast radius, affected sessions, leases,
|
||||
critical sections, mutations, and the counts behind them, evaluated
|
||||
`dry_run=True` against live control-plane state.
|
||||
* **Drain proof (#661)** — verification of a supplied proof: valid, clean,
|
||||
expired, tampered, and the reasons behind a refusal.
|
||||
* **Post-restart reconcile (#662)** — the most recent completion proof, its
|
||||
overall status, and which dimensions still require follow-up.
|
||||
* **Restart classes (#663)** — the least-privilege matrix, with *you may
|
||||
request* and *you may execute* computed for the viewing role rather than for a
|
||||
generic operator.
|
||||
* **Approval controls (#633)** — the authorization state of
|
||||
`system.restart_namespace` and `system.reload_namespace`.
|
||||
* **Break-glass (#664)** — declared and marked unavailable; see below.
|
||||
|
||||
## Three rules this surface holds itself to
|
||||
|
||||
A status page that is wrong is worse than one that is missing, because an
|
||||
operator acts on it. Three properties are enforced by tests, and each was
|
||||
verified by reverting the guard and watching a test fail.
|
||||
|
||||
### An unreadable source reports unavailable, never green
|
||||
|
||||
Every source carries its own `SourceStatus`. Nothing substitutes a default,
|
||||
placeholder, or self-comparison for a reading that failed. An unreadable
|
||||
control-plane database yields `inventory_complete: false`, which the coordinator
|
||||
itself turns into a fail-closed verdict, and the page says the blast radius is
|
||||
unknown rather than showing an empty affected-sessions table.
|
||||
|
||||
An absent drain proof is reported as absent — not as a pass. The #661 gate
|
||||
authorizes a restart only against a valid, unexpired, clean proof, so no proof
|
||||
is precisely the state that gate denies on.
|
||||
|
||||
### Authorization is asked the way execution would ask it
|
||||
|
||||
Every probe passes `for_execution=True`.
|
||||
|
||||
Asked without it, an admin is `allowed` for `system.restart_namespace`. On a
|
||||
control surface that reads as a live button. Asked the way an execution attempt
|
||||
would ask, the same principal is refused `phase_not_active`, because the console
|
||||
is in Phase 1 and the action is Phase 2. This surface reports the second answer.
|
||||
|
||||
`execution_enabled` is therefore `false` for every action and every role today,
|
||||
and a test asserts that across the whole role matrix.
|
||||
|
||||
### The control-plane database is opened read-only
|
||||
|
||||
`ControlPlaneDB()` creates directories and runs migrations on construction — a
|
||||
write. This surface never constructs one. It opens the sqlite file with
|
||||
`mode=ro`, exactly as `webui/inventory.py` does, and treats a missing file as
|
||||
missing authority rather than as an empty inventory.
|
||||
|
||||
The test that protects this points at a path inside a directory that already
|
||||
exists, so a read-write `connect` would really create the file. A nested
|
||||
missing-directory path would have passed for the wrong reason.
|
||||
|
||||
## Break-glass is declared, not offered
|
||||
|
||||
The break-glass workflow (#664) is not available on this branch's base. The
|
||||
panel is rendered to operator-class roles as **unavailable**, naming the issue
|
||||
that tracks it. It is not silently omitted, because an operator who has been
|
||||
told a governance path exists needs to see that it is not wired here; and it is
|
||||
not rendered as a control, because there is nothing behind it.
|
||||
|
||||
Unprivileged viewers see only a note that the surface is operator-class.
|
||||
|
||||
## Redaction and escaping
|
||||
|
||||
Every interpolated value passes through `_esc` (`html.escape(..., quote=True)`).
|
||||
Free-form text and anything that can carry a filesystem path additionally passes
|
||||
through `webui.inventory.scrub_text`, which redacts credential-shaped tokens
|
||||
inside a string rather than only at its start. The impact payload is passed
|
||||
through `webui.inventory.scrub` before rendering.
|
||||
|
||||
## Linkage
|
||||
|
||||
Parent #655 · extends #642 · consumes #658, #661, #662, #663 · RBAC #633 ·
|
||||
console #631 · vision #652 · roadmap #653 · break-glass #664.
|
||||
Reference in New Issue
Block a user