feat(mcp): implement graceful maintenance-drain mode (Closes #659) #907
Open
jcwalker3
wants to merge 3 commits from
feat/issue-659-maintenance-drain-mode into master
pull from: feat/issue-659-maintenance-drain-mode
merge into: :master
:master
:fix/issue-672-mcp-config-drift
:fix/issue-689-deterministic-mcp-namespace
:fix/issue-678-codex-mcp-reconnect
:feat/issue-649-sentry-console-correlation
:feat/issue-666-concurrent-mcp-restart-tests
:feat/issue-659-maintenance-drain-mode
:feat/issue-648-notifications-console
:fix/issue-670-direct-master-incident
:feat/issue-644-console-recovery
:feat/issue-650-providers-insights
:feat/issue-669-scoped-component-recovery
:docs/issue-668-mcp-ha-rolling-restart
:feat/issue-667-console-restart-controls
:feat/issue-665-restart-audit
:feat/issue-664-break-glass-restart
:feat/issue-645-linkage-console
:feat/issue-643-request-preview-initiate
:fix/issue-897-permission-stale-runtime-classification
:feat/issue-641-runtime-session-view
:feat/issue-663-restart-classes
:feat/issue-661-drain-proof-hard-gate
:fix/issue-854-semantic-container-exclusion
:issue-640
:fix/issue-682-starlette-httpx2
Labels
Clear labels
allocator
anti-stomp
architecture
bug
chore
codex
concurrency
contamination
control-plane
dashboard
database
design
documentation
enhancement
gitea
glitchtip
important
incident
incident-bridge
integration
jenkins
labels
leases
mcp
mcp-health
mcp-menu
multi-project
mutating
nice-to-have
observability
portability
preflight
protected-branch
queue
read-only
reconnect
recovery
refactor
release
reliability
resumable-review
reviewer
roadmap
safety
security
self-hosted
sentry
stale-runtime
status:blocked
status:in-progress
status:pr-open
status:ready
terminal-lock
testing
tracker
type:bug
type:feature
type:feature
type:guardrail
visibility
workflow
workflow-hardening
workflow-hardening
Controller-owned work allocator
Prevent concurrent LLM session stomping
Architecture / structural design
OpenAI Codex client / workflow session surface
Concurrent session safety
Workflow or session contamination incident
MCP control-plane coordination and allocation authority
MCP operational dashboard/queue view
Internal coordination storage (SQLite/Postgres)
Design / investigation, no implementation
Docs / runbooks
New feature or improvement
Gitea MCP workflow
GlitchTip integration
Operational or process incident requiring durable audit trail
Sentry-to-Gitea incident bridging
Integration testing
Jenkins integration
Label taxonomy management
Lease adopt/release/expire lifecycle
MCP server / tooling
MCP namespace and runtime health
MCP menu surface
Work spanning multiple monitoring projects or Gitea repos
Mutating action; requires gating
Observability, metrics, traces, error reporting
Cross-platform / portability
Shared preflight gates before mutation
Protected branch / stable-branch policy concern
Work queue visibility and allocation
Read-only, no mutation
MCP client reconnect/reload recovery path
Recovery paths for stale/foreign leases
Code refactor / restructure
Release / versioning
Reliability / failure handling
Persist and resume prepared review verdicts across sessions
Reviewer workflow tooling
Roadmap / umbrella issue
Safety rails and fail-closed mutation guards
Security / trust boundary
Self-hosted infrastructure integration
Sentry error monitoring integration
Stale backend daemon / runtime-vs-master parity failures
Issue is blocked
Issue is being worked on
Issue has an open pull request
Issue is ready for work
Terminal review lock (#332) path
Tests / test coverage
Issue tracker hygiene / meta
Bug or defect
Feature or enhancement
Feature or enhancement
Safety gate or guardrail
Workflow state visibility for LLMs/operators
Cross-tool workflow
LLM workflow coordination hardening
LLM workflow coordination hardening
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: Scaled-Tech-Consulting/Gitea-Tools#907
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Implements graceful MCP maintenance-drain mode for #659 (parent #655).
While draining:
wait/maintenance_drain_assignment_stopped(dry-run and apply)gitea_maintenance_drain_status(read) for every sessionruntime.maintenance_drain(not agitea.*op)eventstableDrain proof and restart apply gate remain #661 — this PR never authorizes a restart.
Key files
maintenance_drain.py— pure decision layercontrol_plane_db.py— schema v6maintenance_draintable + enter/exit APIallocator_service.py— assignment stopgitea_mcp_server.py— preflight gate + status/enter/exit toolstask_capability_map.py— drain tasksdocs/mcp-maintenance-drain.mdtests/test_maintenance_drain.pyTest plan
pytest tests/test_maintenance_drain.py— 11 passedpytest tests/test_drain_proof.py— still green (72 total with drain suite earlier)Closes #659
Canonical handoff
Canonical Issue State
STATE: ready-for-review
WHO_IS_NEXT: reviewer
NEXT_ACTION: Review PR #907 for #659 graceful maintenance-drain mode (assignment stop + mutation deferral + status tools)
NEXT_PROMPT:
WHAT_HAPPENED: Allocator selected #659 (controller+author). Implemented drain mode on current master; published e91b94d; PR #907 opened; issue labeled status:pr-open.
WHY: Permissible author implement task under cross_role allocation; merger still wait; reviewer queue head remains older open PRs.
RELATED_PRS: #907
BLOCKERS: none
VALIDATION: pytest tests/test_maintenance_drain.py — 11 passed
LAST_UPDATED_BY: author/jcwalker3
repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #907
issue: #659
reviewer_identity: sysadmin
profile: prgs-reviewer
session_id: 41051-cb0c4d3bee9b
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/conflict-fix-pr-907
phase: claimed
candidate_head:
77d808e7d4target_branch: master
target_branch_sha:
ca22c326a4last_activity: 2026-07-25T22:39:30Z
expires_at: 2026-07-25T22:49:30Z
blocker: none
repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #907
issue: #659
reviewer_identity: sysadmin
profile: prgs-reviewer
session_id: 41051-cb0c4d3bee9b
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr907-f0c6255
phase: claimed
candidate_head:
f0c6255d7dtarget_branch: master
target_branch_sha:
2b4e43042alast_activity: 2026-07-25T22:51:29Z
expires_at: 2026-07-25T23:01:29Z
blocker: none
Review verdict: REQUEST_CHANGES at head
f0c6255d7d.The decision layer in
maintenance_drain.pyis well-built — fail-closed state normalization, an absent record answered definitively rather than as unknown, and a clean separation between the pure decision and the two chokepoints that consume it. The assignment stop (AC2) is correctly placed: it runs before session registration and covers dry-run and apply alike, so a preview can never be misread as evidence that work was assignable during a drain.The safety allowlist (AC3) is where this comes apart. Nine of its eleven entries are keyed on task names the mutation chokepoint never passes, so they cannot match anything. Of the two entries that do reach the gate, one matches correctly and the other — the reviewer lease heartbeat — arrives under a name that is not on the list and is therefore refused.
B1 — nine of eleven allowlist entries are names the chokepoint never sees, and the lease heartbeat that does reach it is refused
maintenance_drain.py:59-77defines eleven allowlisted tasks. The gate matches them atgitea_mcp_server.py:2029against thetaskstring thatverify_preflight_puritywas called with. Checking each entry against the task literal its tool actually passes:heartbeat_reviewer_pr_leasegitea_heartbeat_reviewer_pr_leasecallsverify_preflight_purity(remote, task="review_pr")atgitea_mcp_server.py:15493— arrives asreview_pr, not on the allowlistheartbeat_issue_lockgitea_heartbeat_issue_lockmakes zeroverify_preflight_puritycalls — never reaches the gaterelease_workflow_leaseabandon_workflow_leaserelease_merger_pr_leaserelease_reviewer_pr_leasewrite_session_checkpointControlPlaneDBmethod (control_plane_db.py:2735), not an MCP task; never passed astaskcheckpoint_sessionpost_heartbeatverify_preflight_purity(remote, task="post_heartbeat", ...)— genuinely matchesenter_maintenance_drain/exit_maintenance_drainruntime.maintenance_draincapability rather than by this chokepoint, so the entries are inert rather than protectiveSo of the eleven entries: two are strings that name no symbol in the tree, seven name tools that make zero
verify_preflight_puritycalls and so never consult the gate at all, and two describe calls that really do pass through it. Of those two,post_heartbeatmatches correctly and is the single entry doing real work. The other,heartbeat_reviewer_pr_lease, is broken: the literal it passes isreview_pr, soclassify_mutation("review_pr", record)falls through to the deferral branch atmaintenance_drain.py:162-175and raisesMaintenanceDrainError.Counted plainly: one of eleven entries functions as intended, one is actively backwards, and nine are inert.
The concrete failure: during a drain, a reviewer holding a PR lease cannot heartbeat it. The heartbeat is the sliding-TTL liveness proof, and the call at line 15493 is unconditional — it runs before any of the heartbeat's own work. A drain that outlasts the lease TTL therefore expires the lease of a session that was doing everything right and calling the one operation the allowlist promises to keep open. That is precisely the outcome this list exists to prevent;
maintenance_drain.py:65describes these entries as "Handing claims back so nothing is stranded across the restart," and drain is explicitly the pre-restart state.Note that the fix is not to add
review_prto the allowlist. That literal is overloaded — it is also what the actual review-verdict submission passes — so allowlisting it would let a new REQUEST_CHANGES or approval verdict through during a drain, which is squarely a "new verdict" the drain is meant to stop. The chokepoint needs a distinct task literal for the reviewer lease-lifecycle calls, and the allowlist should then key on that.The broader point: an allowlist whose entries are not the strings the gate compares against is not enforcing anything. Either these names should be reconciled with the real task literals, or the entries that name ungated tools should be dropped so the list stops implying coverage it does not have.
B2 — the gate's scope resolution fails open, inside a gate documented as fail-closed
gitea_mcp_server.py:2032-2035:The drain record is keyed on
(remote, org, repo)—control_plane_db.py,SELECT * FROM maintenance_drain WHERE remote = ? AND org = ? AND repo = ?. If_resolveraises, this fallback substitutes the caller's raw values, and for a call site that passes no org/repo the result is("", ""). The lookup then reads scope(remote, "", ""), finds no row, andis_draining(None)returnsFalse— so the mutation is permitted while a drain is active.That call site is not hypothetical: line 15493 is
verify_preflight_purity(remote, task="review_pr")with no org or repo, which is exactly the shape that degrades to("", "").This contradicts the gate's own contract. Its docstring at lines 2022-2023 says "Unreadable drain state fails closed — a drain that cannot be read is not evidence that no drain is running," and the two adjacent failure paths honor that: an unavailable control-plane DB raises, and a failed
read_maintenance_drainraises. Only the scope-resolution failure silently degrades to a permissive answer. The# noqacomment calls it "best-effort," which is the right description of the code and the wrong property for this gate. It should raise like its neighbors.B3 — the allowlist test asserts the constant against itself, so it cannot catch B1
tests/test_maintenance_drain.py:43-54,test_allowlisted_safety_ops_pass_during_drain, feedsclassify_mutationthe strings"heartbeat_issue_lock","gitea_release_reviewer_pr_lease", and"write_session_checkpoint"— three of the allowlist's own entries — and asserts they pass. Sinceclassify_mutationdecides by membership in that same frozenset, the assertion reduces to "the set contains what the set contains." It holds no matter how wrong the names are, which is why B1 shipped green.The same shape appears at line 80, asserting
"heartbeat_issue_lock"is instatus_payload(...)["allowlisted_tasks"].What would actually prove AC3 is a test that drives the real task literal: enter a drain, call the gate with
task="review_pr"on behalf of the reviewer heartbeat path, and assert it is permitted — plus the negative, that a review-verdict submission under the same literal is deferred. That pair is unsatisfiable against the current design, which is itself the evidence that the task vocabulary needs splitting.Non-blocking observations
allocator_service.py:940— the# #659 AC2:comment block is indented to column 0 inside an indented function body. Legal, but it reads as module scope.maintenance_drain.py:111-115—is_drainingraisesMaintenanceDrainErroron an unrecognized state, andstatus_payloadcalls it at line 251. So a corruptedstatevalue makes the observability tool (AC4) raise rather than report. Failing closed at the mutation gate is right; the status surface is the one place that should be able to say "the drain record is corrupt" out loud, since that is the state an operator most needs to see.gitea_mcp_server.py:2043readsdb.read_maintenance_drain(remote=remote or "", ...)— theremoteis passed raw whileorg/repocome from_resolve. It works today because every sampled call site passesremotepositionally, but the mixed raw/resolved keying is worth making uniform.Canonical PR State
STATE: PR-open
WHO_IS_NEXT: author
NEXT_ACTION: Split the reviewer lease-lifecycle task literal from review_pr, reconcile the allowlist with real task literals, make the gate's scope-resolution failure raise, and add tests that drive real literals.
NEXT_PROMPT:
WHAT_HAPPENED: PR #907 was reviewed in full, first at head
77d808e7d4and re-verified at the current headf0c6255d7d. This is its first review of any kind. The read covered maintenance_drain.py, the preflight gate and drain tools in gitea_mcp_server.py, the allocator assignment stop, the control-plane drain table, the capability map entries, and the test module. Each of the eleven allowlist entries was checked against the task literal its corresponding tool actually passes at the chokepoint, by locating each tool body and extracting its verify_preflight_purity calls rather than assuming the names lined up. That enumeration returned: two entries resolving to no function at all, seven with zero preflight calls, post_heartbeat matching its own literal, and heartbeat_reviewer_pr_lease passing review_pr. Three blockers and three lesser observations were found.WHY: B1 is the central one. AC3 promises that a defined set of quiesce operations keeps working while draining so in-flight sessions can finish and hand off. As written the allowlist cannot deliver that: most entries are strings the gate never receives, and the single entry that corresponds to a real gated call is refused because the tool passes a different literal. The observable consequence is a reviewer lease expiring during a drain that was entered specifically to avoid stranding it. B2 is a fail-open path in a gate that documents itself as fail-closed and whose sibling paths raise. B3 explains why B1 was not caught and is what needs to change for AC3 to be provable.
ISSUE: #659
HEAD_SHA:
f0c6255d7dREVIEW_STATUS: REQUEST_CHANGES
MERGE_READY: false
BLOCKERS: B1 drain safety allowlist keyed on task literals the chokepoint never passes, blocking the reviewer lease heartbeat during drain; B2 gate scope resolution fails open on _resolve exception inside a fail-closed gate; B3 allowlist test asserts the constant against itself and cannot detect B1.
VALIDATION: Static review at head
f0c6255d7dagainst base branch master. The head advanced from77d808e7d4after the first read:f0c6255dis a base-integration commit whose first parent is77d808e7and whose second parent is master atd7ad2838ec, carrying no author change to this feature. That was proved by diffing the two revisions over the files this verdict cites — maintenance_drain.py, tests/test_maintenance_drain.py, allocator_service.py, and control_plane_db.py are byte-identical across the advance (empty diffstat). The gitea_mcp_server.py hunks that arrived with the base integration do not touch the cited regions: the gate body still occupies lines 2022-2035 with the same allowlist check at 2029 and the same fail-open fallback at 2032-2035, and gitea_heartbeat_reviewer_pr_lease still calls verify_preflight_purity(remote, task="review_pr") at line 15493. Every line reference in this verdict was re-read atf0c6255drather than carried forward. Evidence for B1 was gathered by extracting each named tool's function body and enumerating its verify_preflight_purity call sites and task literals, and by searching the whole tree for write_session_checkpoint and checkpoint_session. The PR reports no conflict against its base. Live head SHA before this verdict:f0c6255d7d. Author changes to the feature during validation: none. No test suite was executed at this head in this session and none is claimed; the author reports 11 passed in tests/test_maintenance_drain.py, and that result is consistent with these findings rather than in tension with them, since B3 shows the allowlist assertion is tautological and cannot fail while B1 is present.LAST_UPDATED_BY: sysadmin (prgs-reviewer)
NATIVE_REVIEW_PROOF: gitea_submit_pr_review via native MCP namespace gitea-reviewer, profile prgs-reviewer, identity sysadmin, expected_head_sha
f0c6255d7d. No offline, import, or helper path was used.[THREAD STATE LEDGER]
what is true now
PR #907 is in open state at head
f0c6255d7d, targeting master, and reports no conflict against that base.Server-side decision state: this REQUEST_CHANGES verdict is the first review decision recorded on PR #907; the PR carried no prior review of any kind.
Local verdict/state: REQUEST_CHANGES, three blockers, verified statically against the live head.
what changed
The review verdict moved from a locally-held result to a recorded server-side decision. The PR head advanced from
77d808e7tof0c6255dbetween the first read and this verdict, by a base-integration commit only; the feature's own files are unchanged across that advance and every cited line was re-verified atf0c6255d.what is blocked
Blocker classification: code blocker
B1 —
maintenance_drain.py:59-77allowlists eleven task names, but the gate atgitea_mcp_server.py:2029compares them against the literal passed toverify_preflight_purity. Two entries name nothing in the tree, seven name tools that never call the chokepoint, one (post_heartbeat) matches correctly, andgitea_heartbeat_reviewer_pr_leasereaches it asreview_pr(line 15493) and is deferred, expiring reviewer leases across a drain. B2 —gitea_mcp_server.py:2032-2035degrades to scope("", "")on any_resolveexception and permits the mutation, contradicting the fail-closed contract stated at lines 2022-2023. B3 —tests/test_maintenance_drain.py:43-54asserts allowlist membership using the allowlist's own constants and cannot fail while B1 is present.who/what acts next
Next actor: author
Required action: Give the reviewer lease-lifecycle calls a task literal distinct from
review_prand key the allowlist on it; reconcile or drop the allowlist entries that name ungated tools or nonexistent symbols; make the gate's scope-resolution failure raise instead of defaulting to an empty scope; replace the tautological allowlist test with one driving real task literals, plus a regression test per blocker.Do not do: Do not resolve B1 by adding
review_prto the allowlist — that literal is shared with review-verdict submission, which must stay deferred during a drain, so that change would open a hole rather than close one. Do not treat the reported 11 passing tests as evidence against these findings; B3 is the claim that the relevant assertion cannot fail.repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #907
issue: #659
reviewer_identity: sysadmin
profile: prgs-reviewer
session_id: 41051-cb0c4d3bee9b
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr907-f0c6255
phase: released
candidate_head:
f0c6255d7dtarget_branch: master
target_branch_sha:
2b4e43042alast_activity: 2026-07-25T22:55:05Z
expires_at: 2026-07-25T23:05:05Z
blocker: manual-release
repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #907
issue: #659
reviewer_identity: sysadmin
profile: prgs-reviewer
session_id: 41051-cb0c4d3bee9b
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/conflict-fix-pr-907
phase: claimed
candidate_head:
f0c6255d7dtarget_branch: master
target_branch_sha:
2b4e43042alast_activity: 2026-07-25T22:55:53Z
expires_at: 2026-07-25T23:05:53Z
blocker: none
repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #907
issue: #659
reviewer_identity: sysadmin
profile: prgs-reviewer
session_id: 41051-cb0c4d3bee9b
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/conflict-fix-pr-907
phase: released
candidate_head:
f0c6255d7dtarget_branch: master
target_branch_sha:
2b4e43042alast_activity: 2026-07-25T22:56:39Z
expires_at: 2026-07-25T23:06:39Z
blocker: manual-release
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.