Compare commits

..
Author SHA1 Message Date
sysadmin f32aaaa3b5 fix: paginate repository-label inventory for gitea_set_issue_labels (#627)
Replace single-page GET labels?limit=100 with api_get_all-backed
_repo_label_id_map so later-page labels (e.g. type:feature,
workflow-hardening) are not falsely rejected during full-set replacement.

Also add post-mutation verification, fix related MCP/CLI label inventory
call sites, and add multi-page regression tests for the #601 reconciler
failure mode.

Closes #627
2026-07-10 13:05:27 -04:00
sysadmin 5347b313ea Merge pull request 'feat: first-class control-plane lease lifecycle (Closes #601)' (#625) from feat/issue-601-first-class-leases into master 2026-07-10 11:23:29 -05:00
sysadmin 1be4600261 fix: address #625 REQUEST_CHANGES for lease reclaim test and EOF blank line
- Update MCP expired-lock recovery test for sticky expired ownership
  (live pid + present worktree still fail closed under #601 reclaim rules)
- Remove trailing blank line at EOF in control_plane_db.py
2026-07-10 11:54:05 -04:00
sysadmin 780ef0b1be feat: first-class control-plane lease lifecycle (Closes #601)
Make active leases queryable workflow state with canonical adopt, release,
expire/reclaim, and abandon operations on the #613 control-plane DB substrate.

- lease_lifecycle.py: policy, proof gates, safe_next_action vocabulary
- control_plane_db: schema v3 provenance columns + list/inspect/adopt/abandon
- MCP tools: gitea_*_workflow_lease(s) for list/inspect/adopt/release/expire/abandon/reclaim
- issue_lock_store: sanctioned reclaim of expired author locks (dead pid / missing wt)
- Tests cover lifecycle, foreign steal refusal, allocator compatibility, merger handoff
2026-07-10 11:30:53 -04:00
sysadmin a654cda058 fix: import Any from typing to resolve IDE/compiler diagnostic warnings 2026-07-10 10:27:21 -04:00
sysadmin ab1c2d7973 Merge pull request 'feat: Sentry/GlitchTip incident bridge via incident_links (Closes #612)' (#623) from feat/issue-612-incident-bridge into master 2026-07-10 08:36:47 -05:00
sysadmin 6a179aeb85 feat: Sentry/GlitchTip incident bridge via incident_links (Closes #612)
Add phase-1 observability bridge that turns provider observations into
normal Gitea issues and control-plane incident_links rows. Dry-run is
default; apply creates/links through sanctioned Gitea issue paths only.
Raw incidents remain non-assignable; the #600 allocator sees bridge work
only as ordinary Gitea issues. Builds on #613 substrate and #600 allocator.

Closes #612
2026-07-10 03:18:54 -04:00
sysadmin f20c8436aa Merge pull request 'feat: controller-owned allocator API on control-plane DB (Closes #600)' (#622) from feat/issue-600-controller-allocator-api into master 2026-07-10 02:11:59 -05:00
sysadmin db5d14184f feat: controller-owned allocator API on control-plane DB (Closes #600)
Add gitea_allocate_next_work and allocator_service routing policy on top of
the #613 ControlPlaneDB substrate. Workers get atomic assign+lease results
(or wait/no_safe_work/terminal-path outcomes) without self-selecting work
via file locks or comment-only leases. #612 remains downstream.

Closes #600
2026-07-10 02:54:20 -04:00
sysadmin 10228e1c06 Merge pull request 'feat: control-plane DB substrate for atomic assign/lease (Closes #613)' (#619) from feat/issue-613-allocator-db-substrate into master 2026-07-10 01:44:19 -05:00
sysadmin d8b2b8f1a7 Merge pull request 'fix: head-scope durable #332 review-decision locks (Closes #620)' (#621) from fix/issue-620-head-scoped-review-locks into master
Reviewed-on: #621
2026-07-10 01:12:57 -05:00
sysadmin 9e1d5c5649 fix: head-scope durable #332 review-decision locks (#620)
Prior REQUEST_CHANGES on head A no longer blocks formal re-review on
head B of the same open PR. Locks store reviewed head SHA, hard-stop and
mark_ready/submit gates allow a fresh decision boundary when the live
head differs, and same-head duplicates remain fail-closed. Open-PR lock
cleanup stays forbidden (#594); assessment reports locked vs current
head and stale_by_head / fresh_review_on_current_head_allowed.

Closes #620
2026-07-10 01:04:37 -04:00
sysadmin 2429d9d2e8 fix: fail closed on conflicting incident_links observation metadata
Legacy NULL-scope dedupe only compared Gitea targets, so duplicate rows
with the same provider key and issue but different fingerprint/status/
event_count/etc. collapsed to the lowest link_id and silently dropped
observation data. Migration now compares all meaningful observation
fields and refuses to discard conflicts (#619 RC3 / #613).
2026-07-09 23:04:17 -04:00
sysadmin 16cd871fbd fix: safe incident_links migration order; require PR head pin
Address remaining PR #619 blockers on head 783ea88:

- Deduplicate legacy NULL-scope incident_links before normalizing to ''
- Fail closed when duplicate rows disagree on Gitea target
- Require non-empty expected_head_sha for PR assign and mutation
2026-07-09 22:51:27 -04:00
sysadmin 783ea88a01 fix: fail closed on stale/terminal assignments; canonicalize incident_links
Address REQUEST_CHANGES on PR #619:

- require_valid_assignment rejects terminal work states and head drift
- incident_links scope keys normalize NULL/blank to '' for UNIQUE
- remove trailing whitespace in control-plane-db-substrate.md
2026-07-09 22:41:25 -04:00
sysadmin 036b78e31e feat: control-plane DB substrate for atomic assign/lease (Closes #613)
Add SQLite single-writer MVP control plane per the allocator ADR:
sessions, work_items (issue/pr only), atomic assign+lease, mutation
gates, terminal-lock index, and provider-neutral incident_links.

DB coordinates concurrency; Gitea remains assignable work; raw
Sentry/GlitchTip incidents are never work items. #600 and #612 build on
this substrate.
2026-07-09 22:27:51 -04:00
sysadmin 08c3488d7c Merge pull request 'docs: ADR for MCP allocator, control-plane DB, and incident bridge (#613)' (#614) from docs/issue-613-allocator-control-plane-observability-adr into master 2026-07-09 21:10:27 -05:00
sysadmin 4f2fd9a8b1 fix(mcp): resolve default remote mapping and connection check bugs 2026-07-09 20:26:58 -04:00
sysadmin 4185ee1417 docs: ADR for MCP allocator, control-plane DB, and incident bridge
Canonical architecture for #613#600#612: DB coordinates, Gitea
records, Sentry/GlitchTip observe; allocator assigns only Gitea issues/PRs.

Refs: #600 #612 #613
2026-07-09 20:00:59 -04:00
sysadmin ae6a3ae00c Merge pull request 'feat: diagnose live MCP namespace EOF health and block merge (#543)' (#587) from feat/issue-543-mcp-namespace-health-check into master 2026-07-09 18:46:52 -05:00
sysadmin 2fde92ad25 Implement review drafts saving and resuming (#609) 2026-07-09 19:08:28 -04:00
sysadmin ebd06b73c9 fix: address PR #587 REQUEST_CHANGES for MCP namespace health (#543)
- Distinguish client_namespace vs offline_spawn probe sources; only IDE
  client probes prove namespace health and feed mutation gates.
- Record assessments in session; gate gitea_submit_pr_review and
  gitea_merge_pr when client-namespace health is unhealthy/non-proven.
- Mark test_mcp_conn.py offline-only; align recovery docs to client
  reconnect (no PID-kill/config-touch as canonical recovery).
- Rebase onto current master so #590 ledger isolation keeps
  TestMergePR.test_unknown_profile_blocks green.
2026-07-09 18:44:04 -04:00
sysadmin af9f1c5944 style: fix trailing newline in mcp_namespace_health.py 2026-07-09 18:39:41 -04:00
sysadminandClaude Opus 4.8 f83d2c2027 docs: add MCP namespace client is closing: EOF recovery runbook (#543)
Adds docs/mcp-namespace-eof-recovery.md documenting the correct recovery
path when a Gitea MCP namespace (gitea-author/reviewer/merger/tools)
returns `client is closing: EOF`.

Satisfies acceptance criterion 7 of #543: symptom, root cause (closed
client transport vs a live-but-stale process), the sanctioned
reconnect/relaunch sequence, diagnostics to capture, and how
test_mcp_conn.py reproduces the registered-vs-callable gap. Distinguishes
this transport-close failure from the ps-based stale-runtime family in
#531/#544 and reinforces the no-direct-import guard (#558).

Docs-only; no code or test behavior changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-09 18:39:41 -04:00
sysadminandClaude Opus 4.8 93781af7e9 feat: enforce live-namespace block in review/merge state machine (#543 AC5)
The namespace health check (05fdcee) classified a false-ready namespace but
only returned an advisory blocks_merge_workflow flag; nothing in the merge
gate consumed it. Wire it in so a broken live namespace hard-blocks merge.

- review_merge_state_machine.assess_workflow_blockers: add live_namespace_broken
  blocker (registered-in-FastMCP but not callable-through-namespace).
- assess_state_advancement: forward **blocker_kwargs so can_approve/can_merge/
  workflow_status honor the full blocker set (also fixes latent drop of
  mcp_reconnect_failed/stale_capability_state through those paths).
- gitea_assess_review_merge_state_machine tool: accept live_namespace_broken and
  thread it through all state-machine calls.
- Tests: prove can_merge/workflow_status/tool block on live_namespace_broken even
  with every review state + pre-merge gate satisfied; bridge classify verdict.
- Docs: enforcement section wiring blocks_merge_workflow -> live_namespace_broken.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-09 18:39:41 -04:00
sysadmin 314615ec2c feat: diagnose live MCP namespace EOF health 2026-07-09 18:39:41 -04:00
34 changed files with 10063 additions and 645 deletions
+610
View File
@@ -0,0 +1,610 @@
"""Controller-owned work allocator policy (#600).
Builds on the #613 control-plane DB substrate (``ControlPlaneDB.assign_and_lease``).
Workers must not self-select exclusive work under the standard multi-LLM
workflow. They call ``gitea_allocate_next_work`` which:
1. Inspects candidate Gitea issues/PRs (never raw monitoring incidents).
2. Applies ADR routing policy (role, terminal path, leases, blocked, deps).
3. Atomically assigns + leases the selected item in one DB transaction.
This module is pure selection + substrate orchestration. Gitea I/O for live
inventory lives in the MCP tool wrapper so tests can inject candidates.
#612 remains downstream: bridge-created Gitea issues become candidates only
after they exist as normal issues; this module never assigns incidents.
"""
from __future__ import annotations
import os
import uuid
from dataclasses import dataclass, field
from typing import Any, Sequence
from control_plane_db import (
ControlPlaneDB,
ControlPlaneError,
InvalidWorkKindError,
LeaseRequiredError,
WORK_KINDS,
)
# Outcomes required by #600 / ADR §5.
OUTCOME_ASSIGNED = "assigned_work"
OUTCOME_WAIT = "wait"
OUTCOME_BLOCKED_TERMINAL = "blocked_by_terminal_path"
OUTCOME_BLOCKED_LEASE = "blocked_by_active_lease"
OUTCOME_NEEDS_CONTROLLER = "needs_controller"
OUTCOME_NO_SAFE = "no_safe_work"
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
ROLE_AUTHOR = "author"
ROLE_REVIEWER = "reviewer"
ROLE_MERGER = "merger"
ROLE_RECONCILER = "reconciler"
ROLE_CONTROLLER = "controller"
VALID_ROLES = frozenset(
{ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER}
)
# Default action matrices by role (mutation gate will re-check).
ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
ROLE_AUTHOR: (
("implement", "comment", "push", "create_pr"),
("approve", "merge", "request_changes", "self_select_without_assignment"),
),
ROLE_REVIEWER: (
("review", "comment", "approve", "request_changes"),
("merge", "push", "create_pr", "self_select_without_assignment"),
),
ROLE_MERGER: (
("merge", "comment"),
("approve", "request_changes", "push", "create_pr", "self_select_without_assignment"),
),
ROLE_RECONCILER: (
("comment", "diagnose", "cleanup"),
("approve", "merge", "push", "create_pr", "self_select_without_assignment"),
),
ROLE_CONTROLLER: (
("comment", "diagnose", "allocate"),
("approve", "merge", "push", "create_pr"),
),
}
@dataclass
class WorkCandidate:
"""One assignable Gitea issue or PR presented to the allocator."""
kind: str # issue | pr
number: int
state: str = "open"
labels: tuple[str, ...] = ()
title: str = ""
priority: int = 0
head_sha: str | None = None
# Routing signals (callers derive from Gitea / review feedback).
request_changes_current_head: bool = False
approval_on_current_head: bool = False
approval_stale: bool = False
approval_contaminated: bool = False
mergeable: bool = False
blocked: bool = False
dependency_unmet: bool = False
dependency_reason: str | None = None
already_claimed_elsewhere: bool = False
def __post_init__(self) -> None:
self.kind = (self.kind or "").strip().lower()
self.state = (self.state or "open").strip().lower()
self.labels = tuple(
str(x).strip().lower() for x in (self.labels or ()) if str(x).strip()
)
if self.kind not in WORK_KINDS:
raise InvalidWorkKindError(
f"candidate kind '{self.kind}' is not assignable; only "
f"{sorted(WORK_KINDS)} (never raw incidents)"
)
def as_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"number": self.number,
"state": self.state,
"labels": list(self.labels),
"title": self.title,
"priority": self.priority,
"head_sha": self.head_sha,
"request_changes_current_head": self.request_changes_current_head,
"approval_on_current_head": self.approval_on_current_head,
"approval_stale": self.approval_stale,
"approval_contaminated": self.approval_contaminated,
"mergeable": self.mergeable,
"blocked": self.blocked,
"dependency_unmet": self.dependency_unmet,
"dependency_reason": self.dependency_reason,
}
@dataclass
class SkipRecord:
kind: str
number: int
reason: str
def as_dict(self) -> dict[str, Any]:
return {"kind": self.kind, "number": self.number, "reason": self.reason}
def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
"""Map profile/role strings to a canonical allocator role."""
raw = (role or "").strip().lower()
if raw in VALID_ROLES:
return raw
prof = (profile_name or "").strip().lower()
for token in VALID_ROLES:
if token in prof or prof.endswith(f"-{token}"):
return token
if "author" in raw:
return ROLE_AUTHOR
if "review" in raw:
return ROLE_REVIEWER
if "merg" in raw:
return ROLE_MERGER
if "reconcil" in raw:
return ROLE_RECONCILER
if "control" in raw:
return ROLE_CONTROLLER
raise ControlPlaneError(
f"unknown allocator role '{role}' (profile={profile_name!r}); "
f"expected one of {sorted(VALID_ROLES)}"
)
def expected_role_for_candidate(c: WorkCandidate) -> str:
"""ADR §5.3 routing: which role should take this work next."""
if c.kind == "pr":
if c.approval_contaminated:
return ROLE_RECONCILER
if c.request_changes_current_head:
return ROLE_AUTHOR
if c.approval_stale:
return ROLE_REVIEWER
if c.approval_on_current_head and c.mergeable:
return ROLE_MERGER
# Open PR without terminal verdict → reviewer
return ROLE_REVIEWER
# Issues: ready work → author by default; blocked stays controller/none
labels = set(c.labels)
if "status:blocked" in labels or c.blocked:
return ROLE_CONTROLLER
return ROLE_AUTHOR
def role_actions(role: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
return ROLE_ACTIONS.get(role, ROLE_ACTIONS[ROLE_AUTHOR])
def classify_skip(
c: WorkCandidate,
*,
role: str,
terminal_pr: int | None,
) -> str | None:
"""Return skip reason, or None if candidate is selectable for *role*."""
if c.state in ("merged", "closed"):
return f"{c.kind}#{c.number} is {c.state}; never assign"
if c.blocked or "status:blocked" in c.labels:
return f"{c.kind}#{c.number} is blocked"
if c.dependency_unmet:
return (
c.dependency_reason
or f"{c.kind}#{c.number} has unmet dependencies"
)
if c.already_claimed_elsewhere:
return f"{c.kind}#{c.number} already claimed elsewhere"
if c.kind == "pr" and not (c.head_sha or "").strip():
return f"pr#{c.number} missing head_sha pin"
# Terminal path first: when an active terminal PR exists, only that PR
# (or controller diagnosis) is assignable for review-path roles.
if terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr:
if role in (ROLE_REVIEWER, ROLE_MERGER):
return (
f"pr#{c.number} skipped: active terminal-review lock on "
f"PR #{terminal_pr} must be resolved first"
)
expected = expected_role_for_candidate(c)
if role == ROLE_CONTROLLER:
# Controller may inspect anything but only assigns diagnosis targets
# when contaminated / blocked.
if expected == ROLE_RECONCILER or c.blocked:
return None
return f"{c.kind}#{c.number} does not require controller (expected {expected})"
if role != expected:
return (
f"{c.kind}#{c.number} expects role '{expected}', active role is '{role}'"
)
# Ready-gate for issues: prefer status:ready when labels present.
if c.kind == "issue" and c.labels:
if "status:ready" not in c.labels and "status:in-progress" not in c.labels:
# Allow unlabeled open issues; only skip explicit non-ready states.
if any(l.startswith("status:") for l in c.labels):
return f"issue#{c.number} not status:ready ({','.join(c.labels)})"
return None
def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
"""Higher priority first; then lower number (older issues) for stability."""
return sorted(
candidates,
key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
)
def allocate_next_work(
db: ControlPlaneDB,
*,
session_id: str,
role: str,
remote: str,
org: str,
repo: str,
candidates: Sequence[WorkCandidate],
apply: bool = False,
profile_name: str | None = None,
username: str | None = None,
lease_ttl_seconds: int | None = None,
) -> dict[str, Any]:
"""Select and optionally reserve the next work unit via control-plane DB.
*apply=False* (default): dry-run selection only — no lease/assignment.
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
Never uses file locks or comment-only leases as the assignment source.
"""
if db is None:
return {
"success": False,
"outcome": OUTCOME_NO_SAFE,
"reasons": [
"control-plane DB substrate unavailable (fail closed, #600/#613)"
],
"skipped": [],
"assignment": None,
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
}
try:
role_norm = normalize_role(role, profile_name=profile_name)
except ControlPlaneError as exc:
return {
"success": False,
"outcome": OUTCOME_ROLE_INELIGIBLE,
"reasons": [str(exc)],
"skipped": [],
"assignment": None,
"substrate": "control_plane_db",
}
session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}"
try:
db.upsert_session(
session_id=session_id,
role=role_norm,
profile=profile_name,
pid=os.getpid(),
)
except Exception as exc: # noqa: BLE001 — surface structured
return {
"success": False,
"outcome": OUTCOME_NO_SAFE,
"reasons": [
f"failed to register session in control-plane DB: {exc} "
"(fail closed, #613)"
],
"skipped": [],
"assignment": None,
"substrate": "control_plane_db",
}
# Expire stale leases globally before selection.
try:
db.expire_stale_leases()
except Exception as exc: # noqa: BLE001
return {
"success": False,
"outcome": OUTCOME_NO_SAFE,
"reasons": [f"lease expiry failed: {exc} (fail closed)"],
"skipped": [],
"assignment": None,
"substrate": "control_plane_db",
}
terminal = None
try:
terminal = db.get_active_terminal_lock(remote=remote, org=org, repo=repo)
except Exception as exc: # noqa: BLE001
return {
"success": False,
"outcome": OUTCOME_NO_SAFE,
"reasons": [f"terminal lock lookup failed: {exc} (fail closed)"],
"skipped": [],
"assignment": None,
"substrate": "control_plane_db",
}
terminal_pr = int(terminal["terminal_pr"]) if terminal else None
skipped: list[SkipRecord] = []
ordered = sort_candidates(list(candidates))
selected: WorkCandidate | None = None
for c in ordered:
reason = classify_skip(c, role=role_norm, terminal_pr=terminal_pr)
if reason:
skipped.append(SkipRecord(c.kind, c.number, reason))
continue
selected = c
break
if selected is None:
# If terminal lock blocks all review work, surface that explicitly.
if terminal_pr is not None and role_norm in (ROLE_REVIEWER, ROLE_MERGER):
outcome = OUTCOME_BLOCKED_TERMINAL
reasons = [
f"no safe work for role '{role_norm}': active terminal-review "
f"lock on PR #{terminal_pr} (resolve terminal path first, #332/#600)"
]
else:
outcome = OUTCOME_NO_SAFE
reasons = [
f"no safe assignable work for role '{role_norm}' "
f"among {len(ordered)} candidates"
]
return {
"success": True,
"outcome": outcome,
"apply": bool(apply),
"role": role_norm,
"profile_name": profile_name,
"username": username,
"session_id": session_id,
"remote": remote,
"org": org,
"repo": repo,
"selected": None,
"expected_role_next": None,
"reasons": reasons,
"skipped": [s.as_dict() for s in skipped],
"terminal_pr": terminal_pr,
"assignment": None,
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
"downstream_note": (
"#612 incident bridge remains downstream of #600; "
"allocator never assigns raw monitoring incidents"
),
}
expected_role = expected_role_for_candidate(selected)
allowed, forbidden = role_actions(role_norm)
selection = {
"kind": selected.kind,
"number": selected.number,
"title": selected.title,
"labels": list(selected.labels),
"head_sha": selected.head_sha,
"priority": selected.priority,
"expected_role_next": expected_role,
"reason_selected": (
f"highest-priority candidate for role '{role_norm}' "
f"(expected_role={expected_role})"
),
}
if not apply:
return {
"success": True,
"outcome": OUTCOME_PREVIEW,
"apply": False,
"role": role_norm,
"profile_name": profile_name,
"username": username,
"session_id": session_id,
"remote": remote,
"org": org,
"repo": repo,
"selected": selection,
"expected_role_next": expected_role,
"reasons": [
"dry-run only (apply=false); no assignment/lease created — "
"call again with apply=true to reserve via control-plane DB"
],
"skipped": [s.as_dict() for s in skipped],
"terminal_pr": terminal_pr,
"assignment": None,
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
"downstream_note": (
"#612 incident bridge remains downstream of #600; "
"allocator never assigns raw monitoring incidents"
),
}
# Atomic reserve via #613 substrate.
ttl = lease_ttl_seconds if lease_ttl_seconds is not None else None
try:
kwargs: dict[str, Any] = {
"session_id": session_id,
"role": role_norm,
"remote": remote,
"org": org,
"repo": repo,
"kind": selected.kind,
"number": selected.number,
"expected_head_sha": selected.head_sha,
"allowed_actions": allowed,
"forbidden_actions": forbidden,
"phase": "allocated",
}
if ttl is not None:
kwargs["lease_ttl_seconds"] = int(ttl)
result = db.assign_and_lease(**kwargs)
except (InvalidWorkKindError, LeaseRequiredError, ControlPlaneError) as exc:
return {
"success": False,
"outcome": OUTCOME_NO_SAFE,
"apply": True,
"role": role_norm,
"session_id": session_id,
"remote": remote,
"org": org,
"repo": repo,
"selected": selection,
"expected_role_next": expected_role,
"reasons": [f"atomic assign+lease failed: {exc} (fail closed, #613)"],
"skipped": [s.as_dict() for s in skipped],
"terminal_pr": terminal_pr,
"assignment": None,
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
}
if result.outcome == "wait":
return {
"success": True,
"outcome": OUTCOME_WAIT,
"apply": True,
"role": role_norm,
"session_id": session_id,
"remote": remote,
"org": org,
"repo": repo,
"selected": selection,
"expected_role_next": expected_role,
"reasons": [result.reason or "foreign active lease"],
"skipped": [s.as_dict() for s in skipped],
"terminal_pr": terminal_pr,
"assignment": result.as_dict(),
"owner_session_id": result.owner_session_id,
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
}
if result.outcome == "no_safe_work":
return {
"success": True,
"outcome": OUTCOME_NO_SAFE,
"apply": True,
"role": role_norm,
"session_id": session_id,
"remote": remote,
"org": org,
"repo": repo,
"selected": selection,
"expected_role_next": expected_role,
"reasons": [result.reason or "no_safe_work"],
"skipped": [s.as_dict() for s in skipped],
"terminal_pr": terminal_pr,
"assignment": result.as_dict(),
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
}
# assigned
return {
"success": True,
"outcome": OUTCOME_ASSIGNED,
"apply": True,
"role": role_norm,
"profile_name": profile_name,
"username": username,
"session_id": session_id,
"remote": remote,
"org": org,
"repo": repo,
"selected": selection,
"expected_role_next": expected_role,
"reasons": [
selection["reason_selected"],
result.reason or "atomic assign+lease created",
],
"skipped": [s.as_dict() for s in skipped],
"terminal_pr": terminal_pr,
"assignment": result.as_dict(),
"lease_proof": {
"assignment_id": result.assignment_id,
"lease_id": result.lease_id,
"expires_at": result.expires_at,
"expected_head_sha": result.expected_head_sha,
"allowed_actions": list(result.allowed_actions),
"forbidden_actions": list(result.forbidden_actions),
"source": "control_plane_db.assign_and_lease",
},
"next_valid_command": _next_command(role_norm, selected),
"substrate": "control_plane_db",
"file_lock_only": False,
"comment_lease_only": False,
"downstream_note": (
"#612 incident bridge remains downstream of #600; "
"allocator never assigns raw monitoring incidents"
),
}
def _next_command(role: str, c: WorkCandidate) -> str:
if role == ROLE_AUTHOR and c.kind == "issue":
return f"implement issue #{c.number} under a branches/ worktree; open PR when ready"
if role == ROLE_AUTHOR and c.kind == "pr":
return (
f"address REQUEST_CHANGES on PR #{c.number} at head "
f"{(c.head_sha or '')[:12]} and push fixes"
)
if role == ROLE_REVIEWER:
return (
f"review PR #{c.number} pinned at head {(c.head_sha or '')[:12]} "
"via full reviewer workflow"
)
if role == ROLE_MERGER:
return (
f"merge PR #{c.number} only with explicit operator MERGE "
f"authorization at head {(c.head_sha or '')[:12]}"
)
if role == ROLE_RECONCILER:
return f"diagnose contested state for {c.kind}#{c.number}"
return f"proceed on {c.kind}#{c.number} under role {role}"
def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
"""Build a WorkCandidate from a plain dict (tests / MCP inventory)."""
return WorkCandidate(
kind=str(data.get("kind") or "issue"),
number=int(data["number"]),
state=str(data.get("state") or "open"),
labels=tuple(data.get("labels") or ()),
title=str(data.get("title") or ""),
priority=int(data.get("priority") or 0),
head_sha=data.get("head_sha"),
request_changes_current_head=bool(data.get("request_changes_current_head")),
approval_on_current_head=bool(data.get("approval_on_current_head")),
approval_stale=bool(data.get("approval_stale")),
approval_contaminated=bool(data.get("approval_contaminated")),
mergeable=bool(data.get("mergeable")),
blocked=bool(data.get("blocked")),
dependency_unmet=bool(data.get("dependency_unmet")),
dependency_reason=data.get("dependency_reason"),
already_claimed_elsewhere=bool(data.get("already_claimed_elsewhere")),
)
+1751
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
# Control-plane DB substrate (#613)
**Status:** Implemented (SQLite single-writer MVP)
**ADR:** [`mcp-allocator-control-plane-observability-adr.md`](mcp-allocator-control-plane-observability-adr.md)
**Module:** `control_plane_db.py`
## Architecture statement
> **DB coordinates, Gitea records, Sentry/GlitchTip observe; the bridge is the only path that turns observations into Gitea work.**
## What this ships
| Capability | Notes |
|------------|--------|
| Schema | `sessions`, `work_items`, `leases`, `assignments`, `terminal_locks`, `events`, `incident_links` |
| Atomic assign+lease | `ControlPlaneDB.assign_and_lease` — one `BEGIN IMMEDIATE` transaction |
| Mutation gate | `require_valid_assignment` — live lease + allowed action + non-terminal work + non-stale head |
| Heartbeat / release / expire | Lease lifecycle helpers |
| Terminal-lock index | Routing signal for #600 (terminal path first) |
| `incident_links` | Provider-neutral link model for #612**not** assignable work; scope keys NULL-safe |
## Hard rules (enforced in code)
1. Assignable `work_items.kind` ∈ {`issue`, `pr`} only — **never** raw Sentry/GlitchTip incidents.
2. Two concurrent sessions cannot both receive an active assignment on the same open work item (second gets `wait`).
3. Merged/closed work items return `no_safe_work` at assign time, and `require_valid_assignment` fails closed if the work item becomes terminal later.
4. Assignments pin `expected_head_sha`; mutations fail closed if the work item head drifts.
5. SQLite path is the **single-writer MVP** (`GITEA_CONTROL_PLANE_DB`, default under `~/.cache/gitea-tools/control-plane/`). Multi-session multi-host production requires **Postgres** or a **single allocator daemon** (ADR §6).
6. `incident_links` optional scope fields are stored as empty strings (never NULL) so UNIQUE is canonical across minimal upserts.
7. Legacy `incident_links` migration collapses NULL-scope duplicates **only** when Gitea targets **and** all meaningful observation metadata agree (fingerprint, status, event_count, permalink, timestamps, linked PRs, etc.). Conflicting metadata fails closed — no silent discard.
## Dependency chain
```text
#613 control-plane DB (this) → #600 allocator API → #612 incident bridge
```
- **#600** must call this substrate (not file locks / comment-only leases alone) for completion.
- **#612** must write `incident_links` here and create **Gitea issues**; the allocator assigns those issues, not raw incidents.
## Allocator API (#600)
Module: `allocator_service.py` · MCP tool: `gitea_allocate_next_work`
- Workers call `gitea_allocate_next_work(apply=false|true)` instead of self-selecting work.
- `apply=false` returns a dry-run selection (`outcome=preview`) with skip reasons.
- `apply=true` reserves via `ControlPlaneDB.assign_and_lease` (atomic assignment+lease).
- Coordination source is **always** the control-plane DB — not file locks or comment-only leases.
- Routing follows ADR §5.3 (REQUEST_CHANGES → author, terminal path first, foreign lease → wait).
- Raw monitoring incidents are never candidates.
## Lease lifecycle API (#601)
Module: `lease_lifecycle.py` · MCP tools: `gitea_*_workflow_lease(s)`
Active control-plane leases are **first-class workflow state**:
| Tool | Purpose |
|------|---------|
| `gitea_list_workflow_leases` | List active (or all) leases for a repo |
| `gitea_inspect_workflow_lease` | Freshness + `safe_next_action` for one lease id |
| `gitea_adopt_workflow_lease` | Owner-resume or sanctioned reclaim with provenance |
| `gitea_release_workflow_lease` | Explicit owner release (audited) |
| `gitea_expire_workflow_leases` | Deterministic expire of past-`expires_at` leases |
| `gitea_abandon_workflow_lease` | Abandon with required proof |
| `gitea_reclaim_expired_workflow_lease` | Expire + re-assign with provenance |
### Hard rules
1. Control-plane DB is the coordination authority — file locks / comment-only leases are not authoritative alone.
2. Active foreign leases cannot be stolen; inspect returns `wait_foreign_active`.
3. Abandon requires `(dead_process OR missing_worktree)` and `no_live_mutation_risk`; foreign abandon also needs `operator_authorized` or full dead+missing+no_open_pr proof.
4. Adopt provenance always records `adopted_from_session_id`, `adopted_by_session_id`, work identity, optional head SHA, and worktree path.
5. Reviewer→merger comment handoff (`gitea_adopt_merger_pr_lease`) is unchanged and remains the Gitea-thread durability path.
```bash
python3 -m pytest tests/test_lease_lifecycle.py tests/test_control_plane_db.py tests/test_allocator_service.py tests/test_merger_lease_adoption.py -q
```
## Incident bridge (#612)
Module: `incident_bridge.py` · MCP tools: `gitea_observability_*`
- Bridge converts Sentry/GlitchTip observations → **normal Gitea issues** + `incident_links`.
- Phase-1: `gitea_observability_reconcile_incident(apply=false|true)` with JSON observation.
- Dry-run performs **no** Gitea mutation and **no** DB write.
- Apply reuses existing links or creates one Gitea issue; never invents `work_items.kind=incident`.
- Config: `GITEA_OBSERVABILITY_PROJECTS_JSON` or `GITEA_OBSERVABILITY_PROJECTS_FILE`.
- Provider tokens never appear in issue bodies, links, or tool results.
- Allocator sees bridge work only after a Gitea issue exists.
## Non-goals (intentionally deferred)
- Full unsupervised watchdog auto-filing (prefer explicit reconcile first)
- Assuming GlitchTip writeback API equals Sentry without verification
- Gitea comment/label mirror writers for every assignment (optional later)
## Tests
```bash
python3 -m pytest tests/test_control_plane_db.py -q
```
@@ -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 §§211.
## 14. Document history
| Date | Change |
|------|--------|
| 2026-07-09 | Initial ADR: dependency order, authority model, topology, lease migration, bridge, `incident_links`, security, non-goals |
+118
View File
@@ -0,0 +1,118 @@
# Recovering from `client is closing: EOF` on a Gitea MCP namespace (#543)
## Symptom
A tool call through a Gitea MCP namespace — `gitea-author`, `gitea-reviewer`,
`gitea-merger`, or the shared `gitea-tools` namespace — fails immediately with:
```
client is closing: EOF
```
Every subsequent call to that same namespace returns the same error, including
cheap read tools such as `gitea_whoami` and `gitea_list_profiles`. Other MCP
servers registered with the same client (for example `context7`) keep working,
so this is **not** a global MCP-client outage.
## Why this is not a code defect
This failure is a **transport-level** condition in the IDE / MCP client manager,
not a missing or broken tool:
- The tool can be present and registered in the Python `FastMCP` tool manager.
- Direct Python inspection of the server confirms the tool exists.
- Running the server manually and sending JSON-RPC over stdio works fine
(offline spawn) — that path does **not** prove the IDE namespace is healthy.
The client manager entered a closed state after the backing subprocess for that
namespace terminated (or was killed) behind its back. Once closed, the client
does **not** re-spawn the child on the next tool call — it just replays
`client is closing: EOF`. The OS process may even still be alive if a parent
language-server process is holding the stdio pipes open.
This is the canonical "registered in FastMCP ≠ callable through the namespace"
false-ready state. It is distinct from the **stale-runtime** family in #531 /
#544, where the process is reachable but running behind `master`; that case is
detected by the `ps`-based `_check_mcp_runtimes_diagnostics` in
`gitea_mcp_server.py`. The EOF case is a dead/closed transport, not a stale one,
so the `ps` check alone will not surface it.
## Recovery path (canonical — client reconnect only)
Do the steps in order. Stop as soon as a live **client-namespace** call succeeds.
1. **Confirm the blast radius.** Call a cheap read tool on the failing namespace
(`gitea_whoami` or `gitea_list_profiles`). Then call the same tool on a
different MCP server (e.g. `context7`).
- Only the Gitea namespace fails → single-namespace transport close. Continue.
- Every server fails → restart the whole MCP client, not just one namespace.
2. **Reconnect the namespace through the client, not the shell.** Use the IDE /
client MCP-reconnect action for that server entry (in Claude Code:
`/mcp` → reconnect the affected `gitea-*` server). Reconnecting forces the
client to spawn a fresh subprocess and re-open the pipe. This clears the
closed-client state that a bare `kill`/respawn from a terminal does **not**.
3. **Do not "fix" it by importing the server or poking the process.** Reaching
for `python -c 'import gitea_mcp_server ...'`, raw JSON-RPC from a shell,
killing PIDs to force a respawn, or touching MCP config mtimes does **not**
restore the *client's* view of the namespace and violates the daemon-import
guard (#558, `docs/mcp-daemon-import-guard.md`). The only sanctioned repair
is a **client reconnect / relaunch**.
4. **Verify through the same path the workflow will use.** After reconnect, call
the specific tool the blocked workflow needs — not just any tool — through
the target namespace. For a merge that means calling the merger-authorized
adoption/merge tool through `gitea-merger`. A green `gitea_whoami` on one
namespace does **not** prove another namespace or another tool is callable.
Record success with:
```text
gitea_assess_mcp_namespace_health(..., probe_source="client_namespace")
```
5. **If reconnect does not clear it,** relaunch the client entirely, then repeat
step 4. If EOF persists after a full relaunch, the backing subprocess is
failing to start — inspect its stderr / launch config (command path, venv,
`*_MCP_CONFIG`, `*_MCP_PROFILE` env) rather than retrying the call. Still
do not use PID kill or config-touch as the primary recovery.
## Diagnostics to capture when reporting EOF
Include all of these so the failure is actionable and reproducible:
- **Namespace name** that returned EOF (`gitea-author` / `gitea-reviewer` /
`gitea-merger` / `gitea-tools`).
- **Tool** that was called and the **exact** error string.
- **PID** of the backing process (if any) and whether it was still alive
(informational only — not a recovery action).
- **Profile / env** for that namespace (execution profile, `*_MCP_PROFILE`,
worktree binding such as `GITEA_AUTHOR_WORKTREE`).
- **Config path** the client launched the server from.
- Result of the **cross-server control** call (did `context7` succeed?).
## Offline spawn probe (non-authoritative)
`test_mcp_conn.py` performs a full JSON-RPC handshake against a **fresh
subprocess** (`initialize` → `initialized` → `tools/list` → `tools/call`) and
classifies with `probe_source=offline_spawn`. That is useful for offline
launch/registration debugging. It is **not** proof the IDE-managed namespace is
healthy. See `docs/mcp-namespace-health.md`.
## Do-not list during EOF recovery
- Do **not** retry a blocked merge/adoption until the required tool is confirmed
callable through the merger-authorized **client** namespace (see #543).
- Do **not** clean, reset, or rebind a **foreign** worktree to work around the
error.
- Do **not** bypass the namespace with direct imports, raw API/curl, or
in-memory state restoration.
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
reconnect.
## Related
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
- `docs/mcp-client-registration.md` — per-server registration contract.
- `docs/mcp-namespace-health.md` — probe sources and mutation enforcement.
+88
View File
@@ -0,0 +1,88 @@
# MCP namespace health diagnostics (#543)
Gitea MCP tools can be registered in the Python FastMCP server while the IDE's
live MCP namespace is still unusable. The failure usually appears as
`client is closing: EOF`, `transport closed`, or an empty response when calling
a tool such as `gitea_whoami`.
Do not treat static tool registration as proof that review or merge workflows
can proceed. A reviewer or merger flow must have **client-namespace** evidence
that the required tool is callable through the configured IDE MCP namespace.
## Probe sources (do not confuse them)
| Source | How obtained | Proves IDE namespace? |
| --- | --- | --- |
| `client_namespace` | Tool call through the IDE-managed MCP client | **Yes** |
| `offline_spawn` | `test_mcp_conn.py` subprocess JSON-RPC handshake | **No** (offline only) |
`gitea_assess_mcp_namespace_health` accepts `probe_source` and only sets
`ide_namespace_proven=true` for `client_namespace` success. Offline spawn
success never clears review/merge mutation gates.
## Client-namespace health check (canonical)
1. Through the IDE client, call a cheap tool on the target namespace
(`gitea_whoami` or `gitea_list_profiles`).
2. Feed the live result into:
```text
gitea_assess_mcp_namespace_health(
namespace="gitea-merger", # or reviewer / author / tools
registered_tools=[...], # optional static list
probe_result={"success": true, "result": {...}},
probe_source="client_namespace",
)
```
3. A healthy client-namespace assessment is recorded in the MCP session and
consulted by **live** `gitea_submit_pr_review` / `gitea_merge_pr` gates.
4. If the probe fails with EOF, recover via **client reconnect only** — see
`docs/mcp-namespace-eof-recovery.md`. Do **not** kill PIDs or touch MCP
config mtimes as a recovery procedure.
## Offline spawn probe (non-authoritative)
```bash
python3 test_mcp_conn.py --config ~/.gemini/config/mcp_config.json
```
This script spawns a **separate** server process from config, performs
JSON-RPC `initialize``tools/list``tools/call`, and classifies the
result with `probe_source=offline_spawn`. Use it for offline debugging of
launch command / registration. It does **not** prove the IDE-managed
namespace is healthy.
By default the script checks:
| Namespace | Required tool |
| --- | --- |
| `gitea-author` | `gitea_whoami` |
| `gitea-reviewer` | `gitea_whoami` |
| `gitea-merger` | `gitea_whoami` |
| `gitea-tools` | `gitea_list_profiles` |
## Recovery (canonical)
When a namespace returns EOF, follow
`docs/mcp-namespace-eof-recovery.md` in order:
1. Confirm blast radius (Gitea namespace vs all MCP servers).
2. **Reconnect the namespace through the client** (IDE reconnect / relaunch).
3. Do not repair via shell imports, raw JSON-RPC, PID kills, or config mtime
touches — those do not restore the client's closed transport.
4. Re-verify the **specific** required tool through the target namespace.
5. Resume review/merge only after a successful `client_namespace` assessment.
## Enforcement
1. **State machine (read-only):** feed `blocks_merge_workflow` from a
`client_namespace` assessment into
`gitea_assess_review_merge_state_machine(live_namespace_broken=...)`.
2. **Live mutations:** `gitea_submit_pr_review` and `gitea_merge_pr` call
`_live_namespace_health_gate` and fail closed when the session has a
recorded unhealthy or non-client probe for the required namespace
(`gitea-reviewer` for review, `gitea-merger` for merge).
When blocked, repair the IDE namespace and re-record a healthy
`client_namespace` assessment before retrying the mutation.
+1441 -160
View File
File diff suppressed because it is too large Load Diff
+788
View File
@@ -0,0 +1,788 @@
"""Observability incident bridge (#612).
Converts Sentry/GlitchTip **observations** into durable **Gitea issues** and
``incident_links`` rows on the #613 control-plane DB.
Hard rules (ADR):
* Gitea owns work; providers own incidents; the bridge only links them.
* Raw monitoring incidents are **never** assignable ``work_items``.
* The #600 allocator sees bridge work only as normal Gitea issues.
* Phase-1 prefers dry-run / explicit reconcile; apply creates/links issues.
* No tokens, DSNs, cookies, or other secrets in bodies, DB, or logs.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Sequence
from control_plane_db import ControlPlaneDB, ControlPlaneError, WORK_KINDS, _norm_scope
PROVIDERS = frozenset({"sentry", "glitchtip"})
OUTCOME_PREVIEW = "preview"
OUTCOME_LINKED = "linked_existing"
OUTCOME_CREATED = "created_issue"
OUTCOME_UPDATED = "updated_link"
OUTCOME_BLOCKED = "blocked"
OUTCOME_NO_ACTION = "no_safe_action"
# Patterns scrubbed from any bridge-facing text (bodies, titles, tags, logs).
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(
r"(?i)\b(api[_-]?token|token|secret|password|passwd)\s*[:=]\s*\S+"
),
re.compile(
r"(?i)\bAuthorization\s*[:=]\s*(?:Bearer\s+)?[A-Za-z0-9._\-+=/]{8,}"
),
re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-]{8,}"),
re.compile(r"(?i)\bBasic\s+[A-Za-z0-9+/=]{8,}"),
re.compile(r"(?i)\b(dsn|sentry_dsn|glitchtip_dsn)\s*[:=]\s*\S+"),
re.compile(r"https?://[^/\s]+:[^@/\s]+@"), # user:pass@host
re.compile(r"(?i)\b(keychain[_-]?id|private[_-]?key)\s*[:=]\s*\S+"),
re.compile(r"(?i)cookie\s*[:=]\s*[^\s;]+"),
re.compile(r"(?i)\bsession[_-]?id\s*[:=]\s*\S+"),
)
_SENSITIVE_TAG_KEYS = frozenset(
{
"authorization",
"cookie",
"set-cookie",
"password",
"passwd",
"secret",
"token",
"api_key",
"apikey",
"dsn",
"sentry_dsn",
"access_token",
"refresh_token",
}
)
DEFAULT_LABELS = (
"type:bug",
"observability",
"status:ready",
)
@dataclass(frozen=True)
class ProjectMapping:
"""Maps a monitoring project to a Gitea repository."""
name: str
provider: str
monitor_base_url: str
monitor_org: str
monitor_project: str
gitea_org: str
gitea_repo: str
default_labels: tuple[str, ...] = DEFAULT_LABELS
environment_filters: tuple[str, ...] = ()
severity_threshold: str | None = None
def __post_init__(self) -> None:
prov = (self.provider or "").strip().lower()
if prov not in PROVIDERS:
raise ControlPlaneError(
f"unknown provider '{self.provider}'; expected one of {sorted(PROVIDERS)}"
)
object.__setattr__(self, "provider", prov)
object.__setattr__(self, "monitor_base_url", (self.monitor_base_url or "").rstrip("/"))
object.__setattr__(self, "monitor_org", (self.monitor_org or "").strip())
object.__setattr__(self, "monitor_project", (self.monitor_project or "").strip())
object.__setattr__(self, "gitea_org", (self.gitea_org or "").strip())
object.__setattr__(self, "gitea_repo", (self.gitea_repo or "").strip())
object.__setattr__(
self,
"default_labels",
tuple(str(x).strip() for x in (self.default_labels or DEFAULT_LABELS) if str(x).strip()),
)
def as_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"provider": self.provider,
"monitor_base_url": self.monitor_base_url,
"monitor_org": self.monitor_org,
"monitor_project": self.monitor_project,
"gitea_org": self.gitea_org,
"gitea_repo": self.gitea_repo,
"default_labels": list(self.default_labels),
"environment_filters": list(self.environment_filters),
"severity_threshold": self.severity_threshold,
}
def matches_observation(self, obs: dict[str, Any]) -> bool:
if (obs.get("provider") or "").strip().lower() != self.provider:
return False
base = (obs.get("provider_base_url") or obs.get("monitor_base_url") or "").rstrip("/")
if base and self.monitor_base_url and base != self.monitor_base_url:
return False
org = (obs.get("provider_org") or obs.get("monitor_org") or "").strip()
if org and self.monitor_org and org != self.monitor_org:
return False
project = (obs.get("provider_project") or obs.get("monitor_project") or "").strip()
if project and self.monitor_project and project != self.monitor_project:
return False
return True
@dataclass
class NormalizedIncident:
provider: str
provider_base_url: str
provider_org: str
provider_project: str
provider_issue_id: str
provider_short_id: str | None = None
provider_permalink: str | None = None
fingerprint: str | None = None
first_seen: str | None = None
last_seen: str | None = None
event_count: int | None = None
status: str = "open"
environment: str | None = None
severity: str | None = None
culprit: str | None = None
title: str = ""
summary: str = ""
tags: dict[str, str] = field(default_factory=dict)
gitea_org: str = ""
gitea_repo: str = ""
default_labels: tuple[str, ...] = DEFAULT_LABELS
def provider_key(self) -> tuple[str, str, str, str, str]:
return (
self.provider,
_norm_scope(self.provider_base_url),
_norm_scope(self.provider_org),
_norm_scope(self.provider_project),
str(self.provider_issue_id),
)
def as_dict(self) -> dict[str, Any]:
return {
"provider": self.provider,
"provider_base_url": self.provider_base_url,
"provider_org": self.provider_org,
"provider_project": self.provider_project,
"provider_issue_id": self.provider_issue_id,
"provider_short_id": self.provider_short_id,
"provider_permalink": self.provider_permalink,
"fingerprint": self.fingerprint,
"first_seen": self.first_seen,
"last_seen": self.last_seen,
"event_count": self.event_count,
"status": self.status,
"environment": self.environment,
"severity": self.severity,
"culprit": self.culprit,
"title": self.title,
"summary": self.summary,
"tags": dict(self.tags),
"gitea_org": self.gitea_org,
"gitea_repo": self.gitea_repo,
"default_labels": list(self.default_labels),
}
def redact_text(value: Any) -> str:
"""Redact secret-like material from a string (fail closed to empty)."""
if value is None:
return ""
text = str(value)
for pat in _SECRET_PATTERNS:
text = pat.sub("[REDACTED]", text)
return text
def sanitize_tags(tags: Any) -> dict[str, str]:
if not isinstance(tags, dict):
return {}
out: dict[str, str] = {}
for k, v in tags.items():
key = str(k).strip().lower()
if not key or key in _SENSITIVE_TAG_KEYS:
continue
if any(s in key for s in ("token", "secret", "password", "cookie", "auth", "dsn")):
continue
val = redact_text(v)
if "[REDACTED]" in val:
continue
if len(val) > 200:
val = val[:200] + ""
out[key] = val
return out
def project_mapping_from_dict(data: dict[str, Any]) -> ProjectMapping:
labels = data.get("default_labels") or DEFAULT_LABELS
envs = data.get("environment_filters") or ()
return ProjectMapping(
name=str(data.get("name") or data.get("monitor_project") or "unnamed"),
provider=str(data.get("provider") or ""),
monitor_base_url=str(data.get("monitor_base_url") or data.get("provider_base_url") or ""),
monitor_org=str(data.get("monitor_org") or data.get("provider_org") or ""),
monitor_project=str(data.get("monitor_project") or data.get("provider_project") or ""),
gitea_org=str(data.get("gitea_org") or ""),
gitea_repo=str(data.get("gitea_repo") or ""),
default_labels=tuple(labels),
environment_filters=tuple(envs),
severity_threshold=data.get("severity_threshold"),
)
def load_project_mappings(
*,
config_path: str | None = None,
mappings_json: str | None = None,
) -> list[ProjectMapping]:
"""Load project mappings from JSON env, file, or explicit JSON string.
Env: ``GITEA_OBSERVABILITY_PROJECTS_JSON`` or path
``GITEA_OBSERVABILITY_PROJECTS_FILE``.
"""
raw: Any = None
if mappings_json:
raw = json.loads(mappings_json)
else:
env_json = (os.environ.get("GITEA_OBSERVABILITY_PROJECTS_JSON") or "").strip()
path = (
(config_path or "").strip()
or (os.environ.get("GITEA_OBSERVABILITY_PROJECTS_FILE") or "").strip()
)
if env_json:
raw = json.loads(env_json)
elif path and os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
raw = json.load(fh)
if raw is None:
return []
if isinstance(raw, dict) and "projects" in raw:
raw = raw["projects"]
if not isinstance(raw, list):
raise ControlPlaneError("observability project mappings must be a list")
return [project_mapping_from_dict(item) for item in raw if isinstance(item, dict)]
def resolve_mapping(
observation: dict[str, Any],
mappings: Sequence[ProjectMapping],
*,
explicit: ProjectMapping | None = None,
) -> ProjectMapping | None:
if explicit is not None:
return explicit
matches = [m for m in mappings if m.matches_observation(observation)]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise ControlPlaneError(
"ambiguous project mapping for observation: "
+ ", ".join(m.name for m in matches)
+ " (fail closed, #612)"
)
# Fallback: observation itself may carry gitea targets
g_org = (observation.get("gitea_org") or "").strip()
g_repo = (observation.get("gitea_repo") or "").strip()
provider = (observation.get("provider") or "").strip().lower()
if g_org and g_repo and provider in PROVIDERS:
return ProjectMapping(
name=f"inline-{provider}",
provider=provider,
monitor_base_url=str(
observation.get("provider_base_url")
or observation.get("monitor_base_url")
or ""
),
monitor_org=str(
observation.get("provider_org") or observation.get("monitor_org") or ""
),
monitor_project=str(
observation.get("provider_project")
or observation.get("monitor_project")
or ""
),
gitea_org=g_org,
gitea_repo=g_repo,
default_labels=tuple(observation.get("default_labels") or DEFAULT_LABELS),
)
return None
def normalize_incident(
observation: dict[str, Any],
mapping: ProjectMapping,
) -> NormalizedIncident:
"""Normalize + sanitize a raw provider observation (fail closed)."""
if not isinstance(observation, dict):
raise ControlPlaneError("observation must be a dict (fail closed, #612)")
provider = (observation.get("provider") or mapping.provider or "").strip().lower()
if provider not in PROVIDERS:
raise ControlPlaneError(
f"unknown provider '{provider}'; expected {sorted(PROVIDERS)} (fail closed)"
)
if provider != mapping.provider:
raise ControlPlaneError(
f"observation provider '{provider}' does not match mapping "
f"'{mapping.provider}' (fail closed, #612)"
)
issue_id = observation.get("provider_issue_id") or observation.get("id")
if issue_id is None or str(issue_id).strip() == "":
raise ControlPlaneError(
"observation missing provider_issue_id (fail closed, #612)"
)
base = (
observation.get("provider_base_url")
or observation.get("monitor_base_url")
or mapping.monitor_base_url
or ""
)
org = (
observation.get("provider_org")
or observation.get("monitor_org")
or mapping.monitor_org
or ""
)
project = (
observation.get("provider_project")
or observation.get("monitor_project")
or mapping.monitor_project
or ""
)
title = redact_text(
observation.get("title")
or observation.get("culprit")
or f"{provider} incident {issue_id}"
)
meta = observation.get("metadata")
meta_value = meta.get("value") if isinstance(meta, dict) else None
raw_sum = (
observation.get("summary")
or observation.get("message")
or meta_value
or title
)
summary = redact_text(raw_sum)
permalink = observation.get("provider_permalink") or observation.get("permalink")
if permalink:
permalink = redact_text(permalink)
if "[REDACTED]" in permalink:
permalink = None
tags = sanitize_tags(observation.get("tags") or {})
event_count = observation.get("event_count") or observation.get("count")
try:
event_count_i = int(event_count) if event_count is not None else None
except (TypeError, ValueError):
event_count_i = None
return NormalizedIncident(
provider=provider,
provider_base_url=str(base).rstrip("/"),
provider_org=str(org).strip(),
provider_project=str(project).strip(),
provider_issue_id=str(issue_id).strip(),
provider_short_id=redact_text(observation.get("provider_short_id") or observation.get("shortId") or "")
or None,
provider_permalink=permalink,
fingerprint=redact_text(observation.get("fingerprint") or "") or None,
first_seen=str(observation.get("first_seen") or observation.get("firstSeen") or "")
or None,
last_seen=str(observation.get("last_seen") or observation.get("lastSeen") or "")
or None,
event_count=event_count_i,
status=str(observation.get("status") or "open").strip().lower() or "open",
environment=redact_text(observation.get("environment") or "") or None,
severity=redact_text(observation.get("severity") or observation.get("level") or "")
or None,
culprit=redact_text(observation.get("culprit") or "") or None,
title=title[:200],
summary=summary[:2000],
tags=tags,
gitea_org=mapping.gitea_org,
gitea_repo=mapping.gitea_repo,
default_labels=mapping.default_labels,
)
def build_gitea_issue_title(inc: NormalizedIncident) -> str:
env = f" [{inc.environment}]" if inc.environment else ""
sev = f" ({inc.severity})" if inc.severity else ""
return redact_text(f"[obs:{inc.provider}]{env}{sev} {inc.title}")[:180]
def build_gitea_issue_body(inc: NormalizedIncident) -> str:
"""Sanitized durable issue body (no secrets)."""
lines = [
"## Observability incident (bridge #612)",
"",
"<!-- mcp-incident-bridge:v1 -->",
f"<!-- provider={inc.provider} issue_id={inc.provider_issue_id} -->",
"",
f"- **provider:** `{inc.provider}`",
f"- **provider_issue_id:** `{inc.provider_issue_id}`",
]
if inc.provider_short_id:
lines.append(f"- **provider_short_id:** `{inc.provider_short_id}`")
if inc.provider_permalink:
lines.append(f"- **provider_url:** {inc.provider_permalink}")
lines.extend(
[
f"- **provider_org/project:** `{inc.provider_org}` / `{inc.provider_project}`",
f"- **base_url:** `{inc.provider_base_url}`",
f"- **fingerprint:** `{inc.fingerprint or ''}`",
f"- **first_seen:** `{inc.first_seen or ''}`",
f"- **last_seen:** `{inc.last_seen or ''}`",
f"- **event_count:** `{inc.event_count if inc.event_count is not None else ''}`",
f"- **environment:** `{inc.environment or ''}`",
f"- **severity:** `{inc.severity or ''}`",
f"- **culprit:** `{inc.culprit or ''}`",
f"- **status:** `{inc.status}`",
"",
"### Summary",
"",
redact_text(inc.summary) or "(no summary)",
"",
"### Safe tags",
"",
]
)
if inc.tags:
for k, v in sorted(inc.tags.items()):
lines.append(f"- `{k}`: `{v}`")
else:
lines.append("- (none)")
lines.extend(
[
"",
"### Canonical next action",
"",
"Author: investigate and fix under normal Gitea workflow. "
"Allocator may select this issue as ordinary Gitea work "
"(never as a raw provider incident).",
"",
"### Bridge notes",
"",
"- Raw Sentry/GlitchTip incidents are **not** assignable work items.",
"- Gitea remains the durable work record; DB stores `incident_links` only.",
"- Provider tokens must never appear in this issue.",
]
)
return "\n".join(lines)
def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | None:
"""Fail closed if existing link targets a different Gitea issue/repo."""
eg_org = str(existing.get("gitea_org") or "")
eg_repo = str(existing.get("gitea_repo") or "")
eg_num = existing.get("gitea_issue_number")
if eg_org and eg_org != inc.gitea_org:
return (
f"existing link points to org '{eg_org}', mapping wants "
f"'{inc.gitea_org}' (fail closed, #612)"
)
if eg_repo and eg_repo != inc.gitea_repo:
return (
f"existing link points to repo '{eg_repo}', mapping wants "
f"'{inc.gitea_repo}' (fail closed, #612)"
)
# fingerprint conflict when both present and disagree
ef = (existing.get("fingerprint") or "").strip()
nf = (inc.fingerprint or "").strip()
if ef and nf and ef != nf:
# Same provider issue id with different fingerprints is ambiguous.
return (
f"fingerprint conflict for provider issue {inc.provider_issue_id}: "
f"link has '{ef}', observation has '{nf}' (fail closed, #612)"
)
return None
CreateIssueFn = Callable[[str, str, list[str], str, str], dict[str, Any]]
# create_issue_fn(title, body, labels, gitea_org, gitea_repo) -> {"number": int, ...}
def reconcile_incident(
db: ControlPlaneDB | None,
*,
observation: dict[str, Any],
mappings: Sequence[ProjectMapping] | None = None,
mapping: ProjectMapping | None = None,
apply: bool = False,
create_issue_fn: CreateIssueFn | None = None,
force_gitea_issue_number: int | None = None,
) -> dict[str, Any]:
"""Reconcile one observation into incident_links + optional Gitea issue.
*apply=False* (default): preview only — no DB mutation, no Gitea mutation.
*apply=True*: upsert link; create Gitea issue when none linked (requires
``create_issue_fn``) or use ``force_gitea_issue_number`` for explicit link.
Never creates control-plane ``work_items`` for raw incidents.
"""
base: dict[str, Any] = {
"success": False,
"apply": bool(apply),
"outcome": OUTCOME_NO_ACTION,
"performed": False,
"gitea_mutated": False,
"db_mutated": False,
"raw_incident_assignable": False,
"work_item_kind_would_be": None,
"reasons": [],
"skipped": None,
"incident": None,
"existing_link": None,
"gitea_issue": None,
"action": None,
"mapping": None,
"substrate": "control_plane_db.incident_links",
"durable_work_system": "gitea_issues",
}
if db is None:
base["reasons"] = [
"control-plane DB substrate unavailable (fail closed, #613/#612)"
]
base["outcome"] = OUTCOME_BLOCKED
return base
try:
maps = list(mappings or [])
resolved = resolve_mapping(observation, maps, explicit=mapping)
if resolved is None:
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [
"no project mapping for observation (fail closed, #612); "
"configure GITEA_OBSERVABILITY_PROJECTS_JSON or pass mapping"
]
return base
base["mapping"] = resolved.as_dict()
inc = normalize_incident(observation, resolved)
base["incident"] = inc.as_dict()
except (ControlPlaneError, json.JSONDecodeError, TypeError, ValueError) as exc:
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [str(exc)]
return base
# Environment filter (optional)
if resolved.environment_filters and inc.environment:
allowed = {e.lower() for e in resolved.environment_filters}
if inc.environment.lower() not in allowed:
base["outcome"] = OUTCOME_NO_ACTION
base["reasons"] = [
f"environment '{inc.environment}' not in filters "
f"{sorted(allowed)}; skip (policy)"
]
base["success"] = True
base["action"] = "skip_environment_filter"
return base
existing = db.get_incident_link_by_provider(
provider=inc.provider,
provider_issue_id=inc.provider_issue_id,
provider_base_url=inc.provider_base_url,
provider_org=inc.provider_org,
provider_project=inc.provider_project,
)
base["existing_link"] = existing
if existing:
conflict = _link_conflict(existing, inc)
if conflict:
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [conflict]
return base
title = build_gitea_issue_title(inc)
body = build_gitea_issue_body(inc)
labels = list(inc.default_labels)
if inc.provider and f"{inc.provider}" not in labels:
labels.append(inc.provider)
if "observability" not in labels:
labels.append("observability")
preview_issue = {
"title": title,
"body_preview": body[:500] + ("" if len(body) > 500 else ""),
"labels": labels,
"gitea_org": inc.gitea_org,
"gitea_repo": inc.gitea_repo,
"would_create": existing is None and force_gitea_issue_number is None,
"would_reuse_issue": int(existing["gitea_issue_number"])
if existing
else force_gitea_issue_number,
}
base["gitea_issue_preview"] = preview_issue
if not apply:
base["success"] = True
base["outcome"] = OUTCOME_PREVIEW
base["action"] = (
"preview_reuse_link" if existing else "preview_create_issue"
)
base["reasons"] = [
"dry-run only (apply=false); no Gitea mutation and no DB write — "
"call again with apply=true to create/link"
]
if existing:
base["gitea_issue"] = {
"number": existing.get("gitea_issue_number"),
"org": existing.get("gitea_org"),
"repo": existing.get("gitea_repo"),
}
# Prove we never treat this as work_item kind incident
base["raw_incident_assignable"] = False
base["work_item_kind_would_be"] = "issue" # only after Gitea issue exists
return base
# --- apply path ---
issue_number: int | None = None
created = False
if existing:
issue_number = int(existing["gitea_issue_number"])
action = "updated_existing_link"
outcome = OUTCOME_UPDATED
elif force_gitea_issue_number is not None:
issue_number = int(force_gitea_issue_number)
action = "link_explicit_issue"
outcome = OUTCOME_LINKED
else:
if create_issue_fn is None:
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [
"apply=true requires create_issue_fn when no existing link "
"(fail closed, #612)"
]
return base
try:
created_res = create_issue_fn(
title, body, labels, inc.gitea_org, inc.gitea_repo
)
except Exception as exc: # noqa: BLE001
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [
f"Gitea issue creation failed: {redact_text(exc)} (fail closed)"
]
return base
number = created_res.get("number") if isinstance(created_res, dict) else None
if number is None:
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [
"Gitea issue creation returned no issue number (fail closed, #612)"
]
base["create_result"] = created_res
return base
issue_number = int(number)
created = True
action = "created_gitea_issue"
outcome = OUTCOME_CREATED
base["gitea_mutated"] = True
base["create_result"] = {
k: created_res.get(k)
for k in ("number", "success", "performed", "reasons")
if isinstance(created_res, dict)
}
assert issue_number is not None
try:
link = db.upsert_incident_link(
provider=inc.provider,
provider_issue_id=inc.provider_issue_id,
gitea_org=inc.gitea_org,
gitea_repo=inc.gitea_repo,
gitea_issue_number=issue_number,
provider_base_url=inc.provider_base_url,
provider_org=inc.provider_org,
provider_project=inc.provider_project,
provider_short_id=inc.provider_short_id,
provider_permalink=inc.provider_permalink,
fingerprint=inc.fingerprint,
first_seen=inc.first_seen,
last_seen=inc.last_seen,
event_count=inc.event_count,
status=inc.status if not created else "open",
)
except Exception as exc: # noqa: BLE001
base["outcome"] = OUTCOME_BLOCKED
base["reasons"] = [
f"incident_links upsert failed: {redact_text(exc)} (fail closed, #613)"
]
base["gitea_issue"] = {
"number": issue_number,
"org": inc.gitea_org,
"repo": inc.gitea_repo,
"created": created,
}
return base
base["success"] = True
base["performed"] = True
base["db_mutated"] = True
base["outcome"] = outcome
base["action"] = action
base["gitea_issue"] = {
"number": issue_number,
"org": inc.gitea_org,
"repo": inc.gitea_repo,
"created": created,
}
base["link"] = {
k: link.get(k)
for k in (
"link_id",
"provider",
"provider_issue_id",
"gitea_org",
"gitea_repo",
"gitea_issue_number",
"fingerprint",
"event_count",
"status",
"last_sync_at",
)
}
base["reasons"] = [
(
f"reused Gitea issue #{issue_number} for provider "
f"{inc.provider}:{inc.provider_issue_id}"
if not created
else f"created Gitea issue #{issue_number} and stored incident_links row"
),
"raw provider incident is not an assignable work_item "
f"(WORK_KINDS={sorted(WORK_KINDS)})",
]
base["raw_incident_assignable"] = False
base["work_item_kind_would_be"] = "issue"
base["allocator_visible_as"] = {
"kind": "issue",
"number": issue_number,
"org": inc.gitea_org,
"repo": inc.gitea_repo,
"note": "allocator selects normal Gitea issues only (#600/#612)",
}
return base
def assert_not_raw_incident_work_item(kind: str) -> None:
"""Guard used by tests / callers: incidents must not enter WORK_KINDS."""
k = (kind or "").strip().lower()
if k not in WORK_KINDS:
if k in ("sentry_incident", "glitchtip_incident", "incident", "observation"):
raise ControlPlaneError(
f"kind '{k}' is not assignable; bridge must create Gitea issues first (#612)"
)
raise ControlPlaneError(f"kind '{k}' is not assignable")
+63
View File
@@ -375,6 +375,64 @@ def _same_realpath(left: str | None, right: str | None) -> bool:
return left == right
def assess_expired_lock_reclaim(
existing_lock: dict[str, Any] | None,
*,
now: datetime | None = None,
) -> dict[str, Any]:
"""Decide whether an expired/stale author issue lock may be reclaimed (#601).
Required proof (any reclaim of non-live lock):
* lease not live (expired or dead pid)
* owner process dead OR worktree missing
* no force-delete of live foreign ownership
"""
if not existing_lock:
return {
"reclaim_allowed": True,
"reasons": ["no existing lock"],
"freshness": assess_lock_freshness(None, now=now),
}
freshness = assess_lock_freshness(existing_lock, now=now)
if freshness.get("live"):
return {
"reclaim_allowed": False,
"reasons": ["lock is still live; cannot reclaim (fail closed)"],
"freshness": freshness,
}
pid = existing_lock.get("session_pid")
if pid is None:
pid = existing_lock.get("pid")
dead = not is_process_alive(pid) if pid is not None else True
wt = str(existing_lock.get("worktree_path") or "")
missing_wt = (not wt) or (not os.path.isdir(os.path.realpath(wt)))
if not (dead or missing_wt):
return {
"reclaim_allowed": False,
"reasons": [
"expired/stale lock still has live owner pid and present worktree; "
"recovery review required (fail closed)"
],
"freshness": freshness,
"owner_pid_dead": dead,
"worktree_missing": missing_wt,
}
return {
"reclaim_allowed": True,
"reasons": [
"non-live lock with dead process and/or missing worktree; "
"sanctioned reclaim allowed"
],
"freshness": freshness,
"owner_pid_dead": dead,
"worktree_missing": missing_wt,
"prior_branch": existing_lock.get("branch_name"),
"prior_worktree": existing_lock.get("worktree_path"),
"prior_pid": pid,
}
def assess_same_issue_lease_conflict(
existing_lock: dict[str, Any] | None,
*,
@@ -405,6 +463,11 @@ def assess_same_issue_lease_conflict(
and _same_realpath(str(existing_worktree or ""), worktree_path)
)
if is_lease_expired(existing_lock, now=now):
reclaim = assess_expired_lock_reclaim(existing_lock, now=now)
if reclaim.get("reclaim_allowed"):
# #601: expired + dead pid / missing worktree may be reclaimed
# through the normal lock path (sanctioned overwrite).
return None
return (
f"Issue #{issue_number} has an expired {operation_type} lease on "
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
+723
View File
@@ -0,0 +1,723 @@
"""First-class control-plane lease lifecycle (#601).
Active leases are queryable workflow state. Canonical operations:
* list / inspect
* adopt (with provenance)
* release (explicit, recorded)
* expire / reclaim
* abandon (requires proof: dead process and/or missing worktree, etc.)
Authority model:
* Control-plane DB is the coordination source for assignment/lease.
* File locks and comment-only leases are **not** authoritative alone.
* Reviewer/merger comment leases (``reviewer_pr_lease`` / merger adoption)
remain for Gitea-thread durability; they must not bypass DB assignment when
the control-plane path is in use.
Fail closed on ambiguous ownership, active foreign leases, mismatched
worktree, stale head, missing capability, and unsafe cleanup.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable, Mapping
import control_plane_db as cpd
# Outcomes / safe next actions (stable vocabulary for tools + tests).
SAFE_OWNER_RESUME = "owner_resume"
SAFE_WAIT_FOREIGN = "wait_foreign_active"
SAFE_RECLAIM_EXPIRED = "reclaim_expired"
SAFE_ABANDON_ALLOWED = "abandon_allowed"
SAFE_RELEASE_OWNED = "release_owned"
SAFE_STALE_PROMPT = "stale_prompt_lease"
SAFE_UNKNOWN = "inspect_only"
SAFE_NO_AUTHORITY = "file_or_comment_not_authoritative"
LEASE_STATUS_ACTIVE = "active"
LEASE_STATUS_RELEASED = "released"
LEASE_STATUS_EXPIRED = "expired"
LEASE_STATUS_ABANDONED = "abandoned"
AUTHORITATIVE_SOURCE = "control_plane_db"
class LeaseLifecycleError(RuntimeError):
"""Fail-closed lifecycle policy error."""
@dataclass(frozen=True)
class AbandonProof:
"""Required evidence to abandon a non-owned or expired lease safely."""
dead_process: bool = False
missing_worktree: bool = False
same_owner: bool = False
no_open_pr: bool = False
no_live_mutation_risk: bool = False
operator_authorized: bool = False
worktree_path: str | None = None
owner_pid: int | None = None
notes: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"dead_process": self.dead_process,
"missing_worktree": self.missing_worktree,
"same_owner": self.same_owner,
"no_open_pr": self.no_open_pr,
"no_live_mutation_risk": self.no_live_mutation_risk,
"operator_authorized": self.operator_authorized,
"worktree_path": self.worktree_path,
"owner_pid": self.owner_pid,
"notes": self.notes,
}
def is_sufficient(self) -> bool:
"""Abandon requires process death or missing worktree + no mutation risk.
Foreign active leases additionally need operator_authorized unless the
owner process is dead and the worktree is missing.
"""
if not self.no_live_mutation_risk:
return False
if not (self.dead_process or self.missing_worktree):
return False
if self.same_owner:
return True
# Foreign abandon: operator flag OR (dead + missing worktree + no risk)
if self.operator_authorized:
return True
return bool(self.dead_process and self.missing_worktree and self.no_open_pr)
def is_process_alive(pid: int | None) -> bool:
if pid is None:
return False
try:
pid_i = int(pid)
except (TypeError, ValueError):
return False
if pid_i <= 0:
return False
try:
os.kill(pid_i, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
# Exists but not owned by us — treat as alive (fail closed).
return True
except OSError:
return False
def worktree_exists(path: str | None) -> bool:
if not path:
return False
try:
return os.path.isdir(os.path.realpath(path))
except OSError:
return False
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _parse_ts(value: str | None) -> datetime | None:
return cpd._parse_ts(value)
def classify_lease_freshness(
lease: Mapping[str, Any],
*,
now: datetime | None = None,
pid_checker: Callable[[int | None], bool] = is_process_alive,
worktree_checker: Callable[[str | None], bool] = worktree_exists,
) -> dict[str, Any]:
"""Classify a control-plane lease row for workflow tooling."""
moment = now or _utc_now()
status = (lease.get("status") or "").strip().lower()
expires = _parse_ts(lease.get("expires_at"))
owner_pid = lease.get("owner_pid")
if owner_pid is None:
owner_pid = lease.get("session_pid")
wt = lease.get("worktree_path")
pid_alive = pid_checker(owner_pid) if owner_pid is not None else None
wt_present = worktree_checker(wt) if wt else None
expired_by_time = bool(expires and expires <= moment)
if status == LEASE_STATUS_ABANDONED:
freshness = "abandoned"
elif status == LEASE_STATUS_RELEASED:
freshness = "released"
elif status == LEASE_STATUS_EXPIRED or expired_by_time:
freshness = "expired"
elif status != LEASE_STATUS_ACTIVE:
freshness = status or "unknown"
elif pid_alive is False:
freshness = "stale_dead_process"
elif wt is not None and wt_present is False:
freshness = "stale_missing_worktree"
else:
freshness = "active"
return {
"freshness": freshness,
"status": status,
"expired_by_time": expired_by_time,
"owner_pid": owner_pid,
"owner_pid_alive": pid_alive,
"worktree_path": wt,
"worktree_present": wt_present,
"expires_at": lease.get("expires_at"),
"authoritative_source": AUTHORITATIVE_SOURCE,
"file_lock_only": False,
"comment_lease_only": False,
}
def decide_safe_next_action(
*,
lease: Mapping[str, Any] | None,
caller_session_id: str,
freshness: Mapping[str, Any] | None = None,
caller_worktree: str | None = None,
) -> dict[str, Any]:
"""Return safe_next_action + reasons for a caller inspecting a lease."""
if not lease:
return {
"safe_next_action": SAFE_STALE_PROMPT,
"reasons": ["lease not found in control-plane DB (stale prompt id?)"],
"block": True,
}
fr = freshness or classify_lease_freshness(lease)
owner = str(lease.get("session_id") or "")
same_owner = owner == str(caller_session_id)
status = fr.get("freshness")
reasons: list[str] = []
# Worktree mismatch for owner resume
lease_wt = lease.get("worktree_path")
if (
same_owner
and lease_wt
and caller_worktree
and os.path.realpath(str(lease_wt)) != os.path.realpath(str(caller_worktree))
):
return {
"safe_next_action": SAFE_UNKNOWN,
"reasons": [
f"worktree mismatch: lease has {lease_wt!r}, caller has "
f"{caller_worktree!r} (fail closed)"
],
"block": True,
"same_owner": True,
}
if status in ("abandoned", "released"):
return {
"safe_next_action": SAFE_STALE_PROMPT,
"reasons": [f"lease status is {status}; do not adopt blindly"],
"block": True,
"same_owner": same_owner,
}
if status == "expired" or fr.get("expired_by_time"):
return {
"safe_next_action": SAFE_RECLAIM_EXPIRED,
"reasons": ["lease expired; reclaim via expire+assign or adopt reclaim path"],
"block": False,
"same_owner": same_owner,
}
if status in ("stale_dead_process", "stale_missing_worktree"):
if same_owner:
return {
"safe_next_action": SAFE_OWNER_RESUME,
"reasons": [
f"caller owns lease with freshness={status}; rebind via "
"adopt/owner-resume or abandon with proof"
],
"block": False,
"same_owner": True,
"also_allowed": [SAFE_ABANDON_ALLOWED, SAFE_RELEASE_OWNED],
}
return {
"safe_next_action": SAFE_ABANDON_ALLOWED,
"reasons": [
f"lease freshness={status}; abandon with required proof then reassign"
],
"block": False,
"same_owner": False,
}
if same_owner and status == "active":
return {
"safe_next_action": SAFE_OWNER_RESUME,
"reasons": [
"caller owns active lease; resume via heartbeat/assign owner-resume "
"or release explicitly"
],
"block": False,
"same_owner": True,
"also_allowed": [SAFE_RELEASE_OWNED],
}
if not same_owner and status == "active":
return {
"safe_next_action": SAFE_WAIT_FOREIGN,
"reasons": [
f"foreign active lease held by session {owner}; "
"do not steal (fail closed)"
],
"block": True,
"same_owner": False,
"owner_session_id": owner,
}
return {
"safe_next_action": SAFE_UNKNOWN,
"reasons": [f"unclassified freshness={status}"],
"block": True,
"same_owner": same_owner,
}
def non_db_lease_authority_report(
*,
file_lock_present: bool = False,
comment_lease_present: bool = False,
db_lease_present: bool = False,
) -> dict[str, Any]:
"""File/comment leases alone are not coordination authority (#601 / #600)."""
authoritative = bool(db_lease_present)
reasons: list[str] = []
if file_lock_present and not db_lease_present:
reasons.append(
"file lock present but control-plane DB lease absent — "
"file lock is not authoritative alone"
)
if comment_lease_present and not db_lease_present:
reasons.append(
"comment-only lease present but control-plane DB lease absent — "
"comment lease is not authoritative alone"
)
if authoritative:
reasons.append("control-plane DB lease is the coordination authority")
return {
"authoritative_source": AUTHORITATIVE_SOURCE if authoritative else None,
"db_lease_present": db_lease_present,
"file_lock_present": file_lock_present,
"comment_lease_present": comment_lease_present,
"safe_next_action": (
SAFE_UNKNOWN if authoritative else SAFE_NO_AUTHORITY
),
"reasons": reasons,
"file_lock_only": bool(file_lock_present and not db_lease_present),
"comment_lease_only": bool(comment_lease_present and not db_lease_present),
}
def build_adopt_provenance(
*,
adopted_from_session_id: str,
adopted_by_session_id: str,
work_kind: str,
work_number: int,
remote: str,
org: str,
repo: str,
worktree_path: str | None,
expected_head_sha: str | None,
prior_lease_id: str | None,
reason: str,
) -> dict[str, Any]:
return {
"adopted_from_session_id": adopted_from_session_id,
"adopted_by_session_id": adopted_by_session_id,
"work_kind": work_kind,
"work_number": int(work_number),
"remote": remote,
"org": org,
"repo": repo,
"worktree_path": worktree_path,
"expected_head_sha": expected_head_sha,
"prior_lease_id": prior_lease_id,
"reason": reason,
"recorded_at": cpd._ts(),
"source": "lease_lifecycle.adopt",
}
def inspect_lease(
db: cpd.ControlPlaneDB,
lease_id: str,
*,
caller_session_id: str,
caller_worktree: str | None = None,
) -> dict[str, Any]:
"""Full first-class lease workflow state for one lease id."""
state = db.get_lease_workflow_state(lease_id)
if not state:
decision = decide_safe_next_action(
lease=None, caller_session_id=caller_session_id
)
return {
"success": True,
"found": False,
"lease_id": lease_id,
"lease": None,
"freshness": None,
**decision,
"authoritative_source": AUTHORITATIVE_SOURCE,
"file_lock_only": False,
"comment_lease_only": False,
}
lease = state["lease"]
freshness = classify_lease_freshness(lease)
decision = decide_safe_next_action(
lease=lease,
caller_session_id=caller_session_id,
freshness=freshness,
caller_worktree=caller_worktree,
)
return {
"success": True,
"found": True,
"lease_id": lease_id,
"lease": lease,
"assignment": state.get("assignment"),
"work_item": state.get("work_item"),
"session": state.get("session"),
"freshness": freshness,
"provenance": state.get("provenance"),
**decision,
"authoritative_source": AUTHORITATIVE_SOURCE,
"file_lock_only": False,
"comment_lease_only": False,
}
def list_active_leases(
db: cpd.ControlPlaneDB,
*,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
role: str | None = None,
include_non_active: bool = False,
limit: int = 100,
) -> dict[str, Any]:
statuses = None if include_non_active else (LEASE_STATUS_ACTIVE,)
rows = db.list_leases(
remote=remote,
org=org,
repo=repo,
role=role,
statuses=statuses,
limit=limit,
)
enriched = []
for row in rows:
fr = classify_lease_freshness(row)
enriched.append({**row, "freshness": fr})
return {
"success": True,
"count": len(enriched),
"leases": enriched,
"authoritative_source": AUTHORITATIVE_SOURCE,
"file_lock_only": False,
"comment_lease_only": False,
"include_non_active": include_non_active,
}
def adopt_lease(
db: cpd.ControlPlaneDB,
*,
lease_id: str,
adopter_session_id: str,
role: str,
worktree_path: str | None = None,
expected_head_sha: str | None = None,
owner_pid: int | None = None,
operator_authorized: bool = False,
) -> dict[str, Any]:
"""Sanctioned adopt path with provenance; never silent foreign steal."""
state = db.get_lease_workflow_state(lease_id)
if not state:
raise LeaseLifecycleError(
f"unknown lease_id {lease_id}; stale prompt id (fail closed)"
)
lease = state["lease"]
work = state["work_item"]
freshness = classify_lease_freshness(lease)
owner = str(lease.get("session_id") or "")
same_owner = owner == str(adopter_session_id)
if freshness["freshness"] == "active" and not same_owner:
raise LeaseLifecycleError(
f"refusing to steal active foreign lease {lease_id} owned by "
f"{owner} (fail closed)"
)
if freshness["freshness"] in ("abandoned", "released"):
raise LeaseLifecycleError(
f"lease {lease_id} is {freshness['freshness']}; cannot adopt "
"(fail closed)"
)
# Expired or stale: require abandon-style safety before ownership transfer
# when not same owner; same owner may reclaim.
if not same_owner and freshness["freshness"] in (
"expired",
"stale_dead_process",
"stale_missing_worktree",
):
if not operator_authorized and freshness["freshness"] == "expired":
# Deterministic reclaim of expired foreign lease is allowed
# without operator flag (sanctioned expire reclaim).
pass
elif freshness["freshness"] != "expired" and not operator_authorized:
# stale active-looking requires abandon proof path
raise LeaseLifecycleError(
f"lease {lease_id} freshness={freshness['freshness']}; "
"use abandon with proof before foreign adopt (fail closed)"
)
provenance = build_adopt_provenance(
adopted_from_session_id=owner,
adopted_by_session_id=adopter_session_id,
work_kind=str(work.get("kind")),
work_number=int(work.get("number")),
remote=str(work.get("remote")),
org=str(work.get("org")),
repo=str(work.get("repo")),
worktree_path=worktree_path,
expected_head_sha=expected_head_sha or lease.get("expected_head_sha"),
prior_lease_id=lease_id,
reason=(
"owner-resume-adopt" if same_owner else "sanctioned-reclaim-adopt"
),
)
result = db.adopt_lease(
lease_id=lease_id,
adopter_session_id=adopter_session_id,
role=role,
worktree_path=worktree_path,
expected_head_sha=expected_head_sha,
owner_pid=owner_pid if owner_pid is not None else os.getpid(),
provenance=provenance,
)
return {
"success": True,
"outcome": result.get("outcome"),
"same_owner": same_owner,
"prior_lease_id": lease_id,
"lease": result.get("lease"),
"assignment": result.get("assignment"),
"provenance": provenance,
"authoritative_source": AUTHORITATIVE_SOURCE,
"file_lock_only": False,
"comment_lease_only": False,
"reasons": result.get("reasons") or [],
}
def release_lease(
db: cpd.ControlPlaneDB,
*,
lease_id: str,
session_id: str,
) -> dict[str, Any]:
proof = db.release_lease_recorded(lease_id=lease_id, session_id=session_id)
return {
"success": True,
"outcome": "released",
"lease_id": lease_id,
"session_id": session_id,
"release_proof": proof,
"authoritative_source": AUTHORITATIVE_SOURCE,
"file_lock_only": False,
"comment_lease_only": False,
}
def expire_leases(db: cpd.ControlPlaneDB) -> dict[str, Any]:
n = db.expire_stale_leases()
return {
"success": True,
"outcome": "expired",
"expired_count": int(n),
"authoritative_source": AUTHORITATIVE_SOURCE,
"file_lock_only": False,
"comment_lease_only": False,
}
def reclaim_expired_lease(
db: cpd.ControlPlaneDB,
*,
lease_id: str,
session_id: str,
role: str,
worktree_path: str | None = None,
expected_head_sha: str | None = None,
) -> dict[str, Any]:
"""Expire-if-needed then assign to *session_id* for the same work item."""
state = db.get_lease_workflow_state(lease_id)
if not state:
raise LeaseLifecycleError(f"unknown lease_id {lease_id}")
lease = state["lease"]
work = state["work_item"]
freshness = classify_lease_freshness(lease)
if freshness["freshness"] == "active":
if lease.get("session_id") != session_id:
raise LeaseLifecycleError(
f"cannot reclaim active foreign lease {lease_id} (fail closed)"
)
# owner resume
return adopt_lease(
db,
lease_id=lease_id,
adopter_session_id=session_id,
role=role,
worktree_path=worktree_path,
expected_head_sha=expected_head_sha,
)
if freshness["freshness"] not in (
"expired",
"stale_dead_process",
"stale_missing_worktree",
"abandoned",
"released",
):
raise LeaseLifecycleError(
f"lease {lease_id} freshness={freshness['freshness']} not reclaimable"
)
# Ensure expired marker is applied
db.expire_stale_leases()
# If still active due to clock skew / non-time stale, force expire this lease
refreshed = db.get_lease_workflow_state(lease_id)
if refreshed and refreshed["lease"].get("status") == "active":
db.force_expire_lease(lease_id, reason="reclaim_expired_lease")
head = expected_head_sha or work.get("current_head_sha")
assigned = db.assign_and_lease(
session_id=session_id,
role=role,
remote=str(work["remote"]),
org=str(work["org"]),
repo=str(work["repo"]),
kind=str(work["kind"]),
number=int(work["number"]),
expected_head_sha=head,
phase="reclaimed",
worktree_path=worktree_path,
owner_pid=os.getpid(),
)
if assigned.outcome != "assigned":
raise LeaseLifecycleError(
f"reclaim assign failed: {assigned.outcome}{assigned.reason}"
)
provenance = build_adopt_provenance(
adopted_from_session_id=str(lease.get("session_id")),
adopted_by_session_id=session_id,
work_kind=str(work["kind"]),
work_number=int(work["number"]),
remote=str(work["remote"]),
org=str(work["org"]),
repo=str(work["repo"]),
worktree_path=worktree_path,
expected_head_sha=head,
prior_lease_id=lease_id,
reason="reclaim_expired",
)
db.attach_lease_provenance(assigned.lease_id, provenance)
return {
"success": True,
"outcome": "reclaimed",
"prior_lease_id": lease_id,
"assignment": assigned.as_dict(),
"provenance": provenance,
"authoritative_source": AUTHORITATIVE_SOURCE,
"file_lock_only": False,
"comment_lease_only": False,
}
def abandon_lease(
db: cpd.ControlPlaneDB,
*,
lease_id: str,
requester_session_id: str,
proof: AbandonProof,
) -> dict[str, Any]:
state = db.get_lease_workflow_state(lease_id)
if not state:
raise LeaseLifecycleError(f"unknown lease_id {lease_id}")
lease = state["lease"]
owner = str(lease.get("session_id") or "")
same_owner = owner == str(requester_session_id)
# Enrich proof with live checks when not provided
owner_pid = proof.owner_pid
if owner_pid is None:
owner_pid = lease.get("owner_pid")
wt = proof.worktree_path if proof.worktree_path is not None else lease.get(
"worktree_path"
)
dead = proof.dead_process or (
owner_pid is not None and not is_process_alive(owner_pid)
)
missing_wt = proof.missing_worktree or (
bool(wt) and not worktree_exists(str(wt))
)
enriched = AbandonProof(
dead_process=bool(dead),
missing_worktree=bool(missing_wt),
same_owner=same_owner or proof.same_owner,
no_open_pr=proof.no_open_pr,
no_live_mutation_risk=proof.no_live_mutation_risk,
operator_authorized=proof.operator_authorized,
worktree_path=str(wt) if wt else None,
owner_pid=int(owner_pid) if owner_pid is not None else None,
notes=proof.notes,
)
if not enriched.is_sufficient():
raise LeaseLifecycleError(
"abandon proof insufficient: need (dead_process or missing_worktree) "
"and no_live_mutation_risk; foreign abandon also needs "
"operator_authorized or (dead+missing_worktree+no_open_pr). "
f"proof={enriched.as_dict()}"
)
# Active foreign with live process + present worktree: never abandon without
# operator (already covered by is_sufficient).
result = db.abandon_lease(
lease_id=lease_id,
requester_session_id=requester_session_id,
proof=enriched.as_dict(),
)
return {
"success": True,
"outcome": "abandoned",
"lease_id": lease_id,
"requester_session_id": requester_session_id,
"prior_owner_session_id": owner,
"abandon_proof": enriched.as_dict(),
"audit": result,
"authoritative_source": AUTHORITATIVE_SOURCE,
"file_lock_only": False,
"comment_lease_only": False,
}
+12 -4
View File
@@ -22,7 +22,7 @@ venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
if os.path.exists(venv_python) and sys.executable != venv_python:
os.execv(venv_python, [venv_python] + sys.argv)
from gitea_auth import get_auth_header, api_request, repo_api_url
from gitea_auth import get_auth_header, api_request, api_get_all, repo_api_url
import issue_workflow_labels
HOST = "gitea.dadeschools.net"
@@ -82,9 +82,17 @@ def api(method, path, auth, payload=None):
def _labels_by_name(auth):
"""Return {label name: id} for the repo's existing labels."""
existing = api("GET", "/labels?limit=100", auth) or []
return {lb["name"]: lb["id"] for lb in existing}
"""Return {label name: id} for the repo's existing labels (all pages, #627)."""
existing = api_get_all(f"{BASE_URL}/labels", auth) or []
name_to_id = {}
for lb in existing:
if not isinstance(lb, dict):
continue
name = lb.get("name")
lid = lb.get("id")
if name and lid is not None and name not in name_to_id:
name_to_id[name] = lid
return name_to_id
def create_labels(auth, dry=False):
+5 -5
View File
@@ -17,7 +17,7 @@ if os.path.exists(venv_python) and sys.executable != venv_python:
from gitea_auth import (
get_auth_header, resolve_remote, add_remote_args,
api_request, repo_api_url,
api_request, api_get_all, repo_api_url,
)
LABEL_NAME = "status:in-progress"
@@ -45,12 +45,12 @@ def main(argv=None):
base = repo_api_url(host, org, repo)
try:
# Find the label ID
labels = api_request("GET", f"{base}/labels?limit=100", auth)
# Paginated inventory (#627): Gitea caps single pages at 50.
labels = api_get_all(f"{base}/labels", auth) or []
label_id = None
for lb in labels:
if lb["name"] == LABEL_NAME:
label_id = lb["id"]
if lb.get("name") == LABEL_NAME:
label_id = lb.get("id")
break
if label_id is None:
+17 -171
View File
@@ -24,26 +24,12 @@ from __future__ import annotations
import os
import subprocess
import time
# Live-remote head cache: the parity gate runs on every mutation and every
# runtime-context read, so the ``git ls-remote`` result is cached briefly to
# avoid a network round-trip per call (#610). Keyed by (root, remote, branch).
_REMOTE_HEAD_CACHE: dict[tuple[str, str, str], tuple[float, str | None]] = {}
_REMOTE_HEAD_TTL = 60.0
def _clear_remote_head_cache() -> None:
"""Reset the live-remote head cache (test isolation / forced refresh)."""
_REMOTE_HEAD_CACHE.clear()
# Environment escape hatches (ops + tests):
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
# GITEA_TEST_LIVE_REMOTE_HEAD -> force the live remote master read, for tests.
ENV_TEST_LIVE_REMOTE_HEAD = "GITEA_TEST_LIVE_REMOTE_HEAD"
def read_git_head(root: str) -> str | None:
@@ -72,57 +58,6 @@ def read_git_head(root: str) -> str | None:
return (res.stdout or "").strip() or None
def read_remote_master_head(
root: str,
remote: str = "origin",
branch: str = "master",
ttl: float = _REMOTE_HEAD_TTL,
) -> str | None:
"""Return the live remote ``branch`` commit SHA, or ``None`` (#610).
Resolves the *live* target commit via ``git ls-remote`` so parity can tell
a daemon that is behind the live remote master apart from one whose local
checkout simply hasn't been pulled. ``None`` means the live head could not
be resolved (offline, no such remote, git unavailable, error) -- callers
must treat unknown live state as *not mutation-safe* while never blocking
read-only diagnostics. A ``GITEA_TEST_LIVE_REMOTE_HEAD`` override takes
precedence so the wiring can be exercised deterministically and offline.
The result is cached for *ttl* seconds per (root, remote, branch) so the
gate does not run a network probe on every mutation/read (``ttl=0`` forces
a live probe). Both hits and ``None`` misses are cached to bound offline
latency; the env override bypasses the cache and the subprocess entirely.
"""
forced = os.environ.get(ENV_TEST_LIVE_REMOTE_HEAD)
if forced is not None:
return forced.strip() or None
if not root:
return None
key = (root, remote, branch)
now = time.monotonic()
if ttl > 0:
cached = _REMOTE_HEAD_CACHE.get(key)
if cached is not None and (now - cached[0]) < ttl:
return cached[1]
sha: str | None = None
try:
res = subprocess.run(
["git", "-C", root, "ls-remote", remote, f"refs/heads/{branch}"],
capture_output=True,
text=True,
check=False,
timeout=5,
)
if res.returncode == 0:
lines = (res.stdout or "").strip().splitlines()
if lines:
sha = lines[0].split("\t", 1)[0].split()[0].strip() or None
except Exception:
sha = None
_REMOTE_HEAD_CACHE[key] = (now, sha)
return sha
def capture_startup_parity(root: str, head: str | None = None) -> dict:
"""Capture the process source-tree baseline once at server startup.
@@ -137,38 +72,18 @@ def _short(sha: str | None) -> str:
return sha[:12] if sha else "unknown"
def assess_master_parity(
startup: dict | None,
current_head: str | None,
live_remote_head: str | None = None,
) -> dict:
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
"""Compare the startup baseline against the current on-disk ``HEAD``.
Pure: all HEADs are supplied by the caller. Returns a structured result:
Pure: both HEADs are supplied by the caller. Returns a structured result:
- ``in_parity`` -- server code matches the on-disk master (or parity
could not be determined, which is not treated as stale).
- ``stale`` -- the on-disk master has definitively advanced past the
running process.
- ``restart_required`` -- ``stale`` or ``live_stale``; the recovery action.
- ``determinable`` -- whether both local HEADs were known well enough to
compare.
- ``restart_required`` -- alias of ``stale``; the recovery action.
- ``determinable`` -- whether both HEADs were known well enough to compare.
- ``startup_head`` / ``current_head`` / ``reasons``.
#610 adds live-remote awareness so a daemon that is stale relative to the
*live* remote master cannot report a mutation-safe result even when the
local checkout HEAD still matches the daemon's startup commit:
- ``daemon_start_head`` -- the commit the running process started at
(alias of ``startup_head``, named for clarity in reports).
- ``local_head`` -- the on-disk checkout HEAD (alias of ``current_head``).
- ``live_remote_head`` -- the live remote target commit, or ``None`` when it
could not be fetched.
- ``live_known`` -- whether the live remote target was resolved.
- ``live_stale`` -- the live remote master has advanced past the running
process (daemon is behind live master) even if local parity is green.
- ``mutation_safe`` -- the daemon code, local checkout, and live remote
target all agree; the only state in which a mutation may rely on parity.
"""
startup_head = (startup or {}).get("startup_head")
reasons: list[str] = []
@@ -176,56 +91,32 @@ def assess_master_parity(
if startup_head is None:
reasons.append(
"startup commit was not captured; code parity cannot be enforced")
return _result(True, False, False, startup_head, current_head,
live_remote_head, False, reasons)
return _result(True, False, False, startup_head, current_head, reasons)
if current_head is None:
reasons.append(
"current workspace HEAD could not be read; code parity cannot be "
"enforced")
return _result(True, False, False, startup_head, current_head,
live_remote_head, False, reasons)
return _result(True, False, False, startup_head, current_head, reasons)
local_in_parity = startup_head == current_head
local_stale = not local_in_parity
if local_stale:
reasons.append(
f"MCP server started at commit {_short(startup_head)} but the "
f"workspace master is now {_short(current_head)}; restart the "
f"server to load the current capability gates")
if startup_head == current_head:
return _result(True, False, True, startup_head, current_head, reasons)
live_known = live_remote_head is not None
live_stale = live_known and live_remote_head != startup_head
if live_stale:
reasons.append(
f"live remote master is {_short(live_remote_head)} but the MCP "
f"server started at {_short(startup_head)}; the daemon is stale "
f"relative to live master -- restart/reconnect before mutating")
return _result(
local_in_parity, local_stale, True, startup_head, current_head,
live_remote_head, live_stale, reasons)
reasons.append(
f"MCP server started at commit {_short(startup_head)} but the workspace "
f"master is now {_short(current_head)}; restart the server to load the "
f"current capability gates")
return _result(False, True, True, startup_head, current_head, reasons)
def _result(in_parity, stale, determinable, startup_head, current_head,
live_remote_head, live_stale, reasons):
live_known = live_remote_head is not None
mutation_safe = (
determinable and in_parity and live_known and not live_stale)
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
return {
"in_parity": in_parity,
"stale": stale,
"restart_required": stale or live_stale,
"restart_required": stale,
"determinable": determinable,
"startup_head": startup_head,
"current_head": current_head,
# #610 distinguished signals:
"daemon_start_head": startup_head,
"local_head": current_head,
"live_remote_head": live_remote_head,
"live_known": live_known,
"live_stale": live_stale,
"mutation_safe": mutation_safe,
"reasons": list(reasons),
}
@@ -239,13 +130,11 @@ def parity_block_reasons(assessment: dict) -> list[str]:
"""Block reasons for a mutation gate (empty when the mutation may proceed).
A disabled gate or an in-parity / non-determinable assessment yields no
reasons. A definitively stale server blocks, and (#610) a daemon that is
stale relative to the *live* remote master blocks even when the local
checkout HEAD still matches the daemon's startup commit.
reasons; only a definitively stale server blocks.
"""
if gate_disabled():
return []
if assessment.get("stale") or assessment.get("live_stale"):
if assessment.get("stale"):
return list(assessment.get("reasons") or
["server code is stale relative to master (fail closed)"])
return []
@@ -258,10 +147,6 @@ def parity_report(assessment: dict) -> dict:
"restart_required": True,
"startup_head": assessment.get("startup_head"),
"current_head": assessment.get("current_head"),
# #610: name the live remote target so the report distinguishes a
# local-code stale from a daemon-behind-live-master stale.
"live_remote_head": assessment.get("live_remote_head"),
"live_stale": bool(assessment.get("live_stale")),
"reasons": list(assessment.get("reasons") or []),
"recovery": [
"The running MCP server is executing code older than the current "
@@ -272,45 +157,6 @@ def parity_report(assessment: dict) -> dict:
}
def parity_resolver_disagreement(
assessment: dict,
resolver_restart_required: bool,
) -> dict | None:
"""Typed blocker when the resolver requires restart but parity looks green.
The capability resolver (``gitea_resolve_task_capability``) detects stale
runtime authoritatively for mutation safety (#610). When it requires a
restart, local-only parity must never override it: this returns a typed,
fail-closed blocker that names the resolver as authoritative. Returns
``None`` when the resolver does not require a restart.
"""
if not resolver_restart_required:
return None
parity_optimistic = bool(assessment.get("in_parity")) and not (
assessment.get("stale") or assessment.get("live_stale"))
return {
"kind": "parity_resolver_disagreement",
"restart_required": True,
"resolver_authoritative": True,
"parity_optimistic": parity_optimistic,
"daemon_start_head": assessment.get("daemon_start_head"),
"local_head": assessment.get("local_head"),
"live_remote_head": assessment.get("live_remote_head"),
"reasons": [
"The capability resolver requires a restart/reconnect (stale "
"runtime) but master-parity reported local code as in-parity. "
"The resolver is authoritative for mutation safety; do not mutate "
"on local parity alone. Restart/reconnect the Gitea MCP server "
"and re-verify before mutating.",
],
"recovery": [
"Trust the resolver: treat this session as stale.",
"Restart or /mcp reconnect the Gitea MCP namespace so it reloads "
"current master and live target state, then re-run preflight.",
],
}
def format_parity(assessment: dict) -> str:
"""One-line human summary for logs / runtime context."""
if assessment.get("stale"):
+306
View File
@@ -0,0 +1,306 @@
"""Assess live MCP namespace health without trusting static registration.
The IDE/client namespace can fail with EOF even when this Python process still
registers the Gitea tools with FastMCP. These helpers keep that distinction
explicit so reviewer/merger flows can fail closed on the live path.
Probe sources
-------------
* ``client_namespace`` — evidence from the IDE-managed MCP client path (the
only source that can prove the workflow namespace is healthy).
* ``offline_spawn`` — a separate ``subprocess.Popen`` JSON-RPC handshake
(e.g. ``test_mcp_conn.py``). Useful offline, but **never** proves the
IDE-managed namespace is callable.
* ``unknown`` — legacy/unspecified; treated as not IDE-proven.
"""
from __future__ import annotations
from typing import Any
REQUIRED_NAMESPACE_TOOLS = {
"gitea-author": "gitea_whoami",
"gitea-reviewer": "gitea_whoami",
"gitea-merger": "gitea_whoami",
"gitea-tools": "gitea_list_profiles",
}
DEFAULT_NAMESPACES = tuple(REQUIRED_NAMESPACE_TOOLS)
# Namespaces that must be healthy for a given mutation task.
TASK_REQUIRED_NAMESPACES = {
"review_pr": "gitea-reviewer",
"submit_review": "gitea-reviewer",
"merge_pr": "gitea-merger",
}
PROBE_SOURCE_CLIENT = "client_namespace"
PROBE_SOURCE_OFFLINE = "offline_spawn"
PROBE_SOURCE_UNKNOWN = "unknown"
VALID_PROBE_SOURCES = frozenset(
{PROBE_SOURCE_CLIENT, PROBE_SOURCE_OFFLINE, PROBE_SOURCE_UNKNOWN}
)
EOF_PATTERNS = (
"client is closing: eof",
"transport closed",
"connection closed",
"broken pipe",
"end of file",
"eof",
)
SAFE_ENV_KEYS = (
"GITEA_MCP_PROFILE",
"GITEA_PROFILE_NAME",
"GITEA_SERVICE",
"GITEA_EXECUTION_ROLE",
"GITEA_MCP_CONFIG",
)
def _as_list(value: Any) -> list[str] | None:
if value is None:
return None
if isinstance(value, (list, tuple, set)):
return [str(v) for v in value]
return [str(value)]
def _contains_eof(text: str | None) -> bool:
lowered = (text or "").lower()
return any(pattern in lowered for pattern in EOF_PATTERNS)
def _normalize_probe_source(probe_source: str | None) -> str:
raw = (probe_source or PROBE_SOURCE_UNKNOWN).strip().lower()
if raw in VALID_PROBE_SOURCES:
return raw
return PROBE_SOURCE_UNKNOWN
def _safe_env_summary(process: dict[str, Any] | None) -> dict[str, str]:
if not process:
return {}
env = process.get("env") or process.get("environment") or {}
if not isinstance(env, dict):
return {}
return {
key: str(env[key])
for key in SAFE_ENV_KEYS
if key in env and env[key] not in (None, "")
}
def classify_namespace_probe(
namespace: str,
*,
required_tool: str | None = None,
registered_tools: list[str] | tuple[str, ...] | set[str] | None = None,
probe_result: dict[str, Any] | None = None,
process: dict[str, Any] | None = None,
config_path: str | None = None,
profile: str | None = None,
configured: bool = True,
probe_source: str | None = None,
) -> dict[str, Any]:
"""Classify whether a required tool is callable through a live namespace.
``registered_tools`` is static/server-side evidence. ``probe_result`` is
live invocation evidence. Only ``probe_source=client_namespace`` proves the
IDE-managed path; ``offline_spawn`` is an offline subprocess check only.
"""
ns = (namespace or "").strip()
tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami"
source = _normalize_probe_source(probe_source)
registered_list = _as_list(registered_tools)
registered = None if registered_list is None else tool in registered_list
probe = probe_result or {}
probe_success = bool(probe.get("success"))
error_message = str(
probe.get("error")
or probe.get("message")
or probe.get("stderr")
or probe.get("exception")
or ""
)
error_type = str(probe.get("error_type") or "").strip()
if not error_type and error_message:
if _contains_eof(error_message):
error_type = "namespace_eof"
elif "timeout" in error_message.lower():
error_type = "namespace_timeout"
else:
error_type = "namespace_call_failed"
if not configured:
error_type = "namespace_not_configured"
elif registered is False:
error_type = "tool_missing"
elif not probe_result:
error_type = "live_probe_missing"
elif not probe_success and not error_type:
error_type = "namespace_call_failed"
callable_live = bool(configured and probe_result and probe_success)
# Probe-path health (spawn or client). IDE-proven only for client path.
healthy = bool(configured and registered is not False and callable_live)
ide_namespace_proven = bool(healthy and source == PROBE_SOURCE_CLIENT)
process_pid = process.get("pid") if isinstance(process, dict) else None
profile_name = profile or (
process.get("profile") if isinstance(process, dict) else None
)
env_summary = _safe_env_summary(process)
reasons: list[str] = []
if not configured:
reasons.append(f"MCP namespace '{ns}' is not configured.")
if registered is False:
reasons.append(
f"Required tool '{tool}' is not registered in namespace '{ns}'."
)
if error_type == "live_probe_missing":
reasons.append(
f"No live client invocation proof was supplied for '{ns}.{tool}'."
)
elif error_type == "namespace_eof":
reasons.append(
f"Live MCP namespace '{ns}' returned EOF while invoking '{tool}'."
)
elif error_type == "namespace_timeout":
reasons.append(
f"Live MCP namespace '{ns}' timed out while invoking '{tool}'."
)
elif error_type == "namespace_call_failed":
reasons.append(
f"Live MCP namespace '{ns}' failed while invoking '{tool}'."
)
if source == PROBE_SOURCE_OFFLINE:
reasons.append(
"Probe source is offline_spawn (subprocess JSON-RPC); this does "
"not prove the IDE-managed MCP namespace is healthy."
)
elif source == PROBE_SOURCE_UNKNOWN and probe_result:
reasons.append(
"Probe source unspecified; treat as not IDE-namespace proof unless "
"re-supplied with probe_source='client_namespace'."
)
remediation = []
if not healthy or not ide_namespace_proven:
remediation.append(
"Reconnect the IDE MCP client namespace (client reconnect / "
f"relaunch), then invoke '{tool}' through namespace '{ns}' and "
"record the result with probe_source='client_namespace'."
)
remediation.append(
"Do not treat offline subprocess probes (test_mcp_conn.py) or "
"shell kill/PID respawn as proof the IDE namespace is repaired."
)
if process_pid and source != PROBE_SOURCE_OFFLINE:
remediation.append(
f"Diagnostics may include PID {process_pid}; process details "
"are informational only — recovery is client-layer reconnect."
)
else:
remediation.append(
f"IDE-managed namespace '{ns}' can invoke '{tool}' "
f"(probe_source={source})."
)
# Client-namespace broken health blocks review/merge. Offline probes never
# authorize mutations and only block when they report unhealthy (still
# fail-closed for known bad spawn evidence).
blocks = False
if source == PROBE_SOURCE_CLIENT:
blocks = namespace_health_blocks_task("merge_pr", healthy)
elif source == PROBE_SOURCE_OFFLINE:
# Offline never proves IDE health; never unblock. Unhealthy offline
# still surfaces as a soft diagnostic, not a mutation-ledger block.
blocks = False
else:
# Unknown source: only block when evidence is unhealthy (fail closed
# on bad data without treating success as IDE proof).
blocks = namespace_health_blocks_task("merge_pr", healthy)
return {
"success": healthy,
"healthy": healthy,
"namespace": ns,
"required_tool": tool,
"configured": configured,
"registered_tools_checked": registered_list is not None,
"required_tool_registered": registered,
"required_tool_callable": callable_live,
"probe_source": source,
"ide_namespace_proven": ide_namespace_proven,
"error_type": None if healthy else error_type,
"error_message": error_message or None,
"reasons": reasons,
"remediation": remediation,
"diagnostics": {
"namespace": ns,
"required_tool": tool,
"process_pid": process_pid,
"profile": profile_name,
"env": env_summary,
"config_path": config_path,
"probe_source": source,
},
"blocks_merge_workflow": blocks,
}
def namespace_health_blocks_task(task: str, healthy: bool) -> bool:
"""Return whether a broken namespace must block a workflow task."""
if healthy:
return False
return (task or "").strip() in {"merge_pr", "review_pr", "submit_review"}
def required_namespace_for_task(task: str) -> str | None:
"""Map a mutation task to the MCP namespace that must be healthy."""
return TASK_REQUIRED_NAMESPACES.get((task or "").strip())
def mutation_gate_from_session(
task: str,
session_health: dict[str, dict[str, Any]] | None,
) -> list[str]:
"""Fail-closed gate using recorded client-namespace health assessments.
* Missing session entry → no gate (caller has not assessed yet).
* Client-namespace unhealthy / not IDE-proven → block mutation.
* Offline-only session entries never authorize mutations.
"""
ns = required_namespace_for_task(task)
if not ns:
return []
store = session_health or {}
entry = store.get(ns)
if not entry:
return []
source = _normalize_probe_source(entry.get("probe_source"))
if source != PROBE_SOURCE_CLIENT:
return [
f"recorded namespace health for '{ns}' is probe_source={source}, "
"not client_namespace; re-probe through the IDE-managed path "
f"before {(task or 'mutation')}"
]
if entry.get("ide_namespace_proven") and entry.get("healthy"):
return []
if entry.get("blocks_merge_workflow") or not entry.get("healthy"):
detail = entry.get("error_type") or "unhealthy"
return [
f"live MCP namespace '{ns}' is recorded {detail} "
f"(probe_source={source}); repair the IDE namespace before "
f"{(task or 'mutation')} (fail closed, #543)"
]
if not entry.get("ide_namespace_proven"):
return [
f"live MCP namespace '{ns}' is not IDE-proven; supply a "
"client_namespace probe before mutation (fail closed, #543)"
]
return []
+3
View File
@@ -6,6 +6,9 @@ Runs over stdio. All tools authenticate via macOS keychain (git credential fill)
import os
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 (
python_bytes_have_conflict_markers,
skip_python_scan_walk_root,
+3
View File
@@ -30,6 +30,9 @@ DEFAULT_TTL_HOURS = 4.0
KIND_WORKFLOW_LOAD = "review_workflow_load"
KIND_DECISION_LOCK = "review_decision_lock"
KIND_REVIEW_DRAFT = "review_draft"
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
+25 -8
View File
@@ -93,8 +93,16 @@ def assess_workflow_blockers(
capability_blocked: bool = False,
mcp_reconnect_failed: bool = False,
stale_capability_state: bool = False,
live_namespace_broken: bool = False,
) -> dict[str, Any]:
"""Return hard blockers that forbid all PR queue work (#290 AC3)."""
"""Return hard blockers that forbid all PR queue work (#290 AC3).
``live_namespace_broken`` fails the merge/review path closed when the live
MCP namespace call path is unusable (e.g. ``client is closing: EOF``) even
though the tool is registered in FastMCP (#543 AC5). Feed it the
``blocks_merge_workflow`` verdict from
``mcp_namespace_health.classify_namespace_probe``.
"""
reasons: list[str] = []
if infra_stop:
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
@@ -104,6 +112,12 @@ def assess_workflow_blockers(
reasons.append("MCP reconnect failed; stale session state cannot be reused")
if stale_capability_state:
reasons.append("stale MCP capability state detected after reconnect failure")
if live_namespace_broken:
reasons.append(
"live MCP namespace call path is broken (registered in FastMCP but "
"not callable through the namespace); repair the namespace before "
"review/merge"
)
return {
"block": bool(reasons),
"reasons": reasons,
@@ -118,16 +132,19 @@ def assess_state_advancement(
state_completion: dict[str, bool] | None,
*,
target_state: str,
infra_stop: bool = False,
capability_blocked: bool = False,
**blocker_kwargs,
) -> dict[str, Any]:
"""Fail closed when *target_state* is requested before upstream gates pass."""
"""Fail closed when *target_state* is requested before upstream gates pass.
Forwards every blocker flag (``infra_stop``, ``capability_blocked``,
``mcp_reconnect_failed``, ``stale_capability_state``,
``live_namespace_broken``) to :func:`assess_workflow_blockers` so
``can_approve``/``can_merge`` honor the full blocker set, not just
infra_stop/capability_blocked (#543 AC5).
"""
completion = dict(state_completion or {})
target = _clean(target_state).upper()
blockers = assess_workflow_blockers(
infra_stop=infra_stop,
capability_blocked=capability_blocked,
)
blockers = assess_workflow_blockers(**blocker_kwargs)
reasons = list(blockers["reasons"])
try:
+194 -6
View File
@@ -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
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
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
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
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"})
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:
"""Return the last terminal live mutation on *lock*, or None."""
if not lock:
@@ -35,18 +80,125 @@ def last_terminal_mutation(lock: dict | None) -> dict | 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]:
"""Normalize a Gitea PR payload into merge/closed flags."""
pr = pr_live or {}
merged = bool(pr.get("merged") or pr.get("merged_at"))
state = (pr.get("state") or "").strip().lower() or None
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 {
"pr_state": state,
"pr_merged": merged,
"pr_merged_or_closed": merged or closed,
"merge_commit_sha": pr.get("merge_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
last = last_terminal_mutation(lock)
mutations = list(lock.get("live_mutations") or [])
last_head = mutation_head_sha(last, lock) if last else None
return {
"task": lock.get("task"),
"remote": lock.get("remote"),
@@ -67,16 +220,21 @@ def lock_summary(lock: dict | None) -> dict[str, Any] | None:
"session_profile": lock.get("session_profile"),
"ready_pr_number": lock.get("ready_pr_number"),
"ready_action": lock.get("ready_action"),
"ready_expected_head_sha": normalize_head_sha(
lock.get("ready_expected_head_sha")
),
"live_mutations_count": len(mutations),
"last_terminal": (
{
"pr_number": last.get("pr_number"),
"action": last.get("action"),
"review_id": last.get("review_id"),
"head_sha": last_head,
}
if last
else None
),
"locked_head_sha": last_head,
"correction_authorized": bool(lock.get("correction_authorized")),
"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_lookup_error: str | None = None,
active_profile_identity: str | None = None,
current_pr_head_sha: str | None = None,
) -> 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
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
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)
result: dict[str, Any] = {
@@ -102,6 +263,10 @@ def assess_stale_review_decision_lock(
"lock_summary": summary,
"last_terminal_pr": 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,
"cleanup_allowed": False,
"profile_match": True,
@@ -141,8 +306,10 @@ def assess_stale_review_decision_lock(
pr_number = last.get("pr_number")
action = last.get("action")
locked_head = mutation_head_sha(last, lock)
result["last_terminal_pr"] = pr_number
result["last_terminal_action"] = action
result["locked_head_sha"] = locked_head
if pr_number is None:
result["reasons"].append(
@@ -165,25 +332,46 @@ def assess_stale_review_decision_lock(
return result
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(
{
"pr_state": live["pr_state"],
"pr_merged": live["pr_merged"],
"pr_merged_or_closed": live["pr_merged_or_closed"],
"merge_commit_sha": live["merge_commit_sha"],
"current_pr_head_sha": current_head,
}
)
if not live["pr_merged_or_closed"]:
result["reasons"].append(
f"terminal review mutation on open PR #{pr_number} is still active "
f"({action}); #332 hard-stop remains — cleanup forbidden (#594)"
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(
f"terminal review mutation on open PR #{pr_number} is still active "
f"({action}); #332 hard-stop remains — cleanup forbidden (#594)"
)
return result
# Merged or closed: lock is moot. Same-PR merge continuation is impossible.
result["is_moot"] = True
result["cleanup_allowed"] = True
result["stale_by_head"] = False
result["fresh_review_on_current_head_allowed"] = False
result["reasons"].append(
f"terminal mutation ({action} on PR #{pr_number}) is moot: PR is "
f"{'merged' if live['pr_merged'] else 'closed'}; canonical cleanup allowed (#594)"
+97
View File
@@ -139,6 +139,103 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.create",
"role": "author",
},
# #600: controller-owned allocator — any authenticated profile may call;
# routing enforces role match to selected work. Uses control-plane DB (#613).
"allocate_next_work": {
"permission": "gitea.read",
"role": "author",
},
"gitea_allocate_next_work": {
"permission": "gitea.read",
"role": "author",
},
# #601 first-class lease lifecycle — inspect/list need read; mutations gate on
# ownership in the control-plane DB (not a separate Gitea write permission).
"list_workflow_leases": {
"permission": "gitea.read",
"role": "author",
},
"gitea_list_workflow_leases": {
"permission": "gitea.read",
"role": "author",
},
"inspect_workflow_lease": {
"permission": "gitea.read",
"role": "author",
},
"gitea_inspect_workflow_lease": {
"permission": "gitea.read",
"role": "author",
},
"adopt_workflow_lease": {
"permission": "gitea.read",
"role": "author",
},
"gitea_adopt_workflow_lease": {
"permission": "gitea.read",
"role": "author",
},
"release_workflow_lease": {
"permission": "gitea.read",
"role": "author",
},
"gitea_release_workflow_lease": {
"permission": "gitea.read",
"role": "author",
},
"expire_workflow_leases": {
"permission": "gitea.read",
"role": "author",
},
"gitea_expire_workflow_leases": {
"permission": "gitea.read",
"role": "author",
},
"abandon_workflow_lease": {
"permission": "gitea.read",
"role": "author",
},
"gitea_abandon_workflow_lease": {
"permission": "gitea.read",
"role": "author",
},
"reclaim_expired_workflow_lease": {
"permission": "gitea.read",
"role": "author",
},
"gitea_reclaim_expired_workflow_lease": {
"permission": "gitea.read",
"role": "author",
},
# #612 incident bridge — reconcile uses create_issue for apply;
# dry-run needs read only. Tools gate apply paths themselves.
"observability_reconcile_incident": {
"permission": "gitea.read",
"role": "author",
},
"gitea_observability_reconcile_incident": {
"permission": "gitea.read",
"role": "author",
},
"observability_list_projects": {
"permission": "gitea.read",
"role": "author",
},
"gitea_observability_list_projects": {
"permission": "gitea.read",
"role": "author",
},
"observability_link_issue": {
"permission": "gitea.read",
"role": "author",
},
"gitea_observability_link_issue": {
"permission": "gitea.read",
"role": "author",
},
"reconcile_landed_pr": {
"permission": "gitea.read",
"role": "author",
+185 -25
View File
@@ -1,20 +1,45 @@
#!/usr/bin/env python3
"""Live health-check script to verify Gitea MCP namespace connections.
"""Offline-only MCP namespace spawn probe (NOT IDE-namespace proof).
Spawns the MCP server processes as defined in the IDE's global config,
performs the JSON-RPC handshake, and queries the tools list to verify
that the connection is fully operational and doesn't return EOF.
Spawns a *separate* MCP server process from config via subprocess.Popen,
performs the JSON-RPC handshake, verifies the required tool is registered,
and invokes that tool. Results are classified with
``probe_source=offline_spawn``.
This path is useful for offline launch/registration debugging. It does
**not** prove the IDE-managed MCP client namespace is healthy (#543). For
workflow gates, pass live IDE call evidence with
``probe_source=client_namespace`` to ``gitea_assess_mcp_namespace_health``.
"""
import argparse
import json
import os
import subprocess
import sys
def run_connection_test(name, config):
from mcp_namespace_health import REQUIRED_NAMESPACE_TOOLS, classify_namespace_probe
def _read_json_line(proc):
line = proc.stdout.readline()
if not line:
stderr_content = proc.stderr.read()
return None, stderr_content
return json.loads(line), None
def _write_message(proc, payload):
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
def run_connection_test(name, config, *, required_tool=None, config_path=None):
print(f"Testing MCP connection for '{name}'...")
command = config.get("command")
args = config.get("args", [])
env = config.get("env", {})
tool_name = required_tool or REQUIRED_NAMESPACE_TOOLS.get(name) or "gitea_whoami"
# Merge current environment
run_env = os.environ.copy()
@@ -31,8 +56,17 @@ def run_connection_test(name, config):
bufsize=1,
env=run_env
)
except Exception as e:
print(f" [FAIL] Failed to spawn process: {e}")
except Exception as exc:
print(f" [FAIL] Failed to spawn process: {exc}")
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
probe_result={"success": False, "error": str(exc)},
process={"profile": env.get("GITEA_MCP_PROFILE"), "env": env},
config_path=config_path,
probe_source="offline_spawn",
)
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
return False
# Send initialize request
@@ -48,26 +82,36 @@ def run_connection_test(name, config):
}
try:
proc.stdin.write(json.dumps(init_req) + "\n")
proc.stdin.flush()
_write_message(proc, init_req)
# Read response
line = proc.stdout.readline()
if not line:
stderr_content = proc.stderr.read()
res, stderr_content = _read_json_line(proc)
if res is None:
print(f" [FAIL] Received EOF from process. Stderr:\n{stderr_content}")
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
probe_result={"success": False, "error": stderr_content or "EOF"},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"env": env,
},
config_path=config_path,
probe_source="offline_spawn",
)
print(f" remediation: {' '.join(assessment['remediation'])}")
proc.terminate()
return False
print(f" [OK] Received initialize response: {line.strip()[:150]}...")
print(f" [OK] Received initialize response: {str(res)[:150]}...")
# Send initialized notification
init_notif = {
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
proc.stdin.write(json.dumps(init_notif) + "\n")
proc.stdin.flush()
_write_message(proc, init_notif)
# Send tools/list request
list_req = {
@@ -76,16 +120,27 @@ def run_connection_test(name, config):
"params": {},
"id": 2
}
proc.stdin.write(json.dumps(list_req) + "\n")
proc.stdin.flush()
_write_message(proc, list_req)
line = proc.stdout.readline()
if not line:
res, stderr_content = _read_json_line(proc)
if res is None:
print(" [FAIL] Received EOF on tools/list request.")
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
probe_result={"success": False, "error": stderr_content or "EOF"},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"env": env,
},
config_path=config_path,
probe_source="offline_spawn",
)
print(f" remediation: {' '.join(assessment['remediation'])}")
proc.terminate()
return False
res = json.loads(line)
if "error" in res:
print(f" [FAIL] Server returned error: {res['error']}")
proc.terminate()
@@ -94,16 +149,115 @@ def run_connection_test(name, config):
tools = res.get("result", {}).get("tools", [])
tool_names = [t.get("name") for t in tools]
print(f" [OK] Successfully retrieved {len(tool_names)} tools: {tool_names[:5]}...")
if tool_name not in tool_names:
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
registered_tools=tool_names,
probe_result={"success": False, "error": "required tool missing"},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"env": env,
},
config_path=config_path,
probe_source="offline_spawn",
)
print(f" [FAIL] Required tool '{tool_name}' is not registered.")
print(f" remediation: {' '.join(assessment['remediation'])}")
proc.terminate()
return False
call_req = {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {"name": tool_name, "arguments": {}},
"id": 3,
}
_write_message(proc, call_req)
call_res, stderr_content = _read_json_line(proc)
if call_res is None:
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
registered_tools=tool_names,
probe_result={"success": False, "error": stderr_content or "EOF"},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"env": env,
},
config_path=config_path,
probe_source="offline_spawn",
)
print(f" [FAIL] Received EOF on {tool_name} invocation.")
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
print(f" remediation: {' '.join(assessment['remediation'])}")
proc.terminate()
return False
if "error" in call_res:
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
registered_tools=tool_names,
probe_result={"success": False, "error": call_res["error"]},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"env": env,
},
config_path=config_path,
probe_source="offline_spawn",
)
print(f" [FAIL] {tool_name} invocation returned error: {call_res['error']}")
print(f" remediation: {' '.join(assessment['remediation'])}")
proc.terminate()
return False
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
registered_tools=tool_names,
probe_result={"success": True, "result": call_res.get("result")},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"env": env,
},
config_path=config_path,
probe_source="offline_spawn",
)
print(f" [OK] Successfully invoked required tool '{tool_name}'.")
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
proc.terminate()
return True
except Exception as e:
print(f" [FAIL] Error during handshake: {e}")
except Exception as exc:
print(f" [FAIL] Error during handshake: {exc}")
proc.terminate()
return False
def main():
config_path = "/Users/jasonwalker/.gemini/config/mcp_config.json"
parser = argparse.ArgumentParser()
parser.add_argument(
"--config",
default=os.environ.get(
"MCP_CONFIG_PATH",
os.path.expanduser("~/.gemini/config/mcp_config.json"),
),
help="Path to MCP config JSON.",
)
parser.add_argument(
"--namespace",
action="append",
dest="namespaces",
help="Namespace to test. May be repeated.",
)
args = parser.parse_args()
config_path = args.config
try:
with open(config_path) as f:
mcp_config = json.load(f)
@@ -112,10 +266,16 @@ def main():
sys.exit(1)
servers = mcp_config.get("mcpServers", {})
namespaces = args.namespaces or list(REQUIRED_NAMESPACE_TOOLS)
failed = False
for name in ["gitea-author", "gitea-reviewer"]:
for name in namespaces:
if name in servers:
if not run_connection_test(name, servers[name]):
if not run_connection_test(
name,
servers[name],
required_tool=REQUIRED_NAMESPACE_TOOLS.get(name),
config_path=config_path,
):
failed = True
else:
print(f"Server '{name}' not found in mcp_config.json")
+1
View File
@@ -66,6 +66,7 @@ def _reset_mutation_authority(monkeypatch):
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
monkeypatch.setattr(mcp_server, "_preflight_capability_called", False)
monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None)
+366
View File
@@ -0,0 +1,366 @@
"""Tests for controller-owned allocator (#600) on control-plane DB (#613)."""
from __future__ import annotations
import os
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor, as_completed
from allocator_service import (
OUTCOME_ASSIGNED,
OUTCOME_BLOCKED_TERMINAL,
OUTCOME_NO_SAFE,
OUTCOME_PREVIEW,
OUTCOME_WAIT,
WorkCandidate,
allocate_next_work,
candidate_from_dict,
classify_skip,
expected_role_for_candidate,
)
from control_plane_db import ControlPlaneDB, InvalidWorkKindError
class AllocatorServiceTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
self.db = ControlPlaneDB(self.db_path)
def tearDown(self) -> None:
self._tmp.cleanup()
def _alloc(self, **kwargs):
defaults = dict(
db=self.db,
session_id="s-test",
role="author",
remote="prgs",
org="org",
repo="repo",
candidates=[],
apply=False,
profile_name="prgs-author",
username="jcwalker3",
)
defaults.update(kwargs)
return allocate_next_work(**defaults)
def test_selects_ready_issue_for_author(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=612,
labels=("status:ready",),
title="bridge",
dependency_unmet=True,
dependency_reason="downstream of #600",
),
WorkCandidate(
kind="issue",
number=600,
labels=("status:ready", "type:feature"),
title="allocator",
priority=50,
),
WorkCandidate(
kind="issue",
number=601,
labels=("status:blocked",),
title="blocked",
blocked=True,
),
]
res = self._alloc(candidates=cands, apply=False)
self.assertTrue(res["success"])
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
self.assertEqual(res["selected"]["number"], 600)
self.assertEqual(res["selected"]["kind"], "issue")
# When 600 is highest priority valid work, lower-priority blocked/dep
# candidates are not visited. Prove they are skipped when they sort first.
res2 = self._alloc(
candidates=[
WorkCandidate(
kind="issue",
number=612,
labels=("status:ready",),
priority=99,
dependency_unmet=True,
dependency_reason="downstream of #600",
),
WorkCandidate(
kind="issue",
number=601,
labels=("status:blocked",),
priority=98,
blocked=True,
),
WorkCandidate(
kind="issue",
number=600,
labels=("status:ready",),
priority=1,
),
],
apply=False,
)
skipped_nums = {s["number"] for s in res2["skipped"]}
self.assertIn(612, skipped_nums)
self.assertIn(601, skipped_nums)
self.assertEqual(res2["selected"]["number"], 600)
self.assertIsNone(res["assignment"])
self.assertFalse(res["file_lock_only"])
self.assertFalse(res["comment_lease_only"])
def test_atomic_assign_and_lease_on_apply(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=600,
labels=("status:ready",),
priority=10,
)
]
res = self._alloc(candidates=cands, apply=True, session_id="s-a")
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
asn = res["assignment"]
self.assertEqual(asn["outcome"], "assigned")
self.assertIsNotNone(asn["assignment_id"])
self.assertIsNotNone(asn["lease_id"])
self.assertEqual(asn["work_number"], 600)
self.assertIn("implement", asn["allowed_actions"])
self.assertIn("merge", asn["forbidden_actions"])
proof = res["lease_proof"]
self.assertEqual(proof["source"], "control_plane_db.assign_and_lease")
def test_concurrent_allocators_no_double_assign(self) -> None:
cand = WorkCandidate(
kind="pr",
number=100,
head_sha="a" * 40,
priority=10,
)
# Seed work item so both race the same target
self.db.upsert_work_item(
remote="prgs",
org="org",
repo="repo",
kind="pr",
number=100,
current_head_sha="a" * 40,
)
results = []
lock = threading.Lock()
def worker(sid: str):
r = allocate_next_work(
self.db,
session_id=sid,
role="reviewer",
remote="prgs",
org="org",
repo="repo",
candidates=[cand],
apply=True,
profile_name="prgs-reviewer",
)
with lock:
results.append(r)
with ThreadPoolExecutor(max_workers=2) as pool:
futs = [pool.submit(worker, f"s-{i}") for i in range(2)]
for f in as_completed(futs):
f.result()
outcomes = [r["outcome"] for r in results]
self.assertEqual(outcomes.count(OUTCOME_ASSIGNED), 1, outcomes)
self.assertEqual(outcomes.count(OUTCOME_WAIT), 1, outcomes)
def test_blocked_and_dependency_skipped(self) -> None:
cands = [
WorkCandidate(kind="issue", number=1, blocked=True, labels=("status:blocked",)),
WorkCandidate(
kind="issue",
number=612,
labels=("status:ready",),
dependency_unmet=True,
dependency_reason="downstream of #600",
),
]
res = self._alloc(candidates=cands, apply=True, role="author")
self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
self.assertIsNone(res["selected"])
reasons = " ".join(s["reason"] for s in res["skipped"])
self.assertIn("blocked", reasons.lower())
self.assertIn("600", reasons)
def test_already_leased_returns_wait(self) -> None:
self.db.upsert_session(session_id="owner", role="author")
self.db.assign_and_lease(
session_id="owner",
role="author",
remote="prgs",
org="org",
repo="repo",
kind="issue",
number=50,
)
res = self._alloc(
session_id="other",
candidates=[
WorkCandidate(kind="issue", number=50, labels=("status:ready",))
],
apply=True,
)
self.assertEqual(res["outcome"], OUTCOME_WAIT)
self.assertEqual(res["owner_session_id"], "owner")
def test_role_ineligible_skipped(self) -> None:
# PR with current-head REQUEST_CHANGES expects author, not reviewer.
cand = WorkCandidate(
kind="pr",
number=9,
head_sha="b" * 40,
request_changes_current_head=True,
)
self.assertEqual(expected_role_for_candidate(cand), "author")
res = self._alloc(
role="reviewer",
profile_name="prgs-reviewer",
candidates=[cand],
apply=False,
)
self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
self.assertTrue(any("expects role" in s["reason"] for s in res["skipped"]))
def test_terminal_lock_blocks_downstream_reviewer_prs(self) -> None:
self.db.set_terminal_lock(
remote="prgs",
org="org",
repo="repo",
terminal_pr=10,
decision="request_changes",
status="active",
)
cands = [
WorkCandidate(kind="pr", number=11, head_sha="c" * 40, priority=5),
WorkCandidate(kind="pr", number=10, head_sha="d" * 40, priority=1),
]
res = self._alloc(
role="reviewer",
profile_name="prgs-reviewer",
candidates=cands,
apply=False,
)
# Terminal PR #10 is selectable; #11 skipped for terminal path.
self.assertEqual(res["selected"]["number"], 10)
self.assertTrue(
any(
s["number"] == 11 and "terminal-review lock" in s["reason"]
for s in res["skipped"]
)
)
def test_terminal_lock_blocks_all_when_only_downstream(self) -> None:
self.db.set_terminal_lock(
remote="prgs",
org="org",
repo="repo",
terminal_pr=10,
decision="request_changes",
status="active",
)
res = self._alloc(
role="reviewer",
profile_name="prgs-reviewer",
candidates=[
WorkCandidate(kind="pr", number=99, head_sha="e" * 40),
],
apply=False,
)
self.assertEqual(res["outcome"], OUTCOME_BLOCKED_TERMINAL)
def test_no_work_structured(self) -> None:
res = self._alloc(candidates=[], apply=True)
self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
self.assertIsNone(res["selected"])
self.assertTrue(res["reasons"])
def test_rejects_incident_kind(self) -> None:
with self.assertRaises(InvalidWorkKindError):
WorkCandidate(kind="sentry_incident", number=1)
def test_612_downstream_marker_in_result(self) -> None:
res = self._alloc(
candidates=[
WorkCandidate(kind="issue", number=600, labels=("status:ready",))
],
apply=True,
)
self.assertIn("612", res.get("downstream_note", ""))
def test_substrate_not_file_or_comment_lease(self) -> None:
res = self._alloc(
candidates=[
WorkCandidate(kind="issue", number=1, labels=("status:ready",))
],
apply=True,
)
self.assertEqual(res["substrate"], "control_plane_db")
self.assertFalse(res["file_lock_only"])
self.assertFalse(res["comment_lease_only"])
self.assertEqual(
res["lease_proof"]["source"], "control_plane_db.assign_and_lease"
)
def test_candidate_from_dict(self) -> None:
c = candidate_from_dict(
{
"kind": "pr",
"number": 7,
"head_sha": "f" * 40,
"approval_on_current_head": True,
"mergeable": True,
}
)
self.assertEqual(expected_role_for_candidate(c), "merger")
def test_merger_gets_clean_approval(self) -> None:
c = WorkCandidate(
kind="pr",
number=3,
head_sha="1" * 40,
approval_on_current_head=True,
mergeable=True,
priority=100,
)
res = self._alloc(
role="merger",
profile_name="prgs-merger",
candidates=[c],
apply=True,
session_id="merger-1",
)
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
self.assertEqual(res["assignment"]["work_number"], 3)
self.assertIn("merge", res["assignment"]["allowed_actions"])
def test_db_unavailable_fails_closed(self) -> None:
res = allocate_next_work(
None, # type: ignore[arg-type]
session_id="x",
role="author",
remote="prgs",
org="o",
repo="r",
candidates=[],
apply=True,
)
self.assertFalse(res["success"])
self.assertIn("unavailable", res["reasons"][0].lower())
if __name__ == "__main__":
unittest.main()
+824
View File
@@ -0,0 +1,824 @@
"""Tests for control-plane DB substrate (#613)."""
from __future__ import annotations
import os
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import timedelta
from control_plane_db import (
ControlPlaneDB,
InvalidWorkKindError,
LeaseRequiredError,
WORK_KINDS,
_ts,
_utc_now,
)
class ControlPlaneDBTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
self.db = ControlPlaneDB(self.db_path)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_schema_and_architecture_meta(self) -> None:
import sqlite3
conn = sqlite3.connect(self.db_path)
try:
rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall())
finally:
conn.close()
self.assertEqual(rows["schema_version"], "3")
self.assertIn("DB coordinates", rows["architecture"])
self.assertIn("bridge", rows["architecture"].lower())
def test_rejects_raw_incident_as_work_kind(self) -> None:
with self.assertRaises(InvalidWorkKindError):
self.db.upsert_work_item(
remote="prgs",
org="org",
repo="repo",
kind="sentry_incident",
number=1,
)
with self.assertRaises(InvalidWorkKindError):
self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="org",
repo="repo",
kind="glitchtip_incident",
number=9,
)
self.assertEqual(WORK_KINDS, frozenset({"issue", "pr"}))
def test_atomic_assign_and_lease_fields(self) -> None:
self.db.upsert_session(session_id="s-a", role="author", profile="prgs-author")
result = self.db.assign_and_lease(
session_id="s-a",
role="author",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
kind="issue",
number=613,
expected_head_sha="abc123",
allowed_actions=("implement", "comment"),
forbidden_actions=("approve", "merge"),
)
self.assertEqual(result.outcome, "assigned")
self.assertIsNotNone(result.assignment_id)
self.assertIsNotNone(result.lease_id)
self.assertEqual(result.role, "author")
self.assertEqual(result.work_kind, "issue")
self.assertEqual(result.work_number, 613)
self.assertEqual(result.expected_head_sha, "abc123")
self.assertIn("implement", result.allowed_actions)
self.assertIn("merge", result.forbidden_actions)
self.assertIsNotNone(result.expires_at)
def test_second_session_waits_on_foreign_lease(self) -> None:
self.db.upsert_session(session_id="s1", role="author")
self.db.upsert_session(session_id="s2", role="author")
first = self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=100,
expected_head_sha="deadbeef",
)
self.assertEqual(first.outcome, "assigned")
second = self.db.assign_and_lease(
session_id="s2",
role="author",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=100,
expected_head_sha="deadbeef",
)
self.assertEqual(second.outcome, "wait")
self.assertEqual(second.owner_session_id, "s1")
def test_owner_resume_refreshes_lease(self) -> None:
self.db.upsert_session(session_id="s1", role="reviewer")
a = self.db.assign_and_lease(
session_id="s1",
role="reviewer",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=50,
expected_head_sha="head-50",
)
b = self.db.assign_and_lease(
session_id="s1",
role="reviewer",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=50,
expected_head_sha="head-50",
)
self.assertEqual(b.outcome, "assigned")
self.assertEqual(b.lease_id, a.lease_id)
self.assertIn("owner-resume", b.reason)
def test_require_valid_assignment_gates_mutations(self) -> None:
self.db.upsert_session(session_id="s1", role="author")
self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=7,
allowed_actions=("implement",),
forbidden_actions=("merge",),
)
proof = self.db.require_valid_assignment(
session_id="s1",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=7,
action="implement",
)
self.assertEqual(proof["session_id"], "s1")
with self.assertRaises(LeaseRequiredError):
self.db.require_valid_assignment(
session_id="s1",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=7,
action="merge",
)
with self.assertRaises(LeaseRequiredError):
self.db.require_valid_assignment(
session_id="s-other",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=7,
action="implement",
)
def test_expired_lease_allows_reassign(self) -> None:
self.db.upsert_session(session_id="s1", role="author")
self.db.upsert_session(session_id="s2", role="author")
past = _utc_now() - timedelta(hours=1)
# Create lease already expired by using negative TTL edge via direct assign then expire
assigned = self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=3,
lease_ttl_seconds=1,
)
self.assertEqual(assigned.outcome, "assigned")
# Force expiry in DB
import sqlite3
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"UPDATE leases SET expires_at = ? WHERE lease_id = ?",
(_ts(past), assigned.lease_id),
)
conn.commit()
finally:
conn.close()
n = self.db.expire_stale_leases()
self.assertGreaterEqual(n, 1)
second = self.db.assign_and_lease(
session_id="s2",
role="author",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=3,
)
self.assertEqual(second.outcome, "assigned")
self.assertEqual(second.session_id, "s2")
def test_merged_work_never_assigned(self) -> None:
self.db.upsert_session(session_id="s1", role="merger")
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="pr",
number=99,
state="merged",
)
result = self.db.assign_and_lease(
session_id="s1",
role="merger",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=99,
expected_head_sha="merged-head",
)
self.assertEqual(result.outcome, "no_safe_work")
def test_four_concurrent_sessions_unique_assignments(self) -> None:
"""Four concurrent assigners on four different issues — all succeed uniquely.
Also two concurrent assigners on the *same* issue: at most one assigned.
"""
for i in range(4):
self.db.upsert_session(session_id=f"sess-{i}", role="author")
def claim_unique(i: int):
return self.db.assign_and_lease(
session_id=f"sess-{i}",
role="author",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=1000 + i,
)
with ThreadPoolExecutor(max_workers=4) as pool:
results = [f.result() for f in as_completed([pool.submit(claim_unique, i) for i in range(4)])]
self.assertEqual({r.outcome for r in results}, {"assigned"})
numbers = sorted(r.work_number for r in results)
self.assertEqual(numbers, [1000, 1001, 1002, 1003])
# Contention on one item
self.db.upsert_session(session_id="c1", role="author")
self.db.upsert_session(session_id="c2", role="author")
self.db.upsert_session(session_id="c3", role="author")
self.db.upsert_session(session_id="c4", role="author")
barrier = threading.Barrier(4)
outcomes: list[str] = []
lock = threading.Lock()
def contend(sid: str) -> None:
barrier.wait()
res = self.db.assign_and_lease(
session_id=sid,
role="author",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=7777,
)
with lock:
outcomes.append(res.outcome)
threads = [threading.Thread(target=contend, args=(f"c{i}",)) for i in range(1, 5)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEqual(outcomes.count("assigned"), 1)
self.assertEqual(outcomes.count("wait"), 3)
def test_terminal_lock_index(self) -> None:
self.db.set_terminal_lock(
remote="prgs",
org="o",
repo="r",
terminal_pr=332,
review_id="rev-1",
decision="approve",
)
row = self.db.get_active_terminal_lock(remote="prgs", org="o", repo="r")
self.assertIsNotNone(row)
assert row is not None
self.assertEqual(row["terminal_pr"], 332)
self.assertEqual(row["status"], "active")
def test_incident_links_not_work_items(self) -> None:
link = self.db.upsert_incident_link(
provider="sentry",
provider_base_url="https://sentry.prgs.cc",
provider_org="prgs",
provider_project="gitea-tools-mcp",
provider_issue_id="12345",
gitea_org="Scaled-Tech-Consulting",
gitea_repo="Gitea-Tools",
gitea_issue_number=9001,
fingerprint="fp-1",
)
self.assertEqual(link["gitea_issue_number"], 9001)
found = self.db.get_incident_link_for_gitea_issue(
gitea_org="Scaled-Tech-Consulting",
gitea_repo="Gitea-Tools",
gitea_issue_number=9001,
)
self.assertIsNotNone(found)
# Linking does not create a work_item of incident kind
with self.assertRaises(InvalidWorkKindError):
self.db.upsert_work_item(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
kind="sentry",
number=12345,
)
def test_heartbeat_and_release(self) -> None:
self.db.upsert_session(session_id="s1", role="author")
a = self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=1,
)
hb = self.db.heartbeat_lease(a.lease_id, session_id="s1")
self.assertEqual(hb["lease_id"], a.lease_id)
self.db.release_lease(a.lease_id, session_id="s1")
# After release another session can claim
self.db.upsert_session(session_id="s2", role="author")
b = self.db.assign_and_lease(
session_id="s2",
role="author",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=1,
)
self.assertEqual(b.outcome, "assigned")
self.assertEqual(b.session_id, "s2")
def test_require_valid_assignment_rejects_stale_head(self) -> None:
"""Assignment must not authorize mutations after work-item head drifts."""
self.db.upsert_session(session_id="s1", role="author")
self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=42,
expected_head_sha="head-v1",
allowed_actions=("implement",),
)
# Head drifts after assignment
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="pr",
number=42,
current_head_sha="head-v2",
)
with self.assertRaises(LeaseRequiredError) as ctx:
self.db.require_valid_assignment(
session_id="s1",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=42,
action="implement",
)
self.assertIn("stale head", str(ctx.exception).lower())
def test_require_valid_assignment_rejects_terminal_state(self) -> None:
"""Assignment must not authorize mutations after work item is merged/closed."""
self.db.upsert_session(session_id="s1", role="author")
self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=55,
expected_head_sha="abc",
allowed_actions=("implement",),
)
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="pr",
number=55,
state="merged",
current_head_sha="abc",
)
with self.assertRaises(LeaseRequiredError) as ctx:
self.db.require_valid_assignment(
session_id="s1",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=55,
action="implement",
)
self.assertIn("terminal", str(ctx.exception).lower())
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="issue",
number=56,
state="open",
)
self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=56,
allowed_actions=("implement",),
)
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="issue",
number=56,
state="closed",
)
with self.assertRaises(LeaseRequiredError):
self.db.require_valid_assignment(
session_id="s1",
remote="prgs",
org="o",
repo="r",
kind="issue",
number=56,
action="implement",
)
def test_pr_assignment_requires_expected_head_sha(self) -> None:
"""PR assign and mutation must fail closed without a head pin."""
self.db.upsert_session(session_id="s1", role="author")
with self.assertRaises(LeaseRequiredError) as ctx:
self.db.assign_and_lease(
session_id="s1",
role="author",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=88,
expected_head_sha=None,
allowed_actions=("implement",),
)
self.assertIn("expected_head_sha", str(ctx.exception))
# Legacy path: force an unpinned PR assignment into the DB, then
# populate head and prove mutation is still rejected.
import sqlite3
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="pr",
number=89,
current_head_sha=None,
)
conn = sqlite3.connect(self.db_path)
try:
wid = conn.execute(
"SELECT work_item_id FROM work_items WHERE kind='pr' AND number=89"
).fetchone()[0]
conn.execute(
"""
INSERT INTO sessions(session_id, role, started_at, last_heartbeat_at, status)
VALUES ('legacy', 'author', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z', 'active')
"""
)
conn.execute(
"""
INSERT INTO leases(
lease_id, work_item_id, session_id, role, phase,
expires_at, heartbeat_at, status
) VALUES (
'lease-legacy', ?, 'legacy', 'author', 'claimed',
'2099-01-01T00:00:00Z', '2020-01-01T00:00:00Z', 'active'
)
""",
(wid,),
)
conn.execute(
"""
INSERT INTO assignments(
assignment_id, work_item_id, session_id, lease_id,
allowed_actions, forbidden_actions, expected_head_sha,
role, status, created_at
) VALUES (
'asn-legacy', ?, 'legacy', 'lease-legacy',
'["implement"]', '["merge"]', NULL,
'author', 'active', '2020-01-01T00:00:00Z'
)
""",
(wid,),
)
conn.commit()
finally:
conn.close()
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="pr",
number=89,
current_head_sha="populated-head",
)
with self.assertRaises(LeaseRequiredError) as ctx2:
self.db.require_valid_assignment(
session_id="legacy",
remote="prgs",
org="o",
repo="r",
kind="pr",
number=89,
action="implement",
)
self.assertIn("expected_head_sha pin", str(ctx2.exception))
def test_migrate_duplicate_null_scope_incident_links(self) -> None:
"""Legacy NULL-scope duplicates must migrate without UNIQUE crash."""
import sqlite3
from control_plane_db import ControlPlaneDB, ControlPlaneError
# Build a v1-like table with nullable scope columns and insert dups
# that collapse under normalization, then open ControlPlaneDB on it.
path = os.path.join(self._tmp.name, "legacy_dups.sqlite3")
conn = sqlite3.connect(path)
try:
conn.executescript(
"""
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE incident_links (
link_id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
provider_base_url TEXT,
provider_org TEXT,
provider_project TEXT,
provider_issue_id TEXT NOT NULL,
provider_short_id TEXT,
provider_permalink TEXT,
fingerprint TEXT,
gitea_org TEXT NOT NULL,
gitea_repo TEXT NOT NULL,
gitea_issue_number INTEGER NOT NULL,
linked_pr_numbers TEXT,
first_seen TEXT,
last_seen TEXT,
event_count INTEGER,
status TEXT NOT NULL DEFAULT 'open',
release_resolved_at TEXT,
last_sync_at TEXT,
UNIQUE (
provider, provider_base_url, provider_org,
provider_project, provider_issue_id
)
);
INSERT INTO incident_links(
provider, provider_base_url, provider_org, provider_project,
provider_issue_id, gitea_org, gitea_repo, gitea_issue_number, status
) VALUES
('sentry', NULL, NULL, NULL, 'dup-1', 'org', 'repo', 10, 'open'),
('sentry', NULL, NULL, NULL, 'dup-1', 'org', 'repo', 10, 'open');
"""
)
# SQLite allows two NULL-scope rows with same provider/issue under UNIQUE.
n = conn.execute("SELECT COUNT(*) FROM incident_links").fetchone()[0]
self.assertEqual(n, 2)
conn.commit()
finally:
conn.close()
db = ControlPlaneDB(path)
conn2 = sqlite3.connect(path)
try:
n2 = conn2.execute("SELECT COUNT(*) FROM incident_links").fetchone()[0]
rows = conn2.execute(
"SELECT provider_base_url, provider_org, provider_project, gitea_issue_number "
"FROM incident_links"
).fetchall()
finally:
conn2.close()
self.assertEqual(n2, 1)
self.assertEqual(rows[0][0], "")
self.assertEqual(rows[0][1], "")
self.assertEqual(rows[0][2], "")
self.assertEqual(rows[0][3], 10)
# Touch to silence unused import in type checkers if needed
self.assertTrue(issubclass(ControlPlaneError, Exception))
del db
def test_migrate_conflicting_duplicate_incident_links_fails_closed(self) -> None:
"""Conflicting Gitea targets for the same provider key must fail closed."""
import sqlite3
from control_plane_db import ControlPlaneDB, ControlPlaneError
path = os.path.join(self._tmp.name, "legacy_conflict.sqlite3")
conn = sqlite3.connect(path)
try:
conn.executescript(
"""
CREATE TABLE incident_links (
link_id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
provider_base_url TEXT,
provider_org TEXT,
provider_project TEXT,
provider_issue_id TEXT NOT NULL,
gitea_org TEXT NOT NULL,
gitea_repo TEXT NOT NULL,
gitea_issue_number INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
UNIQUE (
provider, provider_base_url, provider_org,
provider_project, provider_issue_id
)
);
INSERT INTO incident_links(
provider, provider_base_url, provider_org, provider_project,
provider_issue_id, gitea_org, gitea_repo, gitea_issue_number
) VALUES
('sentry', NULL, NULL, NULL, 'dup-c', 'org', 'repo', 1),
('sentry', NULL, NULL, NULL, 'dup-c', 'org', 'repo', 2);
"""
)
conn.commit()
finally:
conn.close()
with self.assertRaises(ControlPlaneError) as ctx:
ControlPlaneDB(path)
self.assertIn("conflicting", str(ctx.exception).lower())
def test_migrate_conflicting_observation_metadata_fails_closed(self) -> None:
"""Same provider key + same Gitea target but differing obs metadata must fail closed.
Regression for silent data loss: migration used to keep lowest link_id and
delete peers after comparing only Gitea targets (#619 RC3).
"""
import sqlite3
from control_plane_db import ControlPlaneDB, ControlPlaneError
path = os.path.join(self._tmp.name, "legacy_meta_conflict.sqlite3")
conn = sqlite3.connect(path)
try:
conn.executescript(
"""
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE incident_links (
link_id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
provider_base_url TEXT,
provider_org TEXT,
provider_project TEXT,
provider_issue_id TEXT NOT NULL,
provider_short_id TEXT,
provider_permalink TEXT,
fingerprint TEXT,
gitea_org TEXT NOT NULL,
gitea_repo TEXT NOT NULL,
gitea_issue_number INTEGER NOT NULL,
linked_pr_numbers TEXT,
first_seen TEXT,
last_seen TEXT,
event_count INTEGER,
status TEXT NOT NULL DEFAULT 'open',
release_resolved_at TEXT,
last_sync_at TEXT,
UNIQUE (
provider, provider_base_url, provider_org,
provider_project, provider_issue_id
)
);
INSERT INTO incident_links(
provider, provider_base_url, provider_org, provider_project,
provider_issue_id, provider_permalink, fingerprint,
gitea_org, gitea_repo, gitea_issue_number,
event_count, status, first_seen, last_seen
) VALUES
(
'sentry', NULL, NULL, NULL, 'dup-meta',
'https://sentry.example/issues/1', 'fingerprint-A',
'org', 'repo', 42,
1, 'open', '2026-01-01T00:00:00Z', '2026-01-01T01:00:00Z'
),
(
'sentry', NULL, NULL, NULL, 'dup-meta',
'https://sentry.example/issues/1', 'fingerprint-B',
'org', 'repo', 42,
99, 'resolved', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z'
);
"""
)
n = conn.execute("SELECT COUNT(*) FROM incident_links").fetchone()[0]
self.assertEqual(n, 2)
conn.commit()
finally:
conn.close()
with self.assertRaises(ControlPlaneError) as ctx:
ControlPlaneDB(path)
msg = str(ctx.exception).lower()
self.assertIn("conflicting", msg)
self.assertIn("observation metadata", msg)
# Rows must still be present — migration must not delete before failing.
conn2 = sqlite3.connect(path)
try:
remaining = conn2.execute("SELECT COUNT(*) FROM incident_links").fetchone()[0]
fps = {
r[0]
for r in conn2.execute(
"SELECT fingerprint FROM incident_links ORDER BY link_id"
).fetchall()
}
finally:
conn2.close()
self.assertEqual(remaining, 2)
self.assertEqual(fps, {"fingerprint-A", "fingerprint-B"})
def test_incident_links_minimal_upsert_is_canonical(self) -> None:
"""Repeated minimal upserts must update one row (NULL-safe uniqueness)."""
a = self.db.upsert_incident_link(
provider="sentry",
provider_issue_id="inc-1",
gitea_org="org",
gitea_repo="repo",
gitea_issue_number=1,
)
b = self.db.upsert_incident_link(
provider="sentry",
provider_issue_id="inc-1",
gitea_org="org",
gitea_repo="repo",
gitea_issue_number=2,
# omit optional scope fields again
)
c = self.db.upsert_incident_link(
provider="sentry",
provider_issue_id="inc-1",
gitea_org="org",
gitea_repo="repo",
gitea_issue_number=3,
provider_base_url=None,
provider_org="",
provider_project=" ",
)
self.assertEqual(a["link_id"], b["link_id"])
self.assertEqual(b["link_id"], c["link_id"])
self.assertEqual(c["gitea_issue_number"], 3)
self.assertEqual(c["provider_base_url"], "")
self.assertEqual(c["provider_org"], "")
self.assertEqual(c["provider_project"], "")
import sqlite3
conn = sqlite3.connect(self.db_path)
try:
n = conn.execute(
"SELECT COUNT(*) FROM incident_links WHERE provider_issue_id = ?",
("inc-1",),
).fetchone()[0]
finally:
conn.close()
self.assertEqual(n, 1)
if __name__ == "__main__":
unittest.main()
@@ -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()
+345
View File
@@ -0,0 +1,345 @@
"""Tests for observability incident bridge (#612) on #613 substrate."""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from control_plane_db import ControlPlaneDB, InvalidWorkKindError, WORK_KINDS
from incident_bridge import (
OUTCOME_BLOCKED,
OUTCOME_CREATED,
OUTCOME_LINKED,
OUTCOME_PREVIEW,
OUTCOME_UPDATED,
ProjectMapping,
assert_not_raw_incident_work_item,
build_gitea_issue_body,
load_project_mappings,
normalize_incident,
project_mapping_from_dict,
reconcile_incident,
redact_text,
sanitize_tags,
)
def _mapping(**kwargs) -> ProjectMapping:
base = dict(
name="gitea-tools-mcp",
provider="sentry",
monitor_base_url="https://sentry.prgs.cc",
monitor_org="prgs",
monitor_project="gitea-tools-mcp",
gitea_org="Scaled-Tech-Consulting",
gitea_repo="Gitea-Tools",
default_labels=("type:bug", "observability", "sentry", "status:ready"),
)
base.update(kwargs)
return ProjectMapping(**base)
def _obs(**kwargs) -> dict:
base = dict(
provider="sentry",
provider_base_url="https://sentry.prgs.cc",
provider_org="prgs",
provider_project="gitea-tools-mcp",
provider_issue_id="ISSUE-100",
provider_short_id="GITEA-TOOLS-1A",
provider_permalink="https://sentry.prgs.cc/organizations/prgs/issues/100/",
fingerprint="fp-abc",
title="TypeError: boom",
summary="TypeError: boom in handle_request",
first_seen="2026-07-01T00:00:00Z",
last_seen="2026-07-10T00:00:00Z",
event_count=3,
environment="production",
severity="error",
culprit="handle_request",
tags={"runtime": "python", "release": "1.2.3"},
)
base.update(kwargs)
return base
class IncidentBridgeTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
self.mapping = _mapping()
self.created: list[dict] = []
def tearDown(self) -> None:
self._tmp.cleanup()
def _create_issue(self, title, body, labels, g_org, g_repo):
n = 9000 + len(self.created)
rec = {
"number": n,
"success": True,
"performed": True,
"title": title,
"body": body,
"labels": labels,
"org": g_org,
"repo": g_repo,
}
self.created.append(rec)
return rec
def test_redact_secrets(self) -> None:
dirty = "token=supersecret123 Authorization: Bearer abcdefghijklmnop"
clean = redact_text(dirty)
self.assertNotIn("supersecret", clean)
self.assertNotIn("abcdefghijklmnop", clean)
self.assertIn("[REDACTED]", clean)
tags = sanitize_tags(
{"token": "x", "runtime": "py", "password": "nope", "release": "1.0"}
)
self.assertEqual(tags, {"runtime": "py", "release": "1.0"})
def test_body_contains_no_secrets(self) -> None:
inc = normalize_incident(
_obs(
summary="fail password=hunter2 token: abc",
tags={"cookie": "sid=1", "runtime": "py"},
),
self.mapping,
)
body = build_gitea_issue_body(inc)
self.assertNotIn("hunter2", body)
self.assertNotIn("sid=1", body)
self.assertIn("provider_issue_id", body)
self.assertIn("not** assignable", body.lower())
def test_preview_no_mutation(self) -> None:
res = reconcile_incident(
self.db,
observation=_obs(),
mapping=self.mapping,
apply=False,
create_issue_fn=self._create_issue,
)
self.assertTrue(res["success"])
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
self.assertFalse(res["gitea_mutated"])
self.assertFalse(res["db_mutated"])
self.assertFalse(res["raw_incident_assignable"])
self.assertEqual(len(self.created), 0)
self.assertIsNone(
self.db.get_incident_link_by_provider(
provider="sentry",
provider_issue_id="ISSUE-100",
provider_base_url="https://sentry.prgs.cc",
provider_org="prgs",
provider_project="gitea-tools-mcp",
)
)
def test_apply_creates_issue_and_link(self) -> None:
res = reconcile_incident(
self.db,
observation=_obs(),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertTrue(res["success"])
self.assertEqual(res["outcome"], OUTCOME_CREATED)
self.assertTrue(res["gitea_mutated"])
self.assertTrue(res["db_mutated"])
self.assertEqual(len(self.created), 1)
self.assertEqual(res["gitea_issue"]["number"], self.created[0]["number"])
link = self.db.get_incident_link_by_provider(
provider="sentry",
provider_issue_id="ISSUE-100",
provider_base_url="https://sentry.prgs.cc",
provider_org="prgs",
provider_project="gitea-tools-mcp",
)
self.assertIsNotNone(link)
self.assertEqual(int(link["gitea_issue_number"]), self.created[0]["number"])
self.assertEqual(res["allocator_visible_as"]["kind"], "issue")
def test_duplicate_observation_reuses_issue(self) -> None:
first = reconcile_incident(
self.db,
observation=_obs(),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
second = reconcile_incident(
self.db,
observation=_obs(event_count=9, last_seen="2026-07-11T00:00:00Z"),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertEqual(first["outcome"], OUTCOME_CREATED)
self.assertEqual(second["outcome"], OUTCOME_UPDATED)
self.assertEqual(len(self.created), 1)
self.assertEqual(
second["gitea_issue"]["number"], first["gitea_issue"]["number"]
)
link = self.db.get_incident_link_by_provider(
provider="sentry",
provider_issue_id="ISSUE-100",
provider_base_url="https://sentry.prgs.cc",
provider_org="prgs",
provider_project="gitea-tools-mcp",
)
self.assertEqual(int(link["event_count"]), 9)
def test_explicit_link_existing_issue(self) -> None:
res = reconcile_incident(
self.db,
observation=_obs(provider_issue_id="ISSUE-200"),
mapping=self.mapping,
apply=True,
force_gitea_issue_number=555,
create_issue_fn=self._create_issue,
)
self.assertEqual(res["outcome"], OUTCOME_LINKED)
self.assertEqual(len(self.created), 0)
self.assertEqual(res["gitea_issue"]["number"], 555)
link = self.db.get_incident_link_for_gitea_issue(
gitea_org="Scaled-Tech-Consulting",
gitea_repo="Gitea-Tools",
gitea_issue_number=555,
)
self.assertIsNotNone(link)
def test_fingerprint_conflict_fails_closed(self) -> None:
reconcile_incident(
self.db,
observation=_obs(fingerprint="fp-a"),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
res = reconcile_incident(
self.db,
observation=_obs(fingerprint="fp-b"),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertEqual(res["outcome"], OUTCOME_BLOCKED)
self.assertIn("fingerprint conflict", res["reasons"][0].lower())
self.assertEqual(len(self.created), 1)
def test_ambiguous_mapping_fails_closed(self) -> None:
maps = [
_mapping(name="a", monitor_project="gitea-tools-mcp"),
_mapping(name="b", monitor_project="gitea-tools-mcp"),
]
res = reconcile_incident(
self.db,
observation=_obs(),
mappings=maps,
apply=False,
)
self.assertEqual(res["outcome"], OUTCOME_BLOCKED)
self.assertIn("ambiguous", res["reasons"][0].lower())
def test_missing_provider_issue_id_fails_closed(self) -> None:
res = reconcile_incident(
self.db,
observation=_obs(provider_issue_id=""),
mapping=self.mapping,
apply=False,
)
self.assertEqual(res["outcome"], OUTCOME_BLOCKED)
def test_raw_incident_not_work_kind(self) -> None:
self.assertNotIn("sentry_incident", WORK_KINDS)
with self.assertRaises(InvalidWorkKindError):
self.db.upsert_work_item(
remote="prgs",
org="o",
repo="r",
kind="sentry_incident",
number=1,
)
with self.assertRaises(Exception):
assert_not_raw_incident_work_item("glitchtip_incident")
def test_db_unavailable(self) -> None:
res = reconcile_incident(
None,
observation=_obs(),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertFalse(res["success"])
self.assertEqual(res["outcome"], OUTCOME_BLOCKED)
def test_structured_skip_reason_environment(self) -> None:
m = _mapping(environment_filters=("staging",))
res = reconcile_incident(
self.db,
observation=_obs(environment="production"),
mapping=m,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertTrue(res["success"])
self.assertEqual(res["action"], "skip_environment_filter")
self.assertEqual(len(self.created), 0)
def test_load_mappings_json(self) -> None:
payload = json.dumps(
{
"projects": [
{
"name": "gt",
"provider": "glitchtip",
"monitor_base_url": "https://glitchtip.example",
"monitor_org": "org",
"monitor_project": "proj",
"gitea_org": "Scaled-Tech-Consulting",
"gitea_repo": "Gitea-Tools",
}
]
}
)
maps = load_project_mappings(mappings_json=payload)
self.assertEqual(len(maps), 1)
self.assertEqual(maps[0].provider, "glitchtip")
def test_glitchtip_provider_accepted(self) -> None:
m = _mapping(provider="glitchtip", monitor_base_url="https://glitchtip.example")
res = reconcile_incident(
self.db,
observation=_obs(
provider="glitchtip",
provider_base_url="https://glitchtip.example",
),
mapping=m,
apply=True,
create_issue_fn=self._create_issue,
)
self.assertEqual(res["outcome"], OUTCOME_CREATED)
self.assertEqual(res["incident"]["provider"], "glitchtip")
def test_allocator_only_sees_issue_kind(self) -> None:
res = reconcile_incident(
self.db,
observation=_obs(),
mapping=self.mapping,
apply=True,
create_issue_fn=self._create_issue,
)
vis = res["allocator_visible_as"]
self.assertEqual(vis["kind"], "issue")
self.assertIn(vis["kind"], WORK_KINDS)
self.assertFalse(res["raw_incident_assignable"])
if __name__ == "__main__":
unittest.main()
+27 -7
View File
@@ -103,20 +103,40 @@ class TestIssueLockStore(unittest.TestCase):
self.assertIn("live foreign issue lock", block or "")
def test_expired_lease_allows_takeover_with_conflict_check(self):
# #601: expired + dead pid / missing worktree → sanctioned reclaim (no block).
existing = _lock_record(
branch_name="feat/issue-420-other",
worktree_path="/tmp/other",
worktree_path="/tmp/other-does-not-exist-420",
session_pid=1,
work_lease=_lease("2000-01-01T00:00:00Z"),
)
incoming = _lock_record(worktree_path="/tmp/mine")
self.assertIsNone(ils.assess_foreign_lock_overwrite(existing, incoming))
block = ils.assess_same_issue_lease_conflict(
existing,
issue_number=420,
branch_name="feat/issue-420-server-code-parity",
worktree_path="/tmp/mine",
with mock.patch.object(ils, "is_process_alive", return_value=False):
block = ils.assess_same_issue_lease_conflict(
existing,
issue_number=420,
branch_name="feat/issue-420-server-code-parity",
worktree_path="/tmp/mine",
)
self.assertIsNone(block)
# Expired but still-live owner pid AND present worktree → fail closed.
present_wt = self.lock_dir # exists
sticky = _lock_record(
branch_name="feat/issue-420-other",
worktree_path=present_wt,
session_pid=os.getpid(),
work_lease=_lease("2000-01-01T00:00:00Z"),
)
self.assertIn("Recovery review is required", block or "")
with mock.patch.object(ils, "is_process_alive", return_value=True):
blocked = ils.assess_same_issue_lease_conflict(
sticky,
issue_number=420,
branch_name="feat/issue-420-server-code-parity",
worktree_path="/tmp/mine",
)
self.assertIn("Recovery review is required", blocked or "")
def test_same_owner_lease_conflict_allows_refresh(self):
worktree = "/tmp/wt-420"
+392
View File
@@ -0,0 +1,392 @@
"""Tests for first-class control-plane lease lifecycle (#601)."""
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import timedelta
from unittest import mock
from allocator_service import allocate_next_work, WorkCandidate
from control_plane_db import ControlPlaneDB, ForeignLeaseError, _ts, _utc_now
import lease_lifecycle as ll
import merger_lease_adoption as mla
import reviewer_pr_lease as rpl
from datetime import datetime, timezone
class LeaseLifecycleTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
self.db = ControlPlaneDB(self.db_path)
self.db.upsert_session(session_id="owner", role="author", profile="prgs-author", pid=111)
self.db.upsert_session(session_id="other", role="author", profile="prgs-author", pid=222)
def tearDown(self) -> None:
self._tmp.cleanup()
def _assign(self, session_id="owner", number=601, **kwargs):
return self.db.assign_and_lease(
session_id=session_id,
role="author",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
kind="issue",
number=number,
allowed_actions=("implement", "comment", "push", "create_pr"),
forbidden_actions=("approve", "merge", "self_select_without_assignment"),
worktree_path=kwargs.pop("worktree_path", self._tmp.name),
owner_pid=kwargs.pop("owner_pid", os.getpid()),
**kwargs,
)
def test_list_active_leases_as_workflow_state(self) -> None:
a = self._assign(number=601)
self._assign(session_id="other", number=602)
listed = ll.list_active_leases(
self.db, remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools"
)
self.assertTrue(listed["success"])
self.assertEqual(listed["count"], 2)
self.assertEqual(listed["authoritative_source"], "control_plane_db")
self.assertFalse(listed["file_lock_only"])
self.assertFalse(listed["comment_lease_only"])
numbers = sorted(x["work_number"] for x in listed["leases"])
self.assertEqual(numbers, [601, 602])
self.assertIsNotNone(a.lease_id)
def test_adopt_lease_owner_resume_preserves_provenance(self) -> None:
a = self._assign()
res = ll.adopt_lease(
self.db,
lease_id=a.lease_id,
adopter_session_id="owner",
role="author",
worktree_path=self._tmp.name,
)
self.assertTrue(res["success"])
self.assertTrue(res["same_owner"])
prov = res["provenance"]
self.assertEqual(prov["adopted_from_session_id"], "owner")
self.assertEqual(prov["adopted_by_session_id"], "owner")
self.assertEqual(prov["work_number"], 601)
self.assertEqual(prov["worktree_path"], self._tmp.name)
state = self.db.get_lease_workflow_state(a.lease_id)
self.assertIsNotNone(state)
self.assertEqual(state["lease"]["status"], "active")
def test_release_lease_explicit_recorded(self) -> None:
a = self._assign()
res = ll.release_lease(self.db, lease_id=a.lease_id, session_id="owner")
self.assertEqual(res["outcome"], "released")
self.assertIn("release_proof", res)
self.assertEqual(res["release_proof"]["status"], "released")
state = self.db.get_lease_workflow_state(a.lease_id)
self.assertEqual(state["lease"]["status"], "released")
def test_expire_and_reclaim_expired_lease(self) -> None:
a = self._assign(lease_ttl_seconds=1)
past = _utc_now() - timedelta(hours=2)
import sqlite3
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"UPDATE leases SET expires_at = ? WHERE lease_id = ?",
(_ts(past), a.lease_id),
)
conn.commit()
finally:
conn.close()
exp = ll.expire_leases(self.db)
self.assertGreaterEqual(exp["expired_count"], 1)
reclaimed = ll.reclaim_expired_lease(
self.db,
lease_id=a.lease_id,
session_id="other",
role="author",
worktree_path=self._tmp.name,
)
self.assertEqual(reclaimed["outcome"], "reclaimed")
self.assertEqual(reclaimed["assignment"]["session_id"], "other")
self.assertEqual(
reclaimed["provenance"]["adopted_from_session_id"], "owner"
)
self.assertEqual(
reclaimed["provenance"]["adopted_by_session_id"], "other"
)
def test_abandon_stale_lease_with_required_proof(self) -> None:
a = self._assign(owner_pid=99999999, worktree_path="/nonexistent/path/for-601")
# Force active but dead pid + missing worktree
proof = ll.AbandonProof(
dead_process=True,
missing_worktree=True,
no_open_pr=True,
no_live_mutation_risk=True,
owner_pid=99999999,
worktree_path="/nonexistent/path/for-601",
)
res = ll.abandon_lease(
self.db,
lease_id=a.lease_id,
requester_session_id="other",
proof=proof,
)
self.assertEqual(res["outcome"], "abandoned")
self.assertEqual(res["prior_owner_session_id"], "owner")
self.assertTrue(res["abandon_proof"]["dead_process"])
state = self.db.get_lease_workflow_state(a.lease_id)
self.assertEqual(state["lease"]["status"], "abandoned")
def test_refuse_steal_active_foreign_lease(self) -> None:
a = self._assign()
with self.assertRaises(ll.LeaseLifecycleError) as ctx:
ll.adopt_lease(
self.db,
lease_id=a.lease_id,
adopter_session_id="other",
role="author",
)
self.assertIn("steal", str(ctx.exception).lower())
decision = ll.inspect_lease(
self.db, a.lease_id, caller_session_id="other"
)
self.assertEqual(decision["safe_next_action"], ll.SAFE_WAIT_FOREIGN)
self.assertTrue(decision["block"])
def test_refuse_ambiguous_lease_ownership_stale_id(self) -> None:
decision = ll.inspect_lease(
self.db, "lease-does-not-exist", caller_session_id="owner"
)
self.assertFalse(decision["found"])
self.assertEqual(decision["safe_next_action"], ll.SAFE_STALE_PROMPT)
with self.assertRaises(ll.LeaseLifecycleError):
ll.adopt_lease(
self.db,
lease_id="lease-does-not-exist",
adopter_session_id="owner",
role="author",
)
def test_provenance_on_adopt_release_abandon(self) -> None:
a = self._assign()
adopted = ll.adopt_lease(
self.db,
lease_id=a.lease_id,
adopter_session_id="owner",
role="author",
worktree_path=self._tmp.name,
expected_head_sha="abc",
)
self.assertIn("adopted_from_session_id", adopted["provenance"])
self.assertIn("adopted_by_session_id", adopted["provenance"])
released = ll.release_lease(
self.db, lease_id=a.lease_id, session_id="owner"
)
self.assertEqual(released["release_proof"]["session_id"], "owner")
b = self._assign(number=700, owner_pid=1, worktree_path="/no/such/wt")
abandoned = ll.abandon_lease(
self.db,
lease_id=b.lease_id,
requester_session_id="owner",
proof=ll.AbandonProof(
dead_process=True,
missing_worktree=True,
no_live_mutation_risk=True,
no_open_pr=True,
),
)
self.assertIn("abandon_proof", abandoned)
self.assertEqual(abandoned["abandon_proof"]["dead_process"], True)
def test_allocator_assignment_lease_compatible(self) -> None:
"""Allocator assign+lease still works and is listable/inspectable."""
cands = [
WorkCandidate(
kind="issue",
number=601,
labels=("status:ready",),
title="leases",
priority=20,
)
]
res = allocate_next_work(
self.db,
session_id="alloc-s",
role="author",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
candidates=cands,
apply=True,
profile_name="prgs-author",
username="jcwalker3",
)
self.assertEqual(res["outcome"], "assigned_work")
lid = res["assignment"]["lease_id"]
listed = ll.list_active_leases(self.db, remote="prgs")
ids = [x["lease_id"] for x in listed["leases"]]
self.assertIn(lid, ids)
insp = ll.inspect_lease(self.db, lid, caller_session_id="alloc-s")
self.assertTrue(insp["found"])
self.assertIn(
insp["safe_next_action"],
(ll.SAFE_OWNER_RESUME, ll.SAFE_ABANDON_ALLOWED),
)
def test_reviewer_merger_lease_handoff_still_works(self) -> None:
"""#536 merger adoption path is independent and must not regress."""
rpl.clear_session_lease()
body = mla.format_adoption_body(
repo="Scaled-Tech-Consulting/Gitea-Tools",
pr_number=999,
issue_number=601,
adopter_identity="sysadmin",
adopter_profile="prgs-merger",
adopter_session_id="merger-1",
worktree="branches/merge-pr999",
candidate_head="a" * 40,
target_branch="master",
target_branch_sha="b" * 40,
adopted_from_session_id="reviewer-1",
adopted_from_profile="prgs-reviewer",
adopted_from_reviewer_identity="sysadmin",
adopted_from_comment_id=42,
)
self.assertIn(mla.ADOPTION_MARKER, body)
self.assertIn("adopted_from_session_id: reviewer-1", body)
self.assertIn("adopted_by_profile: prgs-merger", body)
prov = mla.build_lease_provenance(
source=mla.SOURCE_ADOPT,
comment_id=42,
adopted_from_session_id="reviewer-1",
adoption_reason=mla.DEFAULT_ADOPTION_REASON,
)
rpl.record_session_lease(
{
"pr_number": 999,
"session_id": "merger-1",
"comment_id": 42,
},
lease_provenance=prov,
)
proof = mla.describe_session_lease_proof(rpl.get_session_lease())
self.assertTrue(proof["lease_proof_sanctioned"])
self.assertEqual(proof["lease_proof_source"], mla.SOURCE_ADOPT)
rpl.clear_session_lease()
def test_missing_worktree_and_dead_pid_handled_safely(self) -> None:
a = self._assign(owner_pid=1, worktree_path="/definitely/missing/wt-601")
with mock.patch.object(ll, "is_process_alive", return_value=False):
fr = ll.classify_lease_freshness(
self.db.get_lease_workflow_state(a.lease_id)["lease"]
)
self.assertIn(fr["freshness"], ("stale_dead_process", "stale_missing_worktree"))
# Insufficient abandon proof fails closed
with self.assertRaises(ll.LeaseLifecycleError):
ll.abandon_lease(
self.db,
lease_id=a.lease_id,
requester_session_id="other",
proof=ll.AbandonProof(dead_process=True), # missing no_live_mutation_risk
)
def test_file_lock_or_comment_not_authoritative_alone(self) -> None:
report = ll.non_db_lease_authority_report(
file_lock_present=True,
comment_lease_present=False,
db_lease_present=False,
)
self.assertEqual(report["safe_next_action"], ll.SAFE_NO_AUTHORITY)
self.assertTrue(report["file_lock_only"])
self.assertIsNone(report["authoritative_source"])
report2 = ll.non_db_lease_authority_report(
file_lock_present=True,
comment_lease_present=True,
db_lease_present=True,
)
self.assertEqual(report2["authoritative_source"], "control_plane_db")
self.assertFalse(report2["file_lock_only"])
self.assertFalse(report2["comment_lease_only"])
def test_abandon_proof_is_sufficient_rules(self) -> None:
self.assertFalse(ll.AbandonProof(dead_process=True).is_sufficient())
self.assertTrue(
ll.AbandonProof(
dead_process=True,
missing_worktree=True,
no_live_mutation_risk=True,
no_open_pr=True,
).is_sufficient()
)
self.assertTrue(
ll.AbandonProof(
dead_process=True,
no_live_mutation_risk=True,
same_owner=True,
).is_sufficient()
)
self.assertTrue(
ll.AbandonProof(
missing_worktree=True,
no_live_mutation_risk=True,
operator_authorized=True,
).is_sufficient()
)
class IssueLockExpiredReclaimTest(unittest.TestCase):
def test_expired_lock_reclaim_allowed_when_dead_and_missing_wt(self) -> None:
import issue_lock_store as ils
lock = {
"issue_number": 601,
"branch_name": "feat/issue-601-x",
"worktree_path": "/no/such/worktree-601",
"session_pid": 1,
"work_lease": {
"operation_type": "author_issue_work",
"expires_at": "2000-01-01T00:00:00Z",
},
}
with mock.patch.object(ils, "is_process_alive", return_value=False):
res = ils.assess_expired_lock_reclaim(lock)
self.assertTrue(res["reclaim_allowed"])
# Conflict assessor allows takeover
block = ils.assess_same_issue_lease_conflict(
lock,
issue_number=601,
branch_name="feat/issue-601-first-class-leases",
worktree_path="/tmp/new",
)
self.assertIsNone(block)
def test_live_lock_reclaim_refused(self) -> None:
import issue_lock_store as ils
from datetime import datetime, timedelta, timezone
future = (datetime.now(timezone.utc) + timedelta(hours=2)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
lock = {
"issue_number": 601,
"branch_name": "feat/issue-601-x",
"worktree_path": tempfile.gettempdir(),
"session_pid": os.getpid(),
"work_lease": {
"operation_type": "author_issue_work",
"expires_at": future,
"last_heartbeat_at": future,
},
}
res = ils.assess_expired_lock_reclaim(lock)
self.assertFalse(res["reclaim_allowed"])
if __name__ == "__main__":
unittest.main()
+29 -34
View File
@@ -28,33 +28,31 @@ class TestLabelCreation(unittest.TestCase):
"""Verify create-or-skip logic for the label set."""
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all")
@patch("manage_labels.api")
def test_skips_existing_labels(self, mock_api, _auth):
# Simulate all labels already exist
def test_skips_existing_labels(self, mock_api, mock_get_all, _auth):
# Simulate all labels already exist (paginated inventory #627)
existing = [_make_label(l["name"], i) for i, l in enumerate(manage_labels.LABELS)]
mock_api.return_value = existing # first call is GET /labels
mock_get_all.return_value = existing
# Patch sys.argv to avoid --dry
with patch.object(sys, "argv", ["manage_labels.py"]):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main()
# The GET call happens, but no POST calls for label creation
get_calls = [c for c in mock_api.call_args_list if c[0][0] == "GET"]
mock_get_all.assert_called()
post_label_calls = [
c for c in mock_api.call_args_list
if c[0][0] == "POST" and c[0][1] == "/labels"
]
self.assertGreaterEqual(len(get_calls), 1)
self.assertEqual(len(post_label_calls), 0)
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[])
@patch("manage_labels.api")
def test_creates_missing_labels(self, mock_api, _auth):
def test_creates_missing_labels(self, mock_api, _get_all, _auth):
# Simulate no existing labels
def side_effect(method, path, auth, payload=None):
if method == "GET" and "/labels" in path:
return [] # no existing labels
if method == "POST" and path == "/labels":
return {"id": 999, "name": payload["name"]}
if method == "PUT":
@@ -79,15 +77,14 @@ class TestLabelCreation(unittest.TestCase):
class TestDryRun(unittest.TestCase):
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[])
@patch("manage_labels.api")
def test_dry_run_makes_no_writes(self, mock_api, _auth):
mock_api.return_value = [] # no existing labels
def test_dry_run_makes_no_writes(self, mock_api, _get_all, _auth):
with patch.object(sys, "argv", ["manage_labels.py", "--dry"]):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main()
# Only the GET call should be made, no POST or PUT
# Dry run should not call write methods via api()
for c in mock_api.call_args_list:
method = c[0][0]
self.assertEqual(method, "GET",
@@ -100,13 +97,13 @@ class TestDryRun(unittest.TestCase):
class TestLabelMapping(unittest.TestCase):
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all")
@patch("manage_labels.api")
def test_applies_mapping_to_issues(self, mock_api, _auth):
def test_applies_mapping_to_issues(self, mock_api, mock_get_all, _auth):
existing = [_make_label(l["name"], i + 1) for i, l in enumerate(manage_labels.LABELS)]
mock_get_all.return_value = existing
def side_effect(method, path, auth, payload=None):
if method == "GET":
return existing
if method == "PUT":
return [{"name": "applied"}]
return None
@@ -158,11 +155,10 @@ class TestModes(unittest.TestCase):
return [(c[0][0], c[0][1]) for c in mock_api.call_args_list]
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[])
@patch("manage_labels.api")
def test_create_labels_only_no_mapping(self, mock_api, _auth):
def test_create_labels_only_no_mapping(self, mock_api, _get_all, _auth):
def se(method, path, auth, payload=None):
if method == "GET":
return [] # no existing labels
if method == "POST" and path == "/labels":
return {"id": 1, "name": payload["name"]}
return None
@@ -173,14 +169,14 @@ class TestModes(unittest.TestCase):
self.assertFalse(any(m[0] == "PUT" for m in methods)) # no mapping applied
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all")
@patch("manage_labels.api")
def test_apply_mapping_only_no_label_creation(self, mock_api, _auth):
def test_apply_mapping_only_no_label_creation(self, mock_api, mock_get_all, _auth):
existing = [_make_label(l["name"], i + 1)
for i, l in enumerate(manage_labels.LABELS)]
mock_get_all.return_value = existing
def se(method, path, auth, payload=None):
if method == "GET":
return existing
if method == "PUT":
return [{"name": "applied"}]
return None
@@ -192,13 +188,10 @@ class TestModes(unittest.TestCase):
self.assertEqual(len(put_calls), len(manage_labels.MAPPING))
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[_make_label("chore", 5)])
@patch("manage_labels.api")
def test_add_label_appends_to_issue(self, mock_api, _auth):
existing = [_make_label("chore", 5)]
def test_add_label_appends_to_issue(self, mock_api, _get_all, _auth):
def se(method, path, auth, payload=None):
if method == "GET":
return existing
if method == "POST":
return [{"name": "chore"}]
return None
@@ -212,19 +205,21 @@ class TestModes(unittest.TestCase):
self.assertFalse(any(c[0][0] == "PUT" for c in mock_api.call_args_list))
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[])
@patch("manage_labels.api")
def test_add_label_unknown_makes_no_write(self, mock_api, _auth):
mock_api.side_effect = lambda *a, **k: [] if a[0] == "GET" else None
def test_add_label_unknown_makes_no_write(self, mock_api, _get_all, _auth):
manage_labels.main(["--add-label", "42", "ghost"])
# Only the GET label lookup; no POST/PUT for an undefined label.
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list))
# No write via api() for an undefined label.
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)
or len(mock_api.call_args_list) == 0)
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api_get_all", return_value=[_make_label("chore", 5)])
@patch("manage_labels.api")
def test_add_label_dry_makes_no_write(self, mock_api, _auth):
mock_api.side_effect = lambda *a, **k: [_make_label("chore", 5)] if a[0] == "GET" else None
def test_add_label_dry_makes_no_write(self, mock_api, _get_all, _auth):
manage_labels.main(["--dry", "--add-label", "42", "chore"])
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list))
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)
or len(mock_api.call_args_list) == 0)
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api")
-198
View File
@@ -78,95 +78,6 @@ class TestBlockReasonsAndReport(unittest.TestCase):
self.assertTrue(report["recovery"])
class TestLiveRemoteParity(unittest.TestCase):
"""#610: parity must account for the live remote master, not just local.
The daemon can be stale relative to the live remote target while the local
checkout HEAD still matches the daemon's startup commit, so local parity
reports green even though a mutation would run against outdated code.
"""
SHA_C = "c" * 40
def test_distinguishes_three_shas(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertEqual(res["daemon_start_head"], SHA_A)
self.assertEqual(res["local_head"], SHA_A)
self.assertEqual(res["live_remote_head"], SHA_B)
def test_mutation_safe_only_when_all_three_match(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_A)
self.assertTrue(res["mutation_safe"])
self.assertTrue(res["live_known"])
self.assertFalse(res["live_stale"])
def test_live_stale_when_remote_advanced_past_daemon(self):
# Local checkout still matches the daemon start (local parity green),
# but the live remote master has advanced -> daemon is live-stale.
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertTrue(res["in_parity"]) # local parity still green
self.assertTrue(res["live_stale"])
self.assertFalse(res["mutation_safe"])
self.assertTrue(any("live" in r.lower() for r in res["reasons"]))
def test_live_unknown_is_not_mutation_safe_but_not_stale(self):
# Non-goal: unfetchable live remote must not be treated as stale for
# read-only, but a mutation-safe claim fails closed.
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=None)
self.assertFalse(res["live_known"])
self.assertFalse(res["mutation_safe"])
self.assertFalse(res["live_stale"])
self.assertTrue(res["in_parity"])
def test_default_live_remote_preserves_legacy_shape(self):
# Callers that do not supply a live head keep the pre-#610 behavior:
# in-parity, not live-stale, no live-derived block.
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertFalse(res["live_stale"])
self.assertEqual(mp.parity_block_reasons(res), [])
class TestLiveStaleBlockAndReport(unittest.TestCase):
"""#610: live-staleness must block mutations and surface a typed blocker."""
def test_live_stale_produces_block_reasons(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertTrue(mp.parity_block_reasons(res))
def test_disable_env_suppresses_live_stale_block(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
self.assertEqual(mp.parity_block_reasons(res), [])
def test_resolver_disagreement_returns_typed_blocker(self):
# Parity says local-green, resolver says restart required -> disagreement
# is a typed, fail-closed blocker naming the resolver as authoritative.
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
blocker = mp.parity_resolver_disagreement(res, resolver_restart_required=True)
self.assertIsNotNone(blocker)
self.assertEqual(blocker["kind"], "parity_resolver_disagreement")
self.assertTrue(blocker["restart_required"])
self.assertTrue(blocker["resolver_authoritative"])
def test_no_disagreement_when_resolver_agrees(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertIsNone(
mp.parity_resolver_disagreement(res, resolver_restart_required=False))
def test_live_stale_report_names_live_remote(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
report = mp.parity_report(res)
self.assertEqual(report["live_remote_head"], SHA_B)
self.assertTrue(report["restart_required"])
class TestReadGitHead(unittest.TestCase):
def test_test_override_takes_precedence(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
@@ -184,78 +95,6 @@ class TestReadGitHead(unittest.TestCase):
self.assertIsNone(mp.read_git_head(""))
class TestReadRemoteMasterHead(unittest.TestCase):
"""#610: live remote master head reader (env-overridable, fails to None)."""
def test_test_override_takes_precedence(self):
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
self.assertEqual(mp.read_remote_master_head("/nonexistent"), SHA_B)
def test_blank_override_is_none(self):
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: " "}):
self.assertIsNone(mp.read_remote_master_head("/nonexistent"))
def test_unfetchable_remote_is_none(self):
# No override; a bogus root/remote must fail closed to None, never raise.
env = {k: v for k, v in os.environ.items()
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
with patch.dict(os.environ, env, clear=True):
self.assertIsNone(
mp.read_remote_master_head("/nonexistent", remote="nope"))
class TestRemoteHeadCache(unittest.TestCase):
"""#610: live remote reads are cached with a TTL to stay off the network.
The parity gate runs on every mutation and every runtime-context read, so an
unbounded ``git ls-remote`` per call would be a latency/flakiness regression.
"""
def setUp(self):
mp._clear_remote_head_cache()
env = {k: v for k, v in os.environ.items()
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
self._env = patch.dict(os.environ, env, clear=True)
self._env.start()
self.addCleanup(self._env.stop)
self.addCleanup(mp._clear_remote_head_cache)
def _fake_run(self, sha):
class _R:
returncode = 0
stdout = f"{sha}\trefs/heads/master\n"
calls = {"n": 0}
def run(*args, **kwargs):
calls["n"] += 1
return _R()
return run, calls
def test_second_call_within_ttl_uses_cache(self):
run, calls = self._fake_run(SHA_B)
with patch.object(mp.subprocess, "run", run):
a = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
b = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
self.assertEqual(a, SHA_B)
self.assertEqual(b, SHA_B)
self.assertEqual(calls["n"], 1)
def test_zero_ttl_bypasses_cache(self):
run, calls = self._fake_run(SHA_B)
with patch.object(mp.subprocess, "run", run):
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
self.assertEqual(calls["n"], 2)
def test_env_override_never_touches_subprocess(self):
run, calls = self._fake_run(SHA_B)
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
with patch.object(mp.subprocess, "run", run):
self.assertEqual(
mp.read_remote_master_head("/repo", remote="prgs"), SHA_A)
self.assertEqual(calls["n"], 0)
class TestServerWiring(unittest.TestCase):
"""Integration with the gate choke point in the server namespace."""
@@ -266,13 +105,6 @@ class TestServerWiring(unittest.TestCase):
self._saved = self.srv._STARTUP_PARITY
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
"startup_head": SHA_A}
# Keep the live-remote read hermetic (no real ls-remote network call):
# default the live master to the daemon start so parity is fully green
# unless a test overrides the live head explicitly (#610).
self._live_patch = patch.dict(
os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A})
self._live_patch.start()
self.addCleanup(self._live_patch.stop)
def tearDown(self):
self.srv._STARTUP_PARITY = self._saved
@@ -315,36 +147,6 @@ class TestServerWiring(unittest.TestCase):
self.assertTrue(out["in_parity"])
self.assertNotIn("report", out)
# --- #610: live-remote wiring -------------------------------------------
def test_live_stale_blocks_mutation_though_local_green(self):
# Local checkout matches the daemon start (local parity green) but the
# live remote master has advanced -> mutations must fail closed.
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
self.assertTrue(
self.srv._master_parity_block("gitea.pr.create"))
def test_assess_tool_exposes_three_distinct_shas(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertEqual(out["daemon_start_head"], SHA_A)
self.assertEqual(out["local_head"], SHA_A)
self.assertEqual(out["live_remote_head"], SHA_B)
self.assertTrue(out["live_stale"])
self.assertFalse(out["mutation_safe"])
self.assertIn("report", out)
def test_assess_tool_mutation_safe_when_all_three_match(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertTrue(out["mutation_safe"])
self.assertFalse(out["live_stale"])
self.assertNotIn("report", out)
if __name__ == "__main__":
unittest.main()
+208
View File
@@ -0,0 +1,208 @@
import unittest
import gitea_mcp_server
import mcp_namespace_health
import review_merge_state_machine as rmsm
class TestMcpNamespaceHealth(unittest.TestCase):
def setUp(self):
gitea_mcp_server._LIVE_NAMESPACE_HEALTH.clear()
def test_registered_tool_but_live_eof_blocks_merge(self):
result = mcp_namespace_health.classify_namespace_probe(
"gitea-reviewer",
required_tool="gitea_whoami",
registered_tools=["gitea_whoami", "gitea_list_profiles"],
probe_result={
"success": False,
"error": "client is closing: EOF",
},
process={
"pid": 4321,
"profile": "prgs-reviewer",
"env": {
"GITEA_MCP_PROFILE": "prgs-reviewer",
"GITEA_TOKEN": "must-not-leak",
},
},
config_path="/Users/jasonwalker/.gemini/config/mcp_config.json",
probe_source="client_namespace",
)
self.assertFalse(result["healthy"])
self.assertEqual(result["error_type"], "namespace_eof")
self.assertTrue(result["required_tool_registered"])
self.assertFalse(result["required_tool_callable"])
self.assertTrue(result["blocks_merge_workflow"])
self.assertFalse(result["ide_namespace_proven"])
self.assertEqual(result["probe_source"], "client_namespace")
self.assertEqual(result["diagnostics"]["process_pid"], 4321)
self.assertEqual(result["diagnostics"]["profile"], "prgs-reviewer")
self.assertEqual(
result["diagnostics"]["env"]["GITEA_MCP_PROFILE"],
"prgs-reviewer",
)
self.assertNotIn("GITEA_TOKEN", result["diagnostics"]["env"])
self.assertIn("gitea-reviewer", result["reasons"][0])
self.assertIn("gitea_whoami", result["reasons"][0])
def test_successful_client_namespace_invocation_is_ide_proven(self):
result = mcp_namespace_health.classify_namespace_probe(
"gitea-author",
registered_tools=["gitea_whoami"],
probe_result={"success": True, "result": {"authenticated": True}},
process={"pid": 1234, "profile": "prgs-author"},
config_path="/tmp/mcp_config.json",
probe_source="client_namespace",
)
self.assertTrue(result["healthy"])
self.assertTrue(result["required_tool_registered"])
self.assertTrue(result["required_tool_callable"])
self.assertTrue(result["ide_namespace_proven"])
self.assertFalse(result["blocks_merge_workflow"])
self.assertIsNone(result["error_type"])
def test_offline_spawn_success_is_not_ide_proof(self):
result = mcp_namespace_health.classify_namespace_probe(
"gitea-merger",
registered_tools=["gitea_whoami"],
probe_result={"success": True, "result": {}},
process={"pid": 99, "profile": "prgs-merger"},
probe_source="offline_spawn",
)
self.assertTrue(result["healthy"])
self.assertFalse(result["ide_namespace_proven"])
self.assertFalse(result["blocks_merge_workflow"])
self.assertTrue(
any("offline_spawn" in r for r in result["reasons"])
)
def test_registered_missing_required_tool_fails_before_probe_success(self):
result = mcp_namespace_health.classify_namespace_probe(
"gitea-tools",
registered_tools=["gitea_whoami"],
probe_result={"success": True, "result": {}},
probe_source="client_namespace",
)
self.assertFalse(result["healthy"])
self.assertEqual(result["required_tool"], "gitea_list_profiles")
self.assertFalse(result["required_tool_registered"])
self.assertEqual(result["error_type"], "tool_missing")
def test_server_tool_exposes_same_assessment_and_records_session(self):
result = gitea_mcp_server.gitea_assess_mcp_namespace_health(
"gitea-merger",
registered_tools=["gitea_whoami"],
probe_result={"success": False, "error": "transport closed"},
process={"pid": 9876, "profile": "prgs-merger"},
probe_source="client_namespace",
)
self.assertFalse(result["success"])
self.assertEqual(result["error_type"], "namespace_eof")
self.assertEqual(result["namespace"], "gitea-merger")
self.assertEqual(result["diagnostics"]["process_pid"], 9876)
self.assertIn("gitea-merger", gitea_mcp_server._LIVE_NAMESPACE_HEALTH)
gate = gitea_mcp_server._live_namespace_health_gate("merge_pr")
self.assertTrue(gate)
self.assertTrue(any("gitea-merger" in r for r in gate))
class TestLiveNamespaceBlocksMerge(unittest.TestCase):
"""AC5: a broken live namespace must hard-block the review/merge state machine."""
def setUp(self):
gitea_mcp_server._LIVE_NAMESPACE_HEALTH.clear()
def _merge_ready_completion(self):
# Completion through PRE_MERGE_RECHECK is the state where a clean merge
# is otherwise allowed (see test_merge_allowed_with_pre_merge_gates).
idx = rmsm.REVIEW_MERGE_STATES.index("PRE_MERGE_RECHECK")
return {state: True for state in rmsm.REVIEW_MERGE_STATES[: idx + 1]}
def _all_gates(self):
return {gate: True for gate in rmsm._PRE_MERGE_REQUIRED_GATES}
def test_assess_workflow_blockers_flags_live_namespace_broken(self):
clean = rmsm.assess_workflow_blockers()
self.assertFalse(clean["block"])
broken = rmsm.assess_workflow_blockers(live_namespace_broken=True)
self.assertTrue(broken["block"])
self.assertTrue(any("namespace" in r.lower() for r in broken["reasons"]))
def test_can_merge_blocks_even_when_all_gates_pass(self):
# Without the namespace blocker a fully-complete workflow can merge...
allowed = rmsm.can_merge(
self._merge_ready_completion(), pre_merge_gates=self._all_gates()
)
self.assertTrue(allowed["allowed"])
# ...but a broken live namespace overrides every satisfied gate.
blocked = rmsm.can_merge(
self._merge_ready_completion(),
pre_merge_gates=self._all_gates(),
live_namespace_broken=True,
)
self.assertTrue(blocked["block"])
self.assertFalse(blocked["allowed"])
def test_workflow_status_forwards_namespace_blocker(self):
status = rmsm.workflow_status(
self._merge_ready_completion(), live_namespace_broken=True
)
self.assertFalse(status["merge_allowed"])
self.assertFalse(status["approve_allowed"])
def test_server_tool_blocks_merge_on_live_namespace_broken(self):
result = gitea_mcp_server.gitea_assess_review_merge_state_machine(
state_completion=self._merge_ready_completion(),
pre_merge_gates=self._all_gates(),
live_namespace_broken=True,
)
self.assertTrue(result["blockers"]["block"])
self.assertFalse(result["merge"]["allowed"])
def test_classify_verdict_bridges_into_merge_block(self):
# The classify verdict is the intended feed for live_namespace_broken.
verdict = mcp_namespace_health.classify_namespace_probe(
"gitea-merger",
registered_tools=["gitea_whoami", "gitea_adopt_merger_pr_lease"],
probe_result={"success": False, "error": "client is closing: EOF"},
process={"pid": 555, "profile": "prgs-merger"},
probe_source="client_namespace",
)
self.assertTrue(verdict["blocks_merge_workflow"])
blocked = rmsm.can_merge(
self._merge_ready_completion(),
pre_merge_gates=self._all_gates(),
live_namespace_broken=verdict["blocks_merge_workflow"],
)
self.assertFalse(blocked["allowed"])
def test_offline_spawn_does_not_authorize_mutation_gate(self):
gitea_mcp_server.gitea_assess_mcp_namespace_health(
"gitea-merger",
registered_tools=["gitea_whoami"],
probe_result={"success": True, "result": {}},
probe_source="offline_spawn",
)
gate = gitea_mcp_server._live_namespace_health_gate("merge_pr")
self.assertTrue(gate)
self.assertTrue(any("offline_spawn" in r or "client_namespace" in r for r in gate))
def test_client_namespace_healthy_clears_mutation_gate(self):
gitea_mcp_server.gitea_assess_mcp_namespace_health(
"gitea-merger",
registered_tools=["gitea_whoami"],
probe_result={"success": True, "result": {}},
probe_source="client_namespace",
)
self.assertEqual(gitea_mcp_server._live_namespace_health_gate("merge_pr"), [])
if __name__ == "__main__":
unittest.main()
+19 -3
View File
@@ -5,6 +5,7 @@ the MCP protocol) with mocked API responses.
"""
import json
import os
import shutil
import sys
import tempfile
import unittest
@@ -3811,7 +3812,15 @@ class TestIssueLocking(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_blocks_expired_same_operation_lease_for_recovery(self, _auth, _api, _git_state):
"""#601: expired lease still blocks takeover when owner pid is live AND worktree exists.
Dead-pid / missing-worktree reclaim is covered by issue_lock_store unit tests.
This MCP path must remain fail-closed for sticky expired foreign ownership.
"""
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
# Present worktree + live pid => reclaim_allowed=False even though expires_at is past.
sticky_worktree = tempfile.mkdtemp(prefix="gitea-sticky-lease-")
self.addCleanup(lambda: shutil.rmtree(sticky_worktree, ignore_errors=True))
issue_lock_store.save_lock_file(
issue_lock_store.lock_file_path(
remote="prgs",
@@ -3825,15 +3834,22 @@ class TestIssueLocking(unittest.TestCase):
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": prgs_repo,
"worktree_path": "/tmp/other-worktree",
"worktree_path": sticky_worktree,
"session_pid": os.getpid(),
"pid": os.getpid(),
"work_lease": {
"operation_type": "author_issue_work",
"expires_at": "2000-01-01T00:00:00Z",
},
},
)
with self.assertRaises(RuntimeError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
with patch.object(issue_lock_store, "is_process_alive", return_value=True):
with self.assertRaises(RuntimeError) as ctx:
gitea_lock_issue(
issue_number=196,
branch_name="feat/issue-196-mutations",
remote="prgs",
)
self.assertIn("Recovery review is required before takeover", str(ctx.exception))
@patch("mcp_server.api_get_all", return_value=[])
+15 -24
View File
@@ -64,54 +64,45 @@ class TestMarkIssueCLI(unittest.TestCase):
with self.assertRaises(SystemExit):
mark_issue.main(["10", "bogus_action"])
@patch("mark_issue.api_get_all", return_value=[{"id": 101, "name": "status:in-progress"}])
@patch("mark_issue.api_request")
@patch("mark_issue.get_auth_header", return_value=FAKE_AUTH)
def test_successful_start(self, _auth, mock_api):
# First call is GET labels, second is POST label
mock_api.side_effect = [
[{"id": 101, "name": "status:in-progress"}],
[{"name": "status:in-progress"}],
]
def test_successful_start(self, _auth, mock_api, mock_get_all):
mock_api.return_value = [{"name": "status:in-progress"}]
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = mark_issue.main(["15", "start"])
self.assertEqual(rc, 0)
self.assertEqual(mock_api.call_count, 2)
# Verify GET labels call
get_call = mock_api.call_args_list[0]
self.assertEqual(get_call[0][0], "GET")
self.assertIn("/labels?limit=100", get_call[0][1])
mock_get_all.assert_called()
self.assertIn("/labels", mock_get_all.call_args[0][0])
# Verify POST labels call
post_call = mock_api.call_args_list[1]
post_call = mock_api.call_args_list[0]
self.assertEqual(post_call[0][0], "POST")
self.assertIn("/issues/15/labels", post_call[0][1])
self.assertEqual(post_call[0][3], {"labels": [101]})
@patch("mark_issue.api_get_all", return_value=[{"id": 101, "name": "status:in-progress"}])
@patch("mark_issue.api_request")
@patch("mark_issue.get_auth_header", return_value=FAKE_AUTH)
def test_successful_done(self, _auth, mock_api):
# First call is GET labels, second is DELETE label
mock_api.side_effect = [
[{"id": 101, "name": "status:in-progress"}],
None,
]
def test_successful_done(self, _auth, mock_api, _get_all):
mock_api.return_value = None
rc = mark_issue.main(["15", "done"])
self.assertEqual(rc, 0)
self.assertEqual(mock_api.call_count, 2)
self.assertEqual(mock_api.call_count, 1)
# Verify DELETE labels call
delete_call = mock_api.call_args_list[1]
delete_call = mock_api.call_args_list[0]
self.assertEqual(delete_call[0][0], "DELETE")
self.assertIn("/issues/15/labels/101", delete_call[0][1])
@patch("mark_issue.api_get_all", return_value=[{"id": 1, "name": "bug"}])
@patch("mark_issue.api_request")
@patch("mark_issue.get_auth_header", return_value=FAKE_AUTH)
def test_label_not_found(self, _auth, mock_api):
# GET labels returns no status:in-progress label
mock_api.return_value = [{"id": 1, "name": "bug"}]
def test_label_not_found(self, _auth, mock_api, _get_all):
# Paginated inventory returns no status:in-progress label
rc = mark_issue.main(["15", "start"])
self.assertEqual(rc, 1)
mock_api.assert_not_called()
if __name__ == "__main__":
+287
View File
@@ -0,0 +1,287 @@
"""Tests for preserving and resuming prepared Gitea review drafts (#609)."""
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
sys_path_root = str(Path(__file__).resolve().parent.parent)
if sys_path_root not in sys.path:
sys.path.insert(0, sys_path_root)
import mcp_session_state
import gitea_mcp_server
import reviewer_pr_lease
class TestReviewDrafts(unittest.TestCase):
def setUp(self):
self._tmpdir = tempfile.TemporaryDirectory()
self.state_dir = self._tmpdir.name
self._env = patch.dict(
os.environ,
{
mcp_session_state.STATE_DIR_ENV: self.state_dir,
mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
"GITEA_MCP_PROFILE": "prgs-reviewer",
"GITEA_PROFILE_NAME": "prgs-reviewer",
"PYTEST_CURRENT_TEST": "1", # skip runtime stale checks
},
clear=False,
)
self._env.start()
# Mock workspace binding to pass in test context
self._nwb_patch = patch(
"gitea_mcp_server.nwb.assess_namespace_mutation_workspace",
return_value={"block": False, "mutation_workspace": "/tmp/test-worktree"},
)
self._nwb_patch.start()
# Mock authentication headers
self._auth_patch = patch(
"gitea_mcp_server.get_auth_header",
return_value="Bearer mock-token",
)
self._auth_patch.start()
self._username_patch = patch(
"gitea_mcp_server._authenticated_username",
return_value="sysadmin",
)
self._username_patch.start()
# Clear/Mock local session lease in-memory state
reviewer_pr_lease.clear_session_lease()
def tearDown(self):
self._username_patch.stop()
self._auth_patch.stop()
self._nwb_patch.stop()
self._env.stop()
self._tmpdir.cleanup()
reviewer_pr_lease.clear_session_lease()
@patch("gitea_mcp_server.api_request")
def test_save_draft_success(self, mock_api):
print("GITEA_MCP_SERVER FILE:", gitea_mcp_server.__file__)
print("DIR OF GITEA_MCP_SERVER:", dir(gitea_mcp_server))
mock_api.return_value = {
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha0987654321"},
}
res = gitea_mcp_server.gitea_save_review_draft(
pr_number=587,
action="approve",
body="Good changes",
expected_head_sha="headsha1234567890",
validation_commands="pytest tests/",
validation_results="all pass",
blocker_reason="stale daemon",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
self.assertTrue(res.get("success"))
self.assertEqual(res.get("head_sha"), "headsha1234567890")
# Load draft and verify fields
draft = mcp_session_state.load_state(
kind=mcp_session_state.KIND_REVIEW_DRAFT,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
profile_identity="prgs-reviewer",
)
self.assertIsNotNone(draft)
self.assertEqual(draft.get("pr_number"), 587)
self.assertEqual(draft.get("action"), "approve")
self.assertEqual(draft.get("body"), "Good changes")
self.assertEqual(draft.get("validation_commands"), "pytest tests/")
self.assertEqual(draft.get("validation_results"), "all pass")
self.assertEqual(draft.get("blocker_reason"), "stale daemon")
self.assertEqual(os.path.realpath(draft.get("worktree_path")), os.path.realpath("/tmp/test-worktree"))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._fetch_pr_comments")
@patch("gitea_mcp_server.gitea_submit_pr_review")
def test_resume_draft_and_submit_success(self, mock_submit, mock_comments, mock_api):
# 1. Save draft
mock_api.return_value = {
"state": "open",
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha0987654321"},
}
gitea_mcp_server.gitea_save_review_draft(
pr_number=587,
action="approve",
body="Good changes",
expected_head_sha="headsha1234567890",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
# Seed local session lease
reviewer_pr_lease.record_session_lease({
"pr_number": 587,
"session_id": "session-1234",
})
# Mock Gitea comments to return active reviewer lease owned by us
mock_comments.return_value = [
{
"id": 1,
"user": {"username": "sysadmin"},
"body": (
"<!-- mcp-review-lease:v1 -->\n"
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
"pr: #587\n"
"reviewer_identity: sysadmin\n"
"profile: prgs-reviewer\n"
"session_id: session-1234\n"
"phase: claimed\n"
),
}
]
mock_submit.return_value = {"performed": True, "success": True}
# 2. Resume draft
res = gitea_mcp_server.gitea_resume_review_draft(
pr_number=587,
submit=True,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
self.assertTrue(res.get("success"))
self.assertTrue(res.get("marked_ready"))
self.assertTrue(res.get("submitted"))
mock_submit.assert_called_once()
# Verify draft was deleted after successful submit
draft = mcp_session_state.load_state(
kind=mcp_session_state.KIND_REVIEW_DRAFT,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
profile_identity="prgs-reviewer",
)
self.assertIsNone(draft)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server._fetch_pr_comments")
def test_resume_draft_fails_checks(self, mock_comments, mock_api):
# Save a draft
mock_api.return_value = {
"state": "open",
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha0987654321"},
}
gitea_mcp_server.gitea_save_review_draft(
pr_number=587,
action="approve",
body="Good changes",
expected_head_sha="headsha1234567890",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
# Seed local session lease
reviewer_pr_lease.record_session_lease({
"pr_number": 587,
"session_id": "session-1234",
})
# Mock Gitea comments to return active reviewer lease owned by us
mock_comments.return_value = [
{
"id": 1,
"user": {"username": "sysadmin"},
"body": (
"<!-- mcp-review-lease:v1 -->\n"
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
"pr: #587\n"
"reviewer_identity: sysadmin\n"
"profile: prgs-reviewer\n"
"session_id: session-1234\n"
"phase: claimed\n"
),
}
]
# Case A: PR closed
mock_api.return_value = {
"state": "closed",
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha0987654321"},
}
res = gitea_mcp_server.gitea_resume_review_draft(
pr_number=587,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
self.assertFalse(res.get("success"))
self.assertIn("PR #587 is not open", res.get("reasons")[0])
# Case B: Head changed
mock_api.return_value = {
"state": "open",
"head": {"sha": "headsha_NEW"},
"base": {"ref": "master", "sha": "basesha0987654321"},
}
res = gitea_mcp_server.gitea_resume_review_draft(
pr_number=587,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
self.assertFalse(res.get("success"))
self.assertIn("PR head SHA changed", res.get("reasons")[0])
# Case C: Target branch changed
mock_api.return_value = {
"state": "open",
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha_NEW"},
}
res = gitea_mcp_server.gitea_resume_review_draft(
pr_number=587,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/test-worktree",
)
self.assertFalse(res.get("success"))
self.assertIn("target branch SHA changed", res.get("reasons")[0])
# Case D: Worktree mismatch
mock_api.return_value = {
"state": "open",
"head": {"sha": "headsha1234567890"},
"base": {"ref": "master", "sha": "basesha0987654321"},
}
res = gitea_mcp_server.gitea_resume_review_draft(
pr_number=587,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path="/tmp/different-worktree",
)
self.assertFalse(res.get("success"))
self.assertIn("worktree binding mismatch", res.get("reasons")[0])
+318
View File
@@ -0,0 +1,318 @@
"""#627: paginated repository-label resolution for gitea_set_issue_labels.
Reproduces the post-merge #601 reconciliation failure where later-page
labels (e.g. type:feature, workflow-hardening) were falsely rejected as
nonexistent because inventory used a single-page GET labels?limit=100.
"""
from __future__ import annotations
import os
import sys
import unittest
from unittest.mock import patch, call
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
import gitea_auth
FAKE_AUTH = "token test-token"
PAGE_SIZE = 50 # Gitea effective max; see gitea_auth.api_get_all
def _lb(name: str, lid: int) -> dict:
return {"id": lid, "name": name, "color": "000000"}
def _pages(labels: list[dict], page_size: int = PAGE_SIZE) -> list[list[dict]]:
if not labels:
return [[]]
pages = []
for i in range(0, len(labels), page_size):
pages.append(labels[i : i + page_size])
return pages
class TestRepoLabelIdMapPagination(unittest.TestCase):
"""_repo_label_id_map must use api_get_all (all pages)."""
@patch("mcp_server.api_get_all")
def test_fewer_than_one_page(self, mock_all):
labels = [_lb(f"l{i}", i) for i in range(3)]
mock_all.return_value = labels
m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertEqual(m, {"l0": 0, "l1": 1, "l2": 2})
mock_all.assert_called_once()
self.assertIn("/labels", mock_all.call_args[0][0])
self.assertNotIn("limit=100", mock_all.call_args[0][0])
@patch("mcp_server.api_get_all")
def test_exactly_one_full_page(self, mock_all):
labels = [_lb(f"l{i:03d}", i) for i in range(PAGE_SIZE)]
mock_all.return_value = labels
m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertEqual(len(m), PAGE_SIZE)
self.assertEqual(m["l000"], 0)
self.assertEqual(m[f"l{PAGE_SIZE - 1:03d}"], PAGE_SIZE - 1)
@patch("mcp_server.api_get_all")
def test_more_than_one_page_includes_later_labels(self, mock_all):
# Page 1: 50 early labels; page 2: type:feature + workflow-hardening (#601 style)
early = [_lb(f"early-{i:02d}", i) for i in range(PAGE_SIZE)]
late = [
_lb("type:feature", 1001),
_lb("workflow-hardening", 1002),
]
mock_all.return_value = early + late
m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertEqual(len(m), PAGE_SIZE + 2)
self.assertEqual(m["type:feature"], 1001)
self.assertEqual(m["workflow-hardening"], 1002)
@patch("mcp_server.api_get_all")
def test_duplicate_names_keep_first_seen_id(self, mock_all):
mock_all.return_value = [
_lb("type:feature", 103),
_lb("type:feature", 130), # duplicate id later
_lb("workflow-hardening", 105),
_lb("workflow-hardening", 128),
]
m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertEqual(m["type:feature"], 103)
self.assertEqual(m["workflow-hardening"], 105)
@patch("mcp_server.api_get_all", return_value={"not": "a list"})
def test_non_list_inventory_fails_closed(self, _all):
with self.assertRaises(RuntimeError) as ctx:
mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertIn("expected a list", str(ctx.exception).lower())
@patch("mcp_server.api_get_all", side_effect=RuntimeError("page 2 failed: connection reset"))
def test_later_page_failure_propagates(self, _all):
with self.assertRaises(RuntimeError) as ctx:
mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH)
self.assertIn("page 2 failed", str(ctx.exception))
class TestSetIssueLabelsPagination(unittest.TestCase):
"""gitea_set_issue_labels full-set replacement with multi-page inventory."""
def setUp(self):
self._remotes = patch.dict(
mcp_server.REMOTES,
{"prgs": {"host": "gitea.example.com", "org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools"}},
)
self._remotes.start()
# Allow mutation path without full preflight stack when possible
self._preflight = patch(
"mcp_server.verify_preflight_purity", return_value=None
)
self._preflight.start()
self._perm = patch(
"mcp_server._profile_permission_block", return_value=None
)
self._perm.start()
self._auth = patch(
"mcp_server._auth", return_value=FAKE_AUTH
)
self._auth.start()
self._audited = patch("mcp_server._audited")
mock_aud = self._audited.start()
mock_aud.return_value.__enter__ = lambda s: None
mock_aud.return_value.__exit__ = lambda s, *a: None
def tearDown(self):
self._remotes.stop()
self._preflight.stop()
self._perm.stop()
self._auth.stop()
self._audited.stop()
def _inventory_early_and_late(self) -> list[dict]:
early = [_lb(f"alpha-{i:02d}", i + 1) for i in range(PAGE_SIZE)]
late = [
_lb("type:feature", 9001),
_lb("workflow-hardening", 9002),
_lb("status:ready", 9003),
_lb("anti-stomp", 9004),
_lb("leases", 9005),
_lb("recovery", 9006),
]
return early + late
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_requested_labels_split_across_pages(self, mock_all, mock_req):
inv = self._inventory_early_and_late()
mock_all.return_value = inv
requested = ["alpha-00", "type:feature", "workflow-hardening"]
mock_req.return_value = [_lb(n, inv_i["id"]) for n in requested
for inv_i in inv if inv_i["name"] == n]
res = mcp_server.gitea_set_issue_labels(
issue_number=601,
labels=requested,
remote="prgs",
)
self.assertEqual({lb["name"] for lb in res}, set(requested))
# PUT must use ids from both pages
put = mock_req.call_args
self.assertEqual(put[0][0], "PUT")
payload_ids = put[0][3]["labels"]
self.assertEqual(payload_ids, [1, 9001, 9002])
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_missing_requested_label_rejected_before_put(self, mock_all, mock_req):
mock_all.return_value = [_lb("bug", 1), _lb("status:ready", 2)]
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_set_issue_labels(
issue_number=9,
labels=["bug", "does-not-exist"],
remote="prgs",
)
self.assertIn("do not exist", str(ctx.exception))
self.assertIn("does-not-exist", str(ctx.exception))
mock_req.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_complete_set_preserves_later_page_labels(self, mock_all, mock_req):
inv = self._inventory_early_and_late()
mock_all.return_value = inv
# Full-set replacement removing only status:pr-open style stale label:
# keep later-page feature labels (the #601 reconciliation shape).
requested = [
"anti-stomp",
"leases",
"recovery",
"type:feature",
"workflow-hardening",
]
mock_req.return_value = [_lb(n, next(x["id"] for x in inv if x["name"] == n))
for n in requested]
res = mcp_server.gitea_set_issue_labels(
issue_number=601, labels=requested, remote="prgs"
)
self.assertEqual([lb["name"] for lb in res], requested)
payload_ids = mock_req.call_args[0][3]["labels"]
self.assertEqual(payload_ids, [9004, 9005, 9006, 9001, 9002])
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_issue_601_regression_later_page_names_accepted(self, mock_all, mock_req):
"""Regression: #601 reconciler rejected type:feature + workflow-hardening.
Single-page inventory would omit them; paginated inventory must accept.
"""
early = [_lb(f"page1-{i:02d}", i + 1) for i in range(PAGE_SIZE)]
# Exactly the labels from the #601 residual set (minus status:pr-open)
late = [
_lb("type:feature", 103),
_lb("workflow-hardening", 105),
_lb("anti-stomp", 106),
_lb("leases", 109),
_lb("recovery", 110),
]
mock_all.return_value = early + late
requested = [
"anti-stomp",
"leases",
"recovery",
"type:feature",
"workflow-hardening",
]
mock_req.return_value = [
_lb(n, next(x["id"] for x in late if x["name"] == n)) for n in requested
]
res = mcp_server.gitea_set_issue_labels(
issue_number=601, labels=requested, remote="prgs"
)
names = {lb["name"] for lb in res}
self.assertEqual(names, set(requested))
self.assertNotIn("status:pr-open", names)
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_duplicate_label_ids_resolve_deterministically(self, mock_all, mock_req):
mock_all.return_value = [
_lb("type:feature", 103),
_lb("type:feature", 130),
_lb("workflow-hardening", 105),
_lb("workflow-hardening", 128),
]
requested = ["type:feature", "workflow-hardening"]
mock_req.return_value = [_lb("type:feature", 103), _lb("workflow-hardening", 105)]
mcp_server.gitea_set_issue_labels(
issue_number=1, labels=requested, remote="prgs"
)
self.assertEqual(mock_req.call_args[0][3]["labels"], [103, 105])
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_post_mutation_mismatch_fails_closed(self, mock_all, mock_req):
mock_all.return_value = [_lb("bug", 1), _lb("status:ready", 2)]
# Server returns incomplete set → verification must fail
mock_req.return_value = [_lb("bug", 1)]
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_set_issue_labels(
issue_number=9,
labels=["bug", "status:ready"],
remote="prgs",
)
self.assertIn("Post-mutation label verification failed", str(ctx.exception))
self.assertIn("status:ready", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all", side_effect=RuntimeError("Gitea 502 on page 2"))
def test_pagination_failure_fails_closed_before_put(self, _all, mock_req):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_set_issue_labels(
issue_number=9, labels=["bug"], remote="prgs"
)
self.assertIn("page 2", str(ctx.exception))
mock_req.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_empty_label_set_clears_all(self, mock_all, mock_req):
mock_all.return_value = [_lb("bug", 1)]
mock_req.return_value = []
res = mcp_server.gitea_set_issue_labels(
issue_number=9, labels=[], remote="prgs"
)
self.assertEqual(res, [])
self.assertEqual(mock_req.call_args[0][3]["labels"], [])
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
def test_does_not_call_single_page_limit_100(self, mock_all, mock_req):
mock_all.return_value = [_lb("bug", 1)]
mock_req.return_value = [_lb("bug", 1)]
mcp_server.gitea_set_issue_labels(
issue_number=9, labels=["bug"], remote="prgs"
)
for c in mock_req.call_args_list:
url = c[0][1] if len(c[0]) > 1 else ""
self.assertNotIn("labels?limit=100", str(url))
mock_all.assert_called()
class TestApiGetAllPageCapDocumentsGiteaLimit(unittest.TestCase):
"""Sanity: api_get_all clamps page_size to 50 (root cause of limit=100 trap)."""
@patch("gitea_auth.api_request")
def test_page_size_clamped_to_fifty(self, mock_req):
mock_req.return_value = []
gitea_auth.api_get_all("https://gitea.example/api/v1/repos/o/r/labels",
FAKE_AUTH, page_size=100)
# First (only) call must use limit=50, not 100
url = mock_req.call_args[0][1]
self.assertIn("limit=50", url)
self.assertNotIn("limit=100", url)
if __name__ == "__main__":
unittest.main()