Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e1d5c5649 | ||
|
|
08c3488d7c | ||
|
|
4f2fd9a8b1 | ||
|
|
4185ee1417 |
@@ -0,0 +1,301 @@
|
|||||||
|
# ADR: MCP allocator, control-plane DB, and Sentry/GlitchTip incident bridge architecture
|
||||||
|
|
||||||
|
- **Status:** Accepted (implementation pending; blocks code for #600 / #612 / #613)
|
||||||
|
- **Date:** 2026-07-09
|
||||||
|
- **Tracking issues:**
|
||||||
|
- [#613](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/613) — control-plane DB (first)
|
||||||
|
- [#600](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/600) — allocator API (second)
|
||||||
|
- [#612](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/612) — Sentry/GlitchTip incident bridge (third)
|
||||||
|
- **Related:** existing GlitchTip contracts (`glitchtip-to-gitea-workflow-design.md`, `glitchtip-gitea-deduplication-linking-design.md`), trust boundaries (`tool-boundaries.md`, `safety-model.md`), current leases (`pr_work_lease.py`, `issue_lock_store.py`)
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
Multiple LLM sessions can independently inspect the Gitea queue and start the same issue or PR. Existing coordination (Gitea comment leases, local issue-lock files, labels) often detects collisions **after** work has started. Parallel issues #600, #612, and #613 each describe part of a fix; without one ADR they risk overlapping stores, wrong dependency order, and trust-boundary violations.
|
||||||
|
|
||||||
|
This ADR is the canonical architecture decision **before** implementing those issues.
|
||||||
|
|
||||||
|
## 2. Decision summary (core)
|
||||||
|
|
||||||
|
| Layer | Owns | Must not |
|
||||||
|
|-------|------|----------|
|
||||||
|
| **Control-plane DB** | Sessions, atomic assignment, leases, heartbeats, terminal-lock index, events, incident links | Bypass Gitea workflow gates or replace issue/PR history |
|
||||||
|
| **Gitea** | Durable work record: issues, PRs, comments, labels, reviews, merges | Be the only concurrency lock under multi-session load |
|
||||||
|
| **Sentry / GlitchTip** | Incidents, events, provider UI | Assign work, approve/merge/close, or mutate Gitea outside the bridge |
|
||||||
|
| **Incident bridge** | Provider adapters, reconcile/create Gitea issues, link storage upsert, optional provider writeback | Hand raw provider incidents to the allocator as work items |
|
||||||
|
|
||||||
|
**One-liner:** **DB coordinates. Gitea records. Sentry/GlitchTip observe. The bridge is the only path that turns observations into Gitea work. The allocator assigns only Gitea issues/PRs, never raw monitoring incidents.**
|
||||||
|
|
||||||
|
## 3. Dependency order
|
||||||
|
|
||||||
|
Implementation **must** follow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
#613 control-plane DB → #600 allocator API → #612 incident bridge
|
||||||
|
(atomic substrate) (routing policy) (feeds Gitea work)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Issue | Role | Depends on |
|
||||||
|
|-------|------|------------|
|
||||||
|
| **#613** | Durable coordination DB + lease/assignment transactions | — |
|
||||||
|
| **#600** | `gitea_allocate_next_work` policy and tool surface | **#613** (hard) |
|
||||||
|
| **#612** | Multi-project Sentry/GlitchTip → Gitea bridge | **#613** for `incident_links` index; **#600** before treating bridge-created issues as allocator feed in multi-worker prod |
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
|
||||||
|
1. Do **not** implement #600 on file locks / comment-only leases and call it done.
|
||||||
|
2. Do **not** ship #612 as a second assignment system for raw incidents.
|
||||||
|
3. Partial previews (read-only list tools, schema stubs) may land earlier if they do not claim “allocator complete” or “bridge complete.”
|
||||||
|
|
||||||
|
## 4. Authority boundaries
|
||||||
|
|
||||||
|
### 4.1 Gitea (durable source of truth for work)
|
||||||
|
|
||||||
|
Gitea remains authoritative for:
|
||||||
|
|
||||||
|
- Issue and PR identity, titles, bodies, state (open/closed/merged)
|
||||||
|
- Comments, reviews, approvals, REQUEST_CHANGES
|
||||||
|
- Labels and workflow status labels (`status:ready`, `status:in-progress`, …)
|
||||||
|
- Merges, closes, branch refs as recorded by Gitea/git hosting
|
||||||
|
|
||||||
|
Operators and auditors read **history** from Gitea.
|
||||||
|
|
||||||
|
### 4.2 Control-plane DB (coordination / index)
|
||||||
|
|
||||||
|
The control-plane DB is authoritative for **live multi-session coordination**:
|
||||||
|
|
||||||
|
- Which session holds which assignment/lease
|
||||||
|
- Heartbeat freshness and expiry
|
||||||
|
- Terminal-lock index for routing
|
||||||
|
- Event log of allocation/lease transitions
|
||||||
|
- `incident_links` index (see §8)
|
||||||
|
|
||||||
|
It is **not** a substitute for Gitea history. When DB and Gitea disagree on durable work state (e.g. PR already merged), **Gitea wins**; the DB is reconciled.
|
||||||
|
|
||||||
|
### 4.3 Sentry / GlitchTip (observe)
|
||||||
|
|
||||||
|
Providers own incident lifecycle and raw event data. They:
|
||||||
|
|
||||||
|
- Must **not** bypass Gitea gates
|
||||||
|
- Must **not** assign LLM work
|
||||||
|
- May receive **writeback** of resolution status only through the bridge, when configured and supported
|
||||||
|
|
||||||
|
### 4.4 Trust boundaries for credentials
|
||||||
|
|
||||||
|
Aligned with `docs/tool-boundaries.md` and `docs/safety-model.md`:
|
||||||
|
|
||||||
|
- **One MCP server process per trust boundary** remains the default.
|
||||||
|
- Monitor API tokens (Sentry/GlitchTip) live in the **bridge/orchestrator boundary** (or a dedicated observability write/read profile), **not** blindly inside every Gitea MCP author/reviewer/merger process.
|
||||||
|
- Gitea tokens stay on Gitea MCP profiles only.
|
||||||
|
- Orchestrators compose services; they must not become a single credential pool that silently mixes Gitea write + monitor tokens into every worker.
|
||||||
|
|
||||||
|
Tool names such as `gitea_observability_*` may exist as a **namespace façade** only if the runtime still enforces separate credential scopes (e.g. bridge process vs pure Gitea mutation process).
|
||||||
|
|
||||||
|
## 5. Allocator rules (#600 on top of #613)
|
||||||
|
|
||||||
|
### 5.1 Worker contract
|
||||||
|
|
||||||
|
- Workers **do not self-select** work under the standard multi-LLM workflow.
|
||||||
|
- Workers call **`gitea_allocate_next_work`** (with `apply=true` only when taking work).
|
||||||
|
- Mutations that claim exclusive work require a **valid assignment and lease** from the control-plane DB.
|
||||||
|
- Return values include at least: role, issue/PR number, expected head SHA (when applicable), lease ID, expiry, allowed actions, forbidden actions — or a non-assignment outcome (`WAIT`, terminal-path block, no safe work, needs controller, etc.).
|
||||||
|
|
||||||
|
### 5.2 Atomic reserve
|
||||||
|
|
||||||
|
- **Assignment creation and lease creation happen in one DB transaction.**
|
||||||
|
- Two concurrent sessions **must not** receive the same issue/PR as assigned work.
|
||||||
|
- On contention: second session gets **WAIT** or **owner-resume**, never a duplicate lease.
|
||||||
|
|
||||||
|
### 5.3 Routing policy
|
||||||
|
|
||||||
|
| Condition | Route |
|
||||||
|
|-----------|--------|
|
||||||
|
| Current-head **REQUEST_CHANGES** | **Author** (not reviewer/merger) |
|
||||||
|
| **Stale** approval (approval not on current head) | **Reviewer** |
|
||||||
|
| **Clean** approval on current head, mergeable | **Merger** |
|
||||||
|
| Contested / contaminated approval | **Diagnosis / reconciler** (controller path) |
|
||||||
|
| Active **foreign** lease | **WAIT** or **owner-resume** |
|
||||||
|
| **Terminal-review** lock present | **Terminal-path resolution first** before downstream review work |
|
||||||
|
| Merged / closed PR | **Never assign** |
|
||||||
|
| Issue locked by sanctioned lease | **Never assign** to another worker unless lease cleanup/adoption is sanctioned |
|
||||||
|
|
||||||
|
### 5.4 Relationship to Gitea mirrors
|
||||||
|
|
||||||
|
After a successful assignment, the allocator (or sanctioned tooling) may update Gitea comments/labels so humans and audits see state. Those mirrors **are not** the sole lock source.
|
||||||
|
|
||||||
|
## 6. Control-plane DB topology (#613)
|
||||||
|
|
||||||
|
### 6.1 Preferred architecture (multi-daemon / Proxmox)
|
||||||
|
|
||||||
|
For true multi-session concurrency (multiple MCP daemons, multiple hosts, or Proxmox VMs):
|
||||||
|
|
||||||
|
**Prefer either:**
|
||||||
|
|
||||||
|
1. **Shared Postgres** used by all Gitea MCP profiles / allocator callers, **or**
|
||||||
|
2. A **single allocator daemon** (sole writer to the coordination store) that all workers call over MCP/RPC.
|
||||||
|
|
||||||
|
Both satisfy: one transactional authority for “who has this work item.”
|
||||||
|
|
||||||
|
### 6.2 SQLite MVP
|
||||||
|
|
||||||
|
SQLite is acceptable **only** for a **single-writer MVP**:
|
||||||
|
|
||||||
|
- One process owns writes (allocator daemon or single co-located MCP server), **or**
|
||||||
|
- Documented single-host single-daemon experiments with file locking and no multi-host claims.
|
||||||
|
|
||||||
|
SQLite is **not** sufficient to declare multi-session production readiness when four independent LLM sessions talk to four MCP processes without a shared writer.
|
||||||
|
|
||||||
|
### 6.3 Suggested entities (normative shape)
|
||||||
|
|
||||||
|
Minimum logical entities (names may vary; semantics must not):
|
||||||
|
|
||||||
|
1. **sessions** — session_id, role/profile, namespace, pid, started_at, last_heartbeat_at, status
|
||||||
|
2. **work_items** — remote/org/repo, kind ∈ {`issue`, `pr`}, number, state, priority, current_head_sha, updated_at
|
||||||
|
3. **leases** — lease_id, work_item_id, session_id, role, phase, expires_at, heartbeat_at, status
|
||||||
|
4. **assignments** — assignment_id, work_item_id, session_id, allowed_action(s), expected_head_sha, status
|
||||||
|
5. **terminal_locks** — repo/org, terminal_pr, review_id, decision, status, cleanup_state
|
||||||
|
6. **events** — event_id, work_item_id, type, message, created_at
|
||||||
|
7. **incident_links** — provider-neutral link model (see §8)
|
||||||
|
|
||||||
|
**Explicit non-kind:** do **not** use `work_items.kind = sentry_incident` (or glitchtip_incident) as an assignable work unit. Incidents become work only after the bridge creates/links a **Gitea issue**.
|
||||||
|
|
||||||
|
## 7. Lease migration (avoid split-brain)
|
||||||
|
|
||||||
|
### 7.1 Target state
|
||||||
|
|
||||||
|
- **Primary** for live coordination: **control-plane DB** leases and assignments.
|
||||||
|
- **Gitea comment leases** (e.g. `<!-- mcp-review-lease:v1 -->`) and **local issue-lock files** become:
|
||||||
|
- **mirrors** of DB state for human visibility / recovery, and/or
|
||||||
|
- written **only** through the allocator (or sanctioned lease tools that update DB + mirror in one workflow).
|
||||||
|
|
||||||
|
### 7.2 Migration rules
|
||||||
|
|
||||||
|
1. New exclusive claims go through DB assignment+lease first.
|
||||||
|
2. Comment lease / file lock writers that skip the DB are **deprecated** once #613+#600 land.
|
||||||
|
3. Reconciler tooling heals: expired DB lease, orphan Gitea comment, orphan local file — fail closed when ambiguous.
|
||||||
|
4. **Split-brain is a defect:** “DB free + Gitea comment leased” or “DB leased + Gitea free” must be detectable and reconcilable; production paths must not rely on two independent writers.
|
||||||
|
|
||||||
|
### 7.3 Heartbeats
|
||||||
|
|
||||||
|
- Heartbeats update the **DB**.
|
||||||
|
- Optional Gitea summaries must avoid comment spam (periodic summary or edit-in-place policy).
|
||||||
|
|
||||||
|
## 8. Observability bridge (#612)
|
||||||
|
|
||||||
|
### 8.1 Role
|
||||||
|
|
||||||
|
The bridge:
|
||||||
|
|
||||||
|
1. Scans configured Sentry and/or GlitchTip projects (multi-project mappings).
|
||||||
|
2. Deduplicates against existing links / Gitea issues.
|
||||||
|
3. Creates or updates **normal Gitea issues** (labels, sanitized body, provider URL).
|
||||||
|
4. Upserts **one** canonical `incident_links` row.
|
||||||
|
5. Optionally writes resolution status back to the provider when supported.
|
||||||
|
6. Never approves, merges, closes, or otherwise bypasses Gitea workflow gates.
|
||||||
|
|
||||||
|
### 8.2 Allocator interaction
|
||||||
|
|
||||||
|
- The allocator sees observability work **only after** a Gitea issue exists (typically `status:ready` + observability labels).
|
||||||
|
- **Do not assign raw Sentry/GlitchTip incidents** as work items.
|
||||||
|
- Bridge AC “allocator can see linked issues” means: **as ordinary Gitea issues**, not a special incident queue.
|
||||||
|
|
||||||
|
### 8.3 Provider adapters and trust
|
||||||
|
|
||||||
|
- Separate **Sentry** and **GlitchTip** adapters; document API differences; do not assume writeback parity.
|
||||||
|
- Self-hosted base URLs supported (e.g. `https://sentry.prgs.cc`).
|
||||||
|
- Tokens from environment / secret store only.
|
||||||
|
- Prefer phase-1 **explicit reconcile / dry-run** before unsupervised watchdog auto-filing, consistent with existing GlitchTip filing safety contracts.
|
||||||
|
|
||||||
|
### 8.4 Home of implementation
|
||||||
|
|
||||||
|
Filing/orchestration may live in:
|
||||||
|
|
||||||
|
- a control-plane / bridge package or profile, and/or
|
||||||
|
- Gitea-Tools as operator-facing tools that **delegate** to that boundary,
|
||||||
|
|
||||||
|
but must not violate §4.4 credential isolation.
|
||||||
|
|
||||||
|
## 9. Incident link storage (single source of truth)
|
||||||
|
|
||||||
|
### 9.1 Canonical model: `incident_links` (provider-neutral)
|
||||||
|
|
||||||
|
Prefer a **provider-neutral** model over Sentry-only `sentry_links`:
|
||||||
|
|
||||||
|
| Field (logical) | Purpose |
|
||||||
|
|-----------------|---------|
|
||||||
|
| provider | `sentry` \| `glitchtip` \| … |
|
||||||
|
| provider_base_url | Self-hosted base |
|
||||||
|
| provider_org / provider_project | Mapping key |
|
||||||
|
| provider_issue_id / provider_short_id | Provider identity |
|
||||||
|
| provider_permalink | Human URL |
|
||||||
|
| fingerprint | Stable dedupe key when available |
|
||||||
|
| gitea_org / gitea_repo / gitea_issue_number | Linked work |
|
||||||
|
| linked_pr_numbers | Optional PR association |
|
||||||
|
| first_seen / last_seen / event_count | Summary metrics (safe) |
|
||||||
|
| status | link lifecycle |
|
||||||
|
| release_resolved_at / last_sync_at | Sync bookkeeping |
|
||||||
|
|
||||||
|
### 9.2 Gitea as human mirror
|
||||||
|
|
||||||
|
- Issue body markers / structured comments (e.g. HTML comment metadata) remain the **human- and audit-visible mirror**.
|
||||||
|
- They must stay consistent with `incident_links` but are **not** a second independent write path for inventing links without the bridge.
|
||||||
|
|
||||||
|
### 9.3 One truth
|
||||||
|
|
||||||
|
- **Do not** maintain two independent systems of record (e.g. full mapping only in #612 storage **and** a separate #613 `sentry_links` with different semantics).
|
||||||
|
- #612 implementation **must** use the same `incident_links` model introduced under #613 (or a single agreed store both reference).
|
||||||
|
|
||||||
|
## 10. Security and redaction
|
||||||
|
|
||||||
|
Mandatory for bridge payloads, Gitea issue bodies/comments, DB-stored event summaries, and logs:
|
||||||
|
|
||||||
|
- No API tokens, DSNs, passwords, cookies, auth headers
|
||||||
|
- No keychain IDs, private config contents, raw session-state files, full prompt bodies
|
||||||
|
- Sanitize stack locals and request data; store only safe tags/context
|
||||||
|
- Logs must never print provider tokens or DSNs
|
||||||
|
- Redaction tests are required for #612
|
||||||
|
|
||||||
|
## 11. Non-goals
|
||||||
|
|
||||||
|
- Replace Gitea as the durable workflow record
|
||||||
|
- Let Sentry/GlitchTip assign work or mutate Gitea workflow state outside sanctioned tools
|
||||||
|
- Let the bridge approve, merge, close, release, or bypass Gitea gates
|
||||||
|
- Implement #600 on file locks / comments alone and call allocator complete
|
||||||
|
- Treat raw monitoring incidents as `work_items` for the allocator
|
||||||
|
- Put monitor tokens into every Gitea MCP worker process by default
|
||||||
|
|
||||||
|
## 12. Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Clear implementation order and ownership
|
||||||
|
- Multi-session safety becomes testable at the DB layer
|
||||||
|
- Observability becomes assignable work without special-casing the allocator
|
||||||
|
- Trust boundaries stay compatible with existing control-plane docs
|
||||||
|
|
||||||
|
### Costs / risks
|
||||||
|
|
||||||
|
- Migration from comment/file leases requires reconciler work
|
||||||
|
- Shared Postgres or single allocator daemon is operational cost
|
||||||
|
- Bridge must be careful not to spam Gitea issues (dedupe, caps, policy modes)
|
||||||
|
|
||||||
|
### Follow-ups (documentation only until implemented)
|
||||||
|
|
||||||
|
1. Update #600, #612, and #613 bodies to **link this ADR** and restate hard dependencies.
|
||||||
|
2. Implement #613 schema + atomic assign/lease.
|
||||||
|
3. Implement #600 against the DB.
|
||||||
|
4. Implement #612 against `incident_links` + Gitea create/update.
|
||||||
|
5. Deprecate dual writers for leases.
|
||||||
|
|
||||||
|
## 13. Acceptance criteria for *this* ADR
|
||||||
|
|
||||||
|
This document is accepted when:
|
||||||
|
|
||||||
|
1. It is merged into `docs/architecture/` on the default branch.
|
||||||
|
2. #600 / #612 / #613 (or their PRs) reference this path as the architecture source of truth.
|
||||||
|
3. No implementation PR for those issues claims completion without conforming to §§2–11.
|
||||||
|
|
||||||
|
## 14. Document history
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-09 | Initial ADR: dependency order, authority model, topology, lease migration, bridge, `incident_links`, security, non-goals |
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
# ADR: Stable control runtime vs dev runtime (Gitea MCP)
|
|
||||||
|
|
||||||
- **Status:** Accepted (policy effective immediately for LLM sessions; tooling may lag)
|
|
||||||
- **Date:** 2026-07-09
|
|
||||||
- **Tracking issue:** [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615)
|
|
||||||
- **Related:**
|
|
||||||
- [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) / `docs/mcp-namespace-health.md` — client-namespace health
|
|
||||||
- `docs/mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery (no PID kill)
|
|
||||||
- [#558](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/558) / `docs/mcp-daemon-import-guard.md` — sanctioned daemon
|
|
||||||
- [#557](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/557) / `docs/bootstrap-review-path.md` — controller bootstrap for self-hosted fixes
|
|
||||||
- Allocator / control-plane ADR: `docs/architecture/mcp-allocator-control-plane-observability-adr.md` (#613 / PR #614)
|
|
||||||
|
|
||||||
## 1. Context
|
|
||||||
|
|
||||||
The Gitea MCP server is the **control plane** for real issue/PR mutations (create, comment, lock, review, merge, etc.). When author/reviewer/merger/reconciler sessions kill or restart that process, relaunch it from a feature worktree, or edit the checkout that process loads, operators observe:
|
|
||||||
|
|
||||||
- Mid-session identity/preflight resets
|
|
||||||
- Stale-runtime vs master parity failures
|
|
||||||
- IDE transport EOF / “tool not found” while code on disk has changed
|
|
||||||
- Accidental production mutations from experimental code
|
|
||||||
|
|
||||||
This ADR separates **stable control runtime** from **dev/test runtime** and defines promotion proof.
|
|
||||||
|
|
||||||
## 2. Decision
|
|
||||||
|
|
||||||
### 2.1 Stable control runtime
|
|
||||||
|
|
||||||
The Gitea MCP server used for **real workflow mutations** is the **stable control runtime**.
|
|
||||||
|
|
||||||
Characteristics:
|
|
||||||
|
|
||||||
- Loads a known, promoted revision of Gitea-Tools (or the packaged release layout operators designate)
|
|
||||||
- Registered in the IDE/client as the production namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, etc.)
|
|
||||||
- Holds production profile credentials via sanctioned keychain/env paths only
|
|
||||||
|
|
||||||
### 2.2 Dev / test runtime
|
|
||||||
|
|
||||||
MCP **server code** development and testing:
|
|
||||||
|
|
||||||
- Happens in isolated **`branches/`** worktrees (or other non-stable checkouts)
|
|
||||||
- May use a **separate** dev/test MCP runtime/process when process-level testing is required
|
|
||||||
- **Must not** be used for real Gitea mutations on production issues/PRs
|
|
||||||
|
|
||||||
### 2.3 Forbidden actions (normal sessions)
|
|
||||||
|
|
||||||
Normal **author, reviewer, merger, and reconciler** LLM sessions **must not**:
|
|
||||||
|
|
||||||
| Forbidden | Why |
|
|
||||||
|-----------|-----|
|
|
||||||
| Kill the running MCP server process | Drops all concurrent sessions; loses preflight state |
|
|
||||||
| Restart / relaunch the MCP server process | Same as kill; causes stale/identity churn mid-workflow |
|
|
||||||
| Relaunch MCP from a development worktree | Runs unpromoted code against production mutations |
|
|
||||||
| Edit files in the stable runtime checkout | Hot-mutates control plane under concurrent users |
|
|
||||||
| Use experimental/dev MCP for real Gitea mutations | Bypasses promotion proof and audit expectations |
|
|
||||||
| Bypass or self-reset a stale master-parity gate | The gate is fail-closed; only an operator reload restores parity |
|
|
||||||
|
|
||||||
**LLM-allowed vs operator-owned (authoritative split):**
|
|
||||||
|
|
||||||
| Actor | May do | Must not do |
|
|
||||||
|-------|--------|-------------|
|
|
||||||
| **LLM session** | Call tools on the already-running stable namespaces; **client reconnect** after transport EOF (no process kill); pass `worktree_path` / role worktree args; report blockers and stop mutations when unhealthy/stale | Kill, restart, or relaunch any MCP process; bump config mtimes to force reload; edit the stable checkout; switch to a dev MCP for production mutations |
|
|
||||||
| **Operator / release-manager** | Supervised restart/reload of the **stable** control runtime; dual-namespace client configuration; §2.4 promotions; incident recovery | — |
|
|
||||||
|
|
||||||
EOF / transport recovery for LLM sessions: **client reconnect only** (see `docs/mcp-namespace-eof-recovery.md`). Do not “fix” health by killing PIDs or bumping MCP config mtimes as a normal session procedure.
|
|
||||||
|
|
||||||
This ADR **supersedes** any older runbook wording that told the LLM to relaunch or restart the client/MCP as a self-service step. Where `docs/llm-workflow-runbooks.md` (or wiki runbooks) discuss dual-namespace setup or workspace rebind, **process restart/relaunch is operator-owned**; the LLM stops, reports, and waits.
|
|
||||||
|
|
||||||
### 2.4 Promotion (operator / release-manager only)
|
|
||||||
|
|
||||||
Promotion of a new revision into the stable control runtime is an **explicit operator/release-manager action**, not an LLM self-service step.
|
|
||||||
|
|
||||||
A promotion **must record** (issue comment, release note, or promotion ledger):
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| **previous runtime SHA** | Commit previously loaded by stable runtime |
|
|
||||||
| **promoted runtime SHA** | Commit after promotion |
|
|
||||||
| **source branch/PR** | Where the change was reviewed |
|
|
||||||
| **restart/reload method** | How the process was cycled (e.g. supervised restart, client reload) |
|
|
||||||
| **health check proof** | Client-namespace probe success (`gitea_whoami` / namespace health) |
|
|
||||||
| **identity/profile proof** | Expected profile(s) and username(s) after reload |
|
|
||||||
| **workspace/root proof** | Stable checkout path / root matches intended layout |
|
|
||||||
| **mutation capability proof** | Required permissions for the target role present; forbidden ops still forbidden |
|
|
||||||
| **rollback instructions** | How to restore previous SHA and re-verify health |
|
|
||||||
|
|
||||||
Suggested durable marker:
|
|
||||||
|
|
||||||
```text
|
|
||||||
## MCP STABLE RUNTIME PROMOTION (#615)
|
|
||||||
|
|
||||||
Status: COMPLETED | ROLLED_BACK | ABORTED
|
|
||||||
Previous-SHA: <full sha>
|
|
||||||
Promoted-SHA: <full sha>
|
|
||||||
Source-PR: <number>
|
|
||||||
Source-Branch: <name>
|
|
||||||
Reload-Method: <text>
|
|
||||||
Health-Proof: client_namespace whoami OK / assess_mcp_namespace_health OK
|
|
||||||
Identity-Proof: profile=<name> user=<name>
|
|
||||||
Workspace-Proof: root=<path>
|
|
||||||
Mutation-Proof: allowed_ops include <…>; forbidden include <…>
|
|
||||||
Rollback: checkout <previous sha>; reload method <…>; re-run health/identity proofs
|
|
||||||
Operator: <username>
|
|
||||||
Timestamp: <ISO-8601>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.5 Unhealthy stable runtime → stop work
|
|
||||||
|
|
||||||
If the stable MCP runtime is **unhealthy**, including any of:
|
|
||||||
|
|
||||||
- client-namespace probes fail
|
|
||||||
- wrong identity / wrong profile
|
|
||||||
- wrong workspace root
|
|
||||||
- missing mutation capability for the intended role
|
|
||||||
- persistent EOF after **client reconnect**
|
|
||||||
- **master parity is stale** (`startup_head` behind on-disk `master` / `restart_required` from the parity gate)
|
|
||||||
|
|
||||||
then:
|
|
||||||
|
|
||||||
1. **Normal PR / review / merge / issue-mutation work must stop immediately.**
|
|
||||||
2. The session **reports** the unhealthy/stale state (tool error, CTH, or operator handoff) with startup vs current head when known.
|
|
||||||
3. Do **not** improvise: no LLM process kill/restart, no dev-worktree MCP for production mutations, no env escape hatches, no manual gate bypass.
|
|
||||||
4. Resume only after:
|
|
||||||
- an **operator** restores the runtime (see §2.6 for routine post-merge parity reload), **or**
|
|
||||||
- a **controlled** promotion/rollback completes with the §2.4 promotion record, **or**
|
|
||||||
- a controller invokes the narrow **bootstrap review path** (#557) when the defect is self-hosted and documented,
|
|
||||||
- **and** the session re-verifies health (and master parity when applicable) before the next mutation.
|
|
||||||
|
|
||||||
### 2.6 Routine post-merge master-parity staleness (operator reload, not promotion)
|
|
||||||
|
|
||||||
**Symptom:** After merges land on `master`, a long-lived stable MCP process still runs the pre-merge `startup_head`. The master-parity gate marks the server **stale** / `restart_required` and **blocks mutations**.
|
|
||||||
|
|
||||||
**Sanctioned response (authoritative):**
|
|
||||||
|
|
||||||
| Step | Actor | Action |
|
|
||||||
|------|-------|--------|
|
|
||||||
| 1 | LLM session | Mutations stop immediately when the gate reports stale. |
|
|
||||||
| 2 | LLM session | Report the stale state (startup head, current master head, that operator reload is required). Do not retry mutations. |
|
|
||||||
| 3 | **Operator** | Reload/restart the **stable** control MCP so it loads current `master` (supervised client/daemon reload). |
|
|
||||||
| 4 | LLM session | Resume only after startup/current-head parity is verified (e.g. `gitea_get_runtime_context` / parity assessment shows in parity). |
|
|
||||||
|
|
||||||
**Not a §2.4 promotion:** Catching the already-designated stable control checkout up to a newly advanced `master` is a **routine operator reload** of the stable runtime. It does **not** require the nine-field promotion ledger. Use §2.4 only when **changing which unpromoted/dev revision** becomes the stable control runtime (new source branch/PR into the stable designation).
|
|
||||||
|
|
||||||
**Code note:** `master_parity_gate.py` may still say “restart the server” in machine-facing reason strings. That string names the **operator recovery action**, not an LLM self-service instruction. This ADR and the runbooks define the actor split.
|
|
||||||
|
|
||||||
## 3. Relationship to other controls
|
|
||||||
|
|
||||||
| Doc / mechanism | Interaction |
|
|
||||||
|-----------------|-------------|
|
|
||||||
| Namespace health (#543) | Proves IDE client can call tools; does not authorize restart |
|
|
||||||
| EOF recovery | Reconnect only; no process kill |
|
|
||||||
| Daemon import guard (#558) | Mutations require sanctioned daemon; not a bare shell import |
|
|
||||||
| Bootstrap path (#557) | Only controller-authorized exception when live runtime cannot review its own fix |
|
|
||||||
| Allocator / control-plane ADR | Coordination DB is separate; still depends on a healthy MCP surface for Gitea writes |
|
|
||||||
|
|
||||||
## 4. Consequences
|
|
||||||
|
|
||||||
### Positive
|
|
||||||
|
|
||||||
- Predictable control plane for concurrent LLMs
|
|
||||||
- Clear operator-only promotion gate with rollback
|
|
||||||
- Aligns session behavior with health/EOF docs already landed
|
|
||||||
|
|
||||||
### Costs
|
|
||||||
|
|
||||||
- LLM sessions must wait when runtime is sick (no DIY restart)
|
|
||||||
- Operators must maintain promotion discipline and dual-runtime config if they use a dev MCP
|
|
||||||
|
|
||||||
### Non-goals
|
|
||||||
|
|
||||||
- Does not ban operator-supervised restarts during incidents
|
|
||||||
- Does not replace CI or code review for MCP changes
|
|
||||||
- Does not authorize editing stable checkout “because tests need a quick fix”
|
|
||||||
|
|
||||||
## 5. Implementation follow-ups (optional tooling)
|
|
||||||
|
|
||||||
These may land in later issues; the **policy binds sessions now**:
|
|
||||||
|
|
||||||
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
|
|
||||||
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
|
|
||||||
3. Promotion checklist script that emits the durable promotion marker fields.
|
|
||||||
|
|
||||||
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
|
|
||||||
|
|
||||||
## 6. Acceptance for this ADR
|
|
||||||
|
|
||||||
1. Document merged under `docs/architecture/mcp-stable-control-runtime-policy-adr.md`.
|
|
||||||
2. **Operator guide / runbooks cross-link this ADR** (`docs/wiki/Operator-Guide.md`, `docs/wiki/Runbooks.md`, `docs/llm-workflow-runbooks.md`).
|
|
||||||
3. Issue #615 references this path.
|
|
||||||
4. LLM/operator runbooks treat kill/restart/relaunch-from-worktree as **LLM violations**; process restart is **operator-owned**.
|
|
||||||
5. Unhealthy runtime (including **stale master parity**) stops normal mutation work until operator restore/reload, promotion/rollback, or #557 bootstrap — then re-verify parity before mutating.
|
|
||||||
6. Routine post-merge parity reload is documented as operator reload (§2.6), not an LLM self-restart and not a full §2.4 promotion.
|
|
||||||
|
|
||||||
## 7. Document history
|
|
||||||
|
|
||||||
| Date | Change |
|
|
||||||
|------|--------|
|
|
||||||
| 2026-07-09 | Initial ADR: stable vs dev runtime, forbidden session actions, promotion proof fields, stop-work rule |
|
|
||||||
| 2026-07-16 | Review 443 remediation (#615 / PR #616): mandatory cross-links; LLM vs operator restart split; routine post-merge parity staleness (§2.6); stale parity in §2.5 unhealthy triggers |
|
|
||||||
@@ -195,7 +195,7 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
|
|||||||
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
||||||
review or author workflows.
|
review or author workflows.
|
||||||
|
|
||||||
* **Fallback (operator-owned):** If the dual-profile MCP launcher pattern is not supported or configured in the client, **do not** have the LLM relaunch or restart the client/MCP. The LLM **stops** role-switching work, reports that the correct static namespace is missing, and waits for an **operator** to configure dual namespaces or reload the client with the correct `GITEA_MCP_PROFILE` for that role. Process restart/relaunch is operator-owned under the [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
|
||||||
|
|
||||||
## Setup runbook — interactive menu
|
## Setup runbook — interactive menu
|
||||||
|
|
||||||
@@ -1200,13 +1200,10 @@ When a mutation blocks on workspace binding:
|
|||||||
|
|
||||||
1. Read the error — it names the **resolved workspace path**, **role
|
1. Read the error — it names the **resolved workspace path**, **role
|
||||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||||
2. **LLM-allowed:** pass `worktree_path` on reviewer/merger mutation tools when
|
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
||||||
the active `branches/` worktree differs from the MCP process root; **client
|
workspace (or set the role-specific env var before launch).
|
||||||
reconnect** after transport EOF only (no process kill).
|
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
||||||
3. **Operator-owned:** if the wrong namespace process was launched, or a role-
|
branches/ worktree differs from the MCP process root.
|
||||||
specific `GITEA_*_WORKTREE` must be set at process start, an **operator**
|
|
||||||
reloads/relaunches the correct static namespace MCP. LLM sessions must not
|
|
||||||
kill or restart MCP processes (see [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md)).
|
|
||||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||||
own namespace — that destroys another agent's WIP.
|
own namespace — that destroys another agent's WIP.
|
||||||
|
|
||||||
@@ -1216,10 +1213,9 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
|
|
||||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||||
- Quote the resolved workspace path and binding source from the error.
|
- Quote the resolved workspace path and binding source from the error.
|
||||||
- Name the safe next action: pass `worktree_path` if that unblocks the tool, or
|
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
||||||
request an **operator** reload of the correct namespace MCP / env binding.
|
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
||||||
- Explicitly note that foreign worktrees must not be cleaned to unblock, and
|
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
||||||
that the LLM must not self-restart the MCP process.
|
|
||||||
|
|
||||||
## Safety notes
|
## Safety notes
|
||||||
|
|
||||||
@@ -1230,7 +1226,6 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
|
|
||||||
## Related documents
|
## Related documents
|
||||||
|
|
||||||
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
|
|
||||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ Handbook for LLM operators and human developers using the Gitea-Tools MCP server
|
|||||||
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
||||||
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
||||||
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
||||||
7. **Stable control runtime** — Real Gitea mutations use only the **stable** MCP control runtime. LLM sessions must not kill, restart, or relaunch MCP processes, edit the stable checkout, or use a dev MCP for production mutations. See the policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
|
||||||
|
|
||||||
## Supported Gitea instances
|
## Supported Gitea instances
|
||||||
|
|
||||||
@@ -31,4 +30,3 @@ Always pass `remote` explicitly on tool calls. The server default is `dadeschool
|
|||||||
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
||||||
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
||||||
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
||||||
5. Confirm master parity / runtime health when tools report stale or unhealthy control runtime — stop mutations and request an **operator** reload of the stable MCP (see [stable control runtime ADR](../architecture/mcp-stable-control-runtime-policy-adr.md)).
|
|
||||||
@@ -12,17 +12,6 @@
|
|||||||
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
||||||
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
||||||
|
|
||||||
## Stable MCP control runtime (#615)
|
|
||||||
|
|
||||||
Policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md).
|
|
||||||
|
|
||||||
| Situation | Who acts | What to do |
|
|
||||||
|-----------|----------|------------|
|
|
||||||
| Transport EOF / missing tools | LLM | **Client reconnect only** — do not kill PIDs |
|
|
||||||
| Wrong profile / dual-namespace missing | Operator | Configure or reload the correct static namespace(s) |
|
|
||||||
| Master parity stale after merge | LLM stops + reports; **operator** reloads stable MCP | Resume only after parity re-verified |
|
|
||||||
| Promote unpromoted MCP code to stable | Operator / release-manager only | Full §2.4 promotion record |
|
|
||||||
|
|
||||||
## Gitea Wiki sync
|
## Gitea Wiki sync
|
||||||
|
|
||||||
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
||||||
|
|||||||
+104
-33
@@ -1367,8 +1367,26 @@ def cleanup_in_progress_for_pr(pr_payload: dict, remote: str, host: str | None,
|
|||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _effective_remote(remote: str) -> str:
|
||||||
|
"""If remote is the default ('dadeschools') but the active profile base_url maps to a known remote, use that remote instead."""
|
||||||
|
try:
|
||||||
|
profile = get_profile()
|
||||||
|
base_url = profile.get("base_url")
|
||||||
|
if remote == "dadeschools" and base_url:
|
||||||
|
import urllib.parse
|
||||||
|
url = urllib.parse.urlparse(base_url)
|
||||||
|
host = (url.netloc or url.path or "").strip().lower()
|
||||||
|
for k, v in REMOTES.items():
|
||||||
|
if v.get("host") == host:
|
||||||
|
return k
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return remote
|
||||||
|
|
||||||
|
|
||||||
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
|
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
|
||||||
"""Resolve remote + overrides to (host, org, repo)."""
|
"""Resolve remote + overrides to (host, org, repo)."""
|
||||||
|
remote = _effective_remote(remote)
|
||||||
if remote not in REMOTES:
|
if remote not in REMOTES:
|
||||||
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
|
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
|
||||||
profile = REMOTES[remote]
|
profile = REMOTES[remote]
|
||||||
@@ -1402,6 +1420,7 @@ def _resolve_control_plane_guide_target(
|
|||||||
if org is not None and repo is not None:
|
if org is not None and repo is not None:
|
||||||
return _resolve(remote, host, org, repo)
|
return _resolve(remote, host, org, repo)
|
||||||
|
|
||||||
|
remote = _effective_remote(remote)
|
||||||
if remote not in REMOTES:
|
if remote not in REMOTES:
|
||||||
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
|
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
|
||||||
|
|
||||||
@@ -3076,11 +3095,15 @@ def check_review_decision_gate(
|
|||||||
)
|
)
|
||||||
|
|
||||||
prior = list(lock.get("live_mutations") or [])
|
prior = list(lock.get("live_mutations") or [])
|
||||||
|
ready_head = lock.get("ready_expected_head_sha")
|
||||||
|
if stale_review_decision_lock.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=pr_number, expected_head_sha=ready_head
|
||||||
|
):
|
||||||
if prior and not lock.get("correction_authorized"):
|
if prior and not lock.get("correction_authorized"):
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"live review mutation already recorded in this run; only one live "
|
"live review mutation already recorded for this PR head in this "
|
||||||
"review mutation is allowed unless "
|
"run; only one live review mutation is allowed per head unless "
|
||||||
"gitea_authorize_review_correction was invoked (fail closed)"
|
"gitea_authorize_review_correction was invoked (fail closed, #620)"
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
action in _TERMINAL_REVIEW_ACTIONS
|
action in _TERMINAL_REVIEW_ACTIONS
|
||||||
@@ -3088,9 +3111,9 @@ def check_review_decision_gate(
|
|||||||
and not lock.get("correction_authorized")
|
and not lock.get("correction_authorized")
|
||||||
):
|
):
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"terminal review decision already submitted on this PR in this "
|
"terminal review decision already submitted for this PR head in "
|
||||||
"run; blocked unless an operator-approved correction was "
|
"this run; blocked unless an operator-approved correction was "
|
||||||
"authorized (fail closed)"
|
"authorized (fail closed, #620)"
|
||||||
)
|
)
|
||||||
|
|
||||||
return reasons
|
return reasons
|
||||||
@@ -3099,12 +3122,18 @@ def check_review_decision_gate(
|
|||||||
def record_live_review_mutation(pr_number: int, action: str, review_id: int | None = None):
|
def record_live_review_mutation(pr_number: int, action: str, review_id: int | None = None):
|
||||||
lock = _load_review_decision_lock() or {}
|
lock = _load_review_decision_lock() or {}
|
||||||
mutations = list(lock.get("live_mutations") or [])
|
mutations = list(lock.get("live_mutations") or [])
|
||||||
mutations.append({
|
head_sha = stale_review_decision_lock.normalize_head_sha(
|
||||||
|
lock.get("ready_expected_head_sha")
|
||||||
|
)
|
||||||
|
entry = {
|
||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
"action": action,
|
"action": action,
|
||||||
"review_id": review_id,
|
"review_id": review_id,
|
||||||
"review_state": action,
|
"review_state": action,
|
||||||
})
|
}
|
||||||
|
if head_sha:
|
||||||
|
entry["head_sha"] = head_sha
|
||||||
|
mutations.append(entry)
|
||||||
lock["live_mutations"] = mutations
|
lock["live_mutations"] = mutations
|
||||||
if lock.get("correction_authorized"):
|
if lock.get("correction_authorized"):
|
||||||
lock["correction_authorized"] = False
|
lock["correction_authorized"] = False
|
||||||
@@ -3112,17 +3141,28 @@ def record_live_review_mutation(pr_number: int, action: str, review_id: int | No
|
|||||||
_save_review_decision_lock(lock)
|
_save_review_decision_lock(lock)
|
||||||
|
|
||||||
|
|
||||||
def terminal_review_hard_stop_reasons(pr_number: int, operation: str) -> list[str]:
|
def terminal_review_hard_stop_reasons(
|
||||||
"""Session hard-stop after a terminal live review mutation (#332).
|
pr_number: int,
|
||||||
|
operation: str,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Session hard-stop after a terminal live review mutation (#332 / #620).
|
||||||
|
|
||||||
After a terminal verdict is consumed in this run, the only permitted
|
After a terminal verdict is consumed for a given PR **head**, the only
|
||||||
continuation is the merge sequence for the same PR that was approved.
|
permitted continuation is the merge sequence for that same approved PR
|
||||||
A REQUEST_CHANGES (or an approval of a different PR) blocks every
|
(merge does not require a new head). A REQUEST_CHANGES (or an approval of
|
||||||
further review/mark-ready/merge mutation. An operator-approved
|
a different PR, or the same head) blocks further review/mark-ready/merge
|
||||||
correction (#211) re-opens the review path only — never a cross-PR
|
mutations for that boundary.
|
||||||
merge.
|
|
||||||
|
|
||||||
*operation* is one of 'merge', 'mark_ready', or 'review'.
|
#620: when *expected_head_sha* differs from the last terminal's reviewed
|
||||||
|
head on the **same open PR**, mark_ready / review / resume may proceed so
|
||||||
|
a fresh formal decision can be recorded for the new head. Historical
|
||||||
|
mutations are preserved.
|
||||||
|
|
||||||
|
An operator-approved correction (#211) re-opens the review path only —
|
||||||
|
never a cross-PR merge.
|
||||||
|
|
||||||
|
*operation* is one of 'merge', 'mark_ready', 'review', or 'resume'.
|
||||||
Returns [] when the operation may proceed.
|
Returns [] when the operation may proceed.
|
||||||
"""
|
"""
|
||||||
lock = _load_review_decision_lock()
|
lock = _load_review_decision_lock()
|
||||||
@@ -3141,8 +3181,19 @@ def terminal_review_hard_stop_reasons(pr_number: int, operation: str) -> list[st
|
|||||||
and last.get("pr_number") == pr_number
|
and last.get("pr_number") == pr_number
|
||||||
):
|
):
|
||||||
return []
|
return []
|
||||||
if operation in ("mark_ready", "review") and lock.get("correction_authorized"):
|
if operation in ("mark_ready", "review", "resume") and lock.get(
|
||||||
|
"correction_authorized"
|
||||||
|
):
|
||||||
return []
|
return []
|
||||||
|
# #620: same PR, different reviewed head → fresh decision boundary.
|
||||||
|
if stale_review_decision_lock.terminal_boundary_allows_fresh_decision(
|
||||||
|
lock,
|
||||||
|
pr_number=pr_number,
|
||||||
|
expected_head_sha=expected_head_sha,
|
||||||
|
operation=operation,
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
locked_head = stale_review_decision_lock.mutation_head_sha(last, lock)
|
||||||
if last.get("action") == "approve":
|
if last.get("action") == "approve":
|
||||||
guidance = (
|
guidance = (
|
||||||
f"only the merge sequence for approved PR "
|
f"only the merge sequence for approved PR "
|
||||||
@@ -3150,15 +3201,22 @@ def terminal_review_hard_stop_reasons(pr_number: int, operation: str) -> list[st
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
guidance = "the session must stop and produce a final report"
|
guidance = "the session must stop and produce a final report"
|
||||||
|
head_note = (
|
||||||
|
f" at head {locked_head[:12]}…"
|
||||||
|
if locked_head
|
||||||
|
else ""
|
||||||
|
)
|
||||||
recovery = (
|
recovery = (
|
||||||
"if that PR is already merged/closed, use "
|
"if that PR is already merged/closed, use "
|
||||||
"gitea_cleanup_stale_review_decision_lock (apply=true) after live-state "
|
"gitea_cleanup_stale_review_decision_lock (apply=true) after live-state "
|
||||||
"proof (#594); never delete session-state files by hand"
|
"proof (#594); if the PR is still open but the head moved, mark/submit "
|
||||||
|
"with the new expected_head_sha (#620); never delete session-state "
|
||||||
|
"files by hand"
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
"terminal review mutation already consumed in this run "
|
"terminal review mutation already consumed in this run "
|
||||||
f"({last.get('action')} on PR #{last.get('pr_number')}); {guidance} "
|
f"({last.get('action')} on PR #{last.get('pr_number')}{head_note}); "
|
||||||
f"(fail closed, #332); {recovery}"
|
f"{guidance} (fail closed, #332); {recovery}"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -3781,18 +3839,11 @@ def gitea_mark_final_review_decision(
|
|||||||
"reasons": workflow_blockers + (
|
"reasons": workflow_blockers + (
|
||||||
review_workflow_load.recovery_handoff_without_replay()),
|
review_workflow_load.recovery_handoff_without_replay()),
|
||||||
}
|
}
|
||||||
hard_stop = terminal_review_hard_stop_reasons(pr_number, "mark_ready")
|
hard_stop = terminal_review_hard_stop_reasons(
|
||||||
|
pr_number, "mark_ready", expected_head_sha=expected_head_sha
|
||||||
|
)
|
||||||
if hard_stop:
|
if hard_stop:
|
||||||
return {"marked_ready": False, "reasons": hard_stop}
|
return {"marked_ready": False, "reasons": hard_stop}
|
||||||
if lock.get("live_mutations") and not lock.get("correction_authorized"):
|
|
||||||
return {
|
|
||||||
"marked_ready": False,
|
|
||||||
"reasons": [
|
|
||||||
"cannot mark final decision after a live review mutation was "
|
|
||||||
"already recorded in this run unless an operator-approved "
|
|
||||||
"correction was authorized"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
if action not in _REVIEW_ACTIONS:
|
if action not in _REVIEW_ACTIONS:
|
||||||
return {
|
return {
|
||||||
"marked_ready": False,
|
"marked_ready": False,
|
||||||
@@ -3809,6 +3860,17 @@ def gitea_mark_final_review_decision(
|
|||||||
"decision (fail closed, #399)"
|
"decision (fail closed, #399)"
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
if stale_review_decision_lock.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"marked_ready": False,
|
||||||
|
"reasons": [
|
||||||
|
"cannot mark final decision after a live review mutation was "
|
||||||
|
"already recorded for this PR head unless an operator-approved "
|
||||||
|
"correction was authorized (fail closed, #620)"
|
||||||
|
],
|
||||||
|
}
|
||||||
elig = gitea_check_pr_eligibility(
|
elig = gitea_check_pr_eligibility(
|
||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
action="review",
|
action="review",
|
||||||
@@ -3861,6 +3923,8 @@ def gitea_mark_final_review_decision(
|
|||||||
"#332)"
|
"#332)"
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
# Preserve historical terminal head boundaries before advancing ready_* (#620).
|
||||||
|
stale_review_decision_lock.backfill_terminal_heads_from_ready(lock)
|
||||||
lock["final_review_decision_ready"] = True
|
lock["final_review_decision_ready"] = True
|
||||||
lock["ready_pr_number"] = pr_number
|
lock["ready_pr_number"] = pr_number
|
||||||
lock["ready_action"] = action
|
lock["ready_action"] = action
|
||||||
@@ -3878,6 +3942,7 @@ def gitea_mark_final_review_decision(
|
|||||||
"org": org,
|
"org": org,
|
||||||
"repo": repo,
|
"repo": repo,
|
||||||
"final_review_decision_ready": True,
|
"final_review_decision_ready": True,
|
||||||
|
"head_scoped": True,
|
||||||
"reasons": [],
|
"reasons": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4044,6 +4109,12 @@ def gitea_cleanup_stale_review_decision_lock(
|
|||||||
"cleanup_allowed": assessment.get("cleanup_allowed"),
|
"cleanup_allowed": assessment.get("cleanup_allowed"),
|
||||||
"last_terminal_pr": assessment.get("last_terminal_pr"),
|
"last_terminal_pr": assessment.get("last_terminal_pr"),
|
||||||
"last_terminal_action": assessment.get("last_terminal_action"),
|
"last_terminal_action": assessment.get("last_terminal_action"),
|
||||||
|
"locked_head_sha": assessment.get("locked_head_sha"),
|
||||||
|
"current_pr_head_sha": assessment.get("current_pr_head_sha"),
|
||||||
|
"stale_by_head": assessment.get("stale_by_head"),
|
||||||
|
"fresh_review_on_current_head_allowed": assessment.get(
|
||||||
|
"fresh_review_on_current_head_allowed"
|
||||||
|
),
|
||||||
"pr_state": assessment.get("pr_state"),
|
"pr_state": assessment.get("pr_state"),
|
||||||
"pr_merged": assessment.get("pr_merged"),
|
"pr_merged": assessment.get("pr_merged"),
|
||||||
"pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
|
"pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
|
||||||
@@ -8750,6 +8821,7 @@ def gitea_whoami(
|
|||||||
metadata: profile_name + allowed_operations; never the token).
|
metadata: profile_name + allowed_operations; never the token).
|
||||||
"""
|
"""
|
||||||
record_preflight_check("whoami")
|
record_preflight_check("whoami")
|
||||||
|
remote = _effective_remote(remote)
|
||||||
if remote not in REMOTES:
|
if remote not in REMOTES:
|
||||||
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
|
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
|
||||||
h = host or REMOTES[remote]["host"]
|
h = host or REMOTES[remote]["host"]
|
||||||
@@ -8858,8 +8930,6 @@ def gitea_get_profile(
|
|||||||
"execution_profile": profile.get("execution_profile"),
|
"execution_profile": profile.get("execution_profile"),
|
||||||
"auth_source_type": profile.get("auth_source_type"),
|
"auth_source_type": profile.get("auth_source_type"),
|
||||||
# Auth is reported as a status only (#120): the token source *name*
|
# Auth is reported as a status only (#120): the token source *name*
|
||||||
# (env var name / keychain id) joins endpoint URLs behind the
|
|
||||||
# GITEA_MCP_REVEAL_ENDPOINTS admin opt-in. Token values never appear.
|
|
||||||
"auth_status": ("configured" if profile["token_source_name"]
|
"auth_status": ("configured" if profile["token_source_name"]
|
||||||
else "unconfigured"),
|
else "unconfigured"),
|
||||||
"remote": remote if remote in REMOTES else None,
|
"remote": remote if remote in REMOTES else None,
|
||||||
@@ -8871,6 +8941,7 @@ def gitea_get_profile(
|
|||||||
result["base_url"] = profile["base_url"]
|
result["base_url"] = profile["base_url"]
|
||||||
result["server"] = None
|
result["server"] = None
|
||||||
|
|
||||||
|
remote = _effective_remote(remote)
|
||||||
if remote not in REMOTES:
|
if remote not in REMOTES:
|
||||||
# Mark ambiguity rather than raising: the tool stays inspectable.
|
# Mark ambiguity rather than raising: the tool stays inspectable.
|
||||||
result["identity_status"] = "unknown"
|
result["identity_status"] = "unknown"
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ Runs over stdio. All tools authenticate via macOS keychain (git credential fill)
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
sys.stderr = open("/tmp/mcp_server_stderr.log", "a", buffering=1)
|
||||||
|
sys.stderr.write(f"\n--- MCP SERVER STARTUP (PID {os.getpid()}) ---\n")
|
||||||
|
|
||||||
from role_session_router import (
|
from role_session_router import (
|
||||||
python_bytes_have_conflict_markers,
|
python_bytes_have_conflict_markers,
|
||||||
skip_python_scan_walk_root,
|
skip_python_scan_walk_root,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594).
|
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620).
|
||||||
|
|
||||||
#332 correctly hard-stops a reviewer session after a terminal live review
|
#332 correctly hard-stops a reviewer session after a terminal live review
|
||||||
mutation. After #559 those locks are durable on disk, so they can outlive the
|
mutation. After #559 those locks are durable on disk, so they can outlive the
|
||||||
@@ -6,6 +6,12 @@ work they protect: when the referenced PR is already merged or closed, no
|
|||||||
same-PR merge sequence remains, yet the durable ledger still blocks unrelated
|
same-PR merge sequence remains, yet the durable ledger still blocks unrelated
|
||||||
new terminal reviews.
|
new terminal reviews.
|
||||||
|
|
||||||
|
#620 scopes the terminal boundary by **reviewed head SHA**: a prior
|
||||||
|
REQUEST_CHANGES (or APPROVE) on head A must not block a fresh formal decision
|
||||||
|
on head B of the **same open PR**. Same-head duplicates remain fail-closed.
|
||||||
|
Open-PR lock *cleanup* remains forbidden unless the PR is truly merged/closed
|
||||||
|
(#594); new-head re-review is allowed without deleting the durable lock.
|
||||||
|
|
||||||
This module is pure policy (no Gitea I/O). The MCP tool
|
This module is pure policy (no Gitea I/O). The MCP tool
|
||||||
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
|
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
|
||||||
helpers, and only then clears durable state when cleanup is allowed.
|
helpers, and only then clears durable state when cleanup is allowed.
|
||||||
@@ -23,6 +29,45 @@ from typing import Any
|
|||||||
TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
|
TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_head_sha(value: Any) -> str | None:
|
||||||
|
"""Normalize a git SHA for equality comparison; None if empty/invalid."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def heads_equal(a: Any, b: Any) -> bool:
|
||||||
|
na = normalize_head_sha(a)
|
||||||
|
nb = normalize_head_sha(b)
|
||||||
|
if not na or not nb:
|
||||||
|
return False
|
||||||
|
return na == nb
|
||||||
|
|
||||||
|
|
||||||
|
def mutation_head_sha(mutation: dict | None, lock: dict | None = None) -> str | None:
|
||||||
|
"""Head SHA that bounds a live mutation (#620).
|
||||||
|
|
||||||
|
Prefers fields recorded on the mutation itself. For *legacy* mutations
|
||||||
|
that predate head-scoping, falls back to the lock's
|
||||||
|
``ready_expected_head_sha`` only when that ready binding is for the same
|
||||||
|
PR number (the frozen durable PR #619-style ledger).
|
||||||
|
"""
|
||||||
|
if not isinstance(mutation, dict):
|
||||||
|
return None
|
||||||
|
for key in ("head_sha", "expected_head_sha", "reviewed_head_sha"):
|
||||||
|
head = normalize_head_sha(mutation.get(key))
|
||||||
|
if head:
|
||||||
|
return head
|
||||||
|
if not isinstance(lock, dict):
|
||||||
|
return None
|
||||||
|
if lock.get("ready_pr_number") != mutation.get("pr_number"):
|
||||||
|
return None
|
||||||
|
return normalize_head_sha(lock.get("ready_expected_head_sha"))
|
||||||
|
|
||||||
|
|
||||||
def last_terminal_mutation(lock: dict | None) -> dict | None:
|
def last_terminal_mutation(lock: dict | None) -> dict | None:
|
||||||
"""Return the last terminal live mutation on *lock*, or None."""
|
"""Return the last terminal live mutation on *lock*, or None."""
|
||||||
if not lock:
|
if not lock:
|
||||||
@@ -35,18 +80,125 @@ def last_terminal_mutation(lock: dict | None) -> dict | None:
|
|||||||
return terminals[-1] if terminals else None
|
return terminals[-1] if terminals else None
|
||||||
|
|
||||||
|
|
||||||
|
def prior_live_mutations_block_boundary(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
expected_head_sha: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when prior live mutations block a new decision for PR+head (#620).
|
||||||
|
|
||||||
|
Historical terminals on a **different head of the same PR** do not block.
|
||||||
|
Any prior mutation on another PR, the same head, or without a comparable
|
||||||
|
head SHA fails closed (blocks).
|
||||||
|
|
||||||
|
Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
|
||||||
|
that only stored ``ready_expected_head_sha`` still compare correctly
|
||||||
|
(PR #619-style).
|
||||||
|
"""
|
||||||
|
if not lock:
|
||||||
|
return False
|
||||||
|
if lock.get("correction_authorized"):
|
||||||
|
return False
|
||||||
|
prior = list(lock.get("live_mutations") or [])
|
||||||
|
if not prior:
|
||||||
|
return False
|
||||||
|
target = normalize_head_sha(expected_head_sha)
|
||||||
|
for m in prior:
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
return True
|
||||||
|
m_pr = m.get("pr_number")
|
||||||
|
m_head = mutation_head_sha(m, lock)
|
||||||
|
if (
|
||||||
|
m_pr == pr_number
|
||||||
|
and target
|
||||||
|
and m_head
|
||||||
|
and not heads_equal(m_head, target)
|
||||||
|
):
|
||||||
|
# Same open PR, different reviewed head — historical boundary only.
|
||||||
|
continue
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_boundary_allows_fresh_decision(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
expected_head_sha: str | None,
|
||||||
|
operation: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620).
|
||||||
|
|
||||||
|
Only for ``mark_ready`` / ``review`` / ``resume`` on the **same PR** when
|
||||||
|
the requested head differs from the last terminal's head. Merge is never
|
||||||
|
reopened by head change (approved head merge path is separate).
|
||||||
|
"""
|
||||||
|
if operation not in ("mark_ready", "review", "resume"):
|
||||||
|
return False
|
||||||
|
if not lock:
|
||||||
|
return False
|
||||||
|
if lock.get("correction_authorized"):
|
||||||
|
return True
|
||||||
|
last = last_terminal_mutation(lock)
|
||||||
|
if last is None:
|
||||||
|
return True
|
||||||
|
if last.get("pr_number") != pr_number:
|
||||||
|
return False
|
||||||
|
locked_head = mutation_head_sha(last, lock)
|
||||||
|
target = normalize_head_sha(expected_head_sha)
|
||||||
|
if not locked_head or not target:
|
||||||
|
return False
|
||||||
|
return not heads_equal(locked_head, target)
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_terminal_heads_from_ready(lock: dict) -> dict:
|
||||||
|
"""Stamp head_sha onto legacy terminal mutations before overwriting ready_*.
|
||||||
|
|
||||||
|
Called when marking a fresh decision on a new head so historical rows keep
|
||||||
|
their original boundary after ``ready_expected_head_sha`` advances (#620).
|
||||||
|
"""
|
||||||
|
if not isinstance(lock, dict):
|
||||||
|
return lock
|
||||||
|
ready_pr = lock.get("ready_pr_number")
|
||||||
|
ready_head = normalize_head_sha(lock.get("ready_expected_head_sha"))
|
||||||
|
if ready_pr is None or not ready_head:
|
||||||
|
return lock
|
||||||
|
mutations = list(lock.get("live_mutations") or [])
|
||||||
|
changed = False
|
||||||
|
for m in mutations:
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
if m.get("action") not in TERMINAL_REVIEW_ACTIONS:
|
||||||
|
continue
|
||||||
|
if m.get("pr_number") != ready_pr:
|
||||||
|
continue
|
||||||
|
if mutation_head_sha(m):
|
||||||
|
continue
|
||||||
|
m["head_sha"] = ready_head
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
lock["live_mutations"] = mutations
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
def classify_pr_live_state(pr_live: dict | None) -> dict[str, Any]:
|
def classify_pr_live_state(pr_live: dict | None) -> dict[str, Any]:
|
||||||
"""Normalize a Gitea PR payload into merge/closed flags."""
|
"""Normalize a Gitea PR payload into merge/closed flags."""
|
||||||
pr = pr_live or {}
|
pr = pr_live or {}
|
||||||
merged = bool(pr.get("merged") or pr.get("merged_at"))
|
merged = bool(pr.get("merged") or pr.get("merged_at"))
|
||||||
state = (pr.get("state") or "").strip().lower() or None
|
state = (pr.get("state") or "").strip().lower() or None
|
||||||
closed = state == "closed"
|
closed = state == "closed"
|
||||||
|
head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
|
||||||
|
head_sha = normalize_head_sha(
|
||||||
|
pr.get("head_sha") or pr.get("head_commit_sha") or head.get("sha")
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"pr_state": state,
|
"pr_state": state,
|
||||||
"pr_merged": merged,
|
"pr_merged": merged,
|
||||||
"pr_merged_or_closed": merged or closed,
|
"pr_merged_or_closed": merged or closed,
|
||||||
"merge_commit_sha": pr.get("merge_commit_sha")
|
"merge_commit_sha": pr.get("merge_commit_sha")
|
||||||
or pr.get("merged_commit_sha"),
|
or pr.get("merged_commit_sha"),
|
||||||
|
"current_pr_head_sha": head_sha,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +208,7 @@ def lock_summary(lock: dict | None) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
last = last_terminal_mutation(lock)
|
last = last_terminal_mutation(lock)
|
||||||
mutations = list(lock.get("live_mutations") or [])
|
mutations = list(lock.get("live_mutations") or [])
|
||||||
|
last_head = mutation_head_sha(last, lock) if last else None
|
||||||
return {
|
return {
|
||||||
"task": lock.get("task"),
|
"task": lock.get("task"),
|
||||||
"remote": lock.get("remote"),
|
"remote": lock.get("remote"),
|
||||||
@@ -67,16 +220,21 @@ def lock_summary(lock: dict | None) -> dict[str, Any] | None:
|
|||||||
"session_profile": lock.get("session_profile"),
|
"session_profile": lock.get("session_profile"),
|
||||||
"ready_pr_number": lock.get("ready_pr_number"),
|
"ready_pr_number": lock.get("ready_pr_number"),
|
||||||
"ready_action": lock.get("ready_action"),
|
"ready_action": lock.get("ready_action"),
|
||||||
|
"ready_expected_head_sha": normalize_head_sha(
|
||||||
|
lock.get("ready_expected_head_sha")
|
||||||
|
),
|
||||||
"live_mutations_count": len(mutations),
|
"live_mutations_count": len(mutations),
|
||||||
"last_terminal": (
|
"last_terminal": (
|
||||||
{
|
{
|
||||||
"pr_number": last.get("pr_number"),
|
"pr_number": last.get("pr_number"),
|
||||||
"action": last.get("action"),
|
"action": last.get("action"),
|
||||||
"review_id": last.get("review_id"),
|
"review_id": last.get("review_id"),
|
||||||
|
"head_sha": last_head,
|
||||||
}
|
}
|
||||||
if last
|
if last
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
"locked_head_sha": last_head,
|
||||||
"correction_authorized": bool(lock.get("correction_authorized")),
|
"correction_authorized": bool(lock.get("correction_authorized")),
|
||||||
"updated_at": lock.get("updated_at") or lock.get("recorded_at"),
|
"updated_at": lock.get("updated_at") or lock.get("recorded_at"),
|
||||||
}
|
}
|
||||||
@@ -88,13 +246,16 @@ def assess_stale_review_decision_lock(
|
|||||||
pr_live: dict | None = None,
|
pr_live: dict | None = None,
|
||||||
pr_lookup_error: str | None = None,
|
pr_lookup_error: str | None = None,
|
||||||
active_profile_identity: str | None = None,
|
active_profile_identity: str | None = None,
|
||||||
|
current_pr_head_sha: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Assess whether a durable review-decision lock is stale/moot (#594).
|
"""Assess whether a durable review-decision lock is stale/moot (#594/#620).
|
||||||
|
|
||||||
*pr_live* must be the live Gitea payload for the **last terminal
|
*pr_live* must be the live Gitea payload for the **last terminal
|
||||||
mutation's PR**. When no lock / no terminal mutation exists, cleanup is
|
mutation's PR**. When no lock / no terminal mutation exists, cleanup is
|
||||||
not applicable. When the PR is still open (or lookup fails), cleanup is
|
not applicable. When the PR is still open (or lookup fails), cleanup is
|
||||||
forbidden and #332 hard-stop remains in force.
|
forbidden (#594). Open PR + different live head reports
|
||||||
|
``stale_by_head`` / ``fresh_review_on_current_head_allowed`` (#620)
|
||||||
|
without enabling cleanup.
|
||||||
"""
|
"""
|
||||||
summary = lock_summary(lock)
|
summary = lock_summary(lock)
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
@@ -102,6 +263,10 @@ def assess_stale_review_decision_lock(
|
|||||||
"lock_summary": summary,
|
"lock_summary": summary,
|
||||||
"last_terminal_pr": None,
|
"last_terminal_pr": None,
|
||||||
"last_terminal_action": None,
|
"last_terminal_action": None,
|
||||||
|
"locked_head_sha": None,
|
||||||
|
"current_pr_head_sha": normalize_head_sha(current_pr_head_sha),
|
||||||
|
"stale_by_head": False,
|
||||||
|
"fresh_review_on_current_head_allowed": False,
|
||||||
"is_moot": False,
|
"is_moot": False,
|
||||||
"cleanup_allowed": False,
|
"cleanup_allowed": False,
|
||||||
"profile_match": True,
|
"profile_match": True,
|
||||||
@@ -141,8 +306,10 @@ def assess_stale_review_decision_lock(
|
|||||||
|
|
||||||
pr_number = last.get("pr_number")
|
pr_number = last.get("pr_number")
|
||||||
action = last.get("action")
|
action = last.get("action")
|
||||||
|
locked_head = mutation_head_sha(last, lock)
|
||||||
result["last_terminal_pr"] = pr_number
|
result["last_terminal_pr"] = pr_number
|
||||||
result["last_terminal_action"] = action
|
result["last_terminal_action"] = action
|
||||||
|
result["locked_head_sha"] = locked_head
|
||||||
|
|
||||||
if pr_number is None:
|
if pr_number is None:
|
||||||
result["reasons"].append(
|
result["reasons"].append(
|
||||||
@@ -165,16 +332,35 @@ def assess_stale_review_decision_lock(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
live = classify_pr_live_state(pr_live)
|
live = classify_pr_live_state(pr_live)
|
||||||
|
current_head = normalize_head_sha(
|
||||||
|
current_pr_head_sha or live.get("current_pr_head_sha")
|
||||||
|
)
|
||||||
result.update(
|
result.update(
|
||||||
{
|
{
|
||||||
"pr_state": live["pr_state"],
|
"pr_state": live["pr_state"],
|
||||||
"pr_merged": live["pr_merged"],
|
"pr_merged": live["pr_merged"],
|
||||||
"pr_merged_or_closed": live["pr_merged_or_closed"],
|
"pr_merged_or_closed": live["pr_merged_or_closed"],
|
||||||
"merge_commit_sha": live["merge_commit_sha"],
|
"merge_commit_sha": live["merge_commit_sha"],
|
||||||
|
"current_pr_head_sha": current_head,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if not live["pr_merged_or_closed"]:
|
if not live["pr_merged_or_closed"]:
|
||||||
|
stale_by_head = bool(
|
||||||
|
locked_head and current_head and not heads_equal(locked_head, current_head)
|
||||||
|
)
|
||||||
|
result["stale_by_head"] = stale_by_head
|
||||||
|
# Fresh formal decision is allowed on the new head without cleanup (#620).
|
||||||
|
result["fresh_review_on_current_head_allowed"] = stale_by_head
|
||||||
|
if stale_by_head:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"terminal {action} on PR #{pr_number} is bound to head "
|
||||||
|
f"{locked_head[:12]}…; live head is {current_head[:12]}… — "
|
||||||
|
"fresh formal review on current head is allowed without "
|
||||||
|
"cleanup (fail closed for cleanup, #620); #332 same-head "
|
||||||
|
"duplicate protection remains"
|
||||||
|
)
|
||||||
|
else:
|
||||||
result["reasons"].append(
|
result["reasons"].append(
|
||||||
f"terminal review mutation on open PR #{pr_number} is still active "
|
f"terminal review mutation on open PR #{pr_number} is still active "
|
||||||
f"({action}); #332 hard-stop remains — cleanup forbidden (#594)"
|
f"({action}); #332 hard-stop remains — cleanup forbidden (#594)"
|
||||||
@@ -184,6 +370,8 @@ def assess_stale_review_decision_lock(
|
|||||||
# Merged or closed: lock is moot. Same-PR merge continuation is impossible.
|
# Merged or closed: lock is moot. Same-PR merge continuation is impossible.
|
||||||
result["is_moot"] = True
|
result["is_moot"] = True
|
||||||
result["cleanup_allowed"] = True
|
result["cleanup_allowed"] = True
|
||||||
|
result["stale_by_head"] = False
|
||||||
|
result["fresh_review_on_current_head_allowed"] = False
|
||||||
result["reasons"].append(
|
result["reasons"].append(
|
||||||
f"terminal mutation ({action} on PR #{pr_number}) is moot: PR is "
|
f"terminal mutation ({action} on PR #{pr_number}) is moot: PR is "
|
||||||
f"{'merged' if live['pr_merged'] else 'closed'}; canonical cleanup allowed (#594)"
|
f"{'merged' if live['pr_merged'] else 'closed'}; canonical cleanup allowed (#594)"
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ def main():
|
|||||||
servers[name],
|
servers[name],
|
||||||
required_tool=REQUIRED_NAMESPACE_TOOLS.get(name),
|
required_tool=REQUIRED_NAMESPACE_TOOLS.get(name),
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
probe_source="offline_spawn",
|
|
||||||
):
|
):
|
||||||
failed = True
|
failed = True
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""Head-scoped #332 review-decision locks (#620).
|
||||||
|
|
||||||
|
Proves:
|
||||||
|
* REQUEST_CHANGES on head A does not block mark_ready/submit on head B
|
||||||
|
* APPROVE on head B is allowed after RC on head A (same open PR)
|
||||||
|
* same-head duplicate terminal remains blocked
|
||||||
|
* open-PR cleanup remains forbidden; assessment reports stale_by_head
|
||||||
|
* historical mutations keep their head_sha (preserved ledger)
|
||||||
|
* PR #619-style: old RC at 036b78e…, current head 2429d9d…, approve allowed
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
import stale_review_decision_lock as srdl
|
||||||
|
|
||||||
|
HEAD_A = "036b78e31ea5d036452b3a52e0e086b01e0c763f"
|
||||||
|
HEAD_B = "2429d9d2e826e519eb8eb4ade45987c00838aefb"
|
||||||
|
HEAD_C = "c" * 40
|
||||||
|
|
||||||
|
|
||||||
|
def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, ready_action=None):
|
||||||
|
return {
|
||||||
|
"task": "review_pr",
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"session_pid": os.getpid(),
|
||||||
|
"session_profile": "prgs-reviewer",
|
||||||
|
"session_profile_lock": "prgs-reviewer",
|
||||||
|
"profile_identity": "prgs-reviewer",
|
||||||
|
"final_review_decision_ready": False,
|
||||||
|
"ready_pr_number": ready_pr,
|
||||||
|
"ready_action": ready_action,
|
||||||
|
"ready_expected_head_sha": ready_head,
|
||||||
|
"ready_remote": "prgs" if ready_pr else None,
|
||||||
|
"ready_org": "Scaled-Tech-Consulting" if ready_pr else None,
|
||||||
|
"ready_repo": "Gitea-Tools" if ready_pr else None,
|
||||||
|
"live_mutations": list(mutations or []),
|
||||||
|
"correction_authorized": correction,
|
||||||
|
"correction_reason": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
RC_619_LEGACY = {
|
||||||
|
"pr_number": 619,
|
||||||
|
"action": "request_changes",
|
||||||
|
"review_id": 410,
|
||||||
|
"review_state": "request_changes",
|
||||||
|
# no head_sha — pre-#620 durable ledger shape
|
||||||
|
}
|
||||||
|
RC_619_HEAD_A = {
|
||||||
|
"pr_number": 619,
|
||||||
|
"action": "request_changes",
|
||||||
|
"review_id": 410,
|
||||||
|
"review_state": "request_changes",
|
||||||
|
"head_sha": HEAD_A,
|
||||||
|
}
|
||||||
|
APPROVE_619_HEAD_B = {
|
||||||
|
"pr_number": 619,
|
||||||
|
"action": "approve",
|
||||||
|
"review_id": 411,
|
||||||
|
"review_state": "approve",
|
||||||
|
"head_sha": HEAD_B,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(mutations=None, **kwargs):
|
||||||
|
import review_workflow_load
|
||||||
|
|
||||||
|
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||||
|
mcp_server._save_review_decision_lock(_lock(mutations, **kwargs))
|
||||||
|
mcp_server.gitea_load_review_workflow()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_pr(pr_number=619, head=HEAD_B):
|
||||||
|
return {
|
||||||
|
"number": pr_number,
|
||||||
|
"state": "open",
|
||||||
|
"merged": False,
|
||||||
|
"merged_at": None,
|
||||||
|
"head": {"sha": head},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _no_lease():
|
||||||
|
return {"block": False, "reasons": [], "mutation_allowed": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _feedback(blocking=False, stale=False, success=True):
|
||||||
|
return {
|
||||||
|
"success": success,
|
||||||
|
"has_blocking_change_requests": blocking,
|
||||||
|
"review_feedback_stale": stale,
|
||||||
|
"current_head_sha": HEAD_B,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestPureHeadScopeHelpers(unittest.TestCase):
|
||||||
|
def test_mutation_head_prefers_recorded_sha(self):
|
||||||
|
m = {"pr_number": 1, "head_sha": HEAD_A}
|
||||||
|
self.assertEqual(srdl.mutation_head_sha(m), HEAD_A)
|
||||||
|
|
||||||
|
def test_legacy_mutation_uses_ready_expected_for_same_pr(self):
|
||||||
|
lock = _lock(
|
||||||
|
[RC_619_LEGACY],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
self.assertEqual(srdl.mutation_head_sha(RC_619_LEGACY, lock), HEAD_A)
|
||||||
|
|
||||||
|
def test_legacy_mutation_ignores_ready_for_other_pr(self):
|
||||||
|
lock = _lock([RC_619_LEGACY], ready_pr=1, ready_head=HEAD_A)
|
||||||
|
self.assertIsNone(srdl.mutation_head_sha(RC_619_LEGACY, lock))
|
||||||
|
|
||||||
|
def test_prior_blocks_same_head_not_other_head(self):
|
||||||
|
lock = _lock([RC_619_HEAD_A])
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=619, expected_head_sha=HEAD_A
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=619, expected_head_sha=HEAD_B
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backfill_stamps_legacy_terminals(self):
|
||||||
|
lock = _lock(
|
||||||
|
[dict(RC_619_LEGACY)],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
srdl.backfill_terminal_heads_from_ready(lock)
|
||||||
|
self.assertEqual(lock["live_mutations"][0]["head_sha"], HEAD_A)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHardStopHeadScope(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._save_review_decision_lock(None)
|
||||||
|
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
|
def test_rc_head_a_allows_mark_ready_head_b(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
619, "mark_ready", expected_head_sha=HEAD_B
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rc_head_a_blocks_mark_ready_same_head(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
619, "mark_ready", expected_head_sha=HEAD_A
|
||||||
|
)
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
self.assertIn("#332", reasons[0])
|
||||||
|
|
||||||
|
def test_rc_head_a_blocks_other_pr(self):
|
||||||
|
_seed([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A)
|
||||||
|
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
700, "mark_ready", expected_head_sha=HEAD_B
|
||||||
|
)
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
|
||||||
|
def test_legacy_619_ledger_allows_new_head(self):
|
||||||
|
"""PR #619-style durable lock without mutation head_sha."""
|
||||||
|
_seed(
|
||||||
|
[RC_619_LEGACY],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
619, "mark_ready", expected_head_sha=HEAD_B
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkFinalAndRecord(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._save_review_decision_lock(None)
|
||||||
|
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
|
def _mark(self, pr, action, head, feedback=None):
|
||||||
|
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||||
|
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_get_pr_review_feedback",
|
||||||
|
return_value=feedback or _feedback(blocking=False, stale=True),
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_check_pr_eligibility",
|
||||||
|
return_value={"eligible": True, "head_sha": head},
|
||||||
|
):
|
||||||
|
return mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=pr,
|
||||||
|
action=action,
|
||||||
|
expected_head_sha=head,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rc_then_rc_on_new_head(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
# Prior RC at head A is unresolved but head moved → feedback stale.
|
||||||
|
res = self._mark(
|
||||||
|
619,
|
||||||
|
"request_changes",
|
||||||
|
HEAD_B,
|
||||||
|
feedback=_feedback(blocking=True, stale=True),
|
||||||
|
)
|
||||||
|
self.assertTrue(res.get("marked_ready"), res)
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
# Historical head A preserved on mutation after backfill.
|
||||||
|
heads = {
|
||||||
|
m.get("head_sha")
|
||||||
|
for m in lock["live_mutations"]
|
||||||
|
if m.get("action") == "request_changes"
|
||||||
|
}
|
||||||
|
self.assertIn(HEAD_A, heads)
|
||||||
|
self.assertEqual(lock["ready_expected_head_sha"], HEAD_B)
|
||||||
|
|
||||||
|
def test_rc_then_approve_on_new_head(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
res = self._mark(619, "approve", HEAD_B)
|
||||||
|
self.assertTrue(res.get("marked_ready"), res)
|
||||||
|
self.assertTrue(res.get("head_scoped"))
|
||||||
|
|
||||||
|
def test_same_head_rc_still_blocked_by_hard_stop(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
res = self._mark(619, "approve", HEAD_A)
|
||||||
|
self.assertFalse(res.get("marked_ready"))
|
||||||
|
self.assertTrue(any("#332" in r for r in res.get("reasons") or []))
|
||||||
|
|
||||||
|
def test_pr619_style_legacy_lock_approve_on_current_head(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_LEGACY],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
res = self._mark(619, "approve", HEAD_B)
|
||||||
|
self.assertTrue(res.get("marked_ready"), res)
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
# Backfill should have stamped HEAD_A onto the legacy mutation.
|
||||||
|
self.assertEqual(lock["live_mutations"][0].get("head_sha"), HEAD_A)
|
||||||
|
self.assertEqual(lock["ready_expected_head_sha"], HEAD_B)
|
||||||
|
|
||||||
|
def test_record_live_mutation_stores_head(self):
|
||||||
|
_seed(ready_pr=619, ready_head=HEAD_B, ready_action="approve")
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
lock["final_review_decision_ready"] = True
|
||||||
|
lock["ready_pr_number"] = 619
|
||||||
|
lock["ready_expected_head_sha"] = HEAD_B
|
||||||
|
mcp_server._save_review_decision_lock(lock)
|
||||||
|
mcp_server.record_live_review_mutation(619, "approve", review_id=99)
|
||||||
|
stored = mcp_server._load_review_decision_lock()["live_mutations"][-1]
|
||||||
|
self.assertEqual(stored["head_sha"], HEAD_B)
|
||||||
|
self.assertEqual(stored["pr_number"], 619)
|
||||||
|
|
||||||
|
def test_submit_gate_allows_new_head_after_prior_terminal(self):
|
||||||
|
"""check_review_decision_gate must not block different-head submit."""
|
||||||
|
mutations = [RC_619_HEAD_A]
|
||||||
|
_seed(
|
||||||
|
mutations,
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_B,
|
||||||
|
ready_action="approve",
|
||||||
|
)
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
lock["final_review_decision_ready"] = True
|
||||||
|
lock["ready_pr_number"] = 619
|
||||||
|
lock["ready_action"] = "approve"
|
||||||
|
lock["ready_expected_head_sha"] = HEAD_B
|
||||||
|
lock["ready_remote"] = "prgs"
|
||||||
|
lock["ready_org"] = "Scaled-Tech-Consulting"
|
||||||
|
lock["ready_repo"] = "Gitea-Tools"
|
||||||
|
mcp_server._save_review_decision_lock(lock)
|
||||||
|
reasons = mcp_server.check_review_decision_gate(
|
||||||
|
619,
|
||||||
|
"approve",
|
||||||
|
final_review_decision_ready=True,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertEqual(reasons, [], reasons)
|
||||||
|
|
||||||
|
def test_submit_gate_blocks_same_head_duplicate(self):
|
||||||
|
mutations = [RC_619_HEAD_A]
|
||||||
|
_seed(
|
||||||
|
mutations,
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
lock["final_review_decision_ready"] = True
|
||||||
|
lock["ready_pr_number"] = 619
|
||||||
|
lock["ready_action"] = "request_changes"
|
||||||
|
lock["ready_expected_head_sha"] = HEAD_A
|
||||||
|
lock["ready_remote"] = "prgs"
|
||||||
|
lock["ready_org"] = "Scaled-Tech-Consulting"
|
||||||
|
lock["ready_repo"] = "Gitea-Tools"
|
||||||
|
mcp_server._save_review_decision_lock(lock)
|
||||||
|
reasons = mcp_server.check_review_decision_gate(
|
||||||
|
619,
|
||||||
|
"request_changes",
|
||||||
|
final_review_decision_ready=True,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssessmentOpenPrHead(unittest.TestCase):
|
||||||
|
def test_open_pr_stale_by_head_no_cleanup(self):
|
||||||
|
lock = _lock(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
lock, pr_live=_open_pr(619, HEAD_B)
|
||||||
|
)
|
||||||
|
self.assertTrue(a["has_lock"])
|
||||||
|
self.assertFalse(a["is_moot"])
|
||||||
|
self.assertFalse(a["cleanup_allowed"])
|
||||||
|
self.assertTrue(a["stale_by_head"])
|
||||||
|
self.assertTrue(a["fresh_review_on_current_head_allowed"])
|
||||||
|
self.assertEqual(a["locked_head_sha"], HEAD_A)
|
||||||
|
self.assertEqual(a["current_pr_head_sha"], HEAD_B)
|
||||||
|
|
||||||
|
def test_open_pr_same_head_no_fresh(self):
|
||||||
|
lock = _lock([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A)
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
lock, pr_live=_open_pr(619, HEAD_A)
|
||||||
|
)
|
||||||
|
self.assertFalse(a["stale_by_head"])
|
||||||
|
self.assertFalse(a["fresh_review_on_current_head_allowed"])
|
||||||
|
self.assertFalse(a["cleanup_allowed"])
|
||||||
|
|
||||||
|
def test_historical_mutation_preserved_in_summary(self):
|
||||||
|
lock = _lock(
|
||||||
|
[RC_619_HEAD_A, APPROVE_619_HEAD_B],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_B,
|
||||||
|
ready_action="approve",
|
||||||
|
)
|
||||||
|
summary = srdl.lock_summary(lock)
|
||||||
|
self.assertEqual(summary["live_mutations_count"], 2)
|
||||||
|
self.assertEqual(summary["last_terminal"]["head_sha"], HEAD_B)
|
||||||
|
self.assertEqual(summary["locked_head_sha"], HEAD_B)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
"""Documentation acceptance for the stable control runtime ADR (#615 / PR #616).
|
|
||||||
|
|
||||||
Enforces review 443 remediation:
|
|
||||||
|
|
||||||
* F1 — operator guide / runbooks cross-link the ADR (issue #615 AC2).
|
|
||||||
* F2 — LLM sessions are not instructed to kill/restart/relaunch MCP; process
|
|
||||||
restart is operator-owned; ADR is the authoritative split.
|
|
||||||
* F3 — routine post-merge master-parity staleness has a sanctioned response
|
|
||||||
(stop → report → operator reload → re-verify parity) and is not a full
|
|
||||||
promotion ledger requirement.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
ADR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "docs"
|
|
||||||
/ "architecture"
|
|
||||||
/ "mcp-stable-control-runtime-policy-adr.md"
|
|
||||||
)
|
|
||||||
ADR_REL = "architecture/mcp-stable-control-runtime-policy-adr.md"
|
|
||||||
ADR_BASENAME = "mcp-stable-control-runtime-policy-adr.md"
|
|
||||||
|
|
||||||
CROSS_LINK_DOCS = (
|
|
||||||
REPO_ROOT / "docs" / "wiki" / "Operator-Guide.md",
|
|
||||||
REPO_ROOT / "docs" / "wiki" / "Runbooks.md",
|
|
||||||
REPO_ROOT / "docs" / "llm-workflow-runbooks.md",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _read(path: Path) -> str:
|
|
||||||
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
|
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def test_adr_exists_with_policy_core():
|
|
||||||
text = _read(ADR)
|
|
||||||
assert text.lstrip().startswith("#"), "ADR lacks a title"
|
|
||||||
assert "#615" in text
|
|
||||||
assert "stable control runtime" in text.lower()
|
|
||||||
assert "2.3" in text and "2.4" in text and "2.5" in text and "2.6" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_f1_operator_docs_cross_link_adr():
|
|
||||||
for path in CROSS_LINK_DOCS:
|
|
||||||
text = _read(path)
|
|
||||||
assert ADR_BASENAME in text, (
|
|
||||||
f"{path.relative_to(REPO_ROOT)} must cross-link {ADR_BASENAME} "
|
|
||||||
f"(issue #615 acceptance criterion 2)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_f1_adr_lists_cross_links_as_acceptance_not_optional_tooling():
|
|
||||||
text = _read(ADR)
|
|
||||||
# Acceptance section must require guide/runbook cross-links.
|
|
||||||
assert "Operator guide / runbooks cross-link" in text or (
|
|
||||||
"operator guide" in text.lower() and "cross-link" in text.lower()
|
|
||||||
and "Acceptance" in text
|
|
||||||
)
|
|
||||||
# Cross-link must not remain only as optional tooling item #4.
|
|
||||||
optional = text.split("## 5. Implementation follow-ups", 1)[-1].split(
|
|
||||||
"## 6. Acceptance", 1
|
|
||||||
)[0]
|
|
||||||
assert "Operator Guide wiki cross-link to this ADR" not in optional, (
|
|
||||||
"ADR §5 must not list operator-guide cross-link as optional tooling"
|
|
||||||
)
|
|
||||||
assert "Not optional" in text or "must** cross-link" in text.lower() or (
|
|
||||||
"must cross-link" in text.lower()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_f2_runbook_fallback_is_operator_owned_not_llm_restart():
|
|
||||||
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
|
||||||
# Forbidden historical self-service instruction.
|
|
||||||
forbidden = (
|
|
||||||
"the LLM must relaunch or restart the client/MCP with the correct "
|
|
||||||
"profile environment variable before claiming or working on any tasks"
|
|
||||||
)
|
|
||||||
assert forbidden not in runbooks, (
|
|
||||||
"llm-workflow-runbooks must not instruct the LLM to relaunch/restart MCP"
|
|
||||||
)
|
|
||||||
assert "operator-owned" in runbooks.lower() or "Operator-owned" in runbooks
|
|
||||||
assert ADR_BASENAME in runbooks
|
|
||||||
|
|
||||||
|
|
||||||
def test_f2_workspace_rebind_does_not_require_llm_process_relaunch():
|
|
||||||
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
|
||||||
section = runbooks.split("### Safe reconnect / rebind procedure", 1)[-1]
|
|
||||||
section = section.split("## Safety notes", 1)[0]
|
|
||||||
# Must not tell the LLM alone to relaunch the MCP process as step 2.
|
|
||||||
assert "Reconnect or relaunch the correct namespace MCP server from the intended" not in section
|
|
||||||
assert "Operator-owned" in section or "operator" in section.lower()
|
|
||||||
assert "worktree_path" in section
|
|
||||||
collapsed = " ".join(section.lower().split())
|
|
||||||
assert "client reconnect" in collapsed
|
|
||||||
|
|
||||||
|
|
||||||
def test_f2_adr_forbids_llm_restart_and_supersedes_self_service_relaunch():
|
|
||||||
text = _read(ADR)
|
|
||||||
lower = text.lower()
|
|
||||||
assert "must not" in lower and "restart" in lower
|
|
||||||
assert "operator" in lower and "reload" in lower
|
|
||||||
assert "supersedes" in lower
|
|
||||||
assert "llm" in lower
|
|
||||||
|
|
||||||
|
|
||||||
def test_f3_adr_defines_post_merge_parity_staleness_response():
|
|
||||||
text = _read(ADR)
|
|
||||||
lower = text.lower()
|
|
||||||
assert "2.6" in text
|
|
||||||
assert "post-merge" in lower or "post merge" in lower
|
|
||||||
assert "parity" in lower and "stale" in lower
|
|
||||||
# Sanctioned steps: stop, report, operator reload, re-verify.
|
|
||||||
assert "stop" in lower
|
|
||||||
assert "report" in lower
|
|
||||||
assert "operator" in lower
|
|
||||||
assert "parity is verified" in lower or "re-verif" in lower or (
|
|
||||||
"startup/current-head" in lower
|
|
||||||
)
|
|
||||||
# Not a full promotion ledger for routine reload.
|
|
||||||
assert "not a §2.4 promotion" in lower or "not a section 2.4 promotion" in lower or (
|
|
||||||
"not a §2.4" in text or "Not a §2.4 promotion" in text
|
|
||||||
)
|
|
||||||
# Stale parity listed among unhealthy triggers.
|
|
||||||
assert "master parity is stale" in lower or "stale master parity" in lower
|
|
||||||
|
|
||||||
|
|
||||||
def test_f3_adr_forbids_llm_bypass_of_parity_gate():
|
|
||||||
text = _read(ADR)
|
|
||||||
lower = text.lower()
|
|
||||||
assert "bypass" in lower or "self-reset" in lower or "self-service" in lower
|
|
||||||
assert "parity" in lower
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_links_do_not_embed_secrets_or_raw_hosts_in_wiki_snippets():
|
|
||||||
for path in CROSS_LINK_DOCS:
|
|
||||||
text = _read(path)
|
|
||||||
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
|
||||||
assert marker not in text, f"{path} contains {marker!r}"
|
|
||||||
Reference in New Issue
Block a user